diff --git a/.github/workflows/dockerimage-pr.yml b/.github/workflows/dockerimage-pr.yml new file mode 100644 index 000000000..e47a70987 --- /dev/null +++ b/.github/workflows/dockerimage-pr.yml @@ -0,0 +1,82 @@ +name: Docker|backend - PR + +concurrency: + group: pr-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +on: + pull_request + +jobs: + artifact: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 # load full history + - name: Get short SHA + id: slug + run: echo "SHA=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT + - name: install node LTS + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + + - name: Build a bot + env: + OPENEXCHANGE_APPID: ${{ secrets.OPENEXCHANGE_APPID }} + run: make + + - name: Add commit file + run: echo "$(echo ${GITHUB_SHA} | cut -c1-8)" >> ./.commit + + - name: Zip a bot + run: make pack + + - uses: actions/upload-artifact@v3 + with: + name: sogeBot-${{ steps.slug.outputs.SHA }} + path: ${{ github.workspace }}/*.zip + + build: + needs: artifact + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Get short SHA + id: slug + run: echo "SHA=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT + - + name: Set up QEMU + uses: docker/setup-qemu-action@v2.1.0 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.2.1 + - + name: Login to DockerHub + uses: docker/login-action@v2.1.0 + with: + username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} + password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} + + - uses: actions/download-artifact@master + with: + name: sogeBot-${{ steps.slug.outputs.SHA }} + path: ${{ github.workspace }}/*.zip + + - + name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64 + push: true + tags: | + sogebot/pr:${{github.head_ref}} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/dockerimage.yml b/.github/workflows/dockerimage-ui.yml similarity index 99% rename from .github/workflows/dockerimage.yml rename to .github/workflows/dockerimage-ui.yml index 0e2daca9f..fbf664f41 100644 --- a/.github/workflows/dockerimage.yml +++ b/.github/workflows/dockerimage-ui.yml @@ -1,4 +1,4 @@ -name: sogeBot Dashboard UI +name: Docker|frontend - master concurrency: group: sogebot-dashboard-${{ github.head_ref || github.ref }} diff --git a/.github/workflows/tests-generic.yml b/.github/workflows/tests-generic.yml new file mode 100644 index 000000000..1273d2e14 --- /dev/null +++ b/.github/workflows/tests-generic.yml @@ -0,0 +1,56 @@ +name: Tests|Backend + +concurrency: + group: generic-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - 'master' + pull_request: + +jobs: + eslint: + name: ESLint check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: All test should be run - tests are not containing describe.only + run: | + if ! grep -r -l 'describe.only' ./test/; then + exit 0 + else + exit 1 + fi; + - name: install node LTS + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install eslint and dependencies + run: | + make dependencies + - name: Run eslint + run: ENV=development make eslint + - name: Run jsonlint + run: ENV=development make jsonlint + fullbuild: + name: Make test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build everything + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make \ No newline at end of file diff --git a/.github/workflows/tests-mysql.yml b/.github/workflows/tests-mysql.yml new file mode 100644 index 000000000..bcfdd6fb1 --- /dev/null +++ b/.github/workflows/tests-mysql.yml @@ -0,0 +1,175 @@ +name: Tests|Backend + +concurrency: + group: mysql-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - 'master' + pull_request: + +jobs: + migration: + name: MySQL/MariaDB tests - migration + runs-on: ubuntu-latest + steps: + - name: Shutdown Ubuntu MySQL (SUDO) + run: sudo service mysql stop # Shutdown the Default MySQL, "sudo" is necessary, please not remove it + - uses: mirromutth/mysql-action@v1.1 + with: + host port: 3306 # Optional, default value is 3306. The port of host + container port: 3306 # Optional, default value is 3306. The port of container + character set server: 'utf8mb4' # Optional, default value is 'utf8mb4'. The '--character-set-server' option for mysqld + collation server: 'utf8mb4_general_ci' # Optional, default value is 'utf8mb4_general_ci'. The '--collation-server' option for mysqld + mysql version: '5.7' # Optional, default value is "latest". The version of the MySQL + mysql database: 'sogebot' # Optional, default value is "test". The specified database which will be create + mysql root password: 'Passw0rd' # Required if "mysql user" is empty, default is empty. The root superuser password + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:mysql + - name: Run migration test + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:migration + postgres: + name: MySQL/MariaDB tests - part 1 + runs-on: ubuntu-latest + steps: + - name: Shutdown Ubuntu MySQL (SUDO) + run: sudo service mysql stop # Shutdown the Default MySQL, "sudo" is necessary, please not remove it + - uses: mirromutth/mysql-action@v1.1 + with: + host port: 3306 # Optional, default value is 3306. The port of host + container port: 3306 # Optional, default value is 3306. The port of container + character set server: 'utf8mb4' # Optional, default value is 'utf8mb4'. The '--character-set-server' option for mysqld + collation server: 'utf8mb4_general_ci' # Optional, default value is 'utf8mb4_general_ci'. The '--collation-server' option for mysqld + mysql version: '5.7' # Optional, default value is "latest". The version of the MySQL + mysql database: 'sogebot' # Optional, default value is "test". The specified database which will be create + mysql root password: 'Passw0rd' # Required if "mysql user" is empty, default is empty. The root superuser password + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:mysql + - name: Run mocha + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: TESTS=@func1 npm test + - uses: codecov/codecov-action@v3 + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + name: codecov-postgres # optional + flags: postgres + postgres2: + name: MySQL/MariaDB tests - part 2 + runs-on: ubuntu-latest + steps: + - name: Shutdown Ubuntu MySQL (SUDO) + run: sudo service mysql stop # Shutdown the Default MySQL, "sudo" is necessary, please not remove it + - uses: mirromutth/mysql-action@v1.1 + with: + host port: 3306 # Optional, default value is 3306. The port of host + container port: 3306 # Optional, default value is 3306. The port of container + character set server: 'utf8mb4' # Optional, default value is 'utf8mb4'. The '--character-set-server' option for mysqld + collation server: 'utf8mb4_general_ci' # Optional, default value is 'utf8mb4_general_ci'. The '--collation-server' option for mysqld + mysql version: '5.7' # Optional, default value is "latest". The version of the MySQL + mysql database: 'sogebot' # Optional, default value is "test". The specified database which will be create + mysql root password: 'Passw0rd' # Required if "mysql user" is empty, default is empty. The root superuser password + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:mysql + - name: Run mocha + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: TESTS=@func2 npm test + - uses: codecov/codecov-action@v3 + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + name: codecov-postgres # optional + flags: postgres + postgres3: + name: MySQL/MariaDB tests - part 3 + runs-on: ubuntu-latest + steps: + - name: Shutdown Ubuntu MySQL (SUDO) + run: sudo service mysql stop # Shutdown the Default MySQL, "sudo" is necessary, please not remove it + - uses: mirromutth/mysql-action@v1.1 + with: + host port: 3306 # Optional, default value is 3306. The port of host + container port: 3306 # Optional, default value is 3306. The port of container + character set server: 'utf8mb4' # Optional, default value is 'utf8mb4'. The '--character-set-server' option for mysqld + collation server: 'utf8mb4_general_ci' # Optional, default value is 'utf8mb4_general_ci'. The '--collation-server' option for mysqld + mysql version: '5.7' # Optional, default value is "latest". The version of the MySQL + mysql database: 'sogebot' # Optional, default value is "test". The specified database which will be create + mysql root password: 'Passw0rd' # Required if "mysql user" is empty, default is empty. The root superuser password + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:mysql + - name: Run mocha + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: TESTS=@func3 npm test + - uses: codecov/codecov-action@v3 + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + name: codecov-mysql # optional + flags: mysql \ No newline at end of file diff --git a/.github/workflows/tests-postgres.yml b/.github/workflows/tests-postgres.yml new file mode 100644 index 000000000..8743ef352 --- /dev/null +++ b/.github/workflows/tests-postgres.yml @@ -0,0 +1,167 @@ +name: Tests|Backend + +concurrency: + group: postgres-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - 'master' + pull_request: + +jobs: + migration: + name: PostgreSQL tests - migration + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: sogebot + ports: + - 5432:5432 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:postgres + - name: Run migration test + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:migration + postgres: + name: PostgreSQL tests - part 1 + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: sogebot + ports: + - 5432:5432 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:postgres + - name: Run mocha + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: TESTS=@func1 npm test + - uses: codecov/codecov-action@v3 + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + name: codecov-postgres # optional + flags: postgres + postgres2: + name: PostgreSQL tests - part 2 + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: sogebot + ports: + - 5432:5432 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:postgres + - name: Run mocha + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: TESTS=@func2 npm test + - uses: codecov/codecov-action@v3 + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + name: codecov-postgres # optional + flags: postgres + postgres3: + name: PostgreSQL tests - part 3 + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: sogebot + ports: + - 5432:5432 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:postgres + - name: Run mocha + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: TESTS=@func3 npm test + - uses: codecov/codecov-action@v3 + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + name: codecov-postgres # optional + flags: postgres \ No newline at end of file diff --git a/.github/workflows/tests-sqlite.yml b/.github/workflows/tests-sqlite.yml new file mode 100644 index 000000000..7e2497717 --- /dev/null +++ b/.github/workflows/tests-sqlite.yml @@ -0,0 +1,131 @@ +name: Tests|Backend + +concurrency: + group: sqlite-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - 'master' + pull_request: + +jobs: + migration: + name: SQLite tests - migration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:sqlite + - name: Run migration test + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:migration + sqlite: + name: SQLite tests - part 1 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:sqlite + - name: Run mocha + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: TESTS=@func1 npm test + - uses: codecov/codecov-action@v3 + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + name: codecov-sqlite # optional + flags: sqlite + sqlite2: + name: SQLite tests - part 2 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:sqlite + - name: Run mocha + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: TESTS=@func2 npm test + - uses: codecov/codecov-action@v3 + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + name: codecov-sqlite # optional + flags: sqlite + sqlite3: + name: SQLite tests - part 3 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + ref: ${{ github.event.pull_request.head.sha }} + - id: log + name: Load commit message for skip test check + run: echo "MESSAGE=$(git log --no-merges -1 --pretty=format:%s%b)" >> $GITHUB_OUTPUT + - name: install node LTS + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + - name: Install all dependencies and build just a bot + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: ENV=development make info clean dependencies bot + - name: Set proper db to use + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: npm run test:config:sqlite + - name: Run mocha + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + run: TESTS=@func3 npm test + - uses: codecov/codecov-action@v3 + if: "!contains(steps.log.outputs.MESSAGE, '[skip-tests]')" + with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + name: codecov-sqlite # optional + flags: sqlite \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 7151f07c9..0bceac1d0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "backend"] - path = backend - url = https://github.com/sogebot/sogeBot [submodule "ui.admin"] path = ui.admin url = https://github.com/sogebot/ui-admin diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 000000000..4312689b9 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname -- "$0")/_/husky.sh" + +node ./backend/tools/pre-commit-message.js $1 $2 $3 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 000000000..335b18ca0 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname -- "$0")/_/husky.sh" + +cd ./backend && FORCE_COLOR=1 npx lint-staged -q && sh tools/pre-commit-tests.sh \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..9b2449778 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +PATH := node_modules/.bin:$(PATH) +SHELL := /bin/bash +VERSION := `node -pe "require('./package.json').version"` +ENV ?= production +NODE_OPTIONS="--max-old-space-size=4096" + +all : info clean dependencies bot +.PHONY : all + +info: + @echo -ne "\n\t ----- Build ENV: $(ENV)" + @echo -ne "\n\t ----- Build commit\n\n" + @git log --oneline -3 | cat + +dependencies: + @echo -ne "\n\t ----- Cleaning up dependencies\n" + @rm -rf node_modules + @echo -ne "\n\t ----- Installation of dependencies\n" + npm install --include=dev + +eslint: + @echo -ne "\n\t ----- Checking eslint\n" + cd backend && NODE_OPTIONS="--max-old-space-size=4096" npx eslint --ext .ts src --quiet + +jsonlint: + @echo -ne "\n\t ----- Checking jsonlint\n" + for a in $$(find ./backend/locales -type f -iname "*.json" -print); do /bin/false; npx jsonlint $$a -q; done + +bot: + @rm -rf dest + @echo -ne "\n\t ----- Fetching rates\n" + node backend/tools/fetchRates.js +ifeq ($(ENV),production) + @echo -ne "\n\t ----- Building bot (strip comments)\n" + npx tsc -p backend/tsconfig.json --removeComments true +else + @echo -ne "\n\t ----- Building bot\n" + npx tsc -p backend/tsconfig.json --removeComments false +endif + @npx tsc-alias -p backend/tsconfig.json + +pack-modules: + @echo -ne "\n\t ----- Packing into node_modules-$(VERSION).zip\n" + @npx --yes bestzip node_modules-$(VERSION).zip node_modules + +pack: + @echo -ne "\n\t ----- Packing into sogeBot-$(VERSION).zip\n" + cd ./backend/ && @cp ./src/data/.env* ./ + cd ./backend/ && @cp ./src/data/.env.sqlite ./.env + cd ./backend/ && @npx --yes bestzip ../sogeBot-$(VERSION).zip .commit .npmrc .env* package-lock.json patches/ dest/ locales/ LICENSE package.json docs/ AUTHORS tools/ bin/ bat/ fonts.json assets/ favicon.ico + +prepare: + @echo -ne "\n\t ----- Cleaning up node_modules\n" + @rm -rf node_modules + +clean: + @echo -ne "\n\t ----- Cleaning up compiled files\n" + @rm -rf dest \ No newline at end of file diff --git a/backend b/backend deleted file mode 160000 index 58d292434..000000000 --- a/backend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 58d292434397a39d72f2ec25054edeb1be815034 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 000000000..5171c5408 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +npm-debug.log \ No newline at end of file diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 000000000..8b4c83bbb --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/backend/.eslintignore b/backend/.eslintignore new file mode 100644 index 000000000..849a214f3 --- /dev/null +++ b/backend/.eslintignore @@ -0,0 +1,3 @@ +webpack.config.js +tools/* +test/* \ No newline at end of file diff --git a/backend/.eslintrc.json b/backend/.eslintrc.json new file mode 100644 index 000000000..32a1557ea --- /dev/null +++ b/backend/.eslintrc.json @@ -0,0 +1,109 @@ +{ + "plugins": [ + "@typescript-eslint", + "import", + "require-extensions" + ], + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:import/typescript", "plugin:require-extensions/recommended" +], + "rules": { + "indent": "off", + "@typescript-eslint/indent": [ "error", 2 ], + "key-spacing": ["error", { + "beforeColon": false, + "afterColon": true, + "align": "value" + }], + "object-curly-spacing": ["error", "always"], + "object-curly-newline": ["error", { + "consistent": true + }], + "@typescript-eslint/no-unused-vars": ["warn", { + "vars": "all", + "varsIgnorePattern": "^_", + "args": "after-used", + "argsIgnorePattern": "^_" + }], + "no-multiple-empty-lines": ["error", { + "max": 1, + "maxEOF": 0, + "maxBOF": 0 + }], + "import/order": ["warn", { + "groups": ["builtin", "external", ["internal"], + ["parent", "sibling"], "index" + ], + "newlines-between": "always", + "alphabetize": { + "order": "asc", + "caseInsensitive": true + }, + "pathGroups": [{ + "pattern": "src/**", + "group": "internal", + "position": "after" + }] + }], + "import/no-cycle": [2, { + "maxDepth": 1 + }], + "import/newline-after-import": ["error", { + "count": 1 + }], + + "no-shadow": "off", + "@typescript-eslint/no-shadow": ["error"], + "@typescript-eslint/explicit-member-accessibility": "off", + "quotes": ["error", "single", { + "allowTemplateLiterals": true + }], + "@typescript-eslint/camelcase": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/explicit-function-return-type": 0, + "@typescript-eslint/no-use-before-define": 0, + "@typescript-eslint/class-name-casing": 0, + "@typescript-eslint/prefer-interface": 0, + "@typescript-eslint/no-namespace": 0, + "interface-over-type-literal": 0, + "@typescript-eslint/no-var-requires": 1, + "@typescript-eslint/no-inferrable-types": 0, + "semi": "off", + "@typescript-eslint/semi": ["error"], + "curly": ["error"], + "prefer-const": ["error", { + "destructuring": "all", + "ignoreReadBeforeAssign": false + }], + "no-var": 2, + "prefer-spread": "error", + "comma-dangle": [2, "always-multiline"], + "dot-notation": 2, + "operator-linebreak": ["error", "before"], + "brace-style": "error", + "no-useless-call": "error" + }, + "parserOptions": { + "parser": "@typescript-eslint/parser", + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion":"latest", + "sourceType": "module", + "useJSXTextNode": true, + "project": ["./tsconfig.eslint.json"], + "tsconfigRootDir": "./", + "extraFileExtensions": [".vue"] + }, + "env": { + "node": true, + "mocha": true + }, + "settings": { + "import/resolver": { + "node": { + "paths": ["src/"] + } + }, + "import/internal-regex": "^src/" + } +} \ No newline at end of file diff --git a/backend/.github/FUNDING.yml b/backend/.github/FUNDING.yml new file mode 100644 index 000000000..138c441c7 --- /dev/null +++ b/backend/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +patreon: soge__ +custom: https://www.paypal.me/sogetwitch/ \ No newline at end of file diff --git a/backend/.github/semantic.yml b/backend/.github/semantic.yml new file mode 100644 index 000000000..3b523527b --- /dev/null +++ b/backend/.github/semantic.yml @@ -0,0 +1,2 @@ +# Always validate the PR title, and ignore the commits +titleOnly: true \ No newline at end of file diff --git a/backend/.github/workflows/crowdin.yml b/backend/.github/workflows/crowdin.yml new file mode 100644 index 000000000..9c824e679 --- /dev/null +++ b/backend/.github/workflows/crowdin.yml @@ -0,0 +1,44 @@ +name: Crowdin Action + +on: + push: + branches: + - master + schedule: + - cron: "0 */12 * * *" # run every 12 hours + +jobs: + synchronize-with-crowdin: + runs-on: ubuntu-latest + + steps: + + - name: Checkout + uses: actions/checkout@v3 + + - name: Remove translations + run: | + find locales/ -type f ! -name 'en*' | grep -v /en/ | xargs rm + + - name: crowdin action + uses: crowdin/github-action@1.5.0 + with: + upload_sources: true + upload_sources_args: '--verbose' + crowdin_branch_name: master + + # This is the name of the git branch that Crowdin will create when opening a pull request. + # This branch does NOT need to be manually created. It will be created automatically by the action. + localization_branch_name: l10n_crowdin_action + download_translations: true + create_pull_request: true + commit_message: 'chore(locales): crowdin update' + pull_request_title: 'chore(locales): crowdin update' + pull_request_base_branch_name: master + + # dry run to test + # dryrun_action: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/backend/.github/workflows/dockerimage-pr.yml b/backend/.github/workflows/dockerimage-pr.yml new file mode 100644 index 000000000..14bb25d0a --- /dev/null +++ b/backend/.github/workflows/dockerimage-pr.yml @@ -0,0 +1,82 @@ +name: Pull Request + +concurrency: + group: pr-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +on: + pull_request + +jobs: + artifact: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 # load full history + - name: Get short SHA + id: slug + run: echo "SHA=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT + - name: install node LTS + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + + - name: Build a bot + env: + OPENEXCHANGE_APPID: ${{ secrets.OPENEXCHANGE_APPID }} + run: make + + - name: Add commit file + run: echo "$(echo ${GITHUB_SHA} | cut -c1-8)" >> ./.commit + + - name: Zip a bot + run: make pack + + - uses: actions/upload-artifact@v3 + with: + name: sogeBot-${{ steps.slug.outputs.SHA }} + path: ${{ github.workspace }}/*.zip + + build: + needs: artifact + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Get short SHA + id: slug + run: echo "SHA=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT + - + name: Set up QEMU + uses: docker/setup-qemu-action@v2.1.0 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.2.1 + - + name: Login to DockerHub + uses: docker/login-action@v2.1.0 + with: + username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} + password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} + + - uses: actions/download-artifact@master + with: + name: sogeBot-${{ steps.slug.outputs.SHA }} + path: ${{ github.workspace }}/*.zip + + - + name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64 + push: true + tags: | + sogebot/pr:${{github.head_ref}} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/backend/.github/workflows/dockerimage-release.yml b/backend/.github/workflows/dockerimage-release.yml new file mode 100644 index 000000000..d10a22c48 --- /dev/null +++ b/backend/.github/workflows/dockerimage-release.yml @@ -0,0 +1,95 @@ +name: Releases + +on: + push: + # Sequence of patterns matched against refs/tags + tags: + - '*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +jobs: + release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 # load full history + + - name: install node LTS + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + + - name: Build a bot + env: + OPENEXCHANGE_APPID: ${{ secrets.OPENEXCHANGE_APPID }} + run: make + + - name: Zip a bot + run: make pack + + - name: Zip node_modules + run: make pack-modules + + - name: Generate changelog + id: log + run: node tools/changelog.js generate > body.md + + - uses: actions/upload-artifact@v3 + with: + name: sogeBot + path: ${{ github.workspace }}/*.zip + + - name: Create Release + uses: ncipollo/release-action@v1 + with: + name: SOGEBOT ${{ github.ref_name }} + artifacts: "sogeBot-*.zip" + bodyFile: "body.md" + makeLatest: true + + build: + needs: release + runs-on: ubuntu-latest + steps: + - + uses: actions/checkout@master + with: + ref: ${{ github.ref }} + - + name: Get the version + id: get_version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + - + name: Set up QEMU + uses: docker/setup-qemu-action@v2.1.0 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.2.1 + - + name: Login to DockerHub + uses: docker/login-action@v2.1.0 + with: + username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} + password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} + + - uses: actions/download-artifact@master + with: + name: sogeBot + path: ${{ github.workspace }}/*.zip + + - + name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64 + push: true + tags: | + sogebot/release:latest + sogebot/release:${{ steps.get_version.outputs.VERSION }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/backend/.github/workflows/dockerimage.yml b/backend/.github/workflows/dockerimage.yml new file mode 100644 index 000000000..23088f266 --- /dev/null +++ b/backend/.github/workflows/dockerimage.yml @@ -0,0 +1,88 @@ +name: Nightlies + +concurrency: + group: nightlies-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - master + +jobs: + artifact: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 # load full history + - name: Get short SHA + id: slug + run: echo "SHA=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT + - name: install node LTS + uses: actions/setup-node@v3 + with: + node-version: lts/* + check-latest: true + + - name: Build a bot + env: + OPENEXCHANGE_APPID: ${{ secrets.OPENEXCHANGE_APPID }} + run: make + + - name: Add commit file + run: echo "$(echo ${GITHUB_SHA} | cut -c1-8)" >> ./.commit + + - name: Zip a bot + run: make pack + + - name: Zip node_modules + run: make pack-modules + + - uses: actions/upload-artifact@v3 + with: + name: sogeBot-${{ steps.slug.outputs.SHA }} + path: ${{ github.workspace }}/*.zip + + build: + needs: artifact + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v3 + - + name: Get short SHA + id: slug + run: echo "SHA=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT + - + name: Set up QEMU + uses: docker/setup-qemu-action@v2.1.0 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.2.1 + - + name: Login to DockerHub + uses: docker/login-action@v2.1.0 + with: + username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} + password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} + + - uses: actions/download-artifact@master + with: + name: sogeBot-${{ steps.slug.outputs.SHA }} + path: ${{ github.workspace }}/*.zip + + - + name: Build and push + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm/v7,linux/arm64 + push: true + tags: | + sogebot/nightly:latest + sogebot/nightly:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..1b266097b --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,28 @@ +.commit +node_modules +package-lock.json +heap +heap-*.csv +logs/* +dest/* +*.zip +db/ +*.db +*.db* +report +tsconfig.tsbuildinfo +restart.pid +.env + +public/dist/* +public/custom/* +public/dist/css +public/dist/js +public/dist/css/dark.css +public/dist/css/light.css +public/*.html +sogeBot-master/ +coverage/ +.nyc_output + +src/.cache/ diff --git a/backend/.lintstagedrc.json b/backend/.lintstagedrc.json new file mode 100644 index 000000000..4c0a02d9e --- /dev/null +++ b/backend/.lintstagedrc.json @@ -0,0 +1,5 @@ +{ + "*.js": "npx eslint --fix --quiet", + "*.ts": "npx eslint --fix --quiet", + "*.json": "npx jsonlint" +} \ No newline at end of file diff --git a/backend/.mailmap b/backend/.mailmap new file mode 100644 index 000000000..d45629854 --- /dev/null +++ b/backend/.mailmap @@ -0,0 +1,2 @@ +Michal Orlik +sogehelper <36539423+sogehelper@users.noreply.github.com> \ No newline at end of file diff --git a/backend/.markdownlint.json b/backend/.markdownlint.json new file mode 100644 index 000000000..b449ba05c --- /dev/null +++ b/backend/.markdownlint.json @@ -0,0 +1,8 @@ +{ + "MD041": false, + "MD002": false, + "MD033": false, + "MD024": false, + "MD029": false, + "MD026": false +} \ No newline at end of file diff --git a/backend/.ncurc.json b/backend/.ncurc.json new file mode 100644 index 000000000..078142480 --- /dev/null +++ b/backend/.ncurc.json @@ -0,0 +1,3 @@ +{ + "upgrade": true +} \ No newline at end of file diff --git a/backend/.npmrc b/backend/.npmrc new file mode 100644 index 000000000..7679778b8 --- /dev/null +++ b/backend/.npmrc @@ -0,0 +1,2 @@ +only=production +unsafe-perm=true \ No newline at end of file diff --git a/backend/.nvmrc b/backend/.nvmrc new file mode 100644 index 000000000..1a2f5bd20 --- /dev/null +++ b/backend/.nvmrc @@ -0,0 +1 @@ +lts/* \ No newline at end of file diff --git a/backend/AUTHORS b/backend/AUTHORS new file mode 100644 index 000000000..833da33a2 --- /dev/null +++ b/backend/AUTHORS @@ -0,0 +1,8 @@ +# This file lists all individuals having contributed content to the repository. +# For how it is generated, see `tools/generate-authors.sh`. + +AlcaDesign +Brandon D +Michal Orlik +Scott Gustafson +sogehelper diff --git a/backend/CODE_OF_CONDUCT.md b/backend/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..0bfb15518 --- /dev/null +++ b/backend/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at sogehige@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/backend/CONTRIBUTING.md b/backend/CONTRIBUTING.md new file mode 100644 index 000000000..367b2d4d8 --- /dev/null +++ b/backend/CONTRIBUTING.md @@ -0,0 +1,103 @@ +Contributing +============ + +Quick Links for Contributing +---------------------------- + +- Our bug tracker: + https://github.com/sogebot/sogeBot/issues + +- Development Discord.gg channel: + https://discordapp.com/invite/52KpmuH + +Coding Guidelines +----------------- + +- Avoid trailing spaces. To view trailing spaces before making a + commit, use "git diff" on your changes. If colors are enabled for + git in the command prompt, it will show you any whitespace issues + marked with red. + +- No Tabs, only Spaces, Space width is 2 + +Commit Guidelines +----------------- + +- sogeBot uses the 50/72 standard for commits. 50 characters max + for the title (excluding type), an empty line, and then a + full description of the commit, wrapped to 72 columns max. See this + link for more information: http://chris.beams.io/posts/git-commit/ + +- Make sure commit titles are always in present tense, and are not + followed by punctuation. + +- Keep second line blank. + +- Prefix commit titles with the `type(scope?)`, followed by a colon and a + space. So for example, if you are modifying the alias system: + + `fix(alias): fix bug with parsing` + + Or for donationalerts.ru integration: + + `feat(donationalerts): fix source not displaying` + + If you are updating non project files like CONTRIBUTING.md, travis.yml, use `chore` + + `chore: update CONTRIBUTING.md` + +- The **header** is mandatory and the **scope** of the header is optional. + +- Commit title should be entirely in lowercase with the exception of proper + nouns, acronyms, and the words that refer to code, like function/variable names + +- If fixing issue, use `Closes` or `Fixes` keywords in commit description to + link with issue or idea + + Example of full commit message: + + ```text + fix: explain the commit in one line + + Body of commit message is a few lines of text, explaining things + in more detail, possibly giving some background about the issue + being fixed, etc. + + The body of the commit message can be several paragraphs, and + please do proper word-wrap and keep columns shorter than about + 72 characters or so. That way, `git log` will show things + nicely even when it is indented. + + Fixes: https://github.com/sogebot/sogeBot/issues/1406 + ``` + +- If you are updating locales, docs and doesn't fix bugs or add feature, you can + add [skip-tests] to skip functional tests of bot into your Pull Request + +- If you still need examples, please view the commit history. + +Commit types +------------ + +Must be one of the following: + +- **feat**: A new feature +- **fix**: A bug fix +- **docs**: Documentation only changes +- **style**: Changes that do not affect the meaning of the code (white-space, + formatting, missing semi-colons, etc) +- **refactor**: A code change that neither fixes a bug nor adds a feature +- **perf**: A code change that improves performance +- **test**: Adding missing or correcting existing tests +- **chore**: Changes to the build process or auxiliary tools and libraries such + as documentation generation + +Commit Squashing +---------------- + +In most cases, do not squash commits that you add to your Pull Request during +the review process. When the commits in your Pull Request land, they may be +squashed into one commit per logical change. Metadata will be added to the +commit message (including links to the Pull Request, links to relevant issues, +and the names of the reviewers). The commit history of your Pull Request, +however, will stay intact on the Pull Request page. \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..52ba0d102 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,42 @@ +FROM node:lts as builder + +ENV LAST_UPDATED 2023-29-10-2347 + +# Defaults to production, docker-compose overrides this to development on build and run. +ARG NODE_ENV=production +ARG ENV=production +ENV NODE_ENV $NODE_ENV +ENV ENV $ENV + +RUN apt-get update +RUN apt-get install -y build-essential unzip nasm libtool make bash git autoconf wget zlib1g-dev python3 + +# Copy artifact +ADD *.zip / + +# Unzip zip file +RUN unzip sogeBot-*.zip -d /app +RUN unzip node_modules-*.zip -d /app + +# Change working directory +WORKDIR /app + +# Rebuid node modules +RUN npm rebuild + +# Prune development dependencies +RUN npm prune --production + +FROM node:lts-slim +COPY --from=builder /app/ /app/ + +# Add startup script +COPY docker.sh / +RUN chmod +x /docker.sh + +# Expose API port to the outside +EXPOSE 20000 +# Expose profiler to the outside +EXPOSE 9229 + +CMD ["/docker.sh"] \ No newline at end of file diff --git a/backend/ISSUE_TEMPLATE.md b/backend/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..6e90d7bd1 --- /dev/null +++ b/backend/ISSUE_TEMPLATE.md @@ -0,0 +1,12 @@ + +### Expected behavior + +### Actual behavior + +### Steps to reproduce + +### Additional informations + + + + diff --git a/backend/LICENSE b/backend/LICENSE new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/backend/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/backend/PULL_REQUEST_TEMPLATE.md b/backend/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..2f3983b79 --- /dev/null +++ b/backend/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ +###### CHECKLIST + +- [ ] I read [contributing docs](https://github.com/sogebot/sogeBot/blob/master/CONTRIBUTING.md) \ No newline at end of file diff --git a/backend/assets/alerts-js.txt b/backend/assets/alerts-js.txt new file mode 100644 index 000000000..87524fa01 --- /dev/null +++ b/backend/assets/alerts-js.txt @@ -0,0 +1,18 @@ +/* + * Available variables + * user: null | UserInterface + * recipient: null | UserInterface + * caster: null | UserInterface + * UserInterface attributes can be seen at https://github.com/sogebot/sogeBot/blob/master/src/database/entity/user.ts#L5 + * + * Available functions + * waitMs(miliseconds: number): Promise + */ + +async function onStarted() { + // triggered on animationIn +} + +async function onEnded() { + // triggered on animationOut +} \ No newline at end of file diff --git a/backend/assets/alerts-with-message.txt b/backend/assets/alerts-with-message.txt new file mode 100644 index 000000000..2841c9e06 --- /dev/null +++ b/backend/assets/alerts-with-message.txt @@ -0,0 +1,17 @@ + + + + + + +
+ + + + +
{messageTemplate}
+ + +
{message}
+
\ No newline at end of file diff --git a/backend/assets/alerts.txt b/backend/assets/alerts.txt new file mode 100644 index 000000000..7e47abd15 --- /dev/null +++ b/backend/assets/alerts.txt @@ -0,0 +1,14 @@ + + + + + + +
+ + + + +
{messageTemplate}
+
\ No newline at end of file diff --git a/backend/assets/alerts/default.gif b/backend/assets/alerts/default.gif new file mode 100644 index 000000000..f8eed40e4 Binary files /dev/null and b/backend/assets/alerts/default.gif differ diff --git a/backend/assets/alerts/default.mp3 b/backend/assets/alerts/default.mp3 new file mode 100644 index 000000000..11b7a9ec0 Binary files /dev/null and b/backend/assets/alerts/default.mp3 differ diff --git a/backend/assets/custom-variables-code.txt b/backend/assets/custom-variables-code.txt new file mode 100644 index 000000000..6b5ac2b21 --- /dev/null +++ b/backend/assets/custom-variables-code.txt @@ -0,0 +1,37 @@ +/* Available functions + info(text: string), warning(text: string) - info and warning logging + user(username?: string): Promise<{ username: string, displayname: string, id: string, is: { follower: boolean, mod: boolean, online: boolean, subscriber: boolean, vip: boolean }}> - returns user object (if null -> sender) + url(url: string, opts?: { method: 'POST' | 'GET', headers: object, data: object}): Promise<{ data: object, status: number, statusText: string}> + waitMs(miliseconds: number): Promise + randomOnlineSubscriber(): Promise + randomOnlineViewer(): Promise + randomSubscriber(): Promise + randomViewer(): Promise +* +* Available variables + _, _current, param (only in custom command) + sender?: { // (only in custom commands, keyword) + username: string, + userId: string, + source: 'twitch' | 'discord' + } + stream: { + uptime: string, + chatMessages: number, + currentViewers: number, + currentBits: number, + currentFollowers: number, + currentHosts: number, + currentTips: number, + currentWatched: number, + currency: string, + maxViewers: number, + newChatters: number, + game: string, + status: string + } +* +* IMPORTANT: Must contain return statement! +*/ + +return ''; \ No newline at end of file diff --git a/backend/assets/globalIgnoreList.json b/backend/assets/globalIgnoreList.json new file mode 100644 index 000000000..89ea98078 --- /dev/null +++ b/backend/assets/globalIgnoreList.json @@ -0,0 +1,1118 @@ +{ + "spam-accounts": { + "reason": "spam accounts", + "known_aliases": [ + "ho03012ss", + "h0ss00312", + "h0ss003122", + "hoss000312", + "hoss0031", + "hoss00310", + "hoss00311", + "hoss00312", + "hoss00312_", + "hoss00312____", + "hoss00312_____", + "hoss00312______________", + "hoss00312_army", + "hoss00312_back", + "hoss00312_covid", + "hoss00312_dinger", + "hoss00312_elite_race", + "hoss00312_giver", + "hoss00312_got_you", + "hoss00312_handy", + "hoss00312_has_won", + "hoss00312_hate_raid", + "hoss00312_hoss00312", + "hoss00312_is_a_hero", + "hoss00312_is_after_you", + "hoss00312_is_alive", + "hoss00312_is_back", + "hoss00312_is_here", + "hoss00312_is_not_a_bot", + "hoss00312_is_real", + "hoss00312_is_the_best", + "hoss00312_kinky", + "hoss00312_lives_again", + "hoss00312_lliiilllliilll", + "hoss00312_lliilllillllii", + "hoss00312_llliiillliiilll", + "hoss00312_llliillillllli", + "hoss00312_llliilllill", + "hoss00312_llliilllliii", + "hoss00312_llliillllilllii", + "hoss00312_llliillllilllli", + "hoss00312_lllillliiilll", + "hoss00312_lllliiillllii", + "hoss00312_lllliiilllliill", + "hoss00312_llllllllllll", + "hoss00312_owns_twitch", + "hoss00312_pogchamp", + "hoss00312_runner", + "hoss00312_spy", + "hoss00312_uwu", + "hoss00312_was_here", + "hoss00312_wins", + "hoss00313", + "hoss00314", + "hoss00315", + "hoss00320", + "hoss00321", + "hoss00322", + "hoss00323", + "hoss0312", + "hoss0o312", + "hosso0132", + "hosso0312", + "hossoo312", + "host00312", + "hoss00312_eyes", + "hoss00312_click_here", + "hoss____00312", + "hoss_watch_me", + "hoss00312_is_unstoppable", + "hoss_______00312", + "hoss00312_kinky", + "hoss00312_lllillliiilll", + "hoss00312_runner", + "hoss00312_is_here", + "hoss00312_is_not_a_bot", + "hoss00312_wins", + "hoss00312_llliiillliiilll", + "hoss00312_spy", + "hoss00312_llliiillliiilll", + "hoss00312_lliiilllliilll", + "hoss00312_haha", + "hoss00312_llliilllliiill", + "hoss00312_lllliiillllii", + "hoss00312_pogchamp", + "hoss00312_llliillillllli", + "hoss00312_has_won", + "hoss00312_is_the_best", + "hoss00312_lliilllillllii", + "hoss00312_llliilllliii", + "hoss00312_uwu", + "hoss00312_is_back", + "hoss00312_dinger", + "hoss00312_llliillllilllii", + "hoss00312_giver", + "hoss00312_hello", + "hoss00312_handy", + "hoss00312_is_not_a_bot", + "hoss00312_kinky", + "hoss00312_back", + "hoss00312_lliilllillllii", + "hoss00312_dinger", + "hoss00312_covid", + "hoss00312_kinky", + "hoss00312_handy", + "hoss00312_is_after_you", + "hoss00312_owns_twitch", + "hoss00312_is_alive", + "hoss00312_has_won", + "hoss00312_lllillliiilll", + "hoss00312_is_here", + "hoss00312_spy", + "hoss00312_is_alive", + "hoss00312_is_after_you", + "hoss00312_is_a_hero", + "hoss00312_haha", + "hoss00312_back", + "hoss00312_is_a_hero", + "hoss00312_llliillllilllli", + "hoss00312_wins", + "hoss00312_lives_again", + "hoss00312_is_not_a_bot", + "hoss00312_is_the_best", + "hoss00312_haha", + "hoss00312_is_after_you", + "hoss00312_runner", + "hoss00312_lliilllillllii", + "hoss00312_lllillliiilll", + "hoss00312_armys", + "hoss00312_phag", + "hoss00312_kqueer", + "hoss00312_i_love_coldmay", + "hoss00312_pray_play_liar", + "pedrinhoss0001", + "mrhossdaboss0096", + "hoss00394", + "hoss002134", + "hosso12", + "hosso1010", + "hosso1", + "hoss3120", + "hoss312_", + "hoss33", + "hoss31028", + "hoss3012", + "hoss3123_hate", + "hoss3201_", + "hoss350", + "bighoss3045", + "hoss360", + "hoss00312_got_you", + "hoss00312_llllllllllll", + "jhoss30", + "hoss3682", + "hoss00312_llllllllllll", + "hoss3988", + "hoss38067", + "hoss00312_returns", + "hoss346209", + "hoss3323", + "theghoss33", + "marquinhoss310", + "phoskinhoss33", + "hoss3000", + "hoss3142", + "matheusinhoss3103", + "hoss00312_reeeeeee", + "hoss00312_has_no_life", + "hoss00312_hihi", + "hoss00312_got_you", + "hoss00312_raid", + "hoss00312__ladybugsareus", + "hoss00312_comicudequemle", + "hoss00312_pinguisright", + "hoss00312_01747q91964", + "hoss00312_damuieluiando", + "hoss00312_bittenichtsperr", + "hoss00312_level100weapons", + "hoss00312_loves_regi", + "hoss00312_llllllllllll", + "hoss00312_giver", + "hoss00312_is_after_you", + "hoss00312__finalcountdown", + "hoss00312_elite_", + "hoss00312__sinterklaas", + "hoss00312__wineandcheese", + "hoss00312__angrypuppies", + "hoss00312_pogchamp", + "hoss00312_has_won", + "hoss00312_has_regi", + "hoss00312_open_boss", + "hoss00312_lllll", + "hoss00312_llliilllliiii", + "hoss00312__iiiiiiii", + "hoss00312___iiiiiiiii", + "hoss00312_iiiilllliill", + "hoss00312_iilliiiilll", + "hoss00312_0", + "hoss00312_2", + "hoss00312_uwu", + "hoss00312_dinger", + "hoss00312_is_not_a_bot", + "hoss00312_covid", + "hoss00312_is_back", + "hoss00312_runner", + "hoss00312_runner", + "hoss00312_returns", + "hoss00312_hahaha", + "hoss00312_raid", + "hoss00312_pure", + "hoss00312_is_watching", + "hoss00312_is_hoss00312", + "hoss00312_llliillillllli", + "hoss00312_eyes", + "hoss00312_new", + "hoss__00312", + "hoss00312_00312_00312", + "hoss00312_real_person", + "hoss00312_watch_me", + "hoss00312_you", + "hoss00312_00312_00312", + "hoss00312_real_person", + "hoss00312_big_gains", + "hoss00312_is_hoss00312", + "hoss00312_0_0", + "hoss00312_host", + "hoss____00312", + "hoss00312_is_unstoppable", + "hoss00312_me", + "hoss00312_not_fake", + "hoss00312_yes", + "hoss000000000000312", + "hoss00312_378592692692962", + "hoss00312_is_watching", + "hoss______00312", + "hoss00312_new", + "hoss00312_eyes", + "hoss00312_watch_me", + "hoss00312_click_here", + "hoss00312_haha", + "hoss00312_lliiilllliilll", + "hoss00312_lllillliiilll", + "hoss00312_lllliiilllliill", + "hoss00312_elite_race", + "hoss00312_llliilllill", + "hoss00312_owns_twitch", + "hoss00312_is_the_best", + "hoss00312_runner", + "hoss00312_spy", + "hoss00312_uwu", + "hoss00312_is_alive", + "hoss00312_hello", + "hoss00312_is_not_a_bot", + "hoss00312_dinger", + "hoss00312_covid", + "hoss00312_llllllllllll", + "hoss00312_pure", + "hoss00312_army", + "hoss00312_llliillillllli", + "hoss00312_llliillllilllli", + "hoss00312_lliilllillllii", + "hoss00312_llliilllliiill", + "hoss00312_llliiillliiilll", + "hoss00312_llliillllilllii", + "hoss00312_you", + "hoss00312_has_won", + "hoss00312_hate", + "hoss00312_is_after_you", + "hoss00312_wins", + "hoss00312_lives_again", + "hoss00312_pogchamp", + "hoss00312_hahaha", + "hoss00312_giver", + "hoss00312_kinky", + "hoss00312_handy", + "hoss00312_is_back", + "hoss00312_is_a_hero", + "hoss00312_is_here", + "hoss00312_lllliiillllii", + "hoss00312_llliilllliii", + "hoss00312_god", + "hoss00312_is_friend", + "hoss00312_got_you", + "hoss00312_dad", + "hoss00312_back", + "hoss00312_raid", + "0x25E", + "0x45", + "0x45D", + "0x45e", + "0x45e_banned", + "0x45e_tta", + "0x45e_twitchsucks", + "0x45e_xml", + "0x81_XML", + "1111111111111115", + "1174", + "1nittogether", + "1xolouka20039", + "2600_6c5e_5b7f_fdcd_venom", + "2603_6000_ba07_8c751_cc60", + "2620_7_6001__fff3_c759", + "3maborys1218", + "3xikarina112840", + "404_preview", + "4abtraceur643", + "544h45h4h5", + "5mdmartha732", + "5txmariofan250", + "869833518434357289", + "870179692248961058", + "9jfangboy081", + "9mvova20000087", + "Abbottcostello", + "abert3020", + "Academyimpossible", + "aiexiaxo", + "aitorki_24", + "AlexiaXo", + "Alfredhitchco", + "allroadsleadtothefarm", + "anglefir", + "anotherttvviewer", + "billiewilder", + "bingcortana", + "bongbar", + "brahdryt2517", + "carbob14xyz", + "carbon14xyz", + "carbon14xyz /carbob14xyz", + "carbot14xyz", + "casinothanks", + "chaimrevivo", + "clickonmeplease", + "clickonmeplease2", + "clickonmeplease3", + "clickonmeplease4", + "clickonmeplease5", + "clickonmeplease6", + "clickonmeplease7", + "core_core_core", + "creatineisback", + "csogard4247", + "d1sc0rdforsmallstreamers", + "d4rk_5ky", + "d4rk_5kyhow", + "Ddatapb34", + "ddatapb9", + "Ddatapc34", + "ddatapc9", + "Delteerdatap34", + "delteerdatap9", + "demimad", + "Deprived_life", + "Deprived_live", + "dexiphp", + "disc0rdforsma11streamers", + "discord_for_streamers", + "droopdoggg", + "dumb_1kid", + "emotexbot", + "eqluv", + "extramoar", + "exxxbot", + "farminggurl", + "fbgreataxe197", + "ForeGATHERS", + "fragilitys", + "ftopayr", + "Fvmh97c", + "gametrendanalytics", + "golang_ontop", + "gowithhim", + "h0ss00312", + "h0ss003122", + "h0ss00313", + "havethis2", + "hooosss00312", + "hoosss00312", + "horss00312", + "hoss002134", + "hoss0031", + "hoss00312_", + "hoss00312_____", + "hoss00312_______", + "hoss00312______________", + "hoss00312_armys", + "hoss00312_back", + "hoss00312_covid", + "hoss00312_dinger", + "hoss00312_giver", + "hoss00312_haha", + "hoss00312_handy", + "hoss00312_has_won", + "hoss00312_hate_raid", + "hoss00312_hello", + "hoss00312_i_love_coldmay", + "hoss00312_is_a_hero", + "hoss00312_is_after_you", + "hoss00312_is_alive", + "hoss00312_is_back", + "hoss00312_is_here", + "hoss00312_is_not_a_bot", + "hoss00312_is_real", + "hoss00312_is_the_best", + "hoss00312_kinky", + "hoss00312_kqueer", + "hoss00312_lives_again", + "hoss00312_lliilllillllii", + "hoss00312_llliillillllli", + "hoss00312_llliilllliii", + "hoss00312_llliillllilllii", + "hoss00312_llliillllilllli", + "hoss00312_lllillliiilll", + "hoss00312_owns_twitch", + "hoss00312_phag", + "hoss00312_pray_play_liar", + "hoss00312_runner", + "hoss00312_spy", + "hoss00312_uwu", + "hoss00312_wins", + "hoss00320", + "hoss00394", + "hoss00812", + "hoss0312", + "hoss0o312", + "hoss31028", + "hoss312_", + "hoss3120", + "hoss33", + "hosso1", + "hosso1010", + "hosso12", + "hossoo312", + "host00312", + "hotgirlfromcali", + "icewizerds", + "ily_dogs", + "ily_shiba", + "im_1dumb", + "ipanzer22297", + "jd1d", + "jointeffortt", + "judgejudysiayer", + "lowtik", + "Luna_ anything", + "lunabanned", + "LunaBotAS", + "lunar_ipv4", + "lunar_was_here", + "lunarlunarlunarlunar", + "Magnolia anything", + "manage_me", + "manolia", + "Manolia anything", + "Manolia_403", + "Manolia_kaat", + "Manolia_life", + "Manolia_lunar", + "Manolia_meow", + "ManoliaTTV", + "Maujior", + "metaforick", + "mmohamed9", + "moisonfl", + "molsonfl", + "mr_lightyman", + "mrhossdaboss0096", + "MrTrollge", + "night_php", + "Night_php_lunasec", + "night_shell", + "no_id_uh", + "omegajeppe", + "orthoduck", + "ox45e", + "painhatesad", + "painhatesad1", + "painhatesad123", + "painhatesad2", + "painhatesad3", + "painhatesad666", + "painhatesadanger", + "painhatesadbot", + "painhatesaddepression2222", + "painhatesadxd", + "painhatesadXDXDXD3", + "panda_man2123", + "pawnlam", + "pboj", + "pedrinhoss0001", + "phpshit", + "public_enemy821", + "qarchie0537", + "RememberLunaSec", + "remove_hostratelimit", + "rivkamichaeli1", + "sad_grl", + "schmidoof", + "SeiIaaa", + "servres", + "shell_access", + "shell_upload_php", + "Smallsteramersdcserver", + "SmallStreamersDCServer", + "social_growth_discord", + "socialfriends11", + "ss0031", + "ss30123", + "stevenspielber", + "stormpostor", + "stygian_styx", + "supergg03", + "tdkamil12389", + "Temp_account131", + "thelurxxer", + "TheTwitchAuthority", + "TheTwitchAuthority_E0244", + "thetwitchauthority_lunar", + "TheTwitchAuthority2", + "ttaproxy", + "twitch_setup_tool", + "twitch_test_raid", + "twitch_test_raid2", + "twitchdetails", + "twitchtwitchtwitchx3", + "udhdhufhje", + "umtqma874945", + "violets_tv", + "vvmanolia", + "wave1outof10", + "wchris1335624", + "winsteno", + "wzqdx", + "xqcmate", + "yamickle", + "yes_please_ok", + "yourmomonmyxnxxtab", + "yubother", + "Zenmatevpn", + "zfurkannn", + "hoss00312_was_here", + "hoss00312_lllIIllllIIIll", + "hoss00312_llllIIIllllIIll", + "hoss00312_elite_race", + "hoss00312_lliiilllliilll", + "hoss00312_army", + "hoss00312_rrigger", + "hoss00312_rriggers", + "hoss00312_llliilllliiii", + "hoss00312_pogchamp", + "h0sso0312", + "hoss00312____", + "hectic_legion_july_4th", + "hectic_legion_1", + "hectic_legion_3", + "hectic_legion_ng", + "hoss00312_lllIIIlllIIIlll", + "oss00312_haha", + "hoss00312_llllIIIllllII", + "hoss00312_lllIIlllIll", + "hoss00312____________", + "hoss00312_hoss00312", + "hosseintsh123123", + "exhoss", + "hossinferno", + "hoss_sheeshers", + "pulsebot5347", + "hossgamers", + "h0ss_00v2", + "hoss10868", + "hoss00312_god", + "hoss00312_pure", + "hoss00312_is_friend", + "hoss00312_again", + "hoss00312_returns", + "keur14", + "elmasterkriko", + "fair_use_claimed", + "shizu_is_a_man", + "short_society777", + "sydni_warren_owned", + "gagchud_gaming_88", + "lawsuit_incoming_lol", + "massive_forehead23103", + "chipmunk_ism", + "sneed_groyper_1312", + "chippysneedy", + "trumpclan565", + "sp0rkeh", + "narutoruto0", + "2fast4yall204", + "tysonw_antiblm1", + "sierrascottistubby", + "strwbrioss", + "chat_fantastic", + "friend_request_me", + "atomic_ceno", + "foxstar1996", + "maddyson_moy_bog", + "hoss00312__", + "hoss00312___", + "hoss00312______", + "hoss00312________", + "hoss00312_________", + "hoss00312__________", + "hoss00391", + "hoss00314_", + "hoss003123", + "hoss00313", + "hoss00316", + "hoss00313_", + "hoss003121", + "hoss000312", + "hoss00301", + "hoss00342_", + "hoss00324_", + "big_hoss003", + "hoss003256_", + "not_hoss00322", + "hoss0312_uwu", + "hoss00312_lllliillllilllii", + "hoss00312_is_watching", + "hoss00312_dad", + "hoss___00312", + "hoss00312_host", + "hoss00312_not_fake", + "hoss__00312", + "hoss00312_hate", + "hoss______00312", + "hoss00312_378592692692962", + "hoss00312_me", + "vmanolia", + "vvvmanolia", + "vvvvmanolia", + "lunaripv4", + "lunar_fake", + "lunar_streamangel", + "lunar_streammanager", + "lunar_te_vigila", + "lunar_tic_", + "lunar_tomatedearbol", + "lunar_trumph", + "lunar_undeprived", + "lunar_zhaynteamosoyalexis", + "algasposandersong967", + "algasposcollinsy254", + "algasposgreenn255", + "algasposhillv1752", + "algasposkingxa5", + "algasposmartinezw1569", + "algasposthomasyc1", + "alidreechallh485", + "alidreechillt4849", + "alidreecjacksonkj9", + "alidreeclewisny9", + "alidreecmartinezk731", + "alidreecmartinezt318", + "alidreecmartinv096", + "alidreecwalkersv726", + "alloideladamsnn2", + "alloidelallennt6", + "alloideledwardsk2920", + "alloidelgonzalezg5133", + "alloidelmooref3353", + "alloidelthomascx2", + "alloidelwhitep004", + "aninkenicampbellf561", + "aninkenidavisn9681", + "aninkenihillb0522", + "aninkenijohnsony7679", + "aninkenimartiny397", + "aninkeniparkercj2", + "aninkeniparkerh0829", + "aninkeniscottqj5", + "aninkeniwilsonR765", + "mdbettyb001", + "6fbettyb0001", + "5tbettyb0015", + "3dbettyb009", + "6fbettyb001", + "2qbettyb0016", + "7nbettyb0015", + "7bbettyb0013", + "0ikbettyb001", + "2afbettyb0018", + "imeistee1233", + "3oeistee1233", + "6xeistee1233", + "6jeistee1233", + "4meistee12306", + "9heistee12336", + "9ueistee12339", + "0pgeistee1233", + "7omeistee12304", + "1zorango947", + "4oorango947", + "5rorango947", + "6zhorango947", + "1usorango947", + "6aorango9478", + "8ylorango947", + "4korango941", + "jkorango9471", + "qatrooper71", + "4detrooper71", + "8xtrooper71", + "6btrooper71", + "trooper7171", + "8strooper711", + "myg0t_dot_win_vekb", + "myg0t_dot_win_emnd", + "myg0t_dot_win_scyu", + "www_myg0t_dot_win_yetr", + "www_myg0t_win_1t84", + "hotgirlfromcal", + "click0nmeplease", + "hoss00312_00312_00312" + ] + }, + "2943": { + "reason": "bot account", + "known_aliases": [ + "teyyd" + ] + }, + "1564983": { + "reason": "bot account", + "safe": true, + "known_aliases": [ + "moobot" + ] + }, + "19264788": { + "reason": "bot account", + "safe": true, + "known_aliases": [ + "nightbot" + ] + }, + "24900234": { + "reason": "bot account", + "known_aliases": [ + "bloodlustr" + ] + }, + "25681094": { + "reason": "bot account", + "known_aliases": [ + "commanderroot" + ] + }, + "27446517": { + "reason": "bot account", + "known_aliases": [ + "monstercat" + ] + }, + "29201680": { + "reason": "bot account", + "safe": true, + "known_aliases": [ + "xanbot" + ] + }, + "29762758": { + "reason": "bot account", + "known_aliases": [ + "philderbeast" + ] + }, + "52268235": { + "reason": "bot account", + "safe": true, + "known_aliases": [ + "wizebot" + ] + }, + "55056264": { + "reason": "bot account", + "known_aliases": [ + "coebot" + ] + }, + "59172433": { + "reason": "bot account", + "known_aliases": [ + "skinnyseahorse" + ] + }, + "62809083": { + "reason": "bot account", + "known_aliases": [ + "ohbot" + ] + }, + "69142710": { + "reason": "bot account", + "known_aliases": [ + "unixchat" + ] + }, + "69857893": { + "reason": "bot account", + "known_aliases": [ + "zanekyber" + ] + }, + "70576998": { + "reason": "bot account", + "safe": true, + "known_aliases": [ + "vivbot" + ] + }, + "72810762": { + "reason": "bot account", + "known_aliases": [ + "not47y" + ] + }, + "72923126": { + "reason": "bot account", + "known_aliases": [ + "alphaduplo" + ] + }, + "73599252": { + "reason": "bot account", + "known_aliases": [ + "skumshop" + ] + }, + "73764126": { + "reason": "bot account", + "known_aliases": [ + "cyclemotion" + ] + }, + "78917118": { + "reason": "bot account", + "known_aliases": [ + "hnlbot" + ] + }, + "81577067": { + "reason": "bot account", + "safe": true, + "known_aliases": [ + "branebot" + ] + }, + "88020958": { + "reason": "bot account", + "safe": true, + "known_aliases": [ + "muxybot" + ] + }, + "88713356": { + "reason": "bot account", + "known_aliases": [ + "revlobot" + ] + }, + "90078902": { + "reason": "bot account", + "known_aliases": [ + "uhhhspike" + ] + }, + "91688618": { + "reason": "bot account", + "known_aliases": [ + "streamjar" + ] + }, + "100135110": { + "reason": "bot account", + "safe": true, + "known_aliases": [ + "streamelements" + ] + }, + "101905203": { + "reason": "bot account", + "known_aliases": [ + "s1faka" + ] + }, + "105962478": { + "reason": "bot account", + "known_aliases": [ + "energyzbot" + ] + }, + "114547979": { + "reason": "bot account", + "known_aliases": [ + "curseappbot" + ] + }, + "114583109": { + "reason": "bot account", + "known_aliases": [ + "electricalskateboard" + ] + }, + "115211897": { + "reason": "bot account", + "known_aliases": [ + "faegwent" + ] + }, + "126732100": { + "reason": "bot account", + "known_aliases": [ + "virgoproz" + ] + }, + "128255790": { + "reason": "bot account", + "known_aliases": [ + "jacksere" + ] + }, + "135204446": { + "reason": "bot account", + "safe": true, + "known_aliases": [ + "pretzelrocks" + ] + }, + "143450202": { + "reason": "bot account", + "known_aliases": [ + "martinnemibot" + ] + }, + "150255693": { + "reason": "bot account", + "known_aliases": [ + "letsdothis_hostraffle" + ] + }, + "157264230": { + "reason": "bot account", + "known_aliases": [ + "slocool" + ] + }, + "183484964": { + "reason": "bot account", + "known_aliases": [ + "stay_hydrated_bot" + ] + }, + "187633179": { + "reason": "bot account", + "known_aliases": [ + "dittys14" + ] + }, + "194921579": { + "reason": "bot account", + "known_aliases": [ + "thronezilla" + ] + }, + "195471777": { + "reason": "bot account", + "known_aliases": [ + "stayhealthybot" + ] + }, + "198236322": { + "reason": "bot account", + "known_aliases": [ + "electricallongboard" + ] + }, + "205052173": { + "reason": "bot account", + "known_aliases": [ + "freddyybot" + ] + }, + "207248342": { + "reason": "bot account", + "known_aliases": [ + "fatmanleg" + ] + }, + "219353269": { + "reason": "bot account", + "known_aliases": [ + "lanfusion" + ] + }, + "219406686": { + "reason": "bot account", + "known_aliases": [ + "nowsongbot" + ] + }, + "223493826": { + "reason": "bot account", + "known_aliases": [ + "ddwithers1" + ] + }, + "227202086": { + "reason": "bot account", + "known_aliases": [ + "v_and_k" + ] + }, + "233208809": { + "reason": "bot account", + "known_aliases": [ + "communityshowcase" + ] + }, + "234992844": { + "reason": "bot account", + "known_aliases": [ + "aislinnsatu" + ] + }, + "236791797": { + "reason": "bot account", + "known_aliases": [ + "decafsmurf" + ] + }, + "242355655": { + "reason": "bot account", + "known_aliases": [ + "woppes" + ] + }, + "245330668": { + "reason": "bot account", + "known_aliases": [ + "wormy_official" + ] + }, + "246267529": { + "reason": "bot account", + "known_aliases": [ + "undefined_ninja" + ] + }, + "249378946": { + "reason": "bot account", + "known_aliases": [ + "konkky" + ] + }, + "252248316": { + "reason": "bot account", + "known_aliases": [ + "lurxx" + ] + }, + "261751321": { + "reason": "bot account", + "known_aliases": [ + "d__u__c__k__e__r__z" + ] + }, + "264246119": { + "reason": "bot account", + "known_aliases": [ + "p0lizei_" + ] + }, + "274110779": { + "reason": "bot account", + "known_aliases": [ + "avocadobadado" + ] + }, + "406576975": { + "reason": "bot account", + "known_aliases": [ + "anotherttvviewer" + ] + }, + "410250281": { + "reason": "bot account", + "known_aliases": [ + "cachebear" + ] + }, + "412934702": { + "reason": "bot account", + "known_aliases": [ + "feuerwehr" + ] + }, + "426232928": { + "reason": "bot account", + "known_aliases": [ + "apex_pro_community" + ] + }, + "501326254": { + "reason": "bot account", + "known_aliases": [ + "letsdothis_music" + ] + } +} diff --git a/backend/assets/happyWords/cs.txt b/backend/assets/happyWords/cs.txt new file mode 100644 index 000000000..d1ea3d55f --- /dev/null +++ b/backend/assets/happyWords/cs.txt @@ -0,0 +1,5 @@ +poník +koťátko +štěnátko +kočička +pejsek diff --git a/backend/assets/happyWords/en.txt b/backend/assets/happyWords/en.txt new file mode 100644 index 000000000..4b8731b38 --- /dev/null +++ b/backend/assets/happyWords/en.txt @@ -0,0 +1,4 @@ +geez +kitty +pony +kitten \ No newline at end of file diff --git a/backend/assets/happyWords/ru.txt b/backend/assets/happyWords/ru.txt new file mode 100644 index 000000000..5da2c309c --- /dev/null +++ b/backend/assets/happyWords/ru.txt @@ -0,0 +1,3 @@ +котенок +песик +пони \ No newline at end of file diff --git a/backend/assets/obswebsocket-code.txt b/backend/assets/obswebsocket-code.txt new file mode 100644 index 000000000..c36037ad3 --- /dev/null +++ b/backend/assets/obswebsocket-code.txt @@ -0,0 +1,22 @@ +/* +To properly wait function to complete, please use await for async functions. + +Supported OBSWebsocket version 5.x, OBS at least 28.0.0 + +Functions exposed to eval: + (async) obs - see details at https://github.com/haganbmj/obs-websocket-js + (async) waitMs(miliseconds: number) - wait in script + log(message: string) - log message in bot logs + +If triggered through event-listener, variable event is accessible + event - contains all attributes from event like event.username etc. + +Example: + await obs.call('SetCurrentProgramScene', { + 'sceneName': 'My Amazing Scene' + }); + await waitMs(5000) // wait 5 seconds + await obs.call('SetCurrentProgramScene', { + 'sceneName': 'This is another scene' + }); +*/ \ No newline at end of file diff --git a/backend/assets/updating.html b/backend/assets/updating.html new file mode 100644 index 000000000..b152013ed --- /dev/null +++ b/backend/assets/updating.html @@ -0,0 +1,16 @@ + + + Updating SogeBot UI + + + +
+ ... UI update in progress ... please wait ... +
+ + + \ No newline at end of file diff --git a/backend/assets/vulgarities/cs.txt b/backend/assets/vulgarities/cs.txt new file mode 100644 index 000000000..9a22b1ec7 --- /dev/null +++ b/backend/assets/vulgarities/cs.txt @@ -0,0 +1,100 @@ +blb +blbina +blbý +bordel +bordelmamá +buzerant +buzík +cecek +cecky +chcanky +chcát +chcípnout +chlastat +chuj +do hajzlu +do piče +do píče +do prdele +flundra +flus +frňák +hajzl +hajzlpapír +hovínko +hovnivál +hovno +huba +idiot +jebat +jebačka +kokot +kokotina +kretén +ksindl +kunda +kurva +kurvafix +kurvit +kušna +lempl +mamrd +morda +mrdat +mrdačka +mrdka +mrdník +nasraný +nasrat +omrdat +pajzl +pazneht +péro +píchat +pizda +piča +píča +píčovina +pičus +píčus +podělaný +podělat +posrat +prcat +prd +prdel +prdelka +prdelolezec +prdět +průser +přefiknout +přeříznout +rozesraný +rozmrdaný +rypák +srágora +sralbotka +sranec +srát +sráč +sračka +vysrat +zajebaný +zasranec +zbouchnout +zkurvenec +zkurvený +zkurvit +zkurvysyn +zmrd +zobák +zpíčený +čubčí syn +čumět +čurák +šoustat +šuk +šukačka +šukézní +šulin +šulín \ No newline at end of file diff --git a/backend/assets/vulgarities/en.txt b/backend/assets/vulgarities/en.txt new file mode 100644 index 000000000..799328c11 --- /dev/null +++ b/backend/assets/vulgarities/en.txt @@ -0,0 +1,69 @@ +anal +anus +arse +ass +ass fuck +ass hole +assfucker +asshole +assshole +bastard +bitch +black cock +bloody hell +boong +cock +cockfucker +cocksuck +cocksucker +coon +coonnass +crap +cunt +damn +darn +dick +dirty +erect +erection +escort +fag +faggot +fuck +Fuck off +fuck you +fuckass +fucker +fuckhole +god damn +gook +hard core +hardcore +homoerotic +hore +mother fucker +motherfuck +motherfucker +negro +nigga +nigger +orgasm +penis +penisfucker +piss +pussy +retard +sadist +sex +sexy +shit +slut +son of a bitch +suck +tit +tits +vagina +viagra +wank +wanker +whore \ No newline at end of file diff --git a/backend/assets/vulgarities/ru.txt b/backend/assets/vulgarities/ru.txt new file mode 100644 index 000000000..93316d18f --- /dev/null +++ b/backend/assets/vulgarities/ru.txt @@ -0,0 +1,285 @@ +архипиздрит +басран +бздение +бздеть +бздех +бзднуть +бздун +бздунья +бздюха +бикса +блежник +блудилище +бляд +блябу +блябуду +блядун +блядунья +блядь +блядюга +взьебка +волосянка +взьебывать +вз'ебывать +выблядок +выблядыш +выебать +выеть +выпердеть +высраться +выссаться +говенка +говенный +говешка +говназия +говнецо +говно +говноед +говночист +говнюк +говнюха +говнядина +говняк +говняный +говнять +гондон +дермо +долбоеб +дрисня +дрист +дристать +дристануть +дристун +дристуха +дрочена +дрочила +дрочилка +дрочить +дрочка +ебало +ебальник +ебануть +ебаный +ебарь +ебатория +ебать +ебаться +ебец +ебливый +ебля +ебнуть +ебнуться +ебня +ебун +елда +елдак +елдачить +заговнять +задристать +задрока +заеба +заебанец +заебать +заебаться +заебываться +заеть +залупа +залупаться +залупить +залупиться +замудохаться +засерун +засеря +засерать +засирать +засранец +засрун +захуячить +злоебучий +изговнять +изговняться +кляпыжиться +курва +курвенок +курвин +курвяжник +курвяжница +курвяжный +манда +мандавошка +мандей +мандеть +мандища +мандюк +минет +минетчик +минетчица +мокрохвостка +мокрощелка +мудак +муде +мудеть +мудила +мудистый +мудня +мудоеб +мудозвон +муйня +набздеть +наговнять +надристать +надрочить +наебать +наебнуться +наебывать +нассать +нахезать +нахуйник +насцать +обдристаться +обдристаться +обосранец +обосрать +обосцать +обосцаться +обсирать +опизде +отпиздячить +отпороть +отъеть +охуевательский +охуевать +охуевающий +охуеть +охуительный +охуячивать +охуячить +педрик +пердеж +пердение +пердеть +пердильник +перднуть +пердун +пердунец +пердунина +пердунья +пердуха +пердь +передок +пернуть +пидор +пидр +пидар +пидорас +пидорасы +пидарасы +пизда +пиздануть +пизденка +пиздеть +пиздить +пиздища +пиздобратия +пиздоватый +пиздорванец +пиздорванка +пиздострадатель +пиздун +пиздюга +пиздюк +пиздячить +писять +питишка +плеха +подговнять +подъебнуться +поебать +поеть +попысать +посрать +поставить +поцоватый +презерватив +проблядь +проебать +промандеть +промудеть +пропиздеть +пропиздячить +пысать +разъеба +разъебай +распиздай +распиздеться +распиздяй +распроеть +растыка +сговнять +секель +серун +серька +сика +сикать +сикель +сирать +сирывать +скурвиться +скуреха +скурея +скуряга +скуряжничать +спиздить +срака +сраный +сранье +срать +срун +ссака +ссаки +ссать +старпер +струк +суходрочка +сцавинье +сцака +сцаки +сцание +сцать +сциха +сцуль +сцыха +сыкун +титечка +титечный +титка +титочка +титька +трипер +триппер +уеть +усраться +усцаться +фик +фуй +хезать +хер +херня +херовина +херовый +хитрожопый +хлюха +хуевина +хуевый +хуек +хуепромышленник +хуерик +хуесос +хуище +хуй +хуйня +хуйрик +хуякать +хуякнуть +целка +шлюха \ No newline at end of file diff --git a/backend/bat/install.bat b/backend/bat/install.bat new file mode 100644 index 000000000..4a1b994e6 --- /dev/null +++ b/backend/bat/install.bat @@ -0,0 +1,8 @@ +@echo off + +cd .. +echo Purging old dependencies +rd /s /q node_modules + +echo Installing dependencies +npm install diff --git a/backend/bat/startup.bat b/backend/bat/startup.bat new file mode 100644 index 000000000..b38b0acc5 --- /dev/null +++ b/backend/bat/startup.bat @@ -0,0 +1,3 @@ +@echo off +cd .. +npm start diff --git a/backend/bin/install.sh b/backend/bin/install.sh new file mode 100644 index 000000000..7a9cfc1f1 --- /dev/null +++ b/backend/bin/install.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +declare DIR +declare npm_exec + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +cd "${DIR}" || exit + +rm -rf node_modules & +echo -n 'Purging old dependencies' +while kill -0 $! 2> /dev/null; do + echo -n '.' + sleep 1 +done +echo '.DONE' + +echo -n 'Installing dependencies' +npm install > /dev/null 2>&1 & +while kill -0 $! 2> /dev/null; do + echo -n '.' + sleep 1 +done +echo '.DONE' diff --git a/backend/bin/startup.sh b/backend/bin/startup.sh new file mode 100644 index 000000000..60980396f --- /dev/null +++ b/backend/bin/startup.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +declare DIR +declare npm_exec + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +npm_exec="$(command -v npm | tee /dev/null 2>&1)" + +cd "${DIR}" || exit +npm start + +exit diff --git a/backend/codecov.yml b/backend/codecov.yml new file mode 100644 index 000000000..5c004386a --- /dev/null +++ b/backend/codecov.yml @@ -0,0 +1,15 @@ +ignore: + - "src/database" + - "src/data" + +coverage: + range: 50..90 # coverage lower than 50 is red, higher than 90 green, between color code + + status: + project: # settings affecting project coverage + default: + target: auto # auto % coverage target + threshold: 2% # allow for 5% reduction of coverage without failing + + # do not run coverage on patch nor changes + patch: off \ No newline at end of file diff --git a/backend/crowdin.yml b/backend/crowdin.yml new file mode 100644 index 000000000..5769a2e13 --- /dev/null +++ b/backend/crowdin.yml @@ -0,0 +1,11 @@ +commit_message: '[skip-tests]' +preserve_hierarchy: true + +files: + - source: /locales/en.json + translation: /locales/%two_letters_code%.json + - source: /locales/en/**/* + translation: /locales/%two_letters_code%/**/%original_file_name% + +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN diff --git a/backend/d.ts/eventlist.d.ts b/backend/d.ts/eventlist.d.ts new file mode 100644 index 000000000..aeccde387 --- /dev/null +++ b/backend/d.ts/eventlist.d.ts @@ -0,0 +1,29 @@ +declare namespace EventList { + export interface Event { + event: 'follow' | 'rewardredeem' | 'cheer' | 'subgift' | 'subcommunitygift' | 'resub' | 'sub' | 'raid' | 'tip'; + timestamp: number; + userId: string; + fromId?: string; + isTest?: boolean; + message?: string; + amount?: number; + titleOfReward?: string; + rewardId?: string; + currency?: string; + months?: number; + bits?: number; + viewers?: number; + tier?: string; + song_title?: string; + song_url?: string; + method?: string; + subStreakShareEnabled?: boolean; + subStreak?: number; + subStreakName?: string; + subCumulativeMonths?: number; + subCumulativeMonthsName?: string; + count?: number; + monthsName?: string; + charityCampaignName?: string; + } +} \ No newline at end of file diff --git a/backend/d.ts/filetypes.d.ts b/backend/d.ts/filetypes.d.ts new file mode 100644 index 000000000..f1d21d4fd --- /dev/null +++ b/backend/d.ts/filetypes.d.ts @@ -0,0 +1,24 @@ +declare module '*.vue' { + import Vue from 'vue'; + export default Vue; +} + +declare module '*.txt' { + const content: string; + export default content; +} + +declare module '*.gif' { + const content: string; + export default content; +} + +declare module '*.png' { + const content: string; + export default content; +} + +declare module '*.mp3' { + const content: string; + export default content; +} \ No newline at end of file diff --git a/backend/d.ts/global.d.ts b/backend/d.ts/global.d.ts new file mode 100644 index 000000000..eb6910d21 --- /dev/null +++ b/backend/d.ts/global.d.ts @@ -0,0 +1,5 @@ +declare namespace NodeJS { + export interface Global { + mocha: boolean; + } +} \ No newline at end of file diff --git a/backend/d.ts/index.d.ts b/backend/d.ts/index.d.ts new file mode 100644 index 000000000..7a2bc2ed7 --- /dev/null +++ b/backend/d.ts/index.d.ts @@ -0,0 +1,202 @@ +type ChatUser = import('@twurple/chat').ChatUser; + +type Writeable = { -readonly [P in keyof T]: T[P] }; + +type UserStateTags = import('twitch-js').UserStateTags; +type KnownNoticeMessageIds = import('twitch-js').KnownNoticeMessageIds; + +type DiscordJsTextChannel = import('discord.js').TextChannel; +type DiscordJsUser = import('discord.js').User; + +declare class Stringified extends String { + private ___stringified: T; +} + +interface JSON { + stringify(value: T, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string & Stringified; + parse(text: Stringified, reviver?: (key: any, value: any) => any): T + parse(text: string, reviver?: (key: any, value: any) => any): any +} + +type TimestampObject = { + hours: number; minutes: number; seconds: number +}; + +type UserStateTagsWithId = UserStateTags & { userId: string }; + +interface Command { + id: string; + name: string; + command?: string; + fnc?: string; + isHelper?: boolean; + permission?: string | null; + dependsOn?: import('../src/bot/_interface').Module[]; +} + +interface Parser { + name: string; + fnc?: string; + permission?: string; + priority?: number; + fireAndForget?: boolean; + skippable?: boolean; + dependsOn?: import('../src/bot/_interface').Module[]; +} + +type onEventSub = { + userName: string; + userId: string; + subCumulativeMonths: number; +}; + +type onEventFollow = { + userName: string; + userId: string; +}; + +type onEventTip = { + userName: string; + amount: number; + message: string; + currency: currency; + timestamp: number; +}; + +type onEventBit = { + userName: string; + amount: number; + message: string; + timestamp: number; +}; + +type onEventMessage = { + sender: ChatUser | null; + message: string; + timestamp: number; +}; + +declare namespace InterfaceSettings { + interface Settings { + commands?: C; + parsers?: Parser[]; + [s: string]: any; + } + + interface On { + startup?: string[]; + message?: (message: onEventMessage) => void; + sub?: (sub: onEventSub) => void; + follow?: (follow: onEventFollow) => void; + tip?: (tip: onEventTip) => void; + bit?: (bit: onEventBit) => void; + streamStart?: () => void; + streamEnd?: () => void; + change?: { + [x: string]: string[]; + }; + load?: { + [x: string]: string[]; + }; + partChannel?: () => void; + joinChannel?: () => void; + } + + interface UI { + [x: string]: { + [s: string]: UISelector | UILink | UINumberInput | UIConfigurableList | UISortableList | UITextInput | UIHighlightsUrlGenerator; + } | boolean | UISelector | UILink | UINumberInput | UIConfigurableList | UISortableList | UITextInput | UIHighlightsUrlGenerator; + } +} + +interface InterfaceSettings { + settings?: InterfaceSettings.Settings<(Command | string)[]>; + on?: InterfaceSettings.On; + ui?: InterfaceSettings.UI; + dependsOn?: string[]; +} + +interface UISelector { + type: 'selector'; + values: string[] | (() => string[]); + if?: () => boolean; +} + +interface UIConfigurableList { + type: 'configurable-list'; + if?: () => boolean; +} + +interface UILink { + type: 'link'; + href: string; + class: string; + rawText: string; + target: string; + if?: () => boolean; +} + +interface UITextInput { + type: 'text-input'; + secret: boolean; + if?: () => boolean; +} + +interface UINumberInput { + type: 'number-input'; + step?: number; + min?: number; + max?: number; + if?: () => boolean; +} + +interface UISortableList { + type: 'sortable-list'; + values: string; + toggle: string; + toggleOnIcon: string; + toggleOffIcon: string; + if?: () => boolean; +} + +interface UIHighlightsUrlGenerator { + type: 'highlights-url-generator'; + if?: () => boolean; +} + +type CommandResponse = import('./src/parser').CommandResponse; +type CommandOptions = import('./src/parser').CommandOptions; +interface ParserOptions { + id: string; + sender: Omit | null; + emotesOffsets: Map + discord: { author: DiscordJsUser; channel: DiscordJsTextChannel } | undefined + isAction: boolean, + isHighlight: boolean, + isFirstTimeMessage: boolean, + parameters: string; + message: string; + skip: boolean; + isParserOptions: boolean; + forbidReply?: boolean; + parser?: import('../src/parser.js').Parser; +} + +interface Vote { + _id?: any; + vid: string; + votedBy: string; + votes: number; + option: number; +} + +interface Poll { + _id?: any; + id: string; + type: 'tips' | 'bits' | 'normal'; + title: string; + isOpened: boolean; + options: string[]; + openedAt: number; + closedAt?: number; +} \ No newline at end of file diff --git a/backend/d.ts/missingTypes.d.ts b/backend/d.ts/missingTypes.d.ts new file mode 100644 index 000000000..f82ed8f4a --- /dev/null +++ b/backend/d.ts/missingTypes.d.ts @@ -0,0 +1,23 @@ +declare module 'git-commit-info'; +declare module 'vue-json-viewer'; +declare module 'winwheel'; +declare module 'vue-plugin-load-script'; +declare module 'vue-headful'; +declare module 'vue-flatpickr-component'; +declare module 'vue-codemirror'; +declare module 'vue-prism-component'; +declare module 'html-entities'; +declare module 'strip-comments'; +declare module 'vue-plyr'; +declare module 'blocked-at'; +declare module 'twitter-api-v2/src'; +declare module 'safe-eval'; +declare module 'trigram-similarity'; + +declare module 'js-beautify' { + export function js(code: string): string; +} + +declare module 'currency-symbol-map' { + export default function (code:string): string; +} \ No newline at end of file diff --git a/backend/d.ts/src/general.d.ts b/backend/d.ts/src/general.d.ts new file mode 100644 index 000000000..41a11fe7e --- /dev/null +++ b/backend/d.ts/src/general.d.ts @@ -0,0 +1,8 @@ +export type Command = { + id: string, + defaultValue: string, + type: string, + name: string, + command: string, + permission: string | null, +}; \ No newline at end of file diff --git a/backend/d.ts/src/helpers/panel/alerts.d.ts b/backend/d.ts/src/helpers/panel/alerts.d.ts new file mode 100644 index 000000000..7b268437f --- /dev/null +++ b/backend/d.ts/src/helpers/panel/alerts.d.ts @@ -0,0 +1 @@ +export type UIError = { name: string; message: string }; \ No newline at end of file diff --git a/backend/d.ts/src/helpers/permissions/check.d.ts b/backend/d.ts/src/helpers/permissions/check.d.ts new file mode 100644 index 000000000..330795d8e --- /dev/null +++ b/backend/d.ts/src/helpers/permissions/check.d.ts @@ -0,0 +1,3 @@ +import type { PermissionsInterface } from '@entity/permissions'; + +export type checkReturnType = {access: boolean; permission?: PermissionsInterface}; diff --git a/backend/d.ts/src/helpers/socket.d.ts b/backend/d.ts/src/helpers/socket.d.ts new file mode 100644 index 000000000..a0aa4ae06 --- /dev/null +++ b/backend/d.ts/src/helpers/socket.d.ts @@ -0,0 +1,512 @@ +import { Filter } from '@devexpress/dx-react-grid'; +import type { AlertInterface, EmitData } from '@entity/alert'; +import type { BetsInterface } from '@entity/bets'; +import type { CacheTitlesInterface } from '@entity/cacheTitles'; +import type { ChecklistInterface } from '@entity/checklist'; +import type { CommandsCountInterface, CommandsGroupInterface, CommandsInterface } from '@entity/commands'; +import type { CooldownInterface } from '@entity/cooldown'; +import type { EventInterface, Events } from '@entity/event'; +import type { EventListInterface } from '@entity/eventList'; +import type { GalleryInterface } from '@entity/gallery'; +import type { HighlightInterface } from '@entity/highlight'; +import type { HowLongToBeatGameInterface, HowLongToBeatGameItemInterface } from '@entity/howLongToBeatGame'; +import type { KeywordGroupInterface, KeywordInterface } from '@entity/keyword'; +import type { OBSWebsocketInterface } from '@entity/obswebsocket'; +import type { OverlayMapperMarathon, Overlay } from '@entity/overlay'; +import type { Permissions } from '@entity/permissions'; +import type { QueueInterface } from '@entity/queue'; +import type { QuotesInterface } from '@entity/quotes'; +import type { RaffleInterface } from '@entity/raffle'; +import type { RandomizerInterface } from '@entity/randomizer'; +import type { RankInterface } from '@entity/rank'; +import type { currentSongType, SongBanInterface, SongPlaylistInterface, SongRequestInterface } from '@entity/song'; +import type { SpotifySongBanInterface } from '@entity/spotify'; +import type { TextInterface } from '@entity/text'; +import type { + UserBitInterface, UserInterface, UserTipInterface, +} from '@entity/user'; +import type { Variable, VariableWatch } from '@entity/variable'; +import { HelixVideo } from '@twurple/api/lib'; +import { ValidationError } from 'class-validator'; +import { Socket } from 'socket.io'; +import { FindConditions } from 'typeorm'; + +import { QuickActions } from '../../../src/database/entity/dashboard.js'; +import { WidgetCustomInterface, WidgetSocialInterface } from '../../../src/database/entity/widget.js'; + +import { AliasGroup, Alias } from '~/database/entity/alias'; +import { CacheGamesInterface } from '~/database/entity/cacheGames'; +import { Plugin } from '~/database/entity/plugins'; +import { MenuItem } from '~/helpers/panel'; + +type Configuration = { + [x:string]: Configuration | string; +}; + +export type ViewerReturnType = UserInterface & {aggregatedBits: number, aggregatedTips: number, permission: Permissions, tips: UserTipInterface[], bits: UserBitInterface[] }; +export type possibleLists = 'systems' | 'core' | 'integrations' | 'overlays' | 'games' | 'services'; +export type tiltifyCampaign = { id: number, name: string, slug: string, startsAt: number, endsAt: null | number, description: string, causeId: number, originalFundraiserGoal: number, fundraiserGoalAmount: number, supportingAmountRaised: number, amountRaised: number, supportable: boolean, status: 'published', type: 'Event', avatar: { src: string, alt: string, width: number, height: number, }, livestream: { type: 'twitch', channel: string, } | null, causeCurrency: 'USD', totalAmountRaised: 0, user: { id: number, username: string, slug: string, url: string, avatar: { src: string, alt: string, width: number, height: number, }, }, regionId: null, metadata: Record}; + +export interface getListOfReturn { + systems: { + name: string; enabled: boolean; areDependenciesEnabled: boolean; isDisabledByEnv: boolean; + }[]; + services: { name: string }[]; + core: { name: string }[]; + integrations: { + name: string; enabled: boolean; areDependenciesEnabled: boolean; isDisabledByEnv: boolean; + }[]; + overlays: { + name: string; enabled: boolean; areDependenciesEnabled: boolean; isDisabledByEnv: boolean; + }[]; + games: { + name: string; enabled: boolean; areDependenciesEnabled: boolean; isDisabledByEnv: boolean; + }[]; +} + +type GenericEvents = { + 'settings': (cb: (error: Error | string | null, settings: Record, ui: Record) => void) => void, + 'settings.update': (opts: Record, cb: (error: Error | string | null) => void) => void, + 'settings.refresh': () => void, + 'set.value': (opts: { variable: string, value: any }, cb: (error: Error | string | null, opts: { variable: string, value: any } | null) => void) => void, + 'get.value': (variable: string, cb: (error: Error | string | null, value: any) => void) => void, +}; + +type generic, K = 'id'> = { + getAll: (cb: (error: Error | string | null, items: Readonly>[]) => void) => void, + getOne: (id: Required, cb: (error: Error | string | null, item: Readonly> | null) => void) => void, + setById: (opts: { id: Required, item: Partial }, cb: (error: ValidationError[] | Error | string | null, item?: Readonly> | null) => void) => void, + save: (item: Partial, cb: (error: ValidationError[] | Error | string | null, item?: Readonly> | null) => void) => void, + deleteById: (id: Required, cb: (error: Error | string | null) => void) => void; + validate: (item: Partial, cb: (error: ValidationError[] | Error | string | null) => void) => void, +}; + +export type ClientToServerEventsWithNamespace = { + '/': GenericEvents & { + 'token::broadcaster-missing-scopes': (cb: (scopes: string[]) => void) => void, + 'leaveBot': () => void, + 'joinBot': () => void, + 'channelName': (cb: (name: string) => void) => void, + 'name': (cb: (name: string) => void) => void, + 'responses.get': (_: null, cb: (data: { default: string; current: string }) => void) => void, + 'responses.set': (data: { name: string, value: string }) => void, + 'responses.revert': (data: { name: string }, cb: () => void) => void, + 'api.stats': (data: { code: number, remaining: number | string, data: string}) => void, + 'translations': (cb: (lang: Record) => void) => void, + 'version': (cb: (version: string) => void) => void, + 'debug::get': (cb: (error: Error | string | null, debug: string) => void) => void, + 'debug::set': (debug: string, cb: (error: Error | string | null) => void) => void, + 'panel::alerts': (cb: (error: Error | string | null, data: { errors: import('./panel/alerts').UIError[], warns: import('./panel/alerts').UIError[] }) => void) => void, + 'getLatestStats': (cb: (error: Error | string | null, stats: Record) => void) => void, + 'populateListOf': (type: list, cb: (error: Error | string | null, data: getListOfReturn[list]) => void) => void, + 'custom.variable.value': (variableName: string, cb: (error: Error | string | null, value: string) => void) => void, + 'updateGameAndTitle': (emit: { game: string; title: string; tags: string[]; }, cb: (error: Error | string | null) => void) => void, + 'cleanupGameAndTitle': () => void, + 'getGameFromTwitch': (value: string, cb: (values: string[]) => void) => void, + 'getUserTwitchGames': (cb: (values: CacheTitlesInterface[], thumbnails: CacheGamesInterface[]) => void) => void, + 'integration::obswebsocket::generic::getOne': generic['getOne'], + 'integration::obswebsocket::generic::getAll': generic['getAll'], + 'integration::obswebsocket::generic::save': generic['save'], + 'integration::obswebsocket::generic::deleteById': generic['deleteById'], + 'integration::obswebsocket::trigger': (opts: { code: string, attributes?: Events.Attributes }, cb: any) => void, + 'integration::obswebsocket::values': (cb: (data: { address: string, password: string }) => void) => void, + 'integration::obswebsocket::function': (fnc: any, cb: any) => void, + 'integration::obswebsocket::log': (toLog: string) => void, + }, + '/core/plugins': GenericEvents & { + 'listeners': (cb: (listeners: Record) => void) => void, + 'generic::getOne': generic['getOne'], + 'generic::getAll': generic['getAll'], + 'generic::save': generic['save'], + 'generic::deleteById': generic['deleteById'], + 'generic::validate': generic['validate'], + }, + '/core/emotes': GenericEvents & { + 'testExplosion': (cb: (err: Error | string | null, data: null ) => void) => void, + 'testFireworks': (cb: (err: Error | string | null, data: null ) => void) => void, + 'test': (cb: (err: Error | string | null, data: null ) => void) => void, + 'removeCache': (cb: (err: Error | string | null, data: null ) => void) => void, + 'getCache': (cb: (err: Error | string | null, data: any ) => void) => void, + }, + '/integrations/discord': GenericEvents & { + 'discord::getRoles': (cb: (err: Error | string | null, data: { text: string, value: string}[] ) => void) => void, + 'discord::getGuilds': (cb: (err: Error | string | null, data: { text: string, value: string}[] ) => void) => void, + 'discord::getChannels': (cb: (err: Error | string | null, data: { text: string, value: string}[] ) => void) => void, + 'discord::authorize': (cb: (err: Error | string | null, action?: null | { do: 'redirect', opts: any[] } ) => void) => void, + }, + '/integrations/kofi': GenericEvents, + '/services/google': GenericEvents & { + 'google::revoke': (cb: (err: Error | string | null) => void) => void, + 'google::token': (data: { refreshToken: string }, cb: (err: Error | string | null) => void) => void, + }, + '/integrations/donationalerts': GenericEvents & { + 'donationalerts::validate': (token: string, cb: (err: Error | string | null) => void) => void, + 'donationalerts::revoke': (cb: (err: Error | string | null) => void) => void, + 'donationalerts::token': (data: { accessToken: string, refreshToken: string }, cb: (err: Error | string | null) => void) => void, + }, + '/integrations/pubg': GenericEvents & { + 'pubg::searchForseasonId': (data: { apiKey: string, platform: string }, cb: (err: Error | string | null, data: null | { data: any[] }) => void) => void, + 'pubg::searchForPlayerId': (data: { apiKey: string, platform: string, playerName: string }, cb: (err: Error | string | null, data: null | any) => void) => void, + 'pubg::getUserStats': (data: { apiKey: string, platform: string, playerId: string, seasonId: string, ranked: boolean}, cb: (err: Error | string | null, data: null | any) => void) => void, + 'pubg::exampleParse': (data: { text: string}, cb: (err: Error | string | null, data: string | null) => void) => void, + }, + '/integrations/tiltify': GenericEvents & { + 'tiltify::campaigns': (cb: (campaigns: tiltifyCampaign[]) => void) => void, + 'tiltify::code': (token: string, cb: (err: Error | string | null) => void) => void, + 'tiltify::revoke': (cb: (err: Error | string | null) => void) => void, + }, + '/integrations/spotify': GenericEvents & { + 'code': (token: string, cb: (err: Error | string | null, state: boolean) => void) => void, + 'spotify::revoke': (cb: (err: Error | string | null, opts?: { do: 'refresh' }) => void) => void, + 'spotify::authorize': (cb: (err: Error | string | null, action?: null | { do: 'redirect', opts: any[] }) => void) => void, + 'spotify::state': (cb: (err: Error | string | null, state: string) => void) => void, + 'spotify::code': (token: string, cb: (err: Error | string | null, state: boolean) => void) => void, + 'spotify::skip': (cb: (err: Error | string | null) => void) => void, + 'spotify::addBan': (spotifyUri: string, cb?: (err: Error | string | null) => void) => void, + 'spotify::deleteBan': (where: FindConditions, cb?: (err: Error | string | null) => void) => void, + 'spotify::getAllBanned': (where: FindConditions, cb?: (err: Error | string | null, items: SpotifySongBanInterface[]) => void) => void, + }, + '/overlays/eventlist': GenericEvents & { + 'getEvents': (opts: { ignore: any[], limit: number }, cb: (err: Error | string | null, data: EventListInterface[]) => void) => void, + 'eventlist::getUserEvents': (userId: string, cb: (err: Error | string | null, events: EventListInterface[]) => void) => void, + }, + '/registries/overlays': GenericEvents & { + 'generic::getOne': generic['getOne'], + 'generic::getAll': generic['getAll'], + 'generic::deleteById': generic['deleteById'], + 'generic::save': generic['save'], + 'overlays::tick': (opts: {groupId: string, id: string, millis: number}) => void, + 'parse': (text: string, cb: (err: Error | string | null, data: string) => void) => void, + }, + '/overlays/gallery': GenericEvents & { + 'generic::getOne': generic['getOne'], + 'generic::getAll': generic['getAll'], + 'generic::deleteById': generic['deleteById'], + 'generic::setById': generic['setById'], + 'gallery::upload': (data: [filename: string, data: { id: string, b64data: string, folder?: string }], cb: (err: Error | string | null, item?: OverlayMapperMarathon) => void) => void, + }, + '/overlays/media': GenericEvents & { + 'alert': (data: any) => void, + 'cache': (cacheLimit: number, cb: (err: Error | string | null, data: any) => void) => void, + }, + '/overlays/chat': GenericEvents & { + 'test': (data: { message: string; username: string }) => void, + 'timeout': (userName: string) => void, + 'message': (data: { id: string, show: boolean; message: string; username: string, timestamp: number, badges: any }) => void, + }, + '/overlays/texttospeech': GenericEvents & { + 'speak': (data: { text: string; highlight: boolean, service: 0 | 1, key: string }) => void, + }, + '/overlays/wordcloud': GenericEvents & { + 'wordcloud:word': (words: string[]) => void, + }, + '/overlays/stats': GenericEvents & { + 'get': (cb: (data: any) => void) => void, + }, + '/overlays/bets': GenericEvents & { + 'data': (cb: (data: Required) => void) => void, + }, + '/overlays/clips': GenericEvents & { + 'clips': (data: any) => void + 'test': (clipURL: string) => void + }, + '/overlays/clipscarousel': GenericEvents & { + 'clips': (opts: { customPeriod: number, numOfClips: number }, cb: (error: Error | string | null,data: { clips: any, settings: any }) => void) => void + }, + '/overlays/credits': GenericEvents & { + 'load': (cb: (error: Error | string | null, opts: any) => void) => void, + 'getClips': (opts: Record, cb: (data: any[]) => void) => void, + }, + '/overlays/polls': GenericEvents & { + 'data': (cb: (item: { + id: string, + title: string, + choices: {title: string, totalVotes: number, id: string}[], + startDate: string, + endDate: string, + } | null, votes: any[]) => void) => void, + }, + '/overlays/marathon': GenericEvents & { + 'marathon::public': (id: string, cb: (err: Error | string | null, item?: OverlayMapperMarathon) => void) => void, + 'marathon::check': (id: string, cb: (err: Error | string | null, item?: OverlayMapperMarathon) => void) => void, + 'marathon::update::set': (data: { time: number, id: string }) => void, + }, + '/overlays/countdown': GenericEvents & { + 'countdown::check': (id: string, cb: (err: Error | string | null, update?: { + timestamp: number; + isEnabled: boolean; + time: number; + }) => void) => void, + 'countdown::update': (data: { id: string, isEnabled: boolean | null, time: number | null }, cb: (_err: null, data?: { isEnabled: boolean | null, time :string | null }) => void) => void, + 'countdown::update::set': (data: { id: string, isEnabled: boolean | null, time: number | null }) => void, + }, + '/overlays/stopwatch': GenericEvents & { + 'stopwatch::check': (id: string, cb: (err: Error | string | null, update?: { + timestamp: number; + isEnabled: boolean; + time: number; + }) => void) => void, + 'stopwatch::update::set': (data: { id: string, isEnabled: boolean | null, time: number | null }) => void, + 'stopwatch::update': (data: { id: string, isEnabled: boolean | null, time: number | null }, cb: (_err: null, data?: { isEnabled: boolean | null, time :string | null }) => void) => void, + }, + '/registries/alerts': GenericEvents & { + 'isAlertUpdated': (data: { updatedAt: number; id: string }, cb: (err: Error | null, isUpdated: boolean, updatedAt: number) => void) => void, + 'alerts::settings': (data: null | { areAlertsMuted: boolean; isSoundMuted: boolean; isTTSMuted: boolean; }, cb: (item: { areAlertsMuted: boolean; isSoundMuted: boolean; isTTSMuted: boolean; }) => void) => void, + 'alerts::save': (item: Required, cb: (error: Error | string | null, item: null | Required) => void) => void, + 'alerts::delete': (item: Required, cb: (error: Error | string | null) => void) => void, + 'test': (emit: EmitData) => void, + 'speak': (opts: { text: string, key: string, voice: string; volume: number; rate: number; pitch: number }, cb: (error: Error | string | null, b64mp3: string) => void) => void, + 'alert': (data: (EmitData & { + id: string; + isTTSMuted: boolean; + isSoundMuted: boolean; + TTSService: number; + TTSKey: string; + caster: UserInterface | null; + user: UserInterface | null; + recipientUser: UserInterface | null; + })) => void, + 'skip': () => void, + }, + '/registries/randomizer': GenericEvents & { + 'spin': (data: { service: 0 | 1, key: string }) => void, + }, + '/core/permissions': GenericEvents & { + 'generic::deleteById': generic['deleteById'], + 'generic::getAll': generic['getAll'], + 'permission::save': (data: Required[], cb?: (error: Error | string | null) => void) => void, + 'test.user': (opts: { pid: string, value: string, state: string }, cb: (error: Error | string | null, response?: { status: import('../helpers/permissions/check').checkReturnType | { access: 2 }, partial: import('../helpers/permissions/check').checkReturnType | { access: 2 }, state: string }) => void) => void, + }, + '/registries/text': GenericEvents & { + 'text::save': (item: TextInterface, cb: (error: Error | string | null, item: TextInterface | null) => void) => void, + 'text::remove': (item: TextInterface, cb: (error: Error | string | null) => void) => void, + 'text::presets': (_: unknown, cb: (error: Error | string | null, folders: string[] | null) => void) => void, + 'generic::getAll': generic['getAll'], + 'generic::getOne': (opts: { id: string, parseText: boolean }, cb: (error: Error | string | null, item: TextInterface & { parsedText: string }) => void) => void, + 'variable-changed': (variableName: string) => void, + }, + '/services/twitch': GenericEvents & { + 'emote': (opts: any) => void, + 'emote.firework': (opts: any) => void, + 'emote.explode': (opts: any) => void, + 'hypetrain-end': () => void, + 'hypetrain-update': (data: { id: string, level: number, goal: number, total: number, subs: Record}) => void, + 'eventsub::reset': () => void, + 'broadcaster': (cb: (error: Error | string | null, username: string) => void) => void, + 'twitch::revoke': (data: { accountType: 'bot' | 'broadcaster' }, cb: (err: Error | string | null) => void) => void, + 'twitch::token': (data: { accessToken: string, refreshToken: string, accountType: 'bot' | 'broadcaster' }, cb: (err: Error | string | null) => void) => void, + 'twitch::token::ownApp': (data: { accessToken: string, refreshToken: string, accountType: 'bot' | 'broadcaster', clientId: string, clientSecret: string }, cb: (err: Error | string | null) => void) => void, + }, + '/core/socket': GenericEvents & { + 'purgeAllConnections': (cb: (error: Error | string | null) => void, socket?: Socket) => void, + }, + '/stats/commandcount': GenericEvents & { + 'commands::count': (cb: (error: Error | string | null, items: CommandsCountInterface[]) => void) => void, + }, + '/stats/profiler': GenericEvents & { + 'profiler::load': (cb: (error: Error | string | null, items: [string, number[]][]) => void) => void, + }, + '/stats/bits': GenericEvents & { + 'generic::getAll': generic['getAll'], + }, + '/stats/tips': GenericEvents & { + 'generic::getAll': generic['getAll'], + }, + '/systems/alias': GenericEvents & { + 'generic::getOne': generic['getOne'], + 'generic::groups::getAll': generic['getAll'], + 'generic::groups::deleteById': generic['deleteById'], + 'generic::groups::save': generic['save'], + 'generic::getAll': generic['getAll'], + 'generic::save': generic['save'], + 'generic::deleteById': generic['deleteById'], + }, + '/systems/bets': GenericEvents & { + 'bets::getCurrentBet': (cb: (error: Error | string | null, item?: BetsInterface) => void) => void, + 'bets::close': (option: 'refund' | string) => void, + }, + '/systems/commercial': GenericEvents & { + 'commercial.run': (data: { seconds: string }) => void, + }, + '/systems/highlights': GenericEvents & { + 'highlight': () => void, + 'generic::getAll': (cb: (error: Error | string | null, highlights: Readonly>[], videos: HelixVideo[]) => void) => void, + 'generic::deleteById': generic['deleteById'], + }, + '/systems/howlongtobeat': GenericEvents & { + 'generic::getAll': (cb: (error: Error | string | null, item: Readonly>[], gameItem: Readonly>[]) => void) => void, + 'hltb::save': (item: HowLongToBeatGameInterface, cb: (error: Error | string | null, item?: HowLongToBeatGameInterface) => void) => void, + 'hltb::addNewGame': (game: string, cb: (error: Error | string | null) => void) => void, + 'hltb::getGamesFromHLTB': (game: string, cb: (error: Error | string | null, games: string[]) => void) => void, + 'hltb::saveStreamChange': (stream: HowLongToBeatGameItemInterface, cb: (error: Error | string | null, stream?: HowLongToBeatGameItemInterface) => void) => void, + 'generic::deleteById': generic['deleteById'], + }, + '/systems/checklist': GenericEvents & { + 'generic::getAll': (cb: (error: Error | string | null, array: any[], items: Readonly>[]) => void) => void, + 'checklist::save': (item: ChecklistInterface, cb: (error: Error | string | null) => void) => void, + }, + '/games/seppuku': GenericEvents, + '/games/heist': GenericEvents, + '/games/fightme': GenericEvents, + '/games/duel': GenericEvents, + '/games/roulette': GenericEvents, + '/games/gamble': GenericEvents, + '/core/dashboard': GenericEvents, + '/core/currency': GenericEvents, + '/systems/userinfo': GenericEvents, + '/systems/scrim': GenericEvents, + '/systems/emotescombo': GenericEvents & { + 'combo': (opts: { count: number; url: string }) => void, + }, + '/systems/antihateraid': GenericEvents, + '/services/google': GenericEvents, + '/integrations/twitter': GenericEvents, + '/integrations/tipeeestream': GenericEvents, + '/integrations/streamlabs': GenericEvents & { + 'revoke': (cb: (err: Error | string | null) => void) => void, + 'token': (data: { accessToken: string }, cb: (err: Error | string | null) => void) => void, + }, + '/integrations/streamelements': GenericEvents, + '/integrations/qiwi': GenericEvents, + '/integrations/lastfm': GenericEvents, + '/systems/levels': GenericEvents & { + 'getLevelsExample': (data: { firstLevelStartsAt: number, nextLevelFormula: string, xpName: string } | ((error: Error | string | null, levels: string[]) => void), cb?: (error: Error | string | null, levels: string[]) => void) => void, + }, + '/systems/moderation': GenericEvents & { + 'lists.get': (cb: (error: Error | string | null, lists: { blacklist: string[], whitelist: string[] }) => void) => void, + 'lists.set': (lists: { blacklist: string[], whitelist: string[] }) => void, + }, + '/systems/points': GenericEvents & { + 'parseCron': (cron: string, cb: (error: Error | string | null, intervals: number[]) => void) => void, + 'reset': () => void, + }, + '/systems/queue': GenericEvents & { + 'queue::getAllPicked': (cb: (error: Error | string | null, items: QueueInterface[]) => void) => void, + 'queue::pick': (data: { username?: string | string[], random: boolean, count: number; }, cb: (error: Error | string | null, items: QueueInterface[]) => void) => void, + 'queue::clear': (cb: (error: Error | string | null) => void) => void, + 'generic::getAll': generic['getAll'], + }, + '/systems/raffles': GenericEvents & { + 'raffle::getWinner': (name: string, cb: (error: Error | string | null, item?: UserInterface) => void) => void, + 'raffle::setEligibility': (opts: {id: string, isEligible: boolean}, cb: (error: Error | string | null) => void) => void, + 'raffle:getLatest': (cb: (error: Error | string | null, item?: RaffleInterface) => void) => void, + 'raffle::pick': () => void, + 'raffle::close': () => void, + 'raffle::open': (message: string) => void, + }, + '/systems/songs': GenericEvents & { + 'isPlaying': (cb: (isPlaying: boolean) => void) => void, + 'songs::getAllRequests': (_: any, cb: (error: Error | string | null, requests: SongRequestInterface[]) => void) => void, + 'current.playlist.tag': (cb: (error: Error | string | null, tag: string) => void) => void, + 'find.playlist': (opts: { filters?: Filter[], page: number, search?: string, tag?: string | null, perPage: number}, cb: (error: Error | string | null, songs: SongPlaylistInterface[], count: number) => void) => void, + 'songs::currentSong': (cb: (error: Error | string | null, song: currentSongType) => void) => void, + 'set.playlist.tag': (tag: string) => void, + 'get.playlist.tags': (cb: (error: Error | string | null, tags: string[]) => void) => void, + 'songs::save': (item: SongPlaylistInterface, cb: (error: Error | string | null, item: SongPlaylistInterface) => void) => void, + 'songs::getAllBanned': (where: Record | null | undefined, cb: (error: Error | string | null, item: SongBanInterface[]) => void) => void, + 'songs::removeRequest': (id: string, cb: (error: Error | string | null) => void) => void, + 'delete.playlist': (id: string, cb: (error: Error | string | null) => void) => void, + 'delete.ban': (id: string, cb: (error: Error | string | null) => void) => void, + 'import.ban': (url: string, cb: (error: Error | string | null, result: import('../parser').CommandResponse[]) => void) => void, + 'import.playlist': (opts: { playlist: string, forcedTag: string | null }, cb: (error: Error | string | null, result: import('../parser').CommandResponse[] | null) => void) => void, + 'import.video': (opts: { playlist: string, forcedTag: string | null }, cb: (error: Error | string | null, result: import('../parser').CommandResponse[] | null) => void) => void, + 'stop.import': () => void, + 'next': () => void, + }, + '/widgets/joinpart': GenericEvents & { + 'joinpart': (data: { users: string[], type: 'join' | 'part' }) => void, + 'viewers': (cb: (error: Error | string | null, data: { chatters: any }) => void) => void, + }, + '/widgets/chat': GenericEvents & { + 'message': (cb: (error: Error | string | null, message: { timestamp: string, message: string, username: string }) => void) => void, + 'room': (cb: (error: Error | string | null, room: string) => void) => void, + 'chat.message.send': (message: string) => void, + 'viewers': (cb: (error: Error | string | null, data: { chatters: any }) => void) => void, + }, + '/widgets/customvariables': GenericEvents & { + 'watched::save': (items: VariableWatch[], cb: (error: Error | string | null, variables: VariableWatch[]) => void) => void, + 'customvariables::list': (cb: (error: Error | string | null, variables: Variable[]) => void) => void, + 'list.watch': (cb: (error: Error | string | null, variables: VariableWatch[]) => void) => void, + 'watched::setValue': (opts: { id: string, value: string | number }, cb: (error: Error | string | null) => void) => void, + }, + '/widgets/eventlist': GenericEvents & { + 'eventlist::removeById': (idList: string[] | string, cb: (error: Error | string | null) => void) => void, + 'eventlist::get': (count: number) => void, + 'skip': () => void, + 'cleanup': () => void, + 'eventlist::resend': (id: string) => void, + 'update': (cb: (values: any) => void) => void, + 'askForGet': (cb: () => void) => void, + }, + '/widgets/custom': GenericEvents & { + 'generic::getAll': (userId: string, cb: (error: Error | string | null, items: Readonly>[]) => void) => void, + 'generic::save': generic['save']; + 'generic::deleteById': generic['deleteById']; + }, + '/widgets/quickaction': GenericEvents & { + 'generic::deleteById': generic['deleteById'], + 'generic::save': generic['save'], + 'generic::getAll': (userId: string, cb: (error: Error | string | null, items: Readonly>[]) => void) => void, + 'trigger': (data: { user: { userId: string, userName: string }, id: string, value?: any}) => void, + }, + '/widgets/social': GenericEvents & { + 'generic::getAll': generic['getAll']; + }, + '/core/events': GenericEvents & { + 'events::getRedeemedRewards': (cb: (error: Error | string | null, rewards: { id: string, name: string }[]) => void) => void, + 'generic::getAll': (cb: (error: Error | string | null, data: EventInterface[]) => void) => void, + 'generic::getOne': (id: string, cb: (error: Error | string | null, data?: EventInterface) => void) => void, + 'list.supported.events': (cb: (error: Error | string | null, data: any[] /* TODO: missing type */) => void) => void, + 'list.supported.operations': (cb: (error: Error | string | null, data: any[] /* TODO: missing type */) => void) => void, + 'test.event': (opts: { id: string; randomized: string[], variables: string[], values: any[] }, cb: (error: Error | string | null) => void) => void, + 'events::save': (event: EventInterface, cb: (error: Error | string | null, data: EventInterface) => void) => void, + 'events::remove': (eventId: Required, cb: (error: Error | string | null) => void) => void, + }, + '/core/tts': GenericEvents & { + 'google::speak': (opts: { volume: number; pitch: number; rate: number; text: string; voice: string; }, cb: (error: Error | string | null, audioContent?: string | null) => void) => void, + 'speak': (opts: { text: string, key: string, voice: string; volume: number; rate: number; pitch: number; triggerTTSByHighlightedMessage?: boolean; }, cb: (error: Error | string | null, b64mp3: string) => void) => void, + }, + '/core/ui': GenericEvents & { + 'configuration': (cb: (error: Error | string | null, data?: Configuration) => void) => void, + }, + '/core/updater': GenericEvents & { + 'updater::check': (cb: (error: Error | string | null) => void) => void, + 'updater::trigger': (opts: { pkg: string, version: string }, cb?: (error: Error | string | null) => void) => void, + }, + '/core/users': GenericEvents & { + 'viewers::resetPointsAll': (cb?: (error: Error | string | null) => void) => void, + 'viewers::resetMessagesAll': (cb?: (error: Error | string | null) => void) => void, + 'viewers::resetWatchedTimeAll': (cb?: (error: Error | string | null) => void) => void, + 'viewers::resetSubgiftsAll': (cb?: (error: Error | string | null) => void) => void, + 'viewers::resetBitsAll': (cb?: (error: Error | string | null) => void) => void, + 'viewers::resetTipsAll': (cb?: (error: Error | string | null) => void) => void, + 'viewers::update': (data: [userId: string, update: Partial & { tips?: UserTipInterface[], bits?: UserBitInterface[] }], cb: (error: Error | string | null) => void) => void, + 'viewers::remove': (userId: string, cb: (error: Error | string | null) => void) => void, + 'getNameById': (id: string, cb: (error: Error | string | null, user: string | null) => void) => void, + 'viewers::findOneBy': (id: string, cb: (error: Error | string | null, viewer: ViewerReturnType) => void) => void + 'find.viewers': (opts: { exactUsernameFromTwitch?: boolean, state: string, page?: number; perPage?: number; order?: { orderBy: string, sortOrder: 'ASC' | 'DESC' }, filter?: { columnName: string, operation: string, value: any }[], search?: string }, cb: (error: Error | string | null, viewers: any[], count: number, state: string | null) => void) => void, + 'logout': (data: { accessToken: string | null, refreshToken: string | null }) => void + }, + '/core/general': GenericEvents & { + 'menu::private': (cb: (items: (MenuItem & { enabled: boolean })[]) => void) => void, + 'generic::getCoreCommands': (cb: (error: Error | string | null, commands: import('../general').Command[]) => void) => void, + 'generic::setCoreCommand': (commands: import('../general').Command, cb: (error: Error | string | null) => void) => void, + }, + '/core/customvariables': GenericEvents & { + 'customvariables::list': (cb: (error: Error | string | null, items: Variable[]) => void) => void, + 'customvariables::runScript': (id: string, cb: (error: Error | string | null, items: Variable | null) => void) => void, + 'customvariables::testScript': (opts: { evalValue: string, currentValue: string }, cb: (error: Error | string | null, returnedValue: any) => void) => void, + 'customvariables::isUnique': (opts: { variable: string, id: string }, cb: (error: Error | string | null, isUnique: boolean) => void) => void, + 'customvariables::delete': (id: string, cb?: (error: Error | string | null) => void) => void, + 'customvariables::save': (item: Variable, cb: (error: ValidationError[] | Error | string | null, itemId: string | null) => void) => void, + } +}; + +type Fn = + (...params: Params) => Result; + +type NestedFnParams< + O extends Record>, + K0 extends keyof O, + K1 extends keyof O[K0], +> = Parameters; \ No newline at end of file diff --git a/backend/d.ts/src/parser.d.ts b/backend/d.ts/src/parser.d.ts new file mode 100644 index 000000000..90347dd47 --- /dev/null +++ b/backend/d.ts/src/parser.d.ts @@ -0,0 +1,27 @@ +type DiscordJsTextChannel = import('discord.js').TextChannel; +type DiscordJsUser = import('discord.js').User; +type ChatUser = import('@twurple/chat').ChatUser; + +export interface CommandResponse { + response: string | Promise; + sender: CommandOptions['sender']; + discord: CommandOptions['discord']; + attr: CommandOptions['attr']; +} + +export interface CommandOptions { + sender: Omit + emotesOffsets: Map + discord: { author: DiscordJsUser; channel: DiscordJsTextChannel } | undefined + command: string; + parameters: string; + isAction: boolean, + isHighlight: boolean, + isFirstTimeMessage: boolean, + createdAt: number; + attr: { + skip?: boolean; + quiet?: boolean; + [attr: string]: any; + }; +} \ No newline at end of file diff --git a/backend/d.ts/src/plugins.d.ts b/backend/d.ts/src/plugins.d.ts new file mode 100644 index 000000000..65e5181b6 --- /dev/null +++ b/backend/d.ts/src/plugins.d.ts @@ -0,0 +1,15 @@ +export type Node = { + id: number, + name: string, + data: { value: T, data: string }, + class: string, + html: string, + inputs: { input_1: { connections: { + node: string, + }[] }} | Record, + outputs: { output_1: { connections: { + node: string, + }[] }, output_2: { connections: { + node: string, + }[] }}, +}; \ No newline at end of file diff --git a/backend/docker.sh b/backend/docker.sh new file mode 100644 index 000000000..834572f2b --- /dev/null +++ b/backend/docker.sh @@ -0,0 +1,10 @@ +#!/bin/bash +cd /app + +if [ -z "$PROFILER" ] +then + npm start +else + echo 'Starting bot with DEBUG flag, inspect exposed at 0.0.0.0:9229' + npm debug +fi \ No newline at end of file diff --git a/backend/docs/.nojekyll b/backend/docs/.nojekyll new file mode 100644 index 000000000..e69de29bb diff --git a/backend/docs/README.md b/backend/docs/README.md new file mode 100644 index 000000000..8291b5a10 --- /dev/null +++ b/backend/docs/README.md @@ -0,0 +1,102 @@ +# SogeBot + +[![Discord](https://img.shields.io/discord/317348946144002050.svg?style=for-the-badge&logo=discord)](https://discordapp.com/invite/52KpmuH) +[![GitHub release](https://img.shields.io/github/release/sogehige/sogebot.svg?style=for-the-badge)](https://github.com/sogebot/sogeBot/releases) +[![Downloads](https://img.shields.io/github/downloads/sogehige/sogebot/total.svg?style=for-the-badge)](https://github.com/sogebot/sogeBot/releases) +[![Donate](https://img.shields.io/badge/paypal-donate-yellow.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/sogetwitch/5eur) +[![Patreon](https://img.shields.io/badge/dynamic/json?logo=patreon&style=for-the-badge&color=%23e85b46&label=Patreon&query=data.attributes.patron_count&suffix=%20patrons&url=https%3A%2F%2Fwww.patreon.com%2Fapi%2Fcampaigns%2F3198445)](https://www.patreon.com/soge__) +[![Codecov](https://img.shields.io/codecov/c/github/sogebot/sogebot/master?logo=codecov&style=for-the-badge)](https://app.codecov.io/gh/sogehige/sogeBot/branch/master) + +Free Twitch Bot built on Node.js + +#### Important links + +- **DISCORD**: +- **GITHUB**: +- **DOCS**: +- **ISSUES**: +- **RELEASES**: +- **FORUM, IDEAS & SUGGESTIONS**: + +#### Screenshots + + + +#### Issues + +If you found an issue with a bot, feel free to create issue at . +You can also contact me on my email sogehige@gmail.com or get support on our [discord server](https://discordapp.com/invite/52KpmuH). + +| System | Description | +|--------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| Alias | Don't like default commands? Make an alias! | +| Checklist | Pre-stream customizable checklist | +| Keywords | Bot will respond on certain keywords | +| Points / Loyalty | Points system for your users | +| Price | Make viewers to spend points on e.g. !songrequest | +| Ranks | Create ranks for your viewers | +| Levels | Create levels for your viewers | +| Scrim | Scrim system to play againts your viewers in Fortnite etc. | +| Custom commands | Create custom commands, call custom APIs, set custom variables | +| Timers | Post a response every x seconds, x messages | +| Queue | Do you lost track of viewers who wants to play with you? Use !queue and be fair! | +| Raffles | Create raffles for you giveaways! | +| Youtube | **Songrequest** and **multiple playlist** support for YouTube with **trimming** of videos and **auto volume normalization** | +| Spotify | **Songrequest** for Spotify Premium Users | +| Cooldowns | Stop spamming of commands with cooldowns! | +| Permissions | Set your custom permissions for your commands! (owner, mods, regular, viewer) | +| Moderation | Automoderate links, colors, symbols, forbidden words and more! | +| Twitch | Be able to change your game and title from webpanel and much more! !uptime, !lastseen, etc. | +| Webpanel and Stats | Bot is tracking your twitch **stats** and bot **webpanel** is user friendly and full of features! | +| | Many widgets for your dashboard: customizable soundboard (/public/dist/soundboard/), follower list, twitch monitor, bets, songs and more | +| | Be able to set your !title and !game from dashboard and **save** them for further use! Use custom variables in titles | +| Overlay | Use various overlays in your OBS or XSplit | +| Events | On numerous events run commands, send messages, do whatever! | +| Chat Games | bets, heists, duels, wheel of fortune | +| Integrations | **Streamlabs**, DonationAlerts.ru, Twitter | + +| Game | Description | +|------------------|------------------------------------------------| +| Bets | | +| Gambling | !seppuku, !roulette commands | +| Duel | !duel - bet your points, only one can win | +| Heists | !bankheist | +| Wheel Of Fortune | !wof | + +| Overlay | Description | +|------------------|-----------------------------------------------------------------------| +| Emotes | Show chat message emotes in your stream! | +| Stats | Show viewers, follower, uptime | +| ImageCarousel | Simple image fadeIn/fadeOut carousel | +| Alerts | Show images, play audio/video | +| Clips | Show clips created through events as replays | +| Credits | End credits like in a movie | +| Text | Show text and variables ($ytSong etc.) | +| Eventlist | Show last events | +| Wheel Of Fortune | Show wheel of fortune spin | +| Bets | Show current bet | +| Goals | Show your goals! | + +#### Languages + +List of languages available at + +#### Documentation + + + +#### FAQ + + + +#### License + +See LICENSE file + +#### Special thanks + +Special thanks goes to team behing tmi.js (you can check it on ) and twitch-js (). They did really awesome job. + +#### Support [![Donate](https://img.shields.io/badge/paypal-donate-yellow.svg?style=flat-square)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=9ZTX5DS2XB5EN) + +If you want to support me, you can click a PayPal link above or you can contribute and we can create something great! diff --git a/backend/docs/_images/install/artifact.png b/backend/docs/_images/install/artifact.png new file mode 100644 index 000000000..c6bc49171 Binary files /dev/null and b/backend/docs/_images/install/artifact.png differ diff --git a/backend/docs/_images/install/nightlies.png b/backend/docs/_images/install/nightlies.png new file mode 100644 index 000000000..3ee73e659 Binary files /dev/null and b/backend/docs/_images/install/nightlies.png differ diff --git a/backend/docs/_images/screenshots/1.png b/backend/docs/_images/screenshots/1.png new file mode 100644 index 000000000..dfccddb02 Binary files /dev/null and b/backend/docs/_images/screenshots/1.png differ diff --git a/backend/docs/_images/screenshots/2.png b/backend/docs/_images/screenshots/2.png new file mode 100644 index 000000000..90d07cbac Binary files /dev/null and b/backend/docs/_images/screenshots/2.png differ diff --git a/backend/docs/_images/screenshots/3.png b/backend/docs/_images/screenshots/3.png new file mode 100644 index 000000000..876fb9179 Binary files /dev/null and b/backend/docs/_images/screenshots/3.png differ diff --git a/backend/docs/_images/screenshots/4.png b/backend/docs/_images/screenshots/4.png new file mode 100644 index 000000000..cf11bf1f1 Binary files /dev/null and b/backend/docs/_images/screenshots/4.png differ diff --git a/backend/docs/_images/screenshots/5.png b/backend/docs/_images/screenshots/5.png new file mode 100644 index 000000000..0462dc7ed Binary files /dev/null and b/backend/docs/_images/screenshots/5.png differ diff --git a/backend/docs/_images/screenshots/6.png b/backend/docs/_images/screenshots/6.png new file mode 100644 index 000000000..a93f1d51b Binary files /dev/null and b/backend/docs/_images/screenshots/6.png differ diff --git a/backend/docs/_images/screenshots/7.png b/backend/docs/_images/screenshots/7.png new file mode 100644 index 000000000..16e245445 Binary files /dev/null and b/backend/docs/_images/screenshots/7.png differ diff --git a/backend/docs/_images/spotify/1.png b/backend/docs/_images/spotify/1.png new file mode 100644 index 000000000..d1db4ff8e Binary files /dev/null and b/backend/docs/_images/spotify/1.png differ diff --git a/backend/docs/_images/spotify/10.png b/backend/docs/_images/spotify/10.png new file mode 100644 index 000000000..384435b3a Binary files /dev/null and b/backend/docs/_images/spotify/10.png differ diff --git a/backend/docs/_images/spotify/2.png b/backend/docs/_images/spotify/2.png new file mode 100644 index 000000000..65125e207 Binary files /dev/null and b/backend/docs/_images/spotify/2.png differ diff --git a/backend/docs/_images/spotify/3.png b/backend/docs/_images/spotify/3.png new file mode 100644 index 000000000..ee31cc61c Binary files /dev/null and b/backend/docs/_images/spotify/3.png differ diff --git a/backend/docs/_images/spotify/4.png b/backend/docs/_images/spotify/4.png new file mode 100644 index 000000000..14368c0e3 Binary files /dev/null and b/backend/docs/_images/spotify/4.png differ diff --git a/backend/docs/_images/spotify/5.png b/backend/docs/_images/spotify/5.png new file mode 100644 index 000000000..6508d44b4 Binary files /dev/null and b/backend/docs/_images/spotify/5.png differ diff --git a/backend/docs/_images/spotify/6.png b/backend/docs/_images/spotify/6.png new file mode 100644 index 000000000..9ed0e83d2 Binary files /dev/null and b/backend/docs/_images/spotify/6.png differ diff --git a/backend/docs/_images/spotify/7.png b/backend/docs/_images/spotify/7.png new file mode 100644 index 000000000..b304d0f60 Binary files /dev/null and b/backend/docs/_images/spotify/7.png differ diff --git a/backend/docs/_images/spotify/8.png b/backend/docs/_images/spotify/8.png new file mode 100644 index 000000000..4844d6121 Binary files /dev/null and b/backend/docs/_images/spotify/8.png differ diff --git a/backend/docs/_images/spotify/9.png b/backend/docs/_images/spotify/9.png new file mode 100644 index 000000000..9e359a2ab Binary files /dev/null and b/backend/docs/_images/spotify/9.png differ diff --git a/backend/docs/_images/twitter/api-key.png b/backend/docs/_images/twitter/api-key.png new file mode 100644 index 000000000..dc0dfd3c5 Binary files /dev/null and b/backend/docs/_images/twitter/api-key.png differ diff --git a/backend/docs/_images/twitter/app-permission.png b/backend/docs/_images/twitter/app-permission.png new file mode 100644 index 000000000..615d04d3b Binary files /dev/null and b/backend/docs/_images/twitter/app-permission.png differ diff --git a/backend/docs/_images/twitter/copy-access-token.png b/backend/docs/_images/twitter/copy-access-token.png new file mode 100644 index 000000000..5d1729e77 Binary files /dev/null and b/backend/docs/_images/twitter/copy-access-token.png differ diff --git a/backend/docs/_images/twitter/create-an-application.png b/backend/docs/_images/twitter/create-an-application.png new file mode 100644 index 000000000..80ca1d950 Binary files /dev/null and b/backend/docs/_images/twitter/create-an-application.png differ diff --git a/backend/docs/_images/twitter/create-new-app.png b/backend/docs/_images/twitter/create-new-app.png new file mode 100644 index 000000000..5311942ba Binary files /dev/null and b/backend/docs/_images/twitter/create-new-app.png differ diff --git a/backend/docs/_images/twitter/edit-app-permission.png b/backend/docs/_images/twitter/edit-app-permission.png new file mode 100644 index 000000000..e189815db Binary files /dev/null and b/backend/docs/_images/twitter/edit-app-permission.png differ diff --git a/backend/docs/_images/twitter/generate-access-token.png b/backend/docs/_images/twitter/generate-access-token.png new file mode 100644 index 000000000..28c2409fc Binary files /dev/null and b/backend/docs/_images/twitter/generate-access-token.png differ diff --git a/backend/docs/_images/twitter/keys-and-token.png b/backend/docs/_images/twitter/keys-and-token.png new file mode 100644 index 000000000..1a595fb97 Binary files /dev/null and b/backend/docs/_images/twitter/keys-and-token.png differ diff --git a/backend/docs/_master/.nojekyll b/backend/docs/_master/.nojekyll new file mode 100644 index 000000000..e69de29bb diff --git a/backend/docs/_master/README.md b/backend/docs/_master/README.md new file mode 100644 index 000000000..8291b5a10 --- /dev/null +++ b/backend/docs/_master/README.md @@ -0,0 +1,102 @@ +# SogeBot + +[![Discord](https://img.shields.io/discord/317348946144002050.svg?style=for-the-badge&logo=discord)](https://discordapp.com/invite/52KpmuH) +[![GitHub release](https://img.shields.io/github/release/sogehige/sogebot.svg?style=for-the-badge)](https://github.com/sogebot/sogeBot/releases) +[![Downloads](https://img.shields.io/github/downloads/sogehige/sogebot/total.svg?style=for-the-badge)](https://github.com/sogebot/sogeBot/releases) +[![Donate](https://img.shields.io/badge/paypal-donate-yellow.svg?style=for-the-badge&logo=paypal)](https://www.paypal.me/sogetwitch/5eur) +[![Patreon](https://img.shields.io/badge/dynamic/json?logo=patreon&style=for-the-badge&color=%23e85b46&label=Patreon&query=data.attributes.patron_count&suffix=%20patrons&url=https%3A%2F%2Fwww.patreon.com%2Fapi%2Fcampaigns%2F3198445)](https://www.patreon.com/soge__) +[![Codecov](https://img.shields.io/codecov/c/github/sogebot/sogebot/master?logo=codecov&style=for-the-badge)](https://app.codecov.io/gh/sogehige/sogeBot/branch/master) + +Free Twitch Bot built on Node.js + +#### Important links + +- **DISCORD**: +- **GITHUB**: +- **DOCS**: +- **ISSUES**: +- **RELEASES**: +- **FORUM, IDEAS & SUGGESTIONS**: + +#### Screenshots + + + +#### Issues + +If you found an issue with a bot, feel free to create issue at . +You can also contact me on my email sogehige@gmail.com or get support on our [discord server](https://discordapp.com/invite/52KpmuH). + +| System | Description | +|--------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| Alias | Don't like default commands? Make an alias! | +| Checklist | Pre-stream customizable checklist | +| Keywords | Bot will respond on certain keywords | +| Points / Loyalty | Points system for your users | +| Price | Make viewers to spend points on e.g. !songrequest | +| Ranks | Create ranks for your viewers | +| Levels | Create levels for your viewers | +| Scrim | Scrim system to play againts your viewers in Fortnite etc. | +| Custom commands | Create custom commands, call custom APIs, set custom variables | +| Timers | Post a response every x seconds, x messages | +| Queue | Do you lost track of viewers who wants to play with you? Use !queue and be fair! | +| Raffles | Create raffles for you giveaways! | +| Youtube | **Songrequest** and **multiple playlist** support for YouTube with **trimming** of videos and **auto volume normalization** | +| Spotify | **Songrequest** for Spotify Premium Users | +| Cooldowns | Stop spamming of commands with cooldowns! | +| Permissions | Set your custom permissions for your commands! (owner, mods, regular, viewer) | +| Moderation | Automoderate links, colors, symbols, forbidden words and more! | +| Twitch | Be able to change your game and title from webpanel and much more! !uptime, !lastseen, etc. | +| Webpanel and Stats | Bot is tracking your twitch **stats** and bot **webpanel** is user friendly and full of features! | +| | Many widgets for your dashboard: customizable soundboard (/public/dist/soundboard/), follower list, twitch monitor, bets, songs and more | +| | Be able to set your !title and !game from dashboard and **save** them for further use! Use custom variables in titles | +| Overlay | Use various overlays in your OBS or XSplit | +| Events | On numerous events run commands, send messages, do whatever! | +| Chat Games | bets, heists, duels, wheel of fortune | +| Integrations | **Streamlabs**, DonationAlerts.ru, Twitter | + +| Game | Description | +|------------------|------------------------------------------------| +| Bets | | +| Gambling | !seppuku, !roulette commands | +| Duel | !duel - bet your points, only one can win | +| Heists | !bankheist | +| Wheel Of Fortune | !wof | + +| Overlay | Description | +|------------------|-----------------------------------------------------------------------| +| Emotes | Show chat message emotes in your stream! | +| Stats | Show viewers, follower, uptime | +| ImageCarousel | Simple image fadeIn/fadeOut carousel | +| Alerts | Show images, play audio/video | +| Clips | Show clips created through events as replays | +| Credits | End credits like in a movie | +| Text | Show text and variables ($ytSong etc.) | +| Eventlist | Show last events | +| Wheel Of Fortune | Show wheel of fortune spin | +| Bets | Show current bet | +| Goals | Show your goals! | + +#### Languages + +List of languages available at + +#### Documentation + + + +#### FAQ + + + +#### License + +See LICENSE file + +#### Special thanks + +Special thanks goes to team behing tmi.js (you can check it on ) and twitch-js (). They did really awesome job. + +#### Support [![Donate](https://img.shields.io/badge/paypal-donate-yellow.svg?style=flat-square)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=9ZTX5DS2XB5EN) + +If you want to support me, you can click a PayPal link above or you can contribute and we can create something great! diff --git a/backend/docs/_master/_images/install/artifact.png b/backend/docs/_master/_images/install/artifact.png new file mode 100644 index 000000000..c6bc49171 Binary files /dev/null and b/backend/docs/_master/_images/install/artifact.png differ diff --git a/backend/docs/_master/_images/install/nightlies.png b/backend/docs/_master/_images/install/nightlies.png new file mode 100644 index 000000000..3ee73e659 Binary files /dev/null and b/backend/docs/_master/_images/install/nightlies.png differ diff --git a/backend/docs/_master/_images/screenshots/1.png b/backend/docs/_master/_images/screenshots/1.png new file mode 100644 index 000000000..dfccddb02 Binary files /dev/null and b/backend/docs/_master/_images/screenshots/1.png differ diff --git a/backend/docs/_master/_images/screenshots/2.png b/backend/docs/_master/_images/screenshots/2.png new file mode 100644 index 000000000..90d07cbac Binary files /dev/null and b/backend/docs/_master/_images/screenshots/2.png differ diff --git a/backend/docs/_master/_images/screenshots/3.png b/backend/docs/_master/_images/screenshots/3.png new file mode 100644 index 000000000..876fb9179 Binary files /dev/null and b/backend/docs/_master/_images/screenshots/3.png differ diff --git a/backend/docs/_master/_images/screenshots/4.png b/backend/docs/_master/_images/screenshots/4.png new file mode 100644 index 000000000..cf11bf1f1 Binary files /dev/null and b/backend/docs/_master/_images/screenshots/4.png differ diff --git a/backend/docs/_master/_images/screenshots/5.png b/backend/docs/_master/_images/screenshots/5.png new file mode 100644 index 000000000..0462dc7ed Binary files /dev/null and b/backend/docs/_master/_images/screenshots/5.png differ diff --git a/backend/docs/_master/_images/screenshots/6.png b/backend/docs/_master/_images/screenshots/6.png new file mode 100644 index 000000000..a93f1d51b Binary files /dev/null and b/backend/docs/_master/_images/screenshots/6.png differ diff --git a/backend/docs/_master/_images/screenshots/7.png b/backend/docs/_master/_images/screenshots/7.png new file mode 100644 index 000000000..16e245445 Binary files /dev/null and b/backend/docs/_master/_images/screenshots/7.png differ diff --git a/backend/docs/_master/_images/spotify/1.png b/backend/docs/_master/_images/spotify/1.png new file mode 100644 index 000000000..d1db4ff8e Binary files /dev/null and b/backend/docs/_master/_images/spotify/1.png differ diff --git a/backend/docs/_master/_images/spotify/10.png b/backend/docs/_master/_images/spotify/10.png new file mode 100644 index 000000000..384435b3a Binary files /dev/null and b/backend/docs/_master/_images/spotify/10.png differ diff --git a/backend/docs/_master/_images/spotify/2.png b/backend/docs/_master/_images/spotify/2.png new file mode 100644 index 000000000..65125e207 Binary files /dev/null and b/backend/docs/_master/_images/spotify/2.png differ diff --git a/backend/docs/_master/_images/spotify/3.png b/backend/docs/_master/_images/spotify/3.png new file mode 100644 index 000000000..ee31cc61c Binary files /dev/null and b/backend/docs/_master/_images/spotify/3.png differ diff --git a/backend/docs/_master/_images/spotify/4.png b/backend/docs/_master/_images/spotify/4.png new file mode 100644 index 000000000..14368c0e3 Binary files /dev/null and b/backend/docs/_master/_images/spotify/4.png differ diff --git a/backend/docs/_master/_images/spotify/5.png b/backend/docs/_master/_images/spotify/5.png new file mode 100644 index 000000000..6508d44b4 Binary files /dev/null and b/backend/docs/_master/_images/spotify/5.png differ diff --git a/backend/docs/_master/_images/spotify/6.png b/backend/docs/_master/_images/spotify/6.png new file mode 100644 index 000000000..9ed0e83d2 Binary files /dev/null and b/backend/docs/_master/_images/spotify/6.png differ diff --git a/backend/docs/_master/_images/spotify/7.png b/backend/docs/_master/_images/spotify/7.png new file mode 100644 index 000000000..b304d0f60 Binary files /dev/null and b/backend/docs/_master/_images/spotify/7.png differ diff --git a/backend/docs/_master/_images/spotify/8.png b/backend/docs/_master/_images/spotify/8.png new file mode 100644 index 000000000..4844d6121 Binary files /dev/null and b/backend/docs/_master/_images/spotify/8.png differ diff --git a/backend/docs/_master/_images/spotify/9.png b/backend/docs/_master/_images/spotify/9.png new file mode 100644 index 000000000..9e359a2ab Binary files /dev/null and b/backend/docs/_master/_images/spotify/9.png differ diff --git a/backend/docs/_master/_images/twitter/api-key.png b/backend/docs/_master/_images/twitter/api-key.png new file mode 100644 index 000000000..dc0dfd3c5 Binary files /dev/null and b/backend/docs/_master/_images/twitter/api-key.png differ diff --git a/backend/docs/_master/_images/twitter/app-permission.png b/backend/docs/_master/_images/twitter/app-permission.png new file mode 100644 index 000000000..615d04d3b Binary files /dev/null and b/backend/docs/_master/_images/twitter/app-permission.png differ diff --git a/backend/docs/_master/_images/twitter/copy-access-token.png b/backend/docs/_master/_images/twitter/copy-access-token.png new file mode 100644 index 000000000..5d1729e77 Binary files /dev/null and b/backend/docs/_master/_images/twitter/copy-access-token.png differ diff --git a/backend/docs/_master/_images/twitter/create-an-application.png b/backend/docs/_master/_images/twitter/create-an-application.png new file mode 100644 index 000000000..80ca1d950 Binary files /dev/null and b/backend/docs/_master/_images/twitter/create-an-application.png differ diff --git a/backend/docs/_master/_images/twitter/create-new-app.png b/backend/docs/_master/_images/twitter/create-new-app.png new file mode 100644 index 000000000..5311942ba Binary files /dev/null and b/backend/docs/_master/_images/twitter/create-new-app.png differ diff --git a/backend/docs/_master/_images/twitter/edit-app-permission.png b/backend/docs/_master/_images/twitter/edit-app-permission.png new file mode 100644 index 000000000..e189815db Binary files /dev/null and b/backend/docs/_master/_images/twitter/edit-app-permission.png differ diff --git a/backend/docs/_master/_images/twitter/generate-access-token.png b/backend/docs/_master/_images/twitter/generate-access-token.png new file mode 100644 index 000000000..28c2409fc Binary files /dev/null and b/backend/docs/_master/_images/twitter/generate-access-token.png differ diff --git a/backend/docs/_master/_images/twitter/keys-and-token.png b/backend/docs/_master/_images/twitter/keys-and-token.png new file mode 100644 index 000000000..1a595fb97 Binary files /dev/null and b/backend/docs/_master/_images/twitter/keys-and-token.png differ diff --git a/backend/docs/_master/_sidebar.md b/backend/docs/_master/_sidebar.md new file mode 100644 index 000000000..de92de058 --- /dev/null +++ b/backend/docs/_master/_sidebar.md @@ -0,0 +1,55 @@ +* [Home](_master/) +* [Install and upgrade](_master/install-and-upgrade.md) +* [FAQ](_master/faq.md) +* Configuration + * [Database](_master/configuration/database.md) + * [Environment variables](_master/configuration/env.md) +* Services + * [Twitch](_master/services/twitch.md) + * [Google](_master/services/google.md) +* Systems + * [Alias](_master/systems/alias.md) + * [Bets](_master/systems/bets.md) + * [Commercial](_master/systems/commercial.md) + * [Permissions](_master/systems/permissions.md) + * [Custom Commands](_master/systems/custom-commands.md) + * [Cooldowns](_master/systems/cooldowns.md) + * [Keywords](_master/systems/keywords.md) + * [Levels](_master/systems/levels.md) + * [Moderation](_master/systems/moderation.md) + * [Timers](_master/systems/timers.md) + * [Points](_master/systems/points.md) + * [Polls](_master/systems/polls.md) + * [Price](_master/systems/price.md) + * [Scrim](_master/systems/scrim.md) + * [Songs](_master/systems/songs.md) + * [Top](_master/systems/top.md) + * [Ranks](_master/systems/ranks.md) + * [Raffles](_master/systems/raffles.md) + * [Queue](_master/systems/queue.md) + * [Highlights](_master/systems/highlights.md) + * [Quotes](_master/systems/quotes.md) + * [Miscellaneous](_master/systems/miscellaneous.md) +* Games + * [Duel](_master/games/duel.md) + * [FightMe](_master/games/fightme.md) + * [Gamble](_master/games/gamble.md) + * [Heist](_master/games/heist.md) + * [Roulette](_master/games/roulette.md) + * [Seppuku](_master/games/seppuku.md) + * [Wheel Of Fortune](_master/games/wheelOfFortune.md) +* Registries + * [Randomizer](/registries/randomizer.md) +* [Response Filters](_master/filters/all.md) +* Overlays + * [Themes](_master/overlays/themes.md) + * [Eventlist](_master/overlays/eventlist.md) +* How To + * [Connection to socket](_master/howto/connection-to-socket.md) + * [Eval snippets](_master/howto/eval.md) + * [Run random command](_master/howto/run-random-command.md) + * [Write own system](_master/howto/write-own-system.md) +* Integrations + * [Twitter](_master/integrations/twitter.md) + * [Last.fm](_master/integrations/lastfm.md) + * [Spotify](_master/integrations/spotify.md) diff --git a/backend/docs/_master/configuration/database.md b/backend/docs/_master/configuration/database.md new file mode 100644 index 000000000..ff9d14ece --- /dev/null +++ b/backend/docs/_master/configuration/database.md @@ -0,0 +1,42 @@ +!> TypeORM is supporting various databases, below are listed **only** supported databases + by sogeBot. + +!> Update **!!! ONLY !!!** your connection informations + +## SQLite3 + +?> SQLite is **default** db (if installed by zipfile), if you didn't set MySQL/MariaDB or PostgreSQL, +you don't need to do anything + +1. Rename `/path/to/sogebot/.env.sqlite` or in case of GIT install `/path/to/sogebot/src/data/.env.sqlite` to `/path/to/sogebot/.env` +2. **DON'T UPDATE ANY OTHER INFORMATIONS (LIKE MIGRATION, ENTITIES), + OTHERWISE DATABASE WON'T WORK** +3. Start bot + +## MySQL/MariaDB + +1. Rename `/path/to/sogebot/.env.mysql` or in case of GIT install `/path/to/sogebot/src/data/.env.mysql` to `/path/to/sogebot/.env` +2. Update your connection options, see + [TypeORM Connection Options](https://typeorm.io/#/connection-options) + for detailed information. +3. **DON'T UPDATE ANY OTHER INFORMATIONS (LIKE MIGRATION, ENTITIES), + OTHERWISE DATABASE WON'T WORK** +4. Start bot + +## PostgreSQL + +1. Rename `/path/to/sogebot/.env.postgres` or in case of GIT install `/path/to/sogebot/src/data/.env.postgres` to `/path/to/sogebot/.env` +2. Update your connection options, see + [TypeORM Connection Options](https://typeorm.io/#/connection-options) + for detailed information. +3. **DON'T UPDATE ANY OTHER INFORMATIONS (LIKE MIGRATION, ENTITIES), + OTHERWISE DATABASE WON'T WORK** +4. Start bot + +## Supported databases + +- SQLite3(**default**) +- PostgreSQL 15 +- MySQL 5.7 + - you need to set `character-set-server=utf8mb4` + and `collation-server=utf8mb4_general_ci` diff --git a/backend/docs/_master/configuration/env.md b/backend/docs/_master/configuration/env.md new file mode 100644 index 000000000..017084c3c --- /dev/null +++ b/backend/docs/_master/configuration/env.md @@ -0,0 +1,94 @@ +Bot can be started with various environment variables + +## DISABLE + +Force system to be disabled. Mainly used for moderation system + +- `DISABLE=moderation` +- nothing is set *default* + +## PORT + +Set port for listening of UI. + +- `PORT=12345` +- `PORT=20000` *default* + +## SECUREPORT + +Set port for listening of UI. + +- `SECUREPORT=12345` +- `SECUREPORT=20443` *default* + +## CA_KEY, CA_CERT + +Sets your certificate and certificate key by **full path** + +- `CA_KEY=/path/to/your/cert.key` +- `CA_CERT=/path/to/your/cert.cert` + +## HEAP + +Enables HEAP snapshot tracking and saving for a bot. In normal environment, +you **should not** enable this environment variable. + +- `HEAP=true` +- `HEAP=false` *default* + +Heaps are saved in `./heap/` folder + +## LOGLEVEL + +Changes log level of a bot + +- `LOGLEVEL=debug` +- `LOGLEVEL=info` *default* + +## DEBUG + +Enables extended debugging, by default its disabled + +- `DEBUG=api.call` - will save `api.bot.csv` and `api.broadcaster.csv` files +- `DEBUG=api.stream` + +## THREAD + +Force worker_threads to be disabled in special cases (e.g. getChannelChattersUnofficialAPI) + +- `THREAD=0` +- nothing is set *default* + +## TIMEZONE + +!> Timezone is affecting only bot logs and `!time` command + +## CORS + +Enable socket.io cors settings + +- `CORS=*` +- nothing is set *default* + +### What is this? + +Changes timezone settings for a bot. Useful if you are on machine, where you +cannot change system timezone or you have several bots for different streamers +in different timezones. + +### Available values + +- *system* - will set timezone defined by system +- other timezones can be found at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + - you are interested in TZ values on wiki: + - Africa/Abidjan + - Europe/Prague + - America/Argentina/San_Luis + +### Examples + +- `TIMEZONE=system` +- `TIMEZONE=Europe/Prague` +- `TIMEZONE=America/Argentina/San_Luis` + +?> If timezone is not set default value is *system* diff --git a/backend/docs/_master/faq.md b/backend/docs/_master/faq.md new file mode 100644 index 000000000..3f90d2a5a --- /dev/null +++ b/backend/docs/_master/faq.md @@ -0,0 +1,39 @@ +**Question:** Does a bot support more than one channel? + +**Answer:** Bot supports **only** one channel to be connected, there is longterm plan to add multichannel support, but currently one bot instance = one channel + +*** + +**Question:** Can you add this super new feature to a bot? + +**Answer:** Please [create new issue/feature request](https://github.com/sogebot/sogeBot/issues/new?labels=feature+request) and I'll look at it! + +*** + +**Question:** Why !title and !game commands doesn't work? + +**Answer:** Bot need channel editor permissions in http://twitch.tv/yourusername/dashboard/permissions and bot oauth token must be generated through http://oauth.sogebot.xyz/ + +*** + +**Question:** How can I run bot on boot/startup? + +**Answer:** You can use [pm2](https://github.com/Unitech/pm2) to manage your bot instance and have it started on boot + +*** + +**Question:** Why is bot not sending whisper messages? + +**Answer:** Please read https://discuss.dev.twitch.tv/t/have-a-chat-whisper-bot-let-us-know/10651 and register your bot in application form. To get your bot user_id use curl command below. + + curl -H 'Accept: application/vnd.twitchtv.v5+json' \ + -H 'Client-ID: 1wjn1i3792t71tl90fmyvd0zl6ri2vg' \ + -X GET https://api.twitch.tv/kraken/users?login= + +*** + +**Question:** Bot on docker have issues with connection to twitch or streamlabs (Error: getaddrinfo EAI_AGAIN)? + +**Answer:** Check https://development.robinwinslow.uk/2016/06/23/fix-docker-networking-dns/ for steps to fix your issue + +*** diff --git a/backend/docs/_master/filters/all.md b/backend/docs/_master/filters/all.md new file mode 100644 index 000000000..00abf1e4f --- /dev/null +++ b/backend/docs/_master/filters/all.md @@ -0,0 +1,560 @@ +!> Response filters are usable in notices, custom commands, keywords and text overlay + +## Global variables + +`$sender` + +- returns username of viewer, who triggered this message + +`$source` + +- returns source of this message (twitch or discord), if comes from +bot it is twitch by default + +`$price` + +- returns command price + +`$game` + +- return current game + +`$thumbnail` + +- return current thumbnail link without defined size, e.g. `https://static-cdn.jtvnw.net/ttv-boxart/33214-{width}x{height}.jpg` + +`$thumbnail(WIDTHxHEIGHT)` + +- return current thumbnail link +- `$thumbnail(150x200)` will return e.g. `https://static-cdn.jtvnw.net/ttv-boxart/33214-150x200.jpg` + +`$title` + +- return current status + +`$language` + +- return current stream language + +`$viewers` + +- return current viewers count + +`$followers` + +- return current followers count + +`$subscribers` + +- return current subscribers count + +`$bits` + +- return current bits count received during current stream + +`$ytSong` + +- return current song playing in YTplayer widget + +`$spotifySong` + +- return current song playing in Spotify + +`$lastfmSong` + +- return current song playing in Last.fm + +`$latestFollower` + +- Latest Follower + +`$latestSubscriber` + +- Latest Subscriber + +`$latestSubscriberMonths` + +- Latest Subscriber cumulative months + +`$latestSubscriberStreak` + +- Latest Subscriber months streak + +`$latestTipAmount` + +- Latest Tip (amount) + +`$latestTipCurrency` + +- Latest Tip (currency) + +`$latestTipMessage` + +- Latest Tip (message) + +`$latestTip` + +- Latest Tip (username) + +`$latestCheerAmount` + +- Latest Cheer (amount) + +`$latestCheerMessage` + +- Latest Cheer (message) + +`$latestCheer` + +- Latest Cheer (username) + +`$toptip.overall.username` + +- Overall top tip (username) + +`$toptip.overall.amount` + +- Overall top tip (amount) + +`$toptip.overall.currency` + +- Overall top tip (currency) + +`$toptip.overall.message` + +- Overall top tip (message) + +`$toptip.stream.username` + +- Current stream top tip (username) + +`$toptip.stream.amount` + +- Current stream top tip (amount) + +`$toptip.stream.currency` + +- Current stream top tip (currency) + +`$toptip.stream.message` + +- Current stream top tip (message) + +`$version` + +- return current bot version + +`$isBotSubscriber` + +- return true/false (boolean) if bot is subscriber + +`$isStreamOnline` + +- return true/false (boolean) if stream is online + +`$uptime` + +- return uptime of the stream + +`$channelDisplayName` + +- return channel display name + +`$channelUserName` + +- return channel user name + +## Count subs / follows/ bits / tips in date interval + +- `(count|subs|)` +- return subs+resubs count in interval + - **example:** + - `(count|subs|day)` +- `(count|follows|)` +- return follows count in interval + - **example:** + - `(count|follows|month)` +- `(count|tips|)` +- return tips count in interval + - **example:** + - `(count|tips|year)` +- `(count|bits|)` +- return bits count in interval + - **example:** + - `(count|bits|week)` + +- available **interval**: hour, day, week, month, year + +## Eval + +`(eval )` + +- will evaluate your javascript code - there **must** be return value + +## If + +`(if '$game'=='Dota 2'|Is dota|Is not dota)` + +- will evaluate your javascript if code, string check + +`(if $viewers>5|Is more than 5 viewers|Is less)` + +- will evaluate your javascript if code, int check + +`(if $viewers>5|Is more than 5 viewers)` + +- will evaluate your javascript if code, without else + +## Online/offline filters + +`(onlineonly)` + +- will enable command only if stream is online + +`(offlineonly)` + +- will enable command only if stream is offline + +## Math filters + +- `(math.#)` +- solve a math problem + - **example:** + - `(math.5+6)` + - `(math.$bits*2)` + - usable with variables in events +- `(toPercent.#)` +- change float number to percent + - **example:** + - `(toPercent|2|0.5)` => 50.00 + - `(toPercent|0.5)` => 50 + - `(toPercent|0.4321)` => 43 + - `(toPercent|2|0.43211123)` => 43.21 +- `(toFloat.#)` +- formats a number using fixed-point notation. + - **example:** + - `(toFloat|2|0.5)` => 0.50 + - `(toFloat|0.5)` => 1 + - `(toFloat|2|0.43211123)` => 0.43 + - `(toFloat|0.4321)` => 0 + +## Random filters + +`(random.online.viewer)` + +- returns random online viewer + +`(random.online.subscriber)` + +- returns random online subscriber + +`(random.viewer)` + +- returns random viewer (offline included) + +`(random.subscriber)` + +- returns random subscriber (offline included) + +`(random.number-#-to-#)` + +- returns random number from # to # - example: `(random.number-5-to-10)` + +`(random.true-or-false)` + +- returns randomly true/false + +## Custom variables + +**\#** is name of variable, e.g. mmr as `$_mmr` + +`$_#` + +- will set value for specified variable **if** argument is passed, else will return its value + +- **example:** + - _command:_ `!mmr` + - _response:_ `My MMR value is $_mmr` + - _chat:_ `!mmr 1000` < only for owners and mods + - _bot response_: `@soge__, mmr was set to 1000.` + - _chat:_ `!mmr` + - _bot response_: `My MMR value is 1000` + - _chat:_ `!mmr +` < only for owners and mods + - _bot response_: `@soge__, mmr was set to 1001.` + - _chat:_ `!mmr -` < only for owners and mods + - _bot response_: `My MMR value is 1000` + - _chat:_ `!mmr +500` < only for owners and mods + - _bot response_: `@soge__, mmr was set to 1500.` + - _chat:_ `!mmr -500` < only for owners and mods + - _bot response_: `@soge__, mmr was set to 1000.` + +`$!_#` + +- same as variable above, except set message is always silent + +`$!!_#` + +- full silent variable, useful in multi responses where first response + might be just setting of variable + +`$touser` + +- is user param variable, if empty, current user is used. This +param accepts `@user` and `user` + +- **example:** + - _command:_ `!point` + - _response:_ `$sender point to $touser` + - _chat:_ `!point @soge__` + - _bot response_: `@foobar points to @soge__` +- **example:** + - _command:_ `!point` + - _response:_ `$sender point to $touser` + - _chat:_ `!point soge__` + - _bot response_: `@foobar points to @soge__` +- **example:** + - _command:_ `!point` + - _response:_ `$sender point to $touser` + - _chat:_ `!point` + - _bot response_: `@foobar points to @foobar` + +`$param` + +- is required temporary variable (command without param will not show) + +- **example:** + - _command:_ `!say` + - _response:_ `$sender said: $param` + - _chat:_ `!say Awesome!` + - _bot response_: `@foobar said: Awesome!` + +`$!param` + +- is not required temporary variable + +- **example:** + - _command:_ `!say` + - _response:_ `$sender said: $!param` + - _chat:_ `!say Awesome!` + - _bot response_: `@foobar said: Awesome!` + +## URI safe strings + +`(url|Lorem Ipsum Dolor)` + +- will generate url safe string to be used in GET + +`(url|$param)` + +- will generate url safe from variable $param + +## Custom APIs + +`(api|http://your.desired.url)` + +- will load data from specified url - only **UTF-8** responses are supported + +`(api|http://your.desired.url?query=$querystring)` + +- will load data from specified url with specified *querystring* + +`(api._response)` + +- returns response, if api response is string + +- **example:** + - _command:_ `!stats` + - _response:_ `(api|https://csgo-stats.net/api/nightbot/rank/sogehige/sogehige) (sender), (api._response)` + - _chat:_ `!stats` + - _bot response_: `@foobar , Kills: 47182, Deaths: 47915, K/D: 0.98, Headshots: 39.0%, Accuracy: 18.5% - https://csgo-stats.net/player/sogehige/` + +`(api.#)` + +- returns json data of specified attribute + +- JSON arrays are accesible as well - _example:_ `(api.some[0].value[1])` +- **example:** + - _command:_ `!api` + - _response:_ `(api|https://jsonplaceholder.typicode.com/posts/5) UserId: (api.userId), id: (api.id), title: (api.title), body: (api.body)` + - _chat_: `!api` + - _bot response_: `UserId: 1, id: 5, title: nesciunt quas odio, body: repudiandae veniam quaerat sunt sedalias aut fugiat sit autem sed estvoluptatem omnis possimus esse voluptatibus quisest aut tenetur dolor neque` + +### GET parameters + +You can add parameters to your urls, _note_ you must escape your parameters where +needed. + +e.g. `(api|https://httpbin.org/get?test=a\\nb) Lorem (api.args.test)` + +## Command filters + +`$count` + +- return how many times current command was used + +`$count('!another')` + +- return how many times `!another` command was used + +`(! )` + +- run `! argument` + +`(!points add $sender 1000)` + +- run `!points add soge__ 1000` + +`(!points add $param 1000)` + +- run `!points add $param 1000` + +`(!.)` + +- run `! ` + +`(!)` + +- run `!` + +`(!!)` + +- run command **silently** + +**Usage 1:** + +- _command:_ `!buypermit` +- _response:_ `(!permit.sender) You have bought a 1 link permit for (price)` +- _chat:_ `foobar: !buypermit` +- _bot example response in chat_: `You have bough a 1 link permit for 100 Points` +- Bot will then send permit command for sender `!permit foobar` with _muted_ permit message + +**Usage 2:** + +- _command:_ `!play` +- _response:_ `(!songrequest.J73cZQzhPW0) You just requested some song!` +- _chat:_ `foobar: !play` +- _bot example response in chat_: `You just requested some song!` +- Bot will then send songrequest command for id `!songrequest J73cZQzhPW0` with _muted_ songrequest message + +## Stream filters + +`(stream|#name|game)` + +- returns game of `#name` channel, it something went wrong, returns `n/a` + +`(stream|#name|title)` + +- returns title of `#name` channel, it something went wrong, returns `n/a` + +`(stream|#name|viewers)` + +- returns viewers count of `#name` channel, it something went wrong, returns `0` + +`(stream|#name|link)` + +- returns link to twitch `#name` channel -> 'twitch.tv/#name' + +`(stream|#name|status)` + +- returns status of twitch `#name` channel -> 'live' | 'offline' + +## YouTube filters + +`$youtube(url, )` + +- returns latest video link e.g. +`$youtube(url, stejk01)` + +`$youtube(title, )` + +- returns latest video title e.g. +`$youtube(title, stejk01)` + +## List filters + +`(list.alias)` + +- will return list of your visible aliases + +`(list.alias|)` + +- will return list of your visible aliases for group + +`(list.alias|)` + +- will return list of your visible aliases without any group + +`(list.!alias)` + +- will return list of your visible !aliases + +`(list.!alias|)` + +- will return list of your visible !aliases for group + +`(list.!alias|)` + +- will return list of your visible !aliases without any group + +`(list.price)` + +- will return list of your set prices + +`(list.command)` + +- will return list of your visible custom commands + +`(list.command.)` + +- will return list of your visible custom commands for permission + +`(list.command|)` + +- will return list of your visible !commands for group + +`(list.command|)` + +- will return list of your visible !commands without any group + +`(list.!command)` + +- will return list of your visible custom !commands + +`(list.!command|)` + +- will return list of your visible !commands for group + +`(list.!command|)` + +- will return list of your visible !commands without any group + +`(list.!command.)` + +- will return list of your visible custom !commands for permission + +`(list.core.)` + +- will return list of your visible custom core commands for permission + +`(list.!core.)` + +- will return list of your visible custom core !commands for permission + +`(list.cooldown)` + +- will return list of your cooldowns (keywords and !commands) + +`(list.ranks)` + +- list of your ranks + +`(list.ranks.sub)` + +- list of your ranks + +**Usage:** + +- _command:_ `!list` +- _response:_ `My uber super awesome command list: (list.commands)` +- _chat:_ `foobar: !list` +- _bot example response in chat_: `My uber super awesome command list: test, test2` diff --git a/backend/docs/_master/games/duel.md b/backend/docs/_master/games/duel.md new file mode 100644 index 000000000..c435737ba --- /dev/null +++ b/backend/docs/_master/games/duel.md @@ -0,0 +1,22 @@ +!> Duel game will work only when Points system is enabled! + +!> By default game is **disabled** + +## Start or participate in duel + +`!duel ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` + - points to bet on yourself + - use **all** to bet all your points + +## Settings + +`!enable game duel` | +`!disable game duel` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/_master/games/gamble.md b/backend/docs/_master/games/gamble.md new file mode 100644 index 000000000..ce89a5bc1 --- /dev/null +++ b/backend/docs/_master/games/gamble.md @@ -0,0 +1,22 @@ +!> Gamble game will work only when Points system is enabled! + +!> By default game is **disabled** + +## Gamble your points for win + +`!gamble ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` + - points to bet on gamble + - use **all** to bet all your points + +## Settings + +`!enable game gamble` | +`!disable game gamble` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/_master/games/heist.md b/backend/docs/_master/games/heist.md new file mode 100644 index 000000000..02a4691b4 --- /dev/null +++ b/backend/docs/_master/games/heist.md @@ -0,0 +1,24 @@ +!> Heist game will work only when Points system is enabled! + +!> By default game is **disabled** + +## Start or participate in heist + +`!bankheist ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` + - points to bet on heist + - use **all** to bet all your points + +## Settings + +Heist needs to be properly set through UI. + +`!enable game heist` | +`!disable game heist` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/_master/games/roulette.md b/backend/docs/_master/games/roulette.md new file mode 100644 index 000000000..493060106 --- /dev/null +++ b/backend/docs/_master/games/roulette.md @@ -0,0 +1,16 @@ +!> Roulette game will work only when Points system is enabled! + +!> By default game is **disabled** + +## Start russian roulette + +`!roulette` + +!> Default permission is **VIEWERS** + +## Settings + +`!enable game roulette` | +`!disable game roulette` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/_master/games/seppuku.md b/backend/docs/_master/games/seppuku.md new file mode 100644 index 000000000..465e0f48b --- /dev/null +++ b/backend/docs/_master/games/seppuku.md @@ -0,0 +1,14 @@ +!> By default game is **disabled** + +## Kick yourself from chat + +`!seppuku` + +!> Default permission is **VIEWERS** + +## Settings + +`!enable game seppuku` | +`!disable game seppuku` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/_master/games/wheelOfFortune.md b/backend/docs/_master/games/wheelOfFortune.md new file mode 100644 index 000000000..440984fd0 --- /dev/null +++ b/backend/docs/_master/games/wheelOfFortune.md @@ -0,0 +1,18 @@ +!> By default game is **disabled** + +## Start wheel of fortune + +`!wof` + +!> Default permission is **VIEWERS** + +!> To be able to use this command, broadcaster must have wof overlay + +## Settings + +Wheel of fortune needs to be properly set through UI. + +`!enable game wheelOfFortune` | +`!disable game wheelOfFortune` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/_master/howto/connection-to-socket.md b/backend/docs/_master/howto/connection-to-socket.md new file mode 100644 index 000000000..61f6209df --- /dev/null +++ b/backend/docs/_master/howto/connection-to-socket.md @@ -0,0 +1,15 @@ +?> To get your unique socket token, go to UI -> settings -> Bot -> Socket + +!> Socket token grants you full access through socket! + +## How to authorize on socket + +```javascript +// Connect to socket.io +import io from 'socket.io-client'; + +const namespace = "/" +const token = ''; + +const socket = io(namespace, { forceNew: true, query: { token } }); +``` diff --git a/backend/docs/_master/howto/eval.md b/backend/docs/_master/howto/eval.md new file mode 100644 index 000000000..4ee15594c --- /dev/null +++ b/backend/docs/_master/howto/eval.md @@ -0,0 +1,53 @@ +These `eval` snippets are working with custom commands responses + +#### Available eval variables +**_**: lodash - see https://lodash.com/docs/ + +**is**: sender informations - is.subscriber, is.mod, is.online + +**random**: same values as random filter - random.viewer, random.online.viewer....if no user is selected -> null + +**sender**: current user who evoked eval + +**param**: param of command + +**users**: list of all users in db + +#### Available eval functions +###### url() + - returns loaded axios response object + - e.g `let api = url('https://jsonplaceholder.typicode.com/posts')` + +### 8ball game +**command:** `!8ball` + +**response:** `$sender asks '(param)' - (eval var sayings = ['Signs point to yes.', 'Yes.', 'Reply hazy, try again.', 'My sources say no.', 'You may rely on it.', 'Concentrate and ask again.', 'Outlook not so good.', 'It is decidedly so.', 'Better not tell you now.', 'Very doubtful.', 'Yes - definitely.', 'It is certain.', 'Cannot predict now.', 'Most likely.', 'Ask again later.', 'My reply is no.', 'Outlook good.', "Don't count on it."]; return sayings[Math.floor(Math.random() * sayings.length)];)` + +### Multitwitch url generator +**command:** `!multi` + +**response:** `(eval if (param.length === 0) return ''; else return 'http://multitwitch.tv/' + param.replace(/ /g, '/');)` + +### Love command + +**command:** `!love` + +**response:** `There is a (random.number-0-to-100)% chance of love between $sender and $param` + +### Custom variable increment +#### Custom variable command +**command:** `!testvariable ` + +**response:** `$_test` + +#### Eval command +**command:** `!inc` + +**response:** `(eval return '(!testvariable ' + (parseInt('$_test', 10)+1) + ')')` + +**response 2 (if you want quiet command):** `(eval return '(!!testvariable ' + (parseInt('$_test', 10)+1) + ')')` + +#### Shoutout command +**command:** `!shoutout` + +**response:** `Shoutout to $param! Playing (stream|$param|game) - (stream|$param|title) for (stream|$param|viewers) viewers! Check it out (stream|$param|link)!` \ No newline at end of file diff --git a/backend/docs/_master/howto/run-random-command.md b/backend/docs/_master/howto/run-random-command.md new file mode 100644 index 000000000..c1cd332b0 --- /dev/null +++ b/backend/docs/_master/howto/run-random-command.md @@ -0,0 +1,55 @@ +!> You need to have **alias** system enabled + +## Example (random) + +### Custom variable $_randomCommands + +For randomizing commands, you will need to create **eval** custom variable with +run script set to **When variable is used** + +```javascript +// set of commands +const commands = [ + '!me', + '!top time', + '!points', +]; + +// return random command +return _.sample(_.shuffle(commands)); +``` + +### Alias configuration + +Create alias `!youraliashere` with response `$_randomCommands`, this will +trigger random command. + +## Example (unique) + +### Custom variable $_randomCommandsUnique + +For randomizing commands, you will need to create **eval** custom variable with +run script set to **When variable is used** + +```javascript +// _current variable comes from bot containing current value + +// set of commands +const commands = [ + '!me', + '!top time', + '!points', +]; + +// return random command +let unique = _current +while (unique === _current) { + unique = _.sample(_.shuffle(commands)); +} +return unique; +``` + +### Alias configuration + +Create alias `!youraliashere2` with response `$_randomCommandsUnique`, this will +trigger random command. \ No newline at end of file diff --git a/backend/docs/_master/howto/write-own-system.md b/backend/docs/_master/howto/write-own-system.md new file mode 100644 index 000000000..8a753e210 --- /dev/null +++ b/backend/docs/_master/howto/write-own-system.md @@ -0,0 +1,193 @@ +!> This guide is for **advanced** users. + +* new systems must be in `/src/bot/systems/` or `/src/bot/games/` folder + +> Games are set as opt-in, by default they are disabled + +## System template + +``` typescript +// bot libraries +const constants = require('../constants') +import System from './_interface'; +import { command, default_permission, parser, settings, ui, shared } from '../decorators'; +import { permissions } from '../permission'; // set of core permissions + +class Yoursystem extends System { + public dependsOn: string[] = [] + + @settings('myCategory') + yourSettingsVariableInMyCategory: string = 'lorem' + + @settings() + yourSettingsVariableWithoutMyCategory: string = 'ipsum' +} + +export default Yoursystem; +``` + +### Disable system by default + +``` typescript +class Yoursystem extends System { + _enabled: boolean = false; + + // ... +} +``` + + +### Depends on different system + +Some systems have dependencies, e.g. bet system cannot work without points system + +``` typescript +class Yoursystem extends System { + public dependsOn: string[] = ['systems.points'] + + // ... +} +``` + +### Settings variable + +**@settings(category?: string)** variable may contain settings for `yoursystem`, +customizable through ui and are saved in db + +``` typescript +class Yoursystem extends System { + @settings('myCategory') + yourSettingsVariableInMyCategory: string = 'lorem' + + @settings() + yourSettingsVariableWithoutMyCategory: string = 'ipsum' + // ... +} +``` + +### Shared variable + +**@shared()** variables are shared through workers and should be correctly accesible +in master and worker + +``` typescript +class Yoursystem extends System { + @shared() + yourSharedVariableShouldBeSameAcrossThreads: string = 'lorem' + // ... +} +``` + +#### Commands + +To define function, which should be command, you must use decorator **@command**. +To override default permission for viewers, use **@default_permission**. +For setting helper function (e.g. price check is skipped for this command) use **@helper**. + +``` javascript +@command('!yourcommand') +@default_permission(defaultPermissions.CASTERS) +@helper() +public foobar(opts: CommandOptions): CommandResponse[] { + // ... command logic ... +} +``` + +#### Parsers + +To define function, which should be command, you must use decorator **@parser**. + +##### Parser options + +* `fireAndForget`: if parser should run in background and we don't care about + result and will not rollback, e.g. stats counting. `false` +* `priority`: what priority should be given to parser, higher priority, sooner + it will run. `constants.LOW` +* `permission`: sets default permission for parser. `defaultPermissions.VIEWERS` + +``` typescript +@parser() +public someParser(opts: ParserOptions) { + // ... parser logic ... +} + +@parser({ fireAndForget: true }) +public anotherParser(opts: ParserOptions) { + // ... parser logic ... +} +``` + +## Database collections + +In systems, you can use `this.collection` object variable to be consistent +in collection names. + +!> You cannot use `this.collection`, but you need to specify category `this.collection.category` + +### Examples with `yoursystem` + +`this.collection.data` -> `systems.yoursystem` + +`this.collection.users` -> `systems.yoursystem.users` + +`this.collection.settings` -> `systems.yoursystem.settings` + +## Command function + +Command function have `opts` object parameter + +``` javascript +function commandFunction(opts) { + /* + opts: { + sender: , + command: , + parameters: + } + */ +} +``` + +## Parser function + +Parser function have `opts` object parameter. Must return **true** or **false**. +Return **false** will halt all next parser and commands. + +``` javascript +function parserFunction(opts) { + /* + opts: { + sender: , + message: , + skip: true/false + } + */ + + return true + // return false +} +``` + +## Locales + +Bot is supporting custom locales (by default **english** and **čeština** are supported). +To create new locale file add **json** file into `/locales/` folder. + +``` javascript +import { prepare } from '../commons'; + +function someCommandFunctionExample(opts) { + // given we have defined path.to.your.locale with value + // Lorem Ipsum $dolor sit amet + + // default locale translations + const defaultTranslation = global.translate('path.to.your.locale') + // => Lorem Ipsum $dolor sit amet + + // locale translation with attributes + const translation = prepare('path.to.your.locale', { + dolor: 'something' + }) + // => Lorem Ipsum something sit amet +} +``` \ No newline at end of file diff --git a/backend/docs/_master/install-and-upgrade.md b/backend/docs/_master/install-and-upgrade.md new file mode 100644 index 000000000..1d2837b1a --- /dev/null +++ b/backend/docs/_master/install-and-upgrade.md @@ -0,0 +1,126 @@ +## Prerequisites + +- **Browsers**: only latest Chrome/Chromium stable are supported +- **[Node.js](https://nodejs.org/en/)**: **18.x LTS** version +- **RAM**: Minimum 512MB, Recommended 1024MB +- **HDD**: Minimum 500MB +- Twitch bot account + +!> You need **separate** account for your bot, bot **won't** work on your + broadcaster account + +## Docker + +### Docker prerequisites + +- **Docker**, Any of the [supported repositories](http://sogebot.github.io/sogeBot/#/configuration/database) + +### Docker installation + +!> If you want to use **SQLite**, be sure to use `./shared/sogebot.db` path to + your db file, so you have an access outside of docker. + +!> Note that **localhost** is accessing docker localhost. You need to use full + IP address for your database connections. + +1. Download `Docker Compose` files + - From GIT: `git clone git@github.com:sogebot/sogeBot-docker.git` + - Without GIT as [ZIP](https://github.com/sogehige/sogeBot-docker/archive/master.zip) +2. Configure properly .env file in `root` directory + - You can find examples at [our GitHub repository](https://github.com/sogebot/sogeBot/tree/master/src/data) +3. Download bot images with `docker compose` + - Release version: `docker-compose pull` + - Nightly version: `docker-compose -f docker-compose-nightly.yml pull` +4. Startup your bot (add -d if you want to detach process) + - Release version: `docker-compose up` + - Nightly version: `docker-compose -f docker-compose-nightly.yml up` + +### Upgrade bot from Docker + +1. Stop your docker container +2. Run steps 3. and 4. from Installation + +## From ZIP + +### Stable + +- Download latest release from + [GitHub sogeBot release page](https://github.com/sogebot/sogeBot/releases) +- Continue at [ZIP Installation](#zip-installation) + +### Nightlies + +- Download desired nightly version from [GitHub sogeBot nightlies page](https://github.com/sogebot/sogeBot/actions?query=workflow%3ANightlies) +- Select run, you want to use (newest first) + +![create-new-app](./_images/install/nightlies.png) + +- Scroll down and download nightly artifact + +![create-new-app](./_images/install/artifact.png) + +- Continue at [ZIP Installation](#zip-installation) + +### ZIP Installation + +- Download your stable release or nightly (see above) +- Set your [database environment](configuration/database) +- Add bot as channel editor + in [Permissions settings](http://twitch.tv/dashboard/permissions) on Twitch +- be sure that you have latest npm installed + + `npm install --location=global npm@latest` + +- before starting a bot, you need to install npm dependencies + + `npm install` + +- start bot + + `npm start` + +- To access webpanel, go to `http://localhost:` where port is configured + as PORT env variable, e.g. `PORT=20001 npm start` + +### Upgrade bot from ZIP + +1. Backup your `.env` and, if using sqlite3, `sogebot.db` file +2. Remove your sogeBot directory +3. Go through Installation steps +4. Before `npm start` recopy your backup back to bot folder + +## From GIT + +### Build prerequisites + +- **Bash**, **Make**, **Git** + +### GIT Installation + +- Download [latest master zip](https://github.com/sogebot/sogeBot/archive/master.zip) + or clone repository `git clone https://github.com/sogebot/sogeBot.git` +- Set your [database environment](configuration/database) +- Add bot as channel editor + in [Permissions settings](http://twitch.tv/dashboard/permissions) on Twitch +- be sure that you have latest npm installed + + `npm install --location=global npm@latest` + +- before starting a bot, you need to build a bot + + `make` + +- start bot + + `npm start` + +- To access webpanel, go to `http://localhost:` where PORT is environment + variable with default value `20000` + +### Upgrade bot from GIT + +1. Backup your database +2. Update bot with `git pull -r origin master` +3. Run `npm install --location=global npm` +4. Run `make` +5. Start bot `npm start` diff --git a/backend/docs/_master/integrations/lastfm.md b/backend/docs/_master/integrations/lastfm.md new file mode 100644 index 000000000..fdef3c073 --- /dev/null +++ b/backend/docs/_master/integrations/lastfm.md @@ -0,0 +1,9 @@ +Current integration is enabling `$lastfmSong` + +## Create your own twitter application + +- go to [Last.fm API creation](https://www.last.fm/api/account/create) +- create new app +![create-api-account](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/lastfm/create-api-account.png) +- copy your API key and username to bot +![copy-api-key](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/copy-api-key.png) \ No newline at end of file diff --git a/backend/docs/_master/integrations/spotify.md b/backend/docs/_master/integrations/spotify.md new file mode 100644 index 000000000..63096a6c8 --- /dev/null +++ b/backend/docs/_master/integrations/spotify.md @@ -0,0 +1,128 @@ +Current integration is enabling `$spotifySong` and song requests(PREMIUM) from Spotify + +!> Spotify WEB Api is often bugged. Although bot offer functionality to skip, + request songs, there *may* be issues with connection to spotify. Which is on spotify + side. + +## How to setup + +1. Go to +2. Log In into your account +3. Create your application + + ![1](../_images/spotify/1.png ':size=300') + ![2](../_images/spotify/2.png ':size=300') + +4. As your app is in development mode, you need to add user to this app + + ![3](../_images/spotify/3.png ':size=300') + ![4](../_images/spotify/4.png ':size=300') + ![5](../_images/spotify/5.png ':size=300') + ![6](../_images/spotify/6.png ':size=300') + +4. Add Client ID and Client Secret to a bot + + ![7](../_images/spotify/7.png ':size=300') + +5. Add Redirect URI to Spotify and a Bot - redirect URI is where you access a bot. + By default `http://localhost:20000/credentials/oauth/spotify` | + **DON'T FORGET TO SAVE ON SPOTIFY** + + ![8](../_images/spotify/8.png ':size=300') + ![9](../_images/spotify/9.png ':size=300') + +6. Enable integration in a bot +7. Authorize user in a bot + + ![10](../_images/spotify/10.png ':size=300') + +8. Done, user is authorized + +## Request song through !spotify command - PREMIUM users only + +`!spotify ` or `!spotify ` or `!spotify ` + +!> Default permission is **DISABLED** + +### Parameters + +- `` - spotify URI of a song you want to play, e.g. `spotify:track:14Vp3NpYyRP3cTu8XkubfS` +- `` - song to search on spotify (will pick first found item), e.g. + `lion king` +- `` - song link, e.g. + `https://open.spotify.com/track/14Vp3NpYyRP3cTu8XkubfS?si=7vJWxZJdRu2VsBdvcVdAuA` + +### Examples + +
+ testuser: !spotify spotify:track:0GrhBz0am9KFJ20MN9o6Lp
+ bot: @testuser, you requested song + Circle of Life - 『ライオン・キング』より from Carmen Twillie +
+ +
+ testuser: !spotify lion king circle of life
+ bot: @testuser, you requested song + Circle of Life - 『ライオン・キング』より from Carmen Twillie +
+ +## Ban current song through !spotify ban command + +`!spotify ban` + +!> Default permission is **DISABLED** + +### Examples + +
+ testuser: !spotify ban
+ bot: @testuser, song + Circle of Life - 『ライオン・キング』より from Carmen Twillie was banned. +
+ +## Unban song through !spotify unban command + +`!spotify unban ` or `!spotify unban ` + +!> Default permission is **DISABLED** + +### Parameters + +- `` - spotify URI of a song you want to unban, e.g. `spotify:track:14Vp3NpYyRP3cTu8XkubfS` +- `` - song link, e.g. + `https://open.spotify.com/track/14Vp3NpYyRP3cTu8XkubfS?si=7vJWxZJdRu2VsBdvcVdAuA` + +### Examples + +
+ testuser: !spotify unban spotify:track:0GrhBz0am9KFJ20MN9o6Lp
+ bot: @testuser, song + Circle of Life - 『ライオン・キング』より from Carmen Twillie was unbanned. +
+ +## Song history with !spotify history command + +`!spotify history` or `!spotify history ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` - how many of songs should be returned in history command, if + omitted, it will show only last song, maximum 10. + +### Examples + +
+ testuser: !spotify history
+ bot: @testuser, previous song was + Circle of Life - 『ライオン・キング』より from Carmen Twillie. +
+ + +
+ testuser: !spotify history 2
+ bot: @testuser, 2 previous songs were:
+ bot: 1 - Circle of Life - 『ライオン・キング』より from Carmen Twillie
+ bot: 2 - The Wolven Storm (Priscilla's Song) from Alina Gingertail. +
diff --git a/backend/docs/_master/integrations/twitter.md b/backend/docs/_master/integrations/twitter.md new file mode 100644 index 000000000..2fe15aaa1 --- /dev/null +++ b/backend/docs/_master/integrations/twitter.md @@ -0,0 +1,32 @@ +## Create your own twitter application + +- go to [Twitter Dev Dashboard](https://apps.twitter.com/) +- create new app + +![create-new-app](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/create-new-app.png ':size=300') + +- name your App and push complete button + +![create-an-application](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/create-an-application.png ':size=300') + +- edit your app permission + +![edit-app-permission](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/edit-app-permission.png ':size=300') + +- select **Read / Write / Direct Messag +es permission** and save + +![app-permission](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/app-permission.png ':size=300') + +- go to **Keys and tokens** tab + +![keys-and-token](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/keys-and-token.png ':size=300') + +- generate **Access Token & Secret** + +![generate-access-token](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/generate-access-token.png ':size=300') + +- copy your tokens to a bot + +![copy-access-token](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/copy-access-token.png ':size=300') + diff --git a/backend/docs/_master/overlays/eventlist.md b/backend/docs/_master/overlays/eventlist.md new file mode 100644 index 000000000..28bfa61f2 --- /dev/null +++ b/backend/docs/_master/overlays/eventlist.md @@ -0,0 +1,9 @@ +You can define several custom properties for your eventlist which are set through URL + +**Example:** `localhost:20000/overlays/eventlist?order=asc&ignore=host,cheer&count=5` + +**Properties:** +- order - asc, desc +- ignore - host, cheer, follow, sub, resub +- count - default: 5 +- display - default: username,event - set in which order and what to show in eventlist \ No newline at end of file diff --git a/backend/docs/_master/overlays/themes.md b/backend/docs/_master/overlays/themes.md new file mode 100644 index 000000000..6ff2aca2a --- /dev/null +++ b/backend/docs/_master/overlays/themes.md @@ -0,0 +1,23 @@ +?> If you have a theme of your own and want to share, don't be afraid to [contribute](https://github.com/sogebot/sogeBot/issues/new?title=Theme:%20your%20theme%20name%20here)! + +# Stats +**Note:** To hide unwanted stat, add this to your style (where \ is viewers, uptime, followers, bits, subscribers) + + span. { + display: none !important; + } + +## PlayerUnknown's Battlegrounds +### Horizontal +![](http://imgur.com/Ub2uK9q.png) + +Copy [styles](https://drive.google.com/open?id=0B-_RLmmL4nXnXzNFR2t2bEJDV28) to OBS + +### Vertical +![](http://imgur.com/tVm2ruz.png) + +Copy [styles](https://drive.google.com/file/d/0B-_RLmmL4nXnMkRxQS0xMjAwU28/view?usp=sharing) to OBS + +# Replays +## Add shadow for video (OBS style) +`.replay { box-shadow: 0px 0px 20px #888888; }` diff --git a/backend/docs/_master/registries/images/registriesRandomizerEntry.png b/backend/docs/_master/registries/images/registriesRandomizerEntry.png new file mode 100644 index 000000000..abe02210f Binary files /dev/null and b/backend/docs/_master/registries/images/registriesRandomizerEntry.png differ diff --git a/backend/docs/_master/registries/randomizer.md b/backend/docs/_master/registries/randomizer.md new file mode 100644 index 000000000..08d691fcf --- /dev/null +++ b/backend/docs/_master/registries/randomizer.md @@ -0,0 +1,54 @@ +## How to use randomizer command + +1. Create your randomizer in **UI -> Registry -> Randomizer** (see example below) + +![Example of randomizer entry](images/registriesRandomizerEntry.png "Example of randomizer entry") + +2. Now we can use **!myrandomizer** command + +### Command usage + +#### Show / Hide randomizer in overlay + +!> Only one randomizer can be shown in overlay. + +!> Randomizer **won't autohide** after while even after spin. + +`!myrandomizer` + +
+ randomizer is hidden
+ owner: !myrandomizer
+ show randomizer in overlay
+ owner: !myrandomizer
+ hide randomizer in overlay
+
+ +#### Start spin of randomizers + +`!myrandomizer go` + +##### Example 1 (manual randomizer show) + +
+ randomizer is hidden
+ owner: !myrandomizer
+ show randomizer in overlay
+ owner: !myrandomizer go
+ randomizer will start to spin / randomize immediately
+ owner: !myrandomizer
+ hide randomizer in overlay
+
+ +##### Example 2 (auto randomizer show) + +!> Auto show is not working in widget, you need to **manually** trigger show of randomizer + +
+ randomizer is hidden
+ owner: !myrandomizer go
+ randomizer will show and start to spin / randomize + after 5 seconds
+ owner: !myrandomizer
+ hide randomizer in overlay
+
diff --git a/backend/docs/_master/services/google.md b/backend/docs/_master/services/google.md new file mode 100644 index 000000000..a7d370dd2 --- /dev/null +++ b/backend/docs/_master/services/google.md @@ -0,0 +1,5 @@ +You can find the settings for that Service in Settings => Modules + +- create service account [Google Cloud Platform](https://console.cloud.google.com/apis/credentials) +- add key and download +- upload file to bot \ No newline at end of file diff --git a/backend/docs/_master/services/twitch.md b/backend/docs/_master/services/twitch.md new file mode 100644 index 000000000..90f2fc034 --- /dev/null +++ b/backend/docs/_master/services/twitch.md @@ -0,0 +1,57 @@ +You can find the settings for that Service in Settings => Modules + +### oAuth + +__Token Generator__ + +Here you can select what Tokengenerator is used for Auth + +__Owners__ + +List of Users with Owner permission +(one user per line) + +### Bot +Here you have to put auth tokens for Bot account + +### Channel +Here you have to put auth tokens from Broadcaster account + +### TMI + +__Title/Game is forced__ + +Bot will force Title and Game (Category) on twitch +(bot forces its setting prior to all other tools) + +__Send Messages with /me__ + +send all messages as Actions in chat + +__Bot is muted__ + +Bot will not send responses to chat + +__Listen on commands on wispers__ + +Bot will accept wisper commands + +__Show users with @__ + +Bot will prefix usernames with @ in responses + +__Send bot messages as replies__ + +Bot will use twitchs message reply function for its responses to users + +__Ignore List__ + +Users in this list will be ignored from Bot + +__Exclude from global ignore list__ + +Bot will respond to this users even if they in global ignore list (list is provided with bot) + +__Eventsub__ + +For use of EventSub you need to have SSL enabled domain and created Twitch App \ No newline at end of file diff --git a/backend/docs/_master/systems/alias.md b/backend/docs/_master/systems/alias.md new file mode 100644 index 000000000..9c42c4dbe --- /dev/null +++ b/backend/docs/_master/systems/alias.md @@ -0,0 +1,241 @@ +## Add a new alias + +`!alias add (-p ) -a -c ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-a ` + - alias to be added +- `-c ` + - command to be aliased + +### Examples + +
+ testuser: !alias add -p viewers -a !uec -c !points
+ bot: @testuser, alias !uec for !points was added +
+ +## Edit an alias + +`!alias edit (-p ) -a -c ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-a ` + - alias to be added +- `-c ` + - command to be aliased + +### Examples + +
+ testuser: !alias edit -p viewers -a !uec -c !me
+ bot: @testuser, alias !uec is changed to !me +
+ +
+ testuser: !alias edit viewer !nonexisting !points
+ bot: @testuser, alias !nonexisting was not found in database +
+ +## Remove an alias + +`!alias remove ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` - alias to be removed + +### Examples + +
+ testuser:!alias remove !uec
+ bot: @testuser, alias !uec2 was removed +
+ +
+ testuser: !alias remove !ueca
+ bot: @testuser, alias !ueca was not found in database +
+ +## List of aliases + +`!alias list` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser:!alias list
+ bot: @testuser, list of aliases: !uec +
+ +## Enable or disable alias + +`!alias toggle ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` - alias to be enabled or disabled + +### Examples + +
+ testuser:!alias toggle !uec
+ bot: @testuser, alias !uec was disabled +
+ +
+ testuser:!alias toggle !uec
+ bot: @testuser, alias !uec was enabled +
+ +## Toggle visibility of alias in lists + +`!alias toggle-visibility ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - alias to be exposed or concealed + +### Examples + +
+ testuser:!alias toggle !uec
+ bot: @testuser, alias !uec was concealed +
+ +
+ testuser:!alias toggle !uec
+ bot: @testuser, alias !uec was exposed +
+ +## Set an alias to group + +`!alias group -set -a ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - group to be set +- `` - alias to be set into group + +### Examples + +
+ testuser:!alias group -set voice -a !yes
+ bot: @testuser, alias !yes was set to group voice +
+ +## Unset an alias group + +`!alias group -unset ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - alias to be unset from group + +### Examples + +
+ testuser:!alias group -unset !yes
+ bot: @testuser, alias !yes group was unset +
+ +## List all alias groups + +`!alias group -list` + +!> Default permission is **OWNER** + +### Examples + +
+ testuser:!alias group -list
+ bot: @testuser, list of aliases groups: voice +
+ +## List all aliases in group + +`!alias group -list ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - group to list aliases + +### Examples + +
+ testuser:!alias group -list voice
+ bot: @testuser, list of aliases in voice: !yes +
+ +## Enable aliases in group + +`!alias group -enable ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - group of aliases to enable + +### Examples + +
+ testuser:!alias group -enable voice
+ bot: @testuser, aliases in voice enabled. +
+ +## Disable aliases in group + +`!alias group -disable ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - group of aliases to disable + +### Examples + +
+ testuser:!alias group -disable voice
+ bot: @testuser, aliases in voice disabled. +
+ +## Other settings + +### Enable or disable alias system + +`!enable system alias` | +`!disable system alias` + +!> Default permission is **OWNER** diff --git a/backend/docs/_master/systems/bets.md b/backend/docs/_master/systems/bets.md new file mode 100644 index 000000000..1e23991ea --- /dev/null +++ b/backend/docs/_master/systems/bets.md @@ -0,0 +1,25 @@ +## Create a new bet + +`!bet open [-timeout 120] -title "Your title here" Option 1 | Option 2 | ...` + +- timeout is set in seconds + +!> Default permission is **MODERATORS** + +## Reuse last bet + +`!vet reuse` + +!> Default permission is **MODERATORS** + +## Lock bet + +`!bet lock` + +!> Default permission is **MODERATORS** + +## Close bet + +`!bet close [idx]` + +!> Default permission is **MODERATORS** diff --git a/backend/docs/_master/systems/commercial.md b/backend/docs/_master/systems/commercial.md new file mode 100644 index 000000000..decb2937b --- /dev/null +++ b/backend/docs/_master/systems/commercial.md @@ -0,0 +1,32 @@ +##### Changelog +| Version | Description | +| --------|:--------------------------------------| +| 8.0.0 | Updated docs | + + +## Run a commercial +`!commercial ` + +!> Default permission is **OWNER** + +### Parameters +- `` - length of commercial break, valid values are 30, 60, 90, 120, 150, 180 + +### Examples + +
+ testuser: !commercial 30
+ ... no response on success ... +
+ +
+ testuser: !commercial 10
+ bot: @testuser, available commercial duration are: 30, 60, 90, 120, 150 and 180 +
+ +## Other settings +### Enable or disable commercial system +`!enable system commercial` | +`!disable system commercial` + +!> Default permission is **OWNER** diff --git a/backend/docs/_master/systems/cooldowns.md b/backend/docs/_master/systems/cooldowns.md new file mode 100644 index 000000000..49d9de581 --- /dev/null +++ b/backend/docs/_master/systems/cooldowns.md @@ -0,0 +1,15 @@ +## Cooldowns system +`!cooldown set ` + +- **OWNER** - set cooldown for command or keyword (per user or global), true/false sets whisper message +- If your command have subcommand, use quote marks, e.g. `!cooldown '!test command' user 60 true` + +`!cooldown unset ` - **OWNER** - unset cooldown for command or keyword + +`!cooldown toggle moderators ` - **OWNER** - enable/disable specified keyword or !command cooldown for moderators (by default disabled) + +`!cooldown toggle owners ` - **OWNER** - enable/disable specified keyword or !command cooldown for owners (by default disabled) + +`!cooldown toggle subscribers ` - **OWNER** - enable/disable specified keyword or !command cooldown for subscribers (by default enabled) + +`!cooldown toggle enabled ` - **OWNER** - enable/disable specified keyword or !command cooldown \ No newline at end of file diff --git a/backend/docs/_master/systems/custom-commands.md b/backend/docs/_master/systems/custom-commands.md new file mode 100644 index 000000000..c5074760b --- /dev/null +++ b/backend/docs/_master/systems/custom-commands.md @@ -0,0 +1,251 @@ +## Add a new command + +`!command add (-p ) (-s) -c -r ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-s` + - *optional boolean* - stop execution after response is sent + - *default value:* false +- `-c ` + - command to be added +- `-r ` + - response to be set + +### Examples + +
+ testuser: !command add -c !test -r me
+ bot: @testuser, command !test was added. +
+ +
+ / create command only for mods /
+ testuser: !command add -p mods -c !test -r me
+ bot: @testuser, command !test was added. +
+ +
+ / create command only for mods and stop if executed /
+ testuser: !command add -p mods -s true -c !test -r me
+ bot: @testuser, command !test was added. +
+ +## Edit a response of command + +`!command edit (-p ) (-s) -c -rid -r ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-s` + - *optional boolean* - stop execution after response is sent + - *default value:* false +- `-c ` + - command to be edited +- `-rid ` + - response id to be updated +- `-r ` + - response to be set + +### Examples + +
+ testuser: !command edit -c !test -rid 1 -r me
+ bot: @testuser, command !test is changed to 'me' +
+ +
+ / set command only for mods /
+ testuser: !command edit -p mods -c !test -rid 1 -r me
+ bot: @testuser, command !test is changed to 'me' +
+ +
+ / set command only for mods and stop if executed /
+ testuser: !command edit -p mods -s true -c !test -rid 1 -r me
+ bot: @testuser, command !test is changed to 'me' +
+ +## Remove a command or a response + +`!command remove -c (-rid )` + +!> Default permission is **CASTERS** + +### Parameters + +- `-c ` + - command to be removed +- `-rid ` + - *optional* + - response id to be updatedremoved + +### Examples + +
+ testuser: !command remove -c !test
+ bot: @testuser, command !test was removed +
+ +
+ testuser: !command remove -c !nonexisting
+ bot: @testuser, command !test was not found +
+ +
+ testuser: !command remove -c !test -rid 1
+ bot: @testuser, command !test was removed +
+ +
+ testuser: !command remove -c !test -rid 2
+ bot: @testuser, response #2 of command !test + was not found in database +
+ +## List of commands + +`!command list` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser: !command list
+ bot: @testuser, list of commands: !command1, !command2 +
+ +## List of command responses + +`!command list !command` + +!> Default permission is **CASTERS** + +### Output + +!`command`#`responseId` (for `permission`) `stop`| `response` + +### Examples + +
+ testuser: !command list !test
+ bot: !test#1 (for viewers) v| Some response of command
+ bot: !test#2 (for mods) _| Some response of command +
+ +## What is stop execution after response + +In certain situations, you may have several responses based on permission. +Some users have higher permission then others. If response with +this settings is executed, all responses below this response will +be ignored. + +### Example without stop + +#### Responses in command !test + +- `owner` - response1 +- `mods` - response2 +- `viewers` - response3 + +
+ owneruser: !test
+ bot: response1
+ bot: response2
+ bot: response3
+
+ +
+ moduser: !test
+ bot: response2
+ bot: response3
+
+ +
+ vieweruser: !test
+ bot: response3
+
+ +### Example with stop + +#### Responses in command !test + +- `owner` - response1 +- `mods` - response2 - `stop here` +- `viewers` - response3 + +
+ owneruser: !test
+ bot: response1
+ bot: response2
+
+ +
+ moduser: !test
+ bot: response2
+
+ +
+ vieweruser: !test
+ bot: response3
+ / response 3 is returned, because response2 was not, so + execution was not stopped! / +
+ +## Command filters + +You can add filter for commands through UI. All filters are checked by **javascript** +engine. + +### Available filters + +Available filters can be found in UI. + +#### Examples + +`$sender == 'soge__'` - run command only for soge__ + +`$source == 'discord'` - run command only if comes from discord + +`$game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run command only when PUBG is set +as game + +`$sender == 'soge__' && $game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run command +only for soge__ **and** when game is set to PUBG + +`$sender == 'soge__' || $game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run command +only for soge__ **or** when game is set to PUBG + +`$subscribers >= 10` - run command when current subscribers count is equal or +greater than 10 + +#### Examples (advanced) + +`$game.toLowerCase() == 'playerunknown's battlegrounds'` - run command only when +PUBG is set as game + +`['soge__', 'otheruser'].includes($sender)` - check if sender is soge__ or otheruser + +## Other settings + +### Enable or disable custom commands system + +`!enable system customCommands` | +`!disable system customCommands` + +!> Default permission is **CASTERS** \ No newline at end of file diff --git a/backend/docs/_master/systems/highlights.md b/backend/docs/_master/systems/highlights.md new file mode 100644 index 000000000..d3a3f6bde --- /dev/null +++ b/backend/docs/_master/systems/highlights.md @@ -0,0 +1,4 @@ +## Highlights system +`!highlight` - save highlight timestamp + +`!highlight list` - get list of highlights in current running or latest stream \ No newline at end of file diff --git a/backend/docs/_master/systems/keywords.md b/backend/docs/_master/systems/keywords.md new file mode 100644 index 000000000..7b0c1bfd7 --- /dev/null +++ b/backend/docs/_master/systems/keywords.md @@ -0,0 +1,257 @@ +## Add a new keyword + +`!keyword add (-p ) (-s) -k -r ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-s` + - *optional boolean* - stop execution after response is sent + - *default value:* false +- `-k ` + - keyword or regexp to be added +- `-r ` + - response to be set + +### Examples + +
+ testuser: !keyword add -k test -r me
+ bot: @testuser, keyword test (7c4fd4f3-2e2a-4e10-a59a-7f149bcb226d) was added. +
+ +
+ / create keyword as regexp /
+ testuser: !keyword add -k (lorem|ipsum) -r me
+ bot: @testuser, keyword (lorem|ipsum) was added. +
+ +
+ / create keyword only for mods /
+ testuser: !keyword add -p mods -k test -r me
+ bot: @testuser, keyword test (7c4fd4f3-2e2a-4e10-a59a-7f149bcb226d) was added. +
+ +
+ / create keyword only for mods and stop if executed /
+ testuser: !keyword add -p mods -s true -k test -r me
+ bot: @testuser, keyword test (7c4fd4f3-2e2a-4e10-a59a-7f149bcb226d) was added. +
+ +## Edit a response of keyword + +`!keyword edit (-p ) (-s) -k -rid -r ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-s` + - *optional boolean* - stop execution after response is sent + - *default value:* false +- `-k ` + - keyword/uuid/regexp to be edited +- `-rid ` + - response id to be updated +- `-r ` + - response to be set + +### Examples + +
+ testuser: !keyword edit -k test -rid 1 -r me
+ bot: @testuser, keyword test is changed to 'me' +
+ +
+ / set keyword only for mods /
+ testuser: !keyword edit -p mods -k test -rid 1 -r me
+ bot: @testuser, keyword test is changed to 'me' +
+ +
+ / set keyword only for mods and stop if executed /
+ testuser: !keyword edit -p mods -s true -k test -rid 1 -r me
+ bot: @testuser, keyword test is changed to 'me' +
+ +## Remove a keyword or a response + +`!keyword remove -k (-rid )` + +!> Default permission is **CASTERS** + +### Parameters + +- `-k ` + - keyword/uuid/regexp to be removed +- `-rid ` + - *optional* + - response id to be updatedremoved + +### Examples + +
+ testuser: !keyword remove -k test
+ bot: @testuser, keyword test was removed +
+ +
+ testuser: !keyword remove -c !nonexisting
+ bot: @testuser, keyword test was not found +
+ +
+ testuser: !keyword remove -k test -rid 1
+ bot: @testuser, keyword test was removed +
+ +
+ testuser: !keyword remove -k test -rid 2
+ bot: @testuser, response #2 of keyword test + was not found in database +
+ +## List of keywords + +`!keyword list` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser: !keyword list
+ bot: @testuser, list of keywords: !keyword1, !keyword2 +
+ +## List of keyword responses + +`!keyword list !keyword` + +!> Default permission is **CASTERS** + +### Output + +!`keyword`#`responseId` (for `permission`) `stop`| `response` + +### Examples + +
+ testuser: !keyword list test
+ bot: test#1 (for viewers) v| Some response of keyword
+ bot: test#2 (for mods) _| Some response of keyword +
+ +## What is stop execution after response + +In certain situations, you may have several responses based on permission. +Some users have higher permission then others. If response with +this settings is executed, all responses below this response will +be ignored. + +### Example without stop + +#### Responses in keyword test + +- `owner` - response1 +- `mods` - response2 +- `viewers` - response3 + +
+ owneruser: test
+ bot: response1
+ bot: response2
+ bot: response3
+
+ +
+ moduser: test
+ bot: response2
+ bot: response3
+
+ +
+ vieweruser: test
+ bot: response3
+
+ +### Example with stop + +#### Responses in keyword test + +- `owner` - response1 +- `mods` - response2 - `stop here` +- `viewers` - response3 + +
+ owneruser: test
+ bot: response1
+ bot: response2
+
+ +
+ moduser: test
+ bot: response2
+
+ +
+ vieweruser: test
+ bot: response3
+ / response 3 is returned, because response2 was not, so + execution was not stopped! / +
+ +## Command filters + +You can add filter for keywords through UI. All filters are checked by **javascript** +engine. + +### Available filters + +Available filters can be found in UI. + +#### Examples + +`$sender == 'soge__'` - run keyword only for soge__ + +`$source == 'discord'` - run keyword only if comes from discord + +`$game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run keyword only when PUBG is set +as game + +`$sender == 'soge__' && $game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run keyword +only for soge__ **and** when game is set to PUBG + +`$sender == 'soge__' || $game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run keyword +only for soge__ **or** when game is set to PUBG + +`$subscribers >= 10` - run keyword when current subscribers count is equal or +greater than 10 + +#### Examples (advanced) + +`$game.toLowerCase() == 'playerunknown's battlegrounds'` - run keyword only when +PUBG is set as game + +`['soge__', 'otheruser'].includes($sender)` - check if sender is soge__ or otheruser + +## Other settings + +### Enable or disable custom keywords system + +`!enable system keywords` | +`!disable system keywords` + +!> Default permission is **CASTERS** \ No newline at end of file diff --git a/backend/docs/_master/systems/levels.md b/backend/docs/_master/systems/levels.md new file mode 100644 index 000000000..787c76ece --- /dev/null +++ b/backend/docs/_master/systems/levels.md @@ -0,0 +1,55 @@ +## Show your level + +`!level` + +!> Default permission is **VIEWERS** + +### Examples + +
+ testuser: !level
+ bot: @testuser, level: 1 (1030 XP), 1470 XP to next level. +
+ +## Buy a level + +`!level buy` + +!> Default permission is **VIEWERS** + +### Examples + +
+ testuser: !level buy
+ bot: @testuser, you bought 1460 XP with 14600 points and reached + level 2. +
+ +
+ testuser: !level buy
+ bot: Sorry @testuser, but you don't have 234370 UEC to buy + 23437 XP for level 5. +
+ +## Change XP of user + +`!level change ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` - username to change XP +- `` - amount of XP to be added or removed + +### Examples + +
+ owner:!level change testuser 100
+ bot: @owner, you changed XP by 100 XP to @testuser. +
+ +
+ owner:!level change testuser -100
+ bot: @owner, you changed XP by -100 XP to @testuser. +
\ No newline at end of file diff --git a/backend/docs/_master/systems/miscellaneous.md b/backend/docs/_master/systems/miscellaneous.md new file mode 100644 index 000000000..d2dee0e52 --- /dev/null +++ b/backend/docs/_master/systems/miscellaneous.md @@ -0,0 +1,22 @@ +## Miscellaneous settings and commands +`!merge from-user to-user` - will move old username to new username + +`!followage ` - how long is user following channel with optional username + +`!age ` - how old is users account + +`!uptime` - how long your stream is online + +`!subs` - show online subs count, last sub and how long ago + +`!followers` - show last follow and how long ago + +`!lastseen ` - when user send a message on your channel + +`!watched ` - how long user watching your stream with optional username + +`!me` - shows points, rank, watched time of user and message count + +`!stats ` - shows points, rank, watched time of user and message count with optional username + +`!top ` - shows top 10 users ordered by [time|points|messages] \ No newline at end of file diff --git a/backend/docs/_master/systems/moderation.md b/backend/docs/_master/systems/moderation.md new file mode 100644 index 000000000..1deee4993 --- /dev/null +++ b/backend/docs/_master/systems/moderation.md @@ -0,0 +1,37 @@ +## Commands +`!permit ` - **MODS** - user will be able +to post a of link without timeout + +You can allow/forbide words in UI `settings->moderation` + +## Allowing and forbidding words + +### Available special characters + +- asterisk -> `*` - zero or more chars +- plus -> `+` - one or more chars + +#### Examples + +- `test*` + - `test` - ✓ + - `test1` - ✓ + - `testa` - ✓ + - `atest` - X +- `test+` + - `test` - X + - `test1` - ✓ + - `testa` - ✓ + - `atest` - X +- `test` + - `test` - ✓ + - `test1` - X + - `testa` - X + - `atest` - X + +## URL whitelisting + +Use this pattern to whitelist your desired url. Change `example.com` to +what you want. + +`(https?:\/\/)?(www\.)?example.com(*)?` or `domain:example.com` diff --git a/backend/docs/_master/systems/permissions.md b/backend/docs/_master/systems/permissions.md new file mode 100644 index 000000000..165e25cd7 --- /dev/null +++ b/backend/docs/_master/systems/permissions.md @@ -0,0 +1,68 @@ +## List permissions + +`!permission list` + +!> Default permission is **CASTERS** + +### Examples + +
+ caster: !permission list
+ bot: List of your permissions:
+ bot: ≥ | Casters | 4300ed23-dca0-4ed9-8014-f5f2f7af55a9
+ bot: ≥ | Moderators | b38c5adb-e912-47e3-937a-89fabd12393a
+ bot: ≥ | VIP | e8490e6e-81ea-400a-b93f-57f55aad8e31
+ bot: ≥ | Subscribers | e3b557e7-c26a-433c-a183-e56c11003ab7
+ bot: ≥ | Viewers | 0efd7b1c-e460-4167-8e06-8aaf2c170311
+
+ +## Add user to exclude list for permissions + +`!permission exclude-add -p -u ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *available values:* list of permission can be obtained by `!permissions list` + or in UI + - **NOTE:** You cannot add user to exclude list of core permissions like + Viewers, Subscribers, etc. You need to create own permission group. +- `-u ` + - username of user who you wish to add to exclude list + +### Examples + +
+ caster: !permission -p YourOwnPermissionGroup soge
+ bot: caster, you added soge to exclude list for permission YourOwnPermissionGroup
+
+ +
+ caster: !permission exclude-add -p Viewers soge
+ bot: caster, you cannot manually exclude user for core permission Viewers
+
+ +## Add user to exclude list for permissions + +`!permission exclude-rm -p -u ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *available values:* list of permission can be obtained by `!permissions list` + or in UI + - **NOTE:** You cannot remove user to exclude list of core permissions like + Viewers, Subscribers, etc. You need to create own permission group. +- `-u ` + - username of user who you wish to remove from exclude list + +### Examples + +
+ caster: !permission exclude-rm -p YourOwnPermissionGroup soge
+ bot: caster, you removed soge from exclude list for permission YourOwnPermissionGroup
+
diff --git a/backend/docs/_master/systems/points.md b/backend/docs/_master/systems/points.md new file mode 100644 index 000000000..d58672246 --- /dev/null +++ b/backend/docs/_master/systems/points.md @@ -0,0 +1,24 @@ +## Points system +### Commands | OWNER +- !points add [username] [number] + - add [number] points to specified [username] +- !points undo [username] + - revert last add, remove or set points operation within 10minutess +- !points remove [username] [number] + - remove [number] points to specified [username] +- !points online [number] + - give or take [number] points to all **online** users +- !points all [number] + - give or take [number] points to all users +- !points set [username] [number] + - set [number] points to specified [username] +- !points get [username] + - get points of [username] +- !makeitrain [number] + - add maximum of [number] points to all **online** users + +### Commands | VIEWER +- !points give [username] [number] + - viewer can give his own [number] points to another [username] +- !points + - print out points of user \ No newline at end of file diff --git a/backend/docs/_master/systems/polls.md b/backend/docs/_master/systems/polls.md new file mode 100644 index 000000000..1fffc1e62 --- /dev/null +++ b/backend/docs/_master/systems/polls.md @@ -0,0 +1,28 @@ +## Create a new poll + +`!poll open -title "Your title here" Option 1 | Option 2 | ...` + +!> Default permission is **MODERATORS** + +## Reuse last poll + +`!poll reuse` + +!> Default permission is **MODERATORS** + +## Close poll + +`!poll close` + +!> Default permission is **MODERATORS** + +### Examples + +
+ owner: !poll open -title "What is better, Star Citizen + or Elite: Dangerous?" Star Citizen | Elite: Dangerous +
+ +## How to vote + +Voting is done through Twitch platform. diff --git a/backend/docs/_master/systems/price.md b/backend/docs/_master/systems/price.md new file mode 100644 index 000000000..3732f36be --- /dev/null +++ b/backend/docs/_master/systems/price.md @@ -0,0 +1,11 @@ +!> Price system will work only when Points system is enabled! Price system will put command behind points paywall. + +### Commands | OWNER +- !price set [command] [number] + - will set price of command to [number] points +- !price list + - bot will print list of created prices +- !price unset [command] + - remove price of command +- !price + - bot will print usage of !price commands \ No newline at end of file diff --git a/backend/docs/_master/systems/queue.md b/backend/docs/_master/systems/queue.md new file mode 100644 index 000000000..bfc916675 --- /dev/null +++ b/backend/docs/_master/systems/queue.md @@ -0,0 +1,9 @@ +## Queue system +| Command | Default permission | Info | +|----------------------|:------------------:|------------------------------------------------| +| !queue | VIEWERS | gets an info whether queue is opened or closed | +| !queue open | OWNER | open a queue | +| !queue close | OWNER | close a queue | +| !queue pick [amount] | OWNER | pick [amount] \(optional) of users from queue | +| !queue join [optional-message] | VIEWERS | join a queue | +| !queue clear | OWNER | clear a queue | \ No newline at end of file diff --git a/backend/docs/_master/systems/quotes.md b/backend/docs/_master/systems/quotes.md new file mode 100644 index 000000000..355dd95e0 --- /dev/null +++ b/backend/docs/_master/systems/quotes.md @@ -0,0 +1,128 @@ +##### Changelog +| Version | Description | +| --------|:--------------------------------------| +| 8.0.0 | First implementation of quotes system | + + +## Add a new quote +`!quote add -tags -quote ` + +!> Default permission is **OWNER** + +### Parameters +- `-tags` - *optional string* - comma-separated tags + - *default value:* general +- `-quote` - *string* - Your quote to save + +### Examples + +
+ testuser: !quote -tags dota 2, funny -quote Sure I'll win!
+ bot: @testuser, quote 1 'Sure I'll win!' was added. (tags: dota 2, funny) +
+ +
+ testuser: !quote add -quote Sure I'll win!
+ bot: @testuser, quote 2 'Sure I'll win!' was added. (tags: general) +
+ +
+ testuser: !quote add -tags dota 2, funny
+ bot: @testuser, !quote add is not correct or missing -quote parameter +
+ +## Remove a quote +`!quote remove -id ` + +!> Default permission is **OWNER** + +### Parameters +- `-id` - *number* - ID of quote you want to delete + +### Examples + +
+ testuser: !quote remove -id 1
+ bot: @testuser, quote 1 was deleted. +
+ +
+ testuser: !quote remove -id a
+ bot: @testuser, quote ID is missing or is not a number. +
+ +
+ testuser: !quote remove -id 999999
+ bot: @testuser, quote 999999 was not found. +
+ +## Show a quote by ID +`!quote -id ` + +!> Default permission is **VIEWER** + +### Parameters +- `-id` - *number* - ID of quote you want to show + +### Examples + +
+ testuser: !quote -id 1
+ bot: Quote 1 by testuser 'Sure I'll win!' +
+ +
+ testuser: !quote -id a
+ bot: @testuser, quote ID is not a number. +
+ +
+ testuser: !quote -id 999999
+ bot: @testuser, quote 999999 was not found. +
+ +## Show a random quote by tag +`!quote -tag ` + +!> Default permission is **VIEWER** + +### Parameters +- `-tag` - *string* - tag, where to get random quote from + +### Examples + +
+ testuser: !quote -tag dota 2
+ bot: Quote 1 by testuser 'Sure I'll win!' +
+ +
+ testuser: !quote -tag nonexisting
+ bot: @testuser, no quotes with tag nonexisting was not found. +
+ +## Set tags for an existing quote +`!quote set -tag -id ` + +!> Default permission is **OWNER** + +### Parameters +- `-tag` - *string* - tag, where to get random quote from +- `-id` - *number* - ID of quote you want to update + +### Examples + +
+ testuser: !quote set -id 2 -tag new tag
+ bot: @testuser, quote 2 tags were set. (tags: new tag) +
+ +## Other settings +### Enable or disable quote system +`!enable system quotes` | +`!disable system quotes` + +!> Default permission is **OWNER** + +### Set URL for !quote list +You need to set your `public URL` in UI `system->quotes`. diff --git a/backend/docs/_master/systems/raffles.md b/backend/docs/_master/systems/raffles.md new file mode 100644 index 000000000..f114fcb00 --- /dev/null +++ b/backend/docs/_master/systems/raffles.md @@ -0,0 +1,14 @@ +## Raffle system +### Commands +`!raffle pick` - **OWNER** - pick or repick a winner of raffle + +`!raffle remove` - **OWNER** - remove raffle without winner + +`!raffle open ![raffle-keyword] [-min #?] [-max #?] [-for subscribers?]` +- open a new raffle with selected keyword, +- -min # - minimal of tickets to join, -max # - max of tickets to join -> ticket raffle +- -for subscribers - who can join raffle, if empty -> everyone + +`!raffle` - **VIEWER** - gets an info about raffle + +`![raffle-keyword]` *or* `![raffle-keyword] ` - **VIEWER** - join a raffle *or* ticket raffle with amount of tickets \ No newline at end of file diff --git a/backend/docs/_master/systems/ranks.md b/backend/docs/_master/systems/ranks.md new file mode 100644 index 000000000..bdb7003cd --- /dev/null +++ b/backend/docs/_master/systems/ranks.md @@ -0,0 +1,17 @@ +### Commands | OWNER + +* !rank add \ \ `add for selected ` +* !rank add-flw \ \ `add for selected ` +* !rank add-sub \ \ `add for selected ` +* !rank rm \ `remove rank for selected ` +* !rank rm-sub \ `remove rank for selected of subscribers` +* !rank list `show rank list` +* !rank list-sub `show rank list for subcribers` +* !rank edit \ \ `edit rank` +* !rank edit-sub \ \ `edit rank for subcribers` +* !rank set \ \ `set custom for ` +* !rank unset \ `unset custom rank for ` + +### Commands | VIEWER + +* !rank `show user rank` diff --git a/backend/docs/_master/systems/scrim.md b/backend/docs/_master/systems/scrim.md new file mode 100644 index 000000000..b30cfbe33 --- /dev/null +++ b/backend/docs/_master/systems/scrim.md @@ -0,0 +1,80 @@ +## Start new scrim countdown + +`!snipe (-c) ` + +!> Default permission is **OWNER** + +### Parameters + +- `-c` + - *optional* + - disables adding match IDs and Current Matches output +- `` + - set type of your scrim, e.g. duo, single, apex, etc. It's + not enforced to specific values. +- `` + - minutes before start + +### Examples + +
+ owner: !snipe duo 1
+ bot: Snipe match (duo) starting in 1 minute
+ bot: Snipe match (duo) starting in 45 seconds
+ bot: Snipe match (duo) starting in 30 seconds
+ bot: Snipe match (duo) starting in 15 seconds
+ bot: Snipe match (duo) starting in 3.
+ bot: Snipe match (duo) starting in 2.
+ bot: Snipe match (duo) starting in 1.
+ bot: Starting now! Go!
+ bot: Please put your match ID in the chat + => !snipe match xxx
+ bot: Current Matches: <empty> +
+ +## Stop countdown + +`!snipe stop` + +!> Default permission is **OWNER** + +### Examples + +
+ owner: !snipe duo 1
+ bot: Snipe match (duo) starting in 1 minute
+ bot: Snipe match (duo) starting in 45 seconds
+ owner: !snipe stop
+ ... no other messages ... +
+ +
+ testuser: !alias edit viewer !nonexisting !points
+ bot: @testuser, alias !nonexisting was not found in database +
+ +## Add matchId to scrim + +`!snipe match ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` - match ID of your match + +### Examples + +
+ bot: Snipe match (duo) starting in 3.
+ bot: Snipe match (duo) starting in 2.
+ bot: Snipe match (duo) starting in 1.
+ bot: Starting now! Go!
+ bot: Please put your match ID in the chat + => !snipe match xxx
+ testuser:!snipe match 123-as-erq
+ testuser2:!snipe match 123-as-erq
+ testuser3:!snipe match 111-as-eee
+ bot: Current Matches: 123-as-erq - testuser, testuser2 | + 111-as-eee - testuser3 +
diff --git a/backend/docs/_master/systems/songs.md b/backend/docs/_master/systems/songs.md new file mode 100644 index 000000000..68c152195 --- /dev/null +++ b/backend/docs/_master/systems/songs.md @@ -0,0 +1,307 @@ +## Enable / Disable song requests + +`!set systems.songs.songrequest ` + +!> Default permission is **CASTERS** + +## Enable / Disable playing song from playlist + +`!set systems.songs.playlist ` + +!> Default permission is **CASTERS** + +## Ban a song + +`!bansong ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - *optional YouTube videoID* + - *default value:* current playing song + +### Examples + +
+ testuser: !bansong
+ bot: @testuser, Song Unknown Brain & Kyle Reynolds - I'm Sorry + Mom [NCS Release] was banned and will never play again!
+ / if user requested song, he will got timeout: You've got timeout for posting + banned song / +
+ +
+ testuser: !bansong s0YJhnVEgMw
+ bot: @testuser, Song Rival - Lonely Way (ft. Caravn) + [NCS Release] was banned and will never play again!
+ / if user requested song, he will got timeout: You've got timeout for posting + banned song / +
+ +## Unban a song + +`!unbansong ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - *YouTube videoID* + +### Examples + +
+ testuser: !unbansong UtE7hYZo8Lo
+ bot: @testuser, This song was not banned.
+
+ +
+ testuser: !unbansong s0YJhnVEgMw
+ bot: @testuser, Song was succesfully unbanned.
+
+ +## Skip currently playing song + +`!skipsong` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser: !skipsong
+ / no response from bot expected /
+ + +## Show currently playing song + +`!currentsong` + +!> Default permission is **VIEWERS** + +### Examples + +
+ testuser: !currentsong
+ bot: testuser, No song is currently playing
+
+ +
+ testuser: !currentsong
+ bot: testuser, Current song is Syn Cole - Time [NCS Release] + from playlist
+
+ +
+ testuser: !currentsong
+ bot: Current song is Rogers & Dean - Jungle [NCS Release] + requested by testuser2
+
+ +## Show current playlist + +`!playlist` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser: !playlist
+ bot: testuser, current playlist is general.
+
+ +## List available playlist + +`!playlist list` + +!> Default permission is **CASTER** + +### Examples + +
+ testuser: !playlist list
+ bot: testuser, available playlists: general, chill, test.
+
+ +## Add song from playlist + +`!playlist add ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - use YouTube videoID, video URL or search string + - Examples: + - QnL5P0tFkwM + - https://www.youtube.com/watch?v=QnL5P0tFkwM + - http://youtu.be/QnL5P0tFkwM + - Rogers & Dean - Jungle + +### Examples + +
+ testuser: !playlist add QnL5P0tFkwM
+ bot: testuser, song Rogers & Dean - Jungle [NCS Release] + was added to playlist.
+
+ +## Remove song from playlist + +`!playlist remove ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - use YouTube videoID + - Examples: + - QnL5P0tFkwM + +### Examples + +
+ testuser: !playlist remove QnL5P0tFkwM
+ bot: testuser, song Rogers & Dean - Jungle [NCS Release] + was removed from playlist.
+
+ +## Import YouTube playlist into playlist + +`!playlist import ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - use YouTube playlist link + - Examples: + - https://www.youtube.com/watch?list=PLGBuKfnErZlD_VXiQ8dkn6wdEYHbC3u0i + +### Examples + +
+ testuser: !playlist remove QnL5P0tFkwM
+ bot: testuser, song Rogers & Dean - Jungle [NCS Release] + was removed from playlist.
+
+ +## Steal current song to playlist + +`!playlist steal` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser: !playlist steal
+ bot: testuser, No song is currently playing.
+
+ +
+ testuser: !playlist steal
+ bot: testuser, song Max Brhon - Pain [NCS Release] was added + to playlist.
+
+ +## Change current playlist + +`!playlist set ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - your desired playlist to play + +### Examples + +
+ testuser: !playlist set general
+ bot: testuser, you changed playlist to general.
+
+ +
+ testuser: !playlist set thisdoesntexist
+ bot: testuser, your requested playlist thisdoesntexist + doesn't exist.
+
+ +## Request song + +`!songrequest ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` + - use YouTube videoID, video URL or search string + - Examples: + - QnL5P0tFkwM + - https://www.youtube.com/watch?v=QnL5P0tFkwM + - http://youtu.be/QnL5P0tFkwM + - Rogers & Dean - Jungle + +### Examples + +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: Sorry, testuser, song requests are disabled
+
+ +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: Sorry, testuser, but this song is banned
+
+ +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: Sorry, testuser, but this song is too long
+
+ +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: Sorry, testuser, but this song must be music category
+
+ +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: testuser, song Rogers & Dean - Jungle [NCS Release] + was added to queue
+
+ +## User skip own requested song + +`!wrongsong` + +!> Default permission is **VIEWERS** + +### Examples + +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: testuser, song Rogers & Dean - Jungle [NCS Release] + was added to queue
+ testuser: !wrongsong
+ bot: testuser, your song Rogers & Dean - Jungle [NCS Release] + was removed from queue
+
+ +## Other settings + +### Enable or disable songs system + +`!enable system songs` | +`!disable system songs` + +!> Default permission is **OWNER** diff --git a/backend/docs/_master/systems/timers.md b/backend/docs/_master/systems/timers.md new file mode 100644 index 000000000..baba2fb91 --- /dev/null +++ b/backend/docs/_master/systems/timers.md @@ -0,0 +1,21 @@ +## Timers system +Timers system will periodically print out set responses, when certain requirements are met. + +### Commands | OWNER +`!timers set -name [name-of-timer] -messages [num-of-msgs-to-trigger|default:0] -seconds [trigger-every-x-seconds|default:60] [-offline]` - will create or update timers with specified requirements to meet + +`!timers unset -name [name-of-timer]` - remove timer and all responses + +`!timers add -name [name-of-timer] -response '[response]'` - add response to specified timer + +`!timers rm -id [id-of-response]` - remove response by id + +`!timers list` - return list of timers + +`!timers list -name [name-of-timer]` - return responses of specified timer + +`!timers toggle -name [name-of-timer]` - enable/disable specified timer + +`!timers toggle -id [id-of-response]` - enable/disable specified response + +`!timers` - show timers system usage help \ No newline at end of file diff --git a/backend/docs/_master/systems/top.md b/backend/docs/_master/systems/top.md new file mode 100644 index 000000000..773f2f7b3 --- /dev/null +++ b/backend/docs/_master/systems/top.md @@ -0,0 +1,47 @@ +## Top 10 by time + +`!top time` + +!> Default permission is **OWNER** + +## Top 10 by tips + +`!top tips` + +!> Default permission is **OWNER** + +## Top 10 by points + +`!top points` + +!> Default permission is **OWNER** + +## Top 10 by messages + +`!top messages` + +!> Default permission is **OWNER** + +## Top 10 by subage + +`!top subage` + +!> Default permission is **OWNER** + +## Top 10 by bits + +`!top bits` + +!> Default permission is **OWNER** + +## Top 10 by sub gifts + +`!top gifts` + +!> Default permission is **OWNER** + +## Top 10 by sub months + +`!top submonths` + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/_navbar.md b/backend/docs/_navbar.md new file mode 100644 index 000000000..13303f0b3 --- /dev/null +++ b/backend/docs/_navbar.md @@ -0,0 +1,3 @@ + + +* **Version:** [stable](/) | [master](/_master/) \ No newline at end of file diff --git a/backend/docs/_sidebar.md b/backend/docs/_sidebar.md new file mode 100644 index 000000000..94f8b80e3 --- /dev/null +++ b/backend/docs/_sidebar.md @@ -0,0 +1,55 @@ +* [Home](/) +* [Install and upgrade](/install-and-upgrade.md) +* [FAQ](/faq.md) +* Configuration + * [Database](/configuration/database.md) + * [Environment variables](/configuration/env.md) +* Services + * [Twitch](/services/twitch.md) + * [Google](/services/google.md) +* Systems + * [Alias](/systems/alias.md) + * [Bets](/systems/bets.md) + * [Commercial](/systems/commercial.md) + * [Permissions](/systems/permissions.md) + * [Custom Commands](/systems/custom-commands.md) + * [Cooldowns](/systems/cooldowns.md) + * [Keywords](/systems/keywords.md) + * [Levels](/systems/levels.md) + * [Moderation](/systems/moderation.md) + * [Timers](/systems/timers.md) + * [Points](/systems/points.md) + * [Polls](/systems/polls.md) + * [Price](/systems/price.md) + * [Scrim](/systems/scrim.md) + * [Songs](/systems/songs.md) + * [Top](/systems/top.md) + * [Ranks](/systems/ranks.md) + * [Raffles](/systems/raffles.md) + * [Queue](/systems/queue.md) + * [Highlights](/systems/highlights.md) + * [Quotes](/systems/quotes.md) + * [Miscellaneous](/systems/miscellaneous.md) +* Games + * [Duel](/games/duel.md) + * [FightMe](/games/fightme.md) + * [Gamble](/games/gamble.md) + * [Heist](/games/heist.md) + * [Roulette](/games/roulette.md) + * [Seppuku](/games/seppuku.md) + * [Wheel Of Fortune](/games/wheelOfFortune.md) +* Registries + * [Randomizer](/registries/randomizer.md) +* [Response Filters](/filters/all.md) +* Overlays + * [Themes](/overlays/themes.md) + * [Eventlist](/overlays/eventlist.md) +* How To + * [Connection to socket](/howto/connection-to-socket.md) + * [Eval snippets](/howto/eval.md) + * [Run random command](/howto/run-random-command.md) + * [Write own system](/howto/write-own-system.md) +* Integrations + * [Twitter](/integrations/twitter.md) + * [Last.fm](/integrations/lastfm.md) + * [Spotify](/integrations/spotify.md) diff --git a/backend/docs/configuration/database.md b/backend/docs/configuration/database.md new file mode 100644 index 000000000..ff9d14ece --- /dev/null +++ b/backend/docs/configuration/database.md @@ -0,0 +1,42 @@ +!> TypeORM is supporting various databases, below are listed **only** supported databases + by sogeBot. + +!> Update **!!! ONLY !!!** your connection informations + +## SQLite3 + +?> SQLite is **default** db (if installed by zipfile), if you didn't set MySQL/MariaDB or PostgreSQL, +you don't need to do anything + +1. Rename `/path/to/sogebot/.env.sqlite` or in case of GIT install `/path/to/sogebot/src/data/.env.sqlite` to `/path/to/sogebot/.env` +2. **DON'T UPDATE ANY OTHER INFORMATIONS (LIKE MIGRATION, ENTITIES), + OTHERWISE DATABASE WON'T WORK** +3. Start bot + +## MySQL/MariaDB + +1. Rename `/path/to/sogebot/.env.mysql` or in case of GIT install `/path/to/sogebot/src/data/.env.mysql` to `/path/to/sogebot/.env` +2. Update your connection options, see + [TypeORM Connection Options](https://typeorm.io/#/connection-options) + for detailed information. +3. **DON'T UPDATE ANY OTHER INFORMATIONS (LIKE MIGRATION, ENTITIES), + OTHERWISE DATABASE WON'T WORK** +4. Start bot + +## PostgreSQL + +1. Rename `/path/to/sogebot/.env.postgres` or in case of GIT install `/path/to/sogebot/src/data/.env.postgres` to `/path/to/sogebot/.env` +2. Update your connection options, see + [TypeORM Connection Options](https://typeorm.io/#/connection-options) + for detailed information. +3. **DON'T UPDATE ANY OTHER INFORMATIONS (LIKE MIGRATION, ENTITIES), + OTHERWISE DATABASE WON'T WORK** +4. Start bot + +## Supported databases + +- SQLite3(**default**) +- PostgreSQL 15 +- MySQL 5.7 + - you need to set `character-set-server=utf8mb4` + and `collation-server=utf8mb4_general_ci` diff --git a/backend/docs/configuration/env.md b/backend/docs/configuration/env.md new file mode 100644 index 000000000..017084c3c --- /dev/null +++ b/backend/docs/configuration/env.md @@ -0,0 +1,94 @@ +Bot can be started with various environment variables + +## DISABLE + +Force system to be disabled. Mainly used for moderation system + +- `DISABLE=moderation` +- nothing is set *default* + +## PORT + +Set port for listening of UI. + +- `PORT=12345` +- `PORT=20000` *default* + +## SECUREPORT + +Set port for listening of UI. + +- `SECUREPORT=12345` +- `SECUREPORT=20443` *default* + +## CA_KEY, CA_CERT + +Sets your certificate and certificate key by **full path** + +- `CA_KEY=/path/to/your/cert.key` +- `CA_CERT=/path/to/your/cert.cert` + +## HEAP + +Enables HEAP snapshot tracking and saving for a bot. In normal environment, +you **should not** enable this environment variable. + +- `HEAP=true` +- `HEAP=false` *default* + +Heaps are saved in `./heap/` folder + +## LOGLEVEL + +Changes log level of a bot + +- `LOGLEVEL=debug` +- `LOGLEVEL=info` *default* + +## DEBUG + +Enables extended debugging, by default its disabled + +- `DEBUG=api.call` - will save `api.bot.csv` and `api.broadcaster.csv` files +- `DEBUG=api.stream` + +## THREAD + +Force worker_threads to be disabled in special cases (e.g. getChannelChattersUnofficialAPI) + +- `THREAD=0` +- nothing is set *default* + +## TIMEZONE + +!> Timezone is affecting only bot logs and `!time` command + +## CORS + +Enable socket.io cors settings + +- `CORS=*` +- nothing is set *default* + +### What is this? + +Changes timezone settings for a bot. Useful if you are on machine, where you +cannot change system timezone or you have several bots for different streamers +in different timezones. + +### Available values + +- *system* - will set timezone defined by system +- other timezones can be found at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + - you are interested in TZ values on wiki: + - Africa/Abidjan + - Europe/Prague + - America/Argentina/San_Luis + +### Examples + +- `TIMEZONE=system` +- `TIMEZONE=Europe/Prague` +- `TIMEZONE=America/Argentina/San_Luis` + +?> If timezone is not set default value is *system* diff --git a/backend/docs/faq.md b/backend/docs/faq.md new file mode 100644 index 000000000..3f90d2a5a --- /dev/null +++ b/backend/docs/faq.md @@ -0,0 +1,39 @@ +**Question:** Does a bot support more than one channel? + +**Answer:** Bot supports **only** one channel to be connected, there is longterm plan to add multichannel support, but currently one bot instance = one channel + +*** + +**Question:** Can you add this super new feature to a bot? + +**Answer:** Please [create new issue/feature request](https://github.com/sogebot/sogeBot/issues/new?labels=feature+request) and I'll look at it! + +*** + +**Question:** Why !title and !game commands doesn't work? + +**Answer:** Bot need channel editor permissions in http://twitch.tv/yourusername/dashboard/permissions and bot oauth token must be generated through http://oauth.sogebot.xyz/ + +*** + +**Question:** How can I run bot on boot/startup? + +**Answer:** You can use [pm2](https://github.com/Unitech/pm2) to manage your bot instance and have it started on boot + +*** + +**Question:** Why is bot not sending whisper messages? + +**Answer:** Please read https://discuss.dev.twitch.tv/t/have-a-chat-whisper-bot-let-us-know/10651 and register your bot in application form. To get your bot user_id use curl command below. + + curl -H 'Accept: application/vnd.twitchtv.v5+json' \ + -H 'Client-ID: 1wjn1i3792t71tl90fmyvd0zl6ri2vg' \ + -X GET https://api.twitch.tv/kraken/users?login= + +*** + +**Question:** Bot on docker have issues with connection to twitch or streamlabs (Error: getaddrinfo EAI_AGAIN)? + +**Answer:** Check https://development.robinwinslow.uk/2016/06/23/fix-docker-networking-dns/ for steps to fix your issue + +*** diff --git a/backend/docs/filters/all.md b/backend/docs/filters/all.md new file mode 100644 index 000000000..3ebdeee99 --- /dev/null +++ b/backend/docs/filters/all.md @@ -0,0 +1,560 @@ +!> Response filters are usable in notices, custom commands, keywords and text overlay + +## Global variables + +`$sender` + +- returns username of viewer, who triggered this message + +`$source` + +- returns source of this message (twitch or discord), if comes from +bot it is twitch by default + +`$price` + +- returns command price + +`$game` + +- return current game + +`$thumbnail` + +- return current thumbnail link without defined size, e.g. `https://static-cdn.jtvnw.net/ttv-boxart/33214-{width}x{height}.jpg` + +`$thumbnail(WIDTHxHEIGHT)` + +- return current thumbnail link +- `$thumbnail(150x200)` will return e.g. `https://static-cdn.jtvnw.net/ttv-boxart/33214-150x200.jpg` + +`$title` + +- return current status + +`$language` + +- return current stream language + +`$viewers` + +- return current viewers count + +`$followers` + +- return current followers count + +`$subscribers` + +- return current subscribers count + +`$bits` + +- return current bits count received during current stream + +`$ytSong` + +- return current song playing in YTplayer widget + +`$spotifySong` + +- return current song playing in Spotify + +`$lastfmSong` + +- return current song playing in Last.fm + +`$latestFollower` + +- Latest Follower + +`$latestSubscriber` + +- Latest Subscriber + +`$latestSubscriberMonths` + +- Latest Subscriber cumulative months + +`$latestSubscriberStreak` + +- Latest Subscriber months streak + +`$latestTipAmount` + +- Latest Tip (amount) + +`$latestTipCurrency` + +- Latest Tip (currency) + +`$latestTipMessage` + +- Latest Tip (message) + +`$latestTip` + +- Latest Tip (username) + +`$latestCheerAmount` + +- Latest Cheer (amount) + +`$latestCheerMessage` + +- Latest Cheer (message) + +`$latestCheer` + +- Latest Cheer (username) + +`$toptip.overall.username` + +- Overall top tip (username) + +`$toptip.overall.amount` + +- Overall top tip (amount) + +`$toptip.overall.currency` + +- Overall top tip (currency) + +`$toptip.overall.message` + +- Overall top tip (message) + +`$toptip.stream.username` + +- Current stream top tip (username) + +`$toptip.stream.amount` + +- Current stream top tip (amount) + +`$toptip.stream.currency` + +- Current stream top tip (currency) + +`$toptip.stream.message` + +- Current stream top tip (message) + +`$version` + +- return current bot version + +`$isBotSubscriber` + +- return true/false (boolean) if bot is subscriber + +`$isStreamOnline` + +- return true/false (boolean) if stream is online + +`$uptime` + +- return uptime of the stream + +`$channelDisplayName` + +- return channel display name + +`$channelUserName` + +- return channel user names + +## Count subs / follows/ bits / tips in date interval + +- `(count|subs|)` +- return subs+resubs count in interval + - **example:** + - `(count|subs|day)` +- `(count|follows|)` +- return follows count in interval + - **example:** + - `(count|follows|month)` +- `(count|tips|)` +- return tips count in interval + - **example:** + - `(count|tips|year)` +- `(count|bits|)` +- return bits count in interval + - **example:** + - `(count|bits|week)` + +- available **interval**: hour, day, week, month, year + +## Eval + +`(eval )` + +- will evaluate your javascript code - there **must** be return value + +## If + +`(if '$game'=='Dota 2'|Is dota|Is not dota)` + +- will evaluate your javascript if code, string check + +`(if $viewers>5|Is more than 5 viewers|Is less)` + +- will evaluate your javascript if code, int check + +`(if $viewers>5|Is more than 5 viewers)` + +- will evaluate your javascript if code, without else + +## Online/offline filters + +`(onlineonly)` + +- will enable command only if stream is online + +`(offlineonly)` + +- will enable command only if stream is offline + +## Math filters + +- `(math.#)` +- solve a math problem + - **example:** + - `(math.5+6)` + - `(math.$bits*2)` + - usable with variables in events +- `(toPercent.#)` +- change float number to percent + - **example:** + - `(toPercent|2|0.5)` => 50.00 + - `(toPercent|0.5)` => 50 + - `(toPercent|0.4321)` => 43 + - `(toPercent|2|0.43211123)` => 43.21 +- `(toFloat.#)` +- formats a number using fixed-point notation. + - **example:** + - `(toFloat|2|0.5)` => 0.50 + - `(toFloat|0.5)` => 1 + - `(toFloat|2|0.43211123)` => 0.43 + - `(toFloat|0.4321)` => 0 + +## Random filters + +`(random.online.viewer)` + +- returns random online viewer + +`(random.online.subscriber)` + +- returns random online subscriber + +`(random.viewer)` + +- returns random viewer (offline included) + +`(random.subscriber)` + +- returns random subscriber (offline included) + +`(random.number-#-to-#)` + +- returns random number from # to # - example: `(random.number-5-to-10)` + +`(random.true-or-false)` + +- returns randomly true/false + +## Custom variables + +**\#** is name of variable, e.g. mmr as `$_mmr` + +`$_#` + +- will set value for specified variable **if** argument is passed, else will return its value + +- **example:** + - _command:_ `!mmr` + - _response:_ `My MMR value is $_mmr` + - _chat:_ `!mmr 1000` < only for owners and mods + - _bot response_: `@soge__, mmr was set to 1000.` + - _chat:_ `!mmr` + - _bot response_: `My MMR value is 1000` + - _chat:_ `!mmr +` < only for owners and mods + - _bot response_: `@soge__, mmr was set to 1001.` + - _chat:_ `!mmr -` < only for owners and mods + - _bot response_: `My MMR value is 1000` + - _chat:_ `!mmr +500` < only for owners and mods + - _bot response_: `@soge__, mmr was set to 1500.` + - _chat:_ `!mmr -500` < only for owners and mods + - _bot response_: `@soge__, mmr was set to 1000.` + +`$!_#` + +- same as variable above, except set message is always silent + +`$!!_#` + +- full silent variable, useful in multi responses where first response + might be just setting of variable + +`$touser` + +- is user param variable, if empty, current user is used. This +param accepts `@user` and `user` + +- **example:** + - _command:_ `!point` + - _response:_ `$sender point to $touser` + - _chat:_ `!point @soge__` + - _bot response_: `@foobar points to @soge__` +- **example:** + - _command:_ `!point` + - _response:_ `$sender point to $touser` + - _chat:_ `!point soge__` + - _bot response_: `@foobar points to @soge__` +- **example:** + - _command:_ `!point` + - _response:_ `$sender point to $touser` + - _chat:_ `!point` + - _bot response_: `@foobar points to @foobar` + +`$param` + +- is required temporary variable (command without param will not show) + +- **example:** + - _command:_ `!say` + - _response:_ `$sender said: $param` + - _chat:_ `!say Awesome!` + - _bot response_: `@foobar said: Awesome!` + +`$!param` + +- is not required temporary variable + +- **example:** + - _command:_ `!say` + - _response:_ `$sender said: $!param` + - _chat:_ `!say Awesome!` + - _bot response_: `@foobar said: Awesome!` + +## URI safe strings + +`(url|Lorem Ipsum Dolor)` + +- will generate url safe string to be used in GET + +`(url|$param)` + +- will generate url safe from variable $param + +## Custom APIs + +`(api|http://your.desired.url)` + +- will load data from specified url - only **UTF-8** responses are supported + +`(api|http://your.desired.url?query=$querystring)` + +- will load data from specified url with specified *querystring* + +`(api._response)` + +- returns response, if api response is string + +- **example:** + - _command:_ `!stats` + - _response:_ `(api|https://csgo-stats.net/api/nightbot/rank/sogehige/sogehige) (sender), (api._response)` + - _chat:_ `!stats` + - _bot response_: `@foobar , Kills: 47182, Deaths: 47915, K/D: 0.98, Headshots: 39.0%, Accuracy: 18.5% - https://csgo-stats.net/player/sogehige/` + +`(api.#)` + +- returns json data of specified attribute + +- JSON arrays are accesible as well - _example:_ `(api.some[0].value[1])` +- **example:** + - _command:_ `!api` + - _response:_ `(api|https://jsonplaceholder.typicode.com/posts/5) UserId: (api.userId), id: (api.id), title: (api.title), body: (api.body)` + - _chat_: `!api` + - _bot response_: `UserId: 1, id: 5, title: nesciunt quas odio, body: repudiandae veniam quaerat sunt sedalias aut fugiat sit autem sed estvoluptatem omnis possimus esse voluptatibus quisest aut tenetur dolor neque` + +### GET parameters + +You can add parameters to your urls, _note_ you must escape your parameters where +needed. + +e.g. `(api|https://httpbin.org/get?test=a\\nb) Lorem (api.args.test)` + +## Command filters + +`$count` + +- return how many times current command was used + +`$count('!another')` + +- return how many times `!another` command was used + +`(! )` + +- run `! argument` + +`(!points add $sender 1000)` + +- run `!points add soge__ 1000` + +`(!points add $param 1000)` + +- run `!points add $param 1000` + +`(!.)` + +- run `! ` + +`(!)` + +- run `!` + +`(!!)` + +- run command **silently** + +**Usage 1:** + +- _command:_ `!buypermit` +- _response:_ `(!permit.sender) You have bought a 1 link permit for (price)` +- _chat:_ `foobar: !buypermit` +- _bot example response in chat_: `You have bough a 1 link permit for 100 Points` +- Bot will then send permit command for sender `!permit foobar` with _muted_ permit message + +**Usage 2:** + +- _command:_ `!play` +- _response:_ `(!songrequest.J73cZQzhPW0) You just requested some song!` +- _chat:_ `foobar: !play` +- _bot example response in chat_: `You just requested some song!` +- Bot will then send songrequest command for id `!songrequest J73cZQzhPW0` with _muted_ songrequest message + +## Stream filters + +`(stream|#name|game)` + +- returns game of `#name` channel, it something went wrong, returns `n/a` + +`(stream|#name|title)` + +- returns title of `#name` channel, it something went wrong, returns `n/a` + +`(stream|#name|viewers)` + +- returns viewers count of `#name` channel, it something went wrong, returns `0` + +`(stream|#name|link)` + +- returns link to twitch `#name` channel -> 'twitch.tv/#name' + +`(stream|#name|status)` + +- returns status of twitch `#name` channel -> 'live' | 'offline' + +## YouTube filters + +`$youtube(url, )` + +- returns latest video link e.g. +`$youtube(url, stejk01)` + +`$youtube(title, )` + +- returns latest video title e.g. +`$youtube(title, stejk01)` + +## List filters + +`(list.alias)` + +- will return list of your visible aliases + +`(list.alias|)` + +- will return list of your visible aliases for group + +`(list.alias|)` + +- will return list of your visible aliases without any group + +`(list.!alias)` + +- will return list of your visible !aliases + +`(list.!alias|)` + +- will return list of your visible !aliases for group + +`(list.!alias|)` + +- will return list of your visible !aliases without any group + +`(list.price)` + +- will return list of your set prices + +`(list.command)` + +- will return list of your visible custom commands + +`(list.command.)` + +- will return list of your visible custom commands for permission + +`(list.command|)` + +- will return list of your visible !commands for group + +`(list.command|)` + +- will return list of your visible !commands without any group + +`(list.!command)` + +- will return list of your visible custom !commands + +`(list.!command|)` + +- will return list of your visible !commands for group + +`(list.!command|)` + +- will return list of your visible !commands without any group + +`(list.!command.)` + +- will return list of your visible custom !commands for permission + +`(list.core.)` + +- will return list of your visible custom core commands for permission + +`(list.!core.)` + +- will return list of your visible custom core !commands for permission + +`(list.cooldown)` + +- will return list of your cooldowns (keywords and !commands) + +`(list.ranks)` + +- list of your ranks + +`(list.ranks.sub)` + +- list of your ranks + +**Usage:** + +- _command:_ `!list` +- _response:_ `My uber super awesome command list: (list.commands)` +- _chat:_ `foobar: !list` +- _bot example response in chat_: `My uber super awesome command list: test, test2` diff --git a/backend/docs/games/duel.md b/backend/docs/games/duel.md new file mode 100644 index 000000000..c435737ba --- /dev/null +++ b/backend/docs/games/duel.md @@ -0,0 +1,22 @@ +!> Duel game will work only when Points system is enabled! + +!> By default game is **disabled** + +## Start or participate in duel + +`!duel ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` + - points to bet on yourself + - use **all** to bet all your points + +## Settings + +`!enable game duel` | +`!disable game duel` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/games/gamble.md b/backend/docs/games/gamble.md new file mode 100644 index 000000000..ce89a5bc1 --- /dev/null +++ b/backend/docs/games/gamble.md @@ -0,0 +1,22 @@ +!> Gamble game will work only when Points system is enabled! + +!> By default game is **disabled** + +## Gamble your points for win + +`!gamble ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` + - points to bet on gamble + - use **all** to bet all your points + +## Settings + +`!enable game gamble` | +`!disable game gamble` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/games/heist.md b/backend/docs/games/heist.md new file mode 100644 index 000000000..02a4691b4 --- /dev/null +++ b/backend/docs/games/heist.md @@ -0,0 +1,24 @@ +!> Heist game will work only when Points system is enabled! + +!> By default game is **disabled** + +## Start or participate in heist + +`!bankheist ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` + - points to bet on heist + - use **all** to bet all your points + +## Settings + +Heist needs to be properly set through UI. + +`!enable game heist` | +`!disable game heist` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/games/roulette.md b/backend/docs/games/roulette.md new file mode 100644 index 000000000..493060106 --- /dev/null +++ b/backend/docs/games/roulette.md @@ -0,0 +1,16 @@ +!> Roulette game will work only when Points system is enabled! + +!> By default game is **disabled** + +## Start russian roulette + +`!roulette` + +!> Default permission is **VIEWERS** + +## Settings + +`!enable game roulette` | +`!disable game roulette` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/games/seppuku.md b/backend/docs/games/seppuku.md new file mode 100644 index 000000000..465e0f48b --- /dev/null +++ b/backend/docs/games/seppuku.md @@ -0,0 +1,14 @@ +!> By default game is **disabled** + +## Kick yourself from chat + +`!seppuku` + +!> Default permission is **VIEWERS** + +## Settings + +`!enable game seppuku` | +`!disable game seppuku` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/games/wheelOfFortune.md b/backend/docs/games/wheelOfFortune.md new file mode 100644 index 000000000..440984fd0 --- /dev/null +++ b/backend/docs/games/wheelOfFortune.md @@ -0,0 +1,18 @@ +!> By default game is **disabled** + +## Start wheel of fortune + +`!wof` + +!> Default permission is **VIEWERS** + +!> To be able to use this command, broadcaster must have wof overlay + +## Settings + +Wheel of fortune needs to be properly set through UI. + +`!enable game wheelOfFortune` | +`!disable game wheelOfFortune` - enable/disable game + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/docs/howto/connection-to-socket.md b/backend/docs/howto/connection-to-socket.md new file mode 100644 index 000000000..61f6209df --- /dev/null +++ b/backend/docs/howto/connection-to-socket.md @@ -0,0 +1,15 @@ +?> To get your unique socket token, go to UI -> settings -> Bot -> Socket + +!> Socket token grants you full access through socket! + +## How to authorize on socket + +```javascript +// Connect to socket.io +import io from 'socket.io-client'; + +const namespace = "/" +const token = ''; + +const socket = io(namespace, { forceNew: true, query: { token } }); +``` diff --git a/backend/docs/howto/eval.md b/backend/docs/howto/eval.md new file mode 100644 index 000000000..4ee15594c --- /dev/null +++ b/backend/docs/howto/eval.md @@ -0,0 +1,53 @@ +These `eval` snippets are working with custom commands responses + +#### Available eval variables +**_**: lodash - see https://lodash.com/docs/ + +**is**: sender informations - is.subscriber, is.mod, is.online + +**random**: same values as random filter - random.viewer, random.online.viewer....if no user is selected -> null + +**sender**: current user who evoked eval + +**param**: param of command + +**users**: list of all users in db + +#### Available eval functions +###### url() + - returns loaded axios response object + - e.g `let api = url('https://jsonplaceholder.typicode.com/posts')` + +### 8ball game +**command:** `!8ball` + +**response:** `$sender asks '(param)' - (eval var sayings = ['Signs point to yes.', 'Yes.', 'Reply hazy, try again.', 'My sources say no.', 'You may rely on it.', 'Concentrate and ask again.', 'Outlook not so good.', 'It is decidedly so.', 'Better not tell you now.', 'Very doubtful.', 'Yes - definitely.', 'It is certain.', 'Cannot predict now.', 'Most likely.', 'Ask again later.', 'My reply is no.', 'Outlook good.', "Don't count on it."]; return sayings[Math.floor(Math.random() * sayings.length)];)` + +### Multitwitch url generator +**command:** `!multi` + +**response:** `(eval if (param.length === 0) return ''; else return 'http://multitwitch.tv/' + param.replace(/ /g, '/');)` + +### Love command + +**command:** `!love` + +**response:** `There is a (random.number-0-to-100)% chance of love between $sender and $param` + +### Custom variable increment +#### Custom variable command +**command:** `!testvariable ` + +**response:** `$_test` + +#### Eval command +**command:** `!inc` + +**response:** `(eval return '(!testvariable ' + (parseInt('$_test', 10)+1) + ')')` + +**response 2 (if you want quiet command):** `(eval return '(!!testvariable ' + (parseInt('$_test', 10)+1) + ')')` + +#### Shoutout command +**command:** `!shoutout` + +**response:** `Shoutout to $param! Playing (stream|$param|game) - (stream|$param|title) for (stream|$param|viewers) viewers! Check it out (stream|$param|link)!` \ No newline at end of file diff --git a/backend/docs/howto/run-random-command.md b/backend/docs/howto/run-random-command.md new file mode 100644 index 000000000..c1cd332b0 --- /dev/null +++ b/backend/docs/howto/run-random-command.md @@ -0,0 +1,55 @@ +!> You need to have **alias** system enabled + +## Example (random) + +### Custom variable $_randomCommands + +For randomizing commands, you will need to create **eval** custom variable with +run script set to **When variable is used** + +```javascript +// set of commands +const commands = [ + '!me', + '!top time', + '!points', +]; + +// return random command +return _.sample(_.shuffle(commands)); +``` + +### Alias configuration + +Create alias `!youraliashere` with response `$_randomCommands`, this will +trigger random command. + +## Example (unique) + +### Custom variable $_randomCommandsUnique + +For randomizing commands, you will need to create **eval** custom variable with +run script set to **When variable is used** + +```javascript +// _current variable comes from bot containing current value + +// set of commands +const commands = [ + '!me', + '!top time', + '!points', +]; + +// return random command +let unique = _current +while (unique === _current) { + unique = _.sample(_.shuffle(commands)); +} +return unique; +``` + +### Alias configuration + +Create alias `!youraliashere2` with response `$_randomCommandsUnique`, this will +trigger random command. \ No newline at end of file diff --git a/backend/docs/howto/write-own-system.md b/backend/docs/howto/write-own-system.md new file mode 100644 index 000000000..8a753e210 --- /dev/null +++ b/backend/docs/howto/write-own-system.md @@ -0,0 +1,193 @@ +!> This guide is for **advanced** users. + +* new systems must be in `/src/bot/systems/` or `/src/bot/games/` folder + +> Games are set as opt-in, by default they are disabled + +## System template + +``` typescript +// bot libraries +const constants = require('../constants') +import System from './_interface'; +import { command, default_permission, parser, settings, ui, shared } from '../decorators'; +import { permissions } from '../permission'; // set of core permissions + +class Yoursystem extends System { + public dependsOn: string[] = [] + + @settings('myCategory') + yourSettingsVariableInMyCategory: string = 'lorem' + + @settings() + yourSettingsVariableWithoutMyCategory: string = 'ipsum' +} + +export default Yoursystem; +``` + +### Disable system by default + +``` typescript +class Yoursystem extends System { + _enabled: boolean = false; + + // ... +} +``` + + +### Depends on different system + +Some systems have dependencies, e.g. bet system cannot work without points system + +``` typescript +class Yoursystem extends System { + public dependsOn: string[] = ['systems.points'] + + // ... +} +``` + +### Settings variable + +**@settings(category?: string)** variable may contain settings for `yoursystem`, +customizable through ui and are saved in db + +``` typescript +class Yoursystem extends System { + @settings('myCategory') + yourSettingsVariableInMyCategory: string = 'lorem' + + @settings() + yourSettingsVariableWithoutMyCategory: string = 'ipsum' + // ... +} +``` + +### Shared variable + +**@shared()** variables are shared through workers and should be correctly accesible +in master and worker + +``` typescript +class Yoursystem extends System { + @shared() + yourSharedVariableShouldBeSameAcrossThreads: string = 'lorem' + // ... +} +``` + +#### Commands + +To define function, which should be command, you must use decorator **@command**. +To override default permission for viewers, use **@default_permission**. +For setting helper function (e.g. price check is skipped for this command) use **@helper**. + +``` javascript +@command('!yourcommand') +@default_permission(defaultPermissions.CASTERS) +@helper() +public foobar(opts: CommandOptions): CommandResponse[] { + // ... command logic ... +} +``` + +#### Parsers + +To define function, which should be command, you must use decorator **@parser**. + +##### Parser options + +* `fireAndForget`: if parser should run in background and we don't care about + result and will not rollback, e.g. stats counting. `false` +* `priority`: what priority should be given to parser, higher priority, sooner + it will run. `constants.LOW` +* `permission`: sets default permission for parser. `defaultPermissions.VIEWERS` + +``` typescript +@parser() +public someParser(opts: ParserOptions) { + // ... parser logic ... +} + +@parser({ fireAndForget: true }) +public anotherParser(opts: ParserOptions) { + // ... parser logic ... +} +``` + +## Database collections + +In systems, you can use `this.collection` object variable to be consistent +in collection names. + +!> You cannot use `this.collection`, but you need to specify category `this.collection.category` + +### Examples with `yoursystem` + +`this.collection.data` -> `systems.yoursystem` + +`this.collection.users` -> `systems.yoursystem.users` + +`this.collection.settings` -> `systems.yoursystem.settings` + +## Command function + +Command function have `opts` object parameter + +``` javascript +function commandFunction(opts) { + /* + opts: { + sender: , + command: , + parameters: + } + */ +} +``` + +## Parser function + +Parser function have `opts` object parameter. Must return **true** or **false**. +Return **false** will halt all next parser and commands. + +``` javascript +function parserFunction(opts) { + /* + opts: { + sender: , + message: , + skip: true/false + } + */ + + return true + // return false +} +``` + +## Locales + +Bot is supporting custom locales (by default **english** and **čeština** are supported). +To create new locale file add **json** file into `/locales/` folder. + +``` javascript +import { prepare } from '../commons'; + +function someCommandFunctionExample(opts) { + // given we have defined path.to.your.locale with value + // Lorem Ipsum $dolor sit amet + + // default locale translations + const defaultTranslation = global.translate('path.to.your.locale') + // => Lorem Ipsum $dolor sit amet + + // locale translation with attributes + const translation = prepare('path.to.your.locale', { + dolor: 'something' + }) + // => Lorem Ipsum something sit amet +} +``` \ No newline at end of file diff --git a/backend/docs/index.html b/backend/docs/index.html new file mode 100644 index 000000000..1e9959b13 --- /dev/null +++ b/backend/docs/index.html @@ -0,0 +1,53 @@ + + + + + sogebot - Free Twitch Bot built on Node.js + + + + + + +
+ + + + + + + + + diff --git a/backend/docs/install-and-upgrade.md b/backend/docs/install-and-upgrade.md new file mode 100644 index 000000000..1d2837b1a --- /dev/null +++ b/backend/docs/install-and-upgrade.md @@ -0,0 +1,126 @@ +## Prerequisites + +- **Browsers**: only latest Chrome/Chromium stable are supported +- **[Node.js](https://nodejs.org/en/)**: **18.x LTS** version +- **RAM**: Minimum 512MB, Recommended 1024MB +- **HDD**: Minimum 500MB +- Twitch bot account + +!> You need **separate** account for your bot, bot **won't** work on your + broadcaster account + +## Docker + +### Docker prerequisites + +- **Docker**, Any of the [supported repositories](http://sogebot.github.io/sogeBot/#/configuration/database) + +### Docker installation + +!> If you want to use **SQLite**, be sure to use `./shared/sogebot.db` path to + your db file, so you have an access outside of docker. + +!> Note that **localhost** is accessing docker localhost. You need to use full + IP address for your database connections. + +1. Download `Docker Compose` files + - From GIT: `git clone git@github.com:sogebot/sogeBot-docker.git` + - Without GIT as [ZIP](https://github.com/sogehige/sogeBot-docker/archive/master.zip) +2. Configure properly .env file in `root` directory + - You can find examples at [our GitHub repository](https://github.com/sogebot/sogeBot/tree/master/src/data) +3. Download bot images with `docker compose` + - Release version: `docker-compose pull` + - Nightly version: `docker-compose -f docker-compose-nightly.yml pull` +4. Startup your bot (add -d if you want to detach process) + - Release version: `docker-compose up` + - Nightly version: `docker-compose -f docker-compose-nightly.yml up` + +### Upgrade bot from Docker + +1. Stop your docker container +2. Run steps 3. and 4. from Installation + +## From ZIP + +### Stable + +- Download latest release from + [GitHub sogeBot release page](https://github.com/sogebot/sogeBot/releases) +- Continue at [ZIP Installation](#zip-installation) + +### Nightlies + +- Download desired nightly version from [GitHub sogeBot nightlies page](https://github.com/sogebot/sogeBot/actions?query=workflow%3ANightlies) +- Select run, you want to use (newest first) + +![create-new-app](./_images/install/nightlies.png) + +- Scroll down and download nightly artifact + +![create-new-app](./_images/install/artifact.png) + +- Continue at [ZIP Installation](#zip-installation) + +### ZIP Installation + +- Download your stable release or nightly (see above) +- Set your [database environment](configuration/database) +- Add bot as channel editor + in [Permissions settings](http://twitch.tv/dashboard/permissions) on Twitch +- be sure that you have latest npm installed + + `npm install --location=global npm@latest` + +- before starting a bot, you need to install npm dependencies + + `npm install` + +- start bot + + `npm start` + +- To access webpanel, go to `http://localhost:` where port is configured + as PORT env variable, e.g. `PORT=20001 npm start` + +### Upgrade bot from ZIP + +1. Backup your `.env` and, if using sqlite3, `sogebot.db` file +2. Remove your sogeBot directory +3. Go through Installation steps +4. Before `npm start` recopy your backup back to bot folder + +## From GIT + +### Build prerequisites + +- **Bash**, **Make**, **Git** + +### GIT Installation + +- Download [latest master zip](https://github.com/sogebot/sogeBot/archive/master.zip) + or clone repository `git clone https://github.com/sogebot/sogeBot.git` +- Set your [database environment](configuration/database) +- Add bot as channel editor + in [Permissions settings](http://twitch.tv/dashboard/permissions) on Twitch +- be sure that you have latest npm installed + + `npm install --location=global npm@latest` + +- before starting a bot, you need to build a bot + + `make` + +- start bot + + `npm start` + +- To access webpanel, go to `http://localhost:` where PORT is environment + variable with default value `20000` + +### Upgrade bot from GIT + +1. Backup your database +2. Update bot with `git pull -r origin master` +3. Run `npm install --location=global npm` +4. Run `make` +5. Start bot `npm start` diff --git a/backend/docs/integrations/lastfm.md b/backend/docs/integrations/lastfm.md new file mode 100644 index 000000000..fdef3c073 --- /dev/null +++ b/backend/docs/integrations/lastfm.md @@ -0,0 +1,9 @@ +Current integration is enabling `$lastfmSong` + +## Create your own twitter application + +- go to [Last.fm API creation](https://www.last.fm/api/account/create) +- create new app +![create-api-account](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/lastfm/create-api-account.png) +- copy your API key and username to bot +![copy-api-key](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/copy-api-key.png) \ No newline at end of file diff --git a/backend/docs/integrations/spotify.md b/backend/docs/integrations/spotify.md new file mode 100644 index 000000000..63096a6c8 --- /dev/null +++ b/backend/docs/integrations/spotify.md @@ -0,0 +1,128 @@ +Current integration is enabling `$spotifySong` and song requests(PREMIUM) from Spotify + +!> Spotify WEB Api is often bugged. Although bot offer functionality to skip, + request songs, there *may* be issues with connection to spotify. Which is on spotify + side. + +## How to setup + +1. Go to +2. Log In into your account +3. Create your application + + ![1](../_images/spotify/1.png ':size=300') + ![2](../_images/spotify/2.png ':size=300') + +4. As your app is in development mode, you need to add user to this app + + ![3](../_images/spotify/3.png ':size=300') + ![4](../_images/spotify/4.png ':size=300') + ![5](../_images/spotify/5.png ':size=300') + ![6](../_images/spotify/6.png ':size=300') + +4. Add Client ID and Client Secret to a bot + + ![7](../_images/spotify/7.png ':size=300') + +5. Add Redirect URI to Spotify and a Bot - redirect URI is where you access a bot. + By default `http://localhost:20000/credentials/oauth/spotify` | + **DON'T FORGET TO SAVE ON SPOTIFY** + + ![8](../_images/spotify/8.png ':size=300') + ![9](../_images/spotify/9.png ':size=300') + +6. Enable integration in a bot +7. Authorize user in a bot + + ![10](../_images/spotify/10.png ':size=300') + +8. Done, user is authorized + +## Request song through !spotify command - PREMIUM users only + +`!spotify ` or `!spotify ` or `!spotify ` + +!> Default permission is **DISABLED** + +### Parameters + +- `` - spotify URI of a song you want to play, e.g. `spotify:track:14Vp3NpYyRP3cTu8XkubfS` +- `` - song to search on spotify (will pick first found item), e.g. + `lion king` +- `` - song link, e.g. + `https://open.spotify.com/track/14Vp3NpYyRP3cTu8XkubfS?si=7vJWxZJdRu2VsBdvcVdAuA` + +### Examples + +
+ testuser: !spotify spotify:track:0GrhBz0am9KFJ20MN9o6Lp
+ bot: @testuser, you requested song + Circle of Life - 『ライオン・キング』より from Carmen Twillie +
+ +
+ testuser: !spotify lion king circle of life
+ bot: @testuser, you requested song + Circle of Life - 『ライオン・キング』より from Carmen Twillie +
+ +## Ban current song through !spotify ban command + +`!spotify ban` + +!> Default permission is **DISABLED** + +### Examples + +
+ testuser: !spotify ban
+ bot: @testuser, song + Circle of Life - 『ライオン・キング』より from Carmen Twillie was banned. +
+ +## Unban song through !spotify unban command + +`!spotify unban ` or `!spotify unban ` + +!> Default permission is **DISABLED** + +### Parameters + +- `` - spotify URI of a song you want to unban, e.g. `spotify:track:14Vp3NpYyRP3cTu8XkubfS` +- `` - song link, e.g. + `https://open.spotify.com/track/14Vp3NpYyRP3cTu8XkubfS?si=7vJWxZJdRu2VsBdvcVdAuA` + +### Examples + +
+ testuser: !spotify unban spotify:track:0GrhBz0am9KFJ20MN9o6Lp
+ bot: @testuser, song + Circle of Life - 『ライオン・キング』より from Carmen Twillie was unbanned. +
+ +## Song history with !spotify history command + +`!spotify history` or `!spotify history ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` - how many of songs should be returned in history command, if + omitted, it will show only last song, maximum 10. + +### Examples + +
+ testuser: !spotify history
+ bot: @testuser, previous song was + Circle of Life - 『ライオン・キング』より from Carmen Twillie. +
+ + +
+ testuser: !spotify history 2
+ bot: @testuser, 2 previous songs were:
+ bot: 1 - Circle of Life - 『ライオン・キング』より from Carmen Twillie
+ bot: 2 - The Wolven Storm (Priscilla's Song) from Alina Gingertail. +
diff --git a/backend/docs/integrations/twitter.md b/backend/docs/integrations/twitter.md new file mode 100644 index 000000000..2fe15aaa1 --- /dev/null +++ b/backend/docs/integrations/twitter.md @@ -0,0 +1,32 @@ +## Create your own twitter application + +- go to [Twitter Dev Dashboard](https://apps.twitter.com/) +- create new app + +![create-new-app](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/create-new-app.png ':size=300') + +- name your App and push complete button + +![create-an-application](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/create-an-application.png ':size=300') + +- edit your app permission + +![edit-app-permission](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/edit-app-permission.png ':size=300') + +- select **Read / Write / Direct Messag +es permission** and save + +![app-permission](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/app-permission.png ':size=300') + +- go to **Keys and tokens** tab + +![keys-and-token](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/keys-and-token.png ':size=300') + +- generate **Access Token & Secret** + +![generate-access-token](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/generate-access-token.png ':size=300') + +- copy your tokens to a bot + +![copy-access-token](https://raw.githubusercontent.com/sogehige/sogeBot/master/docs/_images/twitter/copy-access-token.png ':size=300') + diff --git a/backend/docs/overlays/eventlist.md b/backend/docs/overlays/eventlist.md new file mode 100644 index 000000000..28bfa61f2 --- /dev/null +++ b/backend/docs/overlays/eventlist.md @@ -0,0 +1,9 @@ +You can define several custom properties for your eventlist which are set through URL + +**Example:** `localhost:20000/overlays/eventlist?order=asc&ignore=host,cheer&count=5` + +**Properties:** +- order - asc, desc +- ignore - host, cheer, follow, sub, resub +- count - default: 5 +- display - default: username,event - set in which order and what to show in eventlist \ No newline at end of file diff --git a/backend/docs/overlays/themes.md b/backend/docs/overlays/themes.md new file mode 100644 index 000000000..6ff2aca2a --- /dev/null +++ b/backend/docs/overlays/themes.md @@ -0,0 +1,23 @@ +?> If you have a theme of your own and want to share, don't be afraid to [contribute](https://github.com/sogebot/sogeBot/issues/new?title=Theme:%20your%20theme%20name%20here)! + +# Stats +**Note:** To hide unwanted stat, add this to your style (where \ is viewers, uptime, followers, bits, subscribers) + + span. { + display: none !important; + } + +## PlayerUnknown's Battlegrounds +### Horizontal +![](http://imgur.com/Ub2uK9q.png) + +Copy [styles](https://drive.google.com/open?id=0B-_RLmmL4nXnXzNFR2t2bEJDV28) to OBS + +### Vertical +![](http://imgur.com/tVm2ruz.png) + +Copy [styles](https://drive.google.com/file/d/0B-_RLmmL4nXnMkRxQS0xMjAwU28/view?usp=sharing) to OBS + +# Replays +## Add shadow for video (OBS style) +`.replay { box-shadow: 0px 0px 20px #888888; }` diff --git a/backend/docs/registries/images/registriesRandomizerEntry.png b/backend/docs/registries/images/registriesRandomizerEntry.png new file mode 100644 index 000000000..abe02210f Binary files /dev/null and b/backend/docs/registries/images/registriesRandomizerEntry.png differ diff --git a/backend/docs/registries/randomizer.md b/backend/docs/registries/randomizer.md new file mode 100644 index 000000000..08d691fcf --- /dev/null +++ b/backend/docs/registries/randomizer.md @@ -0,0 +1,54 @@ +## How to use randomizer command + +1. Create your randomizer in **UI -> Registry -> Randomizer** (see example below) + +![Example of randomizer entry](images/registriesRandomizerEntry.png "Example of randomizer entry") + +2. Now we can use **!myrandomizer** command + +### Command usage + +#### Show / Hide randomizer in overlay + +!> Only one randomizer can be shown in overlay. + +!> Randomizer **won't autohide** after while even after spin. + +`!myrandomizer` + +
+ randomizer is hidden
+ owner: !myrandomizer
+ show randomizer in overlay
+ owner: !myrandomizer
+ hide randomizer in overlay
+
+ +#### Start spin of randomizers + +`!myrandomizer go` + +##### Example 1 (manual randomizer show) + +
+ randomizer is hidden
+ owner: !myrandomizer
+ show randomizer in overlay
+ owner: !myrandomizer go
+ randomizer will start to spin / randomize immediately
+ owner: !myrandomizer
+ hide randomizer in overlay
+
+ +##### Example 2 (auto randomizer show) + +!> Auto show is not working in widget, you need to **manually** trigger show of randomizer + +
+ randomizer is hidden
+ owner: !myrandomizer go
+ randomizer will show and start to spin / randomize + after 5 seconds
+ owner: !myrandomizer
+ hide randomizer in overlay
+
diff --git a/backend/docs/services/google.md b/backend/docs/services/google.md new file mode 100644 index 000000000..a7d370dd2 --- /dev/null +++ b/backend/docs/services/google.md @@ -0,0 +1,5 @@ +You can find the settings for that Service in Settings => Modules + +- create service account [Google Cloud Platform](https://console.cloud.google.com/apis/credentials) +- add key and download +- upload file to bot \ No newline at end of file diff --git a/backend/docs/services/twitch.md b/backend/docs/services/twitch.md new file mode 100644 index 000000000..90f2fc034 --- /dev/null +++ b/backend/docs/services/twitch.md @@ -0,0 +1,57 @@ +You can find the settings for that Service in Settings => Modules + +### oAuth + +__Token Generator__ + +Here you can select what Tokengenerator is used for Auth + +__Owners__ + +List of Users with Owner permission +(one user per line) + +### Bot +Here you have to put auth tokens for Bot account + +### Channel +Here you have to put auth tokens from Broadcaster account + +### TMI + +__Title/Game is forced__ + +Bot will force Title and Game (Category) on twitch +(bot forces its setting prior to all other tools) + +__Send Messages with /me__ + +send all messages as Actions in chat + +__Bot is muted__ + +Bot will not send responses to chat + +__Listen on commands on wispers__ + +Bot will accept wisper commands + +__Show users with @__ + +Bot will prefix usernames with @ in responses + +__Send bot messages as replies__ + +Bot will use twitchs message reply function for its responses to users + +__Ignore List__ + +Users in this list will be ignored from Bot + +__Exclude from global ignore list__ + +Bot will respond to this users even if they in global ignore list (list is provided with bot) + +__Eventsub__ + +For use of EventSub you need to have SSL enabled domain and created Twitch App \ No newline at end of file diff --git a/backend/docs/systems/alias.md b/backend/docs/systems/alias.md new file mode 100644 index 000000000..9c42c4dbe --- /dev/null +++ b/backend/docs/systems/alias.md @@ -0,0 +1,241 @@ +## Add a new alias + +`!alias add (-p ) -a -c ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-a ` + - alias to be added +- `-c ` + - command to be aliased + +### Examples + +
+ testuser: !alias add -p viewers -a !uec -c !points
+ bot: @testuser, alias !uec for !points was added +
+ +## Edit an alias + +`!alias edit (-p ) -a -c ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-a ` + - alias to be added +- `-c ` + - command to be aliased + +### Examples + +
+ testuser: !alias edit -p viewers -a !uec -c !me
+ bot: @testuser, alias !uec is changed to !me +
+ +
+ testuser: !alias edit viewer !nonexisting !points
+ bot: @testuser, alias !nonexisting was not found in database +
+ +## Remove an alias + +`!alias remove ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` - alias to be removed + +### Examples + +
+ testuser:!alias remove !uec
+ bot: @testuser, alias !uec2 was removed +
+ +
+ testuser: !alias remove !ueca
+ bot: @testuser, alias !ueca was not found in database +
+ +## List of aliases + +`!alias list` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser:!alias list
+ bot: @testuser, list of aliases: !uec +
+ +## Enable or disable alias + +`!alias toggle ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` - alias to be enabled or disabled + +### Examples + +
+ testuser:!alias toggle !uec
+ bot: @testuser, alias !uec was disabled +
+ +
+ testuser:!alias toggle !uec
+ bot: @testuser, alias !uec was enabled +
+ +## Toggle visibility of alias in lists + +`!alias toggle-visibility ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - alias to be exposed or concealed + +### Examples + +
+ testuser:!alias toggle !uec
+ bot: @testuser, alias !uec was concealed +
+ +
+ testuser:!alias toggle !uec
+ bot: @testuser, alias !uec was exposed +
+ +## Set an alias to group + +`!alias group -set -a ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - group to be set +- `` - alias to be set into group + +### Examples + +
+ testuser:!alias group -set voice -a !yes
+ bot: @testuser, alias !yes was set to group voice +
+ +## Unset an alias group + +`!alias group -unset ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - alias to be unset from group + +### Examples + +
+ testuser:!alias group -unset !yes
+ bot: @testuser, alias !yes group was unset +
+ +## List all alias groups + +`!alias group -list` + +!> Default permission is **OWNER** + +### Examples + +
+ testuser:!alias group -list
+ bot: @testuser, list of aliases groups: voice +
+ +## List all aliases in group + +`!alias group -list ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - group to list aliases + +### Examples + +
+ testuser:!alias group -list voice
+ bot: @testuser, list of aliases in voice: !yes +
+ +## Enable aliases in group + +`!alias group -enable ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - group of aliases to enable + +### Examples + +
+ testuser:!alias group -enable voice
+ bot: @testuser, aliases in voice enabled. +
+ +## Disable aliases in group + +`!alias group -disable ` + +!> Default permission is **OWNER** + +### Parameters + +- `` - group of aliases to disable + +### Examples + +
+ testuser:!alias group -disable voice
+ bot: @testuser, aliases in voice disabled. +
+ +## Other settings + +### Enable or disable alias system + +`!enable system alias` | +`!disable system alias` + +!> Default permission is **OWNER** diff --git a/backend/docs/systems/bets.md b/backend/docs/systems/bets.md new file mode 100644 index 000000000..1e23991ea --- /dev/null +++ b/backend/docs/systems/bets.md @@ -0,0 +1,25 @@ +## Create a new bet + +`!bet open [-timeout 120] -title "Your title here" Option 1 | Option 2 | ...` + +- timeout is set in seconds + +!> Default permission is **MODERATORS** + +## Reuse last bet + +`!vet reuse` + +!> Default permission is **MODERATORS** + +## Lock bet + +`!bet lock` + +!> Default permission is **MODERATORS** + +## Close bet + +`!bet close [idx]` + +!> Default permission is **MODERATORS** diff --git a/backend/docs/systems/commercial.md b/backend/docs/systems/commercial.md new file mode 100644 index 000000000..decb2937b --- /dev/null +++ b/backend/docs/systems/commercial.md @@ -0,0 +1,32 @@ +##### Changelog +| Version | Description | +| --------|:--------------------------------------| +| 8.0.0 | Updated docs | + + +## Run a commercial +`!commercial ` + +!> Default permission is **OWNER** + +### Parameters +- `` - length of commercial break, valid values are 30, 60, 90, 120, 150, 180 + +### Examples + +
+ testuser: !commercial 30
+ ... no response on success ... +
+ +
+ testuser: !commercial 10
+ bot: @testuser, available commercial duration are: 30, 60, 90, 120, 150 and 180 +
+ +## Other settings +### Enable or disable commercial system +`!enable system commercial` | +`!disable system commercial` + +!> Default permission is **OWNER** diff --git a/backend/docs/systems/cooldowns.md b/backend/docs/systems/cooldowns.md new file mode 100644 index 000000000..49d9de581 --- /dev/null +++ b/backend/docs/systems/cooldowns.md @@ -0,0 +1,15 @@ +## Cooldowns system +`!cooldown set ` + +- **OWNER** - set cooldown for command or keyword (per user or global), true/false sets whisper message +- If your command have subcommand, use quote marks, e.g. `!cooldown '!test command' user 60 true` + +`!cooldown unset ` - **OWNER** - unset cooldown for command or keyword + +`!cooldown toggle moderators ` - **OWNER** - enable/disable specified keyword or !command cooldown for moderators (by default disabled) + +`!cooldown toggle owners ` - **OWNER** - enable/disable specified keyword or !command cooldown for owners (by default disabled) + +`!cooldown toggle subscribers ` - **OWNER** - enable/disable specified keyword or !command cooldown for subscribers (by default enabled) + +`!cooldown toggle enabled ` - **OWNER** - enable/disable specified keyword or !command cooldown \ No newline at end of file diff --git a/backend/docs/systems/custom-commands.md b/backend/docs/systems/custom-commands.md new file mode 100644 index 000000000..c5074760b --- /dev/null +++ b/backend/docs/systems/custom-commands.md @@ -0,0 +1,251 @@ +## Add a new command + +`!command add (-p ) (-s) -c -r ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-s` + - *optional boolean* - stop execution after response is sent + - *default value:* false +- `-c ` + - command to be added +- `-r ` + - response to be set + +### Examples + +
+ testuser: !command add -c !test -r me
+ bot: @testuser, command !test was added. +
+ +
+ / create command only for mods /
+ testuser: !command add -p mods -c !test -r me
+ bot: @testuser, command !test was added. +
+ +
+ / create command only for mods and stop if executed /
+ testuser: !command add -p mods -s true -c !test -r me
+ bot: @testuser, command !test was added. +
+ +## Edit a response of command + +`!command edit (-p ) (-s) -c -rid -r ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-s` + - *optional boolean* - stop execution after response is sent + - *default value:* false +- `-c ` + - command to be edited +- `-rid ` + - response id to be updated +- `-r ` + - response to be set + +### Examples + +
+ testuser: !command edit -c !test -rid 1 -r me
+ bot: @testuser, command !test is changed to 'me' +
+ +
+ / set command only for mods /
+ testuser: !command edit -p mods -c !test -rid 1 -r me
+ bot: @testuser, command !test is changed to 'me' +
+ +
+ / set command only for mods and stop if executed /
+ testuser: !command edit -p mods -s true -c !test -rid 1 -r me
+ bot: @testuser, command !test is changed to 'me' +
+ +## Remove a command or a response + +`!command remove -c (-rid )` + +!> Default permission is **CASTERS** + +### Parameters + +- `-c ` + - command to be removed +- `-rid ` + - *optional* + - response id to be updatedremoved + +### Examples + +
+ testuser: !command remove -c !test
+ bot: @testuser, command !test was removed +
+ +
+ testuser: !command remove -c !nonexisting
+ bot: @testuser, command !test was not found +
+ +
+ testuser: !command remove -c !test -rid 1
+ bot: @testuser, command !test was removed +
+ +
+ testuser: !command remove -c !test -rid 2
+ bot: @testuser, response #2 of command !test + was not found in database +
+ +## List of commands + +`!command list` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser: !command list
+ bot: @testuser, list of commands: !command1, !command2 +
+ +## List of command responses + +`!command list !command` + +!> Default permission is **CASTERS** + +### Output + +!`command`#`responseId` (for `permission`) `stop`| `response` + +### Examples + +
+ testuser: !command list !test
+ bot: !test#1 (for viewers) v| Some response of command
+ bot: !test#2 (for mods) _| Some response of command +
+ +## What is stop execution after response + +In certain situations, you may have several responses based on permission. +Some users have higher permission then others. If response with +this settings is executed, all responses below this response will +be ignored. + +### Example without stop + +#### Responses in command !test + +- `owner` - response1 +- `mods` - response2 +- `viewers` - response3 + +
+ owneruser: !test
+ bot: response1
+ bot: response2
+ bot: response3
+
+ +
+ moduser: !test
+ bot: response2
+ bot: response3
+
+ +
+ vieweruser: !test
+ bot: response3
+
+ +### Example with stop + +#### Responses in command !test + +- `owner` - response1 +- `mods` - response2 - `stop here` +- `viewers` - response3 + +
+ owneruser: !test
+ bot: response1
+ bot: response2
+
+ +
+ moduser: !test
+ bot: response2
+
+ +
+ vieweruser: !test
+ bot: response3
+ / response 3 is returned, because response2 was not, so + execution was not stopped! / +
+ +## Command filters + +You can add filter for commands through UI. All filters are checked by **javascript** +engine. + +### Available filters + +Available filters can be found in UI. + +#### Examples + +`$sender == 'soge__'` - run command only for soge__ + +`$source == 'discord'` - run command only if comes from discord + +`$game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run command only when PUBG is set +as game + +`$sender == 'soge__' && $game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run command +only for soge__ **and** when game is set to PUBG + +`$sender == 'soge__' || $game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run command +only for soge__ **or** when game is set to PUBG + +`$subscribers >= 10` - run command when current subscribers count is equal or +greater than 10 + +#### Examples (advanced) + +`$game.toLowerCase() == 'playerunknown's battlegrounds'` - run command only when +PUBG is set as game + +`['soge__', 'otheruser'].includes($sender)` - check if sender is soge__ or otheruser + +## Other settings + +### Enable or disable custom commands system + +`!enable system customCommands` | +`!disable system customCommands` + +!> Default permission is **CASTERS** \ No newline at end of file diff --git a/backend/docs/systems/highlights.md b/backend/docs/systems/highlights.md new file mode 100644 index 000000000..d3a3f6bde --- /dev/null +++ b/backend/docs/systems/highlights.md @@ -0,0 +1,4 @@ +## Highlights system +`!highlight` - save highlight timestamp + +`!highlight list` - get list of highlights in current running or latest stream \ No newline at end of file diff --git a/backend/docs/systems/keywords.md b/backend/docs/systems/keywords.md new file mode 100644 index 000000000..7b0c1bfd7 --- /dev/null +++ b/backend/docs/systems/keywords.md @@ -0,0 +1,257 @@ +## Add a new keyword + +`!keyword add (-p ) (-s) -k -r ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-s` + - *optional boolean* - stop execution after response is sent + - *default value:* false +- `-k ` + - keyword or regexp to be added +- `-r ` + - response to be set + +### Examples + +
+ testuser: !keyword add -k test -r me
+ bot: @testuser, keyword test (7c4fd4f3-2e2a-4e10-a59a-7f149bcb226d) was added. +
+ +
+ / create keyword as regexp /
+ testuser: !keyword add -k (lorem|ipsum) -r me
+ bot: @testuser, keyword (lorem|ipsum) was added. +
+ +
+ / create keyword only for mods /
+ testuser: !keyword add -p mods -k test -r me
+ bot: @testuser, keyword test (7c4fd4f3-2e2a-4e10-a59a-7f149bcb226d) was added. +
+ +
+ / create keyword only for mods and stop if executed /
+ testuser: !keyword add -p mods -s true -k test -r me
+ bot: @testuser, keyword test (7c4fd4f3-2e2a-4e10-a59a-7f149bcb226d) was added. +
+ +## Edit a response of keyword + +`!keyword edit (-p ) (-s) -k -rid -r ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *optional string / uuid* - can be used names of permissions or theirs exact uuid + - *default value:* viewers + - *available values:* list of permission can be obtained by `!permissions list` + or in UI +- `-s` + - *optional boolean* - stop execution after response is sent + - *default value:* false +- `-k ` + - keyword/uuid/regexp to be edited +- `-rid ` + - response id to be updated +- `-r ` + - response to be set + +### Examples + +
+ testuser: !keyword edit -k test -rid 1 -r me
+ bot: @testuser, keyword test is changed to 'me' +
+ +
+ / set keyword only for mods /
+ testuser: !keyword edit -p mods -k test -rid 1 -r me
+ bot: @testuser, keyword test is changed to 'me' +
+ +
+ / set keyword only for mods and stop if executed /
+ testuser: !keyword edit -p mods -s true -k test -rid 1 -r me
+ bot: @testuser, keyword test is changed to 'me' +
+ +## Remove a keyword or a response + +`!keyword remove -k (-rid )` + +!> Default permission is **CASTERS** + +### Parameters + +- `-k ` + - keyword/uuid/regexp to be removed +- `-rid ` + - *optional* + - response id to be updatedremoved + +### Examples + +
+ testuser: !keyword remove -k test
+ bot: @testuser, keyword test was removed +
+ +
+ testuser: !keyword remove -c !nonexisting
+ bot: @testuser, keyword test was not found +
+ +
+ testuser: !keyword remove -k test -rid 1
+ bot: @testuser, keyword test was removed +
+ +
+ testuser: !keyword remove -k test -rid 2
+ bot: @testuser, response #2 of keyword test + was not found in database +
+ +## List of keywords + +`!keyword list` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser: !keyword list
+ bot: @testuser, list of keywords: !keyword1, !keyword2 +
+ +## List of keyword responses + +`!keyword list !keyword` + +!> Default permission is **CASTERS** + +### Output + +!`keyword`#`responseId` (for `permission`) `stop`| `response` + +### Examples + +
+ testuser: !keyword list test
+ bot: test#1 (for viewers) v| Some response of keyword
+ bot: test#2 (for mods) _| Some response of keyword +
+ +## What is stop execution after response + +In certain situations, you may have several responses based on permission. +Some users have higher permission then others. If response with +this settings is executed, all responses below this response will +be ignored. + +### Example without stop + +#### Responses in keyword test + +- `owner` - response1 +- `mods` - response2 +- `viewers` - response3 + +
+ owneruser: test
+ bot: response1
+ bot: response2
+ bot: response3
+
+ +
+ moduser: test
+ bot: response2
+ bot: response3
+
+ +
+ vieweruser: test
+ bot: response3
+
+ +### Example with stop + +#### Responses in keyword test + +- `owner` - response1 +- `mods` - response2 - `stop here` +- `viewers` - response3 + +
+ owneruser: test
+ bot: response1
+ bot: response2
+
+ +
+ moduser: test
+ bot: response2
+
+ +
+ vieweruser: test
+ bot: response3
+ / response 3 is returned, because response2 was not, so + execution was not stopped! / +
+ +## Command filters + +You can add filter for keywords through UI. All filters are checked by **javascript** +engine. + +### Available filters + +Available filters can be found in UI. + +#### Examples + +`$sender == 'soge__'` - run keyword only for soge__ + +`$source == 'discord'` - run keyword only if comes from discord + +`$game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run keyword only when PUBG is set +as game + +`$sender == 'soge__' && $game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run keyword +only for soge__ **and** when game is set to PUBG + +`$sender == 'soge__' || $game == 'PLAYERUNKNOWN'S BATTLEGROUNDS'` - run keyword +only for soge__ **or** when game is set to PUBG + +`$subscribers >= 10` - run keyword when current subscribers count is equal or +greater than 10 + +#### Examples (advanced) + +`$game.toLowerCase() == 'playerunknown's battlegrounds'` - run keyword only when +PUBG is set as game + +`['soge__', 'otheruser'].includes($sender)` - check if sender is soge__ or otheruser + +## Other settings + +### Enable or disable custom keywords system + +`!enable system keywords` | +`!disable system keywords` + +!> Default permission is **CASTERS** \ No newline at end of file diff --git a/backend/docs/systems/levels.md b/backend/docs/systems/levels.md new file mode 100644 index 000000000..787c76ece --- /dev/null +++ b/backend/docs/systems/levels.md @@ -0,0 +1,55 @@ +## Show your level + +`!level` + +!> Default permission is **VIEWERS** + +### Examples + +
+ testuser: !level
+ bot: @testuser, level: 1 (1030 XP), 1470 XP to next level. +
+ +## Buy a level + +`!level buy` + +!> Default permission is **VIEWERS** + +### Examples + +
+ testuser: !level buy
+ bot: @testuser, you bought 1460 XP with 14600 points and reached + level 2. +
+ +
+ testuser: !level buy
+ bot: Sorry @testuser, but you don't have 234370 UEC to buy + 23437 XP for level 5. +
+ +## Change XP of user + +`!level change ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` - username to change XP +- `` - amount of XP to be added or removed + +### Examples + +
+ owner:!level change testuser 100
+ bot: @owner, you changed XP by 100 XP to @testuser. +
+ +
+ owner:!level change testuser -100
+ bot: @owner, you changed XP by -100 XP to @testuser. +
\ No newline at end of file diff --git a/backend/docs/systems/miscellaneous.md b/backend/docs/systems/miscellaneous.md new file mode 100644 index 000000000..d2dee0e52 --- /dev/null +++ b/backend/docs/systems/miscellaneous.md @@ -0,0 +1,22 @@ +## Miscellaneous settings and commands +`!merge from-user to-user` - will move old username to new username + +`!followage ` - how long is user following channel with optional username + +`!age ` - how old is users account + +`!uptime` - how long your stream is online + +`!subs` - show online subs count, last sub and how long ago + +`!followers` - show last follow and how long ago + +`!lastseen ` - when user send a message on your channel + +`!watched ` - how long user watching your stream with optional username + +`!me` - shows points, rank, watched time of user and message count + +`!stats ` - shows points, rank, watched time of user and message count with optional username + +`!top ` - shows top 10 users ordered by [time|points|messages] \ No newline at end of file diff --git a/backend/docs/systems/moderation.md b/backend/docs/systems/moderation.md new file mode 100644 index 000000000..1deee4993 --- /dev/null +++ b/backend/docs/systems/moderation.md @@ -0,0 +1,37 @@ +## Commands +`!permit ` - **MODS** - user will be able +to post a of link without timeout + +You can allow/forbide words in UI `settings->moderation` + +## Allowing and forbidding words + +### Available special characters + +- asterisk -> `*` - zero or more chars +- plus -> `+` - one or more chars + +#### Examples + +- `test*` + - `test` - ✓ + - `test1` - ✓ + - `testa` - ✓ + - `atest` - X +- `test+` + - `test` - X + - `test1` - ✓ + - `testa` - ✓ + - `atest` - X +- `test` + - `test` - ✓ + - `test1` - X + - `testa` - X + - `atest` - X + +## URL whitelisting + +Use this pattern to whitelist your desired url. Change `example.com` to +what you want. + +`(https?:\/\/)?(www\.)?example.com(*)?` or `domain:example.com` diff --git a/backend/docs/systems/permissions.md b/backend/docs/systems/permissions.md new file mode 100644 index 000000000..165e25cd7 --- /dev/null +++ b/backend/docs/systems/permissions.md @@ -0,0 +1,68 @@ +## List permissions + +`!permission list` + +!> Default permission is **CASTERS** + +### Examples + +
+ caster: !permission list
+ bot: List of your permissions:
+ bot: ≥ | Casters | 4300ed23-dca0-4ed9-8014-f5f2f7af55a9
+ bot: ≥ | Moderators | b38c5adb-e912-47e3-937a-89fabd12393a
+ bot: ≥ | VIP | e8490e6e-81ea-400a-b93f-57f55aad8e31
+ bot: ≥ | Subscribers | e3b557e7-c26a-433c-a183-e56c11003ab7
+ bot: ≥ | Viewers | 0efd7b1c-e460-4167-8e06-8aaf2c170311
+
+ +## Add user to exclude list for permissions + +`!permission exclude-add -p -u ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *available values:* list of permission can be obtained by `!permissions list` + or in UI + - **NOTE:** You cannot add user to exclude list of core permissions like + Viewers, Subscribers, etc. You need to create own permission group. +- `-u ` + - username of user who you wish to add to exclude list + +### Examples + +
+ caster: !permission -p YourOwnPermissionGroup soge
+ bot: caster, you added soge to exclude list for permission YourOwnPermissionGroup
+
+ +
+ caster: !permission exclude-add -p Viewers soge
+ bot: caster, you cannot manually exclude user for core permission Viewers
+
+ +## Add user to exclude list for permissions + +`!permission exclude-rm -p -u ` + +!> Default permission is **CASTERS** + +### Parameters + +- `-p ` + - *available values:* list of permission can be obtained by `!permissions list` + or in UI + - **NOTE:** You cannot remove user to exclude list of core permissions like + Viewers, Subscribers, etc. You need to create own permission group. +- `-u ` + - username of user who you wish to remove from exclude list + +### Examples + +
+ caster: !permission exclude-rm -p YourOwnPermissionGroup soge
+ bot: caster, you removed soge from exclude list for permission YourOwnPermissionGroup
+
diff --git a/backend/docs/systems/points.md b/backend/docs/systems/points.md new file mode 100644 index 000000000..d58672246 --- /dev/null +++ b/backend/docs/systems/points.md @@ -0,0 +1,24 @@ +## Points system +### Commands | OWNER +- !points add [username] [number] + - add [number] points to specified [username] +- !points undo [username] + - revert last add, remove or set points operation within 10minutess +- !points remove [username] [number] + - remove [number] points to specified [username] +- !points online [number] + - give or take [number] points to all **online** users +- !points all [number] + - give or take [number] points to all users +- !points set [username] [number] + - set [number] points to specified [username] +- !points get [username] + - get points of [username] +- !makeitrain [number] + - add maximum of [number] points to all **online** users + +### Commands | VIEWER +- !points give [username] [number] + - viewer can give his own [number] points to another [username] +- !points + - print out points of user \ No newline at end of file diff --git a/backend/docs/systems/polls.md b/backend/docs/systems/polls.md new file mode 100644 index 000000000..1fffc1e62 --- /dev/null +++ b/backend/docs/systems/polls.md @@ -0,0 +1,28 @@ +## Create a new poll + +`!poll open -title "Your title here" Option 1 | Option 2 | ...` + +!> Default permission is **MODERATORS** + +## Reuse last poll + +`!poll reuse` + +!> Default permission is **MODERATORS** + +## Close poll + +`!poll close` + +!> Default permission is **MODERATORS** + +### Examples + +
+ owner: !poll open -title "What is better, Star Citizen + or Elite: Dangerous?" Star Citizen | Elite: Dangerous +
+ +## How to vote + +Voting is done through Twitch platform. diff --git a/backend/docs/systems/price.md b/backend/docs/systems/price.md new file mode 100644 index 000000000..3732f36be --- /dev/null +++ b/backend/docs/systems/price.md @@ -0,0 +1,11 @@ +!> Price system will work only when Points system is enabled! Price system will put command behind points paywall. + +### Commands | OWNER +- !price set [command] [number] + - will set price of command to [number] points +- !price list + - bot will print list of created prices +- !price unset [command] + - remove price of command +- !price + - bot will print usage of !price commands \ No newline at end of file diff --git a/backend/docs/systems/queue.md b/backend/docs/systems/queue.md new file mode 100644 index 000000000..bfc916675 --- /dev/null +++ b/backend/docs/systems/queue.md @@ -0,0 +1,9 @@ +## Queue system +| Command | Default permission | Info | +|----------------------|:------------------:|------------------------------------------------| +| !queue | VIEWERS | gets an info whether queue is opened or closed | +| !queue open | OWNER | open a queue | +| !queue close | OWNER | close a queue | +| !queue pick [amount] | OWNER | pick [amount] \(optional) of users from queue | +| !queue join [optional-message] | VIEWERS | join a queue | +| !queue clear | OWNER | clear a queue | \ No newline at end of file diff --git a/backend/docs/systems/quotes.md b/backend/docs/systems/quotes.md new file mode 100644 index 000000000..355dd95e0 --- /dev/null +++ b/backend/docs/systems/quotes.md @@ -0,0 +1,128 @@ +##### Changelog +| Version | Description | +| --------|:--------------------------------------| +| 8.0.0 | First implementation of quotes system | + + +## Add a new quote +`!quote add -tags -quote ` + +!> Default permission is **OWNER** + +### Parameters +- `-tags` - *optional string* - comma-separated tags + - *default value:* general +- `-quote` - *string* - Your quote to save + +### Examples + +
+ testuser: !quote -tags dota 2, funny -quote Sure I'll win!
+ bot: @testuser, quote 1 'Sure I'll win!' was added. (tags: dota 2, funny) +
+ +
+ testuser: !quote add -quote Sure I'll win!
+ bot: @testuser, quote 2 'Sure I'll win!' was added. (tags: general) +
+ +
+ testuser: !quote add -tags dota 2, funny
+ bot: @testuser, !quote add is not correct or missing -quote parameter +
+ +## Remove a quote +`!quote remove -id ` + +!> Default permission is **OWNER** + +### Parameters +- `-id` - *number* - ID of quote you want to delete + +### Examples + +
+ testuser: !quote remove -id 1
+ bot: @testuser, quote 1 was deleted. +
+ +
+ testuser: !quote remove -id a
+ bot: @testuser, quote ID is missing or is not a number. +
+ +
+ testuser: !quote remove -id 999999
+ bot: @testuser, quote 999999 was not found. +
+ +## Show a quote by ID +`!quote -id ` + +!> Default permission is **VIEWER** + +### Parameters +- `-id` - *number* - ID of quote you want to show + +### Examples + +
+ testuser: !quote -id 1
+ bot: Quote 1 by testuser 'Sure I'll win!' +
+ +
+ testuser: !quote -id a
+ bot: @testuser, quote ID is not a number. +
+ +
+ testuser: !quote -id 999999
+ bot: @testuser, quote 999999 was not found. +
+ +## Show a random quote by tag +`!quote -tag ` + +!> Default permission is **VIEWER** + +### Parameters +- `-tag` - *string* - tag, where to get random quote from + +### Examples + +
+ testuser: !quote -tag dota 2
+ bot: Quote 1 by testuser 'Sure I'll win!' +
+ +
+ testuser: !quote -tag nonexisting
+ bot: @testuser, no quotes with tag nonexisting was not found. +
+ +## Set tags for an existing quote +`!quote set -tag -id ` + +!> Default permission is **OWNER** + +### Parameters +- `-tag` - *string* - tag, where to get random quote from +- `-id` - *number* - ID of quote you want to update + +### Examples + +
+ testuser: !quote set -id 2 -tag new tag
+ bot: @testuser, quote 2 tags were set. (tags: new tag) +
+ +## Other settings +### Enable or disable quote system +`!enable system quotes` | +`!disable system quotes` + +!> Default permission is **OWNER** + +### Set URL for !quote list +You need to set your `public URL` in UI `system->quotes`. diff --git a/backend/docs/systems/raffles.md b/backend/docs/systems/raffles.md new file mode 100644 index 000000000..f114fcb00 --- /dev/null +++ b/backend/docs/systems/raffles.md @@ -0,0 +1,14 @@ +## Raffle system +### Commands +`!raffle pick` - **OWNER** - pick or repick a winner of raffle + +`!raffle remove` - **OWNER** - remove raffle without winner + +`!raffle open ![raffle-keyword] [-min #?] [-max #?] [-for subscribers?]` +- open a new raffle with selected keyword, +- -min # - minimal of tickets to join, -max # - max of tickets to join -> ticket raffle +- -for subscribers - who can join raffle, if empty -> everyone + +`!raffle` - **VIEWER** - gets an info about raffle + +`![raffle-keyword]` *or* `![raffle-keyword] ` - **VIEWER** - join a raffle *or* ticket raffle with amount of tickets \ No newline at end of file diff --git a/backend/docs/systems/ranks.md b/backend/docs/systems/ranks.md new file mode 100644 index 000000000..bdb7003cd --- /dev/null +++ b/backend/docs/systems/ranks.md @@ -0,0 +1,17 @@ +### Commands | OWNER + +* !rank add \ \ `add for selected ` +* !rank add-flw \ \ `add for selected ` +* !rank add-sub \ \ `add for selected ` +* !rank rm \ `remove rank for selected ` +* !rank rm-sub \ `remove rank for selected of subscribers` +* !rank list `show rank list` +* !rank list-sub `show rank list for subcribers` +* !rank edit \ \ `edit rank` +* !rank edit-sub \ \ `edit rank for subcribers` +* !rank set \ \ `set custom for ` +* !rank unset \ `unset custom rank for ` + +### Commands | VIEWER + +* !rank `show user rank` diff --git a/backend/docs/systems/scrim.md b/backend/docs/systems/scrim.md new file mode 100644 index 000000000..b30cfbe33 --- /dev/null +++ b/backend/docs/systems/scrim.md @@ -0,0 +1,80 @@ +## Start new scrim countdown + +`!snipe (-c) ` + +!> Default permission is **OWNER** + +### Parameters + +- `-c` + - *optional* + - disables adding match IDs and Current Matches output +- `` + - set type of your scrim, e.g. duo, single, apex, etc. It's + not enforced to specific values. +- `` + - minutes before start + +### Examples + +
+ owner: !snipe duo 1
+ bot: Snipe match (duo) starting in 1 minute
+ bot: Snipe match (duo) starting in 45 seconds
+ bot: Snipe match (duo) starting in 30 seconds
+ bot: Snipe match (duo) starting in 15 seconds
+ bot: Snipe match (duo) starting in 3.
+ bot: Snipe match (duo) starting in 2.
+ bot: Snipe match (duo) starting in 1.
+ bot: Starting now! Go!
+ bot: Please put your match ID in the chat + => !snipe match xxx
+ bot: Current Matches: <empty> +
+ +## Stop countdown + +`!snipe stop` + +!> Default permission is **OWNER** + +### Examples + +
+ owner: !snipe duo 1
+ bot: Snipe match (duo) starting in 1 minute
+ bot: Snipe match (duo) starting in 45 seconds
+ owner: !snipe stop
+ ... no other messages ... +
+ +
+ testuser: !alias edit viewer !nonexisting !points
+ bot: @testuser, alias !nonexisting was not found in database +
+ +## Add matchId to scrim + +`!snipe match ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` - match ID of your match + +### Examples + +
+ bot: Snipe match (duo) starting in 3.
+ bot: Snipe match (duo) starting in 2.
+ bot: Snipe match (duo) starting in 1.
+ bot: Starting now! Go!
+ bot: Please put your match ID in the chat + => !snipe match xxx
+ testuser:!snipe match 123-as-erq
+ testuser2:!snipe match 123-as-erq
+ testuser3:!snipe match 111-as-eee
+ bot: Current Matches: 123-as-erq - testuser, testuser2 | + 111-as-eee - testuser3 +
diff --git a/backend/docs/systems/songs.md b/backend/docs/systems/songs.md new file mode 100644 index 000000000..68c152195 --- /dev/null +++ b/backend/docs/systems/songs.md @@ -0,0 +1,307 @@ +## Enable / Disable song requests + +`!set systems.songs.songrequest ` + +!> Default permission is **CASTERS** + +## Enable / Disable playing song from playlist + +`!set systems.songs.playlist ` + +!> Default permission is **CASTERS** + +## Ban a song + +`!bansong ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - *optional YouTube videoID* + - *default value:* current playing song + +### Examples + +
+ testuser: !bansong
+ bot: @testuser, Song Unknown Brain & Kyle Reynolds - I'm Sorry + Mom [NCS Release] was banned and will never play again!
+ / if user requested song, he will got timeout: You've got timeout for posting + banned song / +
+ +
+ testuser: !bansong s0YJhnVEgMw
+ bot: @testuser, Song Rival - Lonely Way (ft. Caravn) + [NCS Release] was banned and will never play again!
+ / if user requested song, he will got timeout: You've got timeout for posting + banned song / +
+ +## Unban a song + +`!unbansong ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - *YouTube videoID* + +### Examples + +
+ testuser: !unbansong UtE7hYZo8Lo
+ bot: @testuser, This song was not banned.
+
+ +
+ testuser: !unbansong s0YJhnVEgMw
+ bot: @testuser, Song was succesfully unbanned.
+
+ +## Skip currently playing song + +`!skipsong` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser: !skipsong
+ / no response from bot expected /
+ + +## Show currently playing song + +`!currentsong` + +!> Default permission is **VIEWERS** + +### Examples + +
+ testuser: !currentsong
+ bot: testuser, No song is currently playing
+
+ +
+ testuser: !currentsong
+ bot: testuser, Current song is Syn Cole - Time [NCS Release] + from playlist
+
+ +
+ testuser: !currentsong
+ bot: Current song is Rogers & Dean - Jungle [NCS Release] + requested by testuser2
+
+ +## Show current playlist + +`!playlist` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser: !playlist
+ bot: testuser, current playlist is general.
+
+ +## List available playlist + +`!playlist list` + +!> Default permission is **CASTER** + +### Examples + +
+ testuser: !playlist list
+ bot: testuser, available playlists: general, chill, test.
+
+ +## Add song from playlist + +`!playlist add ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - use YouTube videoID, video URL or search string + - Examples: + - QnL5P0tFkwM + - https://www.youtube.com/watch?v=QnL5P0tFkwM + - http://youtu.be/QnL5P0tFkwM + - Rogers & Dean - Jungle + +### Examples + +
+ testuser: !playlist add QnL5P0tFkwM
+ bot: testuser, song Rogers & Dean - Jungle [NCS Release] + was added to playlist.
+
+ +## Remove song from playlist + +`!playlist remove ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - use YouTube videoID + - Examples: + - QnL5P0tFkwM + +### Examples + +
+ testuser: !playlist remove QnL5P0tFkwM
+ bot: testuser, song Rogers & Dean - Jungle [NCS Release] + was removed from playlist.
+
+ +## Import YouTube playlist into playlist + +`!playlist import ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - use YouTube playlist link + - Examples: + - https://www.youtube.com/watch?list=PLGBuKfnErZlD_VXiQ8dkn6wdEYHbC3u0i + +### Examples + +
+ testuser: !playlist remove QnL5P0tFkwM
+ bot: testuser, song Rogers & Dean - Jungle [NCS Release] + was removed from playlist.
+
+ +## Steal current song to playlist + +`!playlist steal` + +!> Default permission is **CASTERS** + +### Examples + +
+ testuser: !playlist steal
+ bot: testuser, No song is currently playing.
+
+ +
+ testuser: !playlist steal
+ bot: testuser, song Max Brhon - Pain [NCS Release] was added + to playlist.
+
+ +## Change current playlist + +`!playlist set ` + +!> Default permission is **CASTERS** + +### Parameters + +- `` + - your desired playlist to play + +### Examples + +
+ testuser: !playlist set general
+ bot: testuser, you changed playlist to general.
+
+ +
+ testuser: !playlist set thisdoesntexist
+ bot: testuser, your requested playlist thisdoesntexist + doesn't exist.
+
+ +## Request song + +`!songrequest ` + +!> Default permission is **VIEWERS** + +### Parameters + +- `` + - use YouTube videoID, video URL or search string + - Examples: + - QnL5P0tFkwM + - https://www.youtube.com/watch?v=QnL5P0tFkwM + - http://youtu.be/QnL5P0tFkwM + - Rogers & Dean - Jungle + +### Examples + +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: Sorry, testuser, song requests are disabled
+
+ +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: Sorry, testuser, but this song is banned
+
+ +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: Sorry, testuser, but this song is too long
+
+ +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: Sorry, testuser, but this song must be music category
+
+ +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: testuser, song Rogers & Dean - Jungle [NCS Release] + was added to queue
+
+ +## User skip own requested song + +`!wrongsong` + +!> Default permission is **VIEWERS** + +### Examples + +
+ testuser: !songrequest QnL5P0tFkwM
+ bot: testuser, song Rogers & Dean - Jungle [NCS Release] + was added to queue
+ testuser: !wrongsong
+ bot: testuser, your song Rogers & Dean - Jungle [NCS Release] + was removed from queue
+
+ +## Other settings + +### Enable or disable songs system + +`!enable system songs` | +`!disable system songs` + +!> Default permission is **OWNER** diff --git a/backend/docs/systems/timers.md b/backend/docs/systems/timers.md new file mode 100644 index 000000000..baba2fb91 --- /dev/null +++ b/backend/docs/systems/timers.md @@ -0,0 +1,21 @@ +## Timers system +Timers system will periodically print out set responses, when certain requirements are met. + +### Commands | OWNER +`!timers set -name [name-of-timer] -messages [num-of-msgs-to-trigger|default:0] -seconds [trigger-every-x-seconds|default:60] [-offline]` - will create or update timers with specified requirements to meet + +`!timers unset -name [name-of-timer]` - remove timer and all responses + +`!timers add -name [name-of-timer] -response '[response]'` - add response to specified timer + +`!timers rm -id [id-of-response]` - remove response by id + +`!timers list` - return list of timers + +`!timers list -name [name-of-timer]` - return responses of specified timer + +`!timers toggle -name [name-of-timer]` - enable/disable specified timer + +`!timers toggle -id [id-of-response]` - enable/disable specified response + +`!timers` - show timers system usage help \ No newline at end of file diff --git a/backend/docs/systems/top.md b/backend/docs/systems/top.md new file mode 100644 index 000000000..773f2f7b3 --- /dev/null +++ b/backend/docs/systems/top.md @@ -0,0 +1,47 @@ +## Top 10 by time + +`!top time` + +!> Default permission is **OWNER** + +## Top 10 by tips + +`!top tips` + +!> Default permission is **OWNER** + +## Top 10 by points + +`!top points` + +!> Default permission is **OWNER** + +## Top 10 by messages + +`!top messages` + +!> Default permission is **OWNER** + +## Top 10 by subage + +`!top subage` + +!> Default permission is **OWNER** + +## Top 10 by bits + +`!top bits` + +!> Default permission is **OWNER** + +## Top 10 by sub gifts + +`!top gifts` + +!> Default permission is **OWNER** + +## Top 10 by sub months + +`!top submonths` + +!> Default permission is **OWNER** \ No newline at end of file diff --git a/backend/favicon.ico b/backend/favicon.ico new file mode 100644 index 000000000..164216a91 Binary files /dev/null and b/backend/favicon.ico differ diff --git a/backend/fonts.json b/backend/fonts.json new file mode 100644 index 000000000..09abfda0f --- /dev/null +++ b/backend/fonts.json @@ -0,0 +1,30332 @@ +{ + "kind": "webfonts#webfontList", + "items": [ + { + "family": "ABeeZee", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/abeezee/v20/esDR31xSG-6AGleN6tKukbcHCpE.ttf", + "italic": "http://fonts.gstatic.com/s/abeezee/v20/esDT31xSG-6AGleN2tCklZUCGpG-GQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Abel", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2020-09-10", + "files": { + "regular": "http://fonts.gstatic.com/s/abel/v12/MwQ5bhbm2POE6VhLPJp6qGI.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Abhaya Libre", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext", + "sinhala" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/abhayalibre/v11/e3tmeuGtX-Co5MNzeAOqinEge0PWovdU4w.ttf", + "500": "http://fonts.gstatic.com/s/abhayalibre/v11/e3t5euGtX-Co5MNzeAOqinEYj2ryqtxI6oYtBA.ttf", + "600": "http://fonts.gstatic.com/s/abhayalibre/v11/e3t5euGtX-Co5MNzeAOqinEYo23yqtxI6oYtBA.ttf", + "700": "http://fonts.gstatic.com/s/abhayalibre/v11/e3t5euGtX-Co5MNzeAOqinEYx2zyqtxI6oYtBA.ttf", + "800": "http://fonts.gstatic.com/s/abhayalibre/v11/e3t5euGtX-Co5MNzeAOqinEY22_yqtxI6oYtBA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Abril Fatface", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/abrilfatface/v18/zOL64pLDlL1D99S8g8PtiKchm-BsjOLhZBY.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Aclonica", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/aclonica/v16/K2FyfZJVlfNNSEBXGb7TCI6oBjLz.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Acme", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/acme/v17/RrQfboBx-C5_bx3Lb23lzLk.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Actor", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/actor/v15/wEOzEBbCkc5cO3ekXygtUMIO.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Adamina", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/adamina/v19/j8_r6-DH1bjoc-dwu-reETl4Bno.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Advent Pro", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "greek", + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/adventpro/v16/V8mCoQfxVT4Dvddr_yOwjVmtLZxcBtItFw.ttf", + "200": "http://fonts.gstatic.com/s/adventpro/v16/V8mDoQfxVT4Dvddr_yOwjfWMDbZyCts0DqQ.ttf", + "300": "http://fonts.gstatic.com/s/adventpro/v16/V8mDoQfxVT4Dvddr_yOwjZGPDbZyCts0DqQ.ttf", + "regular": "http://fonts.gstatic.com/s/adventpro/v16/V8mAoQfxVT4Dvddr_yOwtT2nKb5ZFtI.ttf", + "500": "http://fonts.gstatic.com/s/adventpro/v16/V8mDoQfxVT4Dvddr_yOwjcmODbZyCts0DqQ.ttf", + "600": "http://fonts.gstatic.com/s/adventpro/v16/V8mDoQfxVT4Dvddr_yOwjeWJDbZyCts0DqQ.ttf", + "700": "http://fonts.gstatic.com/s/adventpro/v16/V8mDoQfxVT4Dvddr_yOwjYGIDbZyCts0DqQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Aguafina Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/aguafinascript/v14/If2QXTv_ZzSxGIO30LemWEOmt1bHqs4pgicOrg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Akaya Kanadaka", + "variants": [ + "regular" + ], + "subsets": [ + "kannada", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/akayakanadaka/v14/N0bM2S5CPO5oOQqvazoRRb-8-PfRS5VBBSSF.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Akaya Telivigala", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "telugu" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/akayatelivigala/v20/lJwc-oo_iG9wXqU3rCTD395tp0uifdLdsIH0YH8.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Akronim", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/akronim/v12/fdN-9sqWtWZZlHRp-gBxkFYN-a8.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Aladin", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/aladin/v14/ZgNSjPJFPrvJV5f16Sf4pGT2Ng.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Alata", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/alata/v7/PbytFmztEwbIofe6xKcRQEOX.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Alatsi", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/alatsi/v7/TK3iWkUJAxQ2nLNGHjUHte5fKg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Aldrich", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/aldrich/v11/MCoTzAn-1s3IGyJMZaAS3pP5H_E.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Alef", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "hebrew", + "latin" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/alef/v17/FeVfS0NQpLYgrjJbC5FxxbU.ttf", + "700": "http://fonts.gstatic.com/s/alef/v17/FeVQS0NQpLYglo50L5la2bxii28.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Alegreya", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v26", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/alegreya/v26/4UacrEBBsBhlBjvfkQjt71kZfyBzPgNG9hUI_KCisSGVrw.ttf", + "500": "http://fonts.gstatic.com/s/alegreya/v26/4UacrEBBsBhlBjvfkQjt71kZfyBzPgNGxBUI_KCisSGVrw.ttf", + "600": "http://fonts.gstatic.com/s/alegreya/v26/4UacrEBBsBhlBjvfkQjt71kZfyBzPgNGKBII_KCisSGVrw.ttf", + "700": "http://fonts.gstatic.com/s/alegreya/v26/4UacrEBBsBhlBjvfkQjt71kZfyBzPgNGERII_KCisSGVrw.ttf", + "800": "http://fonts.gstatic.com/s/alegreya/v26/4UacrEBBsBhlBjvfkQjt71kZfyBzPgNGdhII_KCisSGVrw.ttf", + "900": "http://fonts.gstatic.com/s/alegreya/v26/4UacrEBBsBhlBjvfkQjt71kZfyBzPgNGXxII_KCisSGVrw.ttf", + "italic": "http://fonts.gstatic.com/s/alegreya/v26/4UaSrEBBsBhlBjvfkSLk3abBFkvpkARTPlbgv6qmkySFr9V9.ttf", + "500italic": "http://fonts.gstatic.com/s/alegreya/v26/4UaSrEBBsBhlBjvfkSLk3abBFkvpkARTPlbSv6qmkySFr9V9.ttf", + "600italic": "http://fonts.gstatic.com/s/alegreya/v26/4UaSrEBBsBhlBjvfkSLk3abBFkvpkARTPlY-uKqmkySFr9V9.ttf", + "700italic": "http://fonts.gstatic.com/s/alegreya/v26/4UaSrEBBsBhlBjvfkSLk3abBFkvpkARTPlYHuKqmkySFr9V9.ttf", + "800italic": "http://fonts.gstatic.com/s/alegreya/v26/4UaSrEBBsBhlBjvfkSLk3abBFkvpkARTPlZguKqmkySFr9V9.ttf", + "900italic": "http://fonts.gstatic.com/s/alegreya/v26/4UaSrEBBsBhlBjvfkSLk3abBFkvpkARTPlZJuKqmkySFr9V9.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Alegreya SC", + "variants": [ + "regular", + "italic", + "500", + "500italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v20", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/alegreyasc/v20/taiOGmRtCJ62-O0HhNEa-a6o05E5abe_.ttf", + "italic": "http://fonts.gstatic.com/s/alegreyasc/v20/taiMGmRtCJ62-O0HhNEa-Z6q2ZUbbKe_DGs.ttf", + "500": "http://fonts.gstatic.com/s/alegreyasc/v20/taiTGmRtCJ62-O0HhNEa-ZZc-rUxQqu2FXKD.ttf", + "500italic": "http://fonts.gstatic.com/s/alegreyasc/v20/taiRGmRtCJ62-O0HhNEa-Z6q4WEySK-UEGKDBz4.ttf", + "700": "http://fonts.gstatic.com/s/alegreyasc/v20/taiTGmRtCJ62-O0HhNEa-ZYU_LUxQqu2FXKD.ttf", + "700italic": "http://fonts.gstatic.com/s/alegreyasc/v20/taiRGmRtCJ62-O0HhNEa-Z6q4Sk0SK-UEGKDBz4.ttf", + "800": "http://fonts.gstatic.com/s/alegreyasc/v20/taiTGmRtCJ62-O0HhNEa-ZYI_7UxQqu2FXKD.ttf", + "800italic": "http://fonts.gstatic.com/s/alegreyasc/v20/taiRGmRtCJ62-O0HhNEa-Z6q4TU3SK-UEGKDBz4.ttf", + "900": "http://fonts.gstatic.com/s/alegreyasc/v20/taiTGmRtCJ62-O0HhNEa-ZYs_rUxQqu2FXKD.ttf", + "900italic": "http://fonts.gstatic.com/s/alegreyasc/v20/taiRGmRtCJ62-O0HhNEa-Z6q4RE2SK-UEGKDBz4.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Alegreya Sans", + "variants": [ + "100", + "100italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v20", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUt9_-1phKLFgshYDvh6Vwt5TltuGdShm5bsg.ttf", + "100italic": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUv9_-1phKLFgshYDvh6Vwt7V9V3G1WpGtLsgu7.ttf", + "300": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUu9_-1phKLFgshYDvh6Vwt5fFPmE18imdCqxI.ttf", + "300italic": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUo9_-1phKLFgshYDvh6Vwt7V9VFE92jkVHuxKiBA.ttf", + "regular": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUz9_-1phKLFgshYDvh6Vwt3V1nvEVXlm4.ttf", + "italic": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUt9_-1phKLFgshYDvh6Vwt7V9tuGdShm5bsg.ttf", + "500": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUu9_-1phKLFgshYDvh6Vwt5alOmE18imdCqxI.ttf", + "500italic": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUo9_-1phKLFgshYDvh6Vwt7V9VTE52jkVHuxKiBA.ttf", + "700": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUu9_-1phKLFgshYDvh6Vwt5eFImE18imdCqxI.ttf", + "700italic": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUo9_-1phKLFgshYDvh6Vwt7V9VBEh2jkVHuxKiBA.ttf", + "800": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUu9_-1phKLFgshYDvh6Vwt5f1LmE18imdCqxI.ttf", + "800italic": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUo9_-1phKLFgshYDvh6Vwt7V9VGEt2jkVHuxKiBA.ttf", + "900": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUu9_-1phKLFgshYDvh6Vwt5dlKmE18imdCqxI.ttf", + "900italic": "http://fonts.gstatic.com/s/alegreyasans/v20/5aUo9_-1phKLFgshYDvh6Vwt7V9VPEp2jkVHuxKiBA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Alegreya Sans SC", + "variants": [ + "100", + "100italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGn4-RGJqfMvt7P8FUr0Q1j-Hf1Dipl8g5FPYtmMg.ttf", + "100italic": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGl4-RGJqfMvt7P8FUr0Q1j-Hf1BkxdlgRBH452Mvds.ttf", + "300": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGm4-RGJqfMvt7P8FUr0Q1j-Hf1DuJH0iRrMYJ_K-4.ttf", + "300italic": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGk4-RGJqfMvt7P8FUr0Q1j-Hf1BkxdXiZhNaB6O-51OA.ttf", + "regular": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGh4-RGJqfMvt7P8FUr0Q1j-Hf1Nk5v9ixALYs.ttf", + "italic": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGn4-RGJqfMvt7P8FUr0Q1j-Hf1Bkxl8g5FPYtmMg.ttf", + "500": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGm4-RGJqfMvt7P8FUr0Q1j-Hf1DrpG0iRrMYJ_K-4.ttf", + "500italic": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGk4-RGJqfMvt7P8FUr0Q1j-Hf1BkxdBidhNaB6O-51OA.ttf", + "700": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGm4-RGJqfMvt7P8FUr0Q1j-Hf1DvJA0iRrMYJ_K-4.ttf", + "700italic": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGk4-RGJqfMvt7P8FUr0Q1j-Hf1BkxdTiFhNaB6O-51OA.ttf", + "800": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGm4-RGJqfMvt7P8FUr0Q1j-Hf1Du5D0iRrMYJ_K-4.ttf", + "800italic": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGk4-RGJqfMvt7P8FUr0Q1j-Hf1BkxdUiJhNaB6O-51OA.ttf", + "900": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGm4-RGJqfMvt7P8FUr0Q1j-Hf1DspC0iRrMYJ_K-4.ttf", + "900italic": "http://fonts.gstatic.com/s/alegreyasanssc/v18/mtGk4-RGJqfMvt7P8FUr0Q1j-Hf1BkxddiNhNaB6O-51OA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Aleo", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-25", + "files": { + "300": "http://fonts.gstatic.com/s/aleo/v9/c4mg1nF8G8_syKbr9DVDno985KM.ttf", + "300italic": "http://fonts.gstatic.com/s/aleo/v9/c4mi1nF8G8_swAjxeDdJmq159KOnWA.ttf", + "regular": "http://fonts.gstatic.com/s/aleo/v9/c4mv1nF8G8_s8ArD0D1ogoY.ttf", + "italic": "http://fonts.gstatic.com/s/aleo/v9/c4mh1nF8G8_swAjJ1B9tkoZl_Q.ttf", + "700": "http://fonts.gstatic.com/s/aleo/v9/c4mg1nF8G8_syLbs9DVDno985KM.ttf", + "700italic": "http://fonts.gstatic.com/s/aleo/v9/c4mi1nF8G8_swAjxaDBJmq159KOnWA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Alex Brush", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/alexbrush/v18/SZc83FzrJKuqFbwMKk6EtUL57DtOmCc.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Alfa Slab One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/alfaslabone/v16/6NUQ8FmMKwSEKjnm5-4v-4Jh6dVretWvYmE.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Alice", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/alice/v18/OpNCnoEEmtHa6FcJpA_chzJ0.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Alike", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/alike/v18/HI_EiYEYI6BIoEjBSZXAQ4-d.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Alike Angular", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/alikeangular/v18/3qTrojWunjGQtEBlIcwMbSoI3kM6bB7FKjE.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Allan", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/allan/v18/ea8XadU7WuTxEtb2P9SF8nZE.ttf", + "700": "http://fonts.gstatic.com/s/allan/v18/ea8aadU7WuTxEu5KEPCN2WpNgEKU.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Allerta", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/allerta/v16/TwMO-IAHRlkbx940UnEdSQqO5uY.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Allerta Stencil", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/allertastencil/v16/HTx0L209KT-LmIE9N7OR6eiycOeF-zz313DuvQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Allison", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/allison/v7/X7nl4b88AP2nkbvZOCaQ4MTgAgk.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Allura", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2021-11-04", + "files": { + "regular": "http://fonts.gstatic.com/s/allura/v15/9oRPNYsQpS4zjuAPjAIXPtrrGA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Almarai", + "variants": [ + "300", + "regular", + "700", + "800" + ], + "subsets": [ + "arabic" + ], + "version": "v11", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/almarai/v11/tssoApxBaigK_hnnS_anhnicoq72sXg.ttf", + "regular": "http://fonts.gstatic.com/s/almarai/v11/tsstApxBaigK_hnnc1qPonC3vqc.ttf", + "700": "http://fonts.gstatic.com/s/almarai/v11/tssoApxBaigK_hnnS-aghnicoq72sXg.ttf", + "800": "http://fonts.gstatic.com/s/almarai/v11/tssoApxBaigK_hnnS_qjhnicoq72sXg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Almendra", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/almendra/v20/H4ckBXKAlMnTn0CskyY6wr-wg763.ttf", + "italic": "http://fonts.gstatic.com/s/almendra/v20/H4ciBXKAlMnTn0CskxY4yLuShq63czE.ttf", + "700": "http://fonts.gstatic.com/s/almendra/v20/H4cjBXKAlMnTn0Cskx6G7Zu4qKK-aihq.ttf", + "700italic": "http://fonts.gstatic.com/s/almendra/v20/H4chBXKAlMnTn0CskxY48Ae9oqacbzhqDtg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Almendra Display", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/almendradisplay/v23/0FlPVOGWl1Sb4O3tETtADHRRlZhzXS_eTyer338.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Almendra SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/almendrasc/v23/Iure6Yx284eebowr7hbyTZZJprVA4XQ0.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Alumni Sans", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/alumnisans/v8/nwpHtKqkOwdO2aOIwhWudEWpx_zq_Xna-Xd9OO5QqFsJ3C8qng.ttf", + "200": "http://fonts.gstatic.com/s/alumnisans/v8/nwpHtKqkOwdO2aOIwhWudEWpx_zq_Xna-Xd9uO9QqFsJ3C8qng.ttf", + "300": "http://fonts.gstatic.com/s/alumnisans/v8/nwpHtKqkOwdO2aOIwhWudEWpx_zq_Xna-Xd9Zu9QqFsJ3C8qng.ttf", + "regular": "http://fonts.gstatic.com/s/alumnisans/v8/nwpHtKqkOwdO2aOIwhWudEWpx_zq_Xna-Xd9OO9QqFsJ3C8qng.ttf", + "500": "http://fonts.gstatic.com/s/alumnisans/v8/nwpHtKqkOwdO2aOIwhWudEWpx_zq_Xna-Xd9Cu9QqFsJ3C8qng.ttf", + "600": "http://fonts.gstatic.com/s/alumnisans/v8/nwpHtKqkOwdO2aOIwhWudEWpx_zq_Xna-Xd95uhQqFsJ3C8qng.ttf", + "700": "http://fonts.gstatic.com/s/alumnisans/v8/nwpHtKqkOwdO2aOIwhWudEWpx_zq_Xna-Xd93-hQqFsJ3C8qng.ttf", + "800": "http://fonts.gstatic.com/s/alumnisans/v8/nwpHtKqkOwdO2aOIwhWudEWpx_zq_Xna-Xd9uOhQqFsJ3C8qng.ttf", + "900": "http://fonts.gstatic.com/s/alumnisans/v8/nwpHtKqkOwdO2aOIwhWudEWpx_zq_Xna-Xd9kehQqFsJ3C8qng.ttf", + "100italic": "http://fonts.gstatic.com/s/alumnisans/v8/nwpBtKqkOwdO2aOIwhWudG-g9QMylBJAV3Bo8Ky46lEN_io6npfB.ttf", + "200italic": "http://fonts.gstatic.com/s/alumnisans/v8/nwpBtKqkOwdO2aOIwhWudG-g9QMylBJAV3Bo8Kw461EN_io6npfB.ttf", + "300italic": "http://fonts.gstatic.com/s/alumnisans/v8/nwpBtKqkOwdO2aOIwhWudG-g9QMylBJAV3Bo8Kzm61EN_io6npfB.ttf", + "italic": "http://fonts.gstatic.com/s/alumnisans/v8/nwpBtKqkOwdO2aOIwhWudG-g9QMylBJAV3Bo8Ky461EN_io6npfB.ttf", + "500italic": "http://fonts.gstatic.com/s/alumnisans/v8/nwpBtKqkOwdO2aOIwhWudG-g9QMylBJAV3Bo8KyK61EN_io6npfB.ttf", + "600italic": "http://fonts.gstatic.com/s/alumnisans/v8/nwpBtKqkOwdO2aOIwhWudG-g9QMylBJAV3Bo8Kxm7FEN_io6npfB.ttf", + "700italic": "http://fonts.gstatic.com/s/alumnisans/v8/nwpBtKqkOwdO2aOIwhWudG-g9QMylBJAV3Bo8Kxf7FEN_io6npfB.ttf", + "800italic": "http://fonts.gstatic.com/s/alumnisans/v8/nwpBtKqkOwdO2aOIwhWudG-g9QMylBJAV3Bo8Kw47FEN_io6npfB.ttf", + "900italic": "http://fonts.gstatic.com/s/alumnisans/v8/nwpBtKqkOwdO2aOIwhWudG-g9QMylBJAV3Bo8KwR7FEN_io6npfB.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Amarante", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/amarante/v20/xMQXuF1KTa6EvGx9bq-3C3rAmD-b.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Amaranth", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/amaranth/v16/KtkuALODe433f0j1zPnCF9GqwnzW.ttf", + "italic": "http://fonts.gstatic.com/s/amaranth/v16/KtkoALODe433f0j1zMnAHdWIx2zWD4I.ttf", + "700": "http://fonts.gstatic.com/s/amaranth/v16/KtkpALODe433f0j1zMF-OPWi6WDfFpuc.ttf", + "700italic": "http://fonts.gstatic.com/s/amaranth/v16/KtkrALODe433f0j1zMnAJWmn42T9E4ucRY8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Amatic SC", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "cyrillic", + "hebrew", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/amaticsc/v22/TUZyzwprpvBS1izr_vO0De6ecZQf1A.ttf", + "700": "http://fonts.gstatic.com/s/amaticsc/v22/TUZ3zwprpvBS1izr_vOMscG6eb8D3WTy-A.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Amethysta", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/amethysta/v14/rP2Fp2K15kgb_F3ibfWIGDWCBl0O8Q.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Amiko", + "variants": [ + "regular", + "600", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/amiko/v10/WwkQxPq1DFK04tqlc17MMZgJ.ttf", + "600": "http://fonts.gstatic.com/s/amiko/v10/WwkdxPq1DFK04uJ9XXrEGoQAUco5.ttf", + "700": "http://fonts.gstatic.com/s/amiko/v10/WwkdxPq1DFK04uIZXHrEGoQAUco5.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Amiri", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext" + ], + "version": "v23", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/amiri/v23/J7aRnpd8CGxBHqUpvrIw74NL.ttf", + "italic": "http://fonts.gstatic.com/s/amiri/v23/J7afnpd8CGxBHpUrtLYS6pNLAjk.ttf", + "700": "http://fonts.gstatic.com/s/amiri/v23/J7acnpd8CGxBHp2VkZY4xJ9CGyAa.ttf", + "700italic": "http://fonts.gstatic.com/s/amiri/v23/J7aanpd8CGxBHpUrjAo9zptgHjAavCA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Amita", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/amita/v14/HhyaU5si9Om7PQlvAfSKEZZL.ttf", + "700": "http://fonts.gstatic.com/s/amita/v14/HhyXU5si9Om7PTHTLtCCOopCTKkI.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Anaheim", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/anaheim/v8/8vII7w042Wp87g4G0UTUEE5eK_w.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Andada Pro", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "italic", + "500italic", + "600italic", + "700italic", + "800italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/andadapro/v9/HhyEU5Qi9-SuOEhPe4LtKoVCuWGURPcg3DPJBY8cFLzvIt2S.ttf", + "500": "http://fonts.gstatic.com/s/andadapro/v9/HhyEU5Qi9-SuOEhPe4LtKoVCuWGURPcg3DP7BY8cFLzvIt2S.ttf", + "600": "http://fonts.gstatic.com/s/andadapro/v9/HhyEU5Qi9-SuOEhPe4LtKoVCuWGURPcg3DMXAo8cFLzvIt2S.ttf", + "700": "http://fonts.gstatic.com/s/andadapro/v9/HhyEU5Qi9-SuOEhPe4LtKoVCuWGURPcg3DMuAo8cFLzvIt2S.ttf", + "800": "http://fonts.gstatic.com/s/andadapro/v9/HhyEU5Qi9-SuOEhPe4LtKoVCuWGURPcg3DNJAo8cFLzvIt2S.ttf", + "italic": "http://fonts.gstatic.com/s/andadapro/v9/HhyGU5Qi9-SuOEhPe4LtAIxwRrn9L22O2yYBRmdfHrjNJ82Stjw.ttf", + "500italic": "http://fonts.gstatic.com/s/andadapro/v9/HhyGU5Qi9-SuOEhPe4LtAIxwRrn9L22O2yYBRlVfHrjNJ82Stjw.ttf", + "600italic": "http://fonts.gstatic.com/s/andadapro/v9/HhyGU5Qi9-SuOEhPe4LtAIxwRrn9L22O2yYBRrlYHrjNJ82Stjw.ttf", + "700italic": "http://fonts.gstatic.com/s/andadapro/v9/HhyGU5Qi9-SuOEhPe4LtAIxwRrn9L22O2yYBRoBYHrjNJ82Stjw.ttf", + "800italic": "http://fonts.gstatic.com/s/andadapro/v9/HhyGU5Qi9-SuOEhPe4LtAIxwRrn9L22O2yYBRudYHrjNJ82Stjw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Andika", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/andika/v17/mem_Ya6iyW-LwqgAbbwRWrwGVA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Andika New Basic", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/andikanewbasic/v15/taiRGn9tCp-44eleq5Q-mszJivxSSK-UEGKDBz4.ttf", + "italic": "http://fonts.gstatic.com/s/andikanewbasic/v15/taiXGn9tCp-44eleq5Q-mszJivxSeK2eFECGFz5VCg.ttf", + "700": "http://fonts.gstatic.com/s/andikanewbasic/v15/taiWGn9tCp-44eleq5Q-mszJivxScBO7NGqoGzdME84.ttf", + "700italic": "http://fonts.gstatic.com/s/andikanewbasic/v15/taiUGn9tCp-44eleq5Q-mszJivxSeK2mqG-iHxVJA85Okw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Angkor", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v26", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/angkor/v26/H4cmBXyAlsPdnlb-8iw-4Lqggw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Annie Use Your Telescope", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/annieuseyourtelescope/v16/daaLSS4tI2qYYl3Jq9s_Hu74xwktnlKxH6osGVGjlDfB3UUVZA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Anonymous Pro", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "greek", + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/anonymouspro/v19/rP2Bp2a15UIB7Un-bOeISG3pLlw89CH98Ko.ttf", + "italic": "http://fonts.gstatic.com/s/anonymouspro/v19/rP2fp2a15UIB7Un-bOeISG3pHl428AP44Kqr2Q.ttf", + "700": "http://fonts.gstatic.com/s/anonymouspro/v19/rP2cp2a15UIB7Un-bOeISG3pFuAT0CnW7KOywKo.ttf", + "700italic": "http://fonts.gstatic.com/s/anonymouspro/v19/rP2ap2a15UIB7Un-bOeISG3pHl4OTCzc6IG30KqB9Q.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Antic", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/antic/v17/TuGfUVB8XY5DRaZLodgzydtk.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Antic Didone", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/anticdidone/v14/RWmPoKKX6u8sp8fIWdnDKqDiqYsGBGBzCw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Antic Slab", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/anticslab/v15/bWt97fPFfRzkCa9Jlp6IWcJWXW5p5Qo.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Anton", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/anton/v22/1Ptgg87LROyAm0K08i4gS7lu.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Antonio", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/antonio/v8/gNMbW3NwSYq_9WD34ngK5F8vR8T0PVxx8BtIY2DwSXlM.ttf", + "200": "http://fonts.gstatic.com/s/antonio/v8/gNMbW3NwSYq_9WD34ngK5F8vR8T0PVzx8RtIY2DwSXlM.ttf", + "300": "http://fonts.gstatic.com/s/antonio/v8/gNMbW3NwSYq_9WD34ngK5F8vR8T0PVwv8RtIY2DwSXlM.ttf", + "regular": "http://fonts.gstatic.com/s/antonio/v8/gNMbW3NwSYq_9WD34ngK5F8vR8T0PVxx8RtIY2DwSXlM.ttf", + "500": "http://fonts.gstatic.com/s/antonio/v8/gNMbW3NwSYq_9WD34ngK5F8vR8T0PVxD8RtIY2DwSXlM.ttf", + "600": "http://fonts.gstatic.com/s/antonio/v8/gNMbW3NwSYq_9WD34ngK5F8vR8T0PVyv9htIY2DwSXlM.ttf", + "700": "http://fonts.gstatic.com/s/antonio/v8/gNMbW3NwSYq_9WD34ngK5F8vR8T0PVyW9htIY2DwSXlM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Arapey", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/arapey/v14/-W__XJn-UDDA2RC6Z9AcZkIzeg.ttf", + "italic": "http://fonts.gstatic.com/s/arapey/v14/-W_9XJn-UDDA2RCKZdoYREcjeo0k.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Arbutus", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/arbutus/v22/NaPYcZ7dG_5J3poob9JtryO8fMU.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Arbutus Slab", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/arbutusslab/v14/oY1Z8e7OuLXkJGbXtr5ba7ZVa68dJlaFAQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Architects Daughter", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/architectsdaughter/v17/KtkxAKiDZI_td1Lkx62xHZHDtgO_Y-bvfY5q4szgE-Q.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Archivo", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2021-08-18", + "files": { + "100": "http://fonts.gstatic.com/s/archivo/v9/k3k6o8UDI-1M0wlSV9XAw6lQkqWY8Q82sJaRE-NWIDdgffTTNDJp8B1oJ0vyVQ.ttf", + "200": "http://fonts.gstatic.com/s/archivo/v9/k3k6o8UDI-1M0wlSV9XAw6lQkqWY8Q82sJaRE-NWIDdgffTTtDNp8B1oJ0vyVQ.ttf", + "300": "http://fonts.gstatic.com/s/archivo/v9/k3k6o8UDI-1M0wlSV9XAw6lQkqWY8Q82sJaRE-NWIDdgffTTajNp8B1oJ0vyVQ.ttf", + "regular": "http://fonts.gstatic.com/s/archivo/v9/k3k6o8UDI-1M0wlSV9XAw6lQkqWY8Q82sJaRE-NWIDdgffTTNDNp8B1oJ0vyVQ.ttf", + "500": "http://fonts.gstatic.com/s/archivo/v9/k3k6o8UDI-1M0wlSV9XAw6lQkqWY8Q82sJaRE-NWIDdgffTTBjNp8B1oJ0vyVQ.ttf", + "600": "http://fonts.gstatic.com/s/archivo/v9/k3k6o8UDI-1M0wlSV9XAw6lQkqWY8Q82sJaRE-NWIDdgffTT6jRp8B1oJ0vyVQ.ttf", + "700": "http://fonts.gstatic.com/s/archivo/v9/k3k6o8UDI-1M0wlSV9XAw6lQkqWY8Q82sJaRE-NWIDdgffTT0zRp8B1oJ0vyVQ.ttf", + "800": "http://fonts.gstatic.com/s/archivo/v9/k3k6o8UDI-1M0wlSV9XAw6lQkqWY8Q82sJaRE-NWIDdgffTTtDRp8B1oJ0vyVQ.ttf", + "900": "http://fonts.gstatic.com/s/archivo/v9/k3k6o8UDI-1M0wlSV9XAw6lQkqWY8Q82sJaRE-NWIDdgffTTnTRp8B1oJ0vyVQ.ttf", + "100italic": "http://fonts.gstatic.com/s/archivo/v9/k3k8o8UDI-1M0wlSfdzyIEkpwTM29hr-8mTYIRyOSVz60_PG_HCBshdsBU7iVdxQ.ttf", + "200italic": "http://fonts.gstatic.com/s/archivo/v9/k3k8o8UDI-1M0wlSfdzyIEkpwTM29hr-8mTYIRyOSVz60_PG_HABsxdsBU7iVdxQ.ttf", + "300italic": "http://fonts.gstatic.com/s/archivo/v9/k3k8o8UDI-1M0wlSfdzyIEkpwTM29hr-8mTYIRyOSVz60_PG_HDfsxdsBU7iVdxQ.ttf", + "italic": "http://fonts.gstatic.com/s/archivo/v9/k3k8o8UDI-1M0wlSfdzyIEkpwTM29hr-8mTYIRyOSVz60_PG_HCBsxdsBU7iVdxQ.ttf", + "500italic": "http://fonts.gstatic.com/s/archivo/v9/k3k8o8UDI-1M0wlSfdzyIEkpwTM29hr-8mTYIRyOSVz60_PG_HCzsxdsBU7iVdxQ.ttf", + "600italic": "http://fonts.gstatic.com/s/archivo/v9/k3k8o8UDI-1M0wlSfdzyIEkpwTM29hr-8mTYIRyOSVz60_PG_HBftBdsBU7iVdxQ.ttf", + "700italic": "http://fonts.gstatic.com/s/archivo/v9/k3k8o8UDI-1M0wlSfdzyIEkpwTM29hr-8mTYIRyOSVz60_PG_HBmtBdsBU7iVdxQ.ttf", + "800italic": "http://fonts.gstatic.com/s/archivo/v9/k3k8o8UDI-1M0wlSfdzyIEkpwTM29hr-8mTYIRyOSVz60_PG_HABtBdsBU7iVdxQ.ttf", + "900italic": "http://fonts.gstatic.com/s/archivo/v9/k3k8o8UDI-1M0wlSfdzyIEkpwTM29hr-8mTYIRyOSVz60_PG_HAotBdsBU7iVdxQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Archivo Black", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/archivoblack/v16/HTxqL289NzCGg4MzN6KJ7eW6OYuP_x7yx3A.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Archivo Narrow", + "variants": [ + "regular", + "500", + "600", + "700", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v21", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/archivonarrow/v21/tss5ApVBdCYD5Q7hcxTE1ArZ0Zz8oY2KRmwvKhhvLFGKpHOtFCQ76Q.ttf", + "500": "http://fonts.gstatic.com/s/archivonarrow/v21/tss5ApVBdCYD5Q7hcxTE1ArZ0Zz8oY2KRmwvKhhvHlGKpHOtFCQ76Q.ttf", + "600": "http://fonts.gstatic.com/s/archivonarrow/v21/tss5ApVBdCYD5Q7hcxTE1ArZ0Zz8oY2KRmwvKhhv8laKpHOtFCQ76Q.ttf", + "700": "http://fonts.gstatic.com/s/archivonarrow/v21/tss5ApVBdCYD5Q7hcxTE1ArZ0Zz8oY2KRmwvKhhvy1aKpHOtFCQ76Q.ttf", + "italic": "http://fonts.gstatic.com/s/archivonarrow/v21/tss7ApVBdCYD5Q7hcxTE1ArZ0bb1k3JSLwe1hB965BJi53mpNiEr6T6Y.ttf", + "500italic": "http://fonts.gstatic.com/s/archivonarrow/v21/tss7ApVBdCYD5Q7hcxTE1ArZ0bb1k3JSLwe1hB965BJQ53mpNiEr6T6Y.ttf", + "600italic": "http://fonts.gstatic.com/s/archivonarrow/v21/tss7ApVBdCYD5Q7hcxTE1ArZ0bb1k3JSLwe1hB965BK84HmpNiEr6T6Y.ttf", + "700italic": "http://fonts.gstatic.com/s/archivonarrow/v21/tss7ApVBdCYD5Q7hcxTE1ArZ0bb1k3JSLwe1hB965BKF4HmpNiEr6T6Y.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Are You Serious", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/areyouserious/v8/ll8kK2GVSSr-PtjQ5nONVcNn4306hT9nCGRayg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Aref Ruqaa", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext" + ], + "version": "v21", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/arefruqaa/v21/WwkbxPW1E165rajQKDulEIAiVNo5xNY.ttf", + "700": "http://fonts.gstatic.com/s/arefruqaa/v21/WwkYxPW1E165rajQKDulKDwNcNIS2N_7Bdk.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Arima Madurai", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "tamil", + "vietnamese" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/arimamadurai/v12/t5t4IRoeKYORG0WNMgnC3seB1V3PqrGCch4Drg.ttf", + "200": "http://fonts.gstatic.com/s/arimamadurai/v12/t5t7IRoeKYORG0WNMgnC3seB1fHuipusfhcat2c.ttf", + "300": "http://fonts.gstatic.com/s/arimamadurai/v12/t5t7IRoeKYORG0WNMgnC3seB1ZXtipusfhcat2c.ttf", + "regular": "http://fonts.gstatic.com/s/arimamadurai/v12/t5tmIRoeKYORG0WNMgnC3seB7TnFrpOHYh4.ttf", + "500": "http://fonts.gstatic.com/s/arimamadurai/v12/t5t7IRoeKYORG0WNMgnC3seB1c3sipusfhcat2c.ttf", + "700": "http://fonts.gstatic.com/s/arimamadurai/v12/t5t7IRoeKYORG0WNMgnC3seB1YXqipusfhcat2c.ttf", + "800": "http://fonts.gstatic.com/s/arimamadurai/v12/t5t7IRoeKYORG0WNMgnC3seB1Znpipusfhcat2c.ttf", + "900": "http://fonts.gstatic.com/s/arimamadurai/v12/t5t7IRoeKYORG0WNMgnC3seB1b3oipusfhcat2c.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Arimo", + "variants": [ + "regular", + "500", + "600", + "700", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "hebrew", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v24", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/arimo/v24/P5sfzZCDf9_T_3cV7NCUECyoxNk37cxsBxDAVQI4aA.ttf", + "500": "http://fonts.gstatic.com/s/arimo/v24/P5sfzZCDf9_T_3cV7NCUECyoxNk338xsBxDAVQI4aA.ttf", + "600": "http://fonts.gstatic.com/s/arimo/v24/P5sfzZCDf9_T_3cV7NCUECyoxNk3M8tsBxDAVQI4aA.ttf", + "700": "http://fonts.gstatic.com/s/arimo/v24/P5sfzZCDf9_T_3cV7NCUECyoxNk3CstsBxDAVQI4aA.ttf", + "italic": "http://fonts.gstatic.com/s/arimo/v24/P5sdzZCDf9_T_10c3i9MeUcyat4iJY-ERBrEdwcoaKww.ttf", + "500italic": "http://fonts.gstatic.com/s/arimo/v24/P5sdzZCDf9_T_10c3i9MeUcyat4iJY-2RBrEdwcoaKww.ttf", + "600italic": "http://fonts.gstatic.com/s/arimo/v24/P5sdzZCDf9_T_10c3i9MeUcyat4iJY9aQxrEdwcoaKww.ttf", + "700italic": "http://fonts.gstatic.com/s/arimo/v24/P5sdzZCDf9_T_10c3i9MeUcyat4iJY9jQxrEdwcoaKww.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Arizonia", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/arizonia/v16/neIIzCemt4A5qa7mv6WGHK06UY30.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Armata", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/armata/v17/gokvH63_HV5jQ-E9lD53Q2u_mQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Arsenal", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/arsenal/v10/wXKrE3kQtZQ4pF3D11_WAewrhXY.ttf", + "italic": "http://fonts.gstatic.com/s/arsenal/v10/wXKpE3kQtZQ4pF3D513cBc4ulXYrtA.ttf", + "700": "http://fonts.gstatic.com/s/arsenal/v10/wXKuE3kQtZQ4pF3D7-P5JeQAmX8yrdk.ttf", + "700italic": "http://fonts.gstatic.com/s/arsenal/v10/wXKsE3kQtZQ4pF3D513kueEKnV03vdnKjw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Artifika", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/artifika/v18/VEMyRoxzronptCuxu6Wt5jDtreOL.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Arvo", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2020-09-10", + "files": { + "regular": "http://fonts.gstatic.com/s/arvo/v14/tDbD2oWUg0MKmSAa7Lzr7vs.ttf", + "italic": "http://fonts.gstatic.com/s/arvo/v14/tDbN2oWUg0MKqSIQ6J7u_vvijQ.ttf", + "700": "http://fonts.gstatic.com/s/arvo/v14/tDbM2oWUg0MKoZw1yLTA8vL7lAE.ttf", + "700italic": "http://fonts.gstatic.com/s/arvo/v14/tDbO2oWUg0MKqSIoVLHK9tD-hAHkGg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Arya", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/arya/v17/ga6CawNG-HJd9Ub1-beqdFE.ttf", + "700": "http://fonts.gstatic.com/s/arya/v17/ga6NawNG-HJdzfra3b-BaFg3dRE.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Asap", + "variants": [ + "regular", + "500", + "600", + "700", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v21", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/asap/v21/KFO9CniXp96a4Tc2EZzSuDAoKsE61qhOUX-8AEEe.ttf", + "500": "http://fonts.gstatic.com/s/asap/v21/KFO9CniXp96a4Tc2EZzSuDAoKsEI1qhOUX-8AEEe.ttf", + "600": "http://fonts.gstatic.com/s/asap/v21/KFO9CniXp96a4Tc2EZzSuDAoKsHk0ahOUX-8AEEe.ttf", + "700": "http://fonts.gstatic.com/s/asap/v21/KFO9CniXp96a4Tc2EZzSuDAoKsHd0ahOUX-8AEEe.ttf", + "italic": "http://fonts.gstatic.com/s/asap/v21/KFO7CniXp96ayz4E7kS706qGLdTylUANW3ueBVEeezU.ttf", + "500italic": "http://fonts.gstatic.com/s/asap/v21/KFO7CniXp96ayz4E7kS706qGLdTylXINW3ueBVEeezU.ttf", + "600italic": "http://fonts.gstatic.com/s/asap/v21/KFO7CniXp96ayz4E7kS706qGLdTylZ4KW3ueBVEeezU.ttf", + "700italic": "http://fonts.gstatic.com/s/asap/v21/KFO7CniXp96ayz4E7kS706qGLdTylacKW3ueBVEeezU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Asap Condensed", + "variants": [ + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/asapcondensed/v14/pxidypY1o9NHyXh3WvSbGSggdNeLYk1Mq3ap.ttf", + "italic": "http://fonts.gstatic.com/s/asapcondensed/v14/pxifypY1o9NHyXh3WvSbGSggdOeJaElurmapvvM.ttf", + "500": "http://fonts.gstatic.com/s/asapcondensed/v14/pxieypY1o9NHyXh3WvSbGSggdO9_S2lEgGqgp-pO.ttf", + "500italic": "http://fonts.gstatic.com/s/asapcondensed/v14/pxiYypY1o9NHyXh3WvSbGSggdOeJUL1Him6CovpOkXA.ttf", + "600": "http://fonts.gstatic.com/s/asapcondensed/v14/pxieypY1o9NHyXh3WvSbGSggdO9TTGlEgGqgp-pO.ttf", + "600italic": "http://fonts.gstatic.com/s/asapcondensed/v14/pxiYypY1o9NHyXh3WvSbGSggdOeJUJFAim6CovpOkXA.ttf", + "700": "http://fonts.gstatic.com/s/asapcondensed/v14/pxieypY1o9NHyXh3WvSbGSggdO83TWlEgGqgp-pO.ttf", + "700italic": "http://fonts.gstatic.com/s/asapcondensed/v14/pxiYypY1o9NHyXh3WvSbGSggdOeJUPVBim6CovpOkXA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Asar", + "variants": [ + "regular" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/asar/v20/sZlLdRyI6TBIXkYQDLlTW6E.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Asset", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/asset/v22/SLXGc1na-mM4cWImRJqExst1.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Assistant", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "hebrew", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/assistant/v14/2sDPZGJYnIjSi6H75xkZZE1I0yCmYzzQtmZnEGGf3qGuvM4.ttf", + "300": "http://fonts.gstatic.com/s/assistant/v14/2sDPZGJYnIjSi6H75xkZZE1I0yCmYzzQtrhnEGGf3qGuvM4.ttf", + "regular": "http://fonts.gstatic.com/s/assistant/v14/2sDPZGJYnIjSi6H75xkZZE1I0yCmYzzQtuZnEGGf3qGuvM4.ttf", + "500": "http://fonts.gstatic.com/s/assistant/v14/2sDPZGJYnIjSi6H75xkZZE1I0yCmYzzQttRnEGGf3qGuvM4.ttf", + "600": "http://fonts.gstatic.com/s/assistant/v14/2sDPZGJYnIjSi6H75xkZZE1I0yCmYzzQtjhgEGGf3qGuvM4.ttf", + "700": "http://fonts.gstatic.com/s/assistant/v14/2sDPZGJYnIjSi6H75xkZZE1I0yCmYzzQtgFgEGGf3qGuvM4.ttf", + "800": "http://fonts.gstatic.com/s/assistant/v14/2sDPZGJYnIjSi6H75xkZZE1I0yCmYzzQtmZgEGGf3qGuvM4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Astloch", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v24", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/astloch/v24/TuGRUVJ8QI5GSeUjq9wRzMtkH1Q.ttf", + "700": "http://fonts.gstatic.com/s/astloch/v24/TuGUUVJ8QI5GSeUjk2A-6MNPA10xLMQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Asul", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/asul/v17/VuJ-dNjKxYr46fMFXK78JIg.ttf", + "700": "http://fonts.gstatic.com/s/asul/v17/VuJxdNjKxYr40U8qeKbXOIFneRo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Athiti", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-01-13", + "files": { + "200": "http://fonts.gstatic.com/s/athiti/v10/pe0sMISdLIZIv1wAxDNyAv2-C99ycg.ttf", + "300": "http://fonts.gstatic.com/s/athiti/v10/pe0sMISdLIZIv1wAoDByAv2-C99ycg.ttf", + "regular": "http://fonts.gstatic.com/s/athiti/v10/pe0vMISdLIZIv1w4DBhWCtaiAg.ttf", + "500": "http://fonts.gstatic.com/s/athiti/v10/pe0sMISdLIZIv1wA-DFyAv2-C99ycg.ttf", + "600": "http://fonts.gstatic.com/s/athiti/v10/pe0sMISdLIZIv1wA1DZyAv2-C99ycg.ttf", + "700": "http://fonts.gstatic.com/s/athiti/v10/pe0sMISdLIZIv1wAsDdyAv2-C99ycg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Atkinson Hyperlegible", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/atkinsonhyperlegible/v7/9Bt23C1KxNDXMspQ1lPyU89-1h6ONRlW45GE5ZgpewSSbQ.ttf", + "italic": "http://fonts.gstatic.com/s/atkinsonhyperlegible/v7/9Bt43C1KxNDXMspQ1lPyU89-1h6ONRlW45G055ItWQGCbUWn.ttf", + "700": "http://fonts.gstatic.com/s/atkinsonhyperlegible/v7/9Bt73C1KxNDXMspQ1lPyU89-1h6ONRlW45G8WbcNcy-OZFy-FA.ttf", + "700italic": "http://fonts.gstatic.com/s/atkinsonhyperlegible/v7/9Bt93C1KxNDXMspQ1lPyU89-1h6ONRlW45G056qRdiWKRlmuFH24.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Atma", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "bengali", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/atma/v13/uK_z4rqWc-Eoo8JzKjc9PvedRkM.ttf", + "regular": "http://fonts.gstatic.com/s/atma/v13/uK_84rqWc-Eom25bDj8WIv4.ttf", + "500": "http://fonts.gstatic.com/s/atma/v13/uK_z4rqWc-Eoo5pyKjc9PvedRkM.ttf", + "600": "http://fonts.gstatic.com/s/atma/v13/uK_z4rqWc-Eoo7Z1Kjc9PvedRkM.ttf", + "700": "http://fonts.gstatic.com/s/atma/v13/uK_z4rqWc-Eoo9J0Kjc9PvedRkM.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Atomic Age", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v25", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/atomicage/v25/f0Xz0eug6sdmRFkYZZGL58Ht9a8GYeA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Aubrey", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v25", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/aubrey/v25/q5uGsou7NPBw-p7vugNsCxVEgA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Audiowide", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/audiowide/v14/l7gdbjpo0cum0ckerWCtkQXPExpQBw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Autour One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v22", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/autourone/v22/UqyVK80cP25l3fJgbdfbk5lWVscxdKE.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Average", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/average/v14/fC1hPYBHe23MxA7rIeJwVWytTyk.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Average Sans", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/averagesans/v14/1Ptpg8fLXP2dlAXR-HlJJNJPBdqazVoK4A.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Averia Gruesa Libre", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/averiagruesalibre/v11/NGSov4nEGEktOaDRKsY-1dhh8eEtIx3ZUmmJw0SLRA8.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Averia Libre", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v9", + "lastModified": "2020-07-23", + "files": { + "300": "http://fonts.gstatic.com/s/averialibre/v9/2V0FKIcMGZEnV6xygz7eNjEarovtb07t-pQgTw.ttf", + "300italic": "http://fonts.gstatic.com/s/averialibre/v9/2V0HKIcMGZEnV6xygz7eNjESAJFhbUTp2JEwT4Sk.ttf", + "regular": "http://fonts.gstatic.com/s/averialibre/v9/2V0aKIcMGZEnV6xygz7eNjEiAqPJZ2Xx8w.ttf", + "italic": "http://fonts.gstatic.com/s/averialibre/v9/2V0EKIcMGZEnV6xygz7eNjESAKnNRWDh8405.ttf", + "700": "http://fonts.gstatic.com/s/averialibre/v9/2V0FKIcMGZEnV6xygz7eNjEavoztb07t-pQgTw.ttf", + "700italic": "http://fonts.gstatic.com/s/averialibre/v9/2V0HKIcMGZEnV6xygz7eNjESAJFxakTp2JEwT4Sk.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Averia Sans Libre", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2021-03-19", + "files": { + "300": "http://fonts.gstatic.com/s/averiasanslibre/v11/ga6SaxZG_G5OvCf_rt7FH3B6BHLMEd3lMKcQJZP1LmD9.ttf", + "300italic": "http://fonts.gstatic.com/s/averiasanslibre/v11/ga6caxZG_G5OvCf_rt7FH3B6BHLMEdVLKisSL5fXK3D9qtg.ttf", + "regular": "http://fonts.gstatic.com/s/averiasanslibre/v11/ga6XaxZG_G5OvCf_rt7FH3B6BHLMEeVJGIMYDo_8.ttf", + "italic": "http://fonts.gstatic.com/s/averiasanslibre/v11/ga6RaxZG_G5OvCf_rt7FH3B6BHLMEdVLEoc6C5_8N3k.ttf", + "700": "http://fonts.gstatic.com/s/averiasanslibre/v11/ga6SaxZG_G5OvCf_rt7FH3B6BHLMEd31N6cQJZP1LmD9.ttf", + "700italic": "http://fonts.gstatic.com/s/averiasanslibre/v11/ga6caxZG_G5OvCf_rt7FH3B6BHLMEdVLKjsVL5fXK3D9qtg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Averia Serif Libre", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v10", + "lastModified": "2020-07-23", + "files": { + "300": "http://fonts.gstatic.com/s/averiaseriflibre/v10/neIVzD2ms4wxr6GvjeD0X88SHPyX2xYGCSmqwacqdrKvbQ.ttf", + "300italic": "http://fonts.gstatic.com/s/averiaseriflibre/v10/neIbzD2ms4wxr6GvjeD0X88SHPyX2xYOpzMmw60uVLe_bXHq.ttf", + "regular": "http://fonts.gstatic.com/s/averiaseriflibre/v10/neIWzD2ms4wxr6GvjeD0X88SHPyX2xY-pQGOyYw2fw.ttf", + "italic": "http://fonts.gstatic.com/s/averiaseriflibre/v10/neIUzD2ms4wxr6GvjeD0X88SHPyX2xYOpwuK64kmf6u2.ttf", + "700": "http://fonts.gstatic.com/s/averiaseriflibre/v10/neIVzD2ms4wxr6GvjeD0X88SHPyX2xYGGS6qwacqdrKvbQ.ttf", + "700italic": "http://fonts.gstatic.com/s/averiaseriflibre/v10/neIbzD2ms4wxr6GvjeD0X88SHPyX2xYOpzM2xK0uVLe_bXHq.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Azeret Mono", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/azeretmono/v8/3XF5ErsiyJsY9O_Gepph-FvtTQgMQUdNekSfnPRh0raa-5s3AA.ttf", + "200": "http://fonts.gstatic.com/s/azeretmono/v8/3XF5ErsiyJsY9O_Gepph-FvtTQgMQUdNekSfHPVh0raa-5s3AA.ttf", + "300": "http://fonts.gstatic.com/s/azeretmono/v8/3XF5ErsiyJsY9O_Gepph-FvtTQgMQUdNekSfwvVh0raa-5s3AA.ttf", + "regular": "http://fonts.gstatic.com/s/azeretmono/v8/3XF5ErsiyJsY9O_Gepph-FvtTQgMQUdNekSfnPVh0raa-5s3AA.ttf", + "500": "http://fonts.gstatic.com/s/azeretmono/v8/3XF5ErsiyJsY9O_Gepph-FvtTQgMQUdNekSfrvVh0raa-5s3AA.ttf", + "600": "http://fonts.gstatic.com/s/azeretmono/v8/3XF5ErsiyJsY9O_Gepph-FvtTQgMQUdNekSfQvJh0raa-5s3AA.ttf", + "700": "http://fonts.gstatic.com/s/azeretmono/v8/3XF5ErsiyJsY9O_Gepph-FvtTQgMQUdNekSfe_Jh0raa-5s3AA.ttf", + "800": "http://fonts.gstatic.com/s/azeretmono/v8/3XF5ErsiyJsY9O_Gepph-FvtTQgMQUdNekSfHPJh0raa-5s3AA.ttf", + "900": "http://fonts.gstatic.com/s/azeretmono/v8/3XF5ErsiyJsY9O_Gepph-FvtTQgMQUdNekSfNfJh0raa-5s3AA.ttf", + "100italic": "http://fonts.gstatic.com/s/azeretmono/v8/3XF_ErsiyJsY9O_Gepph-HHkf_fUKCzX1EOKVLaJkLye2Z4nAN7J.ttf", + "200italic": "http://fonts.gstatic.com/s/azeretmono/v8/3XF_ErsiyJsY9O_Gepph-HHkf_fUKCzX1EOKVLYJkbye2Z4nAN7J.ttf", + "300italic": "http://fonts.gstatic.com/s/azeretmono/v8/3XF_ErsiyJsY9O_Gepph-HHkf_fUKCzX1EOKVLbXkbye2Z4nAN7J.ttf", + "italic": "http://fonts.gstatic.com/s/azeretmono/v8/3XF_ErsiyJsY9O_Gepph-HHkf_fUKCzX1EOKVLaJkbye2Z4nAN7J.ttf", + "500italic": "http://fonts.gstatic.com/s/azeretmono/v8/3XF_ErsiyJsY9O_Gepph-HHkf_fUKCzX1EOKVLa7kbye2Z4nAN7J.ttf", + "600italic": "http://fonts.gstatic.com/s/azeretmono/v8/3XF_ErsiyJsY9O_Gepph-HHkf_fUKCzX1EOKVLZXlrye2Z4nAN7J.ttf", + "700italic": "http://fonts.gstatic.com/s/azeretmono/v8/3XF_ErsiyJsY9O_Gepph-HHkf_fUKCzX1EOKVLZulrye2Z4nAN7J.ttf", + "800italic": "http://fonts.gstatic.com/s/azeretmono/v8/3XF_ErsiyJsY9O_Gepph-HHkf_fUKCzX1EOKVLYJlrye2Z4nAN7J.ttf", + "900italic": "http://fonts.gstatic.com/s/azeretmono/v8/3XF_ErsiyJsY9O_Gepph-HHkf_fUKCzX1EOKVLYglrye2Z4nAN7J.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "B612", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v10", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/b612/v10/3JnySDDxiSz32jm4GDigUXw.ttf", + "italic": "http://fonts.gstatic.com/s/b612/v10/3Jn8SDDxiSz36juyHBqlQXwdVw.ttf", + "700": "http://fonts.gstatic.com/s/b612/v10/3Jn9SDDxiSz34oWXPDCLTXUETuE.ttf", + "700italic": "http://fonts.gstatic.com/s/b612/v10/3Jn_SDDxiSz36juKoDWBSVcBXuFb0Q.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "B612 Mono", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v10", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/b612mono/v10/kmK_Zq85QVWbN1eW6lJl1wTcquRTtg.ttf", + "italic": "http://fonts.gstatic.com/s/b612mono/v10/kmK5Zq85QVWbN1eW6lJV1Q7YiOFDtqtf.ttf", + "700": "http://fonts.gstatic.com/s/b612mono/v10/kmK6Zq85QVWbN1eW6lJdayv4os9Pv7JGSg.ttf", + "700italic": "http://fonts.gstatic.com/s/b612mono/v10/kmKkZq85QVWbN1eW6lJV1TZkp8VLnbdWSg4x.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Bad Script", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/badscript/v14/6NUT8F6PJgbFWQn47_x7lOwuzd1AZtw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Bahiana", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/bahiana/v17/uU9PCBUV4YenPWJU7xPb3vyHmlI.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bahianita", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/bahianita/v15/yYLr0hTb3vuqqsBUgxWtxTvV2NJPcA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bai Jamjuree", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIqapSCOBt_aeQQ7ftydoa0kePuk5A1-yiSgA.ttf", + "200italic": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIoapSCOBt_aeQQ7ftydoa8W_oGkpox2S2CgOva.ttf", + "300": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIqapSCOBt_aeQQ7ftydoa09eDuk5A1-yiSgA.ttf", + "300italic": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIoapSCOBt_aeQQ7ftydoa8W_pikZox2S2CgOva.ttf", + "regular": "http://fonts.gstatic.com/s/baijamjuree/v9/LDI1apSCOBt_aeQQ7ftydoaMWcjKm7sp8g.ttf", + "italic": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIrapSCOBt_aeQQ7ftydoa8W8LOub458jGL.ttf", + "500": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIqapSCOBt_aeQQ7ftydoa0reHuk5A1-yiSgA.ttf", + "500italic": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIoapSCOBt_aeQQ7ftydoa8W_o6kJox2S2CgOva.ttf", + "600": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIqapSCOBt_aeQQ7ftydoa0gebuk5A1-yiSgA.ttf", + "600italic": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIoapSCOBt_aeQQ7ftydoa8W_oWl5ox2S2CgOva.ttf", + "700": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIqapSCOBt_aeQQ7ftydoa05efuk5A1-yiSgA.ttf", + "700italic": "http://fonts.gstatic.com/s/baijamjuree/v9/LDIoapSCOBt_aeQQ7ftydoa8W_pylpox2S2CgOva.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bakbak One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v3", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/bakbakone/v3/zOL54pXAl6RI-p_ardnuycRuv-hHkOs.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Ballet", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/ballet/v18/QGYyz_MYZA-HM4NjuGOVnUEXme1I4Xi3C4G-EiAou6Y.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Baloo 2", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/baloo2/v11/wXK0E3kTposypRydzVT08TS3JnAmtdgazapv9Fat7WcN.ttf", + "500": "http://fonts.gstatic.com/s/baloo2/v11/wXK0E3kTposypRydzVT08TS3JnAmtdgozapv9Fat7WcN.ttf", + "600": "http://fonts.gstatic.com/s/baloo2/v11/wXK0E3kTposypRydzVT08TS3JnAmtdjEyqpv9Fat7WcN.ttf", + "700": "http://fonts.gstatic.com/s/baloo2/v11/wXK0E3kTposypRydzVT08TS3JnAmtdj9yqpv9Fat7WcN.ttf", + "800": "http://fonts.gstatic.com/s/baloo2/v11/wXK0E3kTposypRydzVT08TS3JnAmtdiayqpv9Fat7WcN.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Baloo Bhai 2", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "gujarati", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/baloobhai2/v16/sZlWdRSL-z1VEWZ4YNA7Y5ItevYWUOHDE8FvNighMXeCo-jsZzo.ttf", + "500": "http://fonts.gstatic.com/s/baloobhai2/v16/sZlWdRSL-z1VEWZ4YNA7Y5ItevYWUOHDE8FvNhohMXeCo-jsZzo.ttf", + "600": "http://fonts.gstatic.com/s/baloobhai2/v16/sZlWdRSL-z1VEWZ4YNA7Y5ItevYWUOHDE8FvNvYmMXeCo-jsZzo.ttf", + "700": "http://fonts.gstatic.com/s/baloobhai2/v16/sZlWdRSL-z1VEWZ4YNA7Y5ItevYWUOHDE8FvNs8mMXeCo-jsZzo.ttf", + "800": "http://fonts.gstatic.com/s/baloobhai2/v16/sZlWdRSL-z1VEWZ4YNA7Y5ItevYWUOHDE8FvNqgmMXeCo-jsZzo.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Baloo Bhaijaan 2", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/baloobhaijaan2/v5/zYXwKUwuEqdVGqM8tPDdAA_Y-_bMKo1EhQd2tWxo8TyRSqP4L4ppfcyC.ttf", + "500": "http://fonts.gstatic.com/s/baloobhaijaan2/v5/zYXwKUwuEqdVGqM8tPDdAA_Y-_bMKo1EhQd2tWxo8TyjSqP4L4ppfcyC.ttf", + "600": "http://fonts.gstatic.com/s/baloobhaijaan2/v5/zYXwKUwuEqdVGqM8tPDdAA_Y-_bMKo1EhQd2tWxo8TxPTaP4L4ppfcyC.ttf", + "700": "http://fonts.gstatic.com/s/baloobhaijaan2/v5/zYXwKUwuEqdVGqM8tPDdAA_Y-_bMKo1EhQd2tWxo8Tx2TaP4L4ppfcyC.ttf", + "800": "http://fonts.gstatic.com/s/baloobhaijaan2/v5/zYXwKUwuEqdVGqM8tPDdAA_Y-_bMKo1EhQd2tWxo8TwRTaP4L4ppfcyC.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Baloo Bhaina 2", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext", + "oriya", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/baloobhaina2/v17/qWc-B6yyq4P9Adr3RtoX1q6ySgbwusXwJjkOS-XEssPvRfRLYWmZSA.ttf", + "500": "http://fonts.gstatic.com/s/baloobhaina2/v17/qWc-B6yyq4P9Adr3RtoX1q6ySgbwusXwJjkOS-XEgMPvRfRLYWmZSA.ttf", + "600": "http://fonts.gstatic.com/s/baloobhaina2/v17/qWc-B6yyq4P9Adr3RtoX1q6ySgbwusXwJjkOS-XEbMTvRfRLYWmZSA.ttf", + "700": "http://fonts.gstatic.com/s/baloobhaina2/v17/qWc-B6yyq4P9Adr3RtoX1q6ySgbwusXwJjkOS-XEVcTvRfRLYWmZSA.ttf", + "800": "http://fonts.gstatic.com/s/baloobhaina2/v17/qWc-B6yyq4P9Adr3RtoX1q6ySgbwusXwJjkOS-XEMsTvRfRLYWmZSA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Baloo Chettan 2", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext", + "malayalam", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/baloochettan2/v11/vm8hdRbmXEva26PK-NtuX4ynWEzF69-L4gqgkIL5CeKTO1oeH9xI2gc.ttf", + "500": "http://fonts.gstatic.com/s/baloochettan2/v11/vm8hdRbmXEva26PK-NtuX4ynWEzF69-L4gqgkIL5CdCTO1oeH9xI2gc.ttf", + "600": "http://fonts.gstatic.com/s/baloochettan2/v11/vm8hdRbmXEva26PK-NtuX4ynWEzF69-L4gqgkIL5CTyUO1oeH9xI2gc.ttf", + "700": "http://fonts.gstatic.com/s/baloochettan2/v11/vm8hdRbmXEva26PK-NtuX4ynWEzF69-L4gqgkIL5CQWUO1oeH9xI2gc.ttf", + "800": "http://fonts.gstatic.com/s/baloochettan2/v11/vm8hdRbmXEva26PK-NtuX4ynWEzF69-L4gqgkIL5CWKUO1oeH9xI2gc.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Baloo Da 2", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "bengali", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/balooda2/v11/2-c39J9j0IaUMQZwAJyJaOX1UUnf3GLnYjALsTNe55aRa7UE.ttf", + "500": "http://fonts.gstatic.com/s/balooda2/v11/2-c39J9j0IaUMQZwAJyJaOX1UUnf3GLnYjA5sTNe55aRa7UE.ttf", + "600": "http://fonts.gstatic.com/s/balooda2/v11/2-c39J9j0IaUMQZwAJyJaOX1UUnf3GLnYjDVtjNe55aRa7UE.ttf", + "700": "http://fonts.gstatic.com/s/balooda2/v11/2-c39J9j0IaUMQZwAJyJaOX1UUnf3GLnYjDstjNe55aRa7UE.ttf", + "800": "http://fonts.gstatic.com/s/balooda2/v11/2-c39J9j0IaUMQZwAJyJaOX1UUnf3GLnYjCLtjNe55aRa7UE.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Baloo Paaji 2", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "gurmukhi", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/baloopaaji2/v17/i7dfIFFzbz-QHZUdV9_UGWZuelmy79QJ1HOSY9AX74fybRUz1r5t.ttf", + "500": "http://fonts.gstatic.com/s/baloopaaji2/v17/i7dfIFFzbz-QHZUdV9_UGWZuelmy79QJ1HOSY9Al74fybRUz1r5t.ttf", + "600": "http://fonts.gstatic.com/s/baloopaaji2/v17/i7dfIFFzbz-QHZUdV9_UGWZuelmy79QJ1HOSY9DJ6IfybRUz1r5t.ttf", + "700": "http://fonts.gstatic.com/s/baloopaaji2/v17/i7dfIFFzbz-QHZUdV9_UGWZuelmy79QJ1HOSY9Dw6IfybRUz1r5t.ttf", + "800": "http://fonts.gstatic.com/s/baloopaaji2/v17/i7dfIFFzbz-QHZUdV9_UGWZuelmy79QJ1HOSY9CX6IfybRUz1r5t.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Baloo Tamma 2", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "kannada", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/balootamma2/v10/vEFE2_hCAgcR46PaajtrYlBbVUMUJgIC5LHTrMscPp-0IF71SGC5.ttf", + "500": "http://fonts.gstatic.com/s/balootamma2/v10/vEFE2_hCAgcR46PaajtrYlBbVUMUJgIC5LHTrMsuPp-0IF71SGC5.ttf", + "600": "http://fonts.gstatic.com/s/balootamma2/v10/vEFE2_hCAgcR46PaajtrYlBbVUMUJgIC5LHTrMvCOZ-0IF71SGC5.ttf", + "700": "http://fonts.gstatic.com/s/balootamma2/v10/vEFE2_hCAgcR46PaajtrYlBbVUMUJgIC5LHTrMv7OZ-0IF71SGC5.ttf", + "800": "http://fonts.gstatic.com/s/balootamma2/v10/vEFE2_hCAgcR46PaajtrYlBbVUMUJgIC5LHTrMucOZ-0IF71SGC5.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Baloo Tammudu 2", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext", + "telugu", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/balootammudu2/v17/1Pt5g8TIS_SAmkLguUdFP8UaJcKkzlPmMT00GaE_Jf8e4c6PZSlGmAA.ttf", + "500": "http://fonts.gstatic.com/s/balootammudu2/v17/1Pt5g8TIS_SAmkLguUdFP8UaJcKkzlPmMT00GaE_Jc0e4c6PZSlGmAA.ttf", + "600": "http://fonts.gstatic.com/s/balootammudu2/v17/1Pt5g8TIS_SAmkLguUdFP8UaJcKkzlPmMT00GaE_JSEZ4c6PZSlGmAA.ttf", + "700": "http://fonts.gstatic.com/s/balootammudu2/v17/1Pt5g8TIS_SAmkLguUdFP8UaJcKkzlPmMT00GaE_JRgZ4c6PZSlGmAA.ttf", + "800": "http://fonts.gstatic.com/s/balootammudu2/v17/1Pt5g8TIS_SAmkLguUdFP8UaJcKkzlPmMT00GaE_JX8Z4c6PZSlGmAA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Baloo Thambi 2", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext", + "tamil", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/baloothambi2/v11/cY9RfjeOW0NHpmOQXranrbDyu5JMJmNp-aDvUBbKzcIzaQRG_n4osQ.ttf", + "500": "http://fonts.gstatic.com/s/baloothambi2/v11/cY9RfjeOW0NHpmOQXranrbDyu5JMJmNp-aDvUBbK_8IzaQRG_n4osQ.ttf", + "600": "http://fonts.gstatic.com/s/baloothambi2/v11/cY9RfjeOW0NHpmOQXranrbDyu5JMJmNp-aDvUBbKE8UzaQRG_n4osQ.ttf", + "700": "http://fonts.gstatic.com/s/baloothambi2/v11/cY9RfjeOW0NHpmOQXranrbDyu5JMJmNp-aDvUBbKKsUzaQRG_n4osQ.ttf", + "800": "http://fonts.gstatic.com/s/baloothambi2/v11/cY9RfjeOW0NHpmOQXranrbDyu5JMJmNp-aDvUBbKTcUzaQRG_n4osQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Balsamiq Sans", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/balsamiqsans/v9/P5sEzZiAbNrN8SB3lQQX7Pnc8dkdIYdNHzs.ttf", + "italic": "http://fonts.gstatic.com/s/balsamiqsans/v9/P5sazZiAbNrN8SB3lQQX7PncwdsXJaVIDzvcXA.ttf", + "700": "http://fonts.gstatic.com/s/balsamiqsans/v9/P5sZzZiAbNrN8SB3lQQX7PncyWUyBY9mAzLFRQI.ttf", + "700italic": "http://fonts.gstatic.com/s/balsamiqsans/v9/P5sfzZiAbNrN8SB3lQQX7PncwdsvmYpsBxDAVQI4aA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Balthazar", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/balthazar/v15/d6lKkaajS8Gm4CVQjFEvyRTo39l8hw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bangers", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v19", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/bangers/v19/FeVQS0BTqb0h60ACL5la2bxii28.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Barlow", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/barlow/v11/7cHrv4kjgoGqM7E3b8s8yn4hnCci.ttf", + "100italic": "http://fonts.gstatic.com/s/barlow/v11/7cHtv4kjgoGqM7E_CfNYwHoDmTcibrA.ttf", + "200": "http://fonts.gstatic.com/s/barlow/v11/7cHqv4kjgoGqM7E3w-oc4FAtlT47dw.ttf", + "200italic": "http://fonts.gstatic.com/s/barlow/v11/7cHsv4kjgoGqM7E_CfP04Voptzsrd6m9.ttf", + "300": "http://fonts.gstatic.com/s/barlow/v11/7cHqv4kjgoGqM7E3p-kc4FAtlT47dw.ttf", + "300italic": "http://fonts.gstatic.com/s/barlow/v11/7cHsv4kjgoGqM7E_CfOQ4loptzsrd6m9.ttf", + "regular": "http://fonts.gstatic.com/s/barlow/v11/7cHpv4kjgoGqM7EPC8E46HsxnA.ttf", + "italic": "http://fonts.gstatic.com/s/barlow/v11/7cHrv4kjgoGqM7E_Ccs8yn4hnCci.ttf", + "500": "http://fonts.gstatic.com/s/barlow/v11/7cHqv4kjgoGqM7E3_-gc4FAtlT47dw.ttf", + "500italic": "http://fonts.gstatic.com/s/barlow/v11/7cHsv4kjgoGqM7E_CfPI41optzsrd6m9.ttf", + "600": "http://fonts.gstatic.com/s/barlow/v11/7cHqv4kjgoGqM7E30-8c4FAtlT47dw.ttf", + "600italic": "http://fonts.gstatic.com/s/barlow/v11/7cHsv4kjgoGqM7E_CfPk5Foptzsrd6m9.ttf", + "700": "http://fonts.gstatic.com/s/barlow/v11/7cHqv4kjgoGqM7E3t-4c4FAtlT47dw.ttf", + "700italic": "http://fonts.gstatic.com/s/barlow/v11/7cHsv4kjgoGqM7E_CfOA5Voptzsrd6m9.ttf", + "800": "http://fonts.gstatic.com/s/barlow/v11/7cHqv4kjgoGqM7E3q-0c4FAtlT47dw.ttf", + "800italic": "http://fonts.gstatic.com/s/barlow/v11/7cHsv4kjgoGqM7E_CfOc5loptzsrd6m9.ttf", + "900": "http://fonts.gstatic.com/s/barlow/v11/7cHqv4kjgoGqM7E3j-wc4FAtlT47dw.ttf", + "900italic": "http://fonts.gstatic.com/s/barlow/v11/7cHsv4kjgoGqM7E_CfO451optzsrd6m9.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Barlow Condensed", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxxL3I-JCGChYJ8VI-L6OO_au7B43LT31vytKgbaw.ttf", + "100italic": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxzL3I-JCGChYJ8VI-L6OO_au7B6xTru1H2lq0La6JN.ttf", + "200": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxwL3I-JCGChYJ8VI-L6OO_au7B497y_3HcuKECcrs.ttf", + "200italic": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxyL3I-JCGChYJ8VI-L6OO_au7B6xTrF3DWvIMHYrtUxg.ttf", + "300": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxwL3I-JCGChYJ8VI-L6OO_au7B47rx_3HcuKECcrs.ttf", + "300italic": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxyL3I-JCGChYJ8VI-L6OO_au7B6xTrc3PWvIMHYrtUxg.ttf", + "regular": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTx3L3I-JCGChYJ8VI-L6OO_au7B2xbZ23n3pKg.ttf", + "italic": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxxL3I-JCGChYJ8VI-L6OO_au7B6xTT31vytKgbaw.ttf", + "500": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxwL3I-JCGChYJ8VI-L6OO_au7B4-Lw_3HcuKECcrs.ttf", + "500italic": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxyL3I-JCGChYJ8VI-L6OO_au7B6xTrK3LWvIMHYrtUxg.ttf", + "600": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxwL3I-JCGChYJ8VI-L6OO_au7B4873_3HcuKECcrs.ttf", + "600italic": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxyL3I-JCGChYJ8VI-L6OO_au7B6xTrB3XWvIMHYrtUxg.ttf", + "700": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxwL3I-JCGChYJ8VI-L6OO_au7B46r2_3HcuKECcrs.ttf", + "700italic": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxyL3I-JCGChYJ8VI-L6OO_au7B6xTrY3TWvIMHYrtUxg.ttf", + "800": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxwL3I-JCGChYJ8VI-L6OO_au7B47b1_3HcuKECcrs.ttf", + "800italic": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxyL3I-JCGChYJ8VI-L6OO_au7B6xTrf3fWvIMHYrtUxg.ttf", + "900": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxwL3I-JCGChYJ8VI-L6OO_au7B45L0_3HcuKECcrs.ttf", + "900italic": "http://fonts.gstatic.com/s/barlowcondensed/v11/HTxyL3I-JCGChYJ8VI-L6OO_au7B6xTrW3bWvIMHYrtUxg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Barlow Semi Condensed", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlphgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRfG4qvKk8ogoSP.ttf", + "100italic": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpjgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRXfbLLIEsKh5SPZWs.ttf", + "200": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpigxjLBV1hqnzfr-F8sEYMB0Yybp0mudRft6uPAGEki52WfA.ttf", + "200italic": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpkgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRXfbJnAWsgqZiGfHK5.ttf", + "300": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpigxjLBV1hqnzfr-F8sEYMB0Yybp0mudRf06iPAGEki52WfA.ttf", + "300italic": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpkgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRXfbIDAmsgqZiGfHK5.ttf", + "regular": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpvgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRnf4CrCEo4gg.ttf", + "italic": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlphgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRXfYqvKk8ogoSP.ttf", + "500": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpigxjLBV1hqnzfr-F8sEYMB0Yybp0mudRfi6mPAGEki52WfA.ttf", + "500italic": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpkgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRXfbJbA2sgqZiGfHK5.ttf", + "600": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpigxjLBV1hqnzfr-F8sEYMB0Yybp0mudRfp66PAGEki52WfA.ttf", + "600italic": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpkgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRXfbJ3BGsgqZiGfHK5.ttf", + "700": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpigxjLBV1hqnzfr-F8sEYMB0Yybp0mudRfw6-PAGEki52WfA.ttf", + "700italic": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpkgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRXfbITBWsgqZiGfHK5.ttf", + "800": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpigxjLBV1hqnzfr-F8sEYMB0Yybp0mudRf36yPAGEki52WfA.ttf", + "800italic": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpkgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRXfbIPBmsgqZiGfHK5.ttf", + "900": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpigxjLBV1hqnzfr-F8sEYMB0Yybp0mudRf-62PAGEki52WfA.ttf", + "900italic": "http://fonts.gstatic.com/s/barlowsemicondensed/v13/wlpkgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRXfbIrB2sgqZiGfHK5.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Barriecito", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/barriecito/v15/WWXXlj-CbBOSLY2QTuY_KdUiYwTO0MU.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Barrio", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/barrio/v17/wEO8EBXBk8hBIDiEdQYhWdsX1Q.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Basic", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/basic/v15/xfu_0WLxV2_XKQN34lDVyR7D.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Baskervville", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/baskervville/v11/YA9Ur0yU4l_XOrogbkun3kQgt5OohvbJ9A.ttf", + "italic": "http://fonts.gstatic.com/s/baskervville/v11/YA9Kr0yU4l_XOrogbkun3kQQtZmspPPZ9Mlt.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Battambang", + "variants": [ + "100", + "300", + "regular", + "700", + "900" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v22", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/battambang/v22/uk-kEGe7raEw-HjkzZabNhGp5w50_o9T7Q.ttf", + "300": "http://fonts.gstatic.com/s/battambang/v22/uk-lEGe7raEw-HjkzZabNtmLxyRa8oZK9I0.ttf", + "regular": "http://fonts.gstatic.com/s/battambang/v22/uk-mEGe7raEw-HjkzZabDnWj4yxx7o8.ttf", + "700": "http://fonts.gstatic.com/s/battambang/v22/uk-lEGe7raEw-HjkzZabNsmMxyRa8oZK9I0.ttf", + "900": "http://fonts.gstatic.com/s/battambang/v22/uk-lEGe7raEw-HjkzZabNvGOxyRa8oZK9I0.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Baumans", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/baumans/v15/-W_-XJj9QyTd3QfpR_oyaksqY5Q.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bayon", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v27", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/bayon/v27/9XUrlJNmn0LPFl-pOhYEd2NJ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Be Vietnam Pro", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVNSTAyLFyeg_IDWvOJmVES_HRUBX8YYbAiah8.ttf", + "100italic": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVLSTAyLFyeg_IDWvOJmVES_HwyPRsSZZIneh-waA.ttf", + "200": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVMSTAyLFyeg_IDWvOJmVES_HT4JF8yT7wrcwap.ttf", + "200italic": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVKSTAyLFyeg_IDWvOJmVES_HwyPbczRbgJdhapcUU.ttf", + "300": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVMSTAyLFyeg_IDWvOJmVES_HScJ18yT7wrcwap.ttf", + "300italic": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVKSTAyLFyeg_IDWvOJmVES_HwyPdMwRbgJdhapcUU.ttf", + "regular": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVPSTAyLFyeg_IDWvOJmVES_EwwD3s6ZKAi.ttf", + "italic": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVNSTAyLFyeg_IDWvOJmVES_HwyBX8YYbAiah8.ttf", + "500": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVMSTAyLFyeg_IDWvOJmVES_HTEJl8yT7wrcwap.ttf", + "500italic": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVKSTAyLFyeg_IDWvOJmVES_HwyPYsxRbgJdhapcUU.ttf", + "600": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVMSTAyLFyeg_IDWvOJmVES_HToIV8yT7wrcwap.ttf", + "600italic": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVKSTAyLFyeg_IDWvOJmVES_HwyPac2RbgJdhapcUU.ttf", + "700": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVMSTAyLFyeg_IDWvOJmVES_HSMIF8yT7wrcwap.ttf", + "700italic": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVKSTAyLFyeg_IDWvOJmVES_HwyPcM3RbgJdhapcUU.ttf", + "800": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVMSTAyLFyeg_IDWvOJmVES_HSQI18yT7wrcwap.ttf", + "800italic": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVKSTAyLFyeg_IDWvOJmVES_HwyPd80RbgJdhapcUU.ttf", + "900": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVMSTAyLFyeg_IDWvOJmVES_HS0Il8yT7wrcwap.ttf", + "900italic": "http://fonts.gstatic.com/s/bevietnampro/v8/QdVKSTAyLFyeg_IDWvOJmVES_HwyPfs1RbgJdhapcUU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bebas Neue", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/bebasneue/v8/JTUSjIg69CK48gW7PXooxW5rygbi49c.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Belgrano", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/belgrano/v16/55xvey5tM9rwKWrJZcMFirl08KDJ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bellefair", + "variants": [ + "regular" + ], + "subsets": [ + "hebrew", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/bellefair/v11/kJExBuYY6AAuhiXUxG19__A2pOdvDA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Belleza", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/belleza/v14/0nkoC9_pNeMfhX4BtcbyawzruP8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bellota", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/bellota/v14/MwQzbhXl3_qEpiwAID55kGMViblPtXs.ttf", + "300italic": "http://fonts.gstatic.com/s/bellota/v14/MwQxbhXl3_qEpiwAKJBjHGEfjZtKpXulTQ.ttf", + "regular": "http://fonts.gstatic.com/s/bellota/v14/MwQ2bhXl3_qEpiwAGJJRtGs-lbA.ttf", + "italic": "http://fonts.gstatic.com/s/bellota/v14/MwQ0bhXl3_qEpiwAKJBbsEk7hbBWrA.ttf", + "700": "http://fonts.gstatic.com/s/bellota/v14/MwQzbhXl3_qEpiwAIC5-kGMViblPtXs.ttf", + "700italic": "http://fonts.gstatic.com/s/bellota/v14/MwQxbhXl3_qEpiwAKJBjDGYfjZtKpXulTQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bellota Text", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/bellotatext/v14/0FlMVP2VnlWS4f3-UE9hHXM5VfsqfQXwQy6yxg.ttf", + "300italic": "http://fonts.gstatic.com/s/bellotatext/v14/0FlOVP2VnlWS4f3-UE9hHXMx--Gmfw_0YSuixmYK.ttf", + "regular": "http://fonts.gstatic.com/s/bellotatext/v14/0FlTVP2VnlWS4f3-UE9hHXMB-dMOdS7sSg.ttf", + "italic": "http://fonts.gstatic.com/s/bellotatext/v14/0FlNVP2VnlWS4f3-UE9hHXMx-9kKVyv8Sjer.ttf", + "700": "http://fonts.gstatic.com/s/bellotatext/v14/0FlMVP2VnlWS4f3-UE9hHXM5RfwqfQXwQy6yxg.ttf", + "700italic": "http://fonts.gstatic.com/s/bellotatext/v14/0FlOVP2VnlWS4f3-UE9hHXMx--G2eA_0YSuixmYK.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "BenchNine", + "variants": [ + "300", + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "300": "http://fonts.gstatic.com/s/benchnine/v14/ahcev8612zF4jxrwMosT--tRhWa8q0v8ag.ttf", + "regular": "http://fonts.gstatic.com/s/benchnine/v14/ahcbv8612zF4jxrwMosrV8N1jU2gog.ttf", + "700": "http://fonts.gstatic.com/s/benchnine/v14/ahcev8612zF4jxrwMosT6-xRhWa8q0v8ag.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Benne", + "variants": [ + "regular" + ], + "subsets": [ + "kannada", + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/benne/v20/L0xzDFAhn18E6Vjxlt6qTDBN.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bentham", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/bentham/v16/VdGeAZQPEpYfmHglKWw7CJaK_y4.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Berkshire Swash", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/berkshireswash/v14/ptRRTi-cavZOGqCvnNJDl5m5XmNPrcQybX4pQA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Besley", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/besley/v9/PlIhFlO1MaNwaNGWUC92IOH_mtG4fbbBSdRoFPOl8-E.ttf", + "500": "http://fonts.gstatic.com/s/besley/v9/PlIhFlO1MaNwaNGWUC92IOH_mtG4fYTBSdRoFPOl8-E.ttf", + "600": "http://fonts.gstatic.com/s/besley/v9/PlIhFlO1MaNwaNGWUC92IOH_mtG4fWjGSdRoFPOl8-E.ttf", + "700": "http://fonts.gstatic.com/s/besley/v9/PlIhFlO1MaNwaNGWUC92IOH_mtG4fVHGSdRoFPOl8-E.ttf", + "800": "http://fonts.gstatic.com/s/besley/v9/PlIhFlO1MaNwaNGWUC92IOH_mtG4fTbGSdRoFPOl8-E.ttf", + "900": "http://fonts.gstatic.com/s/besley/v9/PlIhFlO1MaNwaNGWUC92IOH_mtG4fR_GSdRoFPOl8-E.ttf", + "italic": "http://fonts.gstatic.com/s/besley/v9/PlIjFlO1MaNwaNG8WR2J-IiUAH-_aH6CoZdiENGg4-E04A.ttf", + "500italic": "http://fonts.gstatic.com/s/besley/v9/PlIjFlO1MaNwaNG8WR2J-IiUAH-_aH6Ck5diENGg4-E04A.ttf", + "600italic": "http://fonts.gstatic.com/s/besley/v9/PlIjFlO1MaNwaNG8WR2J-IiUAH-_aH6Cf5BiENGg4-E04A.ttf", + "700italic": "http://fonts.gstatic.com/s/besley/v9/PlIjFlO1MaNwaNG8WR2J-IiUAH-_aH6CRpBiENGg4-E04A.ttf", + "800italic": "http://fonts.gstatic.com/s/besley/v9/PlIjFlO1MaNwaNG8WR2J-IiUAH-_aH6CIZBiENGg4-E04A.ttf", + "900italic": "http://fonts.gstatic.com/s/besley/v9/PlIjFlO1MaNwaNG8WR2J-IiUAH-_aH6CCJBiENGg4-E04A.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Beth Ellen", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/bethellen/v15/WwkbxPW2BE-3rb_JNT-qEIAiVNo5xNY.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Bevan", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/bevan/v18/4iCj6KZ0a9NXjF8aUir7tlSJ.ttf", + "italic": "http://fonts.gstatic.com/s/bevan/v18/4iCt6KZ0a9NXjG8YWC7Zs0SJD4U.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Big Shoulders Display", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v12", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/bigshouldersdisplay/v12/fC1MPZJEZG-e9gHhdI4-NBbfd2ys3SjJCx12wPgf9g-_3F0YdY86JF46SRP4yZQ.ttf", + "200": "http://fonts.gstatic.com/s/bigshouldersdisplay/v12/fC1MPZJEZG-e9gHhdI4-NBbfd2ys3SjJCx12wPgf9g-_3F0YdQ87JF46SRP4yZQ.ttf", + "300": "http://fonts.gstatic.com/s/bigshouldersdisplay/v12/fC1MPZJEZG-e9gHhdI4-NBbfd2ys3SjJCx12wPgf9g-_3F0YddE7JF46SRP4yZQ.ttf", + "regular": "http://fonts.gstatic.com/s/bigshouldersdisplay/v12/fC1MPZJEZG-e9gHhdI4-NBbfd2ys3SjJCx12wPgf9g-_3F0YdY87JF46SRP4yZQ.ttf", + "500": "http://fonts.gstatic.com/s/bigshouldersdisplay/v12/fC1MPZJEZG-e9gHhdI4-NBbfd2ys3SjJCx12wPgf9g-_3F0Ydb07JF46SRP4yZQ.ttf", + "600": "http://fonts.gstatic.com/s/bigshouldersdisplay/v12/fC1MPZJEZG-e9gHhdI4-NBbfd2ys3SjJCx12wPgf9g-_3F0YdVE8JF46SRP4yZQ.ttf", + "700": "http://fonts.gstatic.com/s/bigshouldersdisplay/v12/fC1MPZJEZG-e9gHhdI4-NBbfd2ys3SjJCx12wPgf9g-_3F0YdWg8JF46SRP4yZQ.ttf", + "800": "http://fonts.gstatic.com/s/bigshouldersdisplay/v12/fC1MPZJEZG-e9gHhdI4-NBbfd2ys3SjJCx12wPgf9g-_3F0YdQ88JF46SRP4yZQ.ttf", + "900": "http://fonts.gstatic.com/s/bigshouldersdisplay/v12/fC1MPZJEZG-e9gHhdI4-NBbfd2ys3SjJCx12wPgf9g-_3F0YdSY8JF46SRP4yZQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Big Shoulders Inline Display", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/bigshouldersinlinedisplay/v18/_LOumyfF4eSU_SCrJc9OI24U7siGvBGcZqmqV9-ZZ85CGNOFeNLxoYMPJ0nBEnR5yPc2Huux.ttf", + "200": "http://fonts.gstatic.com/s/bigshouldersinlinedisplay/v18/_LOumyfF4eSU_SCrJc9OI24U7siGvBGcZqmqV9-ZZ85CGNOFeNLxoYMPJ0lBE3R5yPc2Huux.ttf", + "300": "http://fonts.gstatic.com/s/bigshouldersinlinedisplay/v18/_LOumyfF4eSU_SCrJc9OI24U7siGvBGcZqmqV9-ZZ85CGNOFeNLxoYMPJ0mfE3R5yPc2Huux.ttf", + "regular": "http://fonts.gstatic.com/s/bigshouldersinlinedisplay/v18/_LOumyfF4eSU_SCrJc9OI24U7siGvBGcZqmqV9-ZZ85CGNOFeNLxoYMPJ0nBE3R5yPc2Huux.ttf", + "500": "http://fonts.gstatic.com/s/bigshouldersinlinedisplay/v18/_LOumyfF4eSU_SCrJc9OI24U7siGvBGcZqmqV9-ZZ85CGNOFeNLxoYMPJ0nzE3R5yPc2Huux.ttf", + "600": "http://fonts.gstatic.com/s/bigshouldersinlinedisplay/v18/_LOumyfF4eSU_SCrJc9OI24U7siGvBGcZqmqV9-ZZ85CGNOFeNLxoYMPJ0kfFHR5yPc2Huux.ttf", + "700": "http://fonts.gstatic.com/s/bigshouldersinlinedisplay/v18/_LOumyfF4eSU_SCrJc9OI24U7siGvBGcZqmqV9-ZZ85CGNOFeNLxoYMPJ0kmFHR5yPc2Huux.ttf", + "800": "http://fonts.gstatic.com/s/bigshouldersinlinedisplay/v18/_LOumyfF4eSU_SCrJc9OI24U7siGvBGcZqmqV9-ZZ85CGNOFeNLxoYMPJ0lBFHR5yPc2Huux.ttf", + "900": "http://fonts.gstatic.com/s/bigshouldersinlinedisplay/v18/_LOumyfF4eSU_SCrJc9OI24U7siGvBGcZqmqV9-ZZ85CGNOFeNLxoYMPJ0loFHR5yPc2Huux.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Big Shoulders Inline Text", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/bigshouldersinlinetext/v18/vm8XdQDmVECV5-vm5dJ-Tp-6WDeRjL4RV7dP8u-NMyHY74qpoNNcwga0yqGN7Y6Jsc8c.ttf", + "200": "http://fonts.gstatic.com/s/bigshouldersinlinetext/v18/vm8XdQDmVECV5-vm5dJ-Tp-6WDeRjL4RV7dP8u-NMyHY74qpoNNcwgY0y6GN7Y6Jsc8c.ttf", + "300": "http://fonts.gstatic.com/s/bigshouldersinlinetext/v18/vm8XdQDmVECV5-vm5dJ-Tp-6WDeRjL4RV7dP8u-NMyHY74qpoNNcwgbqy6GN7Y6Jsc8c.ttf", + "regular": "http://fonts.gstatic.com/s/bigshouldersinlinetext/v18/vm8XdQDmVECV5-vm5dJ-Tp-6WDeRjL4RV7dP8u-NMyHY74qpoNNcwga0y6GN7Y6Jsc8c.ttf", + "500": "http://fonts.gstatic.com/s/bigshouldersinlinetext/v18/vm8XdQDmVECV5-vm5dJ-Tp-6WDeRjL4RV7dP8u-NMyHY74qpoNNcwgaGy6GN7Y6Jsc8c.ttf", + "600": "http://fonts.gstatic.com/s/bigshouldersinlinetext/v18/vm8XdQDmVECV5-vm5dJ-Tp-6WDeRjL4RV7dP8u-NMyHY74qpoNNcwgZqzKGN7Y6Jsc8c.ttf", + "700": "http://fonts.gstatic.com/s/bigshouldersinlinetext/v18/vm8XdQDmVECV5-vm5dJ-Tp-6WDeRjL4RV7dP8u-NMyHY74qpoNNcwgZTzKGN7Y6Jsc8c.ttf", + "800": "http://fonts.gstatic.com/s/bigshouldersinlinetext/v18/vm8XdQDmVECV5-vm5dJ-Tp-6WDeRjL4RV7dP8u-NMyHY74qpoNNcwgY0zKGN7Y6Jsc8c.ttf", + "900": "http://fonts.gstatic.com/s/bigshouldersinlinetext/v18/vm8XdQDmVECV5-vm5dJ-Tp-6WDeRjL4RV7dP8u-NMyHY74qpoNNcwgYdzKGN7Y6Jsc8c.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Big Shoulders Stencil Display", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/bigshouldersstencildisplay/v18/6aeZ4LS6U6pR_bp5b_t2ugOhHWFcxSGP9ttD96KCb8xPytKb-oPRU-vkuLm_O0nPKHznJucP9w.ttf", + "200": "http://fonts.gstatic.com/s/bigshouldersstencildisplay/v18/6aeZ4LS6U6pR_bp5b_t2ugOhHWFcxSGP9ttD96KCb8xPytKb-oPRU-vkuLm_u0jPKHznJucP9w.ttf", + "300": "http://fonts.gstatic.com/s/bigshouldersstencildisplay/v18/6aeZ4LS6U6pR_bp5b_t2ugOhHWFcxSGP9ttD96KCb8xPytKb-oPRU-vkuLm_ZUjPKHznJucP9w.ttf", + "regular": "http://fonts.gstatic.com/s/bigshouldersstencildisplay/v18/6aeZ4LS6U6pR_bp5b_t2ugOhHWFcxSGP9ttD96KCb8xPytKb-oPRU-vkuLm_O0jPKHznJucP9w.ttf", + "500": "http://fonts.gstatic.com/s/bigshouldersstencildisplay/v18/6aeZ4LS6U6pR_bp5b_t2ugOhHWFcxSGP9ttD96KCb8xPytKb-oPRU-vkuLm_CUjPKHznJucP9w.ttf", + "600": "http://fonts.gstatic.com/s/bigshouldersstencildisplay/v18/6aeZ4LS6U6pR_bp5b_t2ugOhHWFcxSGP9ttD96KCb8xPytKb-oPRU-vkuLm_5U_PKHznJucP9w.ttf", + "700": "http://fonts.gstatic.com/s/bigshouldersstencildisplay/v18/6aeZ4LS6U6pR_bp5b_t2ugOhHWFcxSGP9ttD96KCb8xPytKb-oPRU-vkuLm_3E_PKHznJucP9w.ttf", + "800": "http://fonts.gstatic.com/s/bigshouldersstencildisplay/v18/6aeZ4LS6U6pR_bp5b_t2ugOhHWFcxSGP9ttD96KCb8xPytKb-oPRU-vkuLm_u0_PKHznJucP9w.ttf", + "900": "http://fonts.gstatic.com/s/bigshouldersstencildisplay/v18/6aeZ4LS6U6pR_bp5b_t2ugOhHWFcxSGP9ttD96KCb8xPytKb-oPRU-vkuLm_kk_PKHznJucP9w.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Big Shoulders Stencil Text", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/bigshouldersstenciltext/v18/5aUV9-i2oxDMNwY3dHfW7UAt3Q453SM15wNj53bCcab2SJYLLUtk1OGR04XIGS_Py_AWbQ.ttf", + "200": "http://fonts.gstatic.com/s/bigshouldersstenciltext/v18/5aUV9-i2oxDMNwY3dHfW7UAt3Q453SM15wNj53bCcab2SJYLLUtk1OGRU4TIGS_Py_AWbQ.ttf", + "300": "http://fonts.gstatic.com/s/bigshouldersstenciltext/v18/5aUV9-i2oxDMNwY3dHfW7UAt3Q453SM15wNj53bCcab2SJYLLUtk1OGRjYTIGS_Py_AWbQ.ttf", + "regular": "http://fonts.gstatic.com/s/bigshouldersstenciltext/v18/5aUV9-i2oxDMNwY3dHfW7UAt3Q453SM15wNj53bCcab2SJYLLUtk1OGR04TIGS_Py_AWbQ.ttf", + "500": "http://fonts.gstatic.com/s/bigshouldersstenciltext/v18/5aUV9-i2oxDMNwY3dHfW7UAt3Q453SM15wNj53bCcab2SJYLLUtk1OGR4YTIGS_Py_AWbQ.ttf", + "600": "http://fonts.gstatic.com/s/bigshouldersstenciltext/v18/5aUV9-i2oxDMNwY3dHfW7UAt3Q453SM15wNj53bCcab2SJYLLUtk1OGRDYPIGS_Py_AWbQ.ttf", + "700": "http://fonts.gstatic.com/s/bigshouldersstenciltext/v18/5aUV9-i2oxDMNwY3dHfW7UAt3Q453SM15wNj53bCcab2SJYLLUtk1OGRNIPIGS_Py_AWbQ.ttf", + "800": "http://fonts.gstatic.com/s/bigshouldersstenciltext/v18/5aUV9-i2oxDMNwY3dHfW7UAt3Q453SM15wNj53bCcab2SJYLLUtk1OGRU4PIGS_Py_AWbQ.ttf", + "900": "http://fonts.gstatic.com/s/bigshouldersstenciltext/v18/5aUV9-i2oxDMNwY3dHfW7UAt3Q453SM15wNj53bCcab2SJYLLUtk1OGReoPIGS_Py_AWbQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Big Shoulders Text", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/bigshoulderstext/v14/55xEezRtP9G3CGPIf49hxc8P0eytUxB2l66LmF6xc3kA3Y-r3TIPNl6P2pc.ttf", + "200": "http://fonts.gstatic.com/s/bigshoulderstext/v14/55xEezRtP9G3CGPIf49hxc8P0eytUxB2l66LmF6xc3kA3Q-q3TIPNl6P2pc.ttf", + "300": "http://fonts.gstatic.com/s/bigshoulderstext/v14/55xEezRtP9G3CGPIf49hxc8P0eytUxB2l66LmF6xc3kA3dGq3TIPNl6P2pc.ttf", + "regular": "http://fonts.gstatic.com/s/bigshoulderstext/v14/55xEezRtP9G3CGPIf49hxc8P0eytUxB2l66LmF6xc3kA3Y-q3TIPNl6P2pc.ttf", + "500": "http://fonts.gstatic.com/s/bigshoulderstext/v14/55xEezRtP9G3CGPIf49hxc8P0eytUxB2l66LmF6xc3kA3b2q3TIPNl6P2pc.ttf", + "600": "http://fonts.gstatic.com/s/bigshoulderstext/v14/55xEezRtP9G3CGPIf49hxc8P0eytUxB2l66LmF6xc3kA3VGt3TIPNl6P2pc.ttf", + "700": "http://fonts.gstatic.com/s/bigshoulderstext/v14/55xEezRtP9G3CGPIf49hxc8P0eytUxB2l66LmF6xc3kA3Wit3TIPNl6P2pc.ttf", + "800": "http://fonts.gstatic.com/s/bigshoulderstext/v14/55xEezRtP9G3CGPIf49hxc8P0eytUxB2l66LmF6xc3kA3Q-t3TIPNl6P2pc.ttf", + "900": "http://fonts.gstatic.com/s/bigshoulderstext/v14/55xEezRtP9G3CGPIf49hxc8P0eytUxB2l66LmF6xc3kA3Sat3TIPNl6P2pc.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bigelow Rules", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v21", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/bigelowrules/v21/RrQWboly8iR_I3KWSzeRuN0zT4cCH8WAJVk.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bigshot One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/bigshotone/v23/u-470qukhRkkO6BD_7cM_gxuUQJBXv_-.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bilbo", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/bilbo/v18/o-0EIpgpwWwZ210hpIRz4wxE.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Bilbo Swash Caps", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/bilboswashcaps/v20/zrf-0GXbz-H3Wb4XBsGrTgq2PVmdqAPopiRfKp8.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "BioRhyme", + "variants": [ + "200", + "300", + "regular", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-13", + "files": { + "200": "http://fonts.gstatic.com/s/biorhyme/v10/1cX3aULHBpDMsHYW_ESOjnGAq8Sk1PoH.ttf", + "300": "http://fonts.gstatic.com/s/biorhyme/v10/1cX3aULHBpDMsHYW_ETqjXGAq8Sk1PoH.ttf", + "regular": "http://fonts.gstatic.com/s/biorhyme/v10/1cXwaULHBpDMsHYW_HxGpVWIgNit.ttf", + "700": "http://fonts.gstatic.com/s/biorhyme/v10/1cX3aULHBpDMsHYW_ET6inGAq8Sk1PoH.ttf", + "800": "http://fonts.gstatic.com/s/biorhyme/v10/1cX3aULHBpDMsHYW_ETmiXGAq8Sk1PoH.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "BioRhyme Expanded", + "variants": [ + "200", + "300", + "regular", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-05", + "files": { + "200": "http://fonts.gstatic.com/s/biorhymeexpanded/v17/i7dVIE1zZzytGswgU577CDY9LjbffxxcblSHSdTXrb_z.ttf", + "300": "http://fonts.gstatic.com/s/biorhymeexpanded/v17/i7dVIE1zZzytGswgU577CDY9Ljbffxw4bVSHSdTXrb_z.ttf", + "regular": "http://fonts.gstatic.com/s/biorhymeexpanded/v17/i7dQIE1zZzytGswgU577CDY9LjbffySURXCPYsje.ttf", + "700": "http://fonts.gstatic.com/s/biorhymeexpanded/v17/i7dVIE1zZzytGswgU577CDY9LjbffxwoalSHSdTXrb_z.ttf", + "800": "http://fonts.gstatic.com/s/biorhymeexpanded/v17/i7dVIE1zZzytGswgU577CDY9Ljbffxw0aVSHSdTXrb_z.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Birthstone", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/birthstone/v8/8AtsGs2xO4yLRhy87sv_HLn5jRfZHzM.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Birthstone Bounce", + "variants": [ + "regular", + "500" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/birthstonebounce/v7/ga6XaxZF43lIvTWrktHOTBJZGH7dEeVJGIMYDo_8.ttf", + "500": "http://fonts.gstatic.com/s/birthstonebounce/v7/ga6SaxZF43lIvTWrktHOTBJZGH7dEd29MacQJZP1LmD9.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Biryani", + "variants": [ + "200", + "300", + "regular", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "200": "http://fonts.gstatic.com/s/biryani/v11/hv-TlzNxIFoO84YddYQyGTBSU-J-RxQ.ttf", + "300": "http://fonts.gstatic.com/s/biryani/v11/hv-TlzNxIFoO84YddeAxGTBSU-J-RxQ.ttf", + "regular": "http://fonts.gstatic.com/s/biryani/v11/hv-WlzNxIFoO84YdTUwZPTh5T-s.ttf", + "600": "http://fonts.gstatic.com/s/biryani/v11/hv-TlzNxIFoO84YddZQ3GTBSU-J-RxQ.ttf", + "700": "http://fonts.gstatic.com/s/biryani/v11/hv-TlzNxIFoO84YddfA2GTBSU-J-RxQ.ttf", + "800": "http://fonts.gstatic.com/s/biryani/v11/hv-TlzNxIFoO84Yddew1GTBSU-J-RxQ.ttf", + "900": "http://fonts.gstatic.com/s/biryani/v11/hv-TlzNxIFoO84Yddcg0GTBSU-J-RxQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bitter", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v25", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/bitter/v25/raxhHiqOu8IVPmnRc6SY1KXhnF_Y8fbeCL_EXFh2reU.ttf", + "200": "http://fonts.gstatic.com/s/bitter/v25/raxhHiqOu8IVPmnRc6SY1KXhnF_Y8XbfCL_EXFh2reU.ttf", + "300": "http://fonts.gstatic.com/s/bitter/v25/raxhHiqOu8IVPmnRc6SY1KXhnF_Y8ajfCL_EXFh2reU.ttf", + "regular": "http://fonts.gstatic.com/s/bitter/v25/raxhHiqOu8IVPmnRc6SY1KXhnF_Y8fbfCL_EXFh2reU.ttf", + "500": "http://fonts.gstatic.com/s/bitter/v25/raxhHiqOu8IVPmnRc6SY1KXhnF_Y8cTfCL_EXFh2reU.ttf", + "600": "http://fonts.gstatic.com/s/bitter/v25/raxhHiqOu8IVPmnRc6SY1KXhnF_Y8SjYCL_EXFh2reU.ttf", + "700": "http://fonts.gstatic.com/s/bitter/v25/raxhHiqOu8IVPmnRc6SY1KXhnF_Y8RHYCL_EXFh2reU.ttf", + "800": "http://fonts.gstatic.com/s/bitter/v25/raxhHiqOu8IVPmnRc6SY1KXhnF_Y8XbYCL_EXFh2reU.ttf", + "900": "http://fonts.gstatic.com/s/bitter/v25/raxhHiqOu8IVPmnRc6SY1KXhnF_Y8V_YCL_EXFh2reU.ttf", + "100italic": "http://fonts.gstatic.com/s/bitter/v25/raxjHiqOu8IVPmn7epZnDMyKBvHf5D6c4P3OWHpzveWxBw.ttf", + "200italic": "http://fonts.gstatic.com/s/bitter/v25/raxjHiqOu8IVPmn7epZnDMyKBvHf5D6cYPzOWHpzveWxBw.ttf", + "300italic": "http://fonts.gstatic.com/s/bitter/v25/raxjHiqOu8IVPmn7epZnDMyKBvHf5D6cvvzOWHpzveWxBw.ttf", + "italic": "http://fonts.gstatic.com/s/bitter/v25/raxjHiqOu8IVPmn7epZnDMyKBvHf5D6c4PzOWHpzveWxBw.ttf", + "500italic": "http://fonts.gstatic.com/s/bitter/v25/raxjHiqOu8IVPmn7epZnDMyKBvHf5D6c0vzOWHpzveWxBw.ttf", + "600italic": "http://fonts.gstatic.com/s/bitter/v25/raxjHiqOu8IVPmn7epZnDMyKBvHf5D6cPvvOWHpzveWxBw.ttf", + "700italic": "http://fonts.gstatic.com/s/bitter/v25/raxjHiqOu8IVPmn7epZnDMyKBvHf5D6cB_vOWHpzveWxBw.ttf", + "800italic": "http://fonts.gstatic.com/s/bitter/v25/raxjHiqOu8IVPmn7epZnDMyKBvHf5D6cYPvOWHpzveWxBw.ttf", + "900italic": "http://fonts.gstatic.com/s/bitter/v25/raxjHiqOu8IVPmn7epZnDMyKBvHf5D6cSfvOWHpzveWxBw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Black And White Picture", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/blackandwhitepicture/v20/TwMe-JAERlQd3ooUHBUXGmrmioKjjnRSFO-NqI5HbcMi-yWY.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Black Han Sans", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/blackhansans/v13/ea8Aad44WunzF9a-dL6toA8r8nqVIXSkH-Hc.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Black Ops One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/blackopsone/v17/qWcsB6-ypo7xBdr6Xshe96H3WDzRtjkho4M.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Blinker", + "variants": [ + "100", + "200", + "300", + "regular", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/blinker/v10/cIf_MaFatEE-VTaP_E2hZEsCkIt9QQ.ttf", + "200": "http://fonts.gstatic.com/s/blinker/v10/cIf4MaFatEE-VTaP_OGARGEsnIJkWL4.ttf", + "300": "http://fonts.gstatic.com/s/blinker/v10/cIf4MaFatEE-VTaP_IWDRGEsnIJkWL4.ttf", + "regular": "http://fonts.gstatic.com/s/blinker/v10/cIf9MaFatEE-VTaPxCmrYGkHgIs.ttf", + "600": "http://fonts.gstatic.com/s/blinker/v10/cIf4MaFatEE-VTaP_PGFRGEsnIJkWL4.ttf", + "700": "http://fonts.gstatic.com/s/blinker/v10/cIf4MaFatEE-VTaP_JWERGEsnIJkWL4.ttf", + "800": "http://fonts.gstatic.com/s/blinker/v10/cIf4MaFatEE-VTaP_ImHRGEsnIJkWL4.ttf", + "900": "http://fonts.gstatic.com/s/blinker/v10/cIf4MaFatEE-VTaP_K2GRGEsnIJkWL4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bodoni Moda", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT67PxzY382XsXX63LUYL6GYFcan6NJrKp-VPjfJMShrpsGFUt8oU7awIBytVjMYwE.ttf", + "500": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT67PxzY382XsXX63LUYL6GYFcan6NJrKp-VPjfJMShrpsGFUt8oXzawIBytVjMYwE.ttf", + "600": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT67PxzY382XsXX63LUYL6GYFcan6NJrKp-VPjfJMShrpsGFUt8oZDdwIBytVjMYwE.ttf", + "700": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT67PxzY382XsXX63LUYL6GYFcan6NJrKp-VPjfJMShrpsGFUt8oandwIBytVjMYwE.ttf", + "800": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT67PxzY382XsXX63LUYL6GYFcan6NJrKp-VPjfJMShrpsGFUt8oc7dwIBytVjMYwE.ttf", + "900": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT67PxzY382XsXX63LUYL6GYFcan6NJrKp-VPjfJMShrpsGFUt8oefdwIBytVjMYwE.ttf", + "italic": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT07PxzY382XsXX63LUYJSPUqb0pL6OQqxrZLnVbvZedvJtj-V7tIaZKMN4sXrJcwHqoQ.ttf", + "500italic": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT07PxzY382XsXX63LUYJSPUqb0pL6OQqxrZLnVbvZedvJtj-V7tIaZGsN4sXrJcwHqoQ.ttf", + "600italic": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT07PxzY382XsXX63LUYJSPUqb0pL6OQqxrZLnVbvZedvJtj-V7tIaZ9sR4sXrJcwHqoQ.ttf", + "700italic": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT07PxzY382XsXX63LUYJSPUqb0pL6OQqxrZLnVbvZedvJtj-V7tIaZz8R4sXrJcwHqoQ.ttf", + "800italic": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT07PxzY382XsXX63LUYJSPUqb0pL6OQqxrZLnVbvZedvJtj-V7tIaZqMR4sXrJcwHqoQ.ttf", + "900italic": "http://fonts.gstatic.com/s/bodonimoda/v7/aFT07PxzY382XsXX63LUYJSPUqb0pL6OQqxrZLnVbvZedvJtj-V7tIaZgcR4sXrJcwHqoQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bokor", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v28", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/bokor/v28/m8JcjfpeeaqTiR2WdInbcaxE.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bona Nova", + "variants": [ + "regular", + "italic", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "hebrew", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/bonanova/v7/B50NF7ZCpX7fcHfvIUBJi6hqHK-CLA.ttf", + "italic": "http://fonts.gstatic.com/s/bonanova/v7/B50LF7ZCpX7fcHfvIUB5iaJuPqqSLJYf.ttf", + "700": "http://fonts.gstatic.com/s/bonanova/v7/B50IF7ZCpX7fcHfvIUBxN4dOFISeJY8GgQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bonbon", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v24", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/bonbon/v24/0FlVVPeVlFec4ee_cDEAbQY5-A.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Bonheur Royale", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/bonheurroyale/v7/c4m51nt_GMTrtX-b9GcG4-YRmYK_c0f1N5Ij.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Boogaloo", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/boogaloo/v17/kmK-Zq45GAvOdnaW6x1F_SrQo_1K.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bowlby One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/bowlbyone/v17/taiPGmVuC4y96PFeqp8smo6C_Z0wcK4.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bowlby One SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/bowlbyonesc/v17/DtVlJxerQqQm37tzN3wMug9Pzgj8owhNjuE.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Brawler", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/brawler/v16/xn7gYHE3xXewAscGsgC7S9XdZN8.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bree Serif", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/breeserif/v16/4UaHrEJCrhhnVA3DgluAx63j5pN1MwI.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Brygada 1918", + "variants": [ + "regular", + "500", + "600", + "700", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/brygada1918/v17/pe08MI6eKpdGqlF5LANrM--ACNaeo8mTUIR_y2-f-V8Wu5O3gbo.ttf", + "500": "http://fonts.gstatic.com/s/brygada1918/v17/pe08MI6eKpdGqlF5LANrM--ACNaeo8mTUIR_y12f-V8Wu5O3gbo.ttf", + "600": "http://fonts.gstatic.com/s/brygada1918/v17/pe08MI6eKpdGqlF5LANrM--ACNaeo8mTUIR_y7GY-V8Wu5O3gbo.ttf", + "700": "http://fonts.gstatic.com/s/brygada1918/v17/pe08MI6eKpdGqlF5LANrM--ACNaeo8mTUIR_y4iY-V8Wu5O3gbo.ttf", + "italic": "http://fonts.gstatic.com/s/brygada1918/v17/pe06MI6eKpdGqlF5LANrM--qAeRhe6D4yip43qfcERwcv7GykboaLg.ttf", + "500italic": "http://fonts.gstatic.com/s/brygada1918/v17/pe06MI6eKpdGqlF5LANrM--qAeRhe6D4yip43qfcIxwcv7GykboaLg.ttf", + "600italic": "http://fonts.gstatic.com/s/brygada1918/v17/pe06MI6eKpdGqlF5LANrM--qAeRhe6D4yip43qfczxscv7GykboaLg.ttf", + "700italic": "http://fonts.gstatic.com/s/brygada1918/v17/pe06MI6eKpdGqlF5LANrM--qAeRhe6D4yip43qfc9hscv7GykboaLg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bubblegum Sans", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/bubblegumsans/v14/AYCSpXb_Z9EORv1M5QTjEzMEtdaHzoPPb7R4.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bubbler One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/bubblerone/v18/f0Xy0eqj68ppQV9KBLmAouHH26MPePkt.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Buda", + "variants": [ + "300" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/buda/v23/GFDqWAN8mnyIJSSrG7UBr7pZKA0.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Buenard", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/buenard/v15/OD5DuM6Cyma8FnnsPzf9qGi9HL4.ttf", + "700": "http://fonts.gstatic.com/s/buenard/v15/OD5GuM6Cyma8FnnsB4vSjGCWALepwss.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Bungee", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/bungee/v9/N0bU2SZBIuF2PU_ECn50Kd_PmA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bungee Hairline", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/bungeehairline/v16/snfys0G548t04270a_ljTLUVrv-7YB2dQ5ZPqQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bungee Inline", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/bungeeinline/v9/Gg8zN58UcgnlCweMrih332VuDGJ1-FEglsc.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bungee Outline", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/bungeeoutline/v16/_6_mEDvmVP24UvU2MyiGDslL3Qg3YhJqPXxo.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Bungee Shade", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/bungeeshade/v9/DtVkJxarWL0t2KdzK3oI_jks7iLSrwFUlw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Butcherman", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/butcherman/v22/2EbiL-thF0loflXUBOdb1zWzq_5uT84.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Butterfly Kids", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/butterflykids/v19/ll8lK2CWTjuqAsXDqlnIbMNs5S4arxFrAX1D.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Cabin", + "variants": [ + "regular", + "500", + "600", + "700", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2021-01-30", + "files": { + "regular": "http://fonts.gstatic.com/s/cabin/v18/u-4X0qWljRw-PfU81xCKCpdpbgZJl6XFpfEd7eA9BIxxkV2EL7Gvxm7rE_s.ttf", + "500": "http://fonts.gstatic.com/s/cabin/v18/u-4X0qWljRw-PfU81xCKCpdpbgZJl6XFpfEd7eA9BIxxkW-EL7Gvxm7rE_s.ttf", + "600": "http://fonts.gstatic.com/s/cabin/v18/u-4X0qWljRw-PfU81xCKCpdpbgZJl6XFpfEd7eA9BIxxkYODL7Gvxm7rE_s.ttf", + "700": "http://fonts.gstatic.com/s/cabin/v18/u-4X0qWljRw-PfU81xCKCpdpbgZJl6XFpfEd7eA9BIxxkbqDL7Gvxm7rE_s.ttf", + "italic": "http://fonts.gstatic.com/s/cabin/v18/u-4V0qWljRw-Pd815fNqc8T_wAFcX-c37MPiNYlWniJ2hJXHx_KlwkzuA_u1Bg.ttf", + "500italic": "http://fonts.gstatic.com/s/cabin/v18/u-4V0qWljRw-Pd815fNqc8T_wAFcX-c37MPiNYlWniJ2hJXH9fKlwkzuA_u1Bg.ttf", + "600italic": "http://fonts.gstatic.com/s/cabin/v18/u-4V0qWljRw-Pd815fNqc8T_wAFcX-c37MPiNYlWniJ2hJXHGfWlwkzuA_u1Bg.ttf", + "700italic": "http://fonts.gstatic.com/s/cabin/v18/u-4V0qWljRw-Pd815fNqc8T_wAFcX-c37MPiNYlWniJ2hJXHIPWlwkzuA_u1Bg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cabin Condensed", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/cabincondensed/v17/nwpMtK6mNhBK2err_hqkYhHRqmwaYOjZ5HZl8Q.ttf", + "500": "http://fonts.gstatic.com/s/cabincondensed/v17/nwpJtK6mNhBK2err_hqkYhHRqmwilMH97F15-K1oqQ.ttf", + "600": "http://fonts.gstatic.com/s/cabincondensed/v17/nwpJtK6mNhBK2err_hqkYhHRqmwiuMb97F15-K1oqQ.ttf", + "700": "http://fonts.gstatic.com/s/cabincondensed/v17/nwpJtK6mNhBK2err_hqkYhHRqmwi3Mf97F15-K1oqQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cabin Sketch", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/cabinsketch/v17/QGYpz_kZZAGCONcK2A4bGOjMn9JM6fnuKg.ttf", + "700": "http://fonts.gstatic.com/s/cabinsketch/v17/QGY2z_kZZAGCONcK2A4bGOj0I_1o4dLyI4CMFw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Caesar Dressing", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/caesardressing/v19/yYLx0hLa3vawqtwdswbotmK4vrR3cbb6LZttyg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Cagliostro", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/cagliostro/v19/ZgNWjP5HM73BV5amnX-TjGXEM4COoE4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cairo", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/cairo/v17/SLXVc1nY6HkvangtZmpcWmhzfH5l2WgsQSaT0J0vRQ.ttf", + "300": "http://fonts.gstatic.com/s/cairo/v17/SLXVc1nY6HkvangtZmpcWmhzfH5lB2gsQSaT0J0vRQ.ttf", + "regular": "http://fonts.gstatic.com/s/cairo/v17/SLXVc1nY6HkvangtZmpcWmhzfH5lWWgsQSaT0J0vRQ.ttf", + "500": "http://fonts.gstatic.com/s/cairo/v17/SLXVc1nY6HkvangtZmpcWmhzfH5la2gsQSaT0J0vRQ.ttf", + "600": "http://fonts.gstatic.com/s/cairo/v17/SLXVc1nY6HkvangtZmpcWmhzfH5lh28sQSaT0J0vRQ.ttf", + "700": "http://fonts.gstatic.com/s/cairo/v17/SLXVc1nY6HkvangtZmpcWmhzfH5lvm8sQSaT0J0vRQ.ttf", + "800": "http://fonts.gstatic.com/s/cairo/v17/SLXVc1nY6HkvangtZmpcWmhzfH5l2W8sQSaT0J0vRQ.ttf", + "900": "http://fonts.gstatic.com/s/cairo/v17/SLXVc1nY6HkvangtZmpcWmhzfH5l8G8sQSaT0J0vRQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Caladea", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/caladea/v5/kJEzBugZ7AAjhybUjR93-9IztOc.ttf", + "italic": "http://fonts.gstatic.com/s/caladea/v5/kJExBugZ7AAjhybUvR19__A2pOdvDA.ttf", + "700": "http://fonts.gstatic.com/s/caladea/v5/kJE2BugZ7AAjhybUtaNY39oYqO52FZ0.ttf", + "700italic": "http://fonts.gstatic.com/s/caladea/v5/kJE0BugZ7AAjhybUvR1FQ98SrMxzBZ2lDA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Calistoga", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/calistoga/v8/6NUU8F2OJg6MeR7l4e0vtMYAwdRZfw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Calligraffitti", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/calligraffitti/v17/46k2lbT3XjDVqJw3DCmCFjE0vnFZM5ZBpYN-.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Cambay", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/cambay/v10/SLXJc1rY6H0_ZDsGbrSIz9JsaA.ttf", + "italic": "http://fonts.gstatic.com/s/cambay/v10/SLXLc1rY6H0_ZDs2bL6M7dd8aGZk.ttf", + "700": "http://fonts.gstatic.com/s/cambay/v10/SLXKc1rY6H0_ZDs-0pusx_lwYX99kA.ttf", + "700italic": "http://fonts.gstatic.com/s/cambay/v10/SLXMc1rY6H0_ZDs2bIYwwvN0Q3ptkDMN.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cambo", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/cambo/v12/IFSqHeNEk8FJk416ok7xkPm8.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Candal", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/candal/v13/XoHn2YH6T7-t_8cNAR4Jt9Yxlw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cantarell", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/cantarell/v13/B50NF7ZDq37KMUvlO01Ji6hqHK-CLA.ttf", + "italic": "http://fonts.gstatic.com/s/cantarell/v13/B50LF7ZDq37KMUvlO015iaJuPqqSLJYf.ttf", + "700": "http://fonts.gstatic.com/s/cantarell/v13/B50IF7ZDq37KMUvlO01xN4dOFISeJY8GgQ.ttf", + "700italic": "http://fonts.gstatic.com/s/cantarell/v13/B50WF7ZDq37KMUvlO015iZrSEY6aB4oWgWHB.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cantata One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/cantataone/v13/PlI5Fl60Nb5obNzNe2jslVxEt8CwfGaD.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cantora One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/cantoraone/v15/gyB4hws1JdgnKy56GB_JX6zdZ4vZVbgZ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Capriola", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/capriola/v11/wXKoE3YSppcvo1PDln_8L-AinG8y.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Caramel", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/caramel/v5/P5sCzZKBbMTf_ShyxCRuiZ-uydg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Carattere", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/carattere/v5/4iCv6Kp1b9dXlgt_CkvTt2aMH4V_gg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Cardo", + "variants": [ + "regular", + "italic", + "700" + ], + "subsets": [ + "greek", + "greek-ext", + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/cardo/v18/wlp_gwjKBV1pqiv_1oAZ2H5O.ttf", + "italic": "http://fonts.gstatic.com/s/cardo/v18/wlpxgwjKBV1pqhv93IQ73W5OcCk.ttf", + "700": "http://fonts.gstatic.com/s/cardo/v18/wlpygwjKBV1pqhND-aQR82JHaTBX.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Carme", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/carme/v14/ptRHTiWdbvZIDOjGxLNrxfbZ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Carrois Gothic", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/carroisgothic/v14/Z9XPDmFATg-N1PLtLOOxvIHl9ZmD3i7ajcJ-.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Carrois Gothic SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/carroisgothicsc/v13/ZgNJjOVHM6jfUZCmyUqT2A2HVKjc-28nNHabY4dN.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Carter One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/carterone/v15/q5uCsoe5IOB2-pXv9UcNIxR2hYxREMs.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Castoro", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/castoro/v16/1q2GY5yMCld3-O4cHYhEzOYenEU.ttf", + "italic": "http://fonts.gstatic.com/s/castoro/v16/1q2EY5yMCld3-O4cLYpOyMQbjEX5fw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Catamaran", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "tamil" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/catamaran/v14/o-0bIpQoyXQa2RxT7-5B6Ryxs2E_6n1iPHjc1anXuluiLyw.ttf", + "200": "http://fonts.gstatic.com/s/catamaran/v14/o-0bIpQoyXQa2RxT7-5B6Ryxs2E_6n1iPPjd1anXuluiLyw.ttf", + "300": "http://fonts.gstatic.com/s/catamaran/v14/o-0bIpQoyXQa2RxT7-5B6Ryxs2E_6n1iPCbd1anXuluiLyw.ttf", + "regular": "http://fonts.gstatic.com/s/catamaran/v14/o-0bIpQoyXQa2RxT7-5B6Ryxs2E_6n1iPHjd1anXuluiLyw.ttf", + "500": "http://fonts.gstatic.com/s/catamaran/v14/o-0bIpQoyXQa2RxT7-5B6Ryxs2E_6n1iPErd1anXuluiLyw.ttf", + "600": "http://fonts.gstatic.com/s/catamaran/v14/o-0bIpQoyXQa2RxT7-5B6Ryxs2E_6n1iPKba1anXuluiLyw.ttf", + "700": "http://fonts.gstatic.com/s/catamaran/v14/o-0bIpQoyXQa2RxT7-5B6Ryxs2E_6n1iPJ_a1anXuluiLyw.ttf", + "800": "http://fonts.gstatic.com/s/catamaran/v14/o-0bIpQoyXQa2RxT7-5B6Ryxs2E_6n1iPPja1anXuluiLyw.ttf", + "900": "http://fonts.gstatic.com/s/catamaran/v14/o-0bIpQoyXQa2RxT7-5B6Ryxs2E_6n1iPNHa1anXuluiLyw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Caudex", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "greek", + "greek-ext", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/caudex/v13/esDQ311QOP6BJUrIyviAnb4eEw.ttf", + "italic": "http://fonts.gstatic.com/s/caudex/v13/esDS311QOP6BJUr4yPKEv7sOE4in.ttf", + "700": "http://fonts.gstatic.com/s/caudex/v13/esDT311QOP6BJUrwdteklZUCGpG-GQ.ttf", + "700italic": "http://fonts.gstatic.com/s/caudex/v13/esDV311QOP6BJUr4yMo4kJ8GOJSuGdLB.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Caveat", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/caveat/v14/WnznHAc5bAfYB2QRah7pcpNvOx-pjfJ9SIKjYBxPigs.ttf", + "500": "http://fonts.gstatic.com/s/caveat/v14/WnznHAc5bAfYB2QRah7pcpNvOx-pjcB9SIKjYBxPigs.ttf", + "600": "http://fonts.gstatic.com/s/caveat/v14/WnznHAc5bAfYB2QRah7pcpNvOx-pjSx6SIKjYBxPigs.ttf", + "700": "http://fonts.gstatic.com/s/caveat/v14/WnznHAc5bAfYB2QRah7pcpNvOx-pjRV6SIKjYBxPigs.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Caveat Brush", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/caveatbrush/v9/EYq0maZfwr9S9-ETZc3fKXtMW7mT03pdQw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Cedarville Cursive", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/cedarvillecursive/v15/yYL00g_a2veiudhUmxjo5VKkoqA-B_neJbBxw8BeTg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Ceviche One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/cevicheone/v14/gyB4hws1IcA6JzR-GB_JX6zdZ4vZVbgZ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Chakra Petch", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/chakrapetch/v8/cIflMapbsEk7TDLdtEz1BwkeNIhFQJXE3AY00g.ttf", + "300italic": "http://fonts.gstatic.com/s/chakrapetch/v8/cIfnMapbsEk7TDLdtEz1BwkWmpLJQp_A_gMk0izH.ttf", + "regular": "http://fonts.gstatic.com/s/chakrapetch/v8/cIf6MapbsEk7TDLdtEz1BwkmmKBhSL7Y1Q.ttf", + "italic": "http://fonts.gstatic.com/s/chakrapetch/v8/cIfkMapbsEk7TDLdtEz1BwkWmqplarvI1R8t.ttf", + "500": "http://fonts.gstatic.com/s/chakrapetch/v8/cIflMapbsEk7TDLdtEz1BwkebIlFQJXE3AY00g.ttf", + "500italic": "http://fonts.gstatic.com/s/chakrapetch/v8/cIfnMapbsEk7TDLdtEz1BwkWmpKRQ5_A_gMk0izH.ttf", + "600": "http://fonts.gstatic.com/s/chakrapetch/v8/cIflMapbsEk7TDLdtEz1BwkeQI5FQJXE3AY00g.ttf", + "600italic": "http://fonts.gstatic.com/s/chakrapetch/v8/cIfnMapbsEk7TDLdtEz1BwkWmpK9RJ_A_gMk0izH.ttf", + "700": "http://fonts.gstatic.com/s/chakrapetch/v8/cIflMapbsEk7TDLdtEz1BwkeJI9FQJXE3AY00g.ttf", + "700italic": "http://fonts.gstatic.com/s/chakrapetch/v8/cIfnMapbsEk7TDLdtEz1BwkWmpLZRZ_A_gMk0izH.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Changa", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/changa/v17/2-c79JNi2YuVOUcOarRPgnNGooxCZy2xQjDp9htf1ZM.ttf", + "300": "http://fonts.gstatic.com/s/changa/v17/2-c79JNi2YuVOUcOarRPgnNGooxCZ_OxQjDp9htf1ZM.ttf", + "regular": "http://fonts.gstatic.com/s/changa/v17/2-c79JNi2YuVOUcOarRPgnNGooxCZ62xQjDp9htf1ZM.ttf", + "500": "http://fonts.gstatic.com/s/changa/v17/2-c79JNi2YuVOUcOarRPgnNGooxCZ5-xQjDp9htf1ZM.ttf", + "600": "http://fonts.gstatic.com/s/changa/v17/2-c79JNi2YuVOUcOarRPgnNGooxCZ3O2QjDp9htf1ZM.ttf", + "700": "http://fonts.gstatic.com/s/changa/v17/2-c79JNi2YuVOUcOarRPgnNGooxCZ0q2QjDp9htf1ZM.ttf", + "800": "http://fonts.gstatic.com/s/changa/v17/2-c79JNi2YuVOUcOarRPgnNGooxCZy22QjDp9htf1ZM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Changa One", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/changaone/v16/xfu00W3wXn3QLUJXhzq46AbouLfbK64.ttf", + "italic": "http://fonts.gstatic.com/s/changaone/v16/xfu20W3wXn3QLUJXhzq42ATivJXeO67ISw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Chango", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/chango/v19/2V0cKI0OB5U7WaJyz324TFUaAw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Charm", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/charm/v8/7cHmv4oii5K0MeYvIe804WIo.ttf", + "700": "http://fonts.gstatic.com/s/charm/v8/7cHrv4oii5K0Md6TDss8yn4hnCci.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Charmonman", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/charmonman/v16/MjQDmiR3vP_nuxDv47jiWJGovLdh6OE.ttf", + "700": "http://fonts.gstatic.com/s/charmonman/v16/MjQAmiR3vP_nuxDv47jiYC2HmL9K9OhmGnY.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Chathura", + "variants": [ + "100", + "300", + "regular", + "700", + "800" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "100": "http://fonts.gstatic.com/s/chathura/v18/_gP91R7-rzUuVjim42dEq0SbTvZyuDo.ttf", + "300": "http://fonts.gstatic.com/s/chathura/v18/_gP81R7-rzUuVjim42eMiWSxYPp7oSNy.ttf", + "regular": "http://fonts.gstatic.com/s/chathura/v18/_gP71R7-rzUuVjim418goUC5S-Zy.ttf", + "700": "http://fonts.gstatic.com/s/chathura/v18/_gP81R7-rzUuVjim42ecjmSxYPp7oSNy.ttf", + "800": "http://fonts.gstatic.com/s/chathura/v18/_gP81R7-rzUuVjim42eAjWSxYPp7oSNy.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Chau Philomene One", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/chauphilomeneone/v13/55xxezRsPtfie1vPY49qzdgSlJiHRQFsnIx7QMISdQ.ttf", + "italic": "http://fonts.gstatic.com/s/chauphilomeneone/v13/55xzezRsPtfie1vPY49qzdgSlJiHRQFcnoZ_YscCdXQB.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Chela One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/chelaone/v19/6ae-4KC7Uqgdz_JZdPIy31vWNTMwoQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Chelsea Market", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/chelseamarket/v11/BCawqZsHqfr89WNP_IApC8tzKBhlLA4uKkWk.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Chenla", + "variants": [ + "regular" + ], + "subsets": [ + "khmer" + ], + "version": "v23", + "lastModified": "2021-12-01", + "files": { + "regular": "http://fonts.gstatic.com/s/chenla/v23/SZc43FDpIKu8WZ9eXxfonUPL6Q.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Cherish", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/cherish/v5/ll88K2mXUyqsDsTN5iDCI6IJjg8.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Cherry Cream Soda", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/cherrycreamsoda/v13/UMBIrOxBrW6w2FFyi9paG0fdVdRciTd6Cd47DJ7G.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Cherry Swash", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/cherryswash/v16/i7dNIFByZjaNAMxtZcnfAy58QHi-EwWMbg.ttf", + "700": "http://fonts.gstatic.com/s/cherryswash/v16/i7dSIFByZjaNAMxtZcnfAy5E_FeaGy6QZ3WfYg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Chewy", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/chewy/v12/uK_94ruUb-k-wk5xIDMfO-ed.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Chicle", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/chicle/v19/lJwG-pw9i2dqU-BDyWKuobYSxw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Chilanka", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "malayalam" + ], + "version": "v16", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/chilanka/v16/WWXRlj2DZQiMJYaYRrJQI9EAZhTO.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Chivo", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/chivo/v16/va9F4kzIxd1KFrjDY8Z_uqzGQC_-.ttf", + "300italic": "http://fonts.gstatic.com/s/chivo/v16/va9D4kzIxd1KFrBteUp9sKjkRT_-bF0.ttf", + "regular": "http://fonts.gstatic.com/s/chivo/v16/va9I4kzIxd1KFoBvS-J3kbDP.ttf", + "italic": "http://fonts.gstatic.com/s/chivo/v16/va9G4kzIxd1KFrBtQeZVlKDPWTY.ttf", + "700": "http://fonts.gstatic.com/s/chivo/v16/va9F4kzIxd1KFrjTZMZ_uqzGQC_-.ttf", + "700italic": "http://fonts.gstatic.com/s/chivo/v16/va9D4kzIxd1KFrBteVp6sKjkRT_-bF0.ttf", + "900": "http://fonts.gstatic.com/s/chivo/v16/va9F4kzIxd1KFrjrZsZ_uqzGQC_-.ttf", + "900italic": "http://fonts.gstatic.com/s/chivo/v16/va9D4kzIxd1KFrBteWJ4sKjkRT_-bF0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Chonburi", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/chonburi/v8/8AtqGs-wOpGRTBq66IWaFr3biAfZ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Cinzel", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/cinzel/v16/8vIU7ww63mVu7gtR-kwKxNvkNOjw-tbnTYrvDE5ZdqU.ttf", + "500": "http://fonts.gstatic.com/s/cinzel/v16/8vIU7ww63mVu7gtR-kwKxNvkNOjw-uTnTYrvDE5ZdqU.ttf", + "600": "http://fonts.gstatic.com/s/cinzel/v16/8vIU7ww63mVu7gtR-kwKxNvkNOjw-gjgTYrvDE5ZdqU.ttf", + "700": "http://fonts.gstatic.com/s/cinzel/v16/8vIU7ww63mVu7gtR-kwKxNvkNOjw-jHgTYrvDE5ZdqU.ttf", + "800": "http://fonts.gstatic.com/s/cinzel/v16/8vIU7ww63mVu7gtR-kwKxNvkNOjw-lbgTYrvDE5ZdqU.ttf", + "900": "http://fonts.gstatic.com/s/cinzel/v16/8vIU7ww63mVu7gtR-kwKxNvkNOjw-n_gTYrvDE5ZdqU.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cinzel Decorative", + "variants": [ + "regular", + "700", + "900" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/cinzeldecorative/v12/daaCSScvJGqLYhG8nNt8KPPswUAPnh7URs1LaCyC.ttf", + "700": "http://fonts.gstatic.com/s/cinzeldecorative/v12/daaHSScvJGqLYhG8nNt8KPPswUAPniZoaelDQzCLlQXE.ttf", + "900": "http://fonts.gstatic.com/s/cinzeldecorative/v12/daaHSScvJGqLYhG8nNt8KPPswUAPniZQa-lDQzCLlQXE.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Clicker Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/clickerscript/v11/raxkHiKPvt8CMH6ZWP8PdlEq72rY2zqUKafv.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Coda", + "variants": [ + "regular", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/coda/v19/SLXHc1jY5nQ8JUIMapaN39I.ttf", + "800": "http://fonts.gstatic.com/s/coda/v19/SLXIc1jY5nQ8HeIgTp6mw9t1cX8.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Coda Caption", + "variants": [ + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "800": "http://fonts.gstatic.com/s/codacaption/v17/ieVm2YRII2GMY7SyXSoDRiQGqcx6x_-fACIgaw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Codystar", + "variants": [ + "300", + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/codystar/v13/FwZf7-Q1xVk-40qxOuYsyuyrj0e29bfC.ttf", + "regular": "http://fonts.gstatic.com/s/codystar/v13/FwZY7-Q1xVk-40qxOt6A4sijpFu_.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Coiny", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "tamil", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/coiny/v14/gyByhwU1K989PXwbElSvO5Tc.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Combo", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/combo/v19/BXRlvF3Jh_fIhg0iBu9y8Hf0.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Comfortaa", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v37", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/comfortaa/v37/1Pt_g8LJRfWJmhDAuUsSQamb1W0lwk4S4TbMPrQVIT9c2c8.ttf", + "regular": "http://fonts.gstatic.com/s/comfortaa/v37/1Pt_g8LJRfWJmhDAuUsSQamb1W0lwk4S4WjMPrQVIT9c2c8.ttf", + "500": "http://fonts.gstatic.com/s/comfortaa/v37/1Pt_g8LJRfWJmhDAuUsSQamb1W0lwk4S4VrMPrQVIT9c2c8.ttf", + "600": "http://fonts.gstatic.com/s/comfortaa/v37/1Pt_g8LJRfWJmhDAuUsSQamb1W0lwk4S4bbLPrQVIT9c2c8.ttf", + "700": "http://fonts.gstatic.com/s/comfortaa/v37/1Pt_g8LJRfWJmhDAuUsSQamb1W0lwk4S4Y_LPrQVIT9c2c8.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Comforter", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/comforter/v3/H4clBXOCl8nQnlaql3Qa6JG8iqeuag.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Comforter Brush", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/comforterbrush/v3/Y4GTYa1xVSggrfzZI5WMjxRaOz0jwLL9Th8YYA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Comic Neue", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v6", + "lastModified": "2022-01-13", + "files": { + "300": "http://fonts.gstatic.com/s/comicneue/v6/4UaErEJDsxBrF37olUeD_wHLwpteLwtHJlc.ttf", + "300italic": "http://fonts.gstatic.com/s/comicneue/v6/4UaarEJDsxBrF37olUeD96_RTplUKylCNlcw_Q.ttf", + "regular": "http://fonts.gstatic.com/s/comicneue/v6/4UaHrEJDsxBrF37olUeDx63j5pN1MwI.ttf", + "italic": "http://fonts.gstatic.com/s/comicneue/v6/4UaFrEJDsxBrF37olUeD96_p4rFwIwJePw.ttf", + "700": "http://fonts.gstatic.com/s/comicneue/v6/4UaErEJDsxBrF37olUeD_xHMwpteLwtHJlc.ttf", + "700italic": "http://fonts.gstatic.com/s/comicneue/v6/4UaarEJDsxBrF37olUeD96_RXp5UKylCNlcw_Q.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Coming Soon", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/comingsoon/v17/qWcuB6mzpYL7AJ2VfdQR1u-SUjjzsykh.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Commissioner", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/commissioner/v10/tDbe2o2WnlgI0FNDgduEk4jAhwgIy5k8SlfU5Ni-IO9pOXuRoaY.ttf", + "200": "http://fonts.gstatic.com/s/commissioner/v10/tDbe2o2WnlgI0FNDgduEk4jAhwgIy5k8SlfU5Fi_IO9pOXuRoaY.ttf", + "300": "http://fonts.gstatic.com/s/commissioner/v10/tDbe2o2WnlgI0FNDgduEk4jAhwgIy5k8SlfU5Ia_IO9pOXuRoaY.ttf", + "regular": "http://fonts.gstatic.com/s/commissioner/v10/tDbe2o2WnlgI0FNDgduEk4jAhwgIy5k8SlfU5Ni_IO9pOXuRoaY.ttf", + "500": "http://fonts.gstatic.com/s/commissioner/v10/tDbe2o2WnlgI0FNDgduEk4jAhwgIy5k8SlfU5Oq_IO9pOXuRoaY.ttf", + "600": "http://fonts.gstatic.com/s/commissioner/v10/tDbe2o2WnlgI0FNDgduEk4jAhwgIy5k8SlfU5Aa4IO9pOXuRoaY.ttf", + "700": "http://fonts.gstatic.com/s/commissioner/v10/tDbe2o2WnlgI0FNDgduEk4jAhwgIy5k8SlfU5D-4IO9pOXuRoaY.ttf", + "800": "http://fonts.gstatic.com/s/commissioner/v10/tDbe2o2WnlgI0FNDgduEk4jAhwgIy5k8SlfU5Fi4IO9pOXuRoaY.ttf", + "900": "http://fonts.gstatic.com/s/commissioner/v10/tDbe2o2WnlgI0FNDgduEk4jAhwgIy5k8SlfU5HG4IO9pOXuRoaY.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Concert One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/concertone/v16/VEM1Ro9xs5PjtzCu-srDqRTlhv-CuVAQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Condiment", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/condiment/v18/pONk1hggFNmwvXALyH6Sq4n4o1vyCQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Content", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "khmer" + ], + "version": "v15", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/content/v15/zrfl0HLayePhU_AwUaDyIiL0RCg.ttf", + "700": "http://fonts.gstatic.com/s/content/v15/zrfg0HLayePhU_AwaRzdBirfWCHvkAI.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Contrail One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/contrailone/v13/eLGbP-j_JA-kG0_Zo51noafdZUvt_c092w.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Convergence", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/convergence/v13/rax5HiePvdgXPmmMHcIPYRhasU7Q8Cad.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cookie", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/cookie/v16/syky-y18lb0tSbfNlQCT9tPdpw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Copse", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/copse/v13/11hPGpDKz1rGb0djHkihUb-A.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Corben", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/corben/v17/LYjDdGzzklQtCMp9oAlEpVs3VQ.ttf", + "700": "http://fonts.gstatic.com/s/corben/v17/LYjAdGzzklQtCMpFHCZgrXArXN7HWQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Corinthia", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/corinthia/v7/wEO_EBrAnchaJyPMHE0FUfAL3EsHiA.ttf", + "700": "http://fonts.gstatic.com/s/corinthia/v7/wEO6EBrAnchaJyPMHE097d8v1GAbgbLXQA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Cormorant", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "300": "http://fonts.gstatic.com/s/cormorant/v14/H4cgBXOCl9bbnla_nHIiRLmYgoyyYzFzFw.ttf", + "300italic": "http://fonts.gstatic.com/s/cormorant/v14/H4c-BXOCl9bbnla_nHIq6qMUgIa2QTRjF8ER.ttf", + "regular": "http://fonts.gstatic.com/s/cormorant/v14/H4clBXOCl9bbnla_nHIa6JG8iqeuag.ttf", + "italic": "http://fonts.gstatic.com/s/cormorant/v14/H4cjBXOCl9bbnla_nHIq6pu4qKK-aihq.ttf", + "500": "http://fonts.gstatic.com/s/cormorant/v14/H4cgBXOCl9bbnla_nHIiHLiYgoyyYzFzFw.ttf", + "500italic": "http://fonts.gstatic.com/s/cormorant/v14/H4c-BXOCl9bbnla_nHIq6qNMgYa2QTRjF8ER.ttf", + "600": "http://fonts.gstatic.com/s/cormorant/v14/H4cgBXOCl9bbnla_nHIiML-YgoyyYzFzFw.ttf", + "600italic": "http://fonts.gstatic.com/s/cormorant/v14/H4c-BXOCl9bbnla_nHIq6qNghoa2QTRjF8ER.ttf", + "700": "http://fonts.gstatic.com/s/cormorant/v14/H4cgBXOCl9bbnla_nHIiVL6YgoyyYzFzFw.ttf", + "700italic": "http://fonts.gstatic.com/s/cormorant/v14/H4c-BXOCl9bbnla_nHIq6qMEh4a2QTRjF8ER.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cormorant Garamond", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/cormorantgaramond/v14/co3YmX5slCNuHLi8bLeY9MK7whWMhyjQAllvuQWJ5heb_w.ttf", + "300italic": "http://fonts.gstatic.com/s/cormorantgaramond/v14/co3WmX5slCNuHLi8bLeY9MK7whWMhyjYrEPjuw-NxBKL_y94.ttf", + "regular": "http://fonts.gstatic.com/s/cormorantgaramond/v14/co3bmX5slCNuHLi8bLeY9MK7whWMhyjornFLsS6V7w.ttf", + "italic": "http://fonts.gstatic.com/s/cormorantgaramond/v14/co3ZmX5slCNuHLi8bLeY9MK7whWMhyjYrHtPkyuF7w6C.ttf", + "500": "http://fonts.gstatic.com/s/cormorantgaramond/v14/co3YmX5slCNuHLi8bLeY9MK7whWMhyjQWlhvuQWJ5heb_w.ttf", + "500italic": "http://fonts.gstatic.com/s/cormorantgaramond/v14/co3WmX5slCNuHLi8bLeY9MK7whWMhyjYrEO7ug-NxBKL_y94.ttf", + "600": "http://fonts.gstatic.com/s/cormorantgaramond/v14/co3YmX5slCNuHLi8bLeY9MK7whWMhyjQdl9vuQWJ5heb_w.ttf", + "600italic": "http://fonts.gstatic.com/s/cormorantgaramond/v14/co3WmX5slCNuHLi8bLeY9MK7whWMhyjYrEOXvQ-NxBKL_y94.ttf", + "700": "http://fonts.gstatic.com/s/cormorantgaramond/v14/co3YmX5slCNuHLi8bLeY9MK7whWMhyjQEl5vuQWJ5heb_w.ttf", + "700italic": "http://fonts.gstatic.com/s/cormorantgaramond/v14/co3WmX5slCNuHLi8bLeY9MK7whWMhyjYrEPzvA-NxBKL_y94.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cormorant Infant", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "300": "http://fonts.gstatic.com/s/cormorantinfant/v14/HhyIU44g9vKiM1sORYSiWeAsLN9951w3_DMrQqcdJrk.ttf", + "300italic": "http://fonts.gstatic.com/s/cormorantinfant/v14/HhyKU44g9vKiM1sORYSiWeAsLN997_ItcDEhRoUYNrn_Ig.ttf", + "regular": "http://fonts.gstatic.com/s/cormorantinfant/v14/HhyPU44g9vKiM1sORYSiWeAsLN993_Af2DsAXq4.ttf", + "italic": "http://fonts.gstatic.com/s/cormorantinfant/v14/HhyJU44g9vKiM1sORYSiWeAsLN997_IV3BkFTq4EPw.ttf", + "500": "http://fonts.gstatic.com/s/cormorantinfant/v14/HhyIU44g9vKiM1sORYSiWeAsLN995wQ2_DMrQqcdJrk.ttf", + "500italic": "http://fonts.gstatic.com/s/cormorantinfant/v14/HhyKU44g9vKiM1sORYSiWeAsLN997_ItKDAhRoUYNrn_Ig.ttf", + "600": "http://fonts.gstatic.com/s/cormorantinfant/v14/HhyIU44g9vKiM1sORYSiWeAsLN995ygx_DMrQqcdJrk.ttf", + "600italic": "http://fonts.gstatic.com/s/cormorantinfant/v14/HhyKU44g9vKiM1sORYSiWeAsLN997_ItBDchRoUYNrn_Ig.ttf", + "700": "http://fonts.gstatic.com/s/cormorantinfant/v14/HhyIU44g9vKiM1sORYSiWeAsLN9950ww_DMrQqcdJrk.ttf", + "700italic": "http://fonts.gstatic.com/s/cormorantinfant/v14/HhyKU44g9vKiM1sORYSiWeAsLN997_ItYDYhRoUYNrn_Ig.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cormorant SC", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "300": "http://fonts.gstatic.com/s/cormorantsc/v14/0ybmGD4kxqXBmOVLG30OGwsmABIU_R3y8DOWGA.ttf", + "regular": "http://fonts.gstatic.com/s/cormorantsc/v14/0yb5GD4kxqXBmOVLG30OGwserDow9Tbu-Q.ttf", + "500": "http://fonts.gstatic.com/s/cormorantsc/v14/0ybmGD4kxqXBmOVLG30OGwsmWBMU_R3y8DOWGA.ttf", + "600": "http://fonts.gstatic.com/s/cormorantsc/v14/0ybmGD4kxqXBmOVLG30OGwsmdBQU_R3y8DOWGA.ttf", + "700": "http://fonts.gstatic.com/s/cormorantsc/v14/0ybmGD4kxqXBmOVLG30OGwsmEBUU_R3y8DOWGA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cormorant Unicase", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v21", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/cormorantunicase/v21/HI_ViZUaILtOqhqgDeXoF_n1_fTGX9N_tucv7Gy0DRzS.ttf", + "regular": "http://fonts.gstatic.com/s/cormorantunicase/v21/HI_QiZUaILtOqhqgDeXoF_n1_fTGX-vTnsMnx3C9.ttf", + "500": "http://fonts.gstatic.com/s/cormorantunicase/v21/HI_ViZUaILtOqhqgDeXoF_n1_fTGX9Mnt-cv7Gy0DRzS.ttf", + "600": "http://fonts.gstatic.com/s/cormorantunicase/v21/HI_ViZUaILtOqhqgDeXoF_n1_fTGX9MLsOcv7Gy0DRzS.ttf", + "700": "http://fonts.gstatic.com/s/cormorantunicase/v21/HI_ViZUaILtOqhqgDeXoF_n1_fTGX9Nvsecv7Gy0DRzS.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cormorant Upright", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/cormorantupright/v16/VuJudM3I2Y35poFONtLdafkUCHw1y1N5phDsU9X6RPzQ.ttf", + "regular": "http://fonts.gstatic.com/s/cormorantupright/v16/VuJrdM3I2Y35poFONtLdafkUCHw1y2vVjjTkeMnz.ttf", + "500": "http://fonts.gstatic.com/s/cormorantupright/v16/VuJudM3I2Y35poFONtLdafkUCHw1y1MhpxDsU9X6RPzQ.ttf", + "600": "http://fonts.gstatic.com/s/cormorantupright/v16/VuJudM3I2Y35poFONtLdafkUCHw1y1MNoBDsU9X6RPzQ.ttf", + "700": "http://fonts.gstatic.com/s/cormorantupright/v16/VuJudM3I2Y35poFONtLdafkUCHw1y1NpoRDsU9X6RPzQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Courgette", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/courgette/v12/wEO_EBrAnc9BLjLQAUkFUfAL3EsHiA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Courier Prime", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/courierprime/v5/u-450q2lgwslOqpF_6gQ8kELWwZjW-_-tvg.ttf", + "italic": "http://fonts.gstatic.com/s/courierprime/v5/u-4n0q2lgwslOqpF_6gQ8kELawRpX837pvjxPA.ttf", + "700": "http://fonts.gstatic.com/s/courierprime/v5/u-4k0q2lgwslOqpF_6gQ8kELY7pMf-fVqvHoJXw.ttf", + "700italic": "http://fonts.gstatic.com/s/courierprime/v5/u-4i0q2lgwslOqpF_6gQ8kELawRR4-LfrtPtNXyeAg.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Cousine", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "hebrew", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/cousine/v22/d6lIkaiiRdih4SpPzSMlzTbtz9k.ttf", + "italic": "http://fonts.gstatic.com/s/cousine/v22/d6lKkaiiRdih4SpP_SEvyRTo39l8hw.ttf", + "700": "http://fonts.gstatic.com/s/cousine/v22/d6lNkaiiRdih4SpP9Z8K6T7G09BlnmQ.ttf", + "700italic": "http://fonts.gstatic.com/s/cousine/v22/d6lPkaiiRdih4SpP_SEXdTvM1_JgjmRpOA.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Coustard", + "variants": [ + "regular", + "900" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/coustard/v14/3XFpErgg3YsZ5fqUU9UPvWXuROTd.ttf", + "900": "http://fonts.gstatic.com/s/coustard/v14/3XFuErgg3YsZ5fqUU-2LkEHmb_jU3eRL.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Covered By Your Grace", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/coveredbyyourgrace/v13/QGYwz-AZahWOJJI9kykWW9mD6opopoqXSOS0FgItq6bFIg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Crafty Girls", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v10", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/craftygirls/v10/va9B4kXI39VaDdlPJo8N_NvuQR37fF3Wlg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Creepster", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v9", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/creepster/v9/AlZy_zVUqJz4yMrniH4hdXf4XB0Tow.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Crete Round", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/creteround/v13/55xoey1sJNPjPiv1ZZZrxJ1827zAKnxN.ttf", + "italic": "http://fonts.gstatic.com/s/creteround/v13/55xqey1sJNPjPiv1ZZZrxK1-0bjiL2xNhKc.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Crimson Pro", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v20", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uUsoa5M_tv7IihmnkabC5XiXCAlXGks1WZTm18OJE_VNWoyQ.ttf", + "300": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uUsoa5M_tv7IihmnkabC5XiXCAlXGks1WZkG18OJE_VNWoyQ.ttf", + "regular": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uUsoa5M_tv7IihmnkabC5XiXCAlXGks1WZzm18OJE_VNWoyQ.ttf", + "500": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uUsoa5M_tv7IihmnkabC5XiXCAlXGks1WZ_G18OJE_VNWoyQ.ttf", + "600": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uUsoa5M_tv7IihmnkabC5XiXCAlXGks1WZEGp8OJE_VNWoyQ.ttf", + "700": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uUsoa5M_tv7IihmnkabC5XiXCAlXGks1WZKWp8OJE_VNWoyQ.ttf", + "800": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uUsoa5M_tv7IihmnkabC5XiXCAlXGks1WZTmp8OJE_VNWoyQ.ttf", + "900": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uUsoa5M_tv7IihmnkabC5XiXCAlXGks1WZZ2p8OJE_VNWoyQ.ttf", + "200italic": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uSsoa5M_tv7IihmnkabAReu49Y_Bo-HVKMBi4Ue5s7dtC4yZNE.ttf", + "300italic": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uSsoa5M_tv7IihmnkabAReu49Y_Bo-HVKMBi7Ke5s7dtC4yZNE.ttf", + "italic": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uSsoa5M_tv7IihmnkabAReu49Y_Bo-HVKMBi6Ue5s7dtC4yZNE.ttf", + "500italic": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uSsoa5M_tv7IihmnkabAReu49Y_Bo-HVKMBi6me5s7dtC4yZNE.ttf", + "600italic": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uSsoa5M_tv7IihmnkabAReu49Y_Bo-HVKMBi5KfJs7dtC4yZNE.ttf", + "700italic": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uSsoa5M_tv7IihmnkabAReu49Y_Bo-HVKMBi5zfJs7dtC4yZNE.ttf", + "800italic": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uSsoa5M_tv7IihmnkabAReu49Y_Bo-HVKMBi4UfJs7dtC4yZNE.ttf", + "900italic": "http://fonts.gstatic.com/s/crimsonpro/v20/q5uSsoa5M_tv7IihmnkabAReu49Y_Bo-HVKMBi49fJs7dtC4yZNE.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Croissant One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/croissantone/v18/3y9n6bU9bTPg4m8NDy3Kq24UM3pqn5cdJ-4.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Crushed", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/crushed/v23/U9Mc6dym6WXImTlFT1kfuIqyLzA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Cuprum", + "variants": [ + "regular", + "500", + "600", + "700", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/cuprum/v18/dg45_pLmvrkcOkBnKsOzXyGWTBcmg-X6ZjzSJjQjgnU.ttf", + "500": "http://fonts.gstatic.com/s/cuprum/v18/dg45_pLmvrkcOkBnKsOzXyGWTBcmg9f6ZjzSJjQjgnU.ttf", + "600": "http://fonts.gstatic.com/s/cuprum/v18/dg45_pLmvrkcOkBnKsOzXyGWTBcmgzv9ZjzSJjQjgnU.ttf", + "700": "http://fonts.gstatic.com/s/cuprum/v18/dg45_pLmvrkcOkBnKsOzXyGWTBcmgwL9ZjzSJjQjgnU.ttf", + "italic": "http://fonts.gstatic.com/s/cuprum/v18/dg47_pLmvrkcOkBNI_FMh0j91rkhli25jn_YIhYmknUPEA.ttf", + "500italic": "http://fonts.gstatic.com/s/cuprum/v18/dg47_pLmvrkcOkBNI_FMh0j91rkhli25vH_YIhYmknUPEA.ttf", + "600italic": "http://fonts.gstatic.com/s/cuprum/v18/dg47_pLmvrkcOkBNI_FMh0j91rkhli25UHjYIhYmknUPEA.ttf", + "700italic": "http://fonts.gstatic.com/s/cuprum/v18/dg47_pLmvrkcOkBNI_FMh0j91rkhli25aXjYIhYmknUPEA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cute Font", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/cutefont/v18/Noaw6Uny2oWPbSHMrY6vmJNVNC9hkw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Cutive", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/cutive/v15/NaPZcZ_fHOhV3Ip7T_hDoyqlZQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Cutive Mono", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/cutivemono/v12/m8JWjfRfY7WVjVi2E-K9H5RFRG-K3Mud.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "DM Mono", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "500", + "500italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/dmmono/v8/aFTR7PB1QTsUX8KYvrGyIYSnbKX9Rlk.ttf", + "300italic": "http://fonts.gstatic.com/s/dmmono/v8/aFTT7PB1QTsUX8KYth-orYataIf4VllXuA.ttf", + "regular": "http://fonts.gstatic.com/s/dmmono/v8/aFTU7PB1QTsUX8KYhh2aBYyMcKw.ttf", + "italic": "http://fonts.gstatic.com/s/dmmono/v8/aFTW7PB1QTsUX8KYth-QAa6JYKzkXw.ttf", + "500": "http://fonts.gstatic.com/s/dmmono/v8/aFTR7PB1QTsUX8KYvumzIYSnbKX9Rlk.ttf", + "500italic": "http://fonts.gstatic.com/s/dmmono/v8/aFTT7PB1QTsUX8KYth-o9YetaIf4VllXuA.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "DM Sans", + "variants": [ + "regular", + "italic", + "500", + "500italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/dmsans/v10/rP2Hp2ywxg089UriOZSCHBeHFl0.ttf", + "italic": "http://fonts.gstatic.com/s/dmsans/v10/rP2Fp2ywxg089UriCZaIGDWCBl0O8Q.ttf", + "500": "http://fonts.gstatic.com/s/dmsans/v10/rP2Cp2ywxg089UriAWCrOB-sClQX6Cg.ttf", + "500italic": "http://fonts.gstatic.com/s/dmsans/v10/rP2Ap2ywxg089UriCZaw7BymDnYS-Cjk6Q.ttf", + "700": "http://fonts.gstatic.com/s/dmsans/v10/rP2Cp2ywxg089UriASitOB-sClQX6Cg.ttf", + "700italic": "http://fonts.gstatic.com/s/dmsans/v10/rP2Ap2ywxg089UriCZawpBqmDnYS-Cjk6Q.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "DM Serif Display", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/dmserifdisplay/v9/-nFnOHM81r4j6k0gjAW3mujVU2B2K_d709jy92k.ttf", + "italic": "http://fonts.gstatic.com/s/dmserifdisplay/v9/-nFhOHM81r4j6k0gjAW3mujVU2B2G_Vx1_r352np3Q.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "DM Serif Text", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/dmseriftext/v8/rnCu-xZa_krGokauCeNq1wWyafOPXHIJErY.ttf", + "italic": "http://fonts.gstatic.com/s/dmseriftext/v8/rnCw-xZa_krGokauCeNq1wWyWfGFWFAMArZKqQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Damion", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v10", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/damion/v10/hv-XlzJ3KEUe_YZUbWY3MTFgVg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Dancing Script", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/dancingscript/v22/If2cXTr6YS-zF4S-kcSWSVi_sxjsohD9F50Ruu7BMSoHTeB9ptDqpw.ttf", + "500": "http://fonts.gstatic.com/s/dancingscript/v22/If2cXTr6YS-zF4S-kcSWSVi_sxjsohD9F50Ruu7BAyoHTeB9ptDqpw.ttf", + "600": "http://fonts.gstatic.com/s/dancingscript/v22/If2cXTr6YS-zF4S-kcSWSVi_sxjsohD9F50Ruu7B7y0HTeB9ptDqpw.ttf", + "700": "http://fonts.gstatic.com/s/dancingscript/v22/If2cXTr6YS-zF4S-kcSWSVi_sxjsohD9F50Ruu7B1i0HTeB9ptDqpw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Dangrek", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v24", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/dangrek/v24/LYjCdG30nEgoH8E2gCNqqVIuTN4.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Darker Grotesque", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2022-01-25", + "files": { + "300": "http://fonts.gstatic.com/s/darkergrotesque/v5/U9MA6cuh-mLQlC4BKCtayOfARkSVoxr2AW8hTOsXsX0.ttf", + "regular": "http://fonts.gstatic.com/s/darkergrotesque/v5/U9MH6cuh-mLQlC4BKCtayOfARkSVm7beJWcKUOI.ttf", + "500": "http://fonts.gstatic.com/s/darkergrotesque/v5/U9MA6cuh-mLQlC4BKCtayOfARkSVo0L3AW8hTOsXsX0.ttf", + "600": "http://fonts.gstatic.com/s/darkergrotesque/v5/U9MA6cuh-mLQlC4BKCtayOfARkSVo27wAW8hTOsXsX0.ttf", + "700": "http://fonts.gstatic.com/s/darkergrotesque/v5/U9MA6cuh-mLQlC4BKCtayOfARkSVowrxAW8hTOsXsX0.ttf", + "800": "http://fonts.gstatic.com/s/darkergrotesque/v5/U9MA6cuh-mLQlC4BKCtayOfARkSVoxbyAW8hTOsXsX0.ttf", + "900": "http://fonts.gstatic.com/s/darkergrotesque/v5/U9MA6cuh-mLQlC4BKCtayOfARkSVozLzAW8hTOsXsX0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "David Libre", + "variants": [ + "regular", + "500", + "700" + ], + "subsets": [ + "hebrew", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/davidlibre/v9/snfus0W_99N64iuYSvp4W_l86p6TYS-Y.ttf", + "500": "http://fonts.gstatic.com/s/davidlibre/v9/snfzs0W_99N64iuYSvp4W8GIw7qbSjORSo9W.ttf", + "700": "http://fonts.gstatic.com/s/davidlibre/v9/snfzs0W_99N64iuYSvp4W8HAxbqbSjORSo9W.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Dawning of a New Day", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/dawningofanewday/v14/t5t_IQMbOp2SEwuncwLRjMfIg1yYit_nAz8bhWJGNoBE.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Days One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v10", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/daysone/v10/mem9YaCnxnKRiYZOCLYVeLkWVNBt.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Dekko", + "variants": [ + "regular" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/dekko/v17/46khlb_wWjfSrttFR0vsfl1B.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Dela Gothic One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "greek", + "japanese", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2021-11-04", + "files": { + "regular": "http://fonts.gstatic.com/s/delagothicone/v8/~ChEKD0RlbGEgR290aGljIE9uZSAAKgQIARgB.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Delius", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/delius/v13/PN_xRfK0pW_9e1rtYcI-jT3L_w.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Delius Swash Caps", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/deliusswashcaps/v17/oY1E8fPLr7v4JWCExZpWebxVKORpXXedKmeBvEYs.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Delius Unicase", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v24", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/deliusunicase/v24/845BNMEwEIOVT8BmgfSzIr_6mmLHd-73LXWs.ttf", + "700": "http://fonts.gstatic.com/s/deliusunicase/v24/845CNMEwEIOVT8BmgfSzIr_6mlp7WMr_BmmlS5aw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Della Respira", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/dellarespira/v16/RLp5K5v44KaueWI6iEJQBiGPRfkSu6EuTHo.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Denk One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/denkone/v13/dg4m_pzhrqcFb2IzROtHpbglShon.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Devonshire", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/devonshire/v19/46kqlbDwWirWr4gtBD2BX0Vq01lYAZM.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Dhurjati", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/dhurjati/v18/_6_8ED3gSeatXfFiFX3ySKQtuTA2.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Didact Gothic", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/didactgothic/v18/ahcfv8qz1zt6hCC5G4F_P4ASpUySp0LlcyQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Diplomata", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/diplomata/v22/Cn-0JtiMXwhNwp-wKxyfYGxYrdM9Sg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Diplomata SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/diplomatasc/v19/buExpoi3ecvs3kidKgBJo2kf-P5Oaiw4cw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Do Hyeon", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/dohyeon/v14/TwMN-I8CRRU2zM86HFE3ZwaH__-C.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Dokdo", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/dokdo/v13/esDf315XNuCBLxLo4NaMlKcH.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Domine", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/domine/v17/L0xhDFMnlVwD4h3Lt9JWnbX3jG-2X3LAI10VErGuW8Q.ttf", + "500": "http://fonts.gstatic.com/s/domine/v17/L0xhDFMnlVwD4h3Lt9JWnbX3jG-2X0DAI10VErGuW8Q.ttf", + "600": "http://fonts.gstatic.com/s/domine/v17/L0xhDFMnlVwD4h3Lt9JWnbX3jG-2X6zHI10VErGuW8Q.ttf", + "700": "http://fonts.gstatic.com/s/domine/v17/L0xhDFMnlVwD4h3Lt9JWnbX3jG-2X5XHI10VErGuW8Q.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Donegal One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/donegalone/v18/m8JWjfRYea-ZnFz6fsK9FZRFRG-K3Mud.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Dongle", + "variants": [ + "300", + "regular", + "700" + ], + "subsets": [ + "korean", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v6", + "lastModified": "2021-12-09", + "files": { + "300": "http://fonts.gstatic.com/s/dongle/v6/sJoG3Ltdjt6VPkqeEcxrYjWNzXvVPA.ttf", + "regular": "http://fonts.gstatic.com/s/dongle/v6/sJoF3Ltdjt6VPkqmveRPah6RxA.ttf", + "700": "http://fonts.gstatic.com/s/dongle/v6/sJoG3Ltdjt6VPkqeActrYjWNzXvVPA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Doppio One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/doppioone/v11/Gg8wN5gSaBfyBw2MqCh-lgshKGpe5Fg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Dorsa", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v21", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/dorsa/v21/yYLn0hjd0OGwqo493XCFxAnQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Dosis", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v25", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/dosis/v25/HhyJU5sn9vOmLxNkIwRSjTVNWLEJt7MV3BkFTq4EPw.ttf", + "300": "http://fonts.gstatic.com/s/dosis/v25/HhyJU5sn9vOmLxNkIwRSjTVNWLEJabMV3BkFTq4EPw.ttf", + "regular": "http://fonts.gstatic.com/s/dosis/v25/HhyJU5sn9vOmLxNkIwRSjTVNWLEJN7MV3BkFTq4EPw.ttf", + "500": "http://fonts.gstatic.com/s/dosis/v25/HhyJU5sn9vOmLxNkIwRSjTVNWLEJBbMV3BkFTq4EPw.ttf", + "600": "http://fonts.gstatic.com/s/dosis/v25/HhyJU5sn9vOmLxNkIwRSjTVNWLEJ6bQV3BkFTq4EPw.ttf", + "700": "http://fonts.gstatic.com/s/dosis/v25/HhyJU5sn9vOmLxNkIwRSjTVNWLEJ0LQV3BkFTq4EPw.ttf", + "800": "http://fonts.gstatic.com/s/dosis/v25/HhyJU5sn9vOmLxNkIwRSjTVNWLEJt7QV3BkFTq4EPw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "DotGothic16", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2021-11-04", + "files": { + "regular": "http://fonts.gstatic.com/s/dotgothic16/v10/~Cg0KC0RvdEdvdGhpYzE2IAAqBAgBGAE=.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Dr Sugiyama", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/drsugiyama/v20/HTxoL2k4N3O9n5I1boGI7abRM4-t-g7y.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Duru Sans", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/durusans/v17/xn7iYH8xwmSyTvEV_HOxT_fYdN-WZw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Dynalight", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/dynalight/v16/1Ptsg8LOU_aOmQvTsF4ISotrDfGGxA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "EB Garamond", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "italic", + "500italic", + "600italic", + "700italic", + "800italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v24", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/ebgaramond/v24/SlGDmQSNjdsmc35JDF1K5E55YMjF_7DPuGi-6_RUA4V-e6yHgQ.ttf", + "500": "http://fonts.gstatic.com/s/ebgaramond/v24/SlGDmQSNjdsmc35JDF1K5E55YMjF_7DPuGi-2fRUA4V-e6yHgQ.ttf", + "600": "http://fonts.gstatic.com/s/ebgaramond/v24/SlGDmQSNjdsmc35JDF1K5E55YMjF_7DPuGi-NfNUA4V-e6yHgQ.ttf", + "700": "http://fonts.gstatic.com/s/ebgaramond/v24/SlGDmQSNjdsmc35JDF1K5E55YMjF_7DPuGi-DPNUA4V-e6yHgQ.ttf", + "800": "http://fonts.gstatic.com/s/ebgaramond/v24/SlGDmQSNjdsmc35JDF1K5E55YMjF_7DPuGi-a_NUA4V-e6yHgQ.ttf", + "italic": "http://fonts.gstatic.com/s/ebgaramond/v24/SlGFmQSNjdsmc35JDF1K5GRwUjcdlttVFm-rI7e8QI96WamXgXFI.ttf", + "500italic": "http://fonts.gstatic.com/s/ebgaramond/v24/SlGFmQSNjdsmc35JDF1K5GRwUjcdlttVFm-rI7eOQI96WamXgXFI.ttf", + "600italic": "http://fonts.gstatic.com/s/ebgaramond/v24/SlGFmQSNjdsmc35JDF1K5GRwUjcdlttVFm-rI7diR496WamXgXFI.ttf", + "700italic": "http://fonts.gstatic.com/s/ebgaramond/v24/SlGFmQSNjdsmc35JDF1K5GRwUjcdlttVFm-rI7dbR496WamXgXFI.ttf", + "800italic": "http://fonts.gstatic.com/s/ebgaramond/v24/SlGFmQSNjdsmc35JDF1K5GRwUjcdlttVFm-rI7c8R496WamXgXFI.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Eagle Lake", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/eaglelake/v18/ptRMTiqbbuNJDOiKj9wG5O7yKQNute8.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "East Sea Dokdo", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/eastseadokdo/v18/xfuo0Wn2V2_KanASqXSZp22m05_aGavYS18y.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Eater", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/eater/v19/mtG04_FCK7bOvpu2u3FwsXsR.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Economica", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/economica/v11/Qw3fZQZaHCLgIWa29ZBrMcgAAl1lfQ.ttf", + "italic": "http://fonts.gstatic.com/s/economica/v11/Qw3ZZQZaHCLgIWa29ZBbM8IEIFh1fWUl.ttf", + "700": "http://fonts.gstatic.com/s/economica/v11/Qw3aZQZaHCLgIWa29ZBTjeckCnZ5dHw8iw.ttf", + "700italic": "http://fonts.gstatic.com/s/economica/v11/Qw3EZQZaHCLgIWa29ZBbM_q4D3x9Vnksi4M7.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Eczar", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/eczar/v13/BXRlvF3Pi-DLmw0iBu9y8Hf0.ttf", + "500": "http://fonts.gstatic.com/s/eczar/v13/BXRovF3Pi-DLmzXWL8t622v9WNjW.ttf", + "600": "http://fonts.gstatic.com/s/eczar/v13/BXRovF3Pi-DLmzX6KMt622v9WNjW.ttf", + "700": "http://fonts.gstatic.com/s/eczar/v13/BXRovF3Pi-DLmzWeKct622v9WNjW.ttf", + "800": "http://fonts.gstatic.com/s/eczar/v13/BXRovF3Pi-DLmzWCKst622v9WNjW.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "El Messiri", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "arabic", + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/elmessiri/v14/K2FhfZBRmr9vQ1pHEey6GIGo8_pv3myYjuXwe65ghj3OoapG.ttf", + "500": "http://fonts.gstatic.com/s/elmessiri/v14/K2FhfZBRmr9vQ1pHEey6GIGo8_pv3myYjuXCe65ghj3OoapG.ttf", + "600": "http://fonts.gstatic.com/s/elmessiri/v14/K2FhfZBRmr9vQ1pHEey6GIGo8_pv3myYjuUufK5ghj3OoapG.ttf", + "700": "http://fonts.gstatic.com/s/elmessiri/v14/K2FhfZBRmr9vQ1pHEey6GIGo8_pv3myYjuUXfK5ghj3OoapG.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Electrolize", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/electrolize/v12/cIf5Ma1dtE0zSiGSiED7AUEGso5tQafB.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Elsie", + "variants": [ + "regular", + "900" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2020-11-20", + "files": { + "regular": "http://fonts.gstatic.com/s/elsie/v11/BCanqZABrez54yYu9slAeLgX.ttf", + "900": "http://fonts.gstatic.com/s/elsie/v11/BCaqqZABrez54x6q2-1IU6QeXSBk.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Elsie Swash Caps", + "variants": [ + "regular", + "900" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/elsieswashcaps/v19/845DNN8xGZyVX5MVo_upKf7KnjK0ferVKGWsUo8.ttf", + "900": "http://fonts.gstatic.com/s/elsieswashcaps/v19/845ENN8xGZyVX5MVo_upKf7KnjK0RW74DG2HToawrdU.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Emblema One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/emblemaone/v19/nKKT-GQ0F5dSY8vzG0rOEIRBHl57G_f_.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Emilys Candy", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/emilyscandy/v11/2EbgL-1mD1Rnb0OGKudbk0y5r9xrX84JjA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Encode Sans", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2021-01-30", + "files": { + "100": "http://fonts.gstatic.com/s/encodesans/v8/LDIcapOFNxEwR-Bd1O9uYNmnUQomAgE25imKSbHhROjLsZBWTSrQGGHiZtWP7FJCt2c.ttf", + "200": "http://fonts.gstatic.com/s/encodesans/v8/LDIcapOFNxEwR-Bd1O9uYNmnUQomAgE25imKSbHhROjLsZBWTSrQGOHjZtWP7FJCt2c.ttf", + "300": "http://fonts.gstatic.com/s/encodesans/v8/LDIcapOFNxEwR-Bd1O9uYNmnUQomAgE25imKSbHhROjLsZBWTSrQGD_jZtWP7FJCt2c.ttf", + "regular": "http://fonts.gstatic.com/s/encodesans/v8/LDIcapOFNxEwR-Bd1O9uYNmnUQomAgE25imKSbHhROjLsZBWTSrQGGHjZtWP7FJCt2c.ttf", + "500": "http://fonts.gstatic.com/s/encodesans/v8/LDIcapOFNxEwR-Bd1O9uYNmnUQomAgE25imKSbHhROjLsZBWTSrQGFPjZtWP7FJCt2c.ttf", + "600": "http://fonts.gstatic.com/s/encodesans/v8/LDIcapOFNxEwR-Bd1O9uYNmnUQomAgE25imKSbHhROjLsZBWTSrQGL_kZtWP7FJCt2c.ttf", + "700": "http://fonts.gstatic.com/s/encodesans/v8/LDIcapOFNxEwR-Bd1O9uYNmnUQomAgE25imKSbHhROjLsZBWTSrQGIbkZtWP7FJCt2c.ttf", + "800": "http://fonts.gstatic.com/s/encodesans/v8/LDIcapOFNxEwR-Bd1O9uYNmnUQomAgE25imKSbHhROjLsZBWTSrQGOHkZtWP7FJCt2c.ttf", + "900": "http://fonts.gstatic.com/s/encodesans/v8/LDIcapOFNxEwR-Bd1O9uYNmnUQomAgE25imKSbHhROjLsZBWTSrQGMjkZtWP7FJCt2c.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Encode Sans Condensed", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/encodesanscondensed/v8/j8_76_LD37rqfuwxyIuaZhE6cRXOLtm2gfT-5a-JLQoFI2KR.ttf", + "200": "http://fonts.gstatic.com/s/encodesanscondensed/v8/j8_46_LD37rqfuwxyIuaZhE6cRXOLtm2gfT-SY6pByQJKnuIFA.ttf", + "300": "http://fonts.gstatic.com/s/encodesanscondensed/v8/j8_46_LD37rqfuwxyIuaZhE6cRXOLtm2gfT-LY2pByQJKnuIFA.ttf", + "regular": "http://fonts.gstatic.com/s/encodesanscondensed/v8/j8_16_LD37rqfuwxyIuaZhE6cRXOLtm2gfTGgaWNDw8VIw.ttf", + "500": "http://fonts.gstatic.com/s/encodesanscondensed/v8/j8_46_LD37rqfuwxyIuaZhE6cRXOLtm2gfT-dYypByQJKnuIFA.ttf", + "600": "http://fonts.gstatic.com/s/encodesanscondensed/v8/j8_46_LD37rqfuwxyIuaZhE6cRXOLtm2gfT-WYupByQJKnuIFA.ttf", + "700": "http://fonts.gstatic.com/s/encodesanscondensed/v8/j8_46_LD37rqfuwxyIuaZhE6cRXOLtm2gfT-PYqpByQJKnuIFA.ttf", + "800": "http://fonts.gstatic.com/s/encodesanscondensed/v8/j8_46_LD37rqfuwxyIuaZhE6cRXOLtm2gfT-IYmpByQJKnuIFA.ttf", + "900": "http://fonts.gstatic.com/s/encodesanscondensed/v8/j8_46_LD37rqfuwxyIuaZhE6cRXOLtm2gfT-BYipByQJKnuIFA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Encode Sans Expanded", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-11", + "files": { + "100": "http://fonts.gstatic.com/s/encodesansexpanded/v8/c4mx1mF4GcnstG_Jh1QH6ac4hNLeNyeYUpJGKQNicoAbJlw.ttf", + "200": "http://fonts.gstatic.com/s/encodesansexpanded/v8/c4mw1mF4GcnstG_Jh1QH6ac4hNLeNyeYUpLqCCNIXIwSP0XD.ttf", + "300": "http://fonts.gstatic.com/s/encodesansexpanded/v8/c4mw1mF4GcnstG_Jh1QH6ac4hNLeNyeYUpKOCyNIXIwSP0XD.ttf", + "regular": "http://fonts.gstatic.com/s/encodesansexpanded/v8/c4m_1mF4GcnstG_Jh1QH6ac4hNLeNyeYUqoiIwdAd5Ab.ttf", + "500": "http://fonts.gstatic.com/s/encodesansexpanded/v8/c4mw1mF4GcnstG_Jh1QH6ac4hNLeNyeYUpLWCiNIXIwSP0XD.ttf", + "600": "http://fonts.gstatic.com/s/encodesansexpanded/v8/c4mw1mF4GcnstG_Jh1QH6ac4hNLeNyeYUpL6DSNIXIwSP0XD.ttf", + "700": "http://fonts.gstatic.com/s/encodesansexpanded/v8/c4mw1mF4GcnstG_Jh1QH6ac4hNLeNyeYUpKeDCNIXIwSP0XD.ttf", + "800": "http://fonts.gstatic.com/s/encodesansexpanded/v8/c4mw1mF4GcnstG_Jh1QH6ac4hNLeNyeYUpKCDyNIXIwSP0XD.ttf", + "900": "http://fonts.gstatic.com/s/encodesansexpanded/v8/c4mw1mF4GcnstG_Jh1QH6ac4hNLeNyeYUpKmDiNIXIwSP0XD.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Encode Sans SC", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v4", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/encodesanssc/v4/jVyp7nLwCGzQ9zE7ZyRg0QRXHPZc_uUA6Kb3VJWLE_Pdtm7lcD6qvXT1HHhn8c9NOEEClIc.ttf", + "200": "http://fonts.gstatic.com/s/encodesanssc/v4/jVyp7nLwCGzQ9zE7ZyRg0QRXHPZc_uUA6Kb3VJWLE_Pdtm7lcD6qvXT1HPhm8c9NOEEClIc.ttf", + "300": "http://fonts.gstatic.com/s/encodesanssc/v4/jVyp7nLwCGzQ9zE7ZyRg0QRXHPZc_uUA6Kb3VJWLE_Pdtm7lcD6qvXT1HCZm8c9NOEEClIc.ttf", + "regular": "http://fonts.gstatic.com/s/encodesanssc/v4/jVyp7nLwCGzQ9zE7ZyRg0QRXHPZc_uUA6Kb3VJWLE_Pdtm7lcD6qvXT1HHhm8c9NOEEClIc.ttf", + "500": "http://fonts.gstatic.com/s/encodesanssc/v4/jVyp7nLwCGzQ9zE7ZyRg0QRXHPZc_uUA6Kb3VJWLE_Pdtm7lcD6qvXT1HEpm8c9NOEEClIc.ttf", + "600": "http://fonts.gstatic.com/s/encodesanssc/v4/jVyp7nLwCGzQ9zE7ZyRg0QRXHPZc_uUA6Kb3VJWLE_Pdtm7lcD6qvXT1HKZh8c9NOEEClIc.ttf", + "700": "http://fonts.gstatic.com/s/encodesanssc/v4/jVyp7nLwCGzQ9zE7ZyRg0QRXHPZc_uUA6Kb3VJWLE_Pdtm7lcD6qvXT1HJ9h8c9NOEEClIc.ttf", + "800": "http://fonts.gstatic.com/s/encodesanssc/v4/jVyp7nLwCGzQ9zE7ZyRg0QRXHPZc_uUA6Kb3VJWLE_Pdtm7lcD6qvXT1HPhh8c9NOEEClIc.ttf", + "900": "http://fonts.gstatic.com/s/encodesanssc/v4/jVyp7nLwCGzQ9zE7ZyRg0QRXHPZc_uUA6Kb3VJWLE_Pdtm7lcD6qvXT1HNFh8c9NOEEClIc.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Encode Sans Semi Condensed", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-13", + "files": { + "100": "http://fonts.gstatic.com/s/encodesanssemicondensed/v8/3qT6oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG1T19MFtQ9jpVUA.ttf", + "200": "http://fonts.gstatic.com/s/encodesanssemicondensed/v8/3qT7oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG1RZ1eFHbdTgTFmr.ttf", + "300": "http://fonts.gstatic.com/s/encodesanssemicondensed/v8/3qT7oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG1Q91uFHbdTgTFmr.ttf", + "regular": "http://fonts.gstatic.com/s/encodesanssemicondensed/v8/3qT4oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG2yR_sVPRsjp.ttf", + "500": "http://fonts.gstatic.com/s/encodesanssemicondensed/v8/3qT7oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG1Rl1-FHbdTgTFmr.ttf", + "600": "http://fonts.gstatic.com/s/encodesanssemicondensed/v8/3qT7oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG1RJ0OFHbdTgTFmr.ttf", + "700": "http://fonts.gstatic.com/s/encodesanssemicondensed/v8/3qT7oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG1Qt0eFHbdTgTFmr.ttf", + "800": "http://fonts.gstatic.com/s/encodesanssemicondensed/v8/3qT7oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG1Qx0uFHbdTgTFmr.ttf", + "900": "http://fonts.gstatic.com/s/encodesanssemicondensed/v8/3qT7oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG1QV0-FHbdTgTFmr.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Encode Sans Semi Expanded", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "100": "http://fonts.gstatic.com/s/encodesanssemiexpanded/v16/ke8xOhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TM-41KwrlKXeOEA.ttf", + "200": "http://fonts.gstatic.com/s/encodesanssemiexpanded/v16/ke8yOhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TM0IUCyDLJX6XCWU.ttf", + "300": "http://fonts.gstatic.com/s/encodesanssemiexpanded/v16/ke8yOhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TMyYXCyDLJX6XCWU.ttf", + "regular": "http://fonts.gstatic.com/s/encodesanssemiexpanded/v16/ke83OhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TC4o_LyjgOXc.ttf", + "500": "http://fonts.gstatic.com/s/encodesanssemiexpanded/v16/ke8yOhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TM34WCyDLJX6XCWU.ttf", + "600": "http://fonts.gstatic.com/s/encodesanssemiexpanded/v16/ke8yOhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TM1IRCyDLJX6XCWU.ttf", + "700": "http://fonts.gstatic.com/s/encodesanssemiexpanded/v16/ke8yOhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TMzYQCyDLJX6XCWU.ttf", + "800": "http://fonts.gstatic.com/s/encodesanssemiexpanded/v16/ke8yOhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TMyoTCyDLJX6XCWU.ttf", + "900": "http://fonts.gstatic.com/s/encodesanssemiexpanded/v16/ke8yOhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TMw4SCyDLJX6XCWU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Engagement", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/engagement/v20/x3dlckLDZbqa7RUs9MFVXNossybsHQI.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Englebert", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/englebert/v15/xn7iYH8w2XGrC8AR4HSxT_fYdN-WZw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Enriqueta", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/enriqueta/v13/goksH6L7AUFrRvV44HVTS0CjkP1Yog.ttf", + "500": "http://fonts.gstatic.com/s/enriqueta/v13/gokpH6L7AUFrRvV44HVrv2mHmNZEq6TTFw.ttf", + "600": "http://fonts.gstatic.com/s/enriqueta/v13/gokpH6L7AUFrRvV44HVrk26HmNZEq6TTFw.ttf", + "700": "http://fonts.gstatic.com/s/enriqueta/v13/gokpH6L7AUFrRvV44HVr92-HmNZEq6TTFw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ephesis", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/ephesis/v5/uU9PCBUS8IerL2VG7xPb3vyHmlI.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Epilogue", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/epilogue/v11/O4ZMFGj5hxF0EhjimngomvnCCtqb30OXMDLiDJXVigHPVA.ttf", + "200": "http://fonts.gstatic.com/s/epilogue/v11/O4ZMFGj5hxF0EhjimngomvnCCtqb30OXsDPiDJXVigHPVA.ttf", + "300": "http://fonts.gstatic.com/s/epilogue/v11/O4ZMFGj5hxF0EhjimngomvnCCtqb30OXbjPiDJXVigHPVA.ttf", + "regular": "http://fonts.gstatic.com/s/epilogue/v11/O4ZMFGj5hxF0EhjimngomvnCCtqb30OXMDPiDJXVigHPVA.ttf", + "500": "http://fonts.gstatic.com/s/epilogue/v11/O4ZMFGj5hxF0EhjimngomvnCCtqb30OXAjPiDJXVigHPVA.ttf", + "600": "http://fonts.gstatic.com/s/epilogue/v11/O4ZMFGj5hxF0EhjimngomvnCCtqb30OX7jTiDJXVigHPVA.ttf", + "700": "http://fonts.gstatic.com/s/epilogue/v11/O4ZMFGj5hxF0EhjimngomvnCCtqb30OX1zTiDJXVigHPVA.ttf", + "800": "http://fonts.gstatic.com/s/epilogue/v11/O4ZMFGj5hxF0EhjimngomvnCCtqb30OXsDTiDJXVigHPVA.ttf", + "900": "http://fonts.gstatic.com/s/epilogue/v11/O4ZMFGj5hxF0EhjimngomvnCCtqb30OXmTTiDJXVigHPVA.ttf", + "100italic": "http://fonts.gstatic.com/s/epilogue/v11/O4ZCFGj5hxF0EhjimlIhqAYaY7EBcUSC-HAKTp_RqATfVHNU.ttf", + "200italic": "http://fonts.gstatic.com/s/epilogue/v11/O4ZCFGj5hxF0EhjimlIhqAYaY7EBcUSC-HCKT5_RqATfVHNU.ttf", + "300italic": "http://fonts.gstatic.com/s/epilogue/v11/O4ZCFGj5hxF0EhjimlIhqAYaY7EBcUSC-HBUT5_RqATfVHNU.ttf", + "italic": "http://fonts.gstatic.com/s/epilogue/v11/O4ZCFGj5hxF0EhjimlIhqAYaY7EBcUSC-HAKT5_RqATfVHNU.ttf", + "500italic": "http://fonts.gstatic.com/s/epilogue/v11/O4ZCFGj5hxF0EhjimlIhqAYaY7EBcUSC-HA4T5_RqATfVHNU.ttf", + "600italic": "http://fonts.gstatic.com/s/epilogue/v11/O4ZCFGj5hxF0EhjimlIhqAYaY7EBcUSC-HDUSJ_RqATfVHNU.ttf", + "700italic": "http://fonts.gstatic.com/s/epilogue/v11/O4ZCFGj5hxF0EhjimlIhqAYaY7EBcUSC-HDtSJ_RqATfVHNU.ttf", + "800italic": "http://fonts.gstatic.com/s/epilogue/v11/O4ZCFGj5hxF0EhjimlIhqAYaY7EBcUSC-HCKSJ_RqATfVHNU.ttf", + "900italic": "http://fonts.gstatic.com/s/epilogue/v11/O4ZCFGj5hxF0EhjimlIhqAYaY7EBcUSC-HCjSJ_RqATfVHNU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Erica One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v21", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/ericaone/v21/WBLnrEXccV9VGrOKmGD1W0_MJMGxiQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Esteban", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/esteban/v12/r05bGLZE-bdGdN-GdOuD5jokU8E.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Estonia", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/estonia/v7/7Au_p_4ijSecA1yHCCL8zkwMIFg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Euphoria Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/euphoriascript/v14/mFTpWb0X2bLb_cx6To2B8GpKoD5ak_ZT1D8x7Q.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Ewert", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/ewert/v19/va9I4kzO2tFODYBvS-J3kbDP.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Exo", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/exo/v18/4UaZrEtFpBI4f1ZSIK9d4LjJ4lM2CwNsOl4p5Is.ttf", + "200": "http://fonts.gstatic.com/s/exo/v18/4UaZrEtFpBI4f1ZSIK9d4LjJ4tM3CwNsOl4p5Is.ttf", + "300": "http://fonts.gstatic.com/s/exo/v18/4UaZrEtFpBI4f1ZSIK9d4LjJ4g03CwNsOl4p5Is.ttf", + "regular": "http://fonts.gstatic.com/s/exo/v18/4UaZrEtFpBI4f1ZSIK9d4LjJ4lM3CwNsOl4p5Is.ttf", + "500": "http://fonts.gstatic.com/s/exo/v18/4UaZrEtFpBI4f1ZSIK9d4LjJ4mE3CwNsOl4p5Is.ttf", + "600": "http://fonts.gstatic.com/s/exo/v18/4UaZrEtFpBI4f1ZSIK9d4LjJ4o0wCwNsOl4p5Is.ttf", + "700": "http://fonts.gstatic.com/s/exo/v18/4UaZrEtFpBI4f1ZSIK9d4LjJ4rQwCwNsOl4p5Is.ttf", + "800": "http://fonts.gstatic.com/s/exo/v18/4UaZrEtFpBI4f1ZSIK9d4LjJ4tMwCwNsOl4p5Is.ttf", + "900": "http://fonts.gstatic.com/s/exo/v18/4UaZrEtFpBI4f1ZSIK9d4LjJ4vowCwNsOl4p5Is.ttf", + "100italic": "http://fonts.gstatic.com/s/exo/v18/4UafrEtFpBISdmSt-MY2ehbO95t040FmPnws9Iu-uA.ttf", + "200italic": "http://fonts.gstatic.com/s/exo/v18/4UafrEtFpBISdmSt-MY2ehbO95t0Y0BmPnws9Iu-uA.ttf", + "300italic": "http://fonts.gstatic.com/s/exo/v18/4UafrEtFpBISdmSt-MY2ehbO95t0vUBmPnws9Iu-uA.ttf", + "italic": "http://fonts.gstatic.com/s/exo/v18/4UafrEtFpBISdmSt-MY2ehbO95t040BmPnws9Iu-uA.ttf", + "500italic": "http://fonts.gstatic.com/s/exo/v18/4UafrEtFpBISdmSt-MY2ehbO95t00UBmPnws9Iu-uA.ttf", + "600italic": "http://fonts.gstatic.com/s/exo/v18/4UafrEtFpBISdmSt-MY2ehbO95t0PUdmPnws9Iu-uA.ttf", + "700italic": "http://fonts.gstatic.com/s/exo/v18/4UafrEtFpBISdmSt-MY2ehbO95t0BEdmPnws9Iu-uA.ttf", + "800italic": "http://fonts.gstatic.com/s/exo/v18/4UafrEtFpBISdmSt-MY2ehbO95t0Y0dmPnws9Iu-uA.ttf", + "900italic": "http://fonts.gstatic.com/s/exo/v18/4UafrEtFpBISdmSt-MY2ehbO95t0SkdmPnws9Iu-uA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Exo 2", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/exo2/v18/7cH1v4okm5zmbvwkAx_sfcEuiD8jvvOcPtq-rpvLpQ.ttf", + "200": "http://fonts.gstatic.com/s/exo2/v18/7cH1v4okm5zmbvwkAx_sfcEuiD8jPvKcPtq-rpvLpQ.ttf", + "300": "http://fonts.gstatic.com/s/exo2/v18/7cH1v4okm5zmbvwkAx_sfcEuiD8j4PKcPtq-rpvLpQ.ttf", + "regular": "http://fonts.gstatic.com/s/exo2/v18/7cH1v4okm5zmbvwkAx_sfcEuiD8jvvKcPtq-rpvLpQ.ttf", + "500": "http://fonts.gstatic.com/s/exo2/v18/7cH1v4okm5zmbvwkAx_sfcEuiD8jjPKcPtq-rpvLpQ.ttf", + "600": "http://fonts.gstatic.com/s/exo2/v18/7cH1v4okm5zmbvwkAx_sfcEuiD8jYPWcPtq-rpvLpQ.ttf", + "700": "http://fonts.gstatic.com/s/exo2/v18/7cH1v4okm5zmbvwkAx_sfcEuiD8jWfWcPtq-rpvLpQ.ttf", + "800": "http://fonts.gstatic.com/s/exo2/v18/7cH1v4okm5zmbvwkAx_sfcEuiD8jPvWcPtq-rpvLpQ.ttf", + "900": "http://fonts.gstatic.com/s/exo2/v18/7cH1v4okm5zmbvwkAx_sfcEuiD8jF_WcPtq-rpvLpQ.ttf", + "100italic": "http://fonts.gstatic.com/s/exo2/v18/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drF0fNC6jJ7bpQBL.ttf", + "200italic": "http://fonts.gstatic.com/s/exo2/v18/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drH0fdC6jJ7bpQBL.ttf", + "300italic": "http://fonts.gstatic.com/s/exo2/v18/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drEqfdC6jJ7bpQBL.ttf", + "italic": "http://fonts.gstatic.com/s/exo2/v18/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drF0fdC6jJ7bpQBL.ttf", + "500italic": "http://fonts.gstatic.com/s/exo2/v18/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drFGfdC6jJ7bpQBL.ttf", + "600italic": "http://fonts.gstatic.com/s/exo2/v18/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drGqetC6jJ7bpQBL.ttf", + "700italic": "http://fonts.gstatic.com/s/exo2/v18/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drGTetC6jJ7bpQBL.ttf", + "800italic": "http://fonts.gstatic.com/s/exo2/v18/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drH0etC6jJ7bpQBL.ttf", + "900italic": "http://fonts.gstatic.com/s/exo2/v18/7cH3v4okm5zmbtYtMeA0FKq0Jjg2drHdetC6jJ7bpQBL.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Expletus Sans", + "variants": [ + "regular", + "500", + "600", + "700", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v21", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/expletussans/v21/RLpqK5v5_bqufTYdnhFzDj2dX_IwS3my73zcDaSY2s1oFQTcXfMm.ttf", + "500": "http://fonts.gstatic.com/s/expletussans/v21/RLpqK5v5_bqufTYdnhFzDj2dX_IwS3my73zcDaSq2s1oFQTcXfMm.ttf", + "600": "http://fonts.gstatic.com/s/expletussans/v21/RLpqK5v5_bqufTYdnhFzDj2dX_IwS3my73zcDaRG3c1oFQTcXfMm.ttf", + "700": "http://fonts.gstatic.com/s/expletussans/v21/RLpqK5v5_bqufTYdnhFzDj2dX_IwS3my73zcDaR_3c1oFQTcXfMm.ttf", + "italic": "http://fonts.gstatic.com/s/expletussans/v21/RLpoK5v5_bqufTYdnhFzDj2ddfsCtKHbhOZyCrFQmSUrHwD-WOMmKKY.ttf", + "500italic": "http://fonts.gstatic.com/s/expletussans/v21/RLpoK5v5_bqufTYdnhFzDj2ddfsCtKHbhOZyCrFQmRcrHwD-WOMmKKY.ttf", + "600italic": "http://fonts.gstatic.com/s/expletussans/v21/RLpoK5v5_bqufTYdnhFzDj2ddfsCtKHbhOZyCrFQmfssHwD-WOMmKKY.ttf", + "700italic": "http://fonts.gstatic.com/s/expletussans/v21/RLpoK5v5_bqufTYdnhFzDj2ddfsCtKHbhOZyCrFQmcIsHwD-WOMmKKY.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Explora", + "variants": [ + "regular" + ], + "subsets": [ + "cherokee", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/explora/v5/tsstApxFfjUH4wrvc1qPonC3vqc.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Fahkwang", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "200": "http://fonts.gstatic.com/s/fahkwang/v14/Noa26Uj3zpmBOgbNpOJHmZlRFipxkwjx.ttf", + "200italic": "http://fonts.gstatic.com/s/fahkwang/v14/Noa06Uj3zpmBOgbNpOqNgHFQHC5Tlhjxdw4.ttf", + "300": "http://fonts.gstatic.com/s/fahkwang/v14/Noa26Uj3zpmBOgbNpOIjmplRFipxkwjx.ttf", + "300italic": "http://fonts.gstatic.com/s/fahkwang/v14/Noa06Uj3zpmBOgbNpOqNgBVTHC5Tlhjxdw4.ttf", + "regular": "http://fonts.gstatic.com/s/fahkwang/v14/Noax6Uj3zpmBOgbNpNqPsr1ZPTZ4.ttf", + "italic": "http://fonts.gstatic.com/s/fahkwang/v14/Noa36Uj3zpmBOgbNpOqNuLl7OCZ4ihE.ttf", + "500": "http://fonts.gstatic.com/s/fahkwang/v14/Noa26Uj3zpmBOgbNpOJ7m5lRFipxkwjx.ttf", + "500italic": "http://fonts.gstatic.com/s/fahkwang/v14/Noa06Uj3zpmBOgbNpOqNgE1SHC5Tlhjxdw4.ttf", + "600": "http://fonts.gstatic.com/s/fahkwang/v14/Noa26Uj3zpmBOgbNpOJXnJlRFipxkwjx.ttf", + "600italic": "http://fonts.gstatic.com/s/fahkwang/v14/Noa06Uj3zpmBOgbNpOqNgGFVHC5Tlhjxdw4.ttf", + "700": "http://fonts.gstatic.com/s/fahkwang/v14/Noa26Uj3zpmBOgbNpOIznZlRFipxkwjx.ttf", + "700italic": "http://fonts.gstatic.com/s/fahkwang/v14/Noa06Uj3zpmBOgbNpOqNgAVUHC5Tlhjxdw4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Fanwood Text", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/fanwoodtext/v13/3XFtErwl05Ad_vSCF6Fq7xXGRdbY1P1Sbg.ttf", + "italic": "http://fonts.gstatic.com/s/fanwoodtext/v13/3XFzErwl05Ad_vSCF6Fq7xX2R9zc9vhCblye.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Farro", + "variants": [ + "300", + "regular", + "500", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "300": "http://fonts.gstatic.com/s/farro/v12/i7dJIFl3byGNHa3hNJ6-WkJUQUq7.ttf", + "regular": "http://fonts.gstatic.com/s/farro/v12/i7dEIFl3byGNHZVNHLq2cV5d.ttf", + "500": "http://fonts.gstatic.com/s/farro/v12/i7dJIFl3byGNHa25NZ6-WkJUQUq7.ttf", + "700": "http://fonts.gstatic.com/s/farro/v12/i7dJIFl3byGNHa3xM56-WkJUQUq7.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Farsan", + "variants": [ + "regular" + ], + "subsets": [ + "gujarati", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/farsan/v16/VEMwRoJ0vY_zsyz62q-pxDX9rQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fascinate", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/fascinate/v19/z7NWdRrufC8XJK0IIEli1LbQRPyNrw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fascinate Inline", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/fascinateinline/v20/jVyR7mzzB3zc-jp6QCAu60poNqIy1g3CfRXxWZQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Faster One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/fasterone/v15/H4ciBXCHmdfClFb-vWhfyLuShq63czE.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fasthand", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v24", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/fasthand/v24/0yb9GDohyKTYn_ZEESkuYkw2rQg1.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fauna One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/faunaone/v11/wlpzgwTPBVpjpCuwkuEx2UxLYClOCg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Faustina", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "800", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/faustina/v14/XLY4IZPxYpJfTbZAFXWzNT2SO8wpWHls3IEvGVWWe8tbEg.ttf", + "regular": "http://fonts.gstatic.com/s/faustina/v14/XLY4IZPxYpJfTbZAFXWzNT2SO8wpWHlsgoEvGVWWe8tbEg.ttf", + "500": "http://fonts.gstatic.com/s/faustina/v14/XLY4IZPxYpJfTbZAFXWzNT2SO8wpWHlssIEvGVWWe8tbEg.ttf", + "600": "http://fonts.gstatic.com/s/faustina/v14/XLY4IZPxYpJfTbZAFXWzNT2SO8wpWHlsXIYvGVWWe8tbEg.ttf", + "700": "http://fonts.gstatic.com/s/faustina/v14/XLY4IZPxYpJfTbZAFXWzNT2SO8wpWHlsZYYvGVWWe8tbEg.ttf", + "800": "http://fonts.gstatic.com/s/faustina/v14/XLY4IZPxYpJfTbZAFXWzNT2SO8wpWHlsAoYvGVWWe8tbEg.ttf", + "300italic": "http://fonts.gstatic.com/s/faustina/v14/XLY2IZPxYpJfTbZAFV-6B8JKUqez9n55SsKZWl-SWc5LEnoF.ttf", + "italic": "http://fonts.gstatic.com/s/faustina/v14/XLY2IZPxYpJfTbZAFV-6B8JKUqez9n55SsLHWl-SWc5LEnoF.ttf", + "500italic": "http://fonts.gstatic.com/s/faustina/v14/XLY2IZPxYpJfTbZAFV-6B8JKUqez9n55SsL1Wl-SWc5LEnoF.ttf", + "600italic": "http://fonts.gstatic.com/s/faustina/v14/XLY2IZPxYpJfTbZAFV-6B8JKUqez9n55SsIZXV-SWc5LEnoF.ttf", + "700italic": "http://fonts.gstatic.com/s/faustina/v14/XLY2IZPxYpJfTbZAFV-6B8JKUqez9n55SsIgXV-SWc5LEnoF.ttf", + "800italic": "http://fonts.gstatic.com/s/faustina/v14/XLY2IZPxYpJfTbZAFV-6B8JKUqez9n55SsJHXV-SWc5LEnoF.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Federant", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/federant/v23/2sDdZGNfip_eirT0_U0jRUG0AqUc.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Federo", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/federo/v17/iJWFBX-cbD_ETsbmjVOe2WTG7Q.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Felipa", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/felipa/v17/FwZa7-owz1Eu4F_wSNSEwM2zpA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Fenix", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/fenix/v18/XoHo2YL_S7-g5ostKzAFvs8o.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Festive", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/festive/v5/cY9Ffj6KX1xcoDWhFtfgy9HTkak.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Finger Paint", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/fingerpaint/v13/0QInMXVJ-o-oRn_7dron8YWO85bS8ANesw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fira Code", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/firacode/v17/uU9eCBsR6Z2vfE9aq3bL0fxyUs4tcw4W_GNsFVfxN87gsj0.ttf", + "regular": "http://fonts.gstatic.com/s/firacode/v17/uU9eCBsR6Z2vfE9aq3bL0fxyUs4tcw4W_D1sFVfxN87gsj0.ttf", + "500": "http://fonts.gstatic.com/s/firacode/v17/uU9eCBsR6Z2vfE9aq3bL0fxyUs4tcw4W_A9sFVfxN87gsj0.ttf", + "600": "http://fonts.gstatic.com/s/firacode/v17/uU9eCBsR6Z2vfE9aq3bL0fxyUs4tcw4W_ONrFVfxN87gsj0.ttf", + "700": "http://fonts.gstatic.com/s/firacode/v17/uU9eCBsR6Z2vfE9aq3bL0fxyUs4tcw4W_NprFVfxN87gsj0.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Fira Mono", + "variants": [ + "regular", + "500", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/firamono/v12/N0bX2SlFPv1weGeLZDtQIfTTkdbJYA.ttf", + "500": "http://fonts.gstatic.com/s/firamono/v12/N0bS2SlFPv1weGeLZDto1d33mf3VaZBRBQ.ttf", + "700": "http://fonts.gstatic.com/s/firamono/v12/N0bS2SlFPv1weGeLZDtondv3mf3VaZBRBQ.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Fira Sans", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/firasans/v15/va9C4kDNxMZdWfMOD5Vn9IjOazP3dUTP.ttf", + "100italic": "http://fonts.gstatic.com/s/firasans/v15/va9A4kDNxMZdWfMOD5VvkrCqYTfVcFTPj0s.ttf", + "200": "http://fonts.gstatic.com/s/firasans/v15/va9B4kDNxMZdWfMOD5VnWKnuQR37fF3Wlg.ttf", + "200italic": "http://fonts.gstatic.com/s/firasans/v15/va9f4kDNxMZdWfMOD5VvkrAGQBf_XljGllLX.ttf", + "300": "http://fonts.gstatic.com/s/firasans/v15/va9B4kDNxMZdWfMOD5VnPKruQR37fF3Wlg.ttf", + "300italic": "http://fonts.gstatic.com/s/firasans/v15/va9f4kDNxMZdWfMOD5VvkrBiQxf_XljGllLX.ttf", + "regular": "http://fonts.gstatic.com/s/firasans/v15/va9E4kDNxMZdWfMOD5VfkILKSTbndQ.ttf", + "italic": "http://fonts.gstatic.com/s/firasans/v15/va9C4kDNxMZdWfMOD5VvkojOazP3dUTP.ttf", + "500": "http://fonts.gstatic.com/s/firasans/v15/va9B4kDNxMZdWfMOD5VnZKvuQR37fF3Wlg.ttf", + "500italic": "http://fonts.gstatic.com/s/firasans/v15/va9f4kDNxMZdWfMOD5VvkrA6Qhf_XljGllLX.ttf", + "600": "http://fonts.gstatic.com/s/firasans/v15/va9B4kDNxMZdWfMOD5VnSKzuQR37fF3Wlg.ttf", + "600italic": "http://fonts.gstatic.com/s/firasans/v15/va9f4kDNxMZdWfMOD5VvkrAWRRf_XljGllLX.ttf", + "700": "http://fonts.gstatic.com/s/firasans/v15/va9B4kDNxMZdWfMOD5VnLK3uQR37fF3Wlg.ttf", + "700italic": "http://fonts.gstatic.com/s/firasans/v15/va9f4kDNxMZdWfMOD5VvkrByRBf_XljGllLX.ttf", + "800": "http://fonts.gstatic.com/s/firasans/v15/va9B4kDNxMZdWfMOD5VnMK7uQR37fF3Wlg.ttf", + "800italic": "http://fonts.gstatic.com/s/firasans/v15/va9f4kDNxMZdWfMOD5VvkrBuRxf_XljGllLX.ttf", + "900": "http://fonts.gstatic.com/s/firasans/v15/va9B4kDNxMZdWfMOD5VnFK_uQR37fF3Wlg.ttf", + "900italic": "http://fonts.gstatic.com/s/firasans/v15/va9f4kDNxMZdWfMOD5VvkrBKRhf_XljGllLX.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Fira Sans Condensed", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOjEADFm8hSaQTFG18FErVhsC9x-tarWZXtqOlQfx9CjA.ttf", + "100italic": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOtEADFm8hSaQTFG18FErVhsC9x-tarUfPVzONUXRpSjJcu.ttf", + "200": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOsEADFm8hSaQTFG18FErVhsC9x-tarWTnMiMN-cxZblY4.ttf", + "200italic": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOuEADFm8hSaQTFG18FErVhsC9x-tarUfPVYMJ0dzRehY43EA.ttf", + "300": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOsEADFm8hSaQTFG18FErVhsC9x-tarWV3PiMN-cxZblY4.ttf", + "300italic": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOuEADFm8hSaQTFG18FErVhsC9x-tarUfPVBMF0dzRehY43EA.ttf", + "regular": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOhEADFm8hSaQTFG18FErVhsC9x-tarYfHnrMtVbx8.ttf", + "italic": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOjEADFm8hSaQTFG18FErVhsC9x-tarUfPtqOlQfx9CjA.ttf", + "500": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOsEADFm8hSaQTFG18FErVhsC9x-tarWQXOiMN-cxZblY4.ttf", + "500italic": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOuEADFm8hSaQTFG18FErVhsC9x-tarUfPVXMB0dzRehY43EA.ttf", + "600": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOsEADFm8hSaQTFG18FErVhsC9x-tarWSnJiMN-cxZblY4.ttf", + "600italic": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOuEADFm8hSaQTFG18FErVhsC9x-tarUfPVcMd0dzRehY43EA.ttf", + "700": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOsEADFm8hSaQTFG18FErVhsC9x-tarWU3IiMN-cxZblY4.ttf", + "700italic": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOuEADFm8hSaQTFG18FErVhsC9x-tarUfPVFMZ0dzRehY43EA.ttf", + "800": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOsEADFm8hSaQTFG18FErVhsC9x-tarWVHLiMN-cxZblY4.ttf", + "800italic": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOuEADFm8hSaQTFG18FErVhsC9x-tarUfPVCMV0dzRehY43EA.ttf", + "900": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOsEADFm8hSaQTFG18FErVhsC9x-tarWXXKiMN-cxZblY4.ttf", + "900italic": "http://fonts.gstatic.com/s/firasanscondensed/v9/wEOuEADFm8hSaQTFG18FErVhsC9x-tarUfPVLMR0dzRehY43EA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Fira Sans Extra Condensed", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPMcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda3Zyuv1WarE9ncg.ttf", + "100italic": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPOcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda1fqW21-ejkp3cn22.ttf", + "200": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPPcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda3TCPn3-0oEZ-a2Q.ttf", + "200italic": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPxcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda1fqWd36-pGR7e2SvJQ.ttf", + "300": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPPcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda3VSMn3-0oEZ-a2Q.ttf", + "300italic": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPxcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda1fqWE32-pGR7e2SvJQ.ttf", + "regular": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPKcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda5fiku3efvE8.ttf", + "italic": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPMcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda1fquv1WarE9ncg.ttf", + "500": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPPcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda3QyNn3-0oEZ-a2Q.ttf", + "500italic": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPxcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda1fqWS3y-pGR7e2SvJQ.ttf", + "600": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPPcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda3SCKn3-0oEZ-a2Q.ttf", + "600italic": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPxcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda1fqWZ3u-pGR7e2SvJQ.ttf", + "700": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPPcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda3USLn3-0oEZ-a2Q.ttf", + "700italic": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPxcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda1fqWA3q-pGR7e2SvJQ.ttf", + "800": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPPcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda3ViIn3-0oEZ-a2Q.ttf", + "800italic": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPxcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda1fqWH3m-pGR7e2SvJQ.ttf", + "900": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPPcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda3XyJn3-0oEZ-a2Q.ttf", + "900italic": "http://fonts.gstatic.com/s/firasansextracondensed/v8/NaPxcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda1fqWO3i-pGR7e2SvJQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Fjalla One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/fjallaone/v12/Yq6R-LCAWCX3-6Ky7FAFnOZwkxgtUb8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Fjord One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/fjordone/v19/zOL-4pbEnKBY_9S1jNKr6e5As-FeiQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Flamenco", + "variants": [ + "300", + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/flamenco/v16/neIPzCehqYguo67ssZ0qNIkyepH9qGsf.ttf", + "regular": "http://fonts.gstatic.com/s/flamenco/v16/neIIzCehqYguo67ssaWGHK06UY30.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Flavors", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/flavors/v20/FBV2dDrhxqmveJTpbkzlNqkG9UY.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fleur De Leah", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/fleurdeleah/v5/AYCNpXX7ftYZWLhv9UmPJTMC5vat4I_Gdq0.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Flow Block", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/flowblock/v5/wlp0gwfPCEB65UmTk-d6-WZlbCBXE_I.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Flow Circular", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/flowcircular/v5/lJwB-pc4j2F-H8YKuyvfxdZ45ifpWdr2rIg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Flow Rounded", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/flowrounded/v5/-zki91mtwsU9qlLiGwD4oQX3oZX-Xup87g.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fondamento", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/fondamento/v14/4UaHrEJGsxNmFTPDnkaJx63j5pN1MwI.ttf", + "italic": "http://fonts.gstatic.com/s/fondamento/v14/4UaFrEJGsxNmFTPDnkaJ96_p4rFwIwJePw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Fontdiner Swanky", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/fontdinerswanky/v11/ijwOs4XgRNsiaI5-hcVb4hQgMvCD4uEfKiGvxts.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Forum", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/forum/v14/6aey4Ky-Vb8Ew_IWMJMa3mnT.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Francois One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v19", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/francoisone/v19/_Xmr-H4zszafZw3A-KPSZutNxgKQu_avAg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Frank Ruhl Libre", + "variants": [ + "300", + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "hebrew", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/frankruhllibre/v10/j8_36_fAw7jrcalD7oKYNX0QfAnPUxvHxJDMhYeIHw8.ttf", + "regular": "http://fonts.gstatic.com/s/frankruhllibre/v10/j8_w6_fAw7jrcalD7oKYNX0QfAnPa7fv4JjnmY4.ttf", + "500": "http://fonts.gstatic.com/s/frankruhllibre/v10/j8_36_fAw7jrcalD7oKYNX0QfAnPU0PGxJDMhYeIHw8.ttf", + "700": "http://fonts.gstatic.com/s/frankruhllibre/v10/j8_36_fAw7jrcalD7oKYNX0QfAnPUwvAxJDMhYeIHw8.ttf", + "900": "http://fonts.gstatic.com/s/frankruhllibre/v10/j8_36_fAw7jrcalD7oKYNX0QfAnPUzPCxJDMhYeIHw8.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Fraunces", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2021-03-19", + "files": { + "100": "http://fonts.gstatic.com/s/fraunces/v10/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIctxqjDvTShUtWNg.ttf", + "200": "http://fonts.gstatic.com/s/fraunces/v10/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIcNxujDvTShUtWNg.ttf", + "300": "http://fonts.gstatic.com/s/fraunces/v10/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIc6RujDvTShUtWNg.ttf", + "regular": "http://fonts.gstatic.com/s/fraunces/v10/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIctxujDvTShUtWNg.ttf", + "500": "http://fonts.gstatic.com/s/fraunces/v10/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIchRujDvTShUtWNg.ttf", + "600": "http://fonts.gstatic.com/s/fraunces/v10/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIcaRyjDvTShUtWNg.ttf", + "700": "http://fonts.gstatic.com/s/fraunces/v10/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIcUByjDvTShUtWNg.ttf", + "800": "http://fonts.gstatic.com/s/fraunces/v10/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIcNxyjDvTShUtWNg.ttf", + "900": "http://fonts.gstatic.com/s/fraunces/v10/6NUh8FyLNQOQZAnv9bYEvDiIdE9Ea92uemAk_WBq8U_9v0c2Wa0K7iN7hzFUPJH58nib1603gg7S2nfgRYIcHhyjDvTShUtWNg.ttf", + "100italic": "http://fonts.gstatic.com/s/fraunces/v10/6NVf8FyLNQOQZAnv9ZwNjucMHVn85Ni7emAe9lKqZTnbB-gzTK0K1ChJdt9vIVYX9G37lvd9sPEKsxx664UJf1hLTP7Wp05GNi3k.ttf", + "200italic": "http://fonts.gstatic.com/s/fraunces/v10/6NVf8FyLNQOQZAnv9ZwNjucMHVn85Ni7emAe9lKqZTnbB-gzTK0K1ChJdt9vIVYX9G37lvd9sPEKsxx664UJf1jLTf7Wp05GNi3k.ttf", + "300italic": "http://fonts.gstatic.com/s/fraunces/v10/6NVf8FyLNQOQZAnv9ZwNjucMHVn85Ni7emAe9lKqZTnbB-gzTK0K1ChJdt9vIVYX9G37lvd9sPEKsxx664UJf1gVTf7Wp05GNi3k.ttf", + "italic": "http://fonts.gstatic.com/s/fraunces/v10/6NVf8FyLNQOQZAnv9ZwNjucMHVn85Ni7emAe9lKqZTnbB-gzTK0K1ChJdt9vIVYX9G37lvd9sPEKsxx664UJf1hLTf7Wp05GNi3k.ttf", + "500italic": "http://fonts.gstatic.com/s/fraunces/v10/6NVf8FyLNQOQZAnv9ZwNjucMHVn85Ni7emAe9lKqZTnbB-gzTK0K1ChJdt9vIVYX9G37lvd9sPEKsxx664UJf1h5Tf7Wp05GNi3k.ttf", + "600italic": "http://fonts.gstatic.com/s/fraunces/v10/6NVf8FyLNQOQZAnv9ZwNjucMHVn85Ni7emAe9lKqZTnbB-gzTK0K1ChJdt9vIVYX9G37lvd9sPEKsxx664UJf1iVSv7Wp05GNi3k.ttf", + "700italic": "http://fonts.gstatic.com/s/fraunces/v10/6NVf8FyLNQOQZAnv9ZwNjucMHVn85Ni7emAe9lKqZTnbB-gzTK0K1ChJdt9vIVYX9G37lvd9sPEKsxx664UJf1isSv7Wp05GNi3k.ttf", + "800italic": "http://fonts.gstatic.com/s/fraunces/v10/6NVf8FyLNQOQZAnv9ZwNjucMHVn85Ni7emAe9lKqZTnbB-gzTK0K1ChJdt9vIVYX9G37lvd9sPEKsxx664UJf1jLSv7Wp05GNi3k.ttf", + "900italic": "http://fonts.gstatic.com/s/fraunces/v10/6NVf8FyLNQOQZAnv9ZwNjucMHVn85Ni7emAe9lKqZTnbB-gzTK0K1ChJdt9vIVYX9G37lvd9sPEKsxx664UJf1jiSv7Wp05GNi3k.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Freckle Face", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/freckleface/v12/AMOWz4SXrmKHCvXTohxY-YI0U1K2w9lb4g.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fredericka the Great", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/frederickathegreat/v13/9Bt33CxNwt7aOctW2xjbCstzwVKsIBVV-9Skz7Ylch2L.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fredoka One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/fredokaone/v12/k3kUo8kEI-tA1RRcTZGmTmHBA6aF8Bf_.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Freehand", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v25", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/freehand/v25/cIf-Ma5eqk01VjKTgAmBTmUOmZJk.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fresca", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/fresca/v16/6ae94K--SKgCzbM2Gr0W13DKPA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Frijole", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/frijole/v12/uU9PCBUR8oakM2BQ7xPb3vyHmlI.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fruktur", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/fruktur/v23/SZc53FHsOru5QYsMfz3GkUrS8DI.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fugaz One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/fugazone/v13/rax_HiWKp9EAITukFslMBBJek0vA8A.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Fuggles", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v6", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/fuggles/v6/k3kQo8UEJOlD1hpOTd7iL0nAMaM.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Fuzzy Bubbles", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/fuzzybubbles/v3/6qLGKZMbrgv9pwtjPEVNV0F2NnP5Zxsreko.ttf", + "700": "http://fonts.gstatic.com/s/fuzzybubbles/v3/6qLbKZMbrgv9pwtjPEVNV0F2Ds_WQxMAZkM1pn4.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "GFS Didot", + "variants": [ + "regular" + ], + "subsets": [ + "greek" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/gfsdidot/v13/Jqzh5TybZ9vZMWFssvwiF-fGFSCGAA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "GFS Neohellenic", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "greek" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/gfsneohellenic/v23/8QIRdiDOrfiq0b7R8O1Iw9WLcY5TLahP46UDUw.ttf", + "italic": "http://fonts.gstatic.com/s/gfsneohellenic/v23/8QITdiDOrfiq0b7R8O1Iw9WLcY5jL6JLwaATU91X.ttf", + "700": "http://fonts.gstatic.com/s/gfsneohellenic/v23/8QIUdiDOrfiq0b7R8O1Iw9WLcY5rkYdr644fWsRO9w.ttf", + "700italic": "http://fonts.gstatic.com/s/gfsneohellenic/v23/8QIWdiDOrfiq0b7R8O1Iw9WLcY5jL5r37oQbeMFe985V.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gabriela", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/gabriela/v12/qkBWXvsO6sreR8E-b_m-zrpHmRzC.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gaegu", + "variants": [ + "300", + "regular", + "700" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/gaegu/v13/TuGSUVB6Up9NU57nifw74sdtBk0x.ttf", + "regular": "http://fonts.gstatic.com/s/gaegu/v13/TuGfUVB6Up9NU6ZLodgzydtk.ttf", + "700": "http://fonts.gstatic.com/s/gaegu/v13/TuGSUVB6Up9NU573jvw74sdtBk0x.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Gafata", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/gafata/v14/XRXV3I6Cn0VJKon4MuyAbsrVcA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Galada", + "variants": [ + "regular" + ], + "subsets": [ + "bengali", + "latin" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/galada/v12/H4cmBXyGmcjXlUX-8iw-4Lqggw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Galdeano", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/galdeano/v20/uU9MCBoQ4YOqOW1boDPx8PCOg0uX.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Galindo", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/galindo/v18/HI_KiYMeLqVKqwyuQ5HiRp-dhpQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Gamja Flower", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/gamjaflower/v18/6NUR8FiKJg-Pa0rM6uN40Z4kyf9Fdty2ew.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Gayathri", + "variants": [ + "100", + "regular", + "700" + ], + "subsets": [ + "latin", + "malayalam" + ], + "version": "v13", + "lastModified": "2022-01-05", + "files": { + "100": "http://fonts.gstatic.com/s/gayathri/v13/MCoWzAb429DbBilWLLhc-pvSA_gA2W8.ttf", + "regular": "http://fonts.gstatic.com/s/gayathri/v13/MCoQzAb429DbBilWLIA48J_wBugA.ttf", + "700": "http://fonts.gstatic.com/s/gayathri/v13/MCoXzAb429DbBilWLLiE37v4LfQJwHbn.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gelasio", + "variants": [ + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/gelasio/v7/cIf9MaFfvUQxTTqSxCmrYGkHgIs.ttf", + "italic": "http://fonts.gstatic.com/s/gelasio/v7/cIf_MaFfvUQxTTqS9CuhZEsCkIt9QQ.ttf", + "500": "http://fonts.gstatic.com/s/gelasio/v7/cIf4MaFfvUQxTTqS_N2CRGEsnIJkWL4.ttf", + "500italic": "http://fonts.gstatic.com/s/gelasio/v7/cIf6MaFfvUQxTTqS9CuZkGImmKBhSL7Y1Q.ttf", + "600": "http://fonts.gstatic.com/s/gelasio/v7/cIf4MaFfvUQxTTqS_PGFRGEsnIJkWL4.ttf", + "600italic": "http://fonts.gstatic.com/s/gelasio/v7/cIf6MaFfvUQxTTqS9CuZvGUmmKBhSL7Y1Q.ttf", + "700": "http://fonts.gstatic.com/s/gelasio/v7/cIf4MaFfvUQxTTqS_JWERGEsnIJkWL4.ttf", + "700italic": "http://fonts.gstatic.com/s/gelasio/v7/cIf6MaFfvUQxTTqS9CuZ2GQmmKBhSL7Y1Q.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gemunu Libre", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext", + "sinhala" + ], + "version": "v6", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/gemunulibre/v6/X7n34bQ6Cfy7jKGXVE_YlqnbEQAFP-PIuTCp05iJPvSLeMXPIWA.ttf", + "300": "http://fonts.gstatic.com/s/gemunulibre/v6/X7n34bQ6Cfy7jKGXVE_YlqnbEQAFP-PIuTCp00aJPvSLeMXPIWA.ttf", + "regular": "http://fonts.gstatic.com/s/gemunulibre/v6/X7n34bQ6Cfy7jKGXVE_YlqnbEQAFP-PIuTCp0xiJPvSLeMXPIWA.ttf", + "500": "http://fonts.gstatic.com/s/gemunulibre/v6/X7n34bQ6Cfy7jKGXVE_YlqnbEQAFP-PIuTCp0yqJPvSLeMXPIWA.ttf", + "600": "http://fonts.gstatic.com/s/gemunulibre/v6/X7n34bQ6Cfy7jKGXVE_YlqnbEQAFP-PIuTCp08aOPvSLeMXPIWA.ttf", + "700": "http://fonts.gstatic.com/s/gemunulibre/v6/X7n34bQ6Cfy7jKGXVE_YlqnbEQAFP-PIuTCp0_-OPvSLeMXPIWA.ttf", + "800": "http://fonts.gstatic.com/s/gemunulibre/v6/X7n34bQ6Cfy7jKGXVE_YlqnbEQAFP-PIuTCp05iOPvSLeMXPIWA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Genos", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cherokee", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v4", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/genos/v4/SlGNmQqPqpUOYTYjacb0Hc91fTwVqknorUK6K7ZsAg.ttf", + "200": "http://fonts.gstatic.com/s/genos/v4/SlGNmQqPqpUOYTYjacb0Hc91fTwVKkjorUK6K7ZsAg.ttf", + "300": "http://fonts.gstatic.com/s/genos/v4/SlGNmQqPqpUOYTYjacb0Hc91fTwV9EjorUK6K7ZsAg.ttf", + "regular": "http://fonts.gstatic.com/s/genos/v4/SlGNmQqPqpUOYTYjacb0Hc91fTwVqkjorUK6K7ZsAg.ttf", + "500": "http://fonts.gstatic.com/s/genos/v4/SlGNmQqPqpUOYTYjacb0Hc91fTwVmEjorUK6K7ZsAg.ttf", + "600": "http://fonts.gstatic.com/s/genos/v4/SlGNmQqPqpUOYTYjacb0Hc91fTwVdE_orUK6K7ZsAg.ttf", + "700": "http://fonts.gstatic.com/s/genos/v4/SlGNmQqPqpUOYTYjacb0Hc91fTwVTU_orUK6K7ZsAg.ttf", + "800": "http://fonts.gstatic.com/s/genos/v4/SlGNmQqPqpUOYTYjacb0Hc91fTwVKk_orUK6K7ZsAg.ttf", + "900": "http://fonts.gstatic.com/s/genos/v4/SlGNmQqPqpUOYTYjacb0Hc91fTwVA0_orUK6K7ZsAg.ttf", + "100italic": "http://fonts.gstatic.com/s/genos/v4/SlGPmQqPqpUOYRwqWzksdKTv0zsAYgsA70i-CbN8Ard7.ttf", + "200italic": "http://fonts.gstatic.com/s/genos/v4/SlGPmQqPqpUOYRwqWzksdKTv0zsAYguA7ki-CbN8Ard7.ttf", + "300italic": "http://fonts.gstatic.com/s/genos/v4/SlGPmQqPqpUOYRwqWzksdKTv0zsAYgte7ki-CbN8Ard7.ttf", + "italic": "http://fonts.gstatic.com/s/genos/v4/SlGPmQqPqpUOYRwqWzksdKTv0zsAYgsA7ki-CbN8Ard7.ttf", + "500italic": "http://fonts.gstatic.com/s/genos/v4/SlGPmQqPqpUOYRwqWzksdKTv0zsAYgsy7ki-CbN8Ard7.ttf", + "600italic": "http://fonts.gstatic.com/s/genos/v4/SlGPmQqPqpUOYRwqWzksdKTv0zsAYgve6Ui-CbN8Ard7.ttf", + "700italic": "http://fonts.gstatic.com/s/genos/v4/SlGPmQqPqpUOYRwqWzksdKTv0zsAYgvn6Ui-CbN8Ard7.ttf", + "800italic": "http://fonts.gstatic.com/s/genos/v4/SlGPmQqPqpUOYRwqWzksdKTv0zsAYguA6Ui-CbN8Ard7.ttf", + "900italic": "http://fonts.gstatic.com/s/genos/v4/SlGPmQqPqpUOYRwqWzksdKTv0zsAYgup6Ui-CbN8Ard7.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gentium Basic", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/gentiumbasic/v15/Wnz9HAw9aB_JD2VGQVR80We3HAqDiTI_cIM.ttf", + "italic": "http://fonts.gstatic.com/s/gentiumbasic/v15/WnzjHAw9aB_JD2VGQVR80We3LAiJjRA6YIORZQ.ttf", + "700": "http://fonts.gstatic.com/s/gentiumbasic/v15/WnzgHAw9aB_JD2VGQVR80We3JLasrToUbIqIfBU.ttf", + "700italic": "http://fonts.gstatic.com/s/gentiumbasic/v15/WnzmHAw9aB_JD2VGQVR80We3LAixMT8eaKiNbBVWkw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gentium Book Basic", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/gentiumbookbasic/v14/pe0zMJCbPYBVokB1LHA9bbyaQb8ZGjcIV7t7w6bE2A.ttf", + "italic": "http://fonts.gstatic.com/s/gentiumbookbasic/v14/pe0xMJCbPYBVokB1LHA9bbyaQb8ZGjc4VbF_4aPU2Ec9.ttf", + "700": "http://fonts.gstatic.com/s/gentiumbookbasic/v14/pe0wMJCbPYBVokB1LHA9bbyaQb8ZGjcw65Rfy43Y0V4kvg.ttf", + "700italic": "http://fonts.gstatic.com/s/gentiumbookbasic/v14/pe0-MJCbPYBVokB1LHA9bbyaQb8ZGjc4VYnDzofc81s0voO3.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Geo", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/geo/v17/CSRz4zRZlufVL3BmQjlCbQ.ttf", + "italic": "http://fonts.gstatic.com/s/geo/v17/CSRx4zRZluflLXpiYDxSbf8r.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Georama", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v4", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/georama/v4/MCo5zAn438bIEyxFf6swMnNpvPcUwW4u4yRcDh-ZjxApn9K5GvktmQsL5_tgbg.ttf", + "200": "http://fonts.gstatic.com/s/georama/v4/MCo5zAn438bIEyxFf6swMnNpvPcUwW4u4yRcDh-ZjxApn9K5mvgtmQsL5_tgbg.ttf", + "300": "http://fonts.gstatic.com/s/georama/v4/MCo5zAn438bIEyxFf6swMnNpvPcUwW4u4yRcDh-ZjxApn9K5RPgtmQsL5_tgbg.ttf", + "regular": "http://fonts.gstatic.com/s/georama/v4/MCo5zAn438bIEyxFf6swMnNpvPcUwW4u4yRcDh-ZjxApn9K5GvgtmQsL5_tgbg.ttf", + "500": "http://fonts.gstatic.com/s/georama/v4/MCo5zAn438bIEyxFf6swMnNpvPcUwW4u4yRcDh-ZjxApn9K5KPgtmQsL5_tgbg.ttf", + "600": "http://fonts.gstatic.com/s/georama/v4/MCo5zAn438bIEyxFf6swMnNpvPcUwW4u4yRcDh-ZjxApn9K5xP8tmQsL5_tgbg.ttf", + "700": "http://fonts.gstatic.com/s/georama/v4/MCo5zAn438bIEyxFf6swMnNpvPcUwW4u4yRcDh-ZjxApn9K5_f8tmQsL5_tgbg.ttf", + "800": "http://fonts.gstatic.com/s/georama/v4/MCo5zAn438bIEyxFf6swMnNpvPcUwW4u4yRcDh-ZjxApn9K5mv8tmQsL5_tgbg.ttf", + "900": "http://fonts.gstatic.com/s/georama/v4/MCo5zAn438bIEyxFf6swMnNpvPcUwW4u4yRcDh-ZjxApn9K5s_8tmQsL5_tgbg.ttf", + "100italic": "http://fonts.gstatic.com/s/georama/v4/MCo_zAn438bIEyxFVaIC0ZMQ72G6xnvmodYVPOBB5nuzMdWs0rvF2wEPxf5wbh3T.ttf", + "200italic": "http://fonts.gstatic.com/s/georama/v4/MCo_zAn438bIEyxFVaIC0ZMQ72G6xnvmodYVPOBB5nuzMdWs0rtF2gEPxf5wbh3T.ttf", + "300italic": "http://fonts.gstatic.com/s/georama/v4/MCo_zAn438bIEyxFVaIC0ZMQ72G6xnvmodYVPOBB5nuzMdWs0rub2gEPxf5wbh3T.ttf", + "italic": "http://fonts.gstatic.com/s/georama/v4/MCo_zAn438bIEyxFVaIC0ZMQ72G6xnvmodYVPOBB5nuzMdWs0rvF2gEPxf5wbh3T.ttf", + "500italic": "http://fonts.gstatic.com/s/georama/v4/MCo_zAn438bIEyxFVaIC0ZMQ72G6xnvmodYVPOBB5nuzMdWs0rv32gEPxf5wbh3T.ttf", + "600italic": "http://fonts.gstatic.com/s/georama/v4/MCo_zAn438bIEyxFVaIC0ZMQ72G6xnvmodYVPOBB5nuzMdWs0rsb3QEPxf5wbh3T.ttf", + "700italic": "http://fonts.gstatic.com/s/georama/v4/MCo_zAn438bIEyxFVaIC0ZMQ72G6xnvmodYVPOBB5nuzMdWs0rsi3QEPxf5wbh3T.ttf", + "800italic": "http://fonts.gstatic.com/s/georama/v4/MCo_zAn438bIEyxFVaIC0ZMQ72G6xnvmodYVPOBB5nuzMdWs0rtF3QEPxf5wbh3T.ttf", + "900italic": "http://fonts.gstatic.com/s/georama/v4/MCo_zAn438bIEyxFVaIC0ZMQ72G6xnvmodYVPOBB5nuzMdWs0rts3QEPxf5wbh3T.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Geostar", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/geostar/v13/sykz-yx4n701VLOftSq9-trEvlQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Geostar Fill", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/geostarfill/v13/AMOWz4SWuWiXFfjEohxQ9os0U1K2w9lb4g.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Germania One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/germaniaone/v18/Fh4yPjrqIyv2ucM2qzBjeS3ezAJONau6ew.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Gideon Roman", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/gideonroman/v5/e3tmeuGrVOys8sxzZgWlmXoge0PWovdU4w.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Gidugu", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/gidugu/v19/L0x8DFMkk1Uf6w3RvPCmRSlUig.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gilda Display", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/gildadisplay/v11/t5tmIRoYMoaYG0WEOh7HwMeR7TnFrpOHYh4.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Girassol", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/girassol/v14/JTUUjIo_-DK48laaNC9Nz2pJzxbi.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Give You Glory", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/giveyouglory/v13/8QIQdiHOgt3vv4LR7ahjw9-XYc1zB4ZD6rwa.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Glass Antiqua", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/glassantiqua/v18/xfu30Wr0Wn3NOQM2piC0uXOjnL_wN6fRUkY.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Glegoo", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/glegoo/v10/_Xmt-HQyrTKWaw2Ji6mZAI91xw.ttf", + "700": "http://fonts.gstatic.com/s/glegoo/v10/_Xmu-HQyrTKWaw2xN4a9CKRpzimMsg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gloria Hallelujah", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/gloriahallelujah/v15/LYjYdHv3kUk9BMV96EIswT9DIbW-MLSy3TKEvkCF.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Glory", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/glory/v7/q5uasoi9Lf1w5t3Est24nq9blIRQwIiDpn-dDi9EOQ.ttf", + "200": "http://fonts.gstatic.com/s/glory/v7/q5uasoi9Lf1w5t3Est24nq9blIRQQImDpn-dDi9EOQ.ttf", + "300": "http://fonts.gstatic.com/s/glory/v7/q5uasoi9Lf1w5t3Est24nq9blIRQnomDpn-dDi9EOQ.ttf", + "regular": "http://fonts.gstatic.com/s/glory/v7/q5uasoi9Lf1w5t3Est24nq9blIRQwImDpn-dDi9EOQ.ttf", + "500": "http://fonts.gstatic.com/s/glory/v7/q5uasoi9Lf1w5t3Est24nq9blIRQ8omDpn-dDi9EOQ.ttf", + "600": "http://fonts.gstatic.com/s/glory/v7/q5uasoi9Lf1w5t3Est24nq9blIRQHo6Dpn-dDi9EOQ.ttf", + "700": "http://fonts.gstatic.com/s/glory/v7/q5uasoi9Lf1w5t3Est24nq9blIRQJ46Dpn-dDi9EOQ.ttf", + "800": "http://fonts.gstatic.com/s/glory/v7/q5uasoi9Lf1w5t3Est24nq9blIRQQI6Dpn-dDi9EOQ.ttf", + "100italic": "http://fonts.gstatic.com/s/glory/v7/q5uYsoi9Lf1w5vfNgCJg98TBOoNFCMpr5HWZLCpUOaM6.ttf", + "200italic": "http://fonts.gstatic.com/s/glory/v7/q5uYsoi9Lf1w5vfNgCJg98TBOoNFCMrr5XWZLCpUOaM6.ttf", + "300italic": "http://fonts.gstatic.com/s/glory/v7/q5uYsoi9Lf1w5vfNgCJg98TBOoNFCMo15XWZLCpUOaM6.ttf", + "italic": "http://fonts.gstatic.com/s/glory/v7/q5uYsoi9Lf1w5vfNgCJg98TBOoNFCMpr5XWZLCpUOaM6.ttf", + "500italic": "http://fonts.gstatic.com/s/glory/v7/q5uYsoi9Lf1w5vfNgCJg98TBOoNFCMpZ5XWZLCpUOaM6.ttf", + "600italic": "http://fonts.gstatic.com/s/glory/v7/q5uYsoi9Lf1w5vfNgCJg98TBOoNFCMq14nWZLCpUOaM6.ttf", + "700italic": "http://fonts.gstatic.com/s/glory/v7/q5uYsoi9Lf1w5vfNgCJg98TBOoNFCMqM4nWZLCpUOaM6.ttf", + "800italic": "http://fonts.gstatic.com/s/glory/v7/q5uYsoi9Lf1w5vfNgCJg98TBOoNFCMrr4nWZLCpUOaM6.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gluten", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v6", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/gluten/v6/HhyIU5gk9fW7OUdVIPh2wBPxSqQJ_zh3_DMrQqcdJrk.ttf", + "200": "http://fonts.gstatic.com/s/gluten/v6/HhyIU5gk9fW7OUdVIPh2wBPxSqQJ_7h2_DMrQqcdJrk.ttf", + "300": "http://fonts.gstatic.com/s/gluten/v6/HhyIU5gk9fW7OUdVIPh2wBPxSqQJ_2Z2_DMrQqcdJrk.ttf", + "regular": "http://fonts.gstatic.com/s/gluten/v6/HhyIU5gk9fW7OUdVIPh2wBPxSqQJ_zh2_DMrQqcdJrk.ttf", + "500": "http://fonts.gstatic.com/s/gluten/v6/HhyIU5gk9fW7OUdVIPh2wBPxSqQJ_wp2_DMrQqcdJrk.ttf", + "600": "http://fonts.gstatic.com/s/gluten/v6/HhyIU5gk9fW7OUdVIPh2wBPxSqQJ_-Zx_DMrQqcdJrk.ttf", + "700": "http://fonts.gstatic.com/s/gluten/v6/HhyIU5gk9fW7OUdVIPh2wBPxSqQJ_99x_DMrQqcdJrk.ttf", + "800": "http://fonts.gstatic.com/s/gluten/v6/HhyIU5gk9fW7OUdVIPh2wBPxSqQJ_7hx_DMrQqcdJrk.ttf", + "900": "http://fonts.gstatic.com/s/gluten/v6/HhyIU5gk9fW7OUdVIPh2wBPxSqQJ_5Fx_DMrQqcdJrk.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Goblin One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/goblinone/v20/CSR64z1ZnOqZRjRCBVY_TOcATNt_pOU.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Gochi Hand", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/gochihand/v14/hES06XlsOjtJsgCkx1PkTo71-n0nXWA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Goldman", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/goldman/v13/pe0uMIWbN4JFplR2LDJ4Bt-7G98.ttf", + "700": "http://fonts.gstatic.com/s/goldman/v13/pe0rMIWbN4JFplR2FI5XIteQB9Zra1U.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Gorditas", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/gorditas/v18/ll8_K2aTVD26DsPEtQDoDa4AlxYb.ttf", + "700": "http://fonts.gstatic.com/s/gorditas/v18/ll84K2aTVD26DsPEtThUIooIvAoShA1i.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Gothic A1", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v12", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/gothica1/v12/CSR74z5ZnPydRjlCCwlCCMcqYtd2vfwk.ttf", + "200": "http://fonts.gstatic.com/s/gothica1/v12/CSR44z5ZnPydRjlCCwlCpOYKSPl6tOU9Eg.ttf", + "300": "http://fonts.gstatic.com/s/gothica1/v12/CSR44z5ZnPydRjlCCwlCwOUKSPl6tOU9Eg.ttf", + "regular": "http://fonts.gstatic.com/s/gothica1/v12/CSR94z5ZnPydRjlCCwl6bM0uQNJmvQ.ttf", + "500": "http://fonts.gstatic.com/s/gothica1/v12/CSR44z5ZnPydRjlCCwlCmOQKSPl6tOU9Eg.ttf", + "600": "http://fonts.gstatic.com/s/gothica1/v12/CSR44z5ZnPydRjlCCwlCtOMKSPl6tOU9Eg.ttf", + "700": "http://fonts.gstatic.com/s/gothica1/v12/CSR44z5ZnPydRjlCCwlC0OIKSPl6tOU9Eg.ttf", + "800": "http://fonts.gstatic.com/s/gothica1/v12/CSR44z5ZnPydRjlCCwlCzOEKSPl6tOU9Eg.ttf", + "900": "http://fonts.gstatic.com/s/gothica1/v12/CSR44z5ZnPydRjlCCwlC6OAKSPl6tOU9Eg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gotu", + "variants": [ + "regular" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v12", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/gotu/v12/o-0FIpksx3QOlH0Lioh6-hU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Goudy Bookletter 1911", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/goudybookletter1911/v13/sykt-z54laciWfKv-kX8krex0jDiD2HbY6I5tRbXZ4IXAA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gowun Batang", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "korean", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/gowunbatang/v5/ijwSs5nhRMIjYsdSgcMa3wRhXLH-yuAtLw.ttf", + "700": "http://fonts.gstatic.com/s/gowunbatang/v5/ijwNs5nhRMIjYsdSgcMa3wRZ4J7awssxJii23w.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gowun Dodum", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/gowundodum/v5/3Jn5SD_00GqwlBnWc1TUJF0FfORL0fNy.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Graduate", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/graduate/v11/C8cg4cs3o2n15t_2YxgR6X2NZAn2.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Grand Hotel", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/grandhotel/v11/7Au7p_IgjDKdCRWuR1azpmQNEl0O0kEx.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Grandstander", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/grandstander/v9/ga6fawtA-GpSsTWrnNHPCSIMZhhKpFjyNZIQD1-_D3jWttFGmQk.ttf", + "200": "http://fonts.gstatic.com/s/grandstander/v9/ga6fawtA-GpSsTWrnNHPCSIMZhhKpFjyNZIQD9--D3jWttFGmQk.ttf", + "300": "http://fonts.gstatic.com/s/grandstander/v9/ga6fawtA-GpSsTWrnNHPCSIMZhhKpFjyNZIQDwG-D3jWttFGmQk.ttf", + "regular": "http://fonts.gstatic.com/s/grandstander/v9/ga6fawtA-GpSsTWrnNHPCSIMZhhKpFjyNZIQD1--D3jWttFGmQk.ttf", + "500": "http://fonts.gstatic.com/s/grandstander/v9/ga6fawtA-GpSsTWrnNHPCSIMZhhKpFjyNZIQD22-D3jWttFGmQk.ttf", + "600": "http://fonts.gstatic.com/s/grandstander/v9/ga6fawtA-GpSsTWrnNHPCSIMZhhKpFjyNZIQD4G5D3jWttFGmQk.ttf", + "700": "http://fonts.gstatic.com/s/grandstander/v9/ga6fawtA-GpSsTWrnNHPCSIMZhhKpFjyNZIQD7i5D3jWttFGmQk.ttf", + "800": "http://fonts.gstatic.com/s/grandstander/v9/ga6fawtA-GpSsTWrnNHPCSIMZhhKpFjyNZIQD9-5D3jWttFGmQk.ttf", + "900": "http://fonts.gstatic.com/s/grandstander/v9/ga6fawtA-GpSsTWrnNHPCSIMZhhKpFjyNZIQD_a5D3jWttFGmQk.ttf", + "100italic": "http://fonts.gstatic.com/s/grandstander/v9/ga6ZawtA-GpSsTWrnNHPCSImbyq1fDGZrzwXGpf95zrcsvNDiQlBYQ.ttf", + "200italic": "http://fonts.gstatic.com/s/grandstander/v9/ga6ZawtA-GpSsTWrnNHPCSImbyq1fDGZrzwXGpf9ZzvcsvNDiQlBYQ.ttf", + "300italic": "http://fonts.gstatic.com/s/grandstander/v9/ga6ZawtA-GpSsTWrnNHPCSImbyq1fDGZrzwXGpf9uTvcsvNDiQlBYQ.ttf", + "italic": "http://fonts.gstatic.com/s/grandstander/v9/ga6ZawtA-GpSsTWrnNHPCSImbyq1fDGZrzwXGpf95zvcsvNDiQlBYQ.ttf", + "500italic": "http://fonts.gstatic.com/s/grandstander/v9/ga6ZawtA-GpSsTWrnNHPCSImbyq1fDGZrzwXGpf91TvcsvNDiQlBYQ.ttf", + "600italic": "http://fonts.gstatic.com/s/grandstander/v9/ga6ZawtA-GpSsTWrnNHPCSImbyq1fDGZrzwXGpf9OTzcsvNDiQlBYQ.ttf", + "700italic": "http://fonts.gstatic.com/s/grandstander/v9/ga6ZawtA-GpSsTWrnNHPCSImbyq1fDGZrzwXGpf9ADzcsvNDiQlBYQ.ttf", + "800italic": "http://fonts.gstatic.com/s/grandstander/v9/ga6ZawtA-GpSsTWrnNHPCSImbyq1fDGZrzwXGpf9ZzzcsvNDiQlBYQ.ttf", + "900italic": "http://fonts.gstatic.com/s/grandstander/v9/ga6ZawtA-GpSsTWrnNHPCSImbyq1fDGZrzwXGpf9TjzcsvNDiQlBYQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Gravitas One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/gravitasone/v13/5h1diZ4hJ3cblKy3LWakKQmaDWRNr3DzbQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Great Vibes", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/greatvibes/v13/RWmMoKWR9v4ksMfaWd_JN-XCg6UKDXlq.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Grechen Fuemen", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/grechenfuemen/v5/vEFI2_tHEQ4d5ObgKxBzZh0MAWgc-NaXXq7H.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Grenze", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v12", + "lastModified": "2022-01-05", + "files": { + "100": "http://fonts.gstatic.com/s/grenze/v12/O4ZRFGb7hR12BxqPm2IjuAkalnmd.ttf", + "100italic": "http://fonts.gstatic.com/s/grenze/v12/O4ZXFGb7hR12BxqH_VpHsg04k2md0kI.ttf", + "200": "http://fonts.gstatic.com/s/grenze/v12/O4ZQFGb7hR12BxqPN0MDkicWn2CEyw.ttf", + "200italic": "http://fonts.gstatic.com/s/grenze/v12/O4ZWFGb7hR12BxqH_Vrrky0SvWWUy1uW.ttf", + "300": "http://fonts.gstatic.com/s/grenze/v12/O4ZQFGb7hR12BxqPU0ADkicWn2CEyw.ttf", + "300italic": "http://fonts.gstatic.com/s/grenze/v12/O4ZWFGb7hR12BxqH_VqPkC0SvWWUy1uW.ttf", + "regular": "http://fonts.gstatic.com/s/grenze/v12/O4ZTFGb7hR12Bxq3_2gnmgwKlg.ttf", + "italic": "http://fonts.gstatic.com/s/grenze/v12/O4ZRFGb7hR12BxqH_WIjuAkalnmd.ttf", + "500": "http://fonts.gstatic.com/s/grenze/v12/O4ZQFGb7hR12BxqPC0EDkicWn2CEyw.ttf", + "500italic": "http://fonts.gstatic.com/s/grenze/v12/O4ZWFGb7hR12BxqH_VrXkS0SvWWUy1uW.ttf", + "600": "http://fonts.gstatic.com/s/grenze/v12/O4ZQFGb7hR12BxqPJ0YDkicWn2CEyw.ttf", + "600italic": "http://fonts.gstatic.com/s/grenze/v12/O4ZWFGb7hR12BxqH_Vr7li0SvWWUy1uW.ttf", + "700": "http://fonts.gstatic.com/s/grenze/v12/O4ZQFGb7hR12BxqPQ0cDkicWn2CEyw.ttf", + "700italic": "http://fonts.gstatic.com/s/grenze/v12/O4ZWFGb7hR12BxqH_Vqfly0SvWWUy1uW.ttf", + "800": "http://fonts.gstatic.com/s/grenze/v12/O4ZQFGb7hR12BxqPX0QDkicWn2CEyw.ttf", + "800italic": "http://fonts.gstatic.com/s/grenze/v12/O4ZWFGb7hR12BxqH_VqDlC0SvWWUy1uW.ttf", + "900": "http://fonts.gstatic.com/s/grenze/v12/O4ZQFGb7hR12BxqPe0UDkicWn2CEyw.ttf", + "900italic": "http://fonts.gstatic.com/s/grenze/v12/O4ZWFGb7hR12BxqH_VqnlS0SvWWUy1uW.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Grenze Gotisch", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/grenzegotisch/v10/Fh4hPjjqNDz1osh_jX9YfjudpBJBNV5y5wf_k1i5Lz5UcICdYPSd_w.ttf", + "200": "http://fonts.gstatic.com/s/grenzegotisch/v10/Fh4hPjjqNDz1osh_jX9YfjudpBJBNV5y5wf_k1i5rz9UcICdYPSd_w.ttf", + "300": "http://fonts.gstatic.com/s/grenzegotisch/v10/Fh4hPjjqNDz1osh_jX9YfjudpBJBNV5y5wf_k1i5cT9UcICdYPSd_w.ttf", + "regular": "http://fonts.gstatic.com/s/grenzegotisch/v10/Fh4hPjjqNDz1osh_jX9YfjudpBJBNV5y5wf_k1i5Lz9UcICdYPSd_w.ttf", + "500": "http://fonts.gstatic.com/s/grenzegotisch/v10/Fh4hPjjqNDz1osh_jX9YfjudpBJBNV5y5wf_k1i5HT9UcICdYPSd_w.ttf", + "600": "http://fonts.gstatic.com/s/grenzegotisch/v10/Fh4hPjjqNDz1osh_jX9YfjudpBJBNV5y5wf_k1i58ThUcICdYPSd_w.ttf", + "700": "http://fonts.gstatic.com/s/grenzegotisch/v10/Fh4hPjjqNDz1osh_jX9YfjudpBJBNV5y5wf_k1i5yDhUcICdYPSd_w.ttf", + "800": "http://fonts.gstatic.com/s/grenzegotisch/v10/Fh4hPjjqNDz1osh_jX9YfjudpBJBNV5y5wf_k1i5rzhUcICdYPSd_w.ttf", + "900": "http://fonts.gstatic.com/s/grenzegotisch/v10/Fh4hPjjqNDz1osh_jX9YfjudpBJBNV5y5wf_k1i5hjhUcICdYPSd_w.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Grey Qo", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/greyqo/v5/BXRrvF_Nmv_TyXxNDOtQ9Wf0QcE.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Griffy", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/griffy/v19/FwZa7-ox2FQh9kfwSNSEwM2zpA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Gruppo", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/gruppo/v14/WwkfxPmzE06v_ZWFWXDAOIEQUQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Gudea", + "variants": [ + "regular", + "italic", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/gudea/v13/neIFzCqgsI0mp-CP9IGON7Ez.ttf", + "italic": "http://fonts.gstatic.com/s/gudea/v13/neILzCqgsI0mp9CN_oWsMqEzSJQ.ttf", + "700": "http://fonts.gstatic.com/s/gudea/v13/neIIzCqgsI0mp9gz26WGHK06UY30.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gugi", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/gugi/v11/A2BVn5dXywshVA6A9DEfgqM.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Gupter", + "variants": [ + "regular", + "500", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/gupter/v12/2-cm9JNmxJqPO1QUYZa_Wu_lpA.ttf", + "500": "http://fonts.gstatic.com/s/gupter/v12/2-cl9JNmxJqPO1Qslb-bUsT5rZhaZg.ttf", + "700": "http://fonts.gstatic.com/s/gupter/v12/2-cl9JNmxJqPO1Qs3bmbUsT5rZhaZg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gurajada", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/gurajada/v13/FwZY7-Qx308m-l-0Kd6A4sijpFu_.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Gwendolyn", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/gwendolyn/v3/qkBXXvoO_M3CSss-d7ee5JRLkAXbMQ.ttf", + "700": "http://fonts.gstatic.com/s/gwendolyn/v3/qkBSXvoO_M3CSss-d7emWLtvmC7HONiSFQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Habibi", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/habibi/v19/CSR-4zFWkuqcTTNCShJeZOYySQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Hachi Maru Pop", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/hachimarupop/v15/HI_TiYoRLqpLrEiMAuO9Ysfz7rW1EM_btd8u.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Hahmlet", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "korean", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/hahmlet/v7/BngXUXpCQ3nKpIo0TfPyfCdXfaeU4RhKOdjobsO-aVxn.ttf", + "200": "http://fonts.gstatic.com/s/hahmlet/v7/BngXUXpCQ3nKpIo0TfPyfCdXfaeU4RjKONjobsO-aVxn.ttf", + "300": "http://fonts.gstatic.com/s/hahmlet/v7/BngXUXpCQ3nKpIo0TfPyfCdXfaeU4RgUONjobsO-aVxn.ttf", + "regular": "http://fonts.gstatic.com/s/hahmlet/v7/BngXUXpCQ3nKpIo0TfPyfCdXfaeU4RhKONjobsO-aVxn.ttf", + "500": "http://fonts.gstatic.com/s/hahmlet/v7/BngXUXpCQ3nKpIo0TfPyfCdXfaeU4Rh4ONjobsO-aVxn.ttf", + "600": "http://fonts.gstatic.com/s/hahmlet/v7/BngXUXpCQ3nKpIo0TfPyfCdXfaeU4RiUP9jobsO-aVxn.ttf", + "700": "http://fonts.gstatic.com/s/hahmlet/v7/BngXUXpCQ3nKpIo0TfPyfCdXfaeU4RitP9jobsO-aVxn.ttf", + "800": "http://fonts.gstatic.com/s/hahmlet/v7/BngXUXpCQ3nKpIo0TfPyfCdXfaeU4RjKP9jobsO-aVxn.ttf", + "900": "http://fonts.gstatic.com/s/hahmlet/v7/BngXUXpCQ3nKpIo0TfPyfCdXfaeU4RjjP9jobsO-aVxn.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Halant", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "300": "http://fonts.gstatic.com/s/halant/v11/u-490qaujRI2Pbsvc_pCmwZqcwdRXg.ttf", + "regular": "http://fonts.gstatic.com/s/halant/v11/u-4-0qaujRI2PbsX39Jmky12eg.ttf", + "500": "http://fonts.gstatic.com/s/halant/v11/u-490qaujRI2PbsvK_tCmwZqcwdRXg.ttf", + "600": "http://fonts.gstatic.com/s/halant/v11/u-490qaujRI2PbsvB_xCmwZqcwdRXg.ttf", + "700": "http://fonts.gstatic.com/s/halant/v11/u-490qaujRI2PbsvY_1CmwZqcwdRXg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Hammersmith One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/hammersmithone/v15/qWcyB624q4L_C4jGQ9IK0O_dFlnbshsks4MRXw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Hanalei", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v21", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/hanalei/v21/E21n_dD8iufIjBRHXzgmVydREus.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Hanalei Fill", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/hanaleifill/v19/fC1mPYtObGbfyQznIaQzPQiMVwLBplm9aw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Handlee", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/handlee/v12/-F6xfjBsISg9aMakDmr6oilJ3ik.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Hanuman", + "variants": [ + "100", + "300", + "regular", + "700", + "900" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v21", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/hanuman/v21/VuJzdNvD15HhpJJBQMLdPKNiaRpFvg.ttf", + "300": "http://fonts.gstatic.com/s/hanuman/v21/VuJ0dNvD15HhpJJBQAr_HIlMZRNcp0o.ttf", + "regular": "http://fonts.gstatic.com/s/hanuman/v21/VuJxdNvD15HhpJJBeKbXOIFneRo.ttf", + "700": "http://fonts.gstatic.com/s/hanuman/v21/VuJ0dNvD15HhpJJBQBr4HIlMZRNcp0o.ttf", + "900": "http://fonts.gstatic.com/s/hanuman/v21/VuJ0dNvD15HhpJJBQCL6HIlMZRNcp0o.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Happy Monkey", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/happymonkey/v12/K2F2fZZcl-9SXwl5F_C4R_OABwD2bWqVjw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Harmattan", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2021-04-01", + "files": { + "regular": "http://fonts.gstatic.com/s/harmattan/v11/goksH6L2DkFvVvRp9XpTS0CjkP1Yog.ttf", + "700": "http://fonts.gstatic.com/s/harmattan/v11/gokpH6L2DkFvVvRp9Xpr92-HmNZEq6TTFw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Headland One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/headlandone/v13/yYLu0hHR2vKnp89Tk1TCq3Tx0PlTeZ3mJA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Heebo", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "hebrew", + "latin" + ], + "version": "v17", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/heebo/v17/NGSpv5_NC0k9P_v6ZUCbLRAHxK1EiS2cckOnz02SXQ.ttf", + "200": "http://fonts.gstatic.com/s/heebo/v17/NGSpv5_NC0k9P_v6ZUCbLRAHxK1ECSycckOnz02SXQ.ttf", + "300": "http://fonts.gstatic.com/s/heebo/v17/NGSpv5_NC0k9P_v6ZUCbLRAHxK1E1yycckOnz02SXQ.ttf", + "regular": "http://fonts.gstatic.com/s/heebo/v17/NGSpv5_NC0k9P_v6ZUCbLRAHxK1EiSycckOnz02SXQ.ttf", + "500": "http://fonts.gstatic.com/s/heebo/v17/NGSpv5_NC0k9P_v6ZUCbLRAHxK1EuyycckOnz02SXQ.ttf", + "600": "http://fonts.gstatic.com/s/heebo/v17/NGSpv5_NC0k9P_v6ZUCbLRAHxK1EVyucckOnz02SXQ.ttf", + "700": "http://fonts.gstatic.com/s/heebo/v17/NGSpv5_NC0k9P_v6ZUCbLRAHxK1EbiucckOnz02SXQ.ttf", + "800": "http://fonts.gstatic.com/s/heebo/v17/NGSpv5_NC0k9P_v6ZUCbLRAHxK1ECSucckOnz02SXQ.ttf", + "900": "http://fonts.gstatic.com/s/heebo/v17/NGSpv5_NC0k9P_v6ZUCbLRAHxK1EICucckOnz02SXQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Henny Penny", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/hennypenny/v15/wXKvE3UZookzsxz_kjGSfMQqt3M7tMDT.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Hepta Slab", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/heptaslab/v15/ea8JadoyU_jkHdalebHvyWVNdYoIsHe5HvkV5jfbY5B0NBkz.ttf", + "200": "http://fonts.gstatic.com/s/heptaslab/v15/ea8JadoyU_jkHdalebHvyWVNdYoIsHe5HvmV5zfbY5B0NBkz.ttf", + "300": "http://fonts.gstatic.com/s/heptaslab/v15/ea8JadoyU_jkHdalebHvyWVNdYoIsHe5HvlL5zfbY5B0NBkz.ttf", + "regular": "http://fonts.gstatic.com/s/heptaslab/v15/ea8JadoyU_jkHdalebHvyWVNdYoIsHe5HvkV5zfbY5B0NBkz.ttf", + "500": "http://fonts.gstatic.com/s/heptaslab/v15/ea8JadoyU_jkHdalebHvyWVNdYoIsHe5Hvkn5zfbY5B0NBkz.ttf", + "600": "http://fonts.gstatic.com/s/heptaslab/v15/ea8JadoyU_jkHdalebHvyWVNdYoIsHe5HvnL4DfbY5B0NBkz.ttf", + "700": "http://fonts.gstatic.com/s/heptaslab/v15/ea8JadoyU_jkHdalebHvyWVNdYoIsHe5Hvny4DfbY5B0NBkz.ttf", + "800": "http://fonts.gstatic.com/s/heptaslab/v15/ea8JadoyU_jkHdalebHvyWVNdYoIsHe5HvmV4DfbY5B0NBkz.ttf", + "900": "http://fonts.gstatic.com/s/heptaslab/v15/ea8JadoyU_jkHdalebHvyWVNdYoIsHe5Hvm84DfbY5B0NBkz.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Herr Von Muellerhoff", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/herrvonmuellerhoff/v13/WBL6rFjRZkREW8WqmCWYLgCkQKXb4CAft3c6_qJY3QPQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Hi Melody", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/himelody/v11/46ktlbP8Vnz0pJcqCTbEf29E31BBGA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Hina Mincho", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v6", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/hinamincho/v6/2sDaZGBRhpXa2Jjz5w5LAGW8KbkVZTHR.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Hind", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/hind/v15/5aU19_a8oxmIfMJaIRuYjDpf5Vw.ttf", + "regular": "http://fonts.gstatic.com/s/hind/v15/5aU69_a8oxmIRG5yBROzkDM.ttf", + "500": "http://fonts.gstatic.com/s/hind/v15/5aU19_a8oxmIfJpbIRuYjDpf5Vw.ttf", + "600": "http://fonts.gstatic.com/s/hind/v15/5aU19_a8oxmIfLZcIRuYjDpf5Vw.ttf", + "700": "http://fonts.gstatic.com/s/hind/v15/5aU19_a8oxmIfNJdIRuYjDpf5Vw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Hind Guntur", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "telugu" + ], + "version": "v10", + "lastModified": "2022-01-25", + "files": { + "300": "http://fonts.gstatic.com/s/hindguntur/v10/wXKyE3UZrok56nvamSuJd_yGn1czn9zaj5Ju.ttf", + "regular": "http://fonts.gstatic.com/s/hindguntur/v10/wXKvE3UZrok56nvamSuJd8Qqt3M7tMDT.ttf", + "500": "http://fonts.gstatic.com/s/hindguntur/v10/wXKyE3UZrok56nvamSuJd_zenlczn9zaj5Ju.ttf", + "600": "http://fonts.gstatic.com/s/hindguntur/v10/wXKyE3UZrok56nvamSuJd_zymVczn9zaj5Ju.ttf", + "700": "http://fonts.gstatic.com/s/hindguntur/v10/wXKyE3UZrok56nvamSuJd_yWmFczn9zaj5Ju.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Hind Madurai", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "tamil" + ], + "version": "v10", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/hindmadurai/v10/f0Xu0e2p98ZvDXdZQIOcpqjfXaUnecsoMJ0b_g.ttf", + "regular": "http://fonts.gstatic.com/s/hindmadurai/v10/f0Xx0e2p98ZvDXdZQIOcpqjn8Y0DceA0OQ.ttf", + "500": "http://fonts.gstatic.com/s/hindmadurai/v10/f0Xu0e2p98ZvDXdZQIOcpqjfBaQnecsoMJ0b_g.ttf", + "600": "http://fonts.gstatic.com/s/hindmadurai/v10/f0Xu0e2p98ZvDXdZQIOcpqjfKaMnecsoMJ0b_g.ttf", + "700": "http://fonts.gstatic.com/s/hindmadurai/v10/f0Xu0e2p98ZvDXdZQIOcpqjfTaInecsoMJ0b_g.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Hind Siliguri", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "bengali", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/hindsiliguri/v11/ijwOs5juQtsyLLR5jN4cxBEoRDf44uEfKiGvxts.ttf", + "regular": "http://fonts.gstatic.com/s/hindsiliguri/v11/ijwTs5juQtsyLLR5jN4cxBEofJvQxuk0Nig.ttf", + "500": "http://fonts.gstatic.com/s/hindsiliguri/v11/ijwOs5juQtsyLLR5jN4cxBEoRG_54uEfKiGvxts.ttf", + "600": "http://fonts.gstatic.com/s/hindsiliguri/v11/ijwOs5juQtsyLLR5jN4cxBEoREP-4uEfKiGvxts.ttf", + "700": "http://fonts.gstatic.com/s/hindsiliguri/v11/ijwOs5juQtsyLLR5jN4cxBEoRCf_4uEfKiGvxts.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Hind Vadodara", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "gujarati", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-25", + "files": { + "300": "http://fonts.gstatic.com/s/hindvadodara/v10/neIQzCKvrIcn5pbuuuriV9tTSDn3iXM0oSOL2Yw.ttf", + "regular": "http://fonts.gstatic.com/s/hindvadodara/v10/neINzCKvrIcn5pbuuuriV9tTcJXfrXsfvSo.ttf", + "500": "http://fonts.gstatic.com/s/hindvadodara/v10/neIQzCKvrIcn5pbuuuriV9tTSGH2iXM0oSOL2Yw.ttf", + "600": "http://fonts.gstatic.com/s/hindvadodara/v10/neIQzCKvrIcn5pbuuuriV9tTSE3xiXM0oSOL2Yw.ttf", + "700": "http://fonts.gstatic.com/s/hindvadodara/v10/neIQzCKvrIcn5pbuuuriV9tTSCnwiXM0oSOL2Yw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Holtwood One SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/holtwoodonesc/v14/yYLx0hLR0P-3vMFSk1TCq3Txg5B3cbb6LZttyg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Homemade Apple", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/homemadeapple/v16/Qw3EZQFXECDrI2q789EKQZJob3x9Vnksi4M7.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Homenaje", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/homenaje/v14/FwZY7-Q-xVAi_l-6Ld6A4sijpFu_.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Hurricane", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/hurricane/v3/pe0sMIuULZxTolZ5YldyAv2-C99ycg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "IBM Plex Mono", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6pfjptAgt5VM-kVkqdyU8n3kwq0n1hj-sNFQ.ttf", + "100italic": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6rfjptAgt5VM-kVkqdyU8n1ioStndlre4dFcFh.ttf", + "200": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6qfjptAgt5VM-kVkqdyU8n3uAL8ldPg-IUDNg.ttf", + "200italic": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6sfjptAgt5VM-kVkqdyU8n1ioSGlZFh8ARHNh4zg.ttf", + "300": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6qfjptAgt5VM-kVkqdyU8n3oQI8ldPg-IUDNg.ttf", + "300italic": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6sfjptAgt5VM-kVkqdyU8n1ioSflVFh8ARHNh4zg.ttf", + "regular": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F63fjptAgt5VM-kVkqdyU8n5igg1l9kn-s.ttf", + "italic": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6pfjptAgt5VM-kVkqdyU8n1ioq0n1hj-sNFQ.ttf", + "500": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6qfjptAgt5VM-kVkqdyU8n3twJ8ldPg-IUDNg.ttf", + "500italic": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6sfjptAgt5VM-kVkqdyU8n1ioSJlRFh8ARHNh4zg.ttf", + "600": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6qfjptAgt5VM-kVkqdyU8n3vAO8ldPg-IUDNg.ttf", + "600italic": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6sfjptAgt5VM-kVkqdyU8n1ioSClNFh8ARHNh4zg.ttf", + "700": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6qfjptAgt5VM-kVkqdyU8n3pQP8ldPg-IUDNg.ttf", + "700italic": "http://fonts.gstatic.com/s/ibmplexmono/v11/-F6sfjptAgt5VM-kVkqdyU8n1ioSblJFh8ARHNh4zg.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "IBM Plex Sans", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX-KVElMYYaJe8bpLHnCwDKjbLeEKxIedbzDw.ttf", + "100italic": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX8KVElMYYaJe8bpLHnCwDKhdTmdKZMW9PjD3N8.ttf", + "200": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX9KVElMYYaJe8bpLHnCwDKjR7_MIZmdd_qFmo.ttf", + "200italic": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX7KVElMYYaJe8bpLHnCwDKhdTm2Idscf3vBmpl8A.ttf", + "300": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX9KVElMYYaJe8bpLHnCwDKjXr8MIZmdd_qFmo.ttf", + "300italic": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX7KVElMYYaJe8bpLHnCwDKhdTmvIRscf3vBmpl8A.ttf", + "regular": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYXgKVElMYYaJe8bpLHnCwDKtdbUFI5NadY.ttf", + "italic": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX-KVElMYYaJe8bpLHnCwDKhdTeEKxIedbzDw.ttf", + "500": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX9KVElMYYaJe8bpLHnCwDKjSL9MIZmdd_qFmo.ttf", + "500italic": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX7KVElMYYaJe8bpLHnCwDKhdTm5IVscf3vBmpl8A.ttf", + "600": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX9KVElMYYaJe8bpLHnCwDKjQ76MIZmdd_qFmo.ttf", + "600italic": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX7KVElMYYaJe8bpLHnCwDKhdTmyIJscf3vBmpl8A.ttf", + "700": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX9KVElMYYaJe8bpLHnCwDKjWr7MIZmdd_qFmo.ttf", + "700italic": "http://fonts.gstatic.com/s/ibmplexsans/v13/zYX7KVElMYYaJe8bpLHnCwDKhdTmrINscf3vBmpl8A.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "IBM Plex Sans Arabic", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "arabic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/ibmplexsansarabic/v5/Qw3MZRtWPQCuHme67tEYUIx3Kh0PHR9N6YNe3PC5eMlAMg0.ttf", + "200": "http://fonts.gstatic.com/s/ibmplexsansarabic/v5/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YPy_dCTVsVJKxTs.ttf", + "300": "http://fonts.gstatic.com/s/ibmplexsansarabic/v5/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YOW_tCTVsVJKxTs.ttf", + "regular": "http://fonts.gstatic.com/s/ibmplexsansarabic/v5/Qw3CZRtWPQCuHme67tEYUIx3Kh0PHR9N6bs61vSbfdlA.ttf", + "500": "http://fonts.gstatic.com/s/ibmplexsansarabic/v5/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YPO_9CTVsVJKxTs.ttf", + "600": "http://fonts.gstatic.com/s/ibmplexsansarabic/v5/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YPi-NCTVsVJKxTs.ttf", + "700": "http://fonts.gstatic.com/s/ibmplexsansarabic/v5/Qw3NZRtWPQCuHme67tEYUIx3Kh0PHR9N6YOG-dCTVsVJKxTs.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "IBM Plex Sans Condensed", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8nN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHY7KyKvBgYsMDhM.ttf", + "100italic": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8hN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHYas8M_LhakJHhOgBg.ttf", + "200": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8gN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHY5m6Yvrr4cFFwq5.ttf", + "200italic": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8iN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHYas8GPqpYMnEhq5H1w.ttf", + "300": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8gN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHY4C6ovrr4cFFwq5.ttf", + "300italic": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8iN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHYas8AfppYMnEhq5H1w.ttf", + "regular": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8lN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHbauwq_jhJsM.ttf", + "italic": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8nN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHYasyKvBgYsMDhM.ttf", + "500": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8gN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHY5a64vrr4cFFwq5.ttf", + "500italic": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8iN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHYas8F_opYMnEhq5H1w.ttf", + "600": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8gN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHY527Ivrr4cFFwq5.ttf", + "600italic": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8iN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHYas8HPvpYMnEhq5H1w.ttf", + "700": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8gN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHY4S7Yvrr4cFFwq5.ttf", + "700italic": "http://fonts.gstatic.com/s/ibmplexsanscondensed/v11/Gg8iN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHYas8BfupYMnEhq5H1w.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "IBM Plex Sans Devanagari", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic-ext", + "devanagari", + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/ibmplexsansdevanagari/v5/XRXB3JCMvG4IDoS9SubXB6W-UX5iehIMBFR2-O_HMUjwUcjwCEQq.ttf", + "200": "http://fonts.gstatic.com/s/ibmplexsansdevanagari/v5/XRXA3JCMvG4IDoS9SubXB6W-UX5iehIMBFR2-O_HnWnQe-b8AV0z0w.ttf", + "300": "http://fonts.gstatic.com/s/ibmplexsansdevanagari/v5/XRXA3JCMvG4IDoS9SubXB6W-UX5iehIMBFR2-O_H-WrQe-b8AV0z0w.ttf", + "regular": "http://fonts.gstatic.com/s/ibmplexsansdevanagari/v5/XRXH3JCMvG4IDoS9SubXB6W-UX5iehIMBFR2-O__VUL0c83gCA.ttf", + "500": "http://fonts.gstatic.com/s/ibmplexsansdevanagari/v5/XRXA3JCMvG4IDoS9SubXB6W-UX5iehIMBFR2-O_HoWvQe-b8AV0z0w.ttf", + "600": "http://fonts.gstatic.com/s/ibmplexsansdevanagari/v5/XRXA3JCMvG4IDoS9SubXB6W-UX5iehIMBFR2-O_HjWzQe-b8AV0z0w.ttf", + "700": "http://fonts.gstatic.com/s/ibmplexsansdevanagari/v5/XRXA3JCMvG4IDoS9SubXB6W-UX5iehIMBFR2-O_H6W3Qe-b8AV0z0w.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "IBM Plex Sans Hebrew", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic-ext", + "hebrew", + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/ibmplexsanshebrew/v5/BCa4qYENg9Kw1mpLpO0bGM5lfHAAZHhDXEXB-l0VqDaM7C4.ttf", + "200": "http://fonts.gstatic.com/s/ibmplexsanshebrew/v5/BCa5qYENg9Kw1mpLpO0bGM5lfHAAZHhDXEVt230_hjqF9Tc2.ttf", + "300": "http://fonts.gstatic.com/s/ibmplexsanshebrew/v5/BCa5qYENg9Kw1mpLpO0bGM5lfHAAZHhDXEUJ2H0_hjqF9Tc2.ttf", + "regular": "http://fonts.gstatic.com/s/ibmplexsanshebrew/v5/BCa2qYENg9Kw1mpLpO0bGM5lfHAAZHhDXH2l8Fk3rSaM.ttf", + "500": "http://fonts.gstatic.com/s/ibmplexsanshebrew/v5/BCa5qYENg9Kw1mpLpO0bGM5lfHAAZHhDXEVR2X0_hjqF9Tc2.ttf", + "600": "http://fonts.gstatic.com/s/ibmplexsanshebrew/v5/BCa5qYENg9Kw1mpLpO0bGM5lfHAAZHhDXEV93n0_hjqF9Tc2.ttf", + "700": "http://fonts.gstatic.com/s/ibmplexsanshebrew/v5/BCa5qYENg9Kw1mpLpO0bGM5lfHAAZHhDXEUZ330_hjqF9Tc2.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "IBM Plex Sans KR", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "korean", + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/ibmplexsanskr/v5/vEFM2-VJISZe3O_rc3ZVYh4aTwNOyra_X5zCpMrMfA.ttf", + "200": "http://fonts.gstatic.com/s/ibmplexsanskr/v5/vEFN2-VJISZe3O_rc3ZVYh4aTwNOyhqef7bsqMPVZb4.ttf", + "300": "http://fonts.gstatic.com/s/ibmplexsanskr/v5/vEFN2-VJISZe3O_rc3ZVYh4aTwNOyn6df7bsqMPVZb4.ttf", + "regular": "http://fonts.gstatic.com/s/ibmplexsanskr/v5/vEFK2-VJISZe3O_rc3ZVYh4aTwNO8tK1W77HtMo.ttf", + "500": "http://fonts.gstatic.com/s/ibmplexsanskr/v5/vEFN2-VJISZe3O_rc3ZVYh4aTwNOyiacf7bsqMPVZb4.ttf", + "600": "http://fonts.gstatic.com/s/ibmplexsanskr/v5/vEFN2-VJISZe3O_rc3ZVYh4aTwNOygqbf7bsqMPVZb4.ttf", + "700": "http://fonts.gstatic.com/s/ibmplexsanskr/v5/vEFN2-VJISZe3O_rc3ZVYh4aTwNOym6af7bsqMPVZb4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "IBM Plex Sans Thai", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic-ext", + "latin", + "latin-ext", + "thai" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/ibmplexsansthai/v5/m8JNje1VVIzcq1HzJq2AEdo2Tj_qvLqEatYlR8ZKUqcX.ttf", + "200": "http://fonts.gstatic.com/s/ibmplexsansthai/v5/m8JMje1VVIzcq1HzJq2AEdo2Tj_qvLqExvcFbehGW74OXw.ttf", + "300": "http://fonts.gstatic.com/s/ibmplexsansthai/v5/m8JMje1VVIzcq1HzJq2AEdo2Tj_qvLqEovQFbehGW74OXw.ttf", + "regular": "http://fonts.gstatic.com/s/ibmplexsansthai/v5/m8JPje1VVIzcq1HzJq2AEdo2Tj_qvLq8DtwhZcNaUg.ttf", + "500": "http://fonts.gstatic.com/s/ibmplexsansthai/v5/m8JMje1VVIzcq1HzJq2AEdo2Tj_qvLqE-vUFbehGW74OXw.ttf", + "600": "http://fonts.gstatic.com/s/ibmplexsansthai/v5/m8JMje1VVIzcq1HzJq2AEdo2Tj_qvLqE1vIFbehGW74OXw.ttf", + "700": "http://fonts.gstatic.com/s/ibmplexsansthai/v5/m8JMje1VVIzcq1HzJq2AEdo2Tj_qvLqEsvMFbehGW74OXw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "IBM Plex Sans Thai Looped", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic-ext", + "latin", + "latin-ext", + "thai" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/ibmplexsansthailooped/v5/tss5AoJJRAhL3BTrK3r2xxbFhvKfyBB6l7hHT30L_HaKpHOtFCQ76Q.ttf", + "200": "http://fonts.gstatic.com/s/ibmplexsansthailooped/v5/tss6AoJJRAhL3BTrK3r2xxbFhvKfyBB6l7hHT30L_NqrhFmDGC0i8Cc.ttf", + "300": "http://fonts.gstatic.com/s/ibmplexsansthailooped/v5/tss6AoJJRAhL3BTrK3r2xxbFhvKfyBB6l7hHT30L_L6ohFmDGC0i8Cc.ttf", + "regular": "http://fonts.gstatic.com/s/ibmplexsansthailooped/v5/tss_AoJJRAhL3BTrK3r2xxbFhvKfyBB6l7hHT30LxBKAoFGoBCQ.ttf", + "500": "http://fonts.gstatic.com/s/ibmplexsansthailooped/v5/tss6AoJJRAhL3BTrK3r2xxbFhvKfyBB6l7hHT30L_OaphFmDGC0i8Cc.ttf", + "600": "http://fonts.gstatic.com/s/ibmplexsansthailooped/v5/tss6AoJJRAhL3BTrK3r2xxbFhvKfyBB6l7hHT30L_MquhFmDGC0i8Cc.ttf", + "700": "http://fonts.gstatic.com/s/ibmplexsansthailooped/v5/tss6AoJJRAhL3BTrK3r2xxbFhvKfyBB6l7hHT30L_K6vhFmDGC0i8Cc.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "IBM Plex Serif", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizBREVNn1dOx-zrZ2X3pZvkTi182zIZj1bIkNo.ttf", + "100italic": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizHREVNn1dOx-zrZ2X3pZvkTiUa41YTi3TNgNq55w.ttf", + "200": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizAREVNn1dOx-zrZ2X3pZvkTi3Q-hIzoVrBicOg.ttf", + "200italic": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizGREVNn1dOx-zrZ2X3pZvkTiUa4_oyq17jjNOg_oc.ttf", + "300": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizAREVNn1dOx-zrZ2X3pZvkTi20-RIzoVrBicOg.ttf", + "300italic": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizGREVNn1dOx-zrZ2X3pZvkTiUa454xq17jjNOg_oc.ttf", + "regular": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizDREVNn1dOx-zrZ2X3pZvkThUY0TY7ikbI.ttf", + "italic": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizBREVNn1dOx-zrZ2X3pZvkTiUa2zIZj1bIkNo.ttf", + "500": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizAREVNn1dOx-zrZ2X3pZvkTi3s-BIzoVrBicOg.ttf", + "500italic": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizGREVNn1dOx-zrZ2X3pZvkTiUa48Ywq17jjNOg_oc.ttf", + "600": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizAREVNn1dOx-zrZ2X3pZvkTi3A_xIzoVrBicOg.ttf", + "600italic": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizGREVNn1dOx-zrZ2X3pZvkTiUa4-o3q17jjNOg_oc.ttf", + "700": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizAREVNn1dOx-zrZ2X3pZvkTi2k_hIzoVrBicOg.ttf", + "700italic": "http://fonts.gstatic.com/s/ibmplexserif/v14/jizGREVNn1dOx-zrZ2X3pZvkTiUa4442q17jjNOg_oc.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "IM Fell DW Pica", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/imfelldwpica/v12/2sDGZGRQotv9nbn2qSl0TxXVYNw9ZAPUvi88MQ.ttf", + "italic": "http://fonts.gstatic.com/s/imfelldwpica/v12/2sDEZGRQotv9nbn2qSl0TxXVYNwNZgnQnCosMXm0.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "IM Fell DW Pica SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/imfelldwpicasc/v12/0ybjGCAu5PfqkvtGVU15aBhXz3EUrnTW-BiKEUiBGA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "IM Fell Double Pica", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v10", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/imfelldoublepica/v10/3XF2EqMq_94s9PeKF7Fg4gOKINyMtZ8rT0S1UL5Ayp0.ttf", + "italic": "http://fonts.gstatic.com/s/imfelldoublepica/v10/3XF0EqMq_94s9PeKF7Fg4gOKINyMtZ8rf0a_VJxF2p2G8g.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "IM Fell Double Pica SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/imfelldoublepicasc/v12/neIazDmuiMkFo6zj_sHpQ8teNbWlwBB_hXjJ4Y0Eeru2dGg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "IM Fell English", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v10", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/imfellenglish/v10/Ktk1ALSLW8zDe0rthJysWrnLsAz3F6mZVY9Y5w.ttf", + "italic": "http://fonts.gstatic.com/s/imfellenglish/v10/Ktk3ALSLW8zDe0rthJysWrnLsAzHFaOdd4pI59zg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "IM Fell English SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/imfellenglishsc/v12/a8IENpD3CDX-4zrWfr1VY879qFF05pZLO4gOg0shzA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "IM Fell French Canon", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/imfellfrenchcanon/v12/-F6ufiNtDWYfYc-tDiyiw08rrghJszkK6coVPt1ozoPz.ttf", + "italic": "http://fonts.gstatic.com/s/imfellfrenchcanon/v12/-F6gfiNtDWYfYc-tDiyiw08rrghJszkK6foXNNlKy5PzzrU.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "IM Fell French Canon SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/imfellfrenchcanonsc/v20/FBVmdCru5-ifcor2bgq9V89khWcmQghEURY7H3c0UBCVIVqH.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "IM Fell Great Primer", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/imfellgreatprimer/v12/bx6aNwSJtayYxOkbYFsT6hMsLzX7u85rJorXvDo3SQY1.ttf", + "italic": "http://fonts.gstatic.com/s/imfellgreatprimer/v12/bx6UNwSJtayYxOkbYFsT6hMsLzX7u85rJrrVtj4VTBY1N6U.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "IM Fell Great Primer SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/imfellgreatprimersc/v12/ga6daxBOxyt6sCqz3fjZCTFCTUDMHagsQKdDTLf9BXz0s8FG.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ibarra Real Nova", + "variants": [ + "regular", + "500", + "600", + "700", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/ibarrarealnova/v18/sZlSdQiA-DBIDCcaWtQzL4BZHoiDundw4ATyjed3EXdg5MDtVT9TWIvS.ttf", + "500": "http://fonts.gstatic.com/s/ibarrarealnova/v18/sZlSdQiA-DBIDCcaWtQzL4BZHoiDundw4ATyjed3EXdS5MDtVT9TWIvS.ttf", + "600": "http://fonts.gstatic.com/s/ibarrarealnova/v18/sZlSdQiA-DBIDCcaWtQzL4BZHoiDundw4ATyjed3EXe-48DtVT9TWIvS.ttf", + "700": "http://fonts.gstatic.com/s/ibarrarealnova/v18/sZlSdQiA-DBIDCcaWtQzL4BZHoiDundw4ATyjed3EXeH48DtVT9TWIvS.ttf", + "italic": "http://fonts.gstatic.com/s/ibarrarealnova/v18/sZlsdQiA-DBIDCcaWtQzL4BZHoiDkH5CH9yb5n3ZFmKopyiuXztxXZvSkTo.ttf", + "500italic": "http://fonts.gstatic.com/s/ibarrarealnova/v18/sZlsdQiA-DBIDCcaWtQzL4BZHoiDkH5CH9yb5n3ZFmKopxquXztxXZvSkTo.ttf", + "600italic": "http://fonts.gstatic.com/s/ibarrarealnova/v18/sZlsdQiA-DBIDCcaWtQzL4BZHoiDkH5CH9yb5n3ZFmKop_apXztxXZvSkTo.ttf", + "700italic": "http://fonts.gstatic.com/s/ibarrarealnova/v18/sZlsdQiA-DBIDCcaWtQzL4BZHoiDkH5CH9yb5n3ZFmKop8-pXztxXZvSkTo.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Iceberg", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/iceberg/v18/8QIJdijAiM7o-qnZuIgOq7jkAOw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Iceland", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/iceland/v14/rax9HiuFsdMNOnWPWKxGADBbg0s.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Imbue", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2021-03-19", + "files": { + "100": "http://fonts.gstatic.com/s/imbue/v9/RLpXK5P16Ki3fXhj5cvGrqjocPk4n-gVX3M93TnrnvhoP8iWfOsNNK-Q4xY.ttf", + "200": "http://fonts.gstatic.com/s/imbue/v9/RLpXK5P16Ki3fXhj5cvGrqjocPk4n-gVX3M93TnrnvhoP0iXfOsNNK-Q4xY.ttf", + "300": "http://fonts.gstatic.com/s/imbue/v9/RLpXK5P16Ki3fXhj5cvGrqjocPk4n-gVX3M93TnrnvhoP5aXfOsNNK-Q4xY.ttf", + "regular": "http://fonts.gstatic.com/s/imbue/v9/RLpXK5P16Ki3fXhj5cvGrqjocPk4n-gVX3M93TnrnvhoP8iXfOsNNK-Q4xY.ttf", + "500": "http://fonts.gstatic.com/s/imbue/v9/RLpXK5P16Ki3fXhj5cvGrqjocPk4n-gVX3M93TnrnvhoP_qXfOsNNK-Q4xY.ttf", + "600": "http://fonts.gstatic.com/s/imbue/v9/RLpXK5P16Ki3fXhj5cvGrqjocPk4n-gVX3M93TnrnvhoPxaQfOsNNK-Q4xY.ttf", + "700": "http://fonts.gstatic.com/s/imbue/v9/RLpXK5P16Ki3fXhj5cvGrqjocPk4n-gVX3M93TnrnvhoPy-QfOsNNK-Q4xY.ttf", + "800": "http://fonts.gstatic.com/s/imbue/v9/RLpXK5P16Ki3fXhj5cvGrqjocPk4n-gVX3M93TnrnvhoP0iQfOsNNK-Q4xY.ttf", + "900": "http://fonts.gstatic.com/s/imbue/v9/RLpXK5P16Ki3fXhj5cvGrqjocPk4n-gVX3M93TnrnvhoP2GQfOsNNK-Q4xY.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Imperial Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/imperialscript/v1/5DCPAKrpzy_H98IV2ISnZBbGrVNvPenlvttWNg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Imprima", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/imprima/v14/VEMxRoN7sY3yuy-7-oWHyDzktPo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Inconsolata", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v21", + "lastModified": "2021-01-30", + "files": { + "200": "http://fonts.gstatic.com/s/inconsolata/v21/QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwLYxYWI2qfdm7LppwU8aRr8lleY2co.ttf", + "300": "http://fonts.gstatic.com/s/inconsolata/v21/QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwLYxYWI2qfdm7Lpp9s8aRr8lleY2co.ttf", + "regular": "http://fonts.gstatic.com/s/inconsolata/v21/QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwLYxYWI2qfdm7Lpp4U8aRr8lleY2co.ttf", + "500": "http://fonts.gstatic.com/s/inconsolata/v21/QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwLYxYWI2qfdm7Lpp7c8aRr8lleY2co.ttf", + "600": "http://fonts.gstatic.com/s/inconsolata/v21/QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwLYxYWI2qfdm7Lpp1s7aRr8lleY2co.ttf", + "700": "http://fonts.gstatic.com/s/inconsolata/v21/QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwLYxYWI2qfdm7Lpp2I7aRr8lleY2co.ttf", + "800": "http://fonts.gstatic.com/s/inconsolata/v21/QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwLYxYWI2qfdm7LppwU7aRr8lleY2co.ttf", + "900": "http://fonts.gstatic.com/s/inconsolata/v21/QldgNThLqRwH-OJ1UHjlKENVzkWGVkL3GZQmAwLYxYWI2qfdm7Lppyw7aRr8lleY2co.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Inder", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/inder/v12/w8gUH2YoQe8_4vq6pw-P3U4O.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Indie Flower", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/indieflower/v16/m8JVjfNVeKWVnh3QMuKkFcZlbkGG1dKEDw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Inika", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/inika/v19/rnCm-x5X3QP-phTHRcc2s2XH.ttf", + "700": "http://fonts.gstatic.com/s/inika/v19/rnCr-x5X3QP-pix7auM-mHnOSOuk.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Inknut Antiqua", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "300": "http://fonts.gstatic.com/s/inknutantiqua/v12/Y4GRYax7VC4ot_qNB4nYpBdaKU2vwrj5bBoIYJNf.ttf", + "regular": "http://fonts.gstatic.com/s/inknutantiqua/v12/Y4GSYax7VC4ot_qNB4nYpBdaKXUD6pzxRwYB.ttf", + "500": "http://fonts.gstatic.com/s/inknutantiqua/v12/Y4GRYax7VC4ot_qNB4nYpBdaKU33w7j5bBoIYJNf.ttf", + "600": "http://fonts.gstatic.com/s/inknutantiqua/v12/Y4GRYax7VC4ot_qNB4nYpBdaKU3bxLj5bBoIYJNf.ttf", + "700": "http://fonts.gstatic.com/s/inknutantiqua/v12/Y4GRYax7VC4ot_qNB4nYpBdaKU2_xbj5bBoIYJNf.ttf", + "800": "http://fonts.gstatic.com/s/inknutantiqua/v12/Y4GRYax7VC4ot_qNB4nYpBdaKU2jxrj5bBoIYJNf.ttf", + "900": "http://fonts.gstatic.com/s/inknutantiqua/v12/Y4GRYax7VC4ot_qNB4nYpBdaKU2Hx7j5bBoIYJNf.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Inria Sans", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-05", + "files": { + "300": "http://fonts.gstatic.com/s/inriasans/v12/ptRPTiqXYfZMCOiVj9kQ3ELaDQtFqeY3fX4.ttf", + "300italic": "http://fonts.gstatic.com/s/inriasans/v12/ptRRTiqXYfZMCOiVj9kQ1OzAgQlPrcQybX4pQA.ttf", + "regular": "http://fonts.gstatic.com/s/inriasans/v12/ptRMTiqXYfZMCOiVj9kQ5O7yKQNute8.ttf", + "italic": "http://fonts.gstatic.com/s/inriasans/v12/ptROTiqXYfZMCOiVj9kQ1Oz4LSFrpe8uZA.ttf", + "700": "http://fonts.gstatic.com/s/inriasans/v12/ptRPTiqXYfZMCOiVj9kQ3FLdDQtFqeY3fX4.ttf", + "700italic": "http://fonts.gstatic.com/s/inriasans/v12/ptRRTiqXYfZMCOiVj9kQ1OzAkQ5PrcQybX4pQA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Inria Serif", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-05", + "files": { + "300": "http://fonts.gstatic.com/s/inriaserif/v12/fC14PYxPY3rXxEndZJAzN3wAVQjFhFyta3xN.ttf", + "300italic": "http://fonts.gstatic.com/s/inriaserif/v12/fC16PYxPY3rXxEndZJAzN3SuT4THjliPbmxN0_E.ttf", + "regular": "http://fonts.gstatic.com/s/inriaserif/v12/fC1lPYxPY3rXxEndZJAzN0SsfSzNr0Ck.ttf", + "italic": "http://fonts.gstatic.com/s/inriaserif/v12/fC1nPYxPY3rXxEndZJAzN3SudyjvqlCkcmU.ttf", + "700": "http://fonts.gstatic.com/s/inriaserif/v12/fC14PYxPY3rXxEndZJAzN3wQUgjFhFyta3xN.ttf", + "700italic": "http://fonts.gstatic.com/s/inriaserif/v12/fC16PYxPY3rXxEndZJAzN3SuT5TAjliPbmxN0_E.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Inspiration", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/inspiration/v1/x3dkckPPZa6L4wIg5cZOEvoGnSrlBBsy.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Inter", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2021-11-10", + "files": { + "100": "http://fonts.gstatic.com/s/inter/v7/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyeMZhrib2Bg-4.ttf", + "200": "http://fonts.gstatic.com/s/inter/v7/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuDyfMZhrib2Bg-4.ttf", + "300": "http://fonts.gstatic.com/s/inter/v7/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuOKfMZhrib2Bg-4.ttf", + "regular": "http://fonts.gstatic.com/s/inter/v7/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfMZhrib2Bg-4.ttf", + "500": "http://fonts.gstatic.com/s/inter/v7/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuI6fMZhrib2Bg-4.ttf", + "600": "http://fonts.gstatic.com/s/inter/v7/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuGKYMZhrib2Bg-4.ttf", + "700": "http://fonts.gstatic.com/s/inter/v7/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuFuYMZhrib2Bg-4.ttf", + "800": "http://fonts.gstatic.com/s/inter/v7/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuDyYMZhrib2Bg-4.ttf", + "900": "http://fonts.gstatic.com/s/inter/v7/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuBWYMZhrib2Bg-4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Irish Grover", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/irishgrover/v12/buExpoi6YtLz2QW7LA4flVgf-P5Oaiw4cw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Island Moments", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/islandmoments/v1/NaPBcZfVGvBdxIt7Ar0qzkXJF-TGIohbZ6SY.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Istok Web", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/istokweb/v18/3qTvojGmgSyUukBzKslZAWF-9kIIaQ.ttf", + "italic": "http://fonts.gstatic.com/s/istokweb/v18/3qTpojGmgSyUukBzKslpA2t61EcYaQ7F.ttf", + "700": "http://fonts.gstatic.com/s/istokweb/v18/3qTqojGmgSyUukBzKslhvU5a_mkUYBfcMw.ttf", + "700italic": "http://fonts.gstatic.com/s/istokweb/v18/3qT0ojGmgSyUukBzKslpA1PG-2MQQhLMMygN.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Italiana", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/italiana/v14/QldNNTtLsx4E__B0XTmRY31Wx7Vv.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Italianno", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/italianno/v14/dg4n_p3sv6gCJkwzT6Rnj5YpQwM-gg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Itim", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/itim/v8/0nknC9ziJOYewARKkc7ZdwU.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Jacques Francois", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/jacquesfrancois/v18/ZXu9e04ZvKeOOHIe1TMahbcIU2cgmcPqoeRWfbs.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Jacques Francois Shadow", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/jacquesfrancoisshadow/v19/KR1FBtOz8PKTMk-kqdkLVrvR0ECFrB6Pin-2_q8VsHuV5ULS.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Jaldi", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/jaldi/v10/or3sQ67z0_CI30NUZpD_B6g8.ttf", + "700": "http://fonts.gstatic.com/s/jaldi/v10/or3hQ67z0_CI33voSbT3LLQ1niPn.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "JetBrains Mono", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yK1jPVmUsaaDhw.ttf", + "200": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8SKxjPVmUsaaDhw.ttf", + "300": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8lqxjPVmUsaaDhw.ttf", + "regular": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPVmUsaaDhw.ttf", + "500": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8-qxjPVmUsaaDhw.ttf", + "600": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8FqtjPVmUsaaDhw.ttf", + "700": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8L6tjPVmUsaaDhw.ttf", + "800": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8SKtjPVmUsaaDhw.ttf", + "100italic": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDba2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO-Lf1OQk6OThxPA.ttf", + "200italic": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDba2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO8LflOQk6OThxPA.ttf", + "300italic": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDba2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO_VflOQk6OThxPA.ttf", + "italic": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDba2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO-LflOQk6OThxPA.ttf", + "500italic": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDba2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO-5flOQk6OThxPA.ttf", + "600italic": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDba2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO9VeVOQk6OThxPA.ttf", + "700italic": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDba2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO9seVOQk6OThxPA.ttf", + "800italic": "http://fonts.gstatic.com/s/jetbrainsmono/v11/tDba2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO8LeVOQk6OThxPA.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Jim Nightshade", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/jimnightshade/v18/PlIkFlu9Pb08Q8HLM1PxmB0g-OS4V3qKaMxD.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Jockey One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/jockeyone/v13/HTxpL2g2KjCFj4x8WI6ArIb7HYOk4xc.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Jolly Lodger", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/jollylodger/v18/BXRsvFTAh_bGkA1uQ48dlB3VWerT3ZyuqA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Jomhuria", + "variants": [ + "regular" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2021-03-24", + "files": { + "regular": "http://fonts.gstatic.com/s/jomhuria/v12/Dxxp8j-TMXf-llKur2b1MOGbC3Dh.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Jomolhari", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "tibetan" + ], + "version": "v12", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/jomolhari/v12/EvONzA1M1Iw_CBd2hsQCF1IZKq5INg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Josefin Sans", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v23", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjRXMFrLgTsQV0.ttf", + "200": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_LjQXMFrLgTsQV0.ttf", + "300": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_GbQXMFrLgTsQV0.ttf", + "regular": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQXMFrLgTsQV0.ttf", + "500": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_ArQXMFrLgTsQV0.ttf", + "600": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_ObXXMFrLgTsQV0.ttf", + "700": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_N_XXMFrLgTsQV0.ttf", + "100italic": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3JZQNVED7rKGKxtqIqX5EUCGZ2dIn0FyA96fCTtINhKibpUV3MEQ.ttf", + "200italic": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3JZQNVED7rKGKxtqIqX5EUCGZ2dIn0FyA96fCTNIJhKibpUV3MEQ.ttf", + "300italic": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3JZQNVED7rKGKxtqIqX5EUCGZ2dIn0FyA96fCT6oJhKibpUV3MEQ.ttf", + "italic": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3JZQNVED7rKGKxtqIqX5EUCGZ2dIn0FyA96fCTtIJhKibpUV3MEQ.ttf", + "500italic": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3JZQNVED7rKGKxtqIqX5EUCGZ2dIn0FyA96fCThoJhKibpUV3MEQ.ttf", + "600italic": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3JZQNVED7rKGKxtqIqX5EUCGZ2dIn0FyA96fCTaoVhKibpUV3MEQ.ttf", + "700italic": "http://fonts.gstatic.com/s/josefinsans/v23/Qw3JZQNVED7rKGKxtqIqX5EUCGZ2dIn0FyA96fCTU4VhKibpUV3MEQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Josefin Slab", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/josefinslab/v18/lW-swjwOK3Ps5GSJlNNkMalNpiZe_ldbOR4W71mtd3k3K6CcEyI.ttf", + "200": "http://fonts.gstatic.com/s/josefinslab/v18/lW-swjwOK3Ps5GSJlNNkMalNpiZe_ldbOR4W79msd3k3K6CcEyI.ttf", + "300": "http://fonts.gstatic.com/s/josefinslab/v18/lW-swjwOK3Ps5GSJlNNkMalNpiZe_ldbOR4W7wesd3k3K6CcEyI.ttf", + "regular": "http://fonts.gstatic.com/s/josefinslab/v18/lW-swjwOK3Ps5GSJlNNkMalNpiZe_ldbOR4W71msd3k3K6CcEyI.ttf", + "500": "http://fonts.gstatic.com/s/josefinslab/v18/lW-swjwOK3Ps5GSJlNNkMalNpiZe_ldbOR4W72usd3k3K6CcEyI.ttf", + "600": "http://fonts.gstatic.com/s/josefinslab/v18/lW-swjwOK3Ps5GSJlNNkMalNpiZe_ldbOR4W74erd3k3K6CcEyI.ttf", + "700": "http://fonts.gstatic.com/s/josefinslab/v18/lW-swjwOK3Ps5GSJlNNkMalNpiZe_ldbOR4W776rd3k3K6CcEyI.ttf", + "100italic": "http://fonts.gstatic.com/s/josefinslab/v18/lW-qwjwOK3Ps5GSJlNNkMalnrxShJj4wo7AR-pHvnzs9L4KZAyK43w.ttf", + "200italic": "http://fonts.gstatic.com/s/josefinslab/v18/lW-qwjwOK3Ps5GSJlNNkMalnrxShJj4wo7AR-pHvHzo9L4KZAyK43w.ttf", + "300italic": "http://fonts.gstatic.com/s/josefinslab/v18/lW-qwjwOK3Ps5GSJlNNkMalnrxShJj4wo7AR-pHvwTo9L4KZAyK43w.ttf", + "italic": "http://fonts.gstatic.com/s/josefinslab/v18/lW-qwjwOK3Ps5GSJlNNkMalnrxShJj4wo7AR-pHvnzo9L4KZAyK43w.ttf", + "500italic": "http://fonts.gstatic.com/s/josefinslab/v18/lW-qwjwOK3Ps5GSJlNNkMalnrxShJj4wo7AR-pHvrTo9L4KZAyK43w.ttf", + "600italic": "http://fonts.gstatic.com/s/josefinslab/v18/lW-qwjwOK3Ps5GSJlNNkMalnrxShJj4wo7AR-pHvQT09L4KZAyK43w.ttf", + "700italic": "http://fonts.gstatic.com/s/josefinslab/v18/lW-qwjwOK3Ps5GSJlNNkMalnrxShJj4wo7AR-pHveD09L4KZAyK43w.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Jost", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/jost/v12/92zPtBhPNqw79Ij1E865zBUv7myjJAVGPokMmuHL.ttf", + "200": "http://fonts.gstatic.com/s/jost/v12/92zPtBhPNqw79Ij1E865zBUv7mwjJQVGPokMmuHL.ttf", + "300": "http://fonts.gstatic.com/s/jost/v12/92zPtBhPNqw79Ij1E865zBUv7mz9JQVGPokMmuHL.ttf", + "regular": "http://fonts.gstatic.com/s/jost/v12/92zPtBhPNqw79Ij1E865zBUv7myjJQVGPokMmuHL.ttf", + "500": "http://fonts.gstatic.com/s/jost/v12/92zPtBhPNqw79Ij1E865zBUv7myRJQVGPokMmuHL.ttf", + "600": "http://fonts.gstatic.com/s/jost/v12/92zPtBhPNqw79Ij1E865zBUv7mx9IgVGPokMmuHL.ttf", + "700": "http://fonts.gstatic.com/s/jost/v12/92zPtBhPNqw79Ij1E865zBUv7mxEIgVGPokMmuHL.ttf", + "800": "http://fonts.gstatic.com/s/jost/v12/92zPtBhPNqw79Ij1E865zBUv7mwjIgVGPokMmuHL.ttf", + "900": "http://fonts.gstatic.com/s/jost/v12/92zPtBhPNqw79Ij1E865zBUv7mwKIgVGPokMmuHL.ttf", + "100italic": "http://fonts.gstatic.com/s/jost/v12/92zJtBhPNqw73oHH7BbQp4-B6XlrZu0ENI0un_HLMEo.ttf", + "200italic": "http://fonts.gstatic.com/s/jost/v12/92zJtBhPNqw73oHH7BbQp4-B6XlrZm0FNI0un_HLMEo.ttf", + "300italic": "http://fonts.gstatic.com/s/jost/v12/92zJtBhPNqw73oHH7BbQp4-B6XlrZrMFNI0un_HLMEo.ttf", + "italic": "http://fonts.gstatic.com/s/jost/v12/92zJtBhPNqw73oHH7BbQp4-B6XlrZu0FNI0un_HLMEo.ttf", + "500italic": "http://fonts.gstatic.com/s/jost/v12/92zJtBhPNqw73oHH7BbQp4-B6XlrZt8FNI0un_HLMEo.ttf", + "600italic": "http://fonts.gstatic.com/s/jost/v12/92zJtBhPNqw73oHH7BbQp4-B6XlrZjMCNI0un_HLMEo.ttf", + "700italic": "http://fonts.gstatic.com/s/jost/v12/92zJtBhPNqw73oHH7BbQp4-B6XlrZgoCNI0un_HLMEo.ttf", + "800italic": "http://fonts.gstatic.com/s/jost/v12/92zJtBhPNqw73oHH7BbQp4-B6XlrZm0CNI0un_HLMEo.ttf", + "900italic": "http://fonts.gstatic.com/s/jost/v12/92zJtBhPNqw73oHH7BbQp4-B6XlrZkQCNI0un_HLMEo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Joti One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/jotione/v19/Z9XVDmdJQAmWm9TwaYTe4u2El6GC.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Jua", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/jua/v11/co3KmW9ljjAjc-DZCsKgsg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Judson", + "variants": [ + "regular", + "italic", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/judson/v16/FeVRS0Fbvbc14VxRD7N01bV7kg.ttf", + "italic": "http://fonts.gstatic.com/s/judson/v16/FeVTS0Fbvbc14VxhDblw97BrknZf.ttf", + "700": "http://fonts.gstatic.com/s/judson/v16/FeVSS0Fbvbc14Vxps5xQ3Z5nm29Gww.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Julee", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/julee/v20/TuGfUVB3RpZPQ6ZLodgzydtk.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Julius Sans One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/juliussansone/v12/1Pt2g8TAX_SGgBGUi0tGOYEga5W-xXEW6aGXHw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Junge", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/junge/v18/gokgH670Gl1lUqAdvhB7SnKm.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Jura", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "kayah-li", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/jura/v22/z7NOdRfiaC4Vd8hhoPzfb5vBTP0D7auhTfmrH_rt.ttf", + "regular": "http://fonts.gstatic.com/s/jura/v22/z7NOdRfiaC4Vd8hhoPzfb5vBTP1d7auhTfmrH_rt.ttf", + "500": "http://fonts.gstatic.com/s/jura/v22/z7NOdRfiaC4Vd8hhoPzfb5vBTP1v7auhTfmrH_rt.ttf", + "600": "http://fonts.gstatic.com/s/jura/v22/z7NOdRfiaC4Vd8hhoPzfb5vBTP2D6quhTfmrH_rt.ttf", + "700": "http://fonts.gstatic.com/s/jura/v22/z7NOdRfiaC4Vd8hhoPzfb5vBTP266quhTfmrH_rt.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Just Another Hand", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/justanotherhand/v17/845CNN4-AJyIGvIou-6yJKyptyOpOcr_BmmlS5aw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Just Me Again Down Here", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v22", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/justmeagaindownhere/v22/MwQmbgXtz-Wc6RUEGNMc0QpRrfUh2hSdBBMoAuwHvqDwc_fg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "K2D", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v7", + "lastModified": "2022-01-13", + "files": { + "100": "http://fonts.gstatic.com/s/k2d/v7/J7aRnpF2V0ErE6UpvrIw74NL.ttf", + "100italic": "http://fonts.gstatic.com/s/k2d/v7/J7afnpF2V0EjdZ1NtLYS6pNLAjk.ttf", + "200": "http://fonts.gstatic.com/s/k2d/v7/J7aenpF2V0Erv4QJlJw85ppSGw.ttf", + "200italic": "http://fonts.gstatic.com/s/k2d/v7/J7acnpF2V0EjdZ3hlZY4xJ9CGyAa.ttf", + "300": "http://fonts.gstatic.com/s/k2d/v7/J7aenpF2V0Er24cJlJw85ppSGw.ttf", + "300italic": "http://fonts.gstatic.com/s/k2d/v7/J7acnpF2V0EjdZ2FlpY4xJ9CGyAa.ttf", + "regular": "http://fonts.gstatic.com/s/k2d/v7/J7aTnpF2V0ETd68tnLcg7w.ttf", + "italic": "http://fonts.gstatic.com/s/k2d/v7/J7aRnpF2V0EjdaUpvrIw74NL.ttf", + "500": "http://fonts.gstatic.com/s/k2d/v7/J7aenpF2V0Erg4YJlJw85ppSGw.ttf", + "500italic": "http://fonts.gstatic.com/s/k2d/v7/J7acnpF2V0EjdZ3dl5Y4xJ9CGyAa.ttf", + "600": "http://fonts.gstatic.com/s/k2d/v7/J7aenpF2V0Err4EJlJw85ppSGw.ttf", + "600italic": "http://fonts.gstatic.com/s/k2d/v7/J7acnpF2V0EjdZ3xkJY4xJ9CGyAa.ttf", + "700": "http://fonts.gstatic.com/s/k2d/v7/J7aenpF2V0Ery4AJlJw85ppSGw.ttf", + "700italic": "http://fonts.gstatic.com/s/k2d/v7/J7acnpF2V0EjdZ2VkZY4xJ9CGyAa.ttf", + "800": "http://fonts.gstatic.com/s/k2d/v7/J7aenpF2V0Er14MJlJw85ppSGw.ttf", + "800italic": "http://fonts.gstatic.com/s/k2d/v7/J7acnpF2V0EjdZ2JkpY4xJ9CGyAa.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kadwa", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin" + ], + "version": "v8", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/kadwa/v8/rnCm-x5V0g7iphTHRcc2s2XH.ttf", + "700": "http://fonts.gstatic.com/s/kadwa/v8/rnCr-x5V0g7ipix7auM-mHnOSOuk.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kaisei Decol", + "variants": [ + "regular", + "500", + "700" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v6", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/kaiseidecol/v6/bMrwmSqP45sidWf3QmfFW6iyW1EP22OjoA.ttf", + "500": "http://fonts.gstatic.com/s/kaiseidecol/v6/bMrvmSqP45sidWf3QmfFW6iKr3gr00i_qb57kA.ttf", + "700": "http://fonts.gstatic.com/s/kaiseidecol/v6/bMrvmSqP45sidWf3QmfFW6iK534r00i_qb57kA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kaisei HarunoUmi", + "variants": [ + "regular", + "500", + "700" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v6", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/kaiseiharunoumi/v6/HI_RiZQSLqBQoAHhK_C6N_nzy_jcGsv5sM8u3mk.ttf", + "500": "http://fonts.gstatic.com/s/kaiseiharunoumi/v6/HI_WiZQSLqBQoAHhK_C6N_nzy_jcIj_QlMcFwmC9FAU.ttf", + "700": "http://fonts.gstatic.com/s/kaiseiharunoumi/v6/HI_WiZQSLqBQoAHhK_C6N_nzy_jcInfWlMcFwmC9FAU.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kaisei Opti", + "variants": [ + "regular", + "500", + "700" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v6", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/kaiseiopti/v6/QldKNThJphYb8_g6c2nlIFle7KlmxuHx.ttf", + "500": "http://fonts.gstatic.com/s/kaiseiopti/v6/QldXNThJphYb8_g6c2nlIGGqxY1u7f34DYwn.ttf", + "700": "http://fonts.gstatic.com/s/kaiseiopti/v6/QldXNThJphYb8_g6c2nlIGHiw41u7f34DYwn.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kaisei Tokumin", + "variants": [ + "regular", + "500", + "700", + "800" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v6", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/kaiseitokumin/v6/Gg8sN5wdZg7xCwuMsylww2ZiQkJf1l0pj946.ttf", + "500": "http://fonts.gstatic.com/s/kaiseitokumin/v6/Gg8vN5wdZg7xCwuMsylww2ZiQnqr_3khpMIzeI6v.ttf", + "700": "http://fonts.gstatic.com/s/kaiseitokumin/v6/Gg8vN5wdZg7xCwuMsylww2ZiQnrj-XkhpMIzeI6v.ttf", + "800": "http://fonts.gstatic.com/s/kaiseitokumin/v6/Gg8vN5wdZg7xCwuMsylww2ZiQnr_-nkhpMIzeI6v.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kalam", + "variants": [ + "300", + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/kalam/v15/YA9Qr0Wd4kDdMtD6GgLLmCUItqGt.ttf", + "regular": "http://fonts.gstatic.com/s/kalam/v15/YA9dr0Wd4kDdMuhWMibDszkB.ttf", + "700": "http://fonts.gstatic.com/s/kalam/v15/YA9Qr0Wd4kDdMtDqHQLLmCUItqGt.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Kameron", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/kameron/v11/vm82dR7vXErQxuznsL4wL-XIYH8.ttf", + "700": "http://fonts.gstatic.com/s/kameron/v11/vm8zdR7vXErQxuzniAIfC-3jfHb--NY.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kanit", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/kanit/v11/nKKX-Go6G5tXcr72GwWKcaxALFs.ttf", + "100italic": "http://fonts.gstatic.com/s/kanit/v11/nKKV-Go6G5tXcraQI2GAdY5FPFtrGw.ttf", + "200": "http://fonts.gstatic.com/s/kanit/v11/nKKU-Go6G5tXcr5aOiWgX6BJNUJy.ttf", + "200italic": "http://fonts.gstatic.com/s/kanit/v11/nKKS-Go6G5tXcraQI82hVaRrMFJyAu4.ttf", + "300": "http://fonts.gstatic.com/s/kanit/v11/nKKU-Go6G5tXcr4-OSWgX6BJNUJy.ttf", + "300italic": "http://fonts.gstatic.com/s/kanit/v11/nKKS-Go6G5tXcraQI6miVaRrMFJyAu4.ttf", + "regular": "http://fonts.gstatic.com/s/kanit/v11/nKKZ-Go6G5tXcoaSEQGodLxA.ttf", + "italic": "http://fonts.gstatic.com/s/kanit/v11/nKKX-Go6G5tXcraQGwWKcaxALFs.ttf", + "500": "http://fonts.gstatic.com/s/kanit/v11/nKKU-Go6G5tXcr5mOCWgX6BJNUJy.ttf", + "500italic": "http://fonts.gstatic.com/s/kanit/v11/nKKS-Go6G5tXcraQI_GjVaRrMFJyAu4.ttf", + "600": "http://fonts.gstatic.com/s/kanit/v11/nKKU-Go6G5tXcr5KPyWgX6BJNUJy.ttf", + "600italic": "http://fonts.gstatic.com/s/kanit/v11/nKKS-Go6G5tXcraQI92kVaRrMFJyAu4.ttf", + "700": "http://fonts.gstatic.com/s/kanit/v11/nKKU-Go6G5tXcr4uPiWgX6BJNUJy.ttf", + "700italic": "http://fonts.gstatic.com/s/kanit/v11/nKKS-Go6G5tXcraQI7mlVaRrMFJyAu4.ttf", + "800": "http://fonts.gstatic.com/s/kanit/v11/nKKU-Go6G5tXcr4yPSWgX6BJNUJy.ttf", + "800italic": "http://fonts.gstatic.com/s/kanit/v11/nKKS-Go6G5tXcraQI6WmVaRrMFJyAu4.ttf", + "900": "http://fonts.gstatic.com/s/kanit/v11/nKKU-Go6G5tXcr4WPCWgX6BJNUJy.ttf", + "900italic": "http://fonts.gstatic.com/s/kanit/v11/nKKS-Go6G5tXcraQI4GnVaRrMFJyAu4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kantumruy", + "variants": [ + "300", + "regular", + "700" + ], + "subsets": [ + "khmer" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "300": "http://fonts.gstatic.com/s/kantumruy/v19/syk0-yJ0m7wyVb-f4FOPUtDlpn-UJ1H6Uw.ttf", + "regular": "http://fonts.gstatic.com/s/kantumruy/v19/sykx-yJ0m7wyVb-f4FO3_vjBrlSILg.ttf", + "700": "http://fonts.gstatic.com/s/kantumruy/v19/syk0-yJ0m7wyVb-f4FOPQtflpn-UJ1H6Uw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Karantina", + "variants": [ + "300", + "regular", + "700" + ], + "subsets": [ + "hebrew", + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-05", + "files": { + "300": "http://fonts.gstatic.com/s/karantina/v8/buExpo24ccnh31GVMABxXCgf-P5Oaiw4cw.ttf", + "regular": "http://fonts.gstatic.com/s/karantina/v8/buE0po24ccnh31GVMABJ8AA78NVSYw.ttf", + "700": "http://fonts.gstatic.com/s/karantina/v8/buExpo24ccnh31GVMABxTC8f-P5Oaiw4cw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Karla", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v21", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/karla/v21/qkBIXvYC6trAT55ZBi1ueQVIjQTDeJqqFENLR7fHGw.ttf", + "300": "http://fonts.gstatic.com/s/karla/v21/qkBIXvYC6trAT55ZBi1ueQVIjQTDppqqFENLR7fHGw.ttf", + "regular": "http://fonts.gstatic.com/s/karla/v21/qkBIXvYC6trAT55ZBi1ueQVIjQTD-JqqFENLR7fHGw.ttf", + "500": "http://fonts.gstatic.com/s/karla/v21/qkBIXvYC6trAT55ZBi1ueQVIjQTDypqqFENLR7fHGw.ttf", + "600": "http://fonts.gstatic.com/s/karla/v21/qkBIXvYC6trAT55ZBi1ueQVIjQTDJp2qFENLR7fHGw.ttf", + "700": "http://fonts.gstatic.com/s/karla/v21/qkBIXvYC6trAT55ZBi1ueQVIjQTDH52qFENLR7fHGw.ttf", + "800": "http://fonts.gstatic.com/s/karla/v21/qkBIXvYC6trAT55ZBi1ueQVIjQTDeJ2qFENLR7fHGw.ttf", + "200italic": "http://fonts.gstatic.com/s/karla/v21/qkBKXvYC6trAT7RQNNK2EG7SIwPWMNnCV0lPZbLXGxGR.ttf", + "300italic": "http://fonts.gstatic.com/s/karla/v21/qkBKXvYC6trAT7RQNNK2EG7SIwPWMNkcV0lPZbLXGxGR.ttf", + "italic": "http://fonts.gstatic.com/s/karla/v21/qkBKXvYC6trAT7RQNNK2EG7SIwPWMNlCV0lPZbLXGxGR.ttf", + "500italic": "http://fonts.gstatic.com/s/karla/v21/qkBKXvYC6trAT7RQNNK2EG7SIwPWMNlwV0lPZbLXGxGR.ttf", + "600italic": "http://fonts.gstatic.com/s/karla/v21/qkBKXvYC6trAT7RQNNK2EG7SIwPWMNmcUElPZbLXGxGR.ttf", + "700italic": "http://fonts.gstatic.com/s/karla/v21/qkBKXvYC6trAT7RQNNK2EG7SIwPWMNmlUElPZbLXGxGR.ttf", + "800italic": "http://fonts.gstatic.com/s/karla/v21/qkBKXvYC6trAT7RQNNK2EG7SIwPWMNnCUElPZbLXGxGR.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Karma", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "300": "http://fonts.gstatic.com/s/karma/v14/va9F4kzAzMZRGLjDY8Z_uqzGQC_-.ttf", + "regular": "http://fonts.gstatic.com/s/karma/v14/va9I4kzAzMZRGIBvS-J3kbDP.ttf", + "500": "http://fonts.gstatic.com/s/karma/v14/va9F4kzAzMZRGLibYsZ_uqzGQC_-.ttf", + "600": "http://fonts.gstatic.com/s/karma/v14/va9F4kzAzMZRGLi3ZcZ_uqzGQC_-.ttf", + "700": "http://fonts.gstatic.com/s/karma/v14/va9F4kzAzMZRGLjTZMZ_uqzGQC_-.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Katibeh", + "variants": [ + "regular" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/katibeh/v15/ZGjXol5MQJog4bxDaC1RVDNdGDs.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Kaushan Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/kaushanscript/v13/vm8vdRfvXFLG3OLnsO15WYS5DF7_ytN3M48a.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Kavivanar", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "tamil" + ], + "version": "v16", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/kavivanar/v16/o-0IIpQgyXYSwhxP7_Jb4j5Ba_2c7A.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Kavoon", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/kavoon/v19/pxiFyp4_scRYhlU4NLr6f1pdEQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Kdam Thmor", + "variants": [ + "regular" + ], + "subsets": [ + "khmer" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/kdamthmor/v19/MwQzbhjs3veF6QwJVf0JkGMViblPtXs.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Keania One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/keaniaone/v18/zOL54pXJk65E8pXardnuycRuv-hHkOs.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Kelly Slab", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/kellyslab/v11/-W_7XJX0Rz3cxUnJC5t6TkMBf50kbiM.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Kenia", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/kenia/v22/jizURE5PuHQH9qCONUGswfGM.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Khand", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "300": "http://fonts.gstatic.com/s/khand/v12/TwMN-IINQlQQ0bL5cFE3ZwaH__-C.ttf", + "regular": "http://fonts.gstatic.com/s/khand/v12/TwMA-IINQlQQ0YpVWHU_TBqO.ttf", + "500": "http://fonts.gstatic.com/s/khand/v12/TwMN-IINQlQQ0bKhcVE3ZwaH__-C.ttf", + "600": "http://fonts.gstatic.com/s/khand/v12/TwMN-IINQlQQ0bKNdlE3ZwaH__-C.ttf", + "700": "http://fonts.gstatic.com/s/khand/v12/TwMN-IINQlQQ0bLpd1E3ZwaH__-C.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Khmer", + "variants": [ + "regular" + ], + "subsets": [ + "khmer" + ], + "version": "v23", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/khmer/v23/MjQImit_vPPwpF-BpN2EeYmD.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Khula", + "variants": [ + "300", + "regular", + "600", + "700", + "800" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-25", + "files": { + "300": "http://fonts.gstatic.com/s/khula/v10/OpNPnoEOns3V7G-ljCvUrC59XwXD.ttf", + "regular": "http://fonts.gstatic.com/s/khula/v10/OpNCnoEOns3V7FcJpA_chzJ0.ttf", + "600": "http://fonts.gstatic.com/s/khula/v10/OpNPnoEOns3V7G_RiivUrC59XwXD.ttf", + "700": "http://fonts.gstatic.com/s/khula/v10/OpNPnoEOns3V7G-1iyvUrC59XwXD.ttf", + "800": "http://fonts.gstatic.com/s/khula/v10/OpNPnoEOns3V7G-piCvUrC59XwXD.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kings", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/kings/v3/8AtnGsK4O5CYXU_Iq6GSPaHS.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Kirang Haerang", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/kiranghaerang/v18/E21-_dn_gvvIjhYON1lpIU4-bcqvWPaJq4no.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Kite One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/kiteone/v18/70lQu7shLnA_E02vyq1b6HnGO4uA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kiwi Maru", + "variants": [ + "300", + "regular", + "500" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/kiwimaru/v12/R70djykGkuuDep-hRg6gNCi0Vxn9R5ShnA.ttf", + "regular": "http://fonts.gstatic.com/s/kiwimaru/v12/R70YjykGkuuDep-hRg6YmACQXzLhTg.ttf", + "500": "http://fonts.gstatic.com/s/kiwimaru/v12/R70djykGkuuDep-hRg6gbCm0Vxn9R5ShnA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Klee One", + "variants": [ + "regular", + "600" + ], + "subsets": [ + "cyrillic", + "greek-ext", + "japanese", + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/kleeone/v5/LDIxapCLNRc6A8oT4q4AOeekWPrP.ttf", + "600": "http://fonts.gstatic.com/s/kleeone/v5/LDI2apCLNRc6A8oT4pbYF8Osc-bGkqIw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Knewave", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/knewave/v12/sykz-yx0lLcxQaSItSq9-trEvlQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "KoHo", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "200": "http://fonts.gstatic.com/s/koho/v14/K2FxfZ5fmddNPuE1WJ75JoKhHys.ttf", + "200italic": "http://fonts.gstatic.com/s/koho/v14/K2FzfZ5fmddNNisssJ_zIqCkDyvqZA.ttf", + "300": "http://fonts.gstatic.com/s/koho/v14/K2FxfZ5fmddNPoU2WJ75JoKhHys.ttf", + "300italic": "http://fonts.gstatic.com/s/koho/v14/K2FzfZ5fmddNNiss1JzzIqCkDyvqZA.ttf", + "regular": "http://fonts.gstatic.com/s/koho/v14/K2F-fZ5fmddNBikefJbSOos.ttf", + "italic": "http://fonts.gstatic.com/s/koho/v14/K2FwfZ5fmddNNisUeLTXKou4Bg.ttf", + "500": "http://fonts.gstatic.com/s/koho/v14/K2FxfZ5fmddNPt03WJ75JoKhHys.ttf", + "500italic": "http://fonts.gstatic.com/s/koho/v14/K2FzfZ5fmddNNissjJ3zIqCkDyvqZA.ttf", + "600": "http://fonts.gstatic.com/s/koho/v14/K2FxfZ5fmddNPvEwWJ75JoKhHys.ttf", + "600italic": "http://fonts.gstatic.com/s/koho/v14/K2FzfZ5fmddNNissoJrzIqCkDyvqZA.ttf", + "700": "http://fonts.gstatic.com/s/koho/v14/K2FxfZ5fmddNPpUxWJ75JoKhHys.ttf", + "700italic": "http://fonts.gstatic.com/s/koho/v14/K2FzfZ5fmddNNissxJvzIqCkDyvqZA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kodchasan", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "200": "http://fonts.gstatic.com/s/kodchasan/v14/1cX0aUPOAJv9sG4I-DJeR1Cggeqo3eMeoA.ttf", + "200italic": "http://fonts.gstatic.com/s/kodchasan/v14/1cXqaUPOAJv9sG4I-DJWjUlIgOCs_-YOoIgN.ttf", + "300": "http://fonts.gstatic.com/s/kodchasan/v14/1cX0aUPOAJv9sG4I-DJeI1Oggeqo3eMeoA.ttf", + "300italic": "http://fonts.gstatic.com/s/kodchasan/v14/1cXqaUPOAJv9sG4I-DJWjUksg-Cs_-YOoIgN.ttf", + "regular": "http://fonts.gstatic.com/s/kodchasan/v14/1cXxaUPOAJv9sG4I-DJmj3uEicG01A.ttf", + "italic": "http://fonts.gstatic.com/s/kodchasan/v14/1cX3aUPOAJv9sG4I-DJWjXGAq8Sk1PoH.ttf", + "500": "http://fonts.gstatic.com/s/kodchasan/v14/1cX0aUPOAJv9sG4I-DJee1Kggeqo3eMeoA.ttf", + "500italic": "http://fonts.gstatic.com/s/kodchasan/v14/1cXqaUPOAJv9sG4I-DJWjUl0guCs_-YOoIgN.ttf", + "600": "http://fonts.gstatic.com/s/kodchasan/v14/1cX0aUPOAJv9sG4I-DJeV1Wggeqo3eMeoA.ttf", + "600italic": "http://fonts.gstatic.com/s/kodchasan/v14/1cXqaUPOAJv9sG4I-DJWjUlYheCs_-YOoIgN.ttf", + "700": "http://fonts.gstatic.com/s/kodchasan/v14/1cX0aUPOAJv9sG4I-DJeM1Sggeqo3eMeoA.ttf", + "700italic": "http://fonts.gstatic.com/s/kodchasan/v14/1cXqaUPOAJv9sG4I-DJWjUk8hOCs_-YOoIgN.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Koh Santepheap", + "variants": [ + "100", + "300", + "regular", + "700", + "900" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v7", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/kohsantepheap/v7/gNMfW3p6SJbwyGj2rBZyeOrTjNuFHVyTtjNJUWU.ttf", + "300": "http://fonts.gstatic.com/s/kohsantepheap/v7/gNMeW3p6SJbwyGj2rBZyeOrTjNtNP3y5mD9ASHz5.ttf", + "regular": "http://fonts.gstatic.com/s/kohsantepheap/v7/gNMdW3p6SJbwyGj2rBZyeOrTjOPhF1ixsyNJ.ttf", + "700": "http://fonts.gstatic.com/s/kohsantepheap/v7/gNMeW3p6SJbwyGj2rBZyeOrTjNtdOHy5mD9ASHz5.ttf", + "900": "http://fonts.gstatic.com/s/kohsantepheap/v7/gNMeW3p6SJbwyGj2rBZyeOrTjNtlOny5mD9ASHz5.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Kolker Brush", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/kolkerbrush/v1/iJWDBXWRZjfKWdvmzwvvog3-7KJ6x8qNUQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Kosugi", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2021-11-10", + "files": { + "regular": "http://fonts.gstatic.com/s/kosugi/v11/pxiFyp4_v8FCjlI4NLr6f1pdEQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kosugi Maru", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2021-11-10", + "files": { + "regular": "http://fonts.gstatic.com/s/kosugimaru/v11/0nksC9PgP_wGh21A2KeqGiTqivr9iBq_.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kotta One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/kottaone/v18/S6u_w41LXzPc_jlfNWqPHA3s5dwt7w.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Koulen", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/koulen/v23/AMOQz46as3KIBPeWgnA9kuYMUg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Kranky", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/kranky/v13/hESw6XVgJzlPsFnMpheEZo_H_w.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Kreon", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v30", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/kreon/v30/t5t9IRIUKY-TFF_LW5lnMR3v2DnvPNimejUfp2dWNg.ttf", + "regular": "http://fonts.gstatic.com/s/kreon/v30/t5t9IRIUKY-TFF_LW5lnMR3v2DnvYtimejUfp2dWNg.ttf", + "500": "http://fonts.gstatic.com/s/kreon/v30/t5t9IRIUKY-TFF_LW5lnMR3v2DnvUNimejUfp2dWNg.ttf", + "600": "http://fonts.gstatic.com/s/kreon/v30/t5t9IRIUKY-TFF_LW5lnMR3v2DnvvN-mejUfp2dWNg.ttf", + "700": "http://fonts.gstatic.com/s/kreon/v30/t5t9IRIUKY-TFF_LW5lnMR3v2Dnvhd-mejUfp2dWNg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kristi", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/kristi/v15/uK_y4ricdeU6zwdRCh0TMv6EXw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Krona One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/kronaone/v12/jAnEgHdjHcjgfIb1ZcUCMY-h3cWkWg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Krub", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v7", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/krub/v7/sZlEdRyC6CRYZo47KLF4R6gWaf8.ttf", + "200italic": "http://fonts.gstatic.com/s/krub/v7/sZlGdRyC6CRYbkQiwLByQ4oTef_6gQ.ttf", + "300": "http://fonts.gstatic.com/s/krub/v7/sZlEdRyC6CRYZuo4KLF4R6gWaf8.ttf", + "300italic": "http://fonts.gstatic.com/s/krub/v7/sZlGdRyC6CRYbkQipLNyQ4oTef_6gQ.ttf", + "regular": "http://fonts.gstatic.com/s/krub/v7/sZlLdRyC6CRYXkYQDLlTW6E.ttf", + "italic": "http://fonts.gstatic.com/s/krub/v7/sZlFdRyC6CRYbkQaCJtWS6EPcA.ttf", + "500": "http://fonts.gstatic.com/s/krub/v7/sZlEdRyC6CRYZrI5KLF4R6gWaf8.ttf", + "500italic": "http://fonts.gstatic.com/s/krub/v7/sZlGdRyC6CRYbkQi_LJyQ4oTef_6gQ.ttf", + "600": "http://fonts.gstatic.com/s/krub/v7/sZlEdRyC6CRYZp4-KLF4R6gWaf8.ttf", + "600italic": "http://fonts.gstatic.com/s/krub/v7/sZlGdRyC6CRYbkQi0LVyQ4oTef_6gQ.ttf", + "700": "http://fonts.gstatic.com/s/krub/v7/sZlEdRyC6CRYZvo_KLF4R6gWaf8.ttf", + "700italic": "http://fonts.gstatic.com/s/krub/v7/sZlGdRyC6CRYbkQitLRyQ4oTef_6gQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kufam", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/kufam/v17/C8c-4cY7pG7w_oSJDszBXsKCcBH3lqk7qQCJHvIwYg.ttf", + "500": "http://fonts.gstatic.com/s/kufam/v17/C8c-4cY7pG7w_oSJDszBXsKCcBH3pKk7qQCJHvIwYg.ttf", + "600": "http://fonts.gstatic.com/s/kufam/v17/C8c-4cY7pG7w_oSJDszBXsKCcBH3SK47qQCJHvIwYg.ttf", + "700": "http://fonts.gstatic.com/s/kufam/v17/C8c-4cY7pG7w_oSJDszBXsKCcBH3ca47qQCJHvIwYg.ttf", + "800": "http://fonts.gstatic.com/s/kufam/v17/C8c-4cY7pG7w_oSJDszBXsKCcBH3Fq47qQCJHvIwYg.ttf", + "900": "http://fonts.gstatic.com/s/kufam/v17/C8c-4cY7pG7w_oSJDszBXsKCcBH3P647qQCJHvIwYg.ttf", + "italic": "http://fonts.gstatic.com/s/kufam/v17/C8c84cY7pG7w_q6APDMZN6kY3hbiXurT6gqNPPcgYp0i.ttf", + "500italic": "http://fonts.gstatic.com/s/kufam/v17/C8c84cY7pG7w_q6APDMZN6kY3hbiXurh6gqNPPcgYp0i.ttf", + "600italic": "http://fonts.gstatic.com/s/kufam/v17/C8c84cY7pG7w_q6APDMZN6kY3hbiXuoN7QqNPPcgYp0i.ttf", + "700italic": "http://fonts.gstatic.com/s/kufam/v17/C8c84cY7pG7w_q6APDMZN6kY3hbiXuo07QqNPPcgYp0i.ttf", + "800italic": "http://fonts.gstatic.com/s/kufam/v17/C8c84cY7pG7w_q6APDMZN6kY3hbiXupT7QqNPPcgYp0i.ttf", + "900italic": "http://fonts.gstatic.com/s/kufam/v17/C8c84cY7pG7w_q6APDMZN6kY3hbiXup67QqNPPcgYp0i.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kulim Park", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "200": "http://fonts.gstatic.com/s/kulimpark/v12/fdN49secq3hflz1Uu3IwjJYNwa5aZbUvGjU.ttf", + "200italic": "http://fonts.gstatic.com/s/kulimpark/v12/fdNm9secq3hflz1Uu3IwhFwUKa9QYZcqCjVVUA.ttf", + "300": "http://fonts.gstatic.com/s/kulimpark/v12/fdN49secq3hflz1Uu3IwjPIOwa5aZbUvGjU.ttf", + "300italic": "http://fonts.gstatic.com/s/kulimpark/v12/fdNm9secq3hflz1Uu3IwhFwUTaxQYZcqCjVVUA.ttf", + "regular": "http://fonts.gstatic.com/s/kulimpark/v12/fdN79secq3hflz1Uu3IwtF4m5aZxebw.ttf", + "italic": "http://fonts.gstatic.com/s/kulimpark/v12/fdN59secq3hflz1Uu3IwhFws4YR0abw2Aw.ttf", + "600": "http://fonts.gstatic.com/s/kulimpark/v12/fdN49secq3hflz1Uu3IwjIYIwa5aZbUvGjU.ttf", + "600italic": "http://fonts.gstatic.com/s/kulimpark/v12/fdNm9secq3hflz1Uu3IwhFwUOapQYZcqCjVVUA.ttf", + "700": "http://fonts.gstatic.com/s/kulimpark/v12/fdN49secq3hflz1Uu3IwjOIJwa5aZbUvGjU.ttf", + "700italic": "http://fonts.gstatic.com/s/kulimpark/v12/fdNm9secq3hflz1Uu3IwhFwUXatQYZcqCjVVUA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kumar One", + "variants": [ + "regular" + ], + "subsets": [ + "gujarati", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/kumarone/v15/bMr1mS-P958wYi6YaGeGNO6WU3oT0g.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Kumar One Outline", + "variants": [ + "regular" + ], + "subsets": [ + "gujarati", + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/kumaroneoutline/v16/Noao6VH62pyLP0fsrZ-v18wlUEcX9zDwRQu8EGKF.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Kumbh Sans", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/kumbhsans/v10/c4mw1n92AsfhuCq6tVsaoIx1CHIi4kToNorqSyNIXIwSP0XD.ttf", + "200": "http://fonts.gstatic.com/s/kumbhsans/v10/c4mw1n92AsfhuCq6tVsaoIx1CHIi4kToNopqSiNIXIwSP0XD.ttf", + "300": "http://fonts.gstatic.com/s/kumbhsans/v10/c4mw1n92AsfhuCq6tVsaoIx1CHIi4kToNoq0SiNIXIwSP0XD.ttf", + "regular": "http://fonts.gstatic.com/s/kumbhsans/v10/c4mw1n92AsfhuCq6tVsaoIx1CHIi4kToNorqSiNIXIwSP0XD.ttf", + "500": "http://fonts.gstatic.com/s/kumbhsans/v10/c4mw1n92AsfhuCq6tVsaoIx1CHIi4kToNorYSiNIXIwSP0XD.ttf", + "600": "http://fonts.gstatic.com/s/kumbhsans/v10/c4mw1n92AsfhuCq6tVsaoIx1CHIi4kToNoo0TSNIXIwSP0XD.ttf", + "700": "http://fonts.gstatic.com/s/kumbhsans/v10/c4mw1n92AsfhuCq6tVsaoIx1CHIi4kToNooNTSNIXIwSP0XD.ttf", + "800": "http://fonts.gstatic.com/s/kumbhsans/v10/c4mw1n92AsfhuCq6tVsaoIx1CHIi4kToNopqTSNIXIwSP0XD.ttf", + "900": "http://fonts.gstatic.com/s/kumbhsans/v10/c4mw1n92AsfhuCq6tVsaoIx1CHIi4kToNopDTSNIXIwSP0XD.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Kurale", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "devanagari", + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/kurale/v9/4iCs6KV9e9dXjho6eAT3v02QFg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "La Belle Aurore", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/labelleaurore/v14/RrQIbot8-mNYKnGNDkWlocovHeIIG-eFNVmULg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Lacquer", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/lacquer/v13/EYqzma1QwqpG4_BBB7-AXhttQ5I.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Laila", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "300": "http://fonts.gstatic.com/s/laila/v11/LYjBdG_8nE8jDLzxogNAh14nVcfe.ttf", + "regular": "http://fonts.gstatic.com/s/laila/v11/LYjMdG_8nE8jDIRdiidIrEIu.ttf", + "500": "http://fonts.gstatic.com/s/laila/v11/LYjBdG_8nE8jDLypowNAh14nVcfe.ttf", + "600": "http://fonts.gstatic.com/s/laila/v11/LYjBdG_8nE8jDLyFpANAh14nVcfe.ttf", + "700": "http://fonts.gstatic.com/s/laila/v11/LYjBdG_8nE8jDLzhpQNAh14nVcfe.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lakki Reddy", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/lakkireddy/v17/S6u5w49MUSzD9jlCPmvLZQfox9k97-xZ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Lalezar", + "variants": [ + "regular" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/lalezar/v12/zrfl0HLVx-HwTP82UaDyIiL0RCg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Lancelot", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/lancelot/v20/J7acnppxBGtQEulG4JY4xJ9CGyAa.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Langar", + "variants": [ + "regular" + ], + "subsets": [ + "gurmukhi", + "latin", + "latin-ext" + ], + "version": "v24", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/langar/v24/kJEyBukW7AIlgjGVrTVZ99sqrQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Lateef", + "variants": [ + "regular" + ], + "subsets": [ + "arabic", + "latin" + ], + "version": "v21", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/lateef/v21/hESw6XVnNCxEvkbMpheEZo_H_w.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Lato", + "variants": [ + "100", + "100italic", + "300", + "300italic", + "regular", + "italic", + "700", + "700italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v22", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/lato/v22/S6u8w4BMUTPHh30wWyWrFCbw7A.ttf", + "100italic": "http://fonts.gstatic.com/s/lato/v22/S6u-w4BMUTPHjxsIPy-vNiPg7MU0.ttf", + "300": "http://fonts.gstatic.com/s/lato/v22/S6u9w4BMUTPHh7USew-FGC_p9dw.ttf", + "300italic": "http://fonts.gstatic.com/s/lato/v22/S6u_w4BMUTPHjxsI9w2PHA3s5dwt7w.ttf", + "regular": "http://fonts.gstatic.com/s/lato/v22/S6uyw4BMUTPHvxk6XweuBCY.ttf", + "italic": "http://fonts.gstatic.com/s/lato/v22/S6u8w4BMUTPHjxswWyWrFCbw7A.ttf", + "700": "http://fonts.gstatic.com/s/lato/v22/S6u9w4BMUTPHh6UVew-FGC_p9dw.ttf", + "700italic": "http://fonts.gstatic.com/s/lato/v22/S6u_w4BMUTPHjxsI5wqPHA3s5dwt7w.ttf", + "900": "http://fonts.gstatic.com/s/lato/v22/S6u9w4BMUTPHh50Xew-FGC_p9dw.ttf", + "900italic": "http://fonts.gstatic.com/s/lato/v22/S6u_w4BMUTPHjxsI3wiPHA3s5dwt7w.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "League Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v22", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/leaguescript/v22/CSR54zpSlumSWj9CGVsoBZdeaNNUuOwkC2s.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Leckerli One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/leckerlione/v14/V8mCoQH8VCsNttEnxnGQ-1itLZxcBtItFw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Ledger", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/ledger/v14/j8_q6-HK1L3if_sxm8DwHTBhHw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lekton", + "variants": [ + "regular", + "italic", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/lekton/v15/SZc43FDmLaWmWpBeXxfonUPL6Q.ttf", + "italic": "http://fonts.gstatic.com/s/lekton/v15/SZc63FDmLaWmWpBuXR3sv0bb6StO.ttf", + "700": "http://fonts.gstatic.com/s/lekton/v15/SZc73FDmLaWmWpBm4zjMlWjX4DJXgQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lemon", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/lemon/v12/HI_EiYEVKqRMq0jBSZXAQ4-d.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Lemonada", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/lemonada/v18/0QI-MXFD9oygTWy_R-FFlwV-bgfR7QJGJOt2mfWc3Z2pTg.ttf", + "regular": "http://fonts.gstatic.com/s/lemonada/v18/0QI-MXFD9oygTWy_R-FFlwV-bgfR7QJGeut2mfWc3Z2pTg.ttf", + "500": "http://fonts.gstatic.com/s/lemonada/v18/0QI-MXFD9oygTWy_R-FFlwV-bgfR7QJGSOt2mfWc3Z2pTg.ttf", + "600": "http://fonts.gstatic.com/s/lemonada/v18/0QI-MXFD9oygTWy_R-FFlwV-bgfR7QJGpOx2mfWc3Z2pTg.ttf", + "700": "http://fonts.gstatic.com/s/lemonada/v18/0QI-MXFD9oygTWy_R-FFlwV-bgfR7QJGnex2mfWc3Z2pTg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Lexend", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/lexend/v14/wlptgwvFAVdoq2_F94zlCfv0bz1WCzsX_LBte6KuGEo.ttf", + "200": "http://fonts.gstatic.com/s/lexend/v14/wlptgwvFAVdoq2_F94zlCfv0bz1WC7sW_LBte6KuGEo.ttf", + "300": "http://fonts.gstatic.com/s/lexend/v14/wlptgwvFAVdoq2_F94zlCfv0bz1WC2UW_LBte6KuGEo.ttf", + "regular": "http://fonts.gstatic.com/s/lexend/v14/wlptgwvFAVdoq2_F94zlCfv0bz1WCzsW_LBte6KuGEo.ttf", + "500": "http://fonts.gstatic.com/s/lexend/v14/wlptgwvFAVdoq2_F94zlCfv0bz1WCwkW_LBte6KuGEo.ttf", + "600": "http://fonts.gstatic.com/s/lexend/v14/wlptgwvFAVdoq2_F94zlCfv0bz1WC-UR_LBte6KuGEo.ttf", + "700": "http://fonts.gstatic.com/s/lexend/v14/wlptgwvFAVdoq2_F94zlCfv0bz1WC9wR_LBte6KuGEo.ttf", + "800": "http://fonts.gstatic.com/s/lexend/v14/wlptgwvFAVdoq2_F94zlCfv0bz1WC7sR_LBte6KuGEo.ttf", + "900": "http://fonts.gstatic.com/s/lexend/v14/wlptgwvFAVdoq2_F94zlCfv0bz1WC5IR_LBte6KuGEo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lexend Deca", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/lexenddeca/v15/K2FifZFYk-dHSE0UPPuwQ7CrD94i-NCKm-U48MxArBPCqLNflg.ttf", + "200": "http://fonts.gstatic.com/s/lexenddeca/v15/K2FifZFYk-dHSE0UPPuwQ7CrD94i-NCKm-U4cM1ArBPCqLNflg.ttf", + "300": "http://fonts.gstatic.com/s/lexenddeca/v15/K2FifZFYk-dHSE0UPPuwQ7CrD94i-NCKm-U4rs1ArBPCqLNflg.ttf", + "regular": "http://fonts.gstatic.com/s/lexenddeca/v15/K2FifZFYk-dHSE0UPPuwQ7CrD94i-NCKm-U48M1ArBPCqLNflg.ttf", + "500": "http://fonts.gstatic.com/s/lexenddeca/v15/K2FifZFYk-dHSE0UPPuwQ7CrD94i-NCKm-U4ws1ArBPCqLNflg.ttf", + "600": "http://fonts.gstatic.com/s/lexenddeca/v15/K2FifZFYk-dHSE0UPPuwQ7CrD94i-NCKm-U4LspArBPCqLNflg.ttf", + "700": "http://fonts.gstatic.com/s/lexenddeca/v15/K2FifZFYk-dHSE0UPPuwQ7CrD94i-NCKm-U4F8pArBPCqLNflg.ttf", + "800": "http://fonts.gstatic.com/s/lexenddeca/v15/K2FifZFYk-dHSE0UPPuwQ7CrD94i-NCKm-U4cMpArBPCqLNflg.ttf", + "900": "http://fonts.gstatic.com/s/lexenddeca/v15/K2FifZFYk-dHSE0UPPuwQ7CrD94i-NCKm-U4WcpArBPCqLNflg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lexend Exa", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/lexendexa/v22/UMBCrPdOoHOnxExyjdBeQCH18mulUxBvI9r7T6bHHJ8BRq0b.ttf", + "200": "http://fonts.gstatic.com/s/lexendexa/v22/UMBCrPdOoHOnxExyjdBeQCH18mulUxBvI9p7TqbHHJ8BRq0b.ttf", + "300": "http://fonts.gstatic.com/s/lexendexa/v22/UMBCrPdOoHOnxExyjdBeQCH18mulUxBvI9qlTqbHHJ8BRq0b.ttf", + "regular": "http://fonts.gstatic.com/s/lexendexa/v22/UMBCrPdOoHOnxExyjdBeQCH18mulUxBvI9r7TqbHHJ8BRq0b.ttf", + "500": "http://fonts.gstatic.com/s/lexendexa/v22/UMBCrPdOoHOnxExyjdBeQCH18mulUxBvI9rJTqbHHJ8BRq0b.ttf", + "600": "http://fonts.gstatic.com/s/lexendexa/v22/UMBCrPdOoHOnxExyjdBeQCH18mulUxBvI9olSabHHJ8BRq0b.ttf", + "700": "http://fonts.gstatic.com/s/lexendexa/v22/UMBCrPdOoHOnxExyjdBeQCH18mulUxBvI9ocSabHHJ8BRq0b.ttf", + "800": "http://fonts.gstatic.com/s/lexendexa/v22/UMBCrPdOoHOnxExyjdBeQCH18mulUxBvI9p7SabHHJ8BRq0b.ttf", + "900": "http://fonts.gstatic.com/s/lexendexa/v22/UMBCrPdOoHOnxExyjdBeQCH18mulUxBvI9pSSabHHJ8BRq0b.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lexend Giga", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/lexendgiga/v22/PlIuFl67Mah5Y8yMHE7lkUZPlTBo4MWFfNRC2LmE68oo6eepYQ.ttf", + "200": "http://fonts.gstatic.com/s/lexendgiga/v22/PlIuFl67Mah5Y8yMHE7lkUZPlTBo4MWFfNRCWLiE68oo6eepYQ.ttf", + "300": "http://fonts.gstatic.com/s/lexendgiga/v22/PlIuFl67Mah5Y8yMHE7lkUZPlTBo4MWFfNRChriE68oo6eepYQ.ttf", + "regular": "http://fonts.gstatic.com/s/lexendgiga/v22/PlIuFl67Mah5Y8yMHE7lkUZPlTBo4MWFfNRC2LiE68oo6eepYQ.ttf", + "500": "http://fonts.gstatic.com/s/lexendgiga/v22/PlIuFl67Mah5Y8yMHE7lkUZPlTBo4MWFfNRC6riE68oo6eepYQ.ttf", + "600": "http://fonts.gstatic.com/s/lexendgiga/v22/PlIuFl67Mah5Y8yMHE7lkUZPlTBo4MWFfNRCBr-E68oo6eepYQ.ttf", + "700": "http://fonts.gstatic.com/s/lexendgiga/v22/PlIuFl67Mah5Y8yMHE7lkUZPlTBo4MWFfNRCP7-E68oo6eepYQ.ttf", + "800": "http://fonts.gstatic.com/s/lexendgiga/v22/PlIuFl67Mah5Y8yMHE7lkUZPlTBo4MWFfNRCWL-E68oo6eepYQ.ttf", + "900": "http://fonts.gstatic.com/s/lexendgiga/v22/PlIuFl67Mah5Y8yMHE7lkUZPlTBo4MWFfNRCcb-E68oo6eepYQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lexend Mega", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/lexendmega/v22/qFdX35aBi5JtHD41zSTFEuTByuvYFuE9IbDL8fivveyiq9EqQw.ttf", + "200": "http://fonts.gstatic.com/s/lexendmega/v22/qFdX35aBi5JtHD41zSTFEuTByuvYFuE9IbDLcfmvveyiq9EqQw.ttf", + "300": "http://fonts.gstatic.com/s/lexendmega/v22/qFdX35aBi5JtHD41zSTFEuTByuvYFuE9IbDLr_mvveyiq9EqQw.ttf", + "regular": "http://fonts.gstatic.com/s/lexendmega/v22/qFdX35aBi5JtHD41zSTFEuTByuvYFuE9IbDL8fmvveyiq9EqQw.ttf", + "500": "http://fonts.gstatic.com/s/lexendmega/v22/qFdX35aBi5JtHD41zSTFEuTByuvYFuE9IbDLw_mvveyiq9EqQw.ttf", + "600": "http://fonts.gstatic.com/s/lexendmega/v22/qFdX35aBi5JtHD41zSTFEuTByuvYFuE9IbDLL_6vveyiq9EqQw.ttf", + "700": "http://fonts.gstatic.com/s/lexendmega/v22/qFdX35aBi5JtHD41zSTFEuTByuvYFuE9IbDLFv6vveyiq9EqQw.ttf", + "800": "http://fonts.gstatic.com/s/lexendmega/v22/qFdX35aBi5JtHD41zSTFEuTByuvYFuE9IbDLcf6vveyiq9EqQw.ttf", + "900": "http://fonts.gstatic.com/s/lexendmega/v22/qFdX35aBi5JtHD41zSTFEuTByuvYFuE9IbDLWP6vveyiq9EqQw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lexend Peta", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/lexendpeta/v22/BXR4vFPGjeLPh0kCfI4OkFX-UTQHSCaxvBgR6SFyW1YuRTsnfw.ttf", + "200": "http://fonts.gstatic.com/s/lexendpeta/v22/BXR4vFPGjeLPh0kCfI4OkFX-UTQHSCaxvBgRaSByW1YuRTsnfw.ttf", + "300": "http://fonts.gstatic.com/s/lexendpeta/v22/BXR4vFPGjeLPh0kCfI4OkFX-UTQHSCaxvBgRtyByW1YuRTsnfw.ttf", + "regular": "http://fonts.gstatic.com/s/lexendpeta/v22/BXR4vFPGjeLPh0kCfI4OkFX-UTQHSCaxvBgR6SByW1YuRTsnfw.ttf", + "500": "http://fonts.gstatic.com/s/lexendpeta/v22/BXR4vFPGjeLPh0kCfI4OkFX-UTQHSCaxvBgR2yByW1YuRTsnfw.ttf", + "600": "http://fonts.gstatic.com/s/lexendpeta/v22/BXR4vFPGjeLPh0kCfI4OkFX-UTQHSCaxvBgRNydyW1YuRTsnfw.ttf", + "700": "http://fonts.gstatic.com/s/lexendpeta/v22/BXR4vFPGjeLPh0kCfI4OkFX-UTQHSCaxvBgRDidyW1YuRTsnfw.ttf", + "800": "http://fonts.gstatic.com/s/lexendpeta/v22/BXR4vFPGjeLPh0kCfI4OkFX-UTQHSCaxvBgRaSdyW1YuRTsnfw.ttf", + "900": "http://fonts.gstatic.com/s/lexendpeta/v22/BXR4vFPGjeLPh0kCfI4OkFX-UTQHSCaxvBgRQCdyW1YuRTsnfw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lexend Tera", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/lexendtera/v22/RrQDbo98_jt_IXnBPwCWtYJLZ3P4hnaGKFiM5zITdpz0fYxcrQ.ttf", + "200": "http://fonts.gstatic.com/s/lexendtera/v22/RrQDbo98_jt_IXnBPwCWtYJLZ3P4hnaGKFiMZzMTdpz0fYxcrQ.ttf", + "300": "http://fonts.gstatic.com/s/lexendtera/v22/RrQDbo98_jt_IXnBPwCWtYJLZ3P4hnaGKFiMuTMTdpz0fYxcrQ.ttf", + "regular": "http://fonts.gstatic.com/s/lexendtera/v22/RrQDbo98_jt_IXnBPwCWtYJLZ3P4hnaGKFiM5zMTdpz0fYxcrQ.ttf", + "500": "http://fonts.gstatic.com/s/lexendtera/v22/RrQDbo98_jt_IXnBPwCWtYJLZ3P4hnaGKFiM1TMTdpz0fYxcrQ.ttf", + "600": "http://fonts.gstatic.com/s/lexendtera/v22/RrQDbo98_jt_IXnBPwCWtYJLZ3P4hnaGKFiMOTQTdpz0fYxcrQ.ttf", + "700": "http://fonts.gstatic.com/s/lexendtera/v22/RrQDbo98_jt_IXnBPwCWtYJLZ3P4hnaGKFiMADQTdpz0fYxcrQ.ttf", + "800": "http://fonts.gstatic.com/s/lexendtera/v22/RrQDbo98_jt_IXnBPwCWtYJLZ3P4hnaGKFiMZzQTdpz0fYxcrQ.ttf", + "900": "http://fonts.gstatic.com/s/lexendtera/v22/RrQDbo98_jt_IXnBPwCWtYJLZ3P4hnaGKFiMTjQTdpz0fYxcrQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lexend Zetta", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/lexendzetta/v22/ll8uK2KYXje7CdOFnEWcU8synQbuVYjYB3BCy9bH0z5jbs8qbts.ttf", + "200": "http://fonts.gstatic.com/s/lexendzetta/v22/ll8uK2KYXje7CdOFnEWcU8synQbuVYjYB3BCy1bG0z5jbs8qbts.ttf", + "300": "http://fonts.gstatic.com/s/lexendzetta/v22/ll8uK2KYXje7CdOFnEWcU8synQbuVYjYB3BCy4jG0z5jbs8qbts.ttf", + "regular": "http://fonts.gstatic.com/s/lexendzetta/v22/ll8uK2KYXje7CdOFnEWcU8synQbuVYjYB3BCy9bG0z5jbs8qbts.ttf", + "500": "http://fonts.gstatic.com/s/lexendzetta/v22/ll8uK2KYXje7CdOFnEWcU8synQbuVYjYB3BCy-TG0z5jbs8qbts.ttf", + "600": "http://fonts.gstatic.com/s/lexendzetta/v22/ll8uK2KYXje7CdOFnEWcU8synQbuVYjYB3BCywjB0z5jbs8qbts.ttf", + "700": "http://fonts.gstatic.com/s/lexendzetta/v22/ll8uK2KYXje7CdOFnEWcU8synQbuVYjYB3BCyzHB0z5jbs8qbts.ttf", + "800": "http://fonts.gstatic.com/s/lexendzetta/v22/ll8uK2KYXje7CdOFnEWcU8synQbuVYjYB3BCy1bB0z5jbs8qbts.ttf", + "900": "http://fonts.gstatic.com/s/lexendzetta/v22/ll8uK2KYXje7CdOFnEWcU8synQbuVYjYB3BCy3_B0z5jbs8qbts.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Barcode 128", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v24", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/librebarcode128/v24/cIfnMbdUsUoiW3O_hVviCwVjuLtXeJ_A_gMk0izH.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Barcode 128 Text", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v24", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/librebarcode128text/v24/fdNv9tubt3ZEnz1Gu3I4-zppwZ9CWZ16Z0w5cV3Y6M90w4k.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Barcode 39", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/librebarcode39/v17/-nFnOHM08vwC6h8Li1eQnP_AHzI2K_d709jy92k.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Barcode 39 Extended", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/librebarcode39extended/v23/8At7Gt6_O5yNS0-K4Nf5U922qSzhJ3dUdfJpwNUgfNRCOZ1GOBw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Barcode 39 Extended Text", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/librebarcode39extendedtext/v23/eLG1P_rwIgOiDA7yrs9LoKaYRVLQ1YldrrOnnL7xPO4jNP68fLIiPopNNA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Barcode 39 Text", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v24", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/librebarcode39text/v24/sJoa3KhViNKANw_E3LwoDXvs5Un0HQ1vT-031RRL-9rYaw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Barcode EAN13 Text", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/librebarcodeean13text/v17/wlpigxXFDU1_oCu9nfZytgIqSG0XRcJm_OQiB96PAGEki52WfA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Baskerville", + "variants": [ + "regular", + "italic", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/librebaskerville/v13/kmKnZrc3Hgbbcjq75U4uslyuy4kn0pNeYRI4CN2V.ttf", + "italic": "http://fonts.gstatic.com/s/librebaskerville/v13/kmKhZrc3Hgbbcjq75U4uslyuy4kn0qNcaxYaDc2V2ro.ttf", + "700": "http://fonts.gstatic.com/s/librebaskerville/v13/kmKiZrc3Hgbbcjq75U4uslyuy4kn0qviTjYwI8Gcw6Oi.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Caslon Display", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/librecaslondisplay/v12/TuGOUUFxWphYQ6YI6q9Xp61FQzxDRKmzr2lRdRhtCC4d.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Caslon Text", + "variants": [ + "regular", + "italic", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v2", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/librecaslontext/v2/DdT878IGsGw1aF1JU10PUbTvNNaDMcq_3eNrHgO1.ttf", + "italic": "http://fonts.gstatic.com/s/librecaslontext/v2/DdT678IGsGw1aF1JU10PUbTvNNaDMfq91-dJGxO1q9o.ttf", + "700": "http://fonts.gstatic.com/s/librecaslontext/v2/DdT578IGsGw1aF1JU10PUbTvNNaDMfID8sdjNR-8ssPt.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Libre Franklin", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/librefranklin/v11/jizOREVItHgc8qDIbSTKq4XkRg8T88bjFuXOnduhLsSUB9rIb-JH1g.ttf", + "200": "http://fonts.gstatic.com/s/librefranklin/v11/jizOREVItHgc8qDIbSTKq4XkRg8T88bjFuXOnduhrsWUB9rIb-JH1g.ttf", + "300": "http://fonts.gstatic.com/s/librefranklin/v11/jizOREVItHgc8qDIbSTKq4XkRg8T88bjFuXOnduhcMWUB9rIb-JH1g.ttf", + "regular": "http://fonts.gstatic.com/s/librefranklin/v11/jizOREVItHgc8qDIbSTKq4XkRg8T88bjFuXOnduhLsWUB9rIb-JH1g.ttf", + "500": "http://fonts.gstatic.com/s/librefranklin/v11/jizOREVItHgc8qDIbSTKq4XkRg8T88bjFuXOnduhHMWUB9rIb-JH1g.ttf", + "600": "http://fonts.gstatic.com/s/librefranklin/v11/jizOREVItHgc8qDIbSTKq4XkRg8T88bjFuXOnduh8MKUB9rIb-JH1g.ttf", + "700": "http://fonts.gstatic.com/s/librefranklin/v11/jizOREVItHgc8qDIbSTKq4XkRg8T88bjFuXOnduhycKUB9rIb-JH1g.ttf", + "800": "http://fonts.gstatic.com/s/librefranklin/v11/jizOREVItHgc8qDIbSTKq4XkRg8T88bjFuXOnduhrsKUB9rIb-JH1g.ttf", + "900": "http://fonts.gstatic.com/s/librefranklin/v11/jizOREVItHgc8qDIbSTKq4XkRg8T88bjFuXOnduhh8KUB9rIb-JH1g.ttf", + "100italic": "http://fonts.gstatic.com/s/librefranklin/v11/jizMREVItHgc8qDIbSTKq4XkRiUawTk7f45UM9y05oZ8RdDMTedX1sGE.ttf", + "200italic": "http://fonts.gstatic.com/s/librefranklin/v11/jizMREVItHgc8qDIbSTKq4XkRiUawTk7f45UM9y05ob8RNDMTedX1sGE.ttf", + "300italic": "http://fonts.gstatic.com/s/librefranklin/v11/jizMREVItHgc8qDIbSTKq4XkRiUawTk7f45UM9y05oYiRNDMTedX1sGE.ttf", + "italic": "http://fonts.gstatic.com/s/librefranklin/v11/jizMREVItHgc8qDIbSTKq4XkRiUawTk7f45UM9y05oZ8RNDMTedX1sGE.ttf", + "500italic": "http://fonts.gstatic.com/s/librefranklin/v11/jizMREVItHgc8qDIbSTKq4XkRiUawTk7f45UM9y05oZORNDMTedX1sGE.ttf", + "600italic": "http://fonts.gstatic.com/s/librefranklin/v11/jizMREVItHgc8qDIbSTKq4XkRiUawTk7f45UM9y05oaiQ9DMTedX1sGE.ttf", + "700italic": "http://fonts.gstatic.com/s/librefranklin/v11/jizMREVItHgc8qDIbSTKq4XkRiUawTk7f45UM9y05oabQ9DMTedX1sGE.ttf", + "800italic": "http://fonts.gstatic.com/s/librefranklin/v11/jizMREVItHgc8qDIbSTKq4XkRiUawTk7f45UM9y05ob8Q9DMTedX1sGE.ttf", + "900italic": "http://fonts.gstatic.com/s/librefranklin/v11/jizMREVItHgc8qDIbSTKq4XkRiUawTk7f45UM9y05obVQ9DMTedX1sGE.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Licorice", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/licorice/v1/t5tjIR8TMomTCAyjNk23hqLgzCHu.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Life Savers", + "variants": [ + "regular", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/lifesavers/v16/ZXuie1UftKKabUQMgxAal_lrFgpbuNvB.ttf", + "700": "http://fonts.gstatic.com/s/lifesavers/v16/ZXu_e1UftKKabUQMgxAal8HXOS5Tk8fIpPRW.ttf", + "800": "http://fonts.gstatic.com/s/lifesavers/v16/ZXu_e1UftKKabUQMgxAal8HLOi5Tk8fIpPRW.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Lilita One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/lilitaone/v11/i7dPIFZ9Zz-WBtRtedDbUEZ2RFq7AwU.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Lily Script One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/lilyscriptone/v13/LhW9MV7ZMfIPdMxeBjBvFN8SXLS4gsSjQNsRMg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Limelight", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/limelight/v14/XLYkIZL7aopJVbZJHDuYPeNGrnY2TA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Linden Hill", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/lindenhill/v20/-F61fjxoKSg9Yc3hZgO8ygFI7CwC009k.ttf", + "italic": "http://fonts.gstatic.com/s/lindenhill/v20/-F63fjxoKSg9Yc3hZgO8yjFK5igg1l9kn-s.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Literata", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v23", + "lastModified": "2021-03-11", + "files": { + "200": "http://fonts.gstatic.com/s/literata/v23/or3PQ6P12-iJxAIgLa78DkrbXsDgk0oVDaDPYLanFLHpPf2TbJG_F_bcTWCWp8g.ttf", + "300": "http://fonts.gstatic.com/s/literata/v23/or3PQ6P12-iJxAIgLa78DkrbXsDgk0oVDaDPYLanFLHpPf2TbE-_F_bcTWCWp8g.ttf", + "regular": "http://fonts.gstatic.com/s/literata/v23/or3PQ6P12-iJxAIgLa78DkrbXsDgk0oVDaDPYLanFLHpPf2TbBG_F_bcTWCWp8g.ttf", + "500": "http://fonts.gstatic.com/s/literata/v23/or3PQ6P12-iJxAIgLa78DkrbXsDgk0oVDaDPYLanFLHpPf2TbCO_F_bcTWCWp8g.ttf", + "600": "http://fonts.gstatic.com/s/literata/v23/or3PQ6P12-iJxAIgLa78DkrbXsDgk0oVDaDPYLanFLHpPf2TbM-4F_bcTWCWp8g.ttf", + "700": "http://fonts.gstatic.com/s/literata/v23/or3PQ6P12-iJxAIgLa78DkrbXsDgk0oVDaDPYLanFLHpPf2TbPa4F_bcTWCWp8g.ttf", + "800": "http://fonts.gstatic.com/s/literata/v23/or3PQ6P12-iJxAIgLa78DkrbXsDgk0oVDaDPYLanFLHpPf2TbJG4F_bcTWCWp8g.ttf", + "900": "http://fonts.gstatic.com/s/literata/v23/or3PQ6P12-iJxAIgLa78DkrbXsDgk0oVDaDPYLanFLHpPf2TbLi4F_bcTWCWp8g.ttf", + "200italic": "http://fonts.gstatic.com/s/literata/v23/or3NQ6P12-iJxAIgLYT1PLs1Zd0nfUwAbeGVKoRYzNiCp1OUedn8f7XWSUKTt8iVow.ttf", + "300italic": "http://fonts.gstatic.com/s/literata/v23/or3NQ6P12-iJxAIgLYT1PLs1Zd0nfUwAbeGVKoRYzNiCp1OUedn8obXWSUKTt8iVow.ttf", + "italic": "http://fonts.gstatic.com/s/literata/v23/or3NQ6P12-iJxAIgLYT1PLs1Zd0nfUwAbeGVKoRYzNiCp1OUedn8_7XWSUKTt8iVow.ttf", + "500italic": "http://fonts.gstatic.com/s/literata/v23/or3NQ6P12-iJxAIgLYT1PLs1Zd0nfUwAbeGVKoRYzNiCp1OUedn8zbXWSUKTt8iVow.ttf", + "600italic": "http://fonts.gstatic.com/s/literata/v23/or3NQ6P12-iJxAIgLYT1PLs1Zd0nfUwAbeGVKoRYzNiCp1OUedn8IbLWSUKTt8iVow.ttf", + "700italic": "http://fonts.gstatic.com/s/literata/v23/or3NQ6P12-iJxAIgLYT1PLs1Zd0nfUwAbeGVKoRYzNiCp1OUedn8GLLWSUKTt8iVow.ttf", + "800italic": "http://fonts.gstatic.com/s/literata/v23/or3NQ6P12-iJxAIgLYT1PLs1Zd0nfUwAbeGVKoRYzNiCp1OUedn8f7LWSUKTt8iVow.ttf", + "900italic": "http://fonts.gstatic.com/s/literata/v23/or3NQ6P12-iJxAIgLYT1PLs1Zd0nfUwAbeGVKoRYzNiCp1OUedn8VrLWSUKTt8iVow.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Liu Jian Mao Cao", + "variants": [ + "regular" + ], + "subsets": [ + "chinese-simplified", + "latin" + ], + "version": "v13", + "lastModified": "2021-11-10", + "files": { + "regular": "http://fonts.gstatic.com/s/liujianmaocao/v13/~ChIKEExpdSBKaWFuIE1hbyBDYW8gACoECAEYAQ==.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Livvic", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "100": "http://fonts.gstatic.com/s/livvic/v11/rnCr-x1S2hzjrlffC-M-mHnOSOuk.ttf", + "100italic": "http://fonts.gstatic.com/s/livvic/v11/rnCt-x1S2hzjrlfXbdtakn3sTfukQHs.ttf", + "200": "http://fonts.gstatic.com/s/livvic/v11/rnCq-x1S2hzjrlffp8IeslfCQfK9WQ.ttf", + "200italic": "http://fonts.gstatic.com/s/livvic/v11/rnCs-x1S2hzjrlfXbdv2s13GY_etWWIJ.ttf", + "300": "http://fonts.gstatic.com/s/livvic/v11/rnCq-x1S2hzjrlffw8EeslfCQfK9WQ.ttf", + "300italic": "http://fonts.gstatic.com/s/livvic/v11/rnCs-x1S2hzjrlfXbduSsF3GY_etWWIJ.ttf", + "regular": "http://fonts.gstatic.com/s/livvic/v11/rnCp-x1S2hzjrlfnb-k6unzeSA.ttf", + "italic": "http://fonts.gstatic.com/s/livvic/v11/rnCr-x1S2hzjrlfXbeM-mHnOSOuk.ttf", + "500": "http://fonts.gstatic.com/s/livvic/v11/rnCq-x1S2hzjrlffm8AeslfCQfK9WQ.ttf", + "500italic": "http://fonts.gstatic.com/s/livvic/v11/rnCs-x1S2hzjrlfXbdvKsV3GY_etWWIJ.ttf", + "600": "http://fonts.gstatic.com/s/livvic/v11/rnCq-x1S2hzjrlfft8ceslfCQfK9WQ.ttf", + "600italic": "http://fonts.gstatic.com/s/livvic/v11/rnCs-x1S2hzjrlfXbdvmtl3GY_etWWIJ.ttf", + "700": "http://fonts.gstatic.com/s/livvic/v11/rnCq-x1S2hzjrlff08YeslfCQfK9WQ.ttf", + "700italic": "http://fonts.gstatic.com/s/livvic/v11/rnCs-x1S2hzjrlfXbduCt13GY_etWWIJ.ttf", + "900": "http://fonts.gstatic.com/s/livvic/v11/rnCq-x1S2hzjrlff68QeslfCQfK9WQ.ttf", + "900italic": "http://fonts.gstatic.com/s/livvic/v11/rnCs-x1S2hzjrlfXbdu6tV3GY_etWWIJ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lobster", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v27", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/lobster/v27/neILzCirqoswsqX9_oWsMqEzSJQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Lobster Two", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/lobstertwo/v17/BngMUXZGTXPUvIoyV6yN59fK7KSJ4ACD.ttf", + "italic": "http://fonts.gstatic.com/s/lobstertwo/v17/BngOUXZGTXPUvIoyV6yN5-fI5qCr5RCDY_k.ttf", + "700": "http://fonts.gstatic.com/s/lobstertwo/v17/BngRUXZGTXPUvIoyV6yN5-92w4CByxyKeuDp.ttf", + "700italic": "http://fonts.gstatic.com/s/lobstertwo/v17/BngTUXZGTXPUvIoyV6yN5-fI3hyEwRiof_DpXMY.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Londrina Outline", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v21", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/londrinaoutline/v21/C8c44dM8vmb14dfsZxhetg3pDH-SfuoxrSKMDvI.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Londrina Shadow", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/londrinashadow/v20/oPWX_kB4kOQoWNJmjxLV5JuoCUlXRlaSxkrMCQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Londrina Sketch", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/londrinasketch/v19/c4m41npxGMTnomOHtRU68eIJn8qfWWn5Pos6CA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Londrina Solid", + "variants": [ + "100", + "300", + "regular", + "900" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "100": "http://fonts.gstatic.com/s/londrinasolid/v13/flUjRq6sw40kQEJxWNgkLuudGfs9KBYesZHhV64.ttf", + "300": "http://fonts.gstatic.com/s/londrinasolid/v13/flUiRq6sw40kQEJxWNgkLuudGfv1CjY0n53oTrcL.ttf", + "regular": "http://fonts.gstatic.com/s/londrinasolid/v13/flUhRq6sw40kQEJxWNgkLuudGcNZIhI8tIHh.ttf", + "900": "http://fonts.gstatic.com/s/londrinasolid/v13/flUiRq6sw40kQEJxWNgkLuudGfvdDzY0n53oTrcL.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Long Cang", + "variants": [ + "regular" + ], + "subsets": [ + "chinese-simplified", + "latin" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/longcang/v15/LYjAdGP8kkgoTec8zkRgrXArXN7HWQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Lora", + "variants": [ + "regular", + "500", + "600", + "700", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v23", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/lora/v23/0QI6MX1D_JOuGQbT0gvTJPa787weuyJGmKxemMeZ.ttf", + "500": "http://fonts.gstatic.com/s/lora/v23/0QI6MX1D_JOuGQbT0gvTJPa787wsuyJGmKxemMeZ.ttf", + "600": "http://fonts.gstatic.com/s/lora/v23/0QI6MX1D_JOuGQbT0gvTJPa787zAvCJGmKxemMeZ.ttf", + "700": "http://fonts.gstatic.com/s/lora/v23/0QI6MX1D_JOuGQbT0gvTJPa787z5vCJGmKxemMeZ.ttf", + "italic": "http://fonts.gstatic.com/s/lora/v23/0QI8MX1D_JOuMw_hLdO6T2wV9KnW-MoFkqh8ndeZzZ0.ttf", + "500italic": "http://fonts.gstatic.com/s/lora/v23/0QI8MX1D_JOuMw_hLdO6T2wV9KnW-PgFkqh8ndeZzZ0.ttf", + "600italic": "http://fonts.gstatic.com/s/lora/v23/0QI8MX1D_JOuMw_hLdO6T2wV9KnW-BQCkqh8ndeZzZ0.ttf", + "700italic": "http://fonts.gstatic.com/s/lora/v23/0QI8MX1D_JOuMw_hLdO6T2wV9KnW-C0Ckqh8ndeZzZ0.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Love Light", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/lovelight/v1/t5tlIR0TNJyZWimpNAXDjKbCyTHuspo.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Love Ya Like A Sister", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/loveyalikeasister/v14/R70EjzUBlOqPeouhFDfR80-0FhOqJubN-Be78nZcsGGycA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Loved by the King", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/lovedbytheking/v15/Gw6gwdP76VDVJNXerebZxUMeRXUF2PiNlXFu2R64.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Lovers Quarrel", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/loversquarrel/v19/Yq6N-LSKXTL-5bCy8ksBzpQ_-zAsY7pO6siz.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Luckiest Guy", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/luckiestguy/v17/_gP_1RrxsjcxVyin9l9n_j2RStR3qDpraA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Lusitana", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/lusitana/v11/CSR84z9ShvucWzsMKxhaRuMiSct_.ttf", + "700": "http://fonts.gstatic.com/s/lusitana/v11/CSR74z9ShvucWzsMKyDmaccqYtd2vfwk.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Lustria", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/lustria/v11/9oRONYodvDEyjuhOrCg5MtPyAcg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Luxurious Roman", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/luxuriousroman/v1/buEupou_ZcP1w0yTKxJJokVSmbpqYgckeo9RMw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Luxurious Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/luxuriousscript/v3/ahcCv9e7yydulT32KZ0rBIoD7DzMg0rOby1JtYk.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "M PLUS 1", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v4", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/mplus1/v4/R70EjygA28ymD4HgBUGzkN5Eyoj-WpW5VSe78nZcsGGycA.ttf", + "200": "http://fonts.gstatic.com/s/mplus1/v4/R70EjygA28ymD4HgBUGzkN5Eyoj-WpW51Sa78nZcsGGycA.ttf", + "300": "http://fonts.gstatic.com/s/mplus1/v4/R70EjygA28ymD4HgBUGzkN5Eyoj-WpW5Cya78nZcsGGycA.ttf", + "regular": "http://fonts.gstatic.com/s/mplus1/v4/R70EjygA28ymD4HgBUGzkN5Eyoj-WpW5VSa78nZcsGGycA.ttf", + "500": "http://fonts.gstatic.com/s/mplus1/v4/R70EjygA28ymD4HgBUGzkN5Eyoj-WpW5Zya78nZcsGGycA.ttf", + "600": "http://fonts.gstatic.com/s/mplus1/v4/R70EjygA28ymD4HgBUGzkN5Eyoj-WpW5iyG78nZcsGGycA.ttf", + "700": "http://fonts.gstatic.com/s/mplus1/v4/R70EjygA28ymD4HgBUGzkN5Eyoj-WpW5siG78nZcsGGycA.ttf", + "800": "http://fonts.gstatic.com/s/mplus1/v4/R70EjygA28ymD4HgBUGzkN5Eyoj-WpW51SG78nZcsGGycA.ttf", + "900": "http://fonts.gstatic.com/s/mplus1/v4/R70EjygA28ymD4HgBUGzkN5Eyoj-WpW5_CG78nZcsGGycA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "M PLUS 1 Code", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/mplus1code/v5/ypvMbXOOx2xFpzmYJS3N2_J2hBN6RZ5oIp8m_7iN0XHpapwmdZhY.ttf", + "200": "http://fonts.gstatic.com/s/mplus1code/v5/ypvMbXOOx2xFpzmYJS3N2_J2hBN6RZ5oIp8m_7gN0HHpapwmdZhY.ttf", + "300": "http://fonts.gstatic.com/s/mplus1code/v5/ypvMbXOOx2xFpzmYJS3N2_J2hBN6RZ5oIp8m_7jT0HHpapwmdZhY.ttf", + "regular": "http://fonts.gstatic.com/s/mplus1code/v5/ypvMbXOOx2xFpzmYJS3N2_J2hBN6RZ5oIp8m_7iN0HHpapwmdZhY.ttf", + "500": "http://fonts.gstatic.com/s/mplus1code/v5/ypvMbXOOx2xFpzmYJS3N2_J2hBN6RZ5oIp8m_7i_0HHpapwmdZhY.ttf", + "600": "http://fonts.gstatic.com/s/mplus1code/v5/ypvMbXOOx2xFpzmYJS3N2_J2hBN6RZ5oIp8m_7hT13HpapwmdZhY.ttf", + "700": "http://fonts.gstatic.com/s/mplus1code/v5/ypvMbXOOx2xFpzmYJS3N2_J2hBN6RZ5oIp8m_7hq13HpapwmdZhY.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "M PLUS 1p", + "variants": [ + "100", + "300", + "regular", + "500", + "700", + "800", + "900" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "hebrew", + "japanese", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/mplus1p/v22/e3tleuShHdiFyPFzBRrQnDQAUW3aq-5N.ttf", + "300": "http://fonts.gstatic.com/s/mplus1p/v22/e3tmeuShHdiFyPFzBRrQVBYge0PWovdU4w.ttf", + "regular": "http://fonts.gstatic.com/s/mplus1p/v22/e3tjeuShHdiFyPFzBRro-D4Ec2jKqw.ttf", + "500": "http://fonts.gstatic.com/s/mplus1p/v22/e3tmeuShHdiFyPFzBRrQDBcge0PWovdU4w.ttf", + "700": "http://fonts.gstatic.com/s/mplus1p/v22/e3tmeuShHdiFyPFzBRrQRBEge0PWovdU4w.ttf", + "800": "http://fonts.gstatic.com/s/mplus1p/v22/e3tmeuShHdiFyPFzBRrQWBIge0PWovdU4w.ttf", + "900": "http://fonts.gstatic.com/s/mplus1p/v22/e3tmeuShHdiFyPFzBRrQfBMge0PWovdU4w.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "M PLUS 2", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v4", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/mplus2/v4/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkwOa-VxlqHrzNgAw.ttf", + "200": "http://fonts.gstatic.com/s/mplus2/v4/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkwua6VxlqHrzNgAw.ttf", + "300": "http://fonts.gstatic.com/s/mplus2/v4/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkwZ66VxlqHrzNgAw.ttf", + "regular": "http://fonts.gstatic.com/s/mplus2/v4/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkwOa6VxlqHrzNgAw.ttf", + "500": "http://fonts.gstatic.com/s/mplus2/v4/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkwC66VxlqHrzNgAw.ttf", + "600": "http://fonts.gstatic.com/s/mplus2/v4/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkw56mVxlqHrzNgAw.ttf", + "700": "http://fonts.gstatic.com/s/mplus2/v4/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkw3qmVxlqHrzNgAw.ttf", + "800": "http://fonts.gstatic.com/s/mplus2/v4/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkwuamVxlqHrzNgAw.ttf", + "900": "http://fonts.gstatic.com/s/mplus2/v4/7Auhp_Eq3gO_OGbGGhjdwrDdpeIBxlkwkKmVxlqHrzNgAw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "M PLUS Code Latin", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/mpluscodelatin/v3/hv-ylyV-aXg7x7tULiNXXBA0Np4WMS8fDIymHY8fy8wn4_ifLAtrObKDO0Xf1EbB6i5MqF9TRwg.ttf", + "200": "http://fonts.gstatic.com/s/mpluscodelatin/v3/hv-ylyV-aXg7x7tULiNXXBA0Np4WMS8fDIymHY8fy8wn4_ifLAtrObKDO0Xf1MbA6i5MqF9TRwg.ttf", + "300": "http://fonts.gstatic.com/s/mpluscodelatin/v3/hv-ylyV-aXg7x7tULiNXXBA0Np4WMS8fDIymHY8fy8wn4_ifLAtrObKDO0Xf1BjA6i5MqF9TRwg.ttf", + "regular": "http://fonts.gstatic.com/s/mpluscodelatin/v3/hv-ylyV-aXg7x7tULiNXXBA0Np4WMS8fDIymHY8fy8wn4_ifLAtrObKDO0Xf1EbA6i5MqF9TRwg.ttf", + "500": "http://fonts.gstatic.com/s/mpluscodelatin/v3/hv-ylyV-aXg7x7tULiNXXBA0Np4WMS8fDIymHY8fy8wn4_ifLAtrObKDO0Xf1HTA6i5MqF9TRwg.ttf", + "600": "http://fonts.gstatic.com/s/mpluscodelatin/v3/hv-ylyV-aXg7x7tULiNXXBA0Np4WMS8fDIymHY8fy8wn4_ifLAtrObKDO0Xf1JjH6i5MqF9TRwg.ttf", + "700": "http://fonts.gstatic.com/s/mpluscodelatin/v3/hv-ylyV-aXg7x7tULiNXXBA0Np4WMS8fDIymHY8fy8wn4_ifLAtrObKDO0Xf1KHH6i5MqF9TRwg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "M PLUS Rounded 1c", + "variants": [ + "100", + "300", + "regular", + "500", + "700", + "800", + "900" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "hebrew", + "japanese", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/mplusrounded1c/v13/VdGCAYIAV6gnpUpoWwNkYvrugw9RuM3ixLsg6-av1x0.ttf", + "300": "http://fonts.gstatic.com/s/mplusrounded1c/v13/VdGBAYIAV6gnpUpoWwNkYvrugw9RuM0q5psKxeqmzgRK.ttf", + "regular": "http://fonts.gstatic.com/s/mplusrounded1c/v13/VdGEAYIAV6gnpUpoWwNkYvrugw9RuPWGzr8C7vav.ttf", + "500": "http://fonts.gstatic.com/s/mplusrounded1c/v13/VdGBAYIAV6gnpUpoWwNkYvrugw9RuM1y55sKxeqmzgRK.ttf", + "700": "http://fonts.gstatic.com/s/mplusrounded1c/v13/VdGBAYIAV6gnpUpoWwNkYvrugw9RuM064ZsKxeqmzgRK.ttf", + "800": "http://fonts.gstatic.com/s/mplusrounded1c/v13/VdGBAYIAV6gnpUpoWwNkYvrugw9RuM0m4psKxeqmzgRK.ttf", + "900": "http://fonts.gstatic.com/s/mplusrounded1c/v13/VdGBAYIAV6gnpUpoWwNkYvrugw9RuM0C45sKxeqmzgRK.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ma Shan Zheng", + "variants": [ + "regular" + ], + "subsets": [ + "chinese-simplified", + "latin" + ], + "version": "v8", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/mashanzheng/v8/NaPecZTRCLxvwo41b4gvzkXaRMTsDIRSfr0.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Macondo", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/macondo/v19/RrQQboN9-iB1IXmOS2XO0LBBd4Y.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Macondo Swash Caps", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/macondoswashcaps/v18/6NUL8EaAJgGKZA7lpt941Z9s6ZYgDq6Oekoa_mm5bA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Mada", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "900" + ], + "subsets": [ + "arabic", + "latin" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/mada/v14/7Au_p_0qnzeSdf3nCCL8zkwMIFg.ttf", + "300": "http://fonts.gstatic.com/s/mada/v14/7Au_p_0qnzeSdZnkCCL8zkwMIFg.ttf", + "regular": "http://fonts.gstatic.com/s/mada/v14/7Auwp_0qnzeSTTXMLCrX0kU.ttf", + "500": "http://fonts.gstatic.com/s/mada/v14/7Au_p_0qnzeSdcHlCCL8zkwMIFg.ttf", + "600": "http://fonts.gstatic.com/s/mada/v14/7Au_p_0qnzeSde3iCCL8zkwMIFg.ttf", + "700": "http://fonts.gstatic.com/s/mada/v14/7Au_p_0qnzeSdYnjCCL8zkwMIFg.ttf", + "900": "http://fonts.gstatic.com/s/mada/v14/7Au_p_0qnzeSdbHhCCL8zkwMIFg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Magra", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/magra/v12/uK_94ruaZus72k5xIDMfO-ed.ttf", + "700": "http://fonts.gstatic.com/s/magra/v12/uK_w4ruaZus72nbNDxcXEPuUX1ow.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Maiden Orange", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/maidenorange/v23/kJE1BuIX7AUmhi2V4m08kb1XjOZdCZS8FY8.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Maitree", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-13", + "files": { + "200": "http://fonts.gstatic.com/s/maitree/v8/MjQDmil5tffhpBrklhGNWJGovLdh6OE.ttf", + "300": "http://fonts.gstatic.com/s/maitree/v8/MjQDmil5tffhpBrklnWOWJGovLdh6OE.ttf", + "regular": "http://fonts.gstatic.com/s/maitree/v8/MjQGmil5tffhpBrkrtmmfJmDoL4.ttf", + "500": "http://fonts.gstatic.com/s/maitree/v8/MjQDmil5tffhpBrkli2PWJGovLdh6OE.ttf", + "600": "http://fonts.gstatic.com/s/maitree/v8/MjQDmil5tffhpBrklgGIWJGovLdh6OE.ttf", + "700": "http://fonts.gstatic.com/s/maitree/v8/MjQDmil5tffhpBrklmWJWJGovLdh6OE.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Major Mono Display", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/majormonodisplay/v10/RWmVoLyb5fEqtsfBX9PDZIGr2tFubRhLCn2QIndPww.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Mako", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/mako/v14/H4coBX6Mmc_Z0ST09g478Lo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mali", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v7", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/mali/v7/N0bV2SRONuN4QOLlKlRaJdbWgdY.ttf", + "200italic": "http://fonts.gstatic.com/s/mali/v7/N0bX2SRONuN4SCj8wlVQIfTTkdbJYA.ttf", + "300": "http://fonts.gstatic.com/s/mali/v7/N0bV2SRONuN4QIbmKlRaJdbWgdY.ttf", + "300italic": "http://fonts.gstatic.com/s/mali/v7/N0bX2SRONuN4SCj8plZQIfTTkdbJYA.ttf", + "regular": "http://fonts.gstatic.com/s/mali/v7/N0ba2SRONuN4eCrODlxxOd8.ttf", + "italic": "http://fonts.gstatic.com/s/mali/v7/N0bU2SRONuN4SCjECn50Kd_PmA.ttf", + "500": "http://fonts.gstatic.com/s/mali/v7/N0bV2SRONuN4QN7nKlRaJdbWgdY.ttf", + "500italic": "http://fonts.gstatic.com/s/mali/v7/N0bX2SRONuN4SCj8_ldQIfTTkdbJYA.ttf", + "600": "http://fonts.gstatic.com/s/mali/v7/N0bV2SRONuN4QPLgKlRaJdbWgdY.ttf", + "600italic": "http://fonts.gstatic.com/s/mali/v7/N0bX2SRONuN4SCj80lBQIfTTkdbJYA.ttf", + "700": "http://fonts.gstatic.com/s/mali/v7/N0bV2SRONuN4QJbhKlRaJdbWgdY.ttf", + "700italic": "http://fonts.gstatic.com/s/mali/v7/N0bX2SRONuN4SCj8tlFQIfTTkdbJYA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Mallanna", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/mallanna/v11/hv-Vlzx-KEQb84YaDGwzEzRwVvJ-.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mandali", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/mandali/v12/LhWlMVbYOfASNfNUVFk1ZPdcKtA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Manjari", + "variants": [ + "100", + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "malayalam" + ], + "version": "v7", + "lastModified": "2022-01-13", + "files": { + "100": "http://fonts.gstatic.com/s/manjari/v7/k3kSo8UPMOBO2w1UdbroK2vFIaOV8A.ttf", + "regular": "http://fonts.gstatic.com/s/manjari/v7/k3kQo8UPMOBO2w1UTd7iL0nAMaM.ttf", + "700": "http://fonts.gstatic.com/s/manjari/v7/k3kVo8UPMOBO2w1UdWLNC0HrLaqM6Q4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Manrope", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/manrope/v11/xn7_YHE41ni1AdIRqAuZuw1Bx9mbZk59FO_F87jxeN7B.ttf", + "300": "http://fonts.gstatic.com/s/manrope/v11/xn7_YHE41ni1AdIRqAuZuw1Bx9mbZk6jFO_F87jxeN7B.ttf", + "regular": "http://fonts.gstatic.com/s/manrope/v11/xn7_YHE41ni1AdIRqAuZuw1Bx9mbZk79FO_F87jxeN7B.ttf", + "500": "http://fonts.gstatic.com/s/manrope/v11/xn7_YHE41ni1AdIRqAuZuw1Bx9mbZk7PFO_F87jxeN7B.ttf", + "600": "http://fonts.gstatic.com/s/manrope/v11/xn7_YHE41ni1AdIRqAuZuw1Bx9mbZk4jE-_F87jxeN7B.ttf", + "700": "http://fonts.gstatic.com/s/manrope/v11/xn7_YHE41ni1AdIRqAuZuw1Bx9mbZk4aE-_F87jxeN7B.ttf", + "800": "http://fonts.gstatic.com/s/manrope/v11/xn7_YHE41ni1AdIRqAuZuw1Bx9mbZk59E-_F87jxeN7B.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mansalva", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v7", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/mansalva/v7/aWB4m0aacbtDfvq5NJllI47vdyBg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Manuale", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "800", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v21", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/manuale/v21/f0Xp0eas_8Z-TFZdHv3mMxFaSqASeeG6e7wD1TB_JHHY.ttf", + "regular": "http://fonts.gstatic.com/s/manuale/v21/f0Xp0eas_8Z-TFZdHv3mMxFaSqASeeHke7wD1TB_JHHY.ttf", + "500": "http://fonts.gstatic.com/s/manuale/v21/f0Xp0eas_8Z-TFZdHv3mMxFaSqASeeHWe7wD1TB_JHHY.ttf", + "600": "http://fonts.gstatic.com/s/manuale/v21/f0Xp0eas_8Z-TFZdHv3mMxFaSqASeeE6fLwD1TB_JHHY.ttf", + "700": "http://fonts.gstatic.com/s/manuale/v21/f0Xp0eas_8Z-TFZdHv3mMxFaSqASeeEDfLwD1TB_JHHY.ttf", + "800": "http://fonts.gstatic.com/s/manuale/v21/f0Xp0eas_8Z-TFZdHv3mMxFaSqASeeFkfLwD1TB_JHHY.ttf", + "300italic": "http://fonts.gstatic.com/s/manuale/v21/f0Xn0eas_8Z-TFZdNPTUzMkzITq8fvQsOApA3zRdIWHYr8M.ttf", + "italic": "http://fonts.gstatic.com/s/manuale/v21/f0Xn0eas_8Z-TFZdNPTUzMkzITq8fvQsOFRA3zRdIWHYr8M.ttf", + "500italic": "http://fonts.gstatic.com/s/manuale/v21/f0Xn0eas_8Z-TFZdNPTUzMkzITq8fvQsOGZA3zRdIWHYr8M.ttf", + "600italic": "http://fonts.gstatic.com/s/manuale/v21/f0Xn0eas_8Z-TFZdNPTUzMkzITq8fvQsOIpH3zRdIWHYr8M.ttf", + "700italic": "http://fonts.gstatic.com/s/manuale/v21/f0Xn0eas_8Z-TFZdNPTUzMkzITq8fvQsOLNH3zRdIWHYr8M.ttf", + "800italic": "http://fonts.gstatic.com/s/manuale/v21/f0Xn0eas_8Z-TFZdNPTUzMkzITq8fvQsONRH3zRdIWHYr8M.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Marcellus", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/marcellus/v11/wEO_EBrOk8hQLDvIAF8FUfAL3EsHiA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Marcellus SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/marcellussc/v11/ke8iOgUHP1dg-Rmi6RWjbLEPgdydGKikhA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Marck Script", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/marckscript/v14/nwpTtK2oNgBA3Or78gapdwuCzyI-aMPF7Q.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Margarine", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/margarine/v19/qkBXXvoE6trLT9Y7YLye5JRLkAXbMQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Markazi Text", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v20", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/markazitext/v20/sykh-ydym6AtQaiEtX7yhqb_rV1k_81ZVYYZtfSQT4MlBekmJLo.ttf", + "500": "http://fonts.gstatic.com/s/markazitext/v20/sykh-ydym6AtQaiEtX7yhqb_rV1k_81ZVYYZtcaQT4MlBekmJLo.ttf", + "600": "http://fonts.gstatic.com/s/markazitext/v20/sykh-ydym6AtQaiEtX7yhqb_rV1k_81ZVYYZtSqXT4MlBekmJLo.ttf", + "700": "http://fonts.gstatic.com/s/markazitext/v20/sykh-ydym6AtQaiEtX7yhqb_rV1k_81ZVYYZtROXT4MlBekmJLo.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Marko One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/markoone/v20/9Btq3DFG0cnVM5lw1haaKpUfrHPzUw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Marmelad", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/marmelad/v13/Qw3eZQdSHj_jK2e-8tFLG-YMC0R8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Martel", + "variants": [ + "200", + "300", + "regular", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-27", + "files": { + "200": "http://fonts.gstatic.com/s/martel/v9/PN_yRfK9oXHga0XVqekahRbX9vnDzw.ttf", + "300": "http://fonts.gstatic.com/s/martel/v9/PN_yRfK9oXHga0XVzeoahRbX9vnDzw.ttf", + "regular": "http://fonts.gstatic.com/s/martel/v9/PN_xRfK9oXHga0XtYcI-jT3L_w.ttf", + "600": "http://fonts.gstatic.com/s/martel/v9/PN_yRfK9oXHga0XVuewahRbX9vnDzw.ttf", + "700": "http://fonts.gstatic.com/s/martel/v9/PN_yRfK9oXHga0XV3e0ahRbX9vnDzw.ttf", + "800": "http://fonts.gstatic.com/s/martel/v9/PN_yRfK9oXHga0XVwe4ahRbX9vnDzw.ttf", + "900": "http://fonts.gstatic.com/s/martel/v9/PN_yRfK9oXHga0XV5e8ahRbX9vnDzw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Martel Sans", + "variants": [ + "200", + "300", + "regular", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/martelsans/v10/h0GxssGi7VdzDgKjM-4d8hAX5suHFUknqMxQ.ttf", + "300": "http://fonts.gstatic.com/s/martelsans/v10/h0GxssGi7VdzDgKjM-4d8hBz5cuHFUknqMxQ.ttf", + "regular": "http://fonts.gstatic.com/s/martelsans/v10/h0GsssGi7VdzDgKjM-4d8ijfze-PPlUu.ttf", + "600": "http://fonts.gstatic.com/s/martelsans/v10/h0GxssGi7VdzDgKjM-4d8hAH48uHFUknqMxQ.ttf", + "700": "http://fonts.gstatic.com/s/martelsans/v10/h0GxssGi7VdzDgKjM-4d8hBj4suHFUknqMxQ.ttf", + "800": "http://fonts.gstatic.com/s/martelsans/v10/h0GxssGi7VdzDgKjM-4d8hB_4cuHFUknqMxQ.ttf", + "900": "http://fonts.gstatic.com/s/martelsans/v10/h0GxssGi7VdzDgKjM-4d8hBb4MuHFUknqMxQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Marvel", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v10", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/marvel/v10/nwpVtKeoNgBV0qaIkV7ED366zg.ttf", + "italic": "http://fonts.gstatic.com/s/marvel/v10/nwpXtKeoNgBV0qa4k1TALXuqzhA7.ttf", + "700": "http://fonts.gstatic.com/s/marvel/v10/nwpWtKeoNgBV0qawLXHgB1WmxwkiYQ.ttf", + "700italic": "http://fonts.gstatic.com/s/marvel/v10/nwpQtKeoNgBV0qa4k2x8Al-i5QwyYdrc.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mate", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/mate/v12/m8JdjftRd7WZ2z28WoXSaLU.ttf", + "italic": "http://fonts.gstatic.com/s/mate/v12/m8JTjftRd7WZ6z-2XqfXeLVdbw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mate SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/matesc/v19/-nF8OGQ1-uoVr2wKyiXZ95OkJwA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Maven Pro", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v28", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/mavenpro/v28/7Auup_AqnyWWAxW2Wk3swUz56MS91Eww8SX25nCpozp5GvU.ttf", + "500": "http://fonts.gstatic.com/s/mavenpro/v28/7Auup_AqnyWWAxW2Wk3swUz56MS91Eww8Rf25nCpozp5GvU.ttf", + "600": "http://fonts.gstatic.com/s/mavenpro/v28/7Auup_AqnyWWAxW2Wk3swUz56MS91Eww8fvx5nCpozp5GvU.ttf", + "700": "http://fonts.gstatic.com/s/mavenpro/v28/7Auup_AqnyWWAxW2Wk3swUz56MS91Eww8cLx5nCpozp5GvU.ttf", + "800": "http://fonts.gstatic.com/s/mavenpro/v28/7Auup_AqnyWWAxW2Wk3swUz56MS91Eww8aXx5nCpozp5GvU.ttf", + "900": "http://fonts.gstatic.com/s/mavenpro/v28/7Auup_AqnyWWAxW2Wk3swUz56MS91Eww8Yzx5nCpozp5GvU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "McLaren", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/mclaren/v11/2EbnL-ZuAXFqZFXISYYf8z2Yt_c.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Mea Culpa", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/meaculpa/v1/AMOTz4GcuWbEIuza8jsZms0QW3mqyg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Meddon", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/meddon/v18/kmK8ZqA2EgDNeHTZhBdB3y_Aow.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "MedievalSharp", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v22", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/medievalsharp/v22/EvOJzAlL3oU5AQl2mP5KdgptAq96MwvXLDk.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Medula One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/medulaone/v17/YA9Wr0qb5kjJM6l2V0yukiEqs7GtlvY.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Meera Inimai", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "tamil" + ], + "version": "v10", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/meerainimai/v10/845fNMM5EIqOW5MPuvO3ILep_2jDVevnLQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Megrim", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/megrim/v14/46kulbz5WjvLqJZlbWXgd0RY1g.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Meie Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/meiescript/v19/_LOImzDK7erRjhunIspaMjxn5IXg0WDz.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Meow Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/meowscript/v3/0FlQVPqanlaJrtr8AnJ0ESch0_0CfDf1.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Merienda", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/merienda/v12/gNMHW3x8Qoy5_mf8uVMCOou6_dvg.ttf", + "700": "http://fonts.gstatic.com/s/merienda/v12/gNMAW3x8Qoy5_mf8uWu-Fa-y1sfpPES4.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Merienda One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/meriendaone/v14/H4cgBXaMndbflEq6kyZ1ht6YgoyyYzFzFw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Merriweather", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v28", + "lastModified": "2021-12-17", + "files": { + "300": "http://fonts.gstatic.com/s/merriweather/v28/u-4n0qyriQwlOrhSvowK_l521wRpX837pvjxPA.ttf", + "300italic": "http://fonts.gstatic.com/s/merriweather/v28/u-4l0qyriQwlOrhSvowK_l5-eR7lXcf_hP3hPGWH.ttf", + "regular": "http://fonts.gstatic.com/s/merriweather/v28/u-440qyriQwlOrhSvowK_l5OeyxNV-bnrw.ttf", + "italic": "http://fonts.gstatic.com/s/merriweather/v28/u-4m0qyriQwlOrhSvowK_l5-eSZJdeP3r-Ho.ttf", + "700": "http://fonts.gstatic.com/s/merriweather/v28/u-4n0qyriQwlOrhSvowK_l52xwNpX837pvjxPA.ttf", + "700italic": "http://fonts.gstatic.com/s/merriweather/v28/u-4l0qyriQwlOrhSvowK_l5-eR71Wsf_hP3hPGWH.ttf", + "900": "http://fonts.gstatic.com/s/merriweather/v28/u-4n0qyriQwlOrhSvowK_l52_wFpX837pvjxPA.ttf", + "900italic": "http://fonts.gstatic.com/s/merriweather/v28/u-4l0qyriQwlOrhSvowK_l5-eR7NWMf_hP3hPGWH.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Merriweather Sans", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "800", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic" + ], + "subsets": [ + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v20", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cO9IRs1JiJN1FRAMjTN5zd9vgsFF_5asQTb6hZ2JKZ_O4ljuEG7xFHnQ.ttf", + "regular": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cO9IRs1JiJN1FRAMjTN5zd9vgsFF_5asQTb6hZ2JKZou4ljuEG7xFHnQ.ttf", + "500": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cO9IRs1JiJN1FRAMjTN5zd9vgsFF_5asQTb6hZ2JKZkO4ljuEG7xFHnQ.ttf", + "600": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cO9IRs1JiJN1FRAMjTN5zd9vgsFF_5asQTb6hZ2JKZfOkljuEG7xFHnQ.ttf", + "700": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cO9IRs1JiJN1FRAMjTN5zd9vgsFF_5asQTb6hZ2JKZRekljuEG7xFHnQ.ttf", + "800": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cO9IRs1JiJN1FRAMjTN5zd9vgsFF_5asQTb6hZ2JKZIukljuEG7xFHnQ.ttf", + "300italic": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cM9IRs1JiJN1FRAMjTN5zd9vgsFHXwWDvLBsPDdpWMaq2TzesCzRRXnaur.ttf", + "italic": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cM9IRs1JiJN1FRAMjTN5zd9vgsFHXwWDvLBsPDdpWMaq3NzesCzRRXnaur.ttf", + "500italic": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cM9IRs1JiJN1FRAMjTN5zd9vgsFHXwWDvLBsPDdpWMaq3_zesCzRRXnaur.ttf", + "600italic": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cM9IRs1JiJN1FRAMjTN5zd9vgsFHXwWDvLBsPDdpWMaq0TyusCzRRXnaur.ttf", + "700italic": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cM9IRs1JiJN1FRAMjTN5zd9vgsFHXwWDvLBsPDdpWMaq0qyusCzRRXnaur.ttf", + "800italic": "http://fonts.gstatic.com/s/merriweathersans/v20/2-cM9IRs1JiJN1FRAMjTN5zd9vgsFHXwWDvLBsPDdpWMaq1NyusCzRRXnaur.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Metal", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v26", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/metal/v26/lW-wwjUJIXTo7i3nnoQAUdN2.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Metal Mania", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/metalmania/v20/RWmMoKWb4e8kqMfBUdPFJeXCg6UKDXlq.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Metamorphous", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/metamorphous/v16/Wnz8HA03aAXcC39ZEX5y1330PCCthTsmaQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Metrophobic", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/metrophobic/v17/sJoA3LZUhMSAPV_u0qwiAT-J737FPEEL.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Michroma", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/michroma/v14/PN_zRfy9qWD8fEagAMg6rzjb_-Da.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Milonga", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/milonga/v18/SZc53FHnIaK9W5kffz3GkUrS8DI.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Miltonian", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v24", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/miltonian/v24/zOL-4pbPn6Ne9JqTg9mr6e5As-FeiQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Miltonian Tattoo", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v26", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/miltoniantattoo/v26/EvOUzBRL0o0kCxF-lcMCQxlpVsA_FwP8MDBku-s.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Mina", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "bengali", + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/mina/v9/-nFzOGc18vARrz9j7i3y65o.ttf", + "700": "http://fonts.gstatic.com/s/mina/v9/-nF8OGc18vARl4NMyiXZ95OkJwA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Miniver", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/miniver/v19/eLGcP-PxIg-5H0vC770Cy8r8fWA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Miriam Libre", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "hebrew", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/miriamlibre/v10/DdTh798HsHwubBAqfkcBTL_vYJn_Teun9g.ttf", + "700": "http://fonts.gstatic.com/s/miriamlibre/v10/DdT-798HsHwubBAqfkcBTL_X3LbbRcC7_-Z7Hg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mirza", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/mirza/v13/co3ImWlikiN5EurdKMewsrvI.ttf", + "500": "http://fonts.gstatic.com/s/mirza/v13/co3FmWlikiN5EtIpAeO4mafBomDi.ttf", + "600": "http://fonts.gstatic.com/s/mirza/v13/co3FmWlikiN5EtIFBuO4mafBomDi.ttf", + "700": "http://fonts.gstatic.com/s/mirza/v13/co3FmWlikiN5EtJhB-O4mafBomDi.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Miss Fajardose", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/missfajardose/v20/E21-_dn5gvrawDdPFVl-N0Ajb8qvWPaJq4no.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Mitr", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/mitr/v9/pxiEypw5ucZF8fMZFJDUc1NECPY.ttf", + "300": "http://fonts.gstatic.com/s/mitr/v9/pxiEypw5ucZF8ZcaFJDUc1NECPY.ttf", + "regular": "http://fonts.gstatic.com/s/mitr/v9/pxiLypw5ucZFyTsyMJj_b1o.ttf", + "500": "http://fonts.gstatic.com/s/mitr/v9/pxiEypw5ucZF8c8bFJDUc1NECPY.ttf", + "600": "http://fonts.gstatic.com/s/mitr/v9/pxiEypw5ucZF8eMcFJDUc1NECPY.ttf", + "700": "http://fonts.gstatic.com/s/mitr/v9/pxiEypw5ucZF8YcdFJDUc1NECPY.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mochiy Pop One", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/mochiypopone/v5/QdVPSTA9Jh-gg-5XZP2UmU4O9kwwD3s6ZKAi.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mochiy Pop P One", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/mochiypoppone/v5/Ktk2AKuPeY_td1-h9LayHYWCjAqyN4O3WYZB_sU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Modak", + "variants": [ + "regular" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/modak/v16/EJRYQgs1XtIEsnMH8BVZ76KU.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Modern Antiqua", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/modernantiqua/v20/NGStv5TIAUg6Iq_RLNo_2dp1sI1Ea2u0c3Gi.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Mogra", + "variants": [ + "regular" + ], + "subsets": [ + "gujarati", + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/mogra/v17/f0X40eSs8c95TBo4DvLmxtnG.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Mohave", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "300italic", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v6", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/mohave/v6/7cH0v4ksjJunKqMVAOPIMOeSmiojdif_HvCQopLSvBk.ttf", + "regular": "http://fonts.gstatic.com/s/mohave/v6/7cH0v4ksjJunKqMVAOPIMOeSmiojdnn_HvCQopLSvBk.ttf", + "500": "http://fonts.gstatic.com/s/mohave/v6/7cH0v4ksjJunKqMVAOPIMOeSmiojdkv_HvCQopLSvBk.ttf", + "600": "http://fonts.gstatic.com/s/mohave/v6/7cH0v4ksjJunKqMVAOPIMOeSmiojdqf4HvCQopLSvBk.ttf", + "700": "http://fonts.gstatic.com/s/mohave/v6/7cH0v4ksjJunKqMVAOPIMOeSmiojdp74HvCQopLSvBk.ttf", + "300italic": "http://fonts.gstatic.com/s/mohave/v6/7cH2v4ksjJunKqM_CdE36I75AIQkY7G8qLOaprDXrBlSVw.ttf", + "italic": "http://fonts.gstatic.com/s/mohave/v6/7cH2v4ksjJunKqM_CdE36I75AIQkY7G89rOaprDXrBlSVw.ttf", + "500italic": "http://fonts.gstatic.com/s/mohave/v6/7cH2v4ksjJunKqM_CdE36I75AIQkY7G8xLOaprDXrBlSVw.ttf", + "600italic": "http://fonts.gstatic.com/s/mohave/v6/7cH2v4ksjJunKqM_CdE36I75AIQkY7G8KLSaprDXrBlSVw.ttf", + "700italic": "http://fonts.gstatic.com/s/mohave/v6/7cH2v4ksjJunKqM_CdE36I75AIQkY7G8EbSaprDXrBlSVw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Molengo", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/molengo/v14/I_uuMpWeuBzZNBtQbbRQkiCvs5Y.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Molle", + "variants": [ + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "italic": "http://fonts.gstatic.com/s/molle/v19/E21n_dL5hOXFhWEsXzgmVydREus.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Monda", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/monda/v14/TK3tWkYFABsmjvpmNBsLvPdG.ttf", + "700": "http://fonts.gstatic.com/s/monda/v14/TK3gWkYFABsmjsLaGz8Dl-tPKo2t.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Monofett", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/monofett/v20/mFTyWbofw6zc9NtnW43SuRwr0VJ7.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Monoton", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/monoton/v13/5h1aiZUrOngCibe4fkbBQ2S7FU8.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Monsieur La Doulaise", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/monsieurladoulaise/v12/_Xmz-GY4rjmCbQfc-aPRaa4pqV340p7EZl5ewkEU4HTy.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Montaga", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/montaga/v11/H4cnBX2Ml8rCkEO_0gYQ7LO5mqc.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Montagu Slab", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v2", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/montaguslab/v2/6qLhKZIQtB_zv0xUaXRDWkY_HXsphdLRZF40vm_jzR2jhk_n3T6ACkDbE3P9Fs7bOSO7.ttf", + "200": "http://fonts.gstatic.com/s/montaguslab/v2/6qLhKZIQtB_zv0xUaXRDWkY_HXsphdLRZF40vm_jzR2jhk_n3T6ACkBbEnP9Fs7bOSO7.ttf", + "300": "http://fonts.gstatic.com/s/montaguslab/v2/6qLhKZIQtB_zv0xUaXRDWkY_HXsphdLRZF40vm_jzR2jhk_n3T6ACkCFEnP9Fs7bOSO7.ttf", + "regular": "http://fonts.gstatic.com/s/montaguslab/v2/6qLhKZIQtB_zv0xUaXRDWkY_HXsphdLRZF40vm_jzR2jhk_n3T6ACkDbEnP9Fs7bOSO7.ttf", + "500": "http://fonts.gstatic.com/s/montaguslab/v2/6qLhKZIQtB_zv0xUaXRDWkY_HXsphdLRZF40vm_jzR2jhk_n3T6ACkDpEnP9Fs7bOSO7.ttf", + "600": "http://fonts.gstatic.com/s/montaguslab/v2/6qLhKZIQtB_zv0xUaXRDWkY_HXsphdLRZF40vm_jzR2jhk_n3T6ACkAFFXP9Fs7bOSO7.ttf", + "700": "http://fonts.gstatic.com/s/montaguslab/v2/6qLhKZIQtB_zv0xUaXRDWkY_HXsphdLRZF40vm_jzR2jhk_n3T6ACkA8FXP9Fs7bOSO7.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "MonteCarlo", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/montecarlo/v5/buEzpo6-f9X01GadLA0G0CoV_NxLeiw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Montez", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/montez/v16/845ZNMk5GoGIX8lm1LDeSd-R_g.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Montserrat", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v23", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/montserrat/v23/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Uw-Y3tcoqK5.ttf", + "200": "http://fonts.gstatic.com/s/montserrat/v23/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCvr6Ew-Y3tcoqK5.ttf", + "300": "http://fonts.gstatic.com/s/montserrat/v23/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCs16Ew-Y3tcoqK5.ttf", + "regular": "http://fonts.gstatic.com/s/montserrat/v23/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Ew-Y3tcoqK5.ttf", + "500": "http://fonts.gstatic.com/s/montserrat/v23/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtZ6Ew-Y3tcoqK5.ttf", + "600": "http://fonts.gstatic.com/s/montserrat/v23/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCu170w-Y3tcoqK5.ttf", + "700": "http://fonts.gstatic.com/s/montserrat/v23/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCuM70w-Y3tcoqK5.ttf", + "800": "http://fonts.gstatic.com/s/montserrat/v23/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCvr70w-Y3tcoqK5.ttf", + "900": "http://fonts.gstatic.com/s/montserrat/v23/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCvC70w-Y3tcoqK5.ttf", + "100italic": "http://fonts.gstatic.com/s/montserrat/v23/JTUFjIg1_i6t8kCHKm459Wx7xQYXK0vOoz6jq6R8aX9-p7K5ILg.ttf", + "200italic": "http://fonts.gstatic.com/s/montserrat/v23/JTUFjIg1_i6t8kCHKm459Wx7xQYXK0vOoz6jqyR9aX9-p7K5ILg.ttf", + "300italic": "http://fonts.gstatic.com/s/montserrat/v23/JTUFjIg1_i6t8kCHKm459Wx7xQYXK0vOoz6jq_p9aX9-p7K5ILg.ttf", + "italic": "http://fonts.gstatic.com/s/montserrat/v23/JTUFjIg1_i6t8kCHKm459Wx7xQYXK0vOoz6jq6R9aX9-p7K5ILg.ttf", + "500italic": "http://fonts.gstatic.com/s/montserrat/v23/JTUFjIg1_i6t8kCHKm459Wx7xQYXK0vOoz6jq5Z9aX9-p7K5ILg.ttf", + "600italic": "http://fonts.gstatic.com/s/montserrat/v23/JTUFjIg1_i6t8kCHKm459Wx7xQYXK0vOoz6jq3p6aX9-p7K5ILg.ttf", + "700italic": "http://fonts.gstatic.com/s/montserrat/v23/JTUFjIg1_i6t8kCHKm459Wx7xQYXK0vOoz6jq0N6aX9-p7K5ILg.ttf", + "800italic": "http://fonts.gstatic.com/s/montserrat/v23/JTUFjIg1_i6t8kCHKm459Wx7xQYXK0vOoz6jqyR6aX9-p7K5ILg.ttf", + "900italic": "http://fonts.gstatic.com/s/montserrat/v23/JTUFjIg1_i6t8kCHKm459Wx7xQYXK0vOoz6jqw16aX9-p7K5ILg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Montserrat Alternates", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/montserratalternates/v16/mFThWacfw6zH4dthXcyms1lPpC8I_b0juU0xiKfVKphL03l4.ttf", + "100italic": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTjWacfw6zH4dthXcyms1lPpC8I_b0juU057p-xIJxp1ml4imo.ttf", + "200": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTiWacfw6zH4dthXcyms1lPpC8I_b0juU0xJIb1ALZH2mBhkw.ttf", + "200italic": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTkWacfw6zH4dthXcyms1lPpC8I_b0juU057p8dAbxD-GVxk3Nd.ttf", + "300": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTiWacfw6zH4dthXcyms1lPpC8I_b0juU0xQIX1ALZH2mBhkw.ttf", + "300italic": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTkWacfw6zH4dthXcyms1lPpC8I_b0juU057p95ArxD-GVxk3Nd.ttf", + "regular": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTvWacfw6zH4dthXcyms1lPpC8I_b0juU0J7K3RCJ1b0w.ttf", + "italic": "http://fonts.gstatic.com/s/montserratalternates/v16/mFThWacfw6zH4dthXcyms1lPpC8I_b0juU057qfVKphL03l4.ttf", + "500": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTiWacfw6zH4dthXcyms1lPpC8I_b0juU0xGIT1ALZH2mBhkw.ttf", + "500italic": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTkWacfw6zH4dthXcyms1lPpC8I_b0juU057p8hA7xD-GVxk3Nd.ttf", + "600": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTiWacfw6zH4dthXcyms1lPpC8I_b0juU0xNIP1ALZH2mBhkw.ttf", + "600italic": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTkWacfw6zH4dthXcyms1lPpC8I_b0juU057p8NBLxD-GVxk3Nd.ttf", + "700": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTiWacfw6zH4dthXcyms1lPpC8I_b0juU0xUIL1ALZH2mBhkw.ttf", + "700italic": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTkWacfw6zH4dthXcyms1lPpC8I_b0juU057p9pBbxD-GVxk3Nd.ttf", + "800": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTiWacfw6zH4dthXcyms1lPpC8I_b0juU0xTIH1ALZH2mBhkw.ttf", + "800italic": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTkWacfw6zH4dthXcyms1lPpC8I_b0juU057p91BrxD-GVxk3Nd.ttf", + "900": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTiWacfw6zH4dthXcyms1lPpC8I_b0juU0xaID1ALZH2mBhkw.ttf", + "900italic": "http://fonts.gstatic.com/s/montserratalternates/v16/mFTkWacfw6zH4dthXcyms1lPpC8I_b0juU057p9RB7xD-GVxk3Nd.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Montserrat Subrayada", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/montserratsubrayada/v15/U9MD6c-o9H7PgjlTHThBnNHGVUORwteQQE8LYuceqGT-.ttf", + "700": "http://fonts.gstatic.com/s/montserratsubrayada/v15/U9MM6c-o9H7PgjlTHThBnNHGVUORwteQQHe3TcMWg3j36Ebz.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Moo Lah Lah", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/moolahlah/v1/dg4h_p_opKZOA0w1AYcm55wtYQYugjW4.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Moon Dance", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/moondance/v1/WBLgrEbUbFlYW9ekmGawe2XiKMiokE4.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Moul", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v23", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/moul/v23/nuF2D__FSo_3E-RYiJCy-00.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Moulpali", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v26", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/moulpali/v26/H4ckBXKMl9HagUWymyY6wr-wg763.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Mountains of Christmas", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/mountainsofchristmas/v18/3y9w6a4zcCnn5X0FDyrKi2ZRUBIy8uxoUo7ePNamMPNpJpc.ttf", + "700": "http://fonts.gstatic.com/s/mountainsofchristmas/v18/3y9z6a4zcCnn5X0FDyrKi2ZRUBIy8uxoUo7eBGqJFPtCOp6IaEA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Mouse Memoirs", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/mousememoirs/v11/t5tmIRoSNJ-PH0WNNgDYxdSb7TnFrpOHYh4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mr Bedfort", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/mrbedfort/v19/MQpR-WCtNZSWAdTMwBicliq0XZe_Iy8.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Mr Dafoe", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/mrdafoe/v12/lJwE-pIzkS5NXuMMrGiqg7MCxz_C.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Mr De Haviland", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/mrdehaviland/v12/OpNVnooIhJj96FdB73296ksbOj3C4ULVNTlB.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Mrs Saint Delafield", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/mrssaintdelafield/v11/v6-IGZDIOVXH9xtmTZfRagunqBw5WC62cK4tLsubB2w.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Mrs Sheppards", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/mrssheppards/v19/PN_2Rfm9snC0XUGoEZhb91ig3vjxynMix4Y.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Mukta", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-27", + "files": { + "200": "http://fonts.gstatic.com/s/mukta/v12/iJWHBXyXfDDVXbEOjFma-2HW7ZB_.ttf", + "300": "http://fonts.gstatic.com/s/mukta/v12/iJWHBXyXfDDVXbFqj1ma-2HW7ZB_.ttf", + "regular": "http://fonts.gstatic.com/s/mukta/v12/iJWKBXyXfDDVXYnGp32S0H3f.ttf", + "500": "http://fonts.gstatic.com/s/mukta/v12/iJWHBXyXfDDVXbEyjlma-2HW7ZB_.ttf", + "600": "http://fonts.gstatic.com/s/mukta/v12/iJWHBXyXfDDVXbEeiVma-2HW7ZB_.ttf", + "700": "http://fonts.gstatic.com/s/mukta/v12/iJWHBXyXfDDVXbF6iFma-2HW7ZB_.ttf", + "800": "http://fonts.gstatic.com/s/mukta/v12/iJWHBXyXfDDVXbFmi1ma-2HW7ZB_.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mukta Mahee", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "gurmukhi", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "200": "http://fonts.gstatic.com/s/muktamahee/v13/XRXN3IOIi0hcP8iVU67hA9MFcBoHJndqZCsW.ttf", + "300": "http://fonts.gstatic.com/s/muktamahee/v13/XRXN3IOIi0hcP8iVU67hA9NhcxoHJndqZCsW.ttf", + "regular": "http://fonts.gstatic.com/s/muktamahee/v13/XRXQ3IOIi0hcP8iVU67hA-vNWz4PDWtj.ttf", + "500": "http://fonts.gstatic.com/s/muktamahee/v13/XRXN3IOIi0hcP8iVU67hA9M5choHJndqZCsW.ttf", + "600": "http://fonts.gstatic.com/s/muktamahee/v13/XRXN3IOIi0hcP8iVU67hA9MVdRoHJndqZCsW.ttf", + "700": "http://fonts.gstatic.com/s/muktamahee/v13/XRXN3IOIi0hcP8iVU67hA9NxdBoHJndqZCsW.ttf", + "800": "http://fonts.gstatic.com/s/muktamahee/v13/XRXN3IOIi0hcP8iVU67hA9NtdxoHJndqZCsW.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mukta Malar", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext", + "tamil" + ], + "version": "v10", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/muktamalar/v10/MCoKzAXyz8LOE2FpJMxZqIMwBtAB62ruoAZW.ttf", + "300": "http://fonts.gstatic.com/s/muktamalar/v10/MCoKzAXyz8LOE2FpJMxZqINUBdAB62ruoAZW.ttf", + "regular": "http://fonts.gstatic.com/s/muktamalar/v10/MCoXzAXyz8LOE2FpJMxZqLv4LfQJwHbn.ttf", + "500": "http://fonts.gstatic.com/s/muktamalar/v10/MCoKzAXyz8LOE2FpJMxZqIMMBNAB62ruoAZW.ttf", + "600": "http://fonts.gstatic.com/s/muktamalar/v10/MCoKzAXyz8LOE2FpJMxZqIMgA9AB62ruoAZW.ttf", + "700": "http://fonts.gstatic.com/s/muktamalar/v10/MCoKzAXyz8LOE2FpJMxZqINEAtAB62ruoAZW.ttf", + "800": "http://fonts.gstatic.com/s/muktamalar/v10/MCoKzAXyz8LOE2FpJMxZqINYAdAB62ruoAZW.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mukta Vaani", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "gujarati", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "200": "http://fonts.gstatic.com/s/muktavaani/v11/3JnkSD_-ynaxmxnEfVHPIGXNV8BD-u97MW1a.ttf", + "300": "http://fonts.gstatic.com/s/muktavaani/v11/3JnkSD_-ynaxmxnEfVHPIGWpVMBD-u97MW1a.ttf", + "regular": "http://fonts.gstatic.com/s/muktavaani/v11/3Jn5SD_-ynaxmxnEfVHPIF0FfORL0fNy.ttf", + "500": "http://fonts.gstatic.com/s/muktavaani/v11/3JnkSD_-ynaxmxnEfVHPIGXxVcBD-u97MW1a.ttf", + "600": "http://fonts.gstatic.com/s/muktavaani/v11/3JnkSD_-ynaxmxnEfVHPIGXdUsBD-u97MW1a.ttf", + "700": "http://fonts.gstatic.com/s/muktavaani/v11/3JnkSD_-ynaxmxnEfVHPIGW5U8BD-u97MW1a.ttf", + "800": "http://fonts.gstatic.com/s/muktavaani/v11/3JnkSD_-ynaxmxnEfVHPIGWlUMBD-u97MW1a.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Mulish", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/mulish/v10/1Ptyg83HX_SGhgqO0yLcmjzUAuWexRNRwaClGrw-PTY.ttf", + "300": "http://fonts.gstatic.com/s/mulish/v10/1Ptyg83HX_SGhgqO0yLcmjzUAuWexc1RwaClGrw-PTY.ttf", + "regular": "http://fonts.gstatic.com/s/mulish/v10/1Ptyg83HX_SGhgqO0yLcmjzUAuWexZNRwaClGrw-PTY.ttf", + "500": "http://fonts.gstatic.com/s/mulish/v10/1Ptyg83HX_SGhgqO0yLcmjzUAuWexaFRwaClGrw-PTY.ttf", + "600": "http://fonts.gstatic.com/s/mulish/v10/1Ptyg83HX_SGhgqO0yLcmjzUAuWexU1WwaClGrw-PTY.ttf", + "700": "http://fonts.gstatic.com/s/mulish/v10/1Ptyg83HX_SGhgqO0yLcmjzUAuWexXRWwaClGrw-PTY.ttf", + "800": "http://fonts.gstatic.com/s/mulish/v10/1Ptyg83HX_SGhgqO0yLcmjzUAuWexRNWwaClGrw-PTY.ttf", + "900": "http://fonts.gstatic.com/s/mulish/v10/1Ptyg83HX_SGhgqO0yLcmjzUAuWexTpWwaClGrw-PTY.ttf", + "200italic": "http://fonts.gstatic.com/s/mulish/v10/1Ptwg83HX_SGhgqk2hAjQlW_mEuZ0FsSqeOvHp47LTZFwA.ttf", + "300italic": "http://fonts.gstatic.com/s/mulish/v10/1Ptwg83HX_SGhgqk2hAjQlW_mEuZ0FsSd-OvHp47LTZFwA.ttf", + "italic": "http://fonts.gstatic.com/s/mulish/v10/1Ptwg83HX_SGhgqk2hAjQlW_mEuZ0FsSKeOvHp47LTZFwA.ttf", + "500italic": "http://fonts.gstatic.com/s/mulish/v10/1Ptwg83HX_SGhgqk2hAjQlW_mEuZ0FsSG-OvHp47LTZFwA.ttf", + "600italic": "http://fonts.gstatic.com/s/mulish/v10/1Ptwg83HX_SGhgqk2hAjQlW_mEuZ0FsS9-SvHp47LTZFwA.ttf", + "700italic": "http://fonts.gstatic.com/s/mulish/v10/1Ptwg83HX_SGhgqk2hAjQlW_mEuZ0FsSzuSvHp47LTZFwA.ttf", + "800italic": "http://fonts.gstatic.com/s/mulish/v10/1Ptwg83HX_SGhgqk2hAjQlW_mEuZ0FsSqeSvHp47LTZFwA.ttf", + "900italic": "http://fonts.gstatic.com/s/mulish/v10/1Ptwg83HX_SGhgqk2hAjQlW_mEuZ0FsSgOSvHp47LTZFwA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Murecho", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "japanese", + "latin", + "latin-ext" + ], + "version": "v4", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/murecho/v4/q5uYsoq3NOBn_I-ggCJg98TBOoNFCMpr5HWZLCpUOaM6.ttf", + "200": "http://fonts.gstatic.com/s/murecho/v4/q5uYsoq3NOBn_I-ggCJg98TBOoNFCMrr5XWZLCpUOaM6.ttf", + "300": "http://fonts.gstatic.com/s/murecho/v4/q5uYsoq3NOBn_I-ggCJg98TBOoNFCMo15XWZLCpUOaM6.ttf", + "regular": "http://fonts.gstatic.com/s/murecho/v4/q5uYsoq3NOBn_I-ggCJg98TBOoNFCMpr5XWZLCpUOaM6.ttf", + "500": "http://fonts.gstatic.com/s/murecho/v4/q5uYsoq3NOBn_I-ggCJg98TBOoNFCMpZ5XWZLCpUOaM6.ttf", + "600": "http://fonts.gstatic.com/s/murecho/v4/q5uYsoq3NOBn_I-ggCJg98TBOoNFCMq14nWZLCpUOaM6.ttf", + "700": "http://fonts.gstatic.com/s/murecho/v4/q5uYsoq3NOBn_I-ggCJg98TBOoNFCMqM4nWZLCpUOaM6.ttf", + "800": "http://fonts.gstatic.com/s/murecho/v4/q5uYsoq3NOBn_I-ggCJg98TBOoNFCMrr4nWZLCpUOaM6.ttf", + "900": "http://fonts.gstatic.com/s/murecho/v4/q5uYsoq3NOBn_I-ggCJg98TBOoNFCMrC4nWZLCpUOaM6.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "MuseoModerno", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v19", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/museomoderno/v19/zrf30HnU0_7wWdMrFcWqSEXPVyEaWJ55pTleMlZFuewajeKlCdo.ttf", + "200": "http://fonts.gstatic.com/s/museomoderno/v19/zrf30HnU0_7wWdMrFcWqSEXPVyEaWJ55pTleMtZEuewajeKlCdo.ttf", + "300": "http://fonts.gstatic.com/s/museomoderno/v19/zrf30HnU0_7wWdMrFcWqSEXPVyEaWJ55pTleMghEuewajeKlCdo.ttf", + "regular": "http://fonts.gstatic.com/s/museomoderno/v19/zrf30HnU0_7wWdMrFcWqSEXPVyEaWJ55pTleMlZEuewajeKlCdo.ttf", + "500": "http://fonts.gstatic.com/s/museomoderno/v19/zrf30HnU0_7wWdMrFcWqSEXPVyEaWJ55pTleMmREuewajeKlCdo.ttf", + "600": "http://fonts.gstatic.com/s/museomoderno/v19/zrf30HnU0_7wWdMrFcWqSEXPVyEaWJ55pTleMohDuewajeKlCdo.ttf", + "700": "http://fonts.gstatic.com/s/museomoderno/v19/zrf30HnU0_7wWdMrFcWqSEXPVyEaWJ55pTleMrFDuewajeKlCdo.ttf", + "800": "http://fonts.gstatic.com/s/museomoderno/v19/zrf30HnU0_7wWdMrFcWqSEXPVyEaWJ55pTleMtZDuewajeKlCdo.ttf", + "900": "http://fonts.gstatic.com/s/museomoderno/v19/zrf30HnU0_7wWdMrFcWqSEXPVyEaWJ55pTleMv9DuewajeKlCdo.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Mystery Quest", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/mysteryquest/v18/-nF6OG414u0E6k0wynSGlujRHwElD_9Qz9E.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "NTR", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/ntr/v13/RLpzK5Xy0ZjiGGhs5TA4bg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Nanum Brush Script", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v20", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/nanumbrushscript/v20/wXK2E2wfpokopxzthSqPbcR5_gVaxazyjqBr1lO97Q.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Nanum Gothic", + "variants": [ + "regular", + "700", + "800" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v17", + "lastModified": "2019-07-22", + "files": { + "regular": "http://fonts.gstatic.com/s/nanumgothic/v17/PN_3Rfi-oW3hYwmKDpxS7F_z_tLfxno73g.ttf", + "700": "http://fonts.gstatic.com/s/nanumgothic/v17/PN_oRfi-oW3hYwmKDpxS7F_LQv37zlEn14YEUQ.ttf", + "800": "http://fonts.gstatic.com/s/nanumgothic/v17/PN_oRfi-oW3hYwmKDpxS7F_LXv77zlEn14YEUQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Nanum Gothic Coding", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/nanumgothiccoding/v17/8QIVdjzHisX_8vv59_xMxtPFW4IXROwsy6QxVs1X7tc.ttf", + "700": "http://fonts.gstatic.com/s/nanumgothiccoding/v17/8QIYdjzHisX_8vv59_xMxtPFW4IXROws8xgecsV88t5V9r4.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Nanum Myeongjo", + "variants": [ + "regular", + "700", + "800" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v19", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/nanummyeongjo/v19/9Btx3DZF0dXLMZlywRbVRNhxy1LreHQ8juyl.ttf", + "700": "http://fonts.gstatic.com/s/nanummyeongjo/v19/9Bty3DZF0dXLMZlywRbVRNhxy2pXV1A0pfCs5Kos.ttf", + "800": "http://fonts.gstatic.com/s/nanummyeongjo/v19/9Bty3DZF0dXLMZlywRbVRNhxy2pLVFA0pfCs5Kos.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Nanum Pen Script", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v15", + "lastModified": "2019-07-16", + "files": { + "regular": "http://fonts.gstatic.com/s/nanumpenscript/v15/daaDSSYiLGqEal3MvdA_FOL_3FkN2z7-aMFCcTU.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Neonderthaw", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2022-01-12", + "files": { + "regular": "http://fonts.gstatic.com/s/neonderthaw/v1/Iure6Yx5-oWVZI0r-17AeZZJprVA4XQ0.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Nerko One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/nerkoone/v13/m8JQjfZSc7OXlB3ZMOjzcJ5BZmqa3A.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Neucha", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/neucha/v15/q5uGsou0JOdh94bvugNsCxVEgA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Neuton", + "variants": [ + "200", + "300", + "regular", + "italic", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/neuton/v16/UMBQrPtMoH62xUZKAKkfegD5Drog6Q.ttf", + "300": "http://fonts.gstatic.com/s/neuton/v16/UMBQrPtMoH62xUZKZKofegD5Drog6Q.ttf", + "regular": "http://fonts.gstatic.com/s/neuton/v16/UMBTrPtMoH62xUZyyII7civlBw.ttf", + "italic": "http://fonts.gstatic.com/s/neuton/v16/UMBRrPtMoH62xUZCyog_UC71B6M5.ttf", + "700": "http://fonts.gstatic.com/s/neuton/v16/UMBQrPtMoH62xUZKdK0fegD5Drog6Q.ttf", + "800": "http://fonts.gstatic.com/s/neuton/v16/UMBQrPtMoH62xUZKaK4fegD5Drog6Q.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "New Rocker", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/newrocker/v14/MwQzbhjp3-HImzcCU_cJkGMViblPtXs.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "New Tegomin", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/newtegomin/v8/SLXMc1fV7Gd9USdBAfPlqfN0Q3ptkDMN.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "News Cycle", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/newscycle/v20/CSR64z1Qlv-GDxkbKVQ_TOcATNt_pOU.ttf", + "700": "http://fonts.gstatic.com/s/newscycle/v20/CSR54z1Qlv-GDxkbKVQ_dFsvaNNUuOwkC2s.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Newsreader", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2021-03-19", + "files": { + "200": "http://fonts.gstatic.com/s/newsreader/v7/cY9qfjOCX1hbuyalUrK49dLac06G1ZGsZBtoBCzBDXXD9JVF438w-I_ADOxEPjCggA.ttf", + "300": "http://fonts.gstatic.com/s/newsreader/v7/cY9qfjOCX1hbuyalUrK49dLac06G1ZGsZBtoBCzBDXXD9JVF438wJo_ADOxEPjCggA.ttf", + "regular": "http://fonts.gstatic.com/s/newsreader/v7/cY9qfjOCX1hbuyalUrK49dLac06G1ZGsZBtoBCzBDXXD9JVF438weI_ADOxEPjCggA.ttf", + "500": "http://fonts.gstatic.com/s/newsreader/v7/cY9qfjOCX1hbuyalUrK49dLac06G1ZGsZBtoBCzBDXXD9JVF438wSo_ADOxEPjCggA.ttf", + "600": "http://fonts.gstatic.com/s/newsreader/v7/cY9qfjOCX1hbuyalUrK49dLac06G1ZGsZBtoBCzBDXXD9JVF438wpojADOxEPjCggA.ttf", + "700": "http://fonts.gstatic.com/s/newsreader/v7/cY9qfjOCX1hbuyalUrK49dLac06G1ZGsZBtoBCzBDXXD9JVF438wn4jADOxEPjCggA.ttf", + "800": "http://fonts.gstatic.com/s/newsreader/v7/cY9qfjOCX1hbuyalUrK49dLac06G1ZGsZBtoBCzBDXXD9JVF438w-IjADOxEPjCggA.ttf", + "200italic": "http://fonts.gstatic.com/s/newsreader/v7/cY9kfjOCX1hbuyalUrK439vogqC9yFZCYg7oRZaLP4obnf7fTXglsMyoT-ZAHDWwgECi.ttf", + "300italic": "http://fonts.gstatic.com/s/newsreader/v7/cY9kfjOCX1hbuyalUrK439vogqC9yFZCYg7oRZaLP4obnf7fTXglsMx2T-ZAHDWwgECi.ttf", + "italic": "http://fonts.gstatic.com/s/newsreader/v7/cY9kfjOCX1hbuyalUrK439vogqC9yFZCYg7oRZaLP4obnf7fTXglsMwoT-ZAHDWwgECi.ttf", + "500italic": "http://fonts.gstatic.com/s/newsreader/v7/cY9kfjOCX1hbuyalUrK439vogqC9yFZCYg7oRZaLP4obnf7fTXglsMwaT-ZAHDWwgECi.ttf", + "600italic": "http://fonts.gstatic.com/s/newsreader/v7/cY9kfjOCX1hbuyalUrK439vogqC9yFZCYg7oRZaLP4obnf7fTXglsMz2SOZAHDWwgECi.ttf", + "700italic": "http://fonts.gstatic.com/s/newsreader/v7/cY9kfjOCX1hbuyalUrK439vogqC9yFZCYg7oRZaLP4obnf7fTXglsMzPSOZAHDWwgECi.ttf", + "800italic": "http://fonts.gstatic.com/s/newsreader/v7/cY9kfjOCX1hbuyalUrK439vogqC9yFZCYg7oRZaLP4obnf7fTXglsMyoSOZAHDWwgECi.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Niconne", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/niconne/v13/w8gaH2QvRug1_rTfrQut2F4OuOo.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Niramit", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/niramit/v8/I_urMpWdvgLdNxVLVXx7tiiEr5_BdZ8.ttf", + "200italic": "http://fonts.gstatic.com/s/niramit/v8/I_upMpWdvgLdNxVLXbZiXimOq73EZZ_f6w.ttf", + "300": "http://fonts.gstatic.com/s/niramit/v8/I_urMpWdvgLdNxVLVRh4tiiEr5_BdZ8.ttf", + "300italic": "http://fonts.gstatic.com/s/niramit/v8/I_upMpWdvgLdNxVLXbZiOiqOq73EZZ_f6w.ttf", + "regular": "http://fonts.gstatic.com/s/niramit/v8/I_uuMpWdvgLdNxVLbbRQkiCvs5Y.ttf", + "italic": "http://fonts.gstatic.com/s/niramit/v8/I_usMpWdvgLdNxVLXbZalgKqo5bYbA.ttf", + "500": "http://fonts.gstatic.com/s/niramit/v8/I_urMpWdvgLdNxVLVUB5tiiEr5_BdZ8.ttf", + "500italic": "http://fonts.gstatic.com/s/niramit/v8/I_upMpWdvgLdNxVLXbZiYiuOq73EZZ_f6w.ttf", + "600": "http://fonts.gstatic.com/s/niramit/v8/I_urMpWdvgLdNxVLVWx-tiiEr5_BdZ8.ttf", + "600italic": "http://fonts.gstatic.com/s/niramit/v8/I_upMpWdvgLdNxVLXbZiTiyOq73EZZ_f6w.ttf", + "700": "http://fonts.gstatic.com/s/niramit/v8/I_urMpWdvgLdNxVLVQh_tiiEr5_BdZ8.ttf", + "700italic": "http://fonts.gstatic.com/s/niramit/v8/I_upMpWdvgLdNxVLXbZiKi2Oq73EZZ_f6w.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Nixie One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/nixieone/v14/lW-8wjkKLXjg5y2o2uUoUOFzpS-yLw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Nobile", + "variants": [ + "regular", + "italic", + "500", + "500italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/nobile/v15/m8JTjflSeaOVl1i2XqfXeLVdbw.ttf", + "italic": "http://fonts.gstatic.com/s/nobile/v15/m8JRjflSeaOVl1iGXK3TWrBNb3OD.ttf", + "500": "http://fonts.gstatic.com/s/nobile/v15/m8JQjflSeaOVl1iOqo7zcJ5BZmqa3A.ttf", + "500italic": "http://fonts.gstatic.com/s/nobile/v15/m8JWjflSeaOVl1iGXJUnc5RFRG-K3Mud.ttf", + "700": "http://fonts.gstatic.com/s/nobile/v15/m8JQjflSeaOVl1iO4ojzcJ5BZmqa3A.ttf", + "700italic": "http://fonts.gstatic.com/s/nobile/v15/m8JWjflSeaOVl1iGXJVvdZRFRG-K3Mud.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Nokora", + "variants": [ + "100", + "300", + "regular", + "700", + "900" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v25", + "lastModified": "2021-11-18", + "files": { + "100": "http://fonts.gstatic.com/s/nokora/v25/~CgoKBk5va29yYRhkIAAqBAgBGAE=.ttf", + "300": "http://fonts.gstatic.com/s/nokora/v25/~CgsKBk5va29yYRisAiAAKgQIARgB.ttf", + "regular": "http://fonts.gstatic.com/s/nokora/v25/~CggKBk5va29yYSAAKgQIARgB.ttf", + "700": "http://fonts.gstatic.com/s/nokora/v25/~CgsKBk5va29yYRi8BSAAKgQIARgB.ttf", + "900": "http://fonts.gstatic.com/s/nokora/v25/~CgsKBk5va29yYRiEByAAKgQIARgB.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Norican", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/norican/v12/MwQ2bhXp1eSBqjkPGJJRtGs-lbA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Nosifer", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/nosifer/v11/ZGjXol5JTp0g5bxZaC1RVDNdGDs.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Notable", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/notable/v12/gNMEW3N_SIqx-WX9-HMoFIez5MI.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Nothing You Could Do", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/nothingyoucoulddo/v13/oY1B8fbBpaP5OX3DtrRYf_Q2BPB1SnfZb0OJl1ol2Ymo.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Noticia Text", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/noticiatext/v14/VuJ2dNDF2Yv9qppOePKYRP1GYTFZt0rNpQ.ttf", + "italic": "http://fonts.gstatic.com/s/noticiatext/v14/VuJodNDF2Yv9qppOePKYRP12YztdlU_dpSjt.ttf", + "700": "http://fonts.gstatic.com/s/noticiatext/v14/VuJpdNDF2Yv9qppOePKYRP1-3R59v2HRrDH0eA.ttf", + "700italic": "http://fonts.gstatic.com/s/noticiatext/v14/VuJrdNDF2Yv9qppOePKYRP12YwPhumvVjjTkeMnz.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Kufi Arabic", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "arabic" + ], + "version": "v13", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notokufiarabic/v13/CSRp4ydQnPyaDxEXLFF6LZVLKrodhu8t57o1kDc5Wh5v3obPnLSmf5yD.ttf", + "200": "http://fonts.gstatic.com/s/notokufiarabic/v13/CSRp4ydQnPyaDxEXLFF6LZVLKrodhu8t57o1kDc5Wh7v34bPnLSmf5yD.ttf", + "300": "http://fonts.gstatic.com/s/notokufiarabic/v13/CSRp4ydQnPyaDxEXLFF6LZVLKrodhu8t57o1kDc5Wh4x34bPnLSmf5yD.ttf", + "regular": "http://fonts.gstatic.com/s/notokufiarabic/v13/CSRp4ydQnPyaDxEXLFF6LZVLKrodhu8t57o1kDc5Wh5v34bPnLSmf5yD.ttf", + "500": "http://fonts.gstatic.com/s/notokufiarabic/v13/CSRp4ydQnPyaDxEXLFF6LZVLKrodhu8t57o1kDc5Wh5d34bPnLSmf5yD.ttf", + "600": "http://fonts.gstatic.com/s/notokufiarabic/v13/CSRp4ydQnPyaDxEXLFF6LZVLKrodhu8t57o1kDc5Wh6x2IbPnLSmf5yD.ttf", + "700": "http://fonts.gstatic.com/s/notokufiarabic/v13/CSRp4ydQnPyaDxEXLFF6LZVLKrodhu8t57o1kDc5Wh6I2IbPnLSmf5yD.ttf", + "800": "http://fonts.gstatic.com/s/notokufiarabic/v13/CSRp4ydQnPyaDxEXLFF6LZVLKrodhu8t57o1kDc5Wh7v2IbPnLSmf5yD.ttf", + "900": "http://fonts.gstatic.com/s/notokufiarabic/v13/CSRp4ydQnPyaDxEXLFF6LZVLKrodhu8t57o1kDc5Wh7G2IbPnLSmf5yD.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Music", + "variants": [ + "regular" + ], + "subsets": [ + "music" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notomusic/v13/pe0rMIiSN5pO63htf1sxIteQB9Zra1U.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Naskh Arabic", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "arabic" + ], + "version": "v16", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notonaskharabic/v16/RrQ5bpV-9Dd1b1OAGA6M9PkyDuVBePeKNaxcsss0Y7bwvc5krK0z9_Mnuw.ttf", + "500": "http://fonts.gstatic.com/s/notonaskharabic/v16/RrQ5bpV-9Dd1b1OAGA6M9PkyDuVBePeKNaxcsss0Y7bwj85krK0z9_Mnuw.ttf", + "600": "http://fonts.gstatic.com/s/notonaskharabic/v16/RrQ5bpV-9Dd1b1OAGA6M9PkyDuVBePeKNaxcsss0Y7bwY8lkrK0z9_Mnuw.ttf", + "700": "http://fonts.gstatic.com/s/notonaskharabic/v16/RrQ5bpV-9Dd1b1OAGA6M9PkyDuVBePeKNaxcsss0Y7bwWslkrK0z9_Mnuw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Nastaliq Urdu", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "arabic" + ], + "version": "v11", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notonastaliqurdu/v11/LhW4MUPbN-oZdNFcBy1-DJYsEoTq5puHSPANO9blOA.ttf", + "700": "http://fonts.gstatic.com/s/notonastaliqurdu/v11/LhW7MUPbN-oZdNFcBy1-DJYsEoTq5pu_9N8pM_35MVRvQw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Rashi Hebrew", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "hebrew" + ], + "version": "v16", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notorashihebrew/v16/EJR_Qh82XsIK-QFmqXk4zvLwFVya0vFL-HlKM5e6C6HZB-DkRyq6Nf2pfA.ttf", + "200": "http://fonts.gstatic.com/s/notorashihebrew/v16/EJR_Qh82XsIK-QFmqXk4zvLwFVya0vFL-HlKM5e6C6HZh-HkRyq6Nf2pfA.ttf", + "300": "http://fonts.gstatic.com/s/notorashihebrew/v16/EJR_Qh82XsIK-QFmqXk4zvLwFVya0vFL-HlKM5e6C6HZWeHkRyq6Nf2pfA.ttf", + "regular": "http://fonts.gstatic.com/s/notorashihebrew/v16/EJR_Qh82XsIK-QFmqXk4zvLwFVya0vFL-HlKM5e6C6HZB-HkRyq6Nf2pfA.ttf", + "500": "http://fonts.gstatic.com/s/notorashihebrew/v16/EJR_Qh82XsIK-QFmqXk4zvLwFVya0vFL-HlKM5e6C6HZNeHkRyq6Nf2pfA.ttf", + "600": "http://fonts.gstatic.com/s/notorashihebrew/v16/EJR_Qh82XsIK-QFmqXk4zvLwFVya0vFL-HlKM5e6C6HZ2ebkRyq6Nf2pfA.ttf", + "700": "http://fonts.gstatic.com/s/notorashihebrew/v16/EJR_Qh82XsIK-QFmqXk4zvLwFVya0vFL-HlKM5e6C6HZ4ObkRyq6Nf2pfA.ttf", + "800": "http://fonts.gstatic.com/s/notorashihebrew/v16/EJR_Qh82XsIK-QFmqXk4zvLwFVya0vFL-HlKM5e6C6HZh-bkRyq6Nf2pfA.ttf", + "900": "http://fonts.gstatic.com/s/notorashihebrew/v16/EJR_Qh82XsIK-QFmqXk4zvLwFVya0vFL-HlKM5e6C6HZrubkRyq6Nf2pfA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "devanagari", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v25", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/notosans/v25/o-0IIpQlx3QUlC5A4PNb4j5Ba_2c7A.ttf", + "italic": "http://fonts.gstatic.com/s/notosans/v25/o-0OIpQlx3QUlC5A4PNr4DRFSfiM7HBj.ttf", + "700": "http://fonts.gstatic.com/s/notosans/v25/o-0NIpQlx3QUlC5A4PNjXhFlY9aA5Wl6PQ.ttf", + "700italic": "http://fonts.gstatic.com/s/notosans/v25/o-0TIpQlx3QUlC5A4PNr4Az5ZtyEx2xqPaif.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Adlam", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "adlam" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansadlam/v15/neIczCCpqp0s5pPusPamd81eMfjPonvqdbYxxpgufnv0TGnBZLwhuvk.ttf", + "500": "http://fonts.gstatic.com/s/notosansadlam/v15/neIczCCpqp0s5pPusPamd81eMfjPonvqdbYxxpgufkn0TGnBZLwhuvk.ttf", + "600": "http://fonts.gstatic.com/s/notosansadlam/v15/neIczCCpqp0s5pPusPamd81eMfjPonvqdbYxxpgufqXzTGnBZLwhuvk.ttf", + "700": "http://fonts.gstatic.com/s/notosansadlam/v15/neIczCCpqp0s5pPusPamd81eMfjPonvqdbYxxpgufpzzTGnBZLwhuvk.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Adlam Unjoined", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "adlam" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansadlamunjoined/v15/P5sszY2MYsLRsB5_ildkzPPDsLQXcOEmaFOqOGcaYrzFTIjsPam_Ye35PMEe-E3slUg.ttf", + "500": "http://fonts.gstatic.com/s/notosansadlamunjoined/v15/P5sszY2MYsLRsB5_ildkzPPDsLQXcOEmaFOqOGcaYrzFTIjsPam_Yd_5PMEe-E3slUg.ttf", + "600": "http://fonts.gstatic.com/s/notosansadlamunjoined/v15/P5sszY2MYsLRsB5_ildkzPPDsLQXcOEmaFOqOGcaYrzFTIjsPam_YTP-PMEe-E3slUg.ttf", + "700": "http://fonts.gstatic.com/s/notosansadlamunjoined/v15/P5sszY2MYsLRsB5_ildkzPPDsLQXcOEmaFOqOGcaYrzFTIjsPam_YQr-PMEe-E3slUg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Anatolian Hieroglyphs", + "variants": [ + "regular" + ], + "subsets": [ + "anatolian-hieroglyphs" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansanatolianhieroglyphs/v13/ijw9s4roRME5LLRxjsRb8A0gKPSWq4BbDmHHu6j2pEtUJzZWXybIymc5QYo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Arabic", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "arabic" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosansarabic/v14/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfyG2vu3CBFQLaig.ttf", + "200": "http://fonts.gstatic.com/s/notosansarabic/v14/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfSGyvu3CBFQLaig.ttf", + "300": "http://fonts.gstatic.com/s/notosansarabic/v14/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCflmyvu3CBFQLaig.ttf", + "regular": "http://fonts.gstatic.com/s/notosansarabic/v14/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfyGyvu3CBFQLaig.ttf", + "500": "http://fonts.gstatic.com/s/notosansarabic/v14/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCf-myvu3CBFQLaig.ttf", + "600": "http://fonts.gstatic.com/s/notosansarabic/v14/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfFmuvu3CBFQLaig.ttf", + "700": "http://fonts.gstatic.com/s/notosansarabic/v14/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfL2uvu3CBFQLaig.ttf", + "800": "http://fonts.gstatic.com/s/notosansarabic/v14/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfSGuvu3CBFQLaig.ttf", + "900": "http://fonts.gstatic.com/s/notosansarabic/v14/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfYWuvu3CBFQLaig.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Armenian", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "armenian" + ], + "version": "v28", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/notosansarmenian/v28/ZgN0jOZKPa7CHqq0h37c7ReDUubm2SEdFXp7ig73qtTY5idb74R9UdM3y2nZLorxbq0iYy6zF3Eg.ttf", + "200": "http://fonts.gstatic.com/s/notosansarmenian/v28/ZgN0jOZKPa7CHqq0h37c7ReDUubm2SEdFXp7ig73qtTY5idb74R9UdM3y2nZLopxb60iYy6zF3Eg.ttf", + "300": "http://fonts.gstatic.com/s/notosansarmenian/v28/ZgN0jOZKPa7CHqq0h37c7ReDUubm2SEdFXp7ig73qtTY5idb74R9UdM3y2nZLoqvb60iYy6zF3Eg.ttf", + "regular": "http://fonts.gstatic.com/s/notosansarmenian/v28/ZgN0jOZKPa7CHqq0h37c7ReDUubm2SEdFXp7ig73qtTY5idb74R9UdM3y2nZLorxb60iYy6zF3Eg.ttf", + "500": "http://fonts.gstatic.com/s/notosansarmenian/v28/ZgN0jOZKPa7CHqq0h37c7ReDUubm2SEdFXp7ig73qtTY5idb74R9UdM3y2nZLorDb60iYy6zF3Eg.ttf", + "600": "http://fonts.gstatic.com/s/notosansarmenian/v28/ZgN0jOZKPa7CHqq0h37c7ReDUubm2SEdFXp7ig73qtTY5idb74R9UdM3y2nZLoovaK0iYy6zF3Eg.ttf", + "700": "http://fonts.gstatic.com/s/notosansarmenian/v28/ZgN0jOZKPa7CHqq0h37c7ReDUubm2SEdFXp7ig73qtTY5idb74R9UdM3y2nZLooWaK0iYy6zF3Eg.ttf", + "800": "http://fonts.gstatic.com/s/notosansarmenian/v28/ZgN0jOZKPa7CHqq0h37c7ReDUubm2SEdFXp7ig73qtTY5idb74R9UdM3y2nZLopxaK0iYy6zF3Eg.ttf", + "900": "http://fonts.gstatic.com/s/notosansarmenian/v28/ZgN0jOZKPa7CHqq0h37c7ReDUubm2SEdFXp7ig73qtTY5idb74R9UdM3y2nZLopYaK0iYy6zF3Eg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Avestan", + "variants": [ + "regular" + ], + "subsets": [ + "avestan" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansavestan/v13/bWti7ejKfBziStx7lIzKOLQZKhIJkyu9SASLji8U.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Balinese", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "balinese" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansbalinese/v15/NaPwcYvSBuhTirw6IaFn6UrRDaqje-lpbbRtYf-Fwu2Ov7fdhE5Vd222PPY.ttf", + "500": "http://fonts.gstatic.com/s/notosansbalinese/v15/NaPwcYvSBuhTirw6IaFn6UrRDaqje-lpbbRtYf-Fwu2Ov4XdhE5Vd222PPY.ttf", + "600": "http://fonts.gstatic.com/s/notosansbalinese/v15/NaPwcYvSBuhTirw6IaFn6UrRDaqje-lpbbRtYf-Fwu2Ov2nahE5Vd222PPY.ttf", + "700": "http://fonts.gstatic.com/s/notosansbalinese/v15/NaPwcYvSBuhTirw6IaFn6UrRDaqje-lpbbRtYf-Fwu2Ov1DahE5Vd222PPY.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Bamum", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "bamum" + ], + "version": "v16", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansbamum/v16/uk-0EGK3o6EruUbnwovcbBTkkklK_Ya_PBHfNGTPEddO-_gLykxEkxA.ttf", + "500": "http://fonts.gstatic.com/s/notosansbamum/v16/uk-0EGK3o6EruUbnwovcbBTkkklK_Ya_PBHfNGTPEeVO-_gLykxEkxA.ttf", + "600": "http://fonts.gstatic.com/s/notosansbamum/v16/uk-0EGK3o6EruUbnwovcbBTkkklK_Ya_PBHfNGTPEQlJ-_gLykxEkxA.ttf", + "700": "http://fonts.gstatic.com/s/notosansbamum/v16/uk-0EGK3o6EruUbnwovcbBTkkklK_Ya_PBHfNGTPETBJ-_gLykxEkxA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Bassa Vah", + "variants": [ + "regular" + ], + "subsets": [ + "bassa-vah" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansbassavah/v13/PN_sRee-r3f7LnqsD5sax12gjZn7mBpL_4c2VNUQptE.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Batak", + "variants": [ + "regular" + ], + "subsets": [ + "batak" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansbatak/v13/gok2H6TwAEdtF9N8-mdTCQvT-Zdgo4_PHuk74A.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Bengali", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "bengali" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosansbengali/v14/Cn-SJsCGWQxOjaGwMQ6fIiMywrNJIky6nvd8BjzVMvJx2mcSPVFpVEqE-6KmsolKudCk8izI0lc.ttf", + "200": "http://fonts.gstatic.com/s/notosansbengali/v14/Cn-SJsCGWQxOjaGwMQ6fIiMywrNJIky6nvd8BjzVMvJx2mcSPVFpVEqE-6KmsglLudCk8izI0lc.ttf", + "300": "http://fonts.gstatic.com/s/notosansbengali/v14/Cn-SJsCGWQxOjaGwMQ6fIiMywrNJIky6nvd8BjzVMvJx2mcSPVFpVEqE-6KmstdLudCk8izI0lc.ttf", + "regular": "http://fonts.gstatic.com/s/notosansbengali/v14/Cn-SJsCGWQxOjaGwMQ6fIiMywrNJIky6nvd8BjzVMvJx2mcSPVFpVEqE-6KmsolLudCk8izI0lc.ttf", + "500": "http://fonts.gstatic.com/s/notosansbengali/v14/Cn-SJsCGWQxOjaGwMQ6fIiMywrNJIky6nvd8BjzVMvJx2mcSPVFpVEqE-6KmsrtLudCk8izI0lc.ttf", + "600": "http://fonts.gstatic.com/s/notosansbengali/v14/Cn-SJsCGWQxOjaGwMQ6fIiMywrNJIky6nvd8BjzVMvJx2mcSPVFpVEqE-6KmsldMudCk8izI0lc.ttf", + "700": "http://fonts.gstatic.com/s/notosansbengali/v14/Cn-SJsCGWQxOjaGwMQ6fIiMywrNJIky6nvd8BjzVMvJx2mcSPVFpVEqE-6Kmsm5MudCk8izI0lc.ttf", + "800": "http://fonts.gstatic.com/s/notosansbengali/v14/Cn-SJsCGWQxOjaGwMQ6fIiMywrNJIky6nvd8BjzVMvJx2mcSPVFpVEqE-6KmsglMudCk8izI0lc.ttf", + "900": "http://fonts.gstatic.com/s/notosansbengali/v14/Cn-SJsCGWQxOjaGwMQ6fIiMywrNJIky6nvd8BjzVMvJx2mcSPVFpVEqE-6KmsiBMudCk8izI0lc.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Bhaiksuki", + "variants": [ + "regular" + ], + "subsets": [ + "bhaiksuki" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansbhaiksuki/v13/UcC63EosKniBH4iELXATsSBWdvUHXxhj8rLUdU4wh9U.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Brahmi", + "variants": [ + "regular" + ], + "subsets": [ + "brahmi" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansbrahmi/v13/vEFK2-VODB8RrNDvZSUmQQIIByV18tK1W77HtMo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Buginese", + "variants": [ + "regular" + ], + "subsets": [ + "buginese" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansbuginese/v13/esDM30ldNv-KYGGJpKGk18phe_7Da6_gtfuEXLmNtw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Buhid", + "variants": [ + "regular" + ], + "subsets": [ + "buhid" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansbuhid/v13/Dxxy8jiXMW75w3OmoDXVWJD7YwzAe6tgnaFoGA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Canadian Aboriginal", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "canadian-aboriginal" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notosanscanadianaboriginal/v15/4C_TLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmlzda6uH_nnX1bzigWLj_yAsg0q0uhQ.ttf", + "200": "http://fonts.gstatic.com/s/notosanscanadianaboriginal/v15/4C_TLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmlzda6uH_nnX1bzig2Ln_yAsg0q0uhQ.ttf", + "300": "http://fonts.gstatic.com/s/notosanscanadianaboriginal/v15/4C_TLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmlzda6uH_nnX1bzigBrn_yAsg0q0uhQ.ttf", + "regular": "http://fonts.gstatic.com/s/notosanscanadianaboriginal/v15/4C_TLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmlzda6uH_nnX1bzigWLn_yAsg0q0uhQ.ttf", + "500": "http://fonts.gstatic.com/s/notosanscanadianaboriginal/v15/4C_TLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmlzda6uH_nnX1bzigarn_yAsg0q0uhQ.ttf", + "600": "http://fonts.gstatic.com/s/notosanscanadianaboriginal/v15/4C_TLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmlzda6uH_nnX1bzighr7_yAsg0q0uhQ.ttf", + "700": "http://fonts.gstatic.com/s/notosanscanadianaboriginal/v15/4C_TLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmlzda6uH_nnX1bzigv77_yAsg0q0uhQ.ttf", + "800": "http://fonts.gstatic.com/s/notosanscanadianaboriginal/v15/4C_TLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmlzda6uH_nnX1bzig2L7_yAsg0q0uhQ.ttf", + "900": "http://fonts.gstatic.com/s/notosanscanadianaboriginal/v15/4C_TLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmlzda6uH_nnX1bzig8b7_yAsg0q0uhQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Carian", + "variants": [ + "regular" + ], + "subsets": [ + "carian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanscarian/v13/LDIpaoiONgYwA9Yc6f0gUILeMIOgs7ob9yGLmfI.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Caucasian Albanian", + "variants": [ + "regular" + ], + "subsets": [ + "caucasian-albanian" + ], + "version": "v14", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanscaucasianalbanian/v14/nKKA-HM_FYFRJvXzVXaANsU0VzsAc46QGOkWytlTs-TXrYDmoVmRSZo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Chakma", + "variants": [ + "regular" + ], + "subsets": [ + "chakma" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanschakma/v13/Y4GQYbJ8VTEp4t3MKJSMjg5OIzhi4JjTQhYBeYo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Cham", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "cham" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notosanscham/v15/pe06MIySN5pO62Z5YkFyQb_bbuRhe6D4yip43qfcER0cv7GykboaLg.ttf", + "200": "http://fonts.gstatic.com/s/notosanscham/v15/pe06MIySN5pO62Z5YkFyQb_bbuRhe6D4yip43qfckRwcv7GykboaLg.ttf", + "300": "http://fonts.gstatic.com/s/notosanscham/v15/pe06MIySN5pO62Z5YkFyQb_bbuRhe6D4yip43qfcTxwcv7GykboaLg.ttf", + "regular": "http://fonts.gstatic.com/s/notosanscham/v15/pe06MIySN5pO62Z5YkFyQb_bbuRhe6D4yip43qfcERwcv7GykboaLg.ttf", + "500": "http://fonts.gstatic.com/s/notosanscham/v15/pe06MIySN5pO62Z5YkFyQb_bbuRhe6D4yip43qfcIxwcv7GykboaLg.ttf", + "600": "http://fonts.gstatic.com/s/notosanscham/v15/pe06MIySN5pO62Z5YkFyQb_bbuRhe6D4yip43qfczxscv7GykboaLg.ttf", + "700": "http://fonts.gstatic.com/s/notosanscham/v15/pe06MIySN5pO62Z5YkFyQb_bbuRhe6D4yip43qfc9hscv7GykboaLg.ttf", + "800": "http://fonts.gstatic.com/s/notosanscham/v15/pe06MIySN5pO62Z5YkFyQb_bbuRhe6D4yip43qfckRscv7GykboaLg.ttf", + "900": "http://fonts.gstatic.com/s/notosanscham/v15/pe06MIySN5pO62Z5YkFyQb_bbuRhe6D4yip43qfcuBscv7GykboaLg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Cherokee", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "cherokee" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notosanscherokee/v15/KFOPCm6Yu8uF-29fiz9vQF9YWK6Z8O10cHNA0cSkZCHYWi5ODkm5rAffjl0.ttf", + "200": "http://fonts.gstatic.com/s/notosanscherokee/v15/KFOPCm6Yu8uF-29fiz9vQF9YWK6Z8O10cHNA0cSkZCHYWq5PDkm5rAffjl0.ttf", + "300": "http://fonts.gstatic.com/s/notosanscherokee/v15/KFOPCm6Yu8uF-29fiz9vQF9YWK6Z8O10cHNA0cSkZCHYWnBPDkm5rAffjl0.ttf", + "regular": "http://fonts.gstatic.com/s/notosanscherokee/v15/KFOPCm6Yu8uF-29fiz9vQF9YWK6Z8O10cHNA0cSkZCHYWi5PDkm5rAffjl0.ttf", + "500": "http://fonts.gstatic.com/s/notosanscherokee/v15/KFOPCm6Yu8uF-29fiz9vQF9YWK6Z8O10cHNA0cSkZCHYWhxPDkm5rAffjl0.ttf", + "600": "http://fonts.gstatic.com/s/notosanscherokee/v15/KFOPCm6Yu8uF-29fiz9vQF9YWK6Z8O10cHNA0cSkZCHYWvBIDkm5rAffjl0.ttf", + "700": "http://fonts.gstatic.com/s/notosanscherokee/v15/KFOPCm6Yu8uF-29fiz9vQF9YWK6Z8O10cHNA0cSkZCHYWslIDkm5rAffjl0.ttf", + "800": "http://fonts.gstatic.com/s/notosanscherokee/v15/KFOPCm6Yu8uF-29fiz9vQF9YWK6Z8O10cHNA0cSkZCHYWq5IDkm5rAffjl0.ttf", + "900": "http://fonts.gstatic.com/s/notosanscherokee/v15/KFOPCm6Yu8uF-29fiz9vQF9YWK6Z8O10cHNA0cSkZCHYWodIDkm5rAffjl0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Coptic", + "variants": [ + "regular" + ], + "subsets": [ + "coptic" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanscoptic/v13/iJWfBWmUZi_OHPqn4wq6kgqumOEd78u_VG0xR4Y.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Cuneiform", + "variants": [ + "regular" + ], + "subsets": [ + "cuneiform" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanscuneiform/v13/bMrrmTWK7YY-MF22aHGGd7H8PhJtvBDWgb9JlRQueeQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Cypriot", + "variants": [ + "regular" + ], + "subsets": [ + "cypriot" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanscypriot/v13/8AtzGta9PYqQDjyp79a6f8Cj-3a3cxIsK5MPpahF.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Deseret", + "variants": [ + "regular" + ], + "subsets": [ + "deseret" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansdeseret/v13/MwQsbgPp1eKH6QsAVuFb9AZM6MMr2Vq9ZnJSZtQG.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Devanagari", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "devanagari" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosansdevanagari/v14/TuGAUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv1E1fxxPDT4d_AU.ttf", + "200": "http://fonts.gstatic.com/s/notosansdevanagari/v14/TuGBUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv1GZXjxlIzIU5RwD.ttf", + "300": "http://fonts.gstatic.com/s/notosansdevanagari/v14/TuGBUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv1H9XTxlIzIU5RwD.ttf", + "regular": "http://fonts.gstatic.com/s/notosansdevanagari/v14/TuGOUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv2lRdRhtCC4d.ttf", + "500": "http://fonts.gstatic.com/s/notosansdevanagari/v14/TuGBUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv1GlXDxlIzIU5RwD.ttf", + "600": "http://fonts.gstatic.com/s/notosansdevanagari/v14/TuGBUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv1GJWzxlIzIU5RwD.ttf", + "700": "http://fonts.gstatic.com/s/notosansdevanagari/v14/TuGBUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv1HtWjxlIzIU5RwD.ttf", + "800": "http://fonts.gstatic.com/s/notosansdevanagari/v14/TuGBUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv1HxWTxlIzIU5RwD.ttf", + "900": "http://fonts.gstatic.com/s/notosansdevanagari/v14/TuGBUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv1HVWDxlIzIU5RwD.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Display", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpbK4fy6r6tOBEJg0IAKzqdFZVZxpMkXJMhnB9XjO1o90LuV-PT4Doq_AKp_3cLVTGQ2iHrvWM.ttf", + "200": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpbK4fy6r6tOBEJg0IAKzqdFZVZxpMkXJMhnB9XjO1o90LuV-PT4Doq_AKp__cKVTGQ2iHrvWM.ttf", + "300": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpbK4fy6r6tOBEJg0IAKzqdFZVZxpMkXJMhnB9XjO1o90LuV-PT4Doq_AKp_ykKVTGQ2iHrvWM.ttf", + "regular": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpbK4fy6r6tOBEJg0IAKzqdFZVZxpMkXJMhnB9XjO1o90LuV-PT4Doq_AKp_3cKVTGQ2iHrvWM.ttf", + "500": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpbK4fy6r6tOBEJg0IAKzqdFZVZxpMkXJMhnB9XjO1o90LuV-PT4Doq_AKp_0UKVTGQ2iHrvWM.ttf", + "600": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpbK4fy6r6tOBEJg0IAKzqdFZVZxpMkXJMhnB9XjO1o90LuV-PT4Doq_AKp_6kNVTGQ2iHrvWM.ttf", + "700": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpbK4fy6r6tOBEJg0IAKzqdFZVZxpMkXJMhnB9XjO1o90LuV-PT4Doq_AKp_5ANVTGQ2iHrvWM.ttf", + "800": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpbK4fy6r6tOBEJg0IAKzqdFZVZxpMkXJMhnB9XjO1o90LuV-PT4Doq_AKp__cNVTGQ2iHrvWM.ttf", + "900": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpbK4fy6r6tOBEJg0IAKzqdFZVZxpMkXJMhnB9XjO1o90LuV-PT4Doq_AKp_94NVTGQ2iHrvWM.ttf", + "100italic": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpZK4fy6r6tOBEJg0IAKzqdFZVZxrktbnDB5UzBIup9PwAcHtEsOFNBZqyu6r9JvXOa3gPurWM9uQ.ttf", + "200italic": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpZK4fy6r6tOBEJg0IAKzqdFZVZxrktbnDB5UzBIup9PwAcHtEsOFNBZqyu6r9JPXKa3gPurWM9uQ.ttf", + "300italic": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpZK4fy6r6tOBEJg0IAKzqdFZVZxrktbnDB5UzBIup9PwAcHtEsOFNBZqyu6r9J43Ka3gPurWM9uQ.ttf", + "italic": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpZK4fy6r6tOBEJg0IAKzqdFZVZxrktbnDB5UzBIup9PwAcHtEsOFNBZqyu6r9JvXKa3gPurWM9uQ.ttf", + "500italic": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpZK4fy6r6tOBEJg0IAKzqdFZVZxrktbnDB5UzBIup9PwAcHtEsOFNBZqyu6r9Jj3Ka3gPurWM9uQ.ttf", + "600italic": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpZK4fy6r6tOBEJg0IAKzqdFZVZxrktbnDB5UzBIup9PwAcHtEsOFNBZqyu6r9JY3Wa3gPurWM9uQ.ttf", + "700italic": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpZK4fy6r6tOBEJg0IAKzqdFZVZxrktbnDB5UzBIup9PwAcHtEsOFNBZqyu6r9JWnWa3gPurWM9uQ.ttf", + "800italic": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpZK4fy6r6tOBEJg0IAKzqdFZVZxrktbnDB5UzBIup9PwAcHtEsOFNBZqyu6r9JPXWa3gPurWM9uQ.ttf", + "900italic": "http://fonts.gstatic.com/s/notosansdisplay/v13/RLpZK4fy6r6tOBEJg0IAKzqdFZVZxrktbnDB5UzBIup9PwAcHtEsOFNBZqyu6r9JFHWa3gPurWM9uQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Duployan", + "variants": [ + "regular" + ], + "subsets": [ + "duployan" + ], + "version": "v14", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansduployan/v14/gokzH7nwAEdtF9N8-mdTDx_X9JM5wsvrFsIn6WYDvA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Egyptian Hieroglyphs", + "variants": [ + "regular" + ], + "subsets": [ + "egyptian-hieroglyphs" + ], + "version": "v24", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansegyptianhieroglyphs/v24/vEF42-tODB8RrNDvZSUmRhcQHzx1s7y_F9-j3qSzEcbEYindSVK8xRg7iw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Elbasan", + "variants": [ + "regular" + ], + "subsets": [ + "elbasan" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanselbasan/v13/-F6rfiZqLzI2JPCgQBnw400qp1trvHdlre4dFcFh.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Elymaic", + "variants": [ + "regular" + ], + "subsets": [ + "elymaic" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanselymaic/v13/UqyKK9YTJW5liNMhTMqe9vUFP65ZD4AjWOT0zi2V.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Georgian", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "georgian" + ], + "version": "v28", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/notosansgeorgian/v28/PlIaFke5O6RzLfvNNVSitxkr76PRHBC4Ytyq-Gof7PUs4S7zWn-8YDB09HFNdpvnzVj-f5WK0OQV.ttf", + "200": "http://fonts.gstatic.com/s/notosansgeorgian/v28/PlIaFke5O6RzLfvNNVSitxkr76PRHBC4Ytyq-Gof7PUs4S7zWn-8YDB09HFNdptnzFj-f5WK0OQV.ttf", + "300": "http://fonts.gstatic.com/s/notosansgeorgian/v28/PlIaFke5O6RzLfvNNVSitxkr76PRHBC4Ytyq-Gof7PUs4S7zWn-8YDB09HFNdpu5zFj-f5WK0OQV.ttf", + "regular": "http://fonts.gstatic.com/s/notosansgeorgian/v28/PlIaFke5O6RzLfvNNVSitxkr76PRHBC4Ytyq-Gof7PUs4S7zWn-8YDB09HFNdpvnzFj-f5WK0OQV.ttf", + "500": "http://fonts.gstatic.com/s/notosansgeorgian/v28/PlIaFke5O6RzLfvNNVSitxkr76PRHBC4Ytyq-Gof7PUs4S7zWn-8YDB09HFNdpvVzFj-f5WK0OQV.ttf", + "600": "http://fonts.gstatic.com/s/notosansgeorgian/v28/PlIaFke5O6RzLfvNNVSitxkr76PRHBC4Ytyq-Gof7PUs4S7zWn-8YDB09HFNdps5y1j-f5WK0OQV.ttf", + "700": "http://fonts.gstatic.com/s/notosansgeorgian/v28/PlIaFke5O6RzLfvNNVSitxkr76PRHBC4Ytyq-Gof7PUs4S7zWn-8YDB09HFNdpsAy1j-f5WK0OQV.ttf", + "800": "http://fonts.gstatic.com/s/notosansgeorgian/v28/PlIaFke5O6RzLfvNNVSitxkr76PRHBC4Ytyq-Gof7PUs4S7zWn-8YDB09HFNdptny1j-f5WK0OQV.ttf", + "900": "http://fonts.gstatic.com/s/notosansgeorgian/v28/PlIaFke5O6RzLfvNNVSitxkr76PRHBC4Ytyq-Gof7PUs4S7zWn-8YDB09HFNdptOy1j-f5WK0OQV.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Glagolitic", + "variants": [ + "regular" + ], + "subsets": [ + "glagolitic" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansglagolitic/v13/1q2ZY4-BBFBst88SU_tOj4J-4yuNF_HI4ERK4Amu7nM1.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Gothic", + "variants": [ + "regular" + ], + "subsets": [ + "gothic" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansgothic/v13/TuGKUUVzXI5FBtUq5a8bj6wRbzxTFMX40kFQRx0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Grantha", + "variants": [ + "regular" + ], + "subsets": [ + "grantha" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansgrantha/v13/3y976akwcCjmsU8NDyrKo3IQfQ4o-r8cFeulHc6N.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Gujarati", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "gujarati" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosansgujarati/v14/wlpugx_HC1ti5ViekvcxnhMlCVo3f5pNuJBHVa6nAVMh.ttf", + "200": "http://fonts.gstatic.com/s/notosansgujarati/v14/wlpvgx_HC1ti5ViekvcxnhMlCVo3f5pNFLFnf4CrCEo4gg.ttf", + "300": "http://fonts.gstatic.com/s/notosansgujarati/v14/wlpvgx_HC1ti5ViekvcxnhMlCVo3f5pNcLJnf4CrCEo4gg.ttf", + "regular": "http://fonts.gstatic.com/s/notosansgujarati/v14/wlpsgx_HC1ti5ViekvcxnhMlCVo3f5p13JpDd6u3AQ.ttf", + "500": "http://fonts.gstatic.com/s/notosansgujarati/v14/wlpvgx_HC1ti5ViekvcxnhMlCVo3f5pNKLNnf4CrCEo4gg.ttf", + "600": "http://fonts.gstatic.com/s/notosansgujarati/v14/wlpvgx_HC1ti5ViekvcxnhMlCVo3f5pNBLRnf4CrCEo4gg.ttf", + "700": "http://fonts.gstatic.com/s/notosansgujarati/v14/wlpvgx_HC1ti5ViekvcxnhMlCVo3f5pNYLVnf4CrCEo4gg.ttf", + "800": "http://fonts.gstatic.com/s/notosansgujarati/v14/wlpvgx_HC1ti5ViekvcxnhMlCVo3f5pNfLZnf4CrCEo4gg.ttf", + "900": "http://fonts.gstatic.com/s/notosansgujarati/v14/wlpvgx_HC1ti5ViekvcxnhMlCVo3f5pNWLdnf4CrCEo4gg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Gunjala Gondi", + "variants": [ + "regular" + ], + "subsets": [ + "gunjala-gondi" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansgunjalagondi/v13/bWto7e7KfBziStx7lIzKPrcSMwcEnCv6DW7n5hcVXYMTK4q1.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Gurmukhi", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "gurmukhi" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosansgurmukhi/v14/w8g9H3EvQP81sInb43inmyN9zZ7hb7ATbSWo4q8dJ74a3cVrYFQ_bogT0-gPeG1Oe3bxZ_trdp7h.ttf", + "200": "http://fonts.gstatic.com/s/notosansgurmukhi/v14/w8g9H3EvQP81sInb43inmyN9zZ7hb7ATbSWo4q8dJ74a3cVrYFQ_bogT0-gPeG3OenbxZ_trdp7h.ttf", + "300": "http://fonts.gstatic.com/s/notosansgurmukhi/v14/w8g9H3EvQP81sInb43inmyN9zZ7hb7ATbSWo4q8dJ74a3cVrYFQ_bogT0-gPeG0QenbxZ_trdp7h.ttf", + "regular": "http://fonts.gstatic.com/s/notosansgurmukhi/v14/w8g9H3EvQP81sInb43inmyN9zZ7hb7ATbSWo4q8dJ74a3cVrYFQ_bogT0-gPeG1OenbxZ_trdp7h.ttf", + "500": "http://fonts.gstatic.com/s/notosansgurmukhi/v14/w8g9H3EvQP81sInb43inmyN9zZ7hb7ATbSWo4q8dJ74a3cVrYFQ_bogT0-gPeG18enbxZ_trdp7h.ttf", + "600": "http://fonts.gstatic.com/s/notosansgurmukhi/v14/w8g9H3EvQP81sInb43inmyN9zZ7hb7ATbSWo4q8dJ74a3cVrYFQ_bogT0-gPeG2QfXbxZ_trdp7h.ttf", + "700": "http://fonts.gstatic.com/s/notosansgurmukhi/v14/w8g9H3EvQP81sInb43inmyN9zZ7hb7ATbSWo4q8dJ74a3cVrYFQ_bogT0-gPeG2pfXbxZ_trdp7h.ttf", + "800": "http://fonts.gstatic.com/s/notosansgurmukhi/v14/w8g9H3EvQP81sInb43inmyN9zZ7hb7ATbSWo4q8dJ74a3cVrYFQ_bogT0-gPeG3OfXbxZ_trdp7h.ttf", + "900": "http://fonts.gstatic.com/s/notosansgurmukhi/v14/w8g9H3EvQP81sInb43inmyN9zZ7hb7ATbSWo4q8dJ74a3cVrYFQ_bogT0-gPeG3nfXbxZ_trdp7h.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans HK", + "variants": [ + "100", + "300", + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "chinese-hongkong", + "latin" + ], + "version": "v19", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/notosanshk/v19/nKKO-GM_FYFRJvXzVXaAPe9ZUHp1MOv2ObB7.otf", + "300": "http://fonts.gstatic.com/s/notosanshk/v19/nKKP-GM_FYFRJvXzVXaAPe9ZmFhTHMX6MKliqQ.otf", + "regular": "http://fonts.gstatic.com/s/notosanshk/v19/nKKQ-GM_FYFRJvXzVXaAPe9hMnB3Eu7mOQ.otf", + "500": "http://fonts.gstatic.com/s/notosanshk/v19/nKKP-GM_FYFRJvXzVXaAPe9ZwFlTHMX6MKliqQ.otf", + "700": "http://fonts.gstatic.com/s/notosanshk/v19/nKKP-GM_FYFRJvXzVXaAPe9ZiF9THMX6MKliqQ.otf", + "900": "http://fonts.gstatic.com/s/notosanshk/v19/nKKP-GM_FYFRJvXzVXaAPe9ZsF1THMX6MKliqQ.otf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Hanifi Rohingya", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "hanifi-rohingya" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanshanifirohingya/v14/5h17iYsoOmIC3Yu3MDXLDw3UZCgghyOEBBY7hhLNyo3tiaiuSIAqrIYY4j6vvcudK8rN.ttf", + "500": "http://fonts.gstatic.com/s/notosanshanifirohingya/v14/5h17iYsoOmIC3Yu3MDXLDw3UZCgghyOEBBY7hhLNyo3tiaiuSIAqrIYq4j6vvcudK8rN.ttf", + "600": "http://fonts.gstatic.com/s/notosanshanifirohingya/v14/5h17iYsoOmIC3Yu3MDXLDw3UZCgghyOEBBY7hhLNyo3tiaiuSIAqrIbG5T6vvcudK8rN.ttf", + "700": "http://fonts.gstatic.com/s/notosanshanifirohingya/v14/5h17iYsoOmIC3Yu3MDXLDw3UZCgghyOEBBY7hhLNyo3tiaiuSIAqrIb_5T6vvcudK8rN.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Hanunoo", + "variants": [ + "regular" + ], + "subsets": [ + "hanunoo" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanshanunoo/v13/f0Xs0fCv8dxkDWlZSoXOj6CphMloFsEsEpgL_ix2.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Hatran", + "variants": [ + "regular" + ], + "subsets": [ + "hatran" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanshatran/v13/A2BBn4Ne0RgnVF3Lnko-0sOBIfL_mM83r1nwzDs.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Hebrew", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "hebrew" + ], + "version": "v31", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosanshebrew/v31/or3HQ7v33eiDljA1IufXTtVf7V6RvEEdhQlk0LlGxCyaeNKYZC0sqk3xXGiXd4utoiJltutR2g.ttf", + "200": "http://fonts.gstatic.com/s/notosanshebrew/v31/or3HQ7v33eiDljA1IufXTtVf7V6RvEEdhQlk0LlGxCyaeNKYZC0sqk3xXGiX94qtoiJltutR2g.ttf", + "300": "http://fonts.gstatic.com/s/notosanshebrew/v31/or3HQ7v33eiDljA1IufXTtVf7V6RvEEdhQlk0LlGxCyaeNKYZC0sqk3xXGiXKYqtoiJltutR2g.ttf", + "regular": "http://fonts.gstatic.com/s/notosanshebrew/v31/or3HQ7v33eiDljA1IufXTtVf7V6RvEEdhQlk0LlGxCyaeNKYZC0sqk3xXGiXd4qtoiJltutR2g.ttf", + "500": "http://fonts.gstatic.com/s/notosanshebrew/v31/or3HQ7v33eiDljA1IufXTtVf7V6RvEEdhQlk0LlGxCyaeNKYZC0sqk3xXGiXRYqtoiJltutR2g.ttf", + "600": "http://fonts.gstatic.com/s/notosanshebrew/v31/or3HQ7v33eiDljA1IufXTtVf7V6RvEEdhQlk0LlGxCyaeNKYZC0sqk3xXGiXqY2toiJltutR2g.ttf", + "700": "http://fonts.gstatic.com/s/notosanshebrew/v31/or3HQ7v33eiDljA1IufXTtVf7V6RvEEdhQlk0LlGxCyaeNKYZC0sqk3xXGiXkI2toiJltutR2g.ttf", + "800": "http://fonts.gstatic.com/s/notosanshebrew/v31/or3HQ7v33eiDljA1IufXTtVf7V6RvEEdhQlk0LlGxCyaeNKYZC0sqk3xXGiX942toiJltutR2g.ttf", + "900": "http://fonts.gstatic.com/s/notosanshebrew/v31/or3HQ7v33eiDljA1IufXTtVf7V6RvEEdhQlk0LlGxCyaeNKYZC0sqk3xXGiX3o2toiJltutR2g.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Imperial Aramaic", + "variants": [ + "regular" + ], + "subsets": [ + "imperial-aramaic" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansimperialaramaic/v13/a8IMNpjwKmHXpgXbMIsbTc_kvks91LlLetBr5itQrtdml3YfPNno.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Indic Siyaq Numbers", + "variants": [ + "regular" + ], + "subsets": [ + "indic-siyaq-numbers" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansindicsiyaqnumbers/v13/6xK5dTJFKcWIu4bpRBjRZRpsIYHabOeZ8UZLubTzpXNHKx2WPOpVd5Iu.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Inscriptional Pahlavi", + "variants": [ + "regular" + ], + "subsets": [ + "inscriptional-pahlavi" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansinscriptionalpahlavi/v13/ll8UK3GaVDuxR-TEqFPIbsR79Xxz9WEKbwsjpz7VklYlC7FCVtqVOAYK0QA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Inscriptional Parthian", + "variants": [ + "regular" + ], + "subsets": [ + "inscriptional-parthian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansinscriptionalparthian/v13/k3k7o-IMPvpLmixcA63oYi-yStDkgXuXncL7dzfW3P4TAJ2yklBJ2jNkLlLr.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans JP", + "variants": [ + "100", + "300", + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "japanese", + "latin" + ], + "version": "v40", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/notosansjp/v40/-F6ofjtqLzI2JPCgQBnw7HFQoggM-FNthvIU.otf", + "300": "http://fonts.gstatic.com/s/notosansjp/v40/-F6pfjtqLzI2JPCgQBnw7HFQaioq1H1hj-sNFQ.otf", + "regular": "http://fonts.gstatic.com/s/notosansjp/v40/-F62fjtqLzI2JPCgQBnw7HFowAIO2lZ9hg.otf", + "500": "http://fonts.gstatic.com/s/notosansjp/v40/-F6pfjtqLzI2JPCgQBnw7HFQMisq1H1hj-sNFQ.otf", + "700": "http://fonts.gstatic.com/s/notosansjp/v40/-F6pfjtqLzI2JPCgQBnw7HFQei0q1H1hj-sNFQ.otf", + "900": "http://fonts.gstatic.com/s/notosansjp/v40/-F6pfjtqLzI2JPCgQBnw7HFQQi8q1H1hj-sNFQ.otf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Javanese", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "javanese" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansjavanese/v13/2V0AKJkDAIA6Hp4zoSScDjV0Y-eoHAHJ8r88Rp29eA.ttf", + "700": "http://fonts.gstatic.com/s/notosansjavanese/v13/2V0DKJkDAIA6Hp4zoSScDjV0Y-eoHAHxTpAYTrahcTyFxQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans KR", + "variants": [ + "100", + "300", + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v25", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/notosanskr/v25/Pby6FmXiEBPT4ITbgNA5CgmOsn7uwpYcuH8y.otf", + "300": "http://fonts.gstatic.com/s/notosanskr/v25/Pby7FmXiEBPT4ITbgNA5CgmOelzI7rgQsWYrzw.otf", + "regular": "http://fonts.gstatic.com/s/notosanskr/v25/PbykFmXiEBPT4ITbgNA5Cgm20HTs4JMMuA.otf", + "500": "http://fonts.gstatic.com/s/notosanskr/v25/Pby7FmXiEBPT4ITbgNA5CgmOIl3I7rgQsWYrzw.otf", + "700": "http://fonts.gstatic.com/s/notosanskr/v25/Pby7FmXiEBPT4ITbgNA5CgmOalvI7rgQsWYrzw.otf", + "900": "http://fonts.gstatic.com/s/notosanskr/v25/Pby7FmXiEBPT4ITbgNA5CgmOUlnI7rgQsWYrzw.otf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Kaithi", + "variants": [ + "regular" + ], + "subsets": [ + "kaithi" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanskaithi/v13/buEtppS9f8_vkXadMBJJu0tWjLwjQi0KdoZIKlo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Kannada", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "kannada" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosanskannada/v13/8vIs7xs32H97qzQKnzfeXycxXZyUmySvZWItmf1fe6TVmgop9ndpS-BqHEyGrDvMzSIMLsPKrkY.ttf", + "200": "http://fonts.gstatic.com/s/notosanskannada/v13/8vIs7xs32H97qzQKnzfeXycxXZyUmySvZWItmf1fe6TVmgop9ndpS-BqHEyGrLvNzSIMLsPKrkY.ttf", + "300": "http://fonts.gstatic.com/s/notosanskannada/v13/8vIs7xs32H97qzQKnzfeXycxXZyUmySvZWItmf1fe6TVmgop9ndpS-BqHEyGrGXNzSIMLsPKrkY.ttf", + "regular": "http://fonts.gstatic.com/s/notosanskannada/v13/8vIs7xs32H97qzQKnzfeXycxXZyUmySvZWItmf1fe6TVmgop9ndpS-BqHEyGrDvNzSIMLsPKrkY.ttf", + "500": "http://fonts.gstatic.com/s/notosanskannada/v13/8vIs7xs32H97qzQKnzfeXycxXZyUmySvZWItmf1fe6TVmgop9ndpS-BqHEyGrAnNzSIMLsPKrkY.ttf", + "600": "http://fonts.gstatic.com/s/notosanskannada/v13/8vIs7xs32H97qzQKnzfeXycxXZyUmySvZWItmf1fe6TVmgop9ndpS-BqHEyGrOXKzSIMLsPKrkY.ttf", + "700": "http://fonts.gstatic.com/s/notosanskannada/v13/8vIs7xs32H97qzQKnzfeXycxXZyUmySvZWItmf1fe6TVmgop9ndpS-BqHEyGrNzKzSIMLsPKrkY.ttf", + "800": "http://fonts.gstatic.com/s/notosanskannada/v13/8vIs7xs32H97qzQKnzfeXycxXZyUmySvZWItmf1fe6TVmgop9ndpS-BqHEyGrLvKzSIMLsPKrkY.ttf", + "900": "http://fonts.gstatic.com/s/notosanskannada/v13/8vIs7xs32H97qzQKnzfeXycxXZyUmySvZWItmf1fe6TVmgop9ndpS-BqHEyGrJLKzSIMLsPKrkY.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Kayah Li", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "kayah-li" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanskayahli/v14/B50nF61OpWTRcGrhOVJJwOMXdca6Yecki3E06x2jVTX3WCc3CZH4EXLuKVM.ttf", + "500": "http://fonts.gstatic.com/s/notosanskayahli/v14/B50nF61OpWTRcGrhOVJJwOMXdca6Yecki3E06x2jVTX3WBU3CZH4EXLuKVM.ttf", + "600": "http://fonts.gstatic.com/s/notosanskayahli/v14/B50nF61OpWTRcGrhOVJJwOMXdca6Yecki3E06x2jVTX3WPkwCZH4EXLuKVM.ttf", + "700": "http://fonts.gstatic.com/s/notosanskayahli/v14/B50nF61OpWTRcGrhOVJJwOMXdca6Yecki3E06x2jVTX3WMAwCZH4EXLuKVM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Kharoshthi", + "variants": [ + "regular" + ], + "subsets": [ + "kharoshthi" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanskharoshthi/v13/Fh4qPiLjKS30-P4-pGMMXCCfvkc5Vd7KE5z4rFyx5mR1.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Khmer", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "khmer" + ], + "version": "v12", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/notosanskhmer/v12/ijw3s5roRME5LLRxjsRb-gssOenAyendxrgV2c-Zw-9vbVUti_Z_dWgtWYuNAZz4kAbrddiA.ttf", + "200": "http://fonts.gstatic.com/s/notosanskhmer/v12/ijw3s5roRME5LLRxjsRb-gssOenAyendxrgV2c-Zw-9vbVUti_Z_dWgtWYsNAJz4kAbrddiA.ttf", + "300": "http://fonts.gstatic.com/s/notosanskhmer/v12/ijw3s5roRME5LLRxjsRb-gssOenAyendxrgV2c-Zw-9vbVUti_Z_dWgtWYvTAJz4kAbrddiA.ttf", + "regular": "http://fonts.gstatic.com/s/notosanskhmer/v12/ijw3s5roRME5LLRxjsRb-gssOenAyendxrgV2c-Zw-9vbVUti_Z_dWgtWYuNAJz4kAbrddiA.ttf", + "500": "http://fonts.gstatic.com/s/notosanskhmer/v12/ijw3s5roRME5LLRxjsRb-gssOenAyendxrgV2c-Zw-9vbVUti_Z_dWgtWYu_AJz4kAbrddiA.ttf", + "600": "http://fonts.gstatic.com/s/notosanskhmer/v12/ijw3s5roRME5LLRxjsRb-gssOenAyendxrgV2c-Zw-9vbVUti_Z_dWgtWYtTB5z4kAbrddiA.ttf", + "700": "http://fonts.gstatic.com/s/notosanskhmer/v12/ijw3s5roRME5LLRxjsRb-gssOenAyendxrgV2c-Zw-9vbVUti_Z_dWgtWYtqB5z4kAbrddiA.ttf", + "800": "http://fonts.gstatic.com/s/notosanskhmer/v12/ijw3s5roRME5LLRxjsRb-gssOenAyendxrgV2c-Zw-9vbVUti_Z_dWgtWYsNB5z4kAbrddiA.ttf", + "900": "http://fonts.gstatic.com/s/notosanskhmer/v12/ijw3s5roRME5LLRxjsRb-gssOenAyendxrgV2c-Zw-9vbVUti_Z_dWgtWYskB5z4kAbrddiA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Khojki", + "variants": [ + "regular" + ], + "subsets": [ + "khojki" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanskhojki/v13/-nFnOHM29Oofr2wohFbTuPPKVWpmK_d709jy92k.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Khudawadi", + "variants": [ + "regular" + ], + "subsets": [ + "khudawadi" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanskhudawadi/v13/fdNi9t6ZsWBZ2k5ltHN73zZ5hc8HANlHIjRnVVXz9MY.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Lao", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "lao" + ], + "version": "v16", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/notosanslao/v16/bx6lNx2Ol_ixgdYWLm9BwxM3NW6BOkuf763Clj73CiQ_J1Djx9pidOt4ccfdf5MK3riB2w.ttf", + "200": "http://fonts.gstatic.com/s/notosanslao/v16/bx6lNx2Ol_ixgdYWLm9BwxM3NW6BOkuf763Clj73CiQ_J1Djx9pidOt48cbdf5MK3riB2w.ttf", + "300": "http://fonts.gstatic.com/s/notosanslao/v16/bx6lNx2Ol_ixgdYWLm9BwxM3NW6BOkuf763Clj73CiQ_J1Djx9pidOt4L8bdf5MK3riB2w.ttf", + "regular": "http://fonts.gstatic.com/s/notosanslao/v16/bx6lNx2Ol_ixgdYWLm9BwxM3NW6BOkuf763Clj73CiQ_J1Djx9pidOt4ccbdf5MK3riB2w.ttf", + "500": "http://fonts.gstatic.com/s/notosanslao/v16/bx6lNx2Ol_ixgdYWLm9BwxM3NW6BOkuf763Clj73CiQ_J1Djx9pidOt4Q8bdf5MK3riB2w.ttf", + "600": "http://fonts.gstatic.com/s/notosanslao/v16/bx6lNx2Ol_ixgdYWLm9BwxM3NW6BOkuf763Clj73CiQ_J1Djx9pidOt4r8Hdf5MK3riB2w.ttf", + "700": "http://fonts.gstatic.com/s/notosanslao/v16/bx6lNx2Ol_ixgdYWLm9BwxM3NW6BOkuf763Clj73CiQ_J1Djx9pidOt4lsHdf5MK3riB2w.ttf", + "800": "http://fonts.gstatic.com/s/notosanslao/v16/bx6lNx2Ol_ixgdYWLm9BwxM3NW6BOkuf763Clj73CiQ_J1Djx9pidOt48cHdf5MK3riB2w.ttf", + "900": "http://fonts.gstatic.com/s/notosanslao/v16/bx6lNx2Ol_ixgdYWLm9BwxM3NW6BOkuf763Clj73CiQ_J1Djx9pidOt42MHdf5MK3riB2w.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Lepcha", + "variants": [ + "regular" + ], + "subsets": [ + "lepcha" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanslepcha/v13/0QI7MWlB_JWgA166SKhu05TekNS32AJstqBXgd4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Limbu", + "variants": [ + "regular" + ], + "subsets": [ + "limbu" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanslimbu/v13/3JnlSDv90Gmq2mrzckOBBRRoNJVj0MF3OHRDnA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Linear A", + "variants": [ + "regular" + ], + "subsets": [ + "linear-a" + ], + "version": "v14", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanslineara/v14/oPWS_l16kP4jCuhpgEGmwJOiA18FZj22zmHQAGQicw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Linear B", + "variants": [ + "regular" + ], + "subsets": [ + "linear-b" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanslinearb/v13/HhyJU4wt9vSgfHoORYOiXOckKNB737IV3BkFTq4EPw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Lisu", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "lisu" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanslisu/v15/uk-3EGO3o6EruUbnwovcYhz6kh57_nqbcTdjJnHP2Vwt29IlxkVdig.ttf", + "500": "http://fonts.gstatic.com/s/notosanslisu/v15/uk-3EGO3o6EruUbnwovcYhz6kh57_nqbcTdjJnHP61wt29IlxkVdig.ttf", + "600": "http://fonts.gstatic.com/s/notosanslisu/v15/uk-3EGO3o6EruUbnwovcYhz6kh57_nqbcTdjJnHPB1st29IlxkVdig.ttf", + "700": "http://fonts.gstatic.com/s/notosanslisu/v15/uk-3EGO3o6EruUbnwovcYhz6kh57_nqbcTdjJnHPPlst29IlxkVdig.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Lycian", + "variants": [ + "regular" + ], + "subsets": [ + "lycian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanslycian/v13/QldVNSNMqAsHtsJ7UmqxBQA9r8wA5_naCJwn00E.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Lydian", + "variants": [ + "regular" + ], + "subsets": [ + "lydian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanslydian/v13/c4m71mVzGN7s8FmIukZJ1v4ZlcPReUPXMoIjEQI.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Mahajani", + "variants": [ + "regular" + ], + "subsets": [ + "mahajani" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmahajani/v13/-F6sfiVqLzI2JPCgQBnw60Agp0JrvD5Fh8ARHNh4zg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Malayalam", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "malayalam" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/notosansmalayalam/v13/sJoi3K5XjsSdcnzn071rL37lpAOsUThnDZIfPdbeSNzVakglNM-Qw8EaeB8Nss-_RuH9BFzEr6HxEA.ttf", + "200": "http://fonts.gstatic.com/s/notosansmalayalam/v13/sJoi3K5XjsSdcnzn071rL37lpAOsUThnDZIfPdbeSNzVakglNM-Qw8EaeB8Nss-_xuD9BFzEr6HxEA.ttf", + "300": "http://fonts.gstatic.com/s/notosansmalayalam/v13/sJoi3K5XjsSdcnzn071rL37lpAOsUThnDZIfPdbeSNzVakglNM-Qw8EaeB8Nss-_GOD9BFzEr6HxEA.ttf", + "regular": "http://fonts.gstatic.com/s/notosansmalayalam/v13/sJoi3K5XjsSdcnzn071rL37lpAOsUThnDZIfPdbeSNzVakglNM-Qw8EaeB8Nss-_RuD9BFzEr6HxEA.ttf", + "500": "http://fonts.gstatic.com/s/notosansmalayalam/v13/sJoi3K5XjsSdcnzn071rL37lpAOsUThnDZIfPdbeSNzVakglNM-Qw8EaeB8Nss-_dOD9BFzEr6HxEA.ttf", + "600": "http://fonts.gstatic.com/s/notosansmalayalam/v13/sJoi3K5XjsSdcnzn071rL37lpAOsUThnDZIfPdbeSNzVakglNM-Qw8EaeB8Nss-_mOf9BFzEr6HxEA.ttf", + "700": "http://fonts.gstatic.com/s/notosansmalayalam/v13/sJoi3K5XjsSdcnzn071rL37lpAOsUThnDZIfPdbeSNzVakglNM-Qw8EaeB8Nss-_oef9BFzEr6HxEA.ttf", + "800": "http://fonts.gstatic.com/s/notosansmalayalam/v13/sJoi3K5XjsSdcnzn071rL37lpAOsUThnDZIfPdbeSNzVakglNM-Qw8EaeB8Nss-_xuf9BFzEr6HxEA.ttf", + "900": "http://fonts.gstatic.com/s/notosansmalayalam/v13/sJoi3K5XjsSdcnzn071rL37lpAOsUThnDZIfPdbeSNzVakglNM-Qw8EaeB8Nss-_7-f9BFzEr6HxEA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Mandaic", + "variants": [ + "regular" + ], + "subsets": [ + "mandaic" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmandaic/v13/cIfnMbdWt1w_HgCcilqhKQBo_OsMI5_A_gMk0izH.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Manichaean", + "variants": [ + "regular" + ], + "subsets": [ + "manichaean" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmanichaean/v13/taiVGntiC4--qtsfi4Jp9-_GkPZZCcrfekqCNTtFCtdX.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Marchen", + "variants": [ + "regular" + ], + "subsets": [ + "marchen" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmarchen/v13/aFTO7OZ_Y282EP-WyG6QTOX_C8WZMHhPk652ZaHk.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Masaram Gondi", + "variants": [ + "regular" + ], + "subsets": [ + "masaram-gondi" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmasaramgondi/v13/6xK_dThFKcWIu4bpRBjRYRV7KZCbUq6n_1kPnuGe7RI9WSWX.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Math", + "variants": [ + "regular" + ], + "subsets": [ + "math" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmath/v13/7Aump_cpkSecTWaHRlH2hyV5UHkG-V048PW0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Mayan Numerals", + "variants": [ + "regular" + ], + "subsets": [ + "mayan-numerals" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmayannumerals/v13/PlIuFk25O6RzLfvNNVSivR09_KqYMwvvDKYjfIiE68oo6eepYQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Medefaidrin", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "medefaidrin" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmedefaidrin/v15/WwkzxOq6Dk-wranENynkfeVsNbRZtbOIdLb1exeM4ZeuabBfmErWlT318e5A3rw.ttf", + "500": "http://fonts.gstatic.com/s/notosansmedefaidrin/v15/WwkzxOq6Dk-wranENynkfeVsNbRZtbOIdLb1exeM4ZeuabBfmHjWlT318e5A3rw.ttf", + "600": "http://fonts.gstatic.com/s/notosansmedefaidrin/v15/WwkzxOq6Dk-wranENynkfeVsNbRZtbOIdLb1exeM4ZeuabBfmJTRlT318e5A3rw.ttf", + "700": "http://fonts.gstatic.com/s/notosansmedefaidrin/v15/WwkzxOq6Dk-wranENynkfeVsNbRZtbOIdLb1exeM4ZeuabBfmK3RlT318e5A3rw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Meetei Mayek", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "meetei-mayek" + ], + "version": "v7", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notosansmeeteimayek/v7/HTxAL3QyKieByqY9eZPFweO0be7M21uSphSdhqILnmrRfJ8t_1TJ__TW5PgeFYVa.ttf", + "200": "http://fonts.gstatic.com/s/notosansmeeteimayek/v7/HTxAL3QyKieByqY9eZPFweO0be7M21uSphSdhqILnmrRfJ8t_1RJ_vTW5PgeFYVa.ttf", + "300": "http://fonts.gstatic.com/s/notosansmeeteimayek/v7/HTxAL3QyKieByqY9eZPFweO0be7M21uSphSdhqILnmrRfJ8t_1SX_vTW5PgeFYVa.ttf", + "regular": "http://fonts.gstatic.com/s/notosansmeeteimayek/v7/HTxAL3QyKieByqY9eZPFweO0be7M21uSphSdhqILnmrRfJ8t_1TJ_vTW5PgeFYVa.ttf", + "500": "http://fonts.gstatic.com/s/notosansmeeteimayek/v7/HTxAL3QyKieByqY9eZPFweO0be7M21uSphSdhqILnmrRfJ8t_1T7_vTW5PgeFYVa.ttf", + "600": "http://fonts.gstatic.com/s/notosansmeeteimayek/v7/HTxAL3QyKieByqY9eZPFweO0be7M21uSphSdhqILnmrRfJ8t_1QX-fTW5PgeFYVa.ttf", + "700": "http://fonts.gstatic.com/s/notosansmeeteimayek/v7/HTxAL3QyKieByqY9eZPFweO0be7M21uSphSdhqILnmrRfJ8t_1Qu-fTW5PgeFYVa.ttf", + "800": "http://fonts.gstatic.com/s/notosansmeeteimayek/v7/HTxAL3QyKieByqY9eZPFweO0be7M21uSphSdhqILnmrRfJ8t_1RJ-fTW5PgeFYVa.ttf", + "900": "http://fonts.gstatic.com/s/notosansmeeteimayek/v7/HTxAL3QyKieByqY9eZPFweO0be7M21uSphSdhqILnmrRfJ8t_1Rg-fTW5PgeFYVa.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Meroitic", + "variants": [ + "regular" + ], + "subsets": [ + "meroitic" + ], + "version": "v14", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmeroitic/v14/IFS5HfRJndhE3P4b5jnZ3ITPvC6i00UDgDhTiKY9KQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Miao", + "variants": [ + "regular" + ], + "subsets": [ + "miao" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmiao/v13/Dxxz8jmXMW75w3OmoDXVV4zyZUjgUYVslLhx.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Modi", + "variants": [ + "regular" + ], + "subsets": [ + "modi" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmodi/v13/pe03MIySN5pO62Z5YkFyT7jeav5qWVAgVol-.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Mongolian", + "variants": [ + "regular" + ], + "subsets": [ + "mongolian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmongolian/v13/VdGCAYADGIwE0EopZx8xQfHlgEAMsrToxLsg6-av1x0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Mono", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/notosansmono/v14/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNI49rXVEQQL8Y.ttf", + "200": "http://fonts.gstatic.com/s/notosansmono/v14/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_NNJ49rXVEQQL8Y.ttf", + "300": "http://fonts.gstatic.com/s/notosansmono/v14/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_A1J49rXVEQQL8Y.ttf", + "regular": "http://fonts.gstatic.com/s/notosansmono/v14/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_FNJ49rXVEQQL8Y.ttf", + "500": "http://fonts.gstatic.com/s/notosansmono/v14/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_GFJ49rXVEQQL8Y.ttf", + "600": "http://fonts.gstatic.com/s/notosansmono/v14/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_I1O49rXVEQQL8Y.ttf", + "700": "http://fonts.gstatic.com/s/notosansmono/v14/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_LRO49rXVEQQL8Y.ttf", + "800": "http://fonts.gstatic.com/s/notosansmono/v14/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_NNO49rXVEQQL8Y.ttf", + "900": "http://fonts.gstatic.com/s/notosansmono/v14/BngrUXNETWXI6LwhGYvaxZikqZqK6fBq6kPvUce2oAZcdthSBUsYck4-_PpO49rXVEQQL8Y.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Mro", + "variants": [ + "regular" + ], + "subsets": [ + "mro" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmro/v13/qWcsB6--pZv9TqnUQMhe9b39WDzRtjkho4M.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Multani", + "variants": [ + "regular" + ], + "subsets": [ + "multani" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansmultani/v13/9Bty3ClF38_RfOpe1gCaZ8p30BOFO1A0pfCs5Kos.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Myanmar", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "myanmar" + ], + "version": "v17", + "lastModified": "2021-12-09", + "files": { + "100": "http://fonts.gstatic.com/s/notosansmyanmar/v17/AlZs_y1ZtY3ymOryg38hOCSdOnFq0HGS1uEapkAC3AY.ttf", + "200": "http://fonts.gstatic.com/s/notosansmyanmar/v17/AlZv_y1ZtY3ymOryg38hOCSdOnFq0HE-98EwiEwLxR-r.ttf", + "300": "http://fonts.gstatic.com/s/notosansmyanmar/v17/AlZv_y1ZtY3ymOryg38hOCSdOnFq0HFa9MEwiEwLxR-r.ttf", + "regular": "http://fonts.gstatic.com/s/notosansmyanmar/v17/AlZq_y1ZtY3ymOryg38hOCSdOnFq0En23OU4o1AC.ttf", + "500": "http://fonts.gstatic.com/s/notosansmyanmar/v17/AlZv_y1ZtY3ymOryg38hOCSdOnFq0HEC9cEwiEwLxR-r.ttf", + "600": "http://fonts.gstatic.com/s/notosansmyanmar/v17/AlZv_y1ZtY3ymOryg38hOCSdOnFq0HEu8sEwiEwLxR-r.ttf", + "700": "http://fonts.gstatic.com/s/notosansmyanmar/v17/AlZv_y1ZtY3ymOryg38hOCSdOnFq0HFK88EwiEwLxR-r.ttf", + "800": "http://fonts.gstatic.com/s/notosansmyanmar/v17/AlZv_y1ZtY3ymOryg38hOCSdOnFq0HFW8MEwiEwLxR-r.ttf", + "900": "http://fonts.gstatic.com/s/notosansmyanmar/v17/AlZv_y1ZtY3ymOryg38hOCSdOnFq0HFy8cEwiEwLxR-r.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans N Ko", + "variants": [ + "regular" + ], + "subsets": [ + "nko" + ], + "version": "v15", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansnko/v15/6NUP8FqDKBaKKjnr6P8v-sxPpvVBVNmme3gf.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Nabataean", + "variants": [ + "regular" + ], + "subsets": [ + "nabataean" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansnabataean/v13/IFS4HfVJndhE3P4b5jnZ34DfsjO330dNoBJ9hK8kMK4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans New Tai Lue", + "variants": [ + "regular" + ], + "subsets": [ + "new-tai-lue" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansnewtailue/v13/H4c5BW-Pl9DZ0Xe_nHUapt7PovLXAhAnY7wwY55O4AS32A.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Newa", + "variants": [ + "regular" + ], + "subsets": [ + "newa" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansnewa/v13/7r3fqXp6utEsO9pI4f8ok8sWg8n_qN4R5lNU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Nushu", + "variants": [ + "regular" + ], + "subsets": [ + "nushu" + ], + "version": "v16", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansnushu/v16/rnCw-xRQ3B7652emAbAe_Ai1IYaFWFAMArZKqQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Ogham", + "variants": [ + "regular" + ], + "subsets": [ + "ogham" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansogham/v13/kmKlZqk1GBDGN0mY6k5lmEmww4hrt5laQxcoCA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Ol Chiki", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "ol-chiki" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansolchiki/v15/N0b92TJNOPt-eHmFZCdQbrL32r-4CvhzDzRwlxOQYuVALWk267I6gVrz5gQ.ttf", + "500": "http://fonts.gstatic.com/s/notosansolchiki/v15/N0b92TJNOPt-eHmFZCdQbrL32r-4CvhzDzRwlxOQYuVALVs267I6gVrz5gQ.ttf", + "600": "http://fonts.gstatic.com/s/notosansolchiki/v15/N0b92TJNOPt-eHmFZCdQbrL32r-4CvhzDzRwlxOQYuVALbcx67I6gVrz5gQ.ttf", + "700": "http://fonts.gstatic.com/s/notosansolchiki/v15/N0b92TJNOPt-eHmFZCdQbrL32r-4CvhzDzRwlxOQYuVALY4x67I6gVrz5gQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Old Hungarian", + "variants": [ + "regular" + ], + "subsets": [ + "old-hungarian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansoldhungarian/v13/E213_cD6hP3GwCJPEUssHEM0KqLaHJXg2PiIgRfjbg5nCYXt.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Old Italic", + "variants": [ + "regular" + ], + "subsets": [ + "old-italic" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansolditalic/v13/TuGOUUFzXI5FBtUq5a8bh68BJxxEVam7tWlRdRhtCC4d.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Old North Arabian", + "variants": [ + "regular" + ], + "subsets": [ + "old-north-arabian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansoldnortharabian/v13/esDF30BdNv-KYGGJpKGk2tNiMt7Jar6olZDyNdr81zBQmUo_xw4ABw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Old Permic", + "variants": [ + "regular" + ], + "subsets": [ + "old-permic" + ], + "version": "v14", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansoldpermic/v14/snf1s1q1-dF8pli1TesqcbUY4Mr-ElrwKLdXgv_dKYB5.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Old Persian", + "variants": [ + "regular" + ], + "subsets": [ + "old-persian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansoldpersian/v13/wEOjEAbNnc5caQTFG18FHrZr9Bp6-8CmIJ_tqOlQfx9CjA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Old Sogdian", + "variants": [ + "regular" + ], + "subsets": [ + "old-sogdian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansoldsogdian/v13/3JnjSCH90Gmq2mrzckOBBhFhdrMst48aURt7neIqM-9uyg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Old South Arabian", + "variants": [ + "regular" + ], + "subsets": [ + "old-south-arabian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansoldsoutharabian/v13/3qT5oiOhnSyU8TNFIdhZTice3hB_HWKsEnF--0XCHiKx1OtDT9HwTA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Old Turkic", + "variants": [ + "regular" + ], + "subsets": [ + "old-turkic" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansoldturkic/v13/yMJNMJVya43H0SUF_WmcGEQVqoEMKDKbsE2RjEw-Vyws.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Oriya", + "variants": [ + "100", + "regular", + "700", + "900" + ], + "subsets": [ + "oriya" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosansoriya/v14/AYCRpXfzfccDCstK_hrjDyADv5efgKfHRKhxIh_G.ttf", + "regular": "http://fonts.gstatic.com/s/notosansoriya/v14/AYCTpXfzfccDCstK_hrjDyADv5en5K3DZq1hIg.ttf", + "700": "http://fonts.gstatic.com/s/notosansoriya/v14/AYCWpXfzfccDCstK_hrjDyADv5efWILnboZ9KwbfIQ.ttf", + "900": "http://fonts.gstatic.com/s/notosansoriya/v14/AYCWpXfzfccDCstK_hrjDyADv5efYIDnboZ9KwbfIQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Osage", + "variants": [ + "regular" + ], + "subsets": [ + "osage" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansosage/v13/oPWX_kB6kP4jCuhpgEGmw4mtAVtXRlaSxkrMCQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Osmanya", + "variants": [ + "regular" + ], + "subsets": [ + "osmanya" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansosmanya/v13/8vIS7xs32H97qzQKnzfeWzUyUpOJmz6kR47NCV5Z.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Pahawh Hmong", + "variants": [ + "regular" + ], + "subsets": [ + "pahawh-hmong" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanspahawhhmong/v13/bWtp7e_KfBziStx7lIzKKaMUOBEA3UPQDW7krzc_c48aMpM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Palmyrene", + "variants": [ + "regular" + ], + "subsets": [ + "palmyrene" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanspalmyrene/v13/ZgNPjOdKPa7CHqq0h37c_ASCWvH93SFCPnK5ZpdNtcA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Pau Cin Hau", + "variants": [ + "regular" + ], + "subsets": [ + "pau-cin-hau" + ], + "version": "v14", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanspaucinhau/v14/x3d-cl3IZKmUqiMg_9wBLLtzl22EayN7ehIdjEWqKMxsKw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Phags Pa", + "variants": [ + "regular" + ], + "subsets": [ + "phags-pa" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansphagspa/v13/pxiZyoo6v8ZYyWh5WuPeJzMkd4SrGChkqkSsrvNXiA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Phoenician", + "variants": [ + "regular" + ], + "subsets": [ + "phoenician" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansphoenician/v13/jizFRF9Ksm4Bt9PvcTaEkIHiTVtxmFtS5X7Jot-p5561.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Psalter Pahlavi", + "variants": [ + "regular" + ], + "subsets": [ + "psalter-pahlavi" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanspsalterpahlavi/v13/rP2Vp3K65FkAtHfwd-eISGznYihzggmsicPfud3w1G3KsUQBct4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Rejang", + "variants": [ + "regular" + ], + "subsets": [ + "rejang" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansrejang/v13/Ktk2AKuMeZjqPnXgyqrib7DIogqwN4O3WYZB_sU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Runic", + "variants": [ + "regular" + ], + "subsets": [ + "runic" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansrunic/v13/H4c_BXWPl9DZ0Xe_nHUaus7W68WWaxpvHtgIYg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans SC", + "variants": [ + "100", + "300", + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "chinese-simplified", + "latin" + ], + "version": "v24", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/notosanssc/v24/k3kJo84MPvpLmixcA63oeALZTYKL2wv287Sb.otf", + "300": "http://fonts.gstatic.com/s/notosanssc/v24/k3kIo84MPvpLmixcA63oeALZhaCt9yX6-q2CGg.otf", + "regular": "http://fonts.gstatic.com/s/notosanssc/v24/k3kXo84MPvpLmixcA63oeALhL4iJ-Q7m8w.otf", + "500": "http://fonts.gstatic.com/s/notosanssc/v24/k3kIo84MPvpLmixcA63oeALZ3aGt9yX6-q2CGg.otf", + "700": "http://fonts.gstatic.com/s/notosanssc/v24/k3kIo84MPvpLmixcA63oeALZlaet9yX6-q2CGg.otf", + "900": "http://fonts.gstatic.com/s/notosanssc/v24/k3kIo84MPvpLmixcA63oeALZraWt9yX6-q2CGg.otf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Samaritan", + "variants": [ + "regular" + ], + "subsets": [ + "samaritan" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanssamaritan/v13/buEqppe9f8_vkXadMBJJo0tSmaYjFkxOUo5jNlOVMzQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Saurashtra", + "variants": [ + "regular" + ], + "subsets": [ + "saurashtra" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanssaurashtra/v13/ea8GacQ0Wfz_XKWXe6OtoA8w8zvmYwTef9ndjhPTSIx9.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Sharada", + "variants": [ + "regular" + ], + "subsets": [ + "sharada" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanssharada/v13/gok0H7rwAEdtF9N8-mdTGALG6p0kwoXLPOwr4H8a.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Shavian", + "variants": [ + "regular" + ], + "subsets": [ + "shavian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansshavian/v13/CHy5V_HZE0jxJBQlqAeCKjJvQBNF4EFQSplv2Cwg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Siddham", + "variants": [ + "regular" + ], + "subsets": [ + "siddham" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanssiddham/v13/OZpZg-FwqiNLe9PELUikxTWDoCCeGqndk3Ic92ZH.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Sinhala", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "sinhala" + ], + "version": "v21", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosanssinhala/v21/yMJ2MJBya43H0SUF_WmcBEEf4rQVO2P524V5N_MxQzQtb-tf5dJbC30Fu9zUwg2b5lgLpJwbQRM.ttf", + "200": "http://fonts.gstatic.com/s/notosanssinhala/v21/yMJ2MJBya43H0SUF_WmcBEEf4rQVO2P524V5N_MxQzQtb-tf5dJbC30Fu9zUwo2a5lgLpJwbQRM.ttf", + "300": "http://fonts.gstatic.com/s/notosanssinhala/v21/yMJ2MJBya43H0SUF_WmcBEEf4rQVO2P524V5N_MxQzQtb-tf5dJbC30Fu9zUwlOa5lgLpJwbQRM.ttf", + "regular": "http://fonts.gstatic.com/s/notosanssinhala/v21/yMJ2MJBya43H0SUF_WmcBEEf4rQVO2P524V5N_MxQzQtb-tf5dJbC30Fu9zUwg2a5lgLpJwbQRM.ttf", + "500": "http://fonts.gstatic.com/s/notosanssinhala/v21/yMJ2MJBya43H0SUF_WmcBEEf4rQVO2P524V5N_MxQzQtb-tf5dJbC30Fu9zUwj-a5lgLpJwbQRM.ttf", + "600": "http://fonts.gstatic.com/s/notosanssinhala/v21/yMJ2MJBya43H0SUF_WmcBEEf4rQVO2P524V5N_MxQzQtb-tf5dJbC30Fu9zUwtOd5lgLpJwbQRM.ttf", + "700": "http://fonts.gstatic.com/s/notosanssinhala/v21/yMJ2MJBya43H0SUF_WmcBEEf4rQVO2P524V5N_MxQzQtb-tf5dJbC30Fu9zUwuqd5lgLpJwbQRM.ttf", + "800": "http://fonts.gstatic.com/s/notosanssinhala/v21/yMJ2MJBya43H0SUF_WmcBEEf4rQVO2P524V5N_MxQzQtb-tf5dJbC30Fu9zUwo2d5lgLpJwbQRM.ttf", + "900": "http://fonts.gstatic.com/s/notosanssinhala/v21/yMJ2MJBya43H0SUF_WmcBEEf4rQVO2P524V5N_MxQzQtb-tf5dJbC30Fu9zUwqSd5lgLpJwbQRM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Sogdian", + "variants": [ + "regular" + ], + "subsets": [ + "sogdian" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanssogdian/v13/taiQGn5iC4--qtsfi4Jp6eHPnfxQBo--Pm6KHidM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Sora Sompeng", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "sora-sompeng" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanssorasompeng/v15/PlIRFkO5O6RzLfvNNVSioxM2_OTrEhPyDLolKvCsHzCxWuGkYHR818DpZXJQd4Mu.ttf", + "500": "http://fonts.gstatic.com/s/notosanssorasompeng/v15/PlIRFkO5O6RzLfvNNVSioxM2_OTrEhPyDLolKvCsHzCxWuGkYHRO18DpZXJQd4Mu.ttf", + "600": "http://fonts.gstatic.com/s/notosanssorasompeng/v15/PlIRFkO5O6RzLfvNNVSioxM2_OTrEhPyDLolKvCsHzCxWuGkYHSi0MDpZXJQd4Mu.ttf", + "700": "http://fonts.gstatic.com/s/notosanssorasompeng/v15/PlIRFkO5O6RzLfvNNVSioxM2_OTrEhPyDLolKvCsHzCxWuGkYHSb0MDpZXJQd4Mu.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Soyombo", + "variants": [ + "regular" + ], + "subsets": [ + "soyombo" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanssoyombo/v13/RWmSoL-Y6-8q5LTtXs6MF6q7xsxgY0FrIFOcK25W.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Sundanese", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "sundanese" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanssundanese/v15/FwZw7_84xUkosG2xJo2gm7nFwSLQkdymq2mkz3Gz1_b6ctxpNNHCizv7fQES.ttf", + "500": "http://fonts.gstatic.com/s/notosanssundanese/v15/FwZw7_84xUkosG2xJo2gm7nFwSLQkdymq2mkz3Gz1_b6ctxbNNHCizv7fQES.ttf", + "600": "http://fonts.gstatic.com/s/notosanssundanese/v15/FwZw7_84xUkosG2xJo2gm7nFwSLQkdymq2mkz3Gz1_b6cty3M9HCizv7fQES.ttf", + "700": "http://fonts.gstatic.com/s/notosanssundanese/v15/FwZw7_84xUkosG2xJo2gm7nFwSLQkdymq2mkz3Gz1_b6ctyOM9HCizv7fQES.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Syloti Nagri", + "variants": [ + "regular" + ], + "subsets": [ + "syloti-nagri" + ], + "version": "v13", + "lastModified": "2021-12-09", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanssylotinagri/v13/uU9eCAQZ75uhfF9UoWDRiY3q7Sf_VFV3m4dGFVfxN87gsj0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Symbols", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "symbols" + ], + "version": "v30", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notosanssymbols/v30/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3gfQ4gavVFRkzrbQ.ttf", + "200": "http://fonts.gstatic.com/s/notosanssymbols/v30/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3g_Q8gavVFRkzrbQ.ttf", + "300": "http://fonts.gstatic.com/s/notosanssymbols/v30/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3gIw8gavVFRkzrbQ.ttf", + "regular": "http://fonts.gstatic.com/s/notosanssymbols/v30/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3gfQ8gavVFRkzrbQ.ttf", + "500": "http://fonts.gstatic.com/s/notosanssymbols/v30/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3gTw8gavVFRkzrbQ.ttf", + "600": "http://fonts.gstatic.com/s/notosanssymbols/v30/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3gowggavVFRkzrbQ.ttf", + "700": "http://fonts.gstatic.com/s/notosanssymbols/v30/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3gmgggavVFRkzrbQ.ttf", + "800": "http://fonts.gstatic.com/s/notosanssymbols/v30/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3g_QggavVFRkzrbQ.ttf", + "900": "http://fonts.gstatic.com/s/notosanssymbols/v30/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3g1AggavVFRkzrbQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Symbols 2", + "variants": [ + "regular" + ], + "subsets": [ + "symbols" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanssymbols2/v13/I_uyMoGduATTei9eI8daxVHDyfisHr71ypPqfX71-AI.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Syriac", + "variants": [ + "100", + "regular", + "900" + ], + "subsets": [ + "syriac" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/notosanssyriac/v13/KtkwAKuMeZjqPnXgyqribqzQqgW0D-e9XaRE7sX5Cg.ttf", + "regular": "http://fonts.gstatic.com/s/notosanssyriac/v13/Ktk2AKuMeZjqPnXgyqribqzQqgW0N4O3WYZB_sU.ttf", + "900": "http://fonts.gstatic.com/s/notosanssyriac/v13/KtkxAKuMeZjqPnXgyqribqzQqgW0DweafY5q4szgE-Q.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans TC", + "variants": [ + "100", + "300", + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "chinese-traditional", + "latin" + ], + "version": "v24", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/notosanstc/v24/-nFlOG829Oofr2wohFbTp9i9WyEJIfNZ1sjy.otf", + "300": "http://fonts.gstatic.com/s/notosanstc/v24/-nFkOG829Oofr2wohFbTp9i9kwMvDd1V39Hr7g.otf", + "regular": "http://fonts.gstatic.com/s/notosanstc/v24/-nF7OG829Oofr2wohFbTp9iFOSsLA_ZJ1g.otf", + "500": "http://fonts.gstatic.com/s/notosanstc/v24/-nFkOG829Oofr2wohFbTp9i9ywIvDd1V39Hr7g.otf", + "700": "http://fonts.gstatic.com/s/notosanstc/v24/-nFkOG829Oofr2wohFbTp9i9gwQvDd1V39Hr7g.otf", + "900": "http://fonts.gstatic.com/s/notosanstc/v24/-nFkOG829Oofr2wohFbTp9i9uwYvDd1V39Hr7g.otf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Tagalog", + "variants": [ + "regular" + ], + "subsets": [ + "tagalog" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanstagalog/v13/J7aFnoNzCnFcV9ZI-sUYuvote1R0wwEAA8jHexnL.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Tagbanwa", + "variants": [ + "regular" + ], + "subsets": [ + "tagbanwa" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanstagbanwa/v13/Y4GWYbB8VTEp4t3MKJSMmQdIKjRtt_nZRjQEaYpGoQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Tai Le", + "variants": [ + "regular" + ], + "subsets": [ + "tai-le" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanstaile/v13/vEFK2-VODB8RrNDvZSUmVxEATwR58tK1W77HtMo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Tai Tham", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "tai-tham" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanstaitham/v15/kJEbBv0U4hgtwxDUw2x9q7tbjLIfbPGHBoaVSAZ3MdLJBCUbPgquyaRGKMw.ttf", + "500": "http://fonts.gstatic.com/s/notosanstaitham/v15/kJEbBv0U4hgtwxDUw2x9q7tbjLIfbPGHBoaVSAZ3MdLJBBcbPgquyaRGKMw.ttf", + "600": "http://fonts.gstatic.com/s/notosanstaitham/v15/kJEbBv0U4hgtwxDUw2x9q7tbjLIfbPGHBoaVSAZ3MdLJBPscPgquyaRGKMw.ttf", + "700": "http://fonts.gstatic.com/s/notosanstaitham/v15/kJEbBv0U4hgtwxDUw2x9q7tbjLIfbPGHBoaVSAZ3MdLJBMIcPgquyaRGKMw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Tai Viet", + "variants": [ + "regular" + ], + "subsets": [ + "tai-viet" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanstaiviet/v13/8QIUdj3HhN_lv4jf9vsE-9GMOLsaSPZr644fWsRO9w.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Takri", + "variants": [ + "regular" + ], + "subsets": [ + "takri" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanstakri/v13/TuGJUVpzXI5FBtUq5a8bnKIOdTwQNO_W3khJXg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Tamil", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "tamil" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosanstamil/v14/ieVc2YdFI3GCY6SyQy1KfStzYKZgzN1z4LKDbeZce-0429tBManUktuex7vGor0RqKDt_EvT.ttf", + "200": "http://fonts.gstatic.com/s/notosanstamil/v14/ieVc2YdFI3GCY6SyQy1KfStzYKZgzN1z4LKDbeZce-0429tBManUktuex7tGo70RqKDt_EvT.ttf", + "300": "http://fonts.gstatic.com/s/notosanstamil/v14/ieVc2YdFI3GCY6SyQy1KfStzYKZgzN1z4LKDbeZce-0429tBManUktuex7uYo70RqKDt_EvT.ttf", + "regular": "http://fonts.gstatic.com/s/notosanstamil/v14/ieVc2YdFI3GCY6SyQy1KfStzYKZgzN1z4LKDbeZce-0429tBManUktuex7vGo70RqKDt_EvT.ttf", + "500": "http://fonts.gstatic.com/s/notosanstamil/v14/ieVc2YdFI3GCY6SyQy1KfStzYKZgzN1z4LKDbeZce-0429tBManUktuex7v0o70RqKDt_EvT.ttf", + "600": "http://fonts.gstatic.com/s/notosanstamil/v14/ieVc2YdFI3GCY6SyQy1KfStzYKZgzN1z4LKDbeZce-0429tBManUktuex7sYpL0RqKDt_EvT.ttf", + "700": "http://fonts.gstatic.com/s/notosanstamil/v14/ieVc2YdFI3GCY6SyQy1KfStzYKZgzN1z4LKDbeZce-0429tBManUktuex7shpL0RqKDt_EvT.ttf", + "800": "http://fonts.gstatic.com/s/notosanstamil/v14/ieVc2YdFI3GCY6SyQy1KfStzYKZgzN1z4LKDbeZce-0429tBManUktuex7tGpL0RqKDt_EvT.ttf", + "900": "http://fonts.gstatic.com/s/notosanstamil/v14/ieVc2YdFI3GCY6SyQy1KfStzYKZgzN1z4LKDbeZce-0429tBManUktuex7tvpL0RqKDt_EvT.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Tamil Supplement", + "variants": [ + "regular" + ], + "subsets": [ + "tamil-supplement" + ], + "version": "v17", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanstamilsupplement/v17/DdTz78kEtnooLS5rXF1DaruiCd_bFp_Ph4sGcn7ax_vsAeMkeq1x.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Telugu", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "telugu" + ], + "version": "v12", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/notosanstelugu/v12/0FlxVOGZlE2Rrtr-HmgkMWJNjJ5_RyT8o8c7fHkeg-esVC5dzHkHIJQqrEntezfqQUbf-3v37w.ttf", + "200": "http://fonts.gstatic.com/s/notosanstelugu/v12/0FlxVOGZlE2Rrtr-HmgkMWJNjJ5_RyT8o8c7fHkeg-esVC5dzHkHIJQqrEnt-zbqQUbf-3v37w.ttf", + "300": "http://fonts.gstatic.com/s/notosanstelugu/v12/0FlxVOGZlE2Rrtr-HmgkMWJNjJ5_RyT8o8c7fHkeg-esVC5dzHkHIJQqrEntJTbqQUbf-3v37w.ttf", + "regular": "http://fonts.gstatic.com/s/notosanstelugu/v12/0FlxVOGZlE2Rrtr-HmgkMWJNjJ5_RyT8o8c7fHkeg-esVC5dzHkHIJQqrEntezbqQUbf-3v37w.ttf", + "500": "http://fonts.gstatic.com/s/notosanstelugu/v12/0FlxVOGZlE2Rrtr-HmgkMWJNjJ5_RyT8o8c7fHkeg-esVC5dzHkHIJQqrEntSTbqQUbf-3v37w.ttf", + "600": "http://fonts.gstatic.com/s/notosanstelugu/v12/0FlxVOGZlE2Rrtr-HmgkMWJNjJ5_RyT8o8c7fHkeg-esVC5dzHkHIJQqrEntpTHqQUbf-3v37w.ttf", + "700": "http://fonts.gstatic.com/s/notosanstelugu/v12/0FlxVOGZlE2Rrtr-HmgkMWJNjJ5_RyT8o8c7fHkeg-esVC5dzHkHIJQqrEntnDHqQUbf-3v37w.ttf", + "800": "http://fonts.gstatic.com/s/notosanstelugu/v12/0FlxVOGZlE2Rrtr-HmgkMWJNjJ5_RyT8o8c7fHkeg-esVC5dzHkHIJQqrEnt-zHqQUbf-3v37w.ttf", + "900": "http://fonts.gstatic.com/s/notosanstelugu/v12/0FlxVOGZlE2Rrtr-HmgkMWJNjJ5_RyT8o8c7fHkeg-esVC5dzHkHIJQqrEnt0jHqQUbf-3v37w.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Thaana", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "thaana" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notosansthaana/v14/C8c14dM-vnz-s-3jaEsxlxHkBH-WZOETXfoQrfQ9Y4XrbxLhnu4-tbNu.ttf", + "200": "http://fonts.gstatic.com/s/notosansthaana/v14/C8c14dM-vnz-s-3jaEsxlxHkBH-WZOETXfoQrfQ9Y4VrbhLhnu4-tbNu.ttf", + "300": "http://fonts.gstatic.com/s/notosansthaana/v14/C8c14dM-vnz-s-3jaEsxlxHkBH-WZOETXfoQrfQ9Y4W1bhLhnu4-tbNu.ttf", + "regular": "http://fonts.gstatic.com/s/notosansthaana/v14/C8c14dM-vnz-s-3jaEsxlxHkBH-WZOETXfoQrfQ9Y4XrbhLhnu4-tbNu.ttf", + "500": "http://fonts.gstatic.com/s/notosansthaana/v14/C8c14dM-vnz-s-3jaEsxlxHkBH-WZOETXfoQrfQ9Y4XZbhLhnu4-tbNu.ttf", + "600": "http://fonts.gstatic.com/s/notosansthaana/v14/C8c14dM-vnz-s-3jaEsxlxHkBH-WZOETXfoQrfQ9Y4U1aRLhnu4-tbNu.ttf", + "700": "http://fonts.gstatic.com/s/notosansthaana/v14/C8c14dM-vnz-s-3jaEsxlxHkBH-WZOETXfoQrfQ9Y4UMaRLhnu4-tbNu.ttf", + "800": "http://fonts.gstatic.com/s/notosansthaana/v14/C8c14dM-vnz-s-3jaEsxlxHkBH-WZOETXfoQrfQ9Y4VraRLhnu4-tbNu.ttf", + "900": "http://fonts.gstatic.com/s/notosansthaana/v14/C8c14dM-vnz-s-3jaEsxlxHkBH-WZOETXfoQrfQ9Y4VCaRLhnu4-tbNu.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Thai", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "thai" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notosansthai/v14/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdU5RspzF-QRvzzXg.ttf", + "200": "http://fonts.gstatic.com/s/notosansthai/v14/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdUxRtpzF-QRvzzXg.ttf", + "300": "http://fonts.gstatic.com/s/notosansthai/v14/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdU8ptpzF-QRvzzXg.ttf", + "regular": "http://fonts.gstatic.com/s/notosansthai/v14/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdU5RtpzF-QRvzzXg.ttf", + "500": "http://fonts.gstatic.com/s/notosansthai/v14/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdU6ZtpzF-QRvzzXg.ttf", + "600": "http://fonts.gstatic.com/s/notosansthai/v14/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdU0pqpzF-QRvzzXg.ttf", + "700": "http://fonts.gstatic.com/s/notosansthai/v14/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdU3NqpzF-QRvzzXg.ttf", + "800": "http://fonts.gstatic.com/s/notosansthai/v14/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdUxRqpzF-QRvzzXg.ttf", + "900": "http://fonts.gstatic.com/s/notosansthai/v14/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdUz1qpzF-QRvzzXg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Thai Looped", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "thai" + ], + "version": "v10", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/notosansthailooped/v10/B50fF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3YX6AYeCT_Wfd1.ttf", + "200": "http://fonts.gstatic.com/s/notosansthailooped/v10/B50cF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3Y84E4UgrzUO5sKA.ttf", + "300": "http://fonts.gstatic.com/s/notosansthailooped/v10/B50cF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3Yl4I4UgrzUO5sKA.ttf", + "regular": "http://fonts.gstatic.com/s/notosansthailooped/v10/B50RF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3gO6ocWiHvWQ.ttf", + "500": "http://fonts.gstatic.com/s/notosansthailooped/v10/B50cF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3Yz4M4UgrzUO5sKA.ttf", + "600": "http://fonts.gstatic.com/s/notosansthailooped/v10/B50cF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3Y44Q4UgrzUO5sKA.ttf", + "700": "http://fonts.gstatic.com/s/notosansthailooped/v10/B50cF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3Yh4U4UgrzUO5sKA.ttf", + "800": "http://fonts.gstatic.com/s/notosansthailooped/v10/B50cF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3Ym4Y4UgrzUO5sKA.ttf", + "900": "http://fonts.gstatic.com/s/notosansthailooped/v10/B50cF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3Yv4c4UgrzUO5sKA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Tifinagh", + "variants": [ + "regular" + ], + "subsets": [ + "tifinagh" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanstifinagh/v13/I_uzMoCduATTei9eI8dawkHIwvmhCvbn6rnEcXfs4Q.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Tirhuta", + "variants": [ + "regular" + ], + "subsets": [ + "tirhuta" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanstirhuta/v13/t5t6IQYRNJ6TWjahPR6X-M-apUyby7uGUBsTrn5P.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Ugaritic", + "variants": [ + "regular" + ], + "subsets": [ + "ugaritic" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansugaritic/v13/3qTwoiqhnSyU8TNFIdhZVCwbjCpkAXXkMhoIkiazfg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Vai", + "variants": [ + "regular" + ], + "subsets": [ + "vai" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansvai/v13/NaPecZTSBuhTirw6IaFn_UrURMTsDIRSfr0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Wancho", + "variants": [ + "regular" + ], + "subsets": [ + "wancho" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanswancho/v13/zrf-0GXXyfn6Fs0lH9P4cUubP0GBqAPopiRfKp8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Warang Citi", + "variants": [ + "regular" + ], + "subsets": [ + "warang-citi" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanswarangciti/v13/EYqtmb9SzL1YtsZSScyKDXIeOv3w-zgsNvKRpeVCCXzdgA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Yi", + "variants": [ + "regular" + ], + "subsets": [ + "yi" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosansyi/v13/sJoD3LFXjsSdcnzn071rO3apxVDJNVgSNg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Sans Zanabazar Square", + "variants": [ + "regular" + ], + "subsets": [ + "zanabazar-square" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notosanszanabazarsquare/v13/Cn-jJsuGWQxOjaGwMQ6fOicyxLBEMRfDtkzl4uagQtJxOCEgN0Gc.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v20", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/notoserif/v20/ga6Iaw1J5X9T9RW6j9bNTFAcaRi_bMQ.ttf", + "italic": "http://fonts.gstatic.com/s/notoserif/v20/ga6Kaw1J5X9T9RW6j9bNfFIWbTq6fMRRMw.ttf", + "700": "http://fonts.gstatic.com/s/notoserif/v20/ga6Law1J5X9T9RW6j9bNdOwzTRCUcM1IKoY.ttf", + "700italic": "http://fonts.gstatic.com/s/notoserif/v20/ga6Vaw1J5X9T9RW6j9bNfFIu0RWedO9NOoYIDg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Ahom", + "variants": [ + "regular" + ], + "subsets": [ + "ahom" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notoserifahom/v13/FeVIS0hfp6cprmEUffAW_fUL_AN-wuYrPFiwaw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Armenian", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "armenian" + ], + "version": "v12", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifarmenian/v12/3XFMEqMt3YoFsciDRZxptyCUKJmytZ0kVU-XvF7QaZuL85rnQ_zDNzDe5xNnKxyZi8ObxvXagGdkbg.ttf", + "200": "http://fonts.gstatic.com/s/notoserifarmenian/v12/3XFMEqMt3YoFsciDRZxptyCUKJmytZ0kVU-XvF7QaZuL85rnQ_zDNzDe5xNnKxyZC8KbxvXagGdkbg.ttf", + "300": "http://fonts.gstatic.com/s/notoserifarmenian/v12/3XFMEqMt3YoFsciDRZxptyCUKJmytZ0kVU-XvF7QaZuL85rnQ_zDNzDe5xNnKxyZ1cKbxvXagGdkbg.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifarmenian/v12/3XFMEqMt3YoFsciDRZxptyCUKJmytZ0kVU-XvF7QaZuL85rnQ_zDNzDe5xNnKxyZi8KbxvXagGdkbg.ttf", + "500": "http://fonts.gstatic.com/s/notoserifarmenian/v12/3XFMEqMt3YoFsciDRZxptyCUKJmytZ0kVU-XvF7QaZuL85rnQ_zDNzDe5xNnKxyZucKbxvXagGdkbg.ttf", + "600": "http://fonts.gstatic.com/s/notoserifarmenian/v12/3XFMEqMt3YoFsciDRZxptyCUKJmytZ0kVU-XvF7QaZuL85rnQ_zDNzDe5xNnKxyZVcWbxvXagGdkbg.ttf", + "700": "http://fonts.gstatic.com/s/notoserifarmenian/v12/3XFMEqMt3YoFsciDRZxptyCUKJmytZ0kVU-XvF7QaZuL85rnQ_zDNzDe5xNnKxyZbMWbxvXagGdkbg.ttf", + "800": "http://fonts.gstatic.com/s/notoserifarmenian/v12/3XFMEqMt3YoFsciDRZxptyCUKJmytZ0kVU-XvF7QaZuL85rnQ_zDNzDe5xNnKxyZC8WbxvXagGdkbg.ttf", + "900": "http://fonts.gstatic.com/s/notoserifarmenian/v12/3XFMEqMt3YoFsciDRZxptyCUKJmytZ0kVU-XvF7QaZuL85rnQ_zDNzDe5xNnKxyZIsWbxvXagGdkbg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Balinese", + "variants": [ + "regular" + ], + "subsets": [ + "balinese" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notoserifbalinese/v13/QdVKSS0-JginysQSRvuCmUMB_wVeQAxXRbgJdhapcUU.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Bengali", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "bengali" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifbengali/v13/hYkuPvggTvnzO14VSXltirUdnnkt1pwmWrprmO7RjE0a5BtdATYU1crFaM_5JfcAH3qn4LjQH8yD.ttf", + "200": "http://fonts.gstatic.com/s/notoserifbengali/v13/hYkuPvggTvnzO14VSXltirUdnnkt1pwmWrprmO7RjE0a5BtdATYU1crFaM_5JfeAHnqn4LjQH8yD.ttf", + "300": "http://fonts.gstatic.com/s/notoserifbengali/v13/hYkuPvggTvnzO14VSXltirUdnnkt1pwmWrprmO7RjE0a5BtdATYU1crFaM_5JfdeHnqn4LjQH8yD.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifbengali/v13/hYkuPvggTvnzO14VSXltirUdnnkt1pwmWrprmO7RjE0a5BtdATYU1crFaM_5JfcAHnqn4LjQH8yD.ttf", + "500": "http://fonts.gstatic.com/s/notoserifbengali/v13/hYkuPvggTvnzO14VSXltirUdnnkt1pwmWrprmO7RjE0a5BtdATYU1crFaM_5JfcyHnqn4LjQH8yD.ttf", + "600": "http://fonts.gstatic.com/s/notoserifbengali/v13/hYkuPvggTvnzO14VSXltirUdnnkt1pwmWrprmO7RjE0a5BtdATYU1crFaM_5JffeGXqn4LjQH8yD.ttf", + "700": "http://fonts.gstatic.com/s/notoserifbengali/v13/hYkuPvggTvnzO14VSXltirUdnnkt1pwmWrprmO7RjE0a5BtdATYU1crFaM_5JffnGXqn4LjQH8yD.ttf", + "800": "http://fonts.gstatic.com/s/notoserifbengali/v13/hYkuPvggTvnzO14VSXltirUdnnkt1pwmWrprmO7RjE0a5BtdATYU1crFaM_5JfeAGXqn4LjQH8yD.ttf", + "900": "http://fonts.gstatic.com/s/notoserifbengali/v13/hYkuPvggTvnzO14VSXltirUdnnkt1pwmWrprmO7RjE0a5BtdATYU1crFaM_5JfepGXqn4LjQH8yD.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Devanagari", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "devanagari" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifdevanagari/v13/x3dYcl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4Hh2dGyFzPLcQPVbnRNeFsw0xRWb6uxTA-og-HMUe1u_dv.ttf", + "200": "http://fonts.gstatic.com/s/notoserifdevanagari/v13/x3dYcl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4Hh2dGyFzPLcQPVbnRNeFsw0xRWb6uxTC-ow-HMUe1u_dv.ttf", + "300": "http://fonts.gstatic.com/s/notoserifdevanagari/v13/x3dYcl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4Hh2dGyFzPLcQPVbnRNeFsw0xRWb6uxTBgow-HMUe1u_dv.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifdevanagari/v13/x3dYcl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4Hh2dGyFzPLcQPVbnRNeFsw0xRWb6uxTA-ow-HMUe1u_dv.ttf", + "500": "http://fonts.gstatic.com/s/notoserifdevanagari/v13/x3dYcl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4Hh2dGyFzPLcQPVbnRNeFsw0xRWb6uxTAMow-HMUe1u_dv.ttf", + "600": "http://fonts.gstatic.com/s/notoserifdevanagari/v13/x3dYcl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4Hh2dGyFzPLcQPVbnRNeFsw0xRWb6uxTDgpA-HMUe1u_dv.ttf", + "700": "http://fonts.gstatic.com/s/notoserifdevanagari/v13/x3dYcl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4Hh2dGyFzPLcQPVbnRNeFsw0xRWb6uxTDZpA-HMUe1u_dv.ttf", + "800": "http://fonts.gstatic.com/s/notoserifdevanagari/v13/x3dYcl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4Hh2dGyFzPLcQPVbnRNeFsw0xRWb6uxTC-pA-HMUe1u_dv.ttf", + "900": "http://fonts.gstatic.com/s/notoserifdevanagari/v13/x3dYcl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4Hh2dGyFzPLcQPVbnRNeFsw0xRWb6uxTCXpA-HMUe1u_dv.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Display", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v12", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buERppa9f8_vkXaZLAgP0G5Wi6QmA1QaeYah2sovLCDq_ZgLyt3idQfktOG-PVpd49gKaDU9hvzC.ttf", + "200": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buERppa9f8_vkXaZLAgP0G5Wi6QmA1QaeYah2sovLCDq_ZgLyt3idQfktOG-PVrd4tgKaDU9hvzC.ttf", + "300": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buERppa9f8_vkXaZLAgP0G5Wi6QmA1QaeYah2sovLCDq_ZgLyt3idQfktOG-PVoD4tgKaDU9hvzC.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buERppa9f8_vkXaZLAgP0G5Wi6QmA1QaeYah2sovLCDq_ZgLyt3idQfktOG-PVpd4tgKaDU9hvzC.ttf", + "500": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buERppa9f8_vkXaZLAgP0G5Wi6QmA1QaeYah2sovLCDq_ZgLyt3idQfktOG-PVpv4tgKaDU9hvzC.ttf", + "600": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buERppa9f8_vkXaZLAgP0G5Wi6QmA1QaeYah2sovLCDq_ZgLyt3idQfktOG-PVqD5dgKaDU9hvzC.ttf", + "700": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buERppa9f8_vkXaZLAgP0G5Wi6QmA1QaeYah2sovLCDq_ZgLyt3idQfktOG-PVq65dgKaDU9hvzC.ttf", + "800": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buERppa9f8_vkXaZLAgP0G5Wi6QmA1QaeYah2sovLCDq_ZgLyt3idQfktOG-PVrd5dgKaDU9hvzC.ttf", + "900": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buERppa9f8_vkXaZLAgP0G5Wi6QmA1QaeYah2sovLCDq_ZgLyt3idQfktOG-PVr05dgKaDU9hvzC.ttf", + "100italic": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buEPppa9f8_vkXaZLAgP0G5Wi6QmA1QwcLRCOrN8uo7t6FBJOJTQit-N33sQOk-VoTBIYjEfg-zCmf4.ttf", + "200italic": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buEPppa9f8_vkXaZLAgP0G5Wi6QmA1QwcLRCOrN8uo7t6FBJOJTQit-N33sQOk-VobBJYjEfg-zCmf4.ttf", + "300italic": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buEPppa9f8_vkXaZLAgP0G5Wi6QmA1QwcLRCOrN8uo7t6FBJOJTQit-N33sQOk-VoW5JYjEfg-zCmf4.ttf", + "italic": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buEPppa9f8_vkXaZLAgP0G5Wi6QmA1QwcLRCOrN8uo7t6FBJOJTQit-N33sQOk-VoTBJYjEfg-zCmf4.ttf", + "500italic": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buEPppa9f8_vkXaZLAgP0G5Wi6QmA1QwcLRCOrN8uo7t6FBJOJTQit-N33sQOk-VoQJJYjEfg-zCmf4.ttf", + "600italic": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buEPppa9f8_vkXaZLAgP0G5Wi6QmA1QwcLRCOrN8uo7t6FBJOJTQit-N33sQOk-Voe5OYjEfg-zCmf4.ttf", + "700italic": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buEPppa9f8_vkXaZLAgP0G5Wi6QmA1QwcLRCOrN8uo7t6FBJOJTQit-N33sQOk-VoddOYjEfg-zCmf4.ttf", + "800italic": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buEPppa9f8_vkXaZLAgP0G5Wi6QmA1QwcLRCOrN8uo7t6FBJOJTQit-N33sQOk-VobBOYjEfg-zCmf4.ttf", + "900italic": "http://fonts.gstatic.com/s/notoserifdisplay/v12/buEPppa9f8_vkXaZLAgP0G5Wi6QmA1QwcLRCOrN8uo7t6FBJOJTQit-N33sQOk-VoZlOYjEfg-zCmf4.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Dogra", + "variants": [ + "regular" + ], + "subsets": [ + "dogra" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notoserifdogra/v13/MQpP-XquKMC7ROPP3QOOlm7xPu3fGy63IbPzkns.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Ethiopic", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "ethiopic" + ], + "version": "v12", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifethiopic/v12/V8mjoR7-XjwJ8_Au3Ti5tXj5Rd83frpWLK4d-taxqWw2HMWjDxBAg5S_0QsrggxCzSQjkaO9UVLyiw.ttf", + "200": "http://fonts.gstatic.com/s/notoserifethiopic/v12/V8mjoR7-XjwJ8_Au3Ti5tXj5Rd83frpWLK4d-taxqWw2HMWjDxBAg5S_0QsrggxCTSUjkaO9UVLyiw.ttf", + "300": "http://fonts.gstatic.com/s/notoserifethiopic/v12/V8mjoR7-XjwJ8_Au3Ti5tXj5Rd83frpWLK4d-taxqWw2HMWjDxBAg5S_0QsrggxCkyUjkaO9UVLyiw.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifethiopic/v12/V8mjoR7-XjwJ8_Au3Ti5tXj5Rd83frpWLK4d-taxqWw2HMWjDxBAg5S_0QsrggxCzSUjkaO9UVLyiw.ttf", + "500": "http://fonts.gstatic.com/s/notoserifethiopic/v12/V8mjoR7-XjwJ8_Au3Ti5tXj5Rd83frpWLK4d-taxqWw2HMWjDxBAg5S_0QsrggxC_yUjkaO9UVLyiw.ttf", + "600": "http://fonts.gstatic.com/s/notoserifethiopic/v12/V8mjoR7-XjwJ8_Au3Ti5tXj5Rd83frpWLK4d-taxqWw2HMWjDxBAg5S_0QsrggxCEyIjkaO9UVLyiw.ttf", + "700": "http://fonts.gstatic.com/s/notoserifethiopic/v12/V8mjoR7-XjwJ8_Au3Ti5tXj5Rd83frpWLK4d-taxqWw2HMWjDxBAg5S_0QsrggxCKiIjkaO9UVLyiw.ttf", + "800": "http://fonts.gstatic.com/s/notoserifethiopic/v12/V8mjoR7-XjwJ8_Au3Ti5tXj5Rd83frpWLK4d-taxqWw2HMWjDxBAg5S_0QsrggxCTSIjkaO9UVLyiw.ttf", + "900": "http://fonts.gstatic.com/s/notoserifethiopic/v12/V8mjoR7-XjwJ8_Au3Ti5tXj5Rd83frpWLK4d-taxqWw2HMWjDxBAg5S_0QsrggxCZCIjkaO9UVLyiw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Georgian", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "georgian" + ], + "version": "v12", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifgeorgian/v12/VEMXRpd8s4nv8hG_qOzL7HOAw4nt0Sl_XxyaEduNMvi7T6Y4etRnmGhyLop-R3aSTvsfdzTw-FgZxQ.ttf", + "200": "http://fonts.gstatic.com/s/notoserifgeorgian/v12/VEMXRpd8s4nv8hG_qOzL7HOAw4nt0Sl_XxyaEduNMvi7T6Y4etRnmGhyLop-R3aSzvofdzTw-FgZxQ.ttf", + "300": "http://fonts.gstatic.com/s/notoserifgeorgian/v12/VEMXRpd8s4nv8hG_qOzL7HOAw4nt0Sl_XxyaEduNMvi7T6Y4etRnmGhyLop-R3aSEPofdzTw-FgZxQ.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifgeorgian/v12/VEMXRpd8s4nv8hG_qOzL7HOAw4nt0Sl_XxyaEduNMvi7T6Y4etRnmGhyLop-R3aSTvofdzTw-FgZxQ.ttf", + "500": "http://fonts.gstatic.com/s/notoserifgeorgian/v12/VEMXRpd8s4nv8hG_qOzL7HOAw4nt0Sl_XxyaEduNMvi7T6Y4etRnmGhyLop-R3aSfPofdzTw-FgZxQ.ttf", + "600": "http://fonts.gstatic.com/s/notoserifgeorgian/v12/VEMXRpd8s4nv8hG_qOzL7HOAw4nt0Sl_XxyaEduNMvi7T6Y4etRnmGhyLop-R3aSkP0fdzTw-FgZxQ.ttf", + "700": "http://fonts.gstatic.com/s/notoserifgeorgian/v12/VEMXRpd8s4nv8hG_qOzL7HOAw4nt0Sl_XxyaEduNMvi7T6Y4etRnmGhyLop-R3aSqf0fdzTw-FgZxQ.ttf", + "800": "http://fonts.gstatic.com/s/notoserifgeorgian/v12/VEMXRpd8s4nv8hG_qOzL7HOAw4nt0Sl_XxyaEduNMvi7T6Y4etRnmGhyLop-R3aSzv0fdzTw-FgZxQ.ttf", + "900": "http://fonts.gstatic.com/s/notoserifgeorgian/v12/VEMXRpd8s4nv8hG_qOzL7HOAw4nt0Sl_XxyaEduNMvi7T6Y4etRnmGhyLop-R3aS5_0fdzTw-FgZxQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Grantha", + "variants": [ + "regular" + ], + "subsets": [ + "grantha" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notoserifgrantha/v13/qkBIXuEH5NzDDvc3fLDYxPk9-Wq3WLiqFENLR7fHGw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Gujarati", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "gujarati" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifgujarati/v15/hESa6WBlOixO-3OJ1FTmTsmqlBRUJBVkcgNLpdsspzP2HuYycYzuM1Kf-OJu.ttf", + "200": "http://fonts.gstatic.com/s/notoserifgujarati/v15/hESa6WBlOixO-3OJ1FTmTsmqlBRUJBVkcgNLpdsspzP2HuaycIzuM1Kf-OJu.ttf", + "300": "http://fonts.gstatic.com/s/notoserifgujarati/v15/hESa6WBlOixO-3OJ1FTmTsmqlBRUJBVkcgNLpdsspzP2HuZscIzuM1Kf-OJu.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifgujarati/v15/hESa6WBlOixO-3OJ1FTmTsmqlBRUJBVkcgNLpdsspzP2HuYycIzuM1Kf-OJu.ttf", + "500": "http://fonts.gstatic.com/s/notoserifgujarati/v15/hESa6WBlOixO-3OJ1FTmTsmqlBRUJBVkcgNLpdsspzP2HuYAcIzuM1Kf-OJu.ttf", + "600": "http://fonts.gstatic.com/s/notoserifgujarati/v15/hESa6WBlOixO-3OJ1FTmTsmqlBRUJBVkcgNLpdsspzP2Hubsd4zuM1Kf-OJu.ttf", + "700": "http://fonts.gstatic.com/s/notoserifgujarati/v15/hESa6WBlOixO-3OJ1FTmTsmqlBRUJBVkcgNLpdsspzP2HubVd4zuM1Kf-OJu.ttf", + "800": "http://fonts.gstatic.com/s/notoserifgujarati/v15/hESa6WBlOixO-3OJ1FTmTsmqlBRUJBVkcgNLpdsspzP2Huayd4zuM1Kf-OJu.ttf", + "900": "http://fonts.gstatic.com/s/notoserifgujarati/v15/hESa6WBlOixO-3OJ1FTmTsmqlBRUJBVkcgNLpdsspzP2Huabd4zuM1Kf-OJu.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Gurmukhi", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "gurmukhi" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifgurmukhi/v11/92z-tA9LNqsg7tCYlXdCV1VPnAEeDU0vLoYMbylXk0xTCr6-eBTNmqVU7y6l.ttf", + "200": "http://fonts.gstatic.com/s/notoserifgurmukhi/v11/92z-tA9LNqsg7tCYlXdCV1VPnAEeDU0vLoYMbylXk0xTCr4-eRTNmqVU7y6l.ttf", + "300": "http://fonts.gstatic.com/s/notoserifgurmukhi/v11/92z-tA9LNqsg7tCYlXdCV1VPnAEeDU0vLoYMbylXk0xTCr7geRTNmqVU7y6l.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifgurmukhi/v11/92z-tA9LNqsg7tCYlXdCV1VPnAEeDU0vLoYMbylXk0xTCr6-eRTNmqVU7y6l.ttf", + "500": "http://fonts.gstatic.com/s/notoserifgurmukhi/v11/92z-tA9LNqsg7tCYlXdCV1VPnAEeDU0vLoYMbylXk0xTCr6MeRTNmqVU7y6l.ttf", + "600": "http://fonts.gstatic.com/s/notoserifgurmukhi/v11/92z-tA9LNqsg7tCYlXdCV1VPnAEeDU0vLoYMbylXk0xTCr5gfhTNmqVU7y6l.ttf", + "700": "http://fonts.gstatic.com/s/notoserifgurmukhi/v11/92z-tA9LNqsg7tCYlXdCV1VPnAEeDU0vLoYMbylXk0xTCr5ZfhTNmqVU7y6l.ttf", + "800": "http://fonts.gstatic.com/s/notoserifgurmukhi/v11/92z-tA9LNqsg7tCYlXdCV1VPnAEeDU0vLoYMbylXk0xTCr4-fhTNmqVU7y6l.ttf", + "900": "http://fonts.gstatic.com/s/notoserifgurmukhi/v11/92z-tA9LNqsg7tCYlXdCV1VPnAEeDU0vLoYMbylXk0xTCr4XfhTNmqVU7y6l.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Hebrew", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "hebrew" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifhebrew/v13/k3k0o9MMPvpLmixYH7euCwmkS9DohjX1-kRyiqyBqIxnoLbp93i9IKrXKF_qVAwTAG8_vlQxz24.ttf", + "200": "http://fonts.gstatic.com/s/notoserifhebrew/v13/k3k0o9MMPvpLmixYH7euCwmkS9DohjX1-kRyiqyBqIxnoLbp93i9IKrXKF_qVIwSAG8_vlQxz24.ttf", + "300": "http://fonts.gstatic.com/s/notoserifhebrew/v13/k3k0o9MMPvpLmixYH7euCwmkS9DohjX1-kRyiqyBqIxnoLbp93i9IKrXKF_qVFISAG8_vlQxz24.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifhebrew/v13/k3k0o9MMPvpLmixYH7euCwmkS9DohjX1-kRyiqyBqIxnoLbp93i9IKrXKF_qVAwSAG8_vlQxz24.ttf", + "500": "http://fonts.gstatic.com/s/notoserifhebrew/v13/k3k0o9MMPvpLmixYH7euCwmkS9DohjX1-kRyiqyBqIxnoLbp93i9IKrXKF_qVD4SAG8_vlQxz24.ttf", + "600": "http://fonts.gstatic.com/s/notoserifhebrew/v13/k3k0o9MMPvpLmixYH7euCwmkS9DohjX1-kRyiqyBqIxnoLbp93i9IKrXKF_qVNIVAG8_vlQxz24.ttf", + "700": "http://fonts.gstatic.com/s/notoserifhebrew/v13/k3k0o9MMPvpLmixYH7euCwmkS9DohjX1-kRyiqyBqIxnoLbp93i9IKrXKF_qVOsVAG8_vlQxz24.ttf", + "800": "http://fonts.gstatic.com/s/notoserifhebrew/v13/k3k0o9MMPvpLmixYH7euCwmkS9DohjX1-kRyiqyBqIxnoLbp93i9IKrXKF_qVIwVAG8_vlQxz24.ttf", + "900": "http://fonts.gstatic.com/s/notoserifhebrew/v13/k3k0o9MMPvpLmixYH7euCwmkS9DohjX1-kRyiqyBqIxnoLbp93i9IKrXKF_qVKUVAG8_vlQxz24.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif JP", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "900" + ], + "subsets": [ + "japanese", + "latin" + ], + "version": "v19", + "lastModified": "2022-01-27", + "files": { + "200": "http://fonts.gstatic.com/s/notoserifjp/v19/xn77YHs72GKoTvER4Gn3b5eMZBaPRkgfU8fEwb0.otf", + "300": "http://fonts.gstatic.com/s/notoserifjp/v19/xn77YHs72GKoTvER4Gn3b5eMZHKMRkgfU8fEwb0.otf", + "regular": "http://fonts.gstatic.com/s/notoserifjp/v19/xn7mYHs72GKoTvER4Gn3b5eMXNikYkY0T84.otf", + "500": "http://fonts.gstatic.com/s/notoserifjp/v19/xn77YHs72GKoTvER4Gn3b5eMZCqNRkgfU8fEwb0.otf", + "600": "http://fonts.gstatic.com/s/notoserifjp/v19/xn77YHs72GKoTvER4Gn3b5eMZAaKRkgfU8fEwb0.otf", + "700": "http://fonts.gstatic.com/s/notoserifjp/v19/xn77YHs72GKoTvER4Gn3b5eMZGKLRkgfU8fEwb0.otf", + "900": "http://fonts.gstatic.com/s/notoserifjp/v19/xn77YHs72GKoTvER4Gn3b5eMZFqJRkgfU8fEwb0.otf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif KR", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "900" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/notoserifkr/v17/3JnmSDn90Gmq2mr3blnHaTZXTihC8O1ZNH1ahck.otf", + "300": "http://fonts.gstatic.com/s/notoserifkr/v17/3JnmSDn90Gmq2mr3blnHaTZXTkxB8O1ZNH1ahck.otf", + "regular": "http://fonts.gstatic.com/s/notoserifkr/v17/3Jn7SDn90Gmq2mr3blnHaTZXduZp1ONyKHQ.otf", + "500": "http://fonts.gstatic.com/s/notoserifkr/v17/3JnmSDn90Gmq2mr3blnHaTZXThRA8O1ZNH1ahck.otf", + "600": "http://fonts.gstatic.com/s/notoserifkr/v17/3JnmSDn90Gmq2mr3blnHaTZXTjhH8O1ZNH1ahck.otf", + "700": "http://fonts.gstatic.com/s/notoserifkr/v17/3JnmSDn90Gmq2mr3blnHaTZXTlxG8O1ZNH1ahck.otf", + "900": "http://fonts.gstatic.com/s/notoserifkr/v17/3JnmSDn90Gmq2mr3blnHaTZXTmRE8O1ZNH1ahck.otf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Kannada", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "kannada" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifkannada/v15/v6-8GZHLJFKIhClqUYqXDiWqpxQxWSPoW6bz-l4hGHiNgcYCceRJ71svgcI.ttf", + "200": "http://fonts.gstatic.com/s/notoserifkannada/v15/v6-8GZHLJFKIhClqUYqXDiWqpxQxWSPoW6bz-l4hGHiNgUYDceRJ71svgcI.ttf", + "300": "http://fonts.gstatic.com/s/notoserifkannada/v15/v6-8GZHLJFKIhClqUYqXDiWqpxQxWSPoW6bz-l4hGHiNgZgDceRJ71svgcI.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifkannada/v15/v6-8GZHLJFKIhClqUYqXDiWqpxQxWSPoW6bz-l4hGHiNgcYDceRJ71svgcI.ttf", + "500": "http://fonts.gstatic.com/s/notoserifkannada/v15/v6-8GZHLJFKIhClqUYqXDiWqpxQxWSPoW6bz-l4hGHiNgfQDceRJ71svgcI.ttf", + "600": "http://fonts.gstatic.com/s/notoserifkannada/v15/v6-8GZHLJFKIhClqUYqXDiWqpxQxWSPoW6bz-l4hGHiNgRgEceRJ71svgcI.ttf", + "700": "http://fonts.gstatic.com/s/notoserifkannada/v15/v6-8GZHLJFKIhClqUYqXDiWqpxQxWSPoW6bz-l4hGHiNgSEEceRJ71svgcI.ttf", + "800": "http://fonts.gstatic.com/s/notoserifkannada/v15/v6-8GZHLJFKIhClqUYqXDiWqpxQxWSPoW6bz-l4hGHiNgUYEceRJ71svgcI.ttf", + "900": "http://fonts.gstatic.com/s/notoserifkannada/v15/v6-8GZHLJFKIhClqUYqXDiWqpxQxWSPoW6bz-l4hGHiNgW8EceRJ71svgcI.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Khmer", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "khmer" + ], + "version": "v12", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifkhmer/v12/-F6UfidqLzI2JPCkXAO2hmogq0146FxtbwKEr951z5s6lI40sDRH_AVhUKdN6B4wXEZK9Xo4xg.ttf", + "200": "http://fonts.gstatic.com/s/notoserifkhmer/v12/-F6UfidqLzI2JPCkXAO2hmogq0146FxtbwKEr951z5s6lI40sDRH_AVhUKdNaB8wXEZK9Xo4xg.ttf", + "300": "http://fonts.gstatic.com/s/notoserifkhmer/v12/-F6UfidqLzI2JPCkXAO2hmogq0146FxtbwKEr951z5s6lI40sDRH_AVhUKdNth8wXEZK9Xo4xg.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifkhmer/v12/-F6UfidqLzI2JPCkXAO2hmogq0146FxtbwKEr951z5s6lI40sDRH_AVhUKdN6B8wXEZK9Xo4xg.ttf", + "500": "http://fonts.gstatic.com/s/notoserifkhmer/v12/-F6UfidqLzI2JPCkXAO2hmogq0146FxtbwKEr951z5s6lI40sDRH_AVhUKdN2h8wXEZK9Xo4xg.ttf", + "600": "http://fonts.gstatic.com/s/notoserifkhmer/v12/-F6UfidqLzI2JPCkXAO2hmogq0146FxtbwKEr951z5s6lI40sDRH_AVhUKdNNhgwXEZK9Xo4xg.ttf", + "700": "http://fonts.gstatic.com/s/notoserifkhmer/v12/-F6UfidqLzI2JPCkXAO2hmogq0146FxtbwKEr951z5s6lI40sDRH_AVhUKdNDxgwXEZK9Xo4xg.ttf", + "800": "http://fonts.gstatic.com/s/notoserifkhmer/v12/-F6UfidqLzI2JPCkXAO2hmogq0146FxtbwKEr951z5s6lI40sDRH_AVhUKdNaBgwXEZK9Xo4xg.ttf", + "900": "http://fonts.gstatic.com/s/notoserifkhmer/v12/-F6UfidqLzI2JPCkXAO2hmogq0146FxtbwKEr951z5s6lI40sDRH_AVhUKdNQRgwXEZK9Xo4xg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Lao", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "lao" + ], + "version": "v12", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/notoseriflao/v12/3y9C6bYwcCjmsU8JEzCMxEwQfEBLk3f0rlSqCdaM_LlSNZ59oNw0BWH8VeMLrvOjlmyhHHQ.ttf", + "200": "http://fonts.gstatic.com/s/notoseriflao/v12/3y9C6bYwcCjmsU8JEzCMxEwQfEBLk3f0rlSqCdaM_LlSNZ59oNw0BWH8VWMKrvOjlmyhHHQ.ttf", + "300": "http://fonts.gstatic.com/s/notoseriflao/v12/3y9C6bYwcCjmsU8JEzCMxEwQfEBLk3f0rlSqCdaM_LlSNZ59oNw0BWH8Vb0KrvOjlmyhHHQ.ttf", + "regular": "http://fonts.gstatic.com/s/notoseriflao/v12/3y9C6bYwcCjmsU8JEzCMxEwQfEBLk3f0rlSqCdaM_LlSNZ59oNw0BWH8VeMKrvOjlmyhHHQ.ttf", + "500": "http://fonts.gstatic.com/s/notoseriflao/v12/3y9C6bYwcCjmsU8JEzCMxEwQfEBLk3f0rlSqCdaM_LlSNZ59oNw0BWH8VdEKrvOjlmyhHHQ.ttf", + "600": "http://fonts.gstatic.com/s/notoseriflao/v12/3y9C6bYwcCjmsU8JEzCMxEwQfEBLk3f0rlSqCdaM_LlSNZ59oNw0BWH8VT0NrvOjlmyhHHQ.ttf", + "700": "http://fonts.gstatic.com/s/notoseriflao/v12/3y9C6bYwcCjmsU8JEzCMxEwQfEBLk3f0rlSqCdaM_LlSNZ59oNw0BWH8VQQNrvOjlmyhHHQ.ttf", + "800": "http://fonts.gstatic.com/s/notoseriflao/v12/3y9C6bYwcCjmsU8JEzCMxEwQfEBLk3f0rlSqCdaM_LlSNZ59oNw0BWH8VWMNrvOjlmyhHHQ.ttf", + "900": "http://fonts.gstatic.com/s/notoseriflao/v12/3y9C6bYwcCjmsU8JEzCMxEwQfEBLk3f0rlSqCdaM_LlSNZ59oNw0BWH8VUoNrvOjlmyhHHQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Malayalam", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "malayalam" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifmalayalam/v14/JIAZUU5sdmdP_HMcVcZFcH7DeVBeGVgSMEk2cmVDq1ihUXL1t-1fnVwHpQVySg.ttf", + "200": "http://fonts.gstatic.com/s/notoserifmalayalam/v14/JIAZUU5sdmdP_HMcVcZFcH7DeVBeGVgSMEk2cmVDq1ihUXL1N-xfnVwHpQVySg.ttf", + "300": "http://fonts.gstatic.com/s/notoserifmalayalam/v14/JIAZUU5sdmdP_HMcVcZFcH7DeVBeGVgSMEk2cmVDq1ihUXL16exfnVwHpQVySg.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifmalayalam/v14/JIAZUU5sdmdP_HMcVcZFcH7DeVBeGVgSMEk2cmVDq1ihUXL1t-xfnVwHpQVySg.ttf", + "500": "http://fonts.gstatic.com/s/notoserifmalayalam/v14/JIAZUU5sdmdP_HMcVcZFcH7DeVBeGVgSMEk2cmVDq1ihUXL1hexfnVwHpQVySg.ttf", + "600": "http://fonts.gstatic.com/s/notoserifmalayalam/v14/JIAZUU5sdmdP_HMcVcZFcH7DeVBeGVgSMEk2cmVDq1ihUXL1aetfnVwHpQVySg.ttf", + "700": "http://fonts.gstatic.com/s/notoserifmalayalam/v14/JIAZUU5sdmdP_HMcVcZFcH7DeVBeGVgSMEk2cmVDq1ihUXL1UOtfnVwHpQVySg.ttf", + "800": "http://fonts.gstatic.com/s/notoserifmalayalam/v14/JIAZUU5sdmdP_HMcVcZFcH7DeVBeGVgSMEk2cmVDq1ihUXL1N-tfnVwHpQVySg.ttf", + "900": "http://fonts.gstatic.com/s/notoserifmalayalam/v14/JIAZUU5sdmdP_HMcVcZFcH7DeVBeGVgSMEk2cmVDq1ihUXL1HutfnVwHpQVySg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Myanmar", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "myanmar" + ], + "version": "v10", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifmyanmar/v10/VuJudM7F2Yv76aBKKs-bHMQfAHUw3jnNwBDsU9X6RPzQ.ttf", + "200": "http://fonts.gstatic.com/s/notoserifmyanmar/v10/VuJvdM7F2Yv76aBKKs-bHMQfAHUw3jnNbDHMefv2TeXJng.ttf", + "300": "http://fonts.gstatic.com/s/notoserifmyanmar/v10/VuJvdM7F2Yv76aBKKs-bHMQfAHUw3jnNCDLMefv2TeXJng.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifmyanmar/v10/VuJsdM7F2Yv76aBKKs-bHMQfAHUw3jn1pBrocdDqRA.ttf", + "500": "http://fonts.gstatic.com/s/notoserifmyanmar/v10/VuJvdM7F2Yv76aBKKs-bHMQfAHUw3jnNUDPMefv2TeXJng.ttf", + "600": "http://fonts.gstatic.com/s/notoserifmyanmar/v10/VuJvdM7F2Yv76aBKKs-bHMQfAHUw3jnNfDTMefv2TeXJng.ttf", + "700": "http://fonts.gstatic.com/s/notoserifmyanmar/v10/VuJvdM7F2Yv76aBKKs-bHMQfAHUw3jnNGDXMefv2TeXJng.ttf", + "800": "http://fonts.gstatic.com/s/notoserifmyanmar/v10/VuJvdM7F2Yv76aBKKs-bHMQfAHUw3jnNBDbMefv2TeXJng.ttf", + "900": "http://fonts.gstatic.com/s/notoserifmyanmar/v10/VuJvdM7F2Yv76aBKKs-bHMQfAHUw3jnNIDfMefv2TeXJng.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Nyiakeng Puachue Hmong", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "nyiakeng-puachue-hmong" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notoserifnyiakengpuachuehmong/v14/5h1jibMoOmIC3YuzLC-NZyLDZC8iwh-MTC8ggAjEhePFNRVcneAFp44kcYMUkNqVKhqPDFvbZkrZmb0.ttf", + "500": "http://fonts.gstatic.com/s/notoserifnyiakengpuachuehmong/v14/5h1jibMoOmIC3YuzLC-NZyLDZC8iwh-MTC8ggAjEhePFNRVcneAFp44kcYMUkNqVKiiPDFvbZkrZmb0.ttf", + "600": "http://fonts.gstatic.com/s/notoserifnyiakengpuachuehmong/v14/5h1jibMoOmIC3YuzLC-NZyLDZC8iwh-MTC8ggAjEhePFNRVcneAFp44kcYMUkNqVKsSIDFvbZkrZmb0.ttf", + "700": "http://fonts.gstatic.com/s/notoserifnyiakengpuachuehmong/v14/5h1jibMoOmIC3YuzLC-NZyLDZC8iwh-MTC8ggAjEhePFNRVcneAFp44kcYMUkNqVKv2IDFvbZkrZmb0.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif SC", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "900" + ], + "subsets": [ + "chinese-simplified", + "latin" + ], + "version": "v19", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/notoserifsc/v19/H4c8BXePl9DZ0Xe7gG9cyOj7mm63SzZBEtERe7U.otf", + "300": "http://fonts.gstatic.com/s/notoserifsc/v19/H4c8BXePl9DZ0Xe7gG9cyOj7mgq0SzZBEtERe7U.otf", + "regular": "http://fonts.gstatic.com/s/notoserifsc/v19/H4chBXePl9DZ0Xe7gG9cyOj7oqCcbzhqDtg.otf", + "500": "http://fonts.gstatic.com/s/notoserifsc/v19/H4c8BXePl9DZ0Xe7gG9cyOj7mlK1SzZBEtERe7U.otf", + "600": "http://fonts.gstatic.com/s/notoserifsc/v19/H4c8BXePl9DZ0Xe7gG9cyOj7mn6ySzZBEtERe7U.otf", + "700": "http://fonts.gstatic.com/s/notoserifsc/v19/H4c8BXePl9DZ0Xe7gG9cyOj7mhqzSzZBEtERe7U.otf", + "900": "http://fonts.gstatic.com/s/notoserifsc/v19/H4c8BXePl9DZ0Xe7gG9cyOj7miKxSzZBEtERe7U.otf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Sinhala", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "sinhala" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifsinhala/v13/DtVEJwinQqclnZE2CnsPug9lgGC3y2F2nehQ7Eg4EdBKWxPiDxMivFLgRXs_-pGxRlMsxaLRn3W-.ttf", + "200": "http://fonts.gstatic.com/s/notoserifsinhala/v13/DtVEJwinQqclnZE2CnsPug9lgGC3y2F2nehQ7Eg4EdBKWxPiDxMivFLgRXs_-pExR1MsxaLRn3W-.ttf", + "300": "http://fonts.gstatic.com/s/notoserifsinhala/v13/DtVEJwinQqclnZE2CnsPug9lgGC3y2F2nehQ7Eg4EdBKWxPiDxMivFLgRXs_-pHvR1MsxaLRn3W-.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifsinhala/v13/DtVEJwinQqclnZE2CnsPug9lgGC3y2F2nehQ7Eg4EdBKWxPiDxMivFLgRXs_-pGxR1MsxaLRn3W-.ttf", + "500": "http://fonts.gstatic.com/s/notoserifsinhala/v13/DtVEJwinQqclnZE2CnsPug9lgGC3y2F2nehQ7Eg4EdBKWxPiDxMivFLgRXs_-pGDR1MsxaLRn3W-.ttf", + "600": "http://fonts.gstatic.com/s/notoserifsinhala/v13/DtVEJwinQqclnZE2CnsPug9lgGC3y2F2nehQ7Eg4EdBKWxPiDxMivFLgRXs_-pFvQFMsxaLRn3W-.ttf", + "700": "http://fonts.gstatic.com/s/notoserifsinhala/v13/DtVEJwinQqclnZE2CnsPug9lgGC3y2F2nehQ7Eg4EdBKWxPiDxMivFLgRXs_-pFWQFMsxaLRn3W-.ttf", + "800": "http://fonts.gstatic.com/s/notoserifsinhala/v13/DtVEJwinQqclnZE2CnsPug9lgGC3y2F2nehQ7Eg4EdBKWxPiDxMivFLgRXs_-pExQFMsxaLRn3W-.ttf", + "900": "http://fonts.gstatic.com/s/notoserifsinhala/v13/DtVEJwinQqclnZE2CnsPug9lgGC3y2F2nehQ7Eg4EdBKWxPiDxMivFLgRXs_-pEYQFMsxaLRn3W-.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif TC", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "900" + ], + "subsets": [ + "chinese-traditional", + "latin" + ], + "version": "v20", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/notoseriftc/v20/XLY9IZb5bJNDGYxLBibeHZ0Bvr8vbX9GTsoOAX4.otf", + "300": "http://fonts.gstatic.com/s/notoseriftc/v20/XLY9IZb5bJNDGYxLBibeHZ0BvtssbX9GTsoOAX4.otf", + "regular": "http://fonts.gstatic.com/s/notoseriftc/v20/XLYgIZb5bJNDGYxLBibeHZ0BhnEESXFtUsM.otf", + "500": "http://fonts.gstatic.com/s/notoseriftc/v20/XLY9IZb5bJNDGYxLBibeHZ0BvoMtbX9GTsoOAX4.otf", + "600": "http://fonts.gstatic.com/s/notoseriftc/v20/XLY9IZb5bJNDGYxLBibeHZ0Bvq8qbX9GTsoOAX4.otf", + "700": "http://fonts.gstatic.com/s/notoseriftc/v20/XLY9IZb5bJNDGYxLBibeHZ0BvssrbX9GTsoOAX4.otf", + "900": "http://fonts.gstatic.com/s/notoseriftc/v20/XLY9IZb5bJNDGYxLBibeHZ0BvvMpbX9GTsoOAX4.otf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Tamil", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "tamil" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjndHr-klIgTfc40komjQ5OObazYp-6H94dBF-RX6nNRJfi-Gf55IgAecattN6R8Pz3v8Etew.ttf", + "200": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjndHr-klIgTfc40komjQ5OObazYp-6H94dBF-RX6nNRJfi-Gf55IgAecatNN-R8Pz3v8Etew.ttf", + "300": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjndHr-klIgTfc40komjQ5OObazYp-6H94dBF-RX6nNRJfi-Gf55IgAecat6t-R8Pz3v8Etew.ttf", + "regular": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjndHr-klIgTfc40komjQ5OObazYp-6H94dBF-RX6nNRJfi-Gf55IgAecattN-R8Pz3v8Etew.ttf", + "500": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjndHr-klIgTfc40komjQ5OObazYp-6H94dBF-RX6nNRJfi-Gf55IgAecatht-R8Pz3v8Etew.ttf", + "600": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjndHr-klIgTfc40komjQ5OObazYp-6H94dBF-RX6nNRJfi-Gf55IgAecatatiR8Pz3v8Etew.ttf", + "700": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjndHr-klIgTfc40komjQ5OObazYp-6H94dBF-RX6nNRJfi-Gf55IgAecatU9iR8Pz3v8Etew.ttf", + "800": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjndHr-klIgTfc40komjQ5OObazYp-6H94dBF-RX6nNRJfi-Gf55IgAecatNNiR8Pz3v8Etew.ttf", + "900": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjndHr-klIgTfc40komjQ5OObazYp-6H94dBF-RX6nNRJfi-Gf55IgAecatHdiR8Pz3v8Etew.ttf", + "100italic": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjldHr-klIgTfc40komjQ5OObazSJaI_D5kV8k_WLwFBmWrypghjeOa18G4fJx5svbzncQ9e3wx.ttf", + "200italic": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjldHr-klIgTfc40komjQ5OObazSJaI_D5kV8k_WLwFBmWrypghjeOa18G4fJz5s_bzncQ9e3wx.ttf", + "300italic": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjldHr-klIgTfc40komjQ5OObazSJaI_D5kV8k_WLwFBmWrypghjeOa18G4fJwns_bzncQ9e3wx.ttf", + "italic": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjldHr-klIgTfc40komjQ5OObazSJaI_D5kV8k_WLwFBmWrypghjeOa18G4fJx5s_bzncQ9e3wx.ttf", + "500italic": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjldHr-klIgTfc40komjQ5OObazSJaI_D5kV8k_WLwFBmWrypghjeOa18G4fJxLs_bzncQ9e3wx.ttf", + "600italic": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjldHr-klIgTfc40komjQ5OObazSJaI_D5kV8k_WLwFBmWrypghjeOa18G4fJyntPbzncQ9e3wx.ttf", + "700italic": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjldHr-klIgTfc40komjQ5OObazSJaI_D5kV8k_WLwFBmWrypghjeOa18G4fJyetPbzncQ9e3wx.ttf", + "800italic": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjldHr-klIgTfc40komjQ5OObazSJaI_D5kV8k_WLwFBmWrypghjeOa18G4fJz5tPbzncQ9e3wx.ttf", + "900italic": "http://fonts.gstatic.com/s/notoseriftamil/v14/LYjldHr-klIgTfc40komjQ5OObazSJaI_D5kV8k_WLwFBmWrypghjeOa18G4fJzQtPbzncQ9e3wx.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Tangut", + "variants": [ + "regular" + ], + "subsets": [ + "tangut" + ], + "version": "v13", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/notoseriftangut/v13/xn76YGc72GKoTvER4Gn3b4m9Ern7Em41fcvN2KT4.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Telugu", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "telugu" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notoseriftelugu/v14/tDbl2pCbnkEKmXNVmt2M1q6f4HWbbj6MRbYEeav7Fe9D9TGwuY2fjgrZYA.ttf", + "200": "http://fonts.gstatic.com/s/notoseriftelugu/v14/tDbl2pCbnkEKmXNVmt2M1q6f4HWbbj6MRbYEeav7Fe9DdTCwuY2fjgrZYA.ttf", + "300": "http://fonts.gstatic.com/s/notoseriftelugu/v14/tDbl2pCbnkEKmXNVmt2M1q6f4HWbbj6MRbYEeav7Fe9DqzCwuY2fjgrZYA.ttf", + "regular": "http://fonts.gstatic.com/s/notoseriftelugu/v14/tDbl2pCbnkEKmXNVmt2M1q6f4HWbbj6MRbYEeav7Fe9D9TCwuY2fjgrZYA.ttf", + "500": "http://fonts.gstatic.com/s/notoseriftelugu/v14/tDbl2pCbnkEKmXNVmt2M1q6f4HWbbj6MRbYEeav7Fe9DxzCwuY2fjgrZYA.ttf", + "600": "http://fonts.gstatic.com/s/notoseriftelugu/v14/tDbl2pCbnkEKmXNVmt2M1q6f4HWbbj6MRbYEeav7Fe9DKzewuY2fjgrZYA.ttf", + "700": "http://fonts.gstatic.com/s/notoseriftelugu/v14/tDbl2pCbnkEKmXNVmt2M1q6f4HWbbj6MRbYEeav7Fe9DEjewuY2fjgrZYA.ttf", + "800": "http://fonts.gstatic.com/s/notoseriftelugu/v14/tDbl2pCbnkEKmXNVmt2M1q6f4HWbbj6MRbYEeav7Fe9DdTewuY2fjgrZYA.ttf", + "900": "http://fonts.gstatic.com/s/notoseriftelugu/v14/tDbl2pCbnkEKmXNVmt2M1q6f4HWbbj6MRbYEeav7Fe9DXDewuY2fjgrZYA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Thai", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "thai" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/notoserifthai/v13/k3kyo80MPvpLmixYH7euCxWpSMu3-gcWGj0hHAKGvUQlUv_bCKDUSzB5L0oiFuRRCmsdu0Qx.ttf", + "200": "http://fonts.gstatic.com/s/notoserifthai/v13/k3kyo80MPvpLmixYH7euCxWpSMu3-gcWGj0hHAKGvUQlUv_bCKDUSzB5L0qiF-RRCmsdu0Qx.ttf", + "300": "http://fonts.gstatic.com/s/notoserifthai/v13/k3kyo80MPvpLmixYH7euCxWpSMu3-gcWGj0hHAKGvUQlUv_bCKDUSzB5L0p8F-RRCmsdu0Qx.ttf", + "regular": "http://fonts.gstatic.com/s/notoserifthai/v13/k3kyo80MPvpLmixYH7euCxWpSMu3-gcWGj0hHAKGvUQlUv_bCKDUSzB5L0oiF-RRCmsdu0Qx.ttf", + "500": "http://fonts.gstatic.com/s/notoserifthai/v13/k3kyo80MPvpLmixYH7euCxWpSMu3-gcWGj0hHAKGvUQlUv_bCKDUSzB5L0oQF-RRCmsdu0Qx.ttf", + "600": "http://fonts.gstatic.com/s/notoserifthai/v13/k3kyo80MPvpLmixYH7euCxWpSMu3-gcWGj0hHAKGvUQlUv_bCKDUSzB5L0r8EORRCmsdu0Qx.ttf", + "700": "http://fonts.gstatic.com/s/notoserifthai/v13/k3kyo80MPvpLmixYH7euCxWpSMu3-gcWGj0hHAKGvUQlUv_bCKDUSzB5L0rFEORRCmsdu0Qx.ttf", + "800": "http://fonts.gstatic.com/s/notoserifthai/v13/k3kyo80MPvpLmixYH7euCxWpSMu3-gcWGj0hHAKGvUQlUv_bCKDUSzB5L0qiEORRCmsdu0Qx.ttf", + "900": "http://fonts.gstatic.com/s/notoserifthai/v13/k3kyo80MPvpLmixYH7euCxWpSMu3-gcWGj0hHAKGvUQlUv_bCKDUSzB5L0qLEORRCmsdu0Qx.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Tibetan", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "tibetan" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/notoseriftibetan/v14/gokGH7nwAEdtF9N45n0Vaz7O-pk0wsvxHeDXMfqguoCmIrYdPS7rdSy_32c.ttf", + "200": "http://fonts.gstatic.com/s/notoseriftibetan/v14/gokGH7nwAEdtF9N45n0Vaz7O-pk0wsvxHeDXMfqguoCmIjYcPS7rdSy_32c.ttf", + "300": "http://fonts.gstatic.com/s/notoseriftibetan/v14/gokGH7nwAEdtF9N45n0Vaz7O-pk0wsvxHeDXMfqguoCmIugcPS7rdSy_32c.ttf", + "regular": "http://fonts.gstatic.com/s/notoseriftibetan/v14/gokGH7nwAEdtF9N45n0Vaz7O-pk0wsvxHeDXMfqguoCmIrYcPS7rdSy_32c.ttf", + "500": "http://fonts.gstatic.com/s/notoseriftibetan/v14/gokGH7nwAEdtF9N45n0Vaz7O-pk0wsvxHeDXMfqguoCmIoQcPS7rdSy_32c.ttf", + "600": "http://fonts.gstatic.com/s/notoseriftibetan/v14/gokGH7nwAEdtF9N45n0Vaz7O-pk0wsvxHeDXMfqguoCmImgbPS7rdSy_32c.ttf", + "700": "http://fonts.gstatic.com/s/notoseriftibetan/v14/gokGH7nwAEdtF9N45n0Vaz7O-pk0wsvxHeDXMfqguoCmIlEbPS7rdSy_32c.ttf", + "800": "http://fonts.gstatic.com/s/notoseriftibetan/v14/gokGH7nwAEdtF9N45n0Vaz7O-pk0wsvxHeDXMfqguoCmIjYbPS7rdSy_32c.ttf", + "900": "http://fonts.gstatic.com/s/notoseriftibetan/v14/gokGH7nwAEdtF9N45n0Vaz7O-pk0wsvxHeDXMfqguoCmIh8bPS7rdSy_32c.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Serif Yezidi", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "yezidi" + ], + "version": "v14", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/notoserifyezidi/v14/XLYPIYr5bJNDGYxLBibeHZAn3B5KJENnQjbfhMSVZspD2yEkrlGJgmVCqg.ttf", + "500": "http://fonts.gstatic.com/s/notoserifyezidi/v14/XLYPIYr5bJNDGYxLBibeHZAn3B5KJENnQjbfhMSVZspD6SEkrlGJgmVCqg.ttf", + "600": "http://fonts.gstatic.com/s/notoserifyezidi/v14/XLYPIYr5bJNDGYxLBibeHZAn3B5KJENnQjbfhMSVZspDBSYkrlGJgmVCqg.ttf", + "700": "http://fonts.gstatic.com/s/notoserifyezidi/v14/XLYPIYr5bJNDGYxLBibeHZAn3B5KJENnQjbfhMSVZspDPCYkrlGJgmVCqg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Noto Traditional Nushu", + "variants": [ + "regular" + ], + "subsets": [ + "nushu" + ], + "version": "v14", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/nototraditionalnushu/v14/SZco3EDkJ7q9FaoMPlmF4Su8hlIjoGh5aj67J011GNh6SYA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Nova Cut", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/novacut/v22/KFOkCnSYu8mL-39LkWxPKTM1K9nz.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Nova Flat", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/novaflat/v22/QdVUSTc-JgqpytEbVebEuStkm20oJA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Nova Mono", + "variants": [ + "regular" + ], + "subsets": [ + "greek", + "latin" + ], + "version": "v16", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/novamono/v16/Cn-0JtiGWQ5Ajb--MRKfYGxYrdM9Sg.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Nova Oval", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/novaoval/v22/jAnEgHdmANHvPenMaswCMY-h3cWkWg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Nova Round", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/novaround/v19/flU9Rqquw5UhEnlwTJYTYYfeeetYEBc.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Nova Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/novascript/v23/7Au7p_IpkSWSTWaFWkumvmQNEl0O0kEx.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Nova Slim", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/novaslim/v22/Z9XUDmZNQAuem8jyZcn-yMOInrib9Q.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Nova Square", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/novasquare/v18/RrQUbo9-9DV7b06QHgSWsZhARYMgGtWA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Numans", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/numans/v13/SlGRmQmGupYAfH8IYRggiHVqaQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Nunito", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/nunito/v22/XRXI3I6Li01BKofiOc5wtlZ2di8HDDshRTM9jo7eTWk.ttf", + "300": "http://fonts.gstatic.com/s/nunito/v22/XRXI3I6Li01BKofiOc5wtlZ2di8HDOUhRTM9jo7eTWk.ttf", + "regular": "http://fonts.gstatic.com/s/nunito/v22/XRXI3I6Li01BKofiOc5wtlZ2di8HDLshRTM9jo7eTWk.ttf", + "500": "http://fonts.gstatic.com/s/nunito/v22/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhRTM9jo7eTWk.ttf", + "600": "http://fonts.gstatic.com/s/nunito/v22/XRXI3I6Li01BKofiOc5wtlZ2di8HDGUmRTM9jo7eTWk.ttf", + "700": "http://fonts.gstatic.com/s/nunito/v22/XRXI3I6Li01BKofiOc5wtlZ2di8HDFwmRTM9jo7eTWk.ttf", + "800": "http://fonts.gstatic.com/s/nunito/v22/XRXI3I6Li01BKofiOc5wtlZ2di8HDDsmRTM9jo7eTWk.ttf", + "900": "http://fonts.gstatic.com/s/nunito/v22/XRXI3I6Li01BKofiOc5wtlZ2di8HDBImRTM9jo7eTWk.ttf", + "200italic": "http://fonts.gstatic.com/s/nunito/v22/XRXK3I6Li01BKofIMPyPbj8d7IEAGXNiLXA3iqzbXWnoeg.ttf", + "300italic": "http://fonts.gstatic.com/s/nunito/v22/XRXK3I6Li01BKofIMPyPbj8d7IEAGXNi83A3iqzbXWnoeg.ttf", + "italic": "http://fonts.gstatic.com/s/nunito/v22/XRXK3I6Li01BKofIMPyPbj8d7IEAGXNirXA3iqzbXWnoeg.ttf", + "500italic": "http://fonts.gstatic.com/s/nunito/v22/XRXK3I6Li01BKofIMPyPbj8d7IEAGXNin3A3iqzbXWnoeg.ttf", + "600italic": "http://fonts.gstatic.com/s/nunito/v22/XRXK3I6Li01BKofIMPyPbj8d7IEAGXNic3c3iqzbXWnoeg.ttf", + "700italic": "http://fonts.gstatic.com/s/nunito/v22/XRXK3I6Li01BKofIMPyPbj8d7IEAGXNiSnc3iqzbXWnoeg.ttf", + "800italic": "http://fonts.gstatic.com/s/nunito/v22/XRXK3I6Li01BKofIMPyPbj8d7IEAGXNiLXc3iqzbXWnoeg.ttf", + "900italic": "http://fonts.gstatic.com/s/nunito/v22/XRXK3I6Li01BKofIMPyPbj8d7IEAGXNiBHc3iqzbXWnoeg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Nunito Sans", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-01-27", + "files": { + "200": "http://fonts.gstatic.com/s/nunitosans/v11/pe03MImSLYBIv1o4X1M8cc9yAv5qWVAgVol-.ttf", + "200italic": "http://fonts.gstatic.com/s/nunitosans/v11/pe01MImSLYBIv1o4X1M8cce4GxZrU1QCU5l-06Y.ttf", + "300": "http://fonts.gstatic.com/s/nunitosans/v11/pe03MImSLYBIv1o4X1M8cc8WAf5qWVAgVol-.ttf", + "300italic": "http://fonts.gstatic.com/s/nunitosans/v11/pe01MImSLYBIv1o4X1M8cce4G3JoU1QCU5l-06Y.ttf", + "regular": "http://fonts.gstatic.com/s/nunitosans/v11/pe0qMImSLYBIv1o4X1M8cfe6Kdpickwp.ttf", + "italic": "http://fonts.gstatic.com/s/nunitosans/v11/pe0oMImSLYBIv1o4X1M8cce4I95Ad1wpT5A.ttf", + "600": "http://fonts.gstatic.com/s/nunitosans/v11/pe03MImSLYBIv1o4X1M8cc9iB_5qWVAgVol-.ttf", + "600italic": "http://fonts.gstatic.com/s/nunitosans/v11/pe01MImSLYBIv1o4X1M8cce4GwZuU1QCU5l-06Y.ttf", + "700": "http://fonts.gstatic.com/s/nunitosans/v11/pe03MImSLYBIv1o4X1M8cc8GBv5qWVAgVol-.ttf", + "700italic": "http://fonts.gstatic.com/s/nunitosans/v11/pe01MImSLYBIv1o4X1M8cce4G2JvU1QCU5l-06Y.ttf", + "800": "http://fonts.gstatic.com/s/nunitosans/v11/pe03MImSLYBIv1o4X1M8cc8aBf5qWVAgVol-.ttf", + "800italic": "http://fonts.gstatic.com/s/nunitosans/v11/pe01MImSLYBIv1o4X1M8cce4G35sU1QCU5l-06Y.ttf", + "900": "http://fonts.gstatic.com/s/nunitosans/v11/pe03MImSLYBIv1o4X1M8cc8-BP5qWVAgVol-.ttf", + "900italic": "http://fonts.gstatic.com/s/nunitosans/v11/pe01MImSLYBIv1o4X1M8cce4G1ptU1QCU5l-06Y.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Odibee Sans", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/odibeesans/v12/neIPzCSooYAho6WvjeToRYkyepH9qGsf.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Odor Mean Chey", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v25", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/odormeanchey/v25/raxkHiKDttkTe1aOGcJMR1A_4mrY2zqUKafv.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Offside", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/offside/v18/HI_KiYMWKa9QrAykQ5HiRp-dhpQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Oi", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "tamil", + "vietnamese" + ], + "version": "v13", + "lastModified": "2021-12-01", + "files": { + "regular": "http://fonts.gstatic.com/s/oi/v13/w8gXH2EuRqtaut6yjBOG.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Old Standard TT", + "variants": [ + "regular", + "italic", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/oldstandardtt/v17/MwQubh3o1vLImiwAVvYawgcf2eVurVC5RHdCZg.ttf", + "italic": "http://fonts.gstatic.com/s/oldstandardtt/v17/MwQsbh3o1vLImiwAVvYawgcf2eVer1q9ZnJSZtQG.ttf", + "700": "http://fonts.gstatic.com/s/oldstandardtt/v17/MwQrbh3o1vLImiwAVvYawgcf2eVWEX-dTFxeb80flQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Oldenburg", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/oldenburg/v18/fC1jPY5JYWzbywv7c4V6UU6oXyndrw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Ole", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/ole/v1/dFazZf6Z-rd89fw69qJ_ew.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Oleo Script", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/oleoscript/v12/rax5HieDvtMOe0iICsUccBhasU7Q8Cad.ttf", + "700": "http://fonts.gstatic.com/s/oleoscript/v12/raxkHieDvtMOe0iICsUccCDmnmrY2zqUKafv.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Oleo Script Swash Caps", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/oleoscriptswashcaps/v11/Noaj6Vb-w5SFbTTAsZP_7JkCS08K-jCzDn_HMXquSY0Hg90.ttf", + "700": "http://fonts.gstatic.com/s/oleoscriptswashcaps/v11/Noag6Vb-w5SFbTTAsZP_7JkCS08K-jCzDn_HCcaBbYUsn9T5dt0.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Oooh Baby", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/ooohbaby/v1/2sDcZGJWgJTT2Jf76xQDb2-4C7wFZQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Open Sans", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "800", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "hebrew", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v27", + "lastModified": "2021-10-28", + "files": { + "300": "http://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsiH0C4nY1M2xLER.ttf", + "regular": "http://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0C4nY1M2xLER.ttf", + "500": "http://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjr0C4nY1M2xLER.ttf", + "600": "http://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsgH1y4nY1M2xLER.ttf", + "700": "http://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsg-1y4nY1M2xLER.ttf", + "800": "http://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgshZ1y4nY1M2xLER.ttf", + "300italic": "http://fonts.gstatic.com/s/opensans/v27/memQYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWq8tWZ0Pw86hd0Rk5hkaVcUwaERZjA.ttf", + "italic": "http://fonts.gstatic.com/s/opensans/v27/memQYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWq8tWZ0Pw86hd0Rk8ZkaVcUwaERZjA.ttf", + "500italic": "http://fonts.gstatic.com/s/opensans/v27/memQYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWq8tWZ0Pw86hd0Rk_RkaVcUwaERZjA.ttf", + "600italic": "http://fonts.gstatic.com/s/opensans/v27/memQYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWq8tWZ0Pw86hd0RkxhjaVcUwaERZjA.ttf", + "700italic": "http://fonts.gstatic.com/s/opensans/v27/memQYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWq8tWZ0Pw86hd0RkyFjaVcUwaERZjA.ttf", + "800italic": "http://fonts.gstatic.com/s/opensans/v27/memQYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWq8tWZ0Pw86hd0Rk0ZjaVcUwaERZjA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Open Sans Condensed", + "variants": [ + "300", + "300italic", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v21", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/opensanscondensed/v21/z7NFdQDnbTkabZAIOl9il_O6KJj73e7Ff1GhPuLGRpWRyAs.ttf", + "300italic": "http://fonts.gstatic.com/s/opensanscondensed/v21/z7NHdQDnbTkabZAIOl9il_O6KJj73e7Fd_-7suDMQreU2AsJSg.ttf", + "700": "http://fonts.gstatic.com/s/opensanscondensed/v21/z7NFdQDnbTkabZAIOl9il_O6KJj73e7Ff0GmPuLGRpWRyAs.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Oranienbaum", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/oranienbaum/v13/OZpHg_txtzZKMuXLIVrx-3zn7kz3dpHc.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Orbitron", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/orbitron/v23/yMJMMIlzdpvBhQQL_SC3X9yhF25-T1nyGy6xpmIyXjU1pg.ttf", + "500": "http://fonts.gstatic.com/s/orbitron/v23/yMJMMIlzdpvBhQQL_SC3X9yhF25-T1nyKS6xpmIyXjU1pg.ttf", + "600": "http://fonts.gstatic.com/s/orbitron/v23/yMJMMIlzdpvBhQQL_SC3X9yhF25-T1nyxSmxpmIyXjU1pg.ttf", + "700": "http://fonts.gstatic.com/s/orbitron/v23/yMJMMIlzdpvBhQQL_SC3X9yhF25-T1ny_CmxpmIyXjU1pg.ttf", + "800": "http://fonts.gstatic.com/s/orbitron/v23/yMJMMIlzdpvBhQQL_SC3X9yhF25-T1nymymxpmIyXjU1pg.ttf", + "900": "http://fonts.gstatic.com/s/orbitron/v23/yMJMMIlzdpvBhQQL_SC3X9yhF25-T1nysimxpmIyXjU1pg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Oregano", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/oregano/v11/If2IXTPxciS3H4S2kZffPznO3yM.ttf", + "italic": "http://fonts.gstatic.com/s/oregano/v11/If2KXTPxciS3H4S2oZXVOxvLzyP_qw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Orelega One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/orelegaone/v8/3qTpojOggD2XtAdFb-QXZGt61EcYaQ7F.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Orienta", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/orienta/v11/PlI9FlK4Jrl5Y9zNeyeo9HRFhcU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Original Surfer", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/originalsurfer/v16/RWmQoKGZ9vIirYntXJ3_MbekzNMiDEtvAlaMKw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Oswald", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v41", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/oswald/v41/TK3_WkUHHAIjg75cFRf3bXL8LICs13FvgUFoZAaRliE.ttf", + "300": "http://fonts.gstatic.com/s/oswald/v41/TK3_WkUHHAIjg75cFRf3bXL8LICs169vgUFoZAaRliE.ttf", + "regular": "http://fonts.gstatic.com/s/oswald/v41/TK3_WkUHHAIjg75cFRf3bXL8LICs1_FvgUFoZAaRliE.ttf", + "500": "http://fonts.gstatic.com/s/oswald/v41/TK3_WkUHHAIjg75cFRf3bXL8LICs18NvgUFoZAaRliE.ttf", + "600": "http://fonts.gstatic.com/s/oswald/v41/TK3_WkUHHAIjg75cFRf3bXL8LICs1y9ogUFoZAaRliE.ttf", + "700": "http://fonts.gstatic.com/s/oswald/v41/TK3_WkUHHAIjg75cFRf3bXL8LICs1xZogUFoZAaRliE.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Otomanopee One", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext" + ], + "version": "v4", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/otomanopeeone/v4/xMQNuFtEVKCbvGxme-rSATGm_Aea91uCCB9o.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Outfit", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin" + ], + "version": "v4", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/outfit/v4/QGYyz_MVcBeNP4NjuGObqx1XmO1I4TC0C4G-EiAou6Y.ttf", + "200": "http://fonts.gstatic.com/s/outfit/v4/QGYyz_MVcBeNP4NjuGObqx1XmO1I4bC1C4G-EiAou6Y.ttf", + "300": "http://fonts.gstatic.com/s/outfit/v4/QGYyz_MVcBeNP4NjuGObqx1XmO1I4W61C4G-EiAou6Y.ttf", + "regular": "http://fonts.gstatic.com/s/outfit/v4/QGYyz_MVcBeNP4NjuGObqx1XmO1I4TC1C4G-EiAou6Y.ttf", + "500": "http://fonts.gstatic.com/s/outfit/v4/QGYyz_MVcBeNP4NjuGObqx1XmO1I4QK1C4G-EiAou6Y.ttf", + "600": "http://fonts.gstatic.com/s/outfit/v4/QGYyz_MVcBeNP4NjuGObqx1XmO1I4e6yC4G-EiAou6Y.ttf", + "700": "http://fonts.gstatic.com/s/outfit/v4/QGYyz_MVcBeNP4NjuGObqx1XmO1I4deyC4G-EiAou6Y.ttf", + "800": "http://fonts.gstatic.com/s/outfit/v4/QGYyz_MVcBeNP4NjuGObqx1XmO1I4bCyC4G-EiAou6Y.ttf", + "900": "http://fonts.gstatic.com/s/outfit/v4/QGYyz_MVcBeNP4NjuGObqx1XmO1I4ZmyC4G-EiAou6Y.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Over the Rainbow", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/overtherainbow/v14/11haGoXG1k_HKhMLUWz7Mc7vvW5upvOm9NA2XG0.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Overlock", + "variants": [ + "regular", + "italic", + "700", + "700italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/overlock/v13/Z9XVDmdMWRiN1_T9Z4Te4u2El6GC.ttf", + "italic": "http://fonts.gstatic.com/s/overlock/v13/Z9XTDmdMWRiN1_T9Z7Tc6OmmkrGC7Cs.ttf", + "700": "http://fonts.gstatic.com/s/overlock/v13/Z9XSDmdMWRiN1_T9Z7xizcmMvL2L9TLT.ttf", + "700italic": "http://fonts.gstatic.com/s/overlock/v13/Z9XQDmdMWRiN1_T9Z7Tc0FWJtrmp8CLTlNs.ttf", + "900": "http://fonts.gstatic.com/s/overlock/v13/Z9XSDmdMWRiN1_T9Z7xaz8mMvL2L9TLT.ttf", + "900italic": "http://fonts.gstatic.com/s/overlock/v13/Z9XQDmdMWRiN1_T9Z7Tc0G2Ltrmp8CLTlNs.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Overlock SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/overlocksc/v19/1cX3aUHKGZrstGAY8nwVzHGAq8Sk1PoH.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Overpass", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/overpass/v10/qFda35WCmI96Ajtm83upeyoaX6QPnlo6_PLrOZCLtce-og.ttf", + "200": "http://fonts.gstatic.com/s/overpass/v10/qFda35WCmI96Ajtm83upeyoaX6QPnlo6fPPrOZCLtce-og.ttf", + "300": "http://fonts.gstatic.com/s/overpass/v10/qFda35WCmI96Ajtm83upeyoaX6QPnlo6ovPrOZCLtce-og.ttf", + "regular": "http://fonts.gstatic.com/s/overpass/v10/qFda35WCmI96Ajtm83upeyoaX6QPnlo6_PPrOZCLtce-og.ttf", + "500": "http://fonts.gstatic.com/s/overpass/v10/qFda35WCmI96Ajtm83upeyoaX6QPnlo6zvPrOZCLtce-og.ttf", + "600": "http://fonts.gstatic.com/s/overpass/v10/qFda35WCmI96Ajtm83upeyoaX6QPnlo6IvTrOZCLtce-og.ttf", + "700": "http://fonts.gstatic.com/s/overpass/v10/qFda35WCmI96Ajtm83upeyoaX6QPnlo6G_TrOZCLtce-og.ttf", + "800": "http://fonts.gstatic.com/s/overpass/v10/qFda35WCmI96Ajtm83upeyoaX6QPnlo6fPTrOZCLtce-og.ttf", + "900": "http://fonts.gstatic.com/s/overpass/v10/qFda35WCmI96Ajtm83upeyoaX6QPnlo6VfTrOZCLtce-og.ttf", + "100italic": "http://fonts.gstatic.com/s/overpass/v10/qFdU35WCmI96Ajtm81GgSdXCNs-VMF0vNLADe5qPl8Kuosgz.ttf", + "200italic": "http://fonts.gstatic.com/s/overpass/v10/qFdU35WCmI96Ajtm81GgSdXCNs-VMF0vNLCDepqPl8Kuosgz.ttf", + "300italic": "http://fonts.gstatic.com/s/overpass/v10/qFdU35WCmI96Ajtm81GgSdXCNs-VMF0vNLBdepqPl8Kuosgz.ttf", + "italic": "http://fonts.gstatic.com/s/overpass/v10/qFdU35WCmI96Ajtm81GgSdXCNs-VMF0vNLADepqPl8Kuosgz.ttf", + "500italic": "http://fonts.gstatic.com/s/overpass/v10/qFdU35WCmI96Ajtm81GgSdXCNs-VMF0vNLAxepqPl8Kuosgz.ttf", + "600italic": "http://fonts.gstatic.com/s/overpass/v10/qFdU35WCmI96Ajtm81GgSdXCNs-VMF0vNLDdfZqPl8Kuosgz.ttf", + "700italic": "http://fonts.gstatic.com/s/overpass/v10/qFdU35WCmI96Ajtm81GgSdXCNs-VMF0vNLDkfZqPl8Kuosgz.ttf", + "800italic": "http://fonts.gstatic.com/s/overpass/v10/qFdU35WCmI96Ajtm81GgSdXCNs-VMF0vNLCDfZqPl8Kuosgz.ttf", + "900italic": "http://fonts.gstatic.com/s/overpass/v10/qFdU35WCmI96Ajtm81GgSdXCNs-VMF0vNLCqfZqPl8Kuosgz.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Overpass Mono", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/overpassmono/v13/_Xm5-H86tzKDdAPa-KPQZ-AC_COcRycquHlL6EWKokzzXur-SmIr.ttf", + "regular": "http://fonts.gstatic.com/s/overpassmono/v13/_Xm5-H86tzKDdAPa-KPQZ-AC_COcRycquHlL6EXUokzzXur-SmIr.ttf", + "500": "http://fonts.gstatic.com/s/overpassmono/v13/_Xm5-H86tzKDdAPa-KPQZ-AC_COcRycquHlL6EXmokzzXur-SmIr.ttf", + "600": "http://fonts.gstatic.com/s/overpassmono/v13/_Xm5-H86tzKDdAPa-KPQZ-AC_COcRycquHlL6EUKpUzzXur-SmIr.ttf", + "700": "http://fonts.gstatic.com/s/overpassmono/v13/_Xm5-H86tzKDdAPa-KPQZ-AC_COcRycquHlL6EUzpUzzXur-SmIr.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Ovo", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/ovo/v15/yYLl0h7Wyfzjy4Q5_3WVxA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Oxanium", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/oxanium/v12/RrQPboN_4yJ0JmiMUW7sIGjd1IA9G83JfniMBXQ7d67x.ttf", + "300": "http://fonts.gstatic.com/s/oxanium/v12/RrQPboN_4yJ0JmiMUW7sIGjd1IA9G80XfniMBXQ7d67x.ttf", + "regular": "http://fonts.gstatic.com/s/oxanium/v12/RrQPboN_4yJ0JmiMUW7sIGjd1IA9G81JfniMBXQ7d67x.ttf", + "500": "http://fonts.gstatic.com/s/oxanium/v12/RrQPboN_4yJ0JmiMUW7sIGjd1IA9G817fniMBXQ7d67x.ttf", + "600": "http://fonts.gstatic.com/s/oxanium/v12/RrQPboN_4yJ0JmiMUW7sIGjd1IA9G82XeXiMBXQ7d67x.ttf", + "700": "http://fonts.gstatic.com/s/oxanium/v12/RrQPboN_4yJ0JmiMUW7sIGjd1IA9G82ueXiMBXQ7d67x.ttf", + "800": "http://fonts.gstatic.com/s/oxanium/v12/RrQPboN_4yJ0JmiMUW7sIGjd1IA9G83JeXiMBXQ7d67x.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Oxygen", + "variants": [ + "300", + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/oxygen/v14/2sDcZG1Wl4LcnbuCJW8Db2-4C7wFZQ.ttf", + "regular": "http://fonts.gstatic.com/s/oxygen/v14/2sDfZG1Wl4Lcnbu6iUcnZ0SkAg.ttf", + "700": "http://fonts.gstatic.com/s/oxygen/v14/2sDcZG1Wl4LcnbuCNWgDb2-4C7wFZQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Oxygen Mono", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/oxygenmono/v11/h0GsssGg9FxgDgCjLeAd7ijfze-PPlUu.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "PT Mono", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/ptmono/v11/9oRONYoBnWILk-9ArCg5MtPyAcg.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "PT Sans", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/ptsans/v16/jizaRExUiTo99u79P0WOxOGMMDQ.ttf", + "italic": "http://fonts.gstatic.com/s/ptsans/v16/jizYRExUiTo99u79D0eEwMOJIDQA-g.ttf", + "700": "http://fonts.gstatic.com/s/ptsans/v16/jizfRExUiTo99u79B_mh4OmnLD0Z4zM.ttf", + "700italic": "http://fonts.gstatic.com/s/ptsans/v16/jizdRExUiTo99u79D0e8fOytKB8c8zMrig.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "PT Sans Caption", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/ptsanscaption/v17/0FlMVP6Hrxmt7-fsUFhlFXNIlpcqfQXwQy6yxg.ttf", + "700": "http://fonts.gstatic.com/s/ptsanscaption/v17/0FlJVP6Hrxmt7-fsUFhlFXNIlpcSwSrUSwWuz38Tgg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "PT Sans Narrow", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/ptsansnarrow/v16/BngRUXNadjH0qYEzV7ab-oWlsYCByxyKeuDp.ttf", + "700": "http://fonts.gstatic.com/s/ptsansnarrow/v16/BngSUXNadjH0qYEzV7ab-oWlsbg95DiCUfzgRd-3.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "PT Serif", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/ptserif/v16/EJRVQgYoZZY2vCFuvDFRxL6ddjb-.ttf", + "italic": "http://fonts.gstatic.com/s/ptserif/v16/EJRTQgYoZZY2vCFuvAFTzrq_cyb-vco.ttf", + "700": "http://fonts.gstatic.com/s/ptserif/v16/EJRSQgYoZZY2vCFuvAnt65qVXSr3pNNB.ttf", + "700italic": "http://fonts.gstatic.com/s/ptserif/v16/EJRQQgYoZZY2vCFuvAFT9gaQVy7VocNB6Iw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "PT Serif Caption", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/ptserifcaption/v15/ieVl2ZhbGCW-JoW6S34pSDpqYKU059WxDCs5cvI.ttf", + "italic": "http://fonts.gstatic.com/s/ptserifcaption/v15/ieVj2ZhbGCW-JoW6S34pSDpqYKU019e7CAk8YvJEeg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Pacifico", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v21", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/pacifico/v21/FwZY7-Qmy14u9lezJ96A4sijpFu_.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Padauk", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "myanmar" + ], + "version": "v12", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/padauk/v12/RrQRboJg-id7OnbBa0_g3LlYbg.ttf", + "700": "http://fonts.gstatic.com/s/padauk/v12/RrQSboJg-id7Onb512DE1JJEZ4YwGg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Palanquin", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/palanquin/v11/9XUhlJ90n1fBFg7ceXwUEltI7rWmZzTH.ttf", + "200": "http://fonts.gstatic.com/s/palanquin/v11/9XUilJ90n1fBFg7ceXwUvnpoxJuqbi3ezg.ttf", + "300": "http://fonts.gstatic.com/s/palanquin/v11/9XUilJ90n1fBFg7ceXwU2nloxJuqbi3ezg.ttf", + "regular": "http://fonts.gstatic.com/s/palanquin/v11/9XUnlJ90n1fBFg7ceXwsdlFMzLC2Zw.ttf", + "500": "http://fonts.gstatic.com/s/palanquin/v11/9XUilJ90n1fBFg7ceXwUgnhoxJuqbi3ezg.ttf", + "600": "http://fonts.gstatic.com/s/palanquin/v11/9XUilJ90n1fBFg7ceXwUrn9oxJuqbi3ezg.ttf", + "700": "http://fonts.gstatic.com/s/palanquin/v11/9XUilJ90n1fBFg7ceXwUyn5oxJuqbi3ezg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Palanquin Dark", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/palanquindark/v10/xn75YHgl1nqmANMB-26xC7yuF_6OTEo9VtfE.ttf", + "500": "http://fonts.gstatic.com/s/palanquindark/v10/xn76YHgl1nqmANMB-26xC7yuF8Z6ZW41fcvN2KT4.ttf", + "600": "http://fonts.gstatic.com/s/palanquindark/v10/xn76YHgl1nqmANMB-26xC7yuF8ZWYm41fcvN2KT4.ttf", + "700": "http://fonts.gstatic.com/s/palanquindark/v10/xn76YHgl1nqmANMB-26xC7yuF8YyY241fcvN2KT4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Palette Mosaic", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/palettemosaic/v5/AMOIz4aBvWuBFe3TohdW6YZ9MFiy4dxL4jSr.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Pangolin", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/pangolin/v9/cY9GfjGcW0FPpi-tWPfK5d3aiLBG.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Paprika", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/paprika/v18/8QIJdijZitv49rDfuIgOq7jkAOw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Parisienne", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/parisienne/v12/E21i_d3kivvAkxhLEVZpcy96DuKuavM.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Passero One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/passeroone/v22/JTUTjIko8DOq5FeaeEAjgE5B5Arr-s50.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Passion One", + "variants": [ + "regular", + "700", + "900" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/passionone/v14/PbynFmL8HhTPqbjUzux3JHuW_Frg6YoV.ttf", + "700": "http://fonts.gstatic.com/s/passionone/v14/Pby6FmL8HhTPqbjUzux3JEMq037owpYcuH8y.ttf", + "900": "http://fonts.gstatic.com/s/passionone/v14/Pby6FmL8HhTPqbjUzux3JEMS0X7owpYcuH8y.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Passions Conflict", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/passionsconflict/v3/kmKnZrcrFhfafnWX9x0GuEC-zowow5NeYRI4CN2V.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Pathway Gothic One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/pathwaygothicone/v13/MwQrbgD32-KAvjkYGNUUxAtW7pEBwx-dTFxeb80flQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Patrick Hand", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/patrickhand/v18/LDI1apSQOAYtSuYWp8ZhfYeMWcjKm7sp8g.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Patrick Hand SC", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/patrickhandsc/v11/0nkwC9f7MfsBiWcLtY65AWDK873ViSi6JQc7Vg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Pattaya", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/pattaya/v10/ea8ZadcqV_zkHY-XNdCn92ZEmVs.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Patua One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/patuaone/v15/ZXuke1cDvLCKLDcimxBI5PNvNA9LuA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Pavanam", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "tamil" + ], + "version": "v9", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/pavanam/v9/BXRrvF_aiezLh0xPDOtQ9Wf0QcE.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Paytone One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/paytoneone/v16/0nksC9P7MfYHj2oFtYm2CiTqivr9iBq_.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Peddana", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/peddana/v18/aFTU7PBhaX89UcKWhh2aBYyMcKw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Peralta", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/peralta/v15/hYkJPu0-RP_9d3kRGxAhrv956B8.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Permanent Marker", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v10", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/permanentmarker/v10/Fh4uPib9Iyv2ucM6pGQMWimMp004HaqIfrT5nlk.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Petemoss", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/petemoss/v3/A2BZn5tA2xgtGWHZgxkesKb9UouQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Petit Formal Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/petitformalscript/v11/B50TF6xQr2TXJBnGOFME6u5OR83oRP5qoHnqP4gZSiE.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Petrona", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v26", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/petrona/v26/mtGl4_NXL7bZo9XXq35wRLONYyOjFk6NsARBH452Mvds.ttf", + "200": "http://fonts.gstatic.com/s/petrona/v26/mtGl4_NXL7bZo9XXq35wRLONYyOjFk4NsQRBH452Mvds.ttf", + "300": "http://fonts.gstatic.com/s/petrona/v26/mtGl4_NXL7bZo9XXq35wRLONYyOjFk7TsQRBH452Mvds.ttf", + "regular": "http://fonts.gstatic.com/s/petrona/v26/mtGl4_NXL7bZo9XXq35wRLONYyOjFk6NsQRBH452Mvds.ttf", + "500": "http://fonts.gstatic.com/s/petrona/v26/mtGl4_NXL7bZo9XXq35wRLONYyOjFk6_sQRBH452Mvds.ttf", + "600": "http://fonts.gstatic.com/s/petrona/v26/mtGl4_NXL7bZo9XXq35wRLONYyOjFk5TtgRBH452Mvds.ttf", + "700": "http://fonts.gstatic.com/s/petrona/v26/mtGl4_NXL7bZo9XXq35wRLONYyOjFk5qtgRBH452Mvds.ttf", + "800": "http://fonts.gstatic.com/s/petrona/v26/mtGl4_NXL7bZo9XXq35wRLONYyOjFk4NtgRBH452Mvds.ttf", + "900": "http://fonts.gstatic.com/s/petrona/v26/mtGl4_NXL7bZo9XXq35wRLONYyOjFk4ktgRBH452Mvds.ttf", + "100italic": "http://fonts.gstatic.com/s/petrona/v26/mtGr4_NXL7bZo9XXgXdCu2vkCLkNEVtF8uwDFYpUN-dsIWs.ttf", + "200italic": "http://fonts.gstatic.com/s/petrona/v26/mtGr4_NXL7bZo9XXgXdCu2vkCLkNEVtF8mwCFYpUN-dsIWs.ttf", + "300italic": "http://fonts.gstatic.com/s/petrona/v26/mtGr4_NXL7bZo9XXgXdCu2vkCLkNEVtF8rICFYpUN-dsIWs.ttf", + "italic": "http://fonts.gstatic.com/s/petrona/v26/mtGr4_NXL7bZo9XXgXdCu2vkCLkNEVtF8uwCFYpUN-dsIWs.ttf", + "500italic": "http://fonts.gstatic.com/s/petrona/v26/mtGr4_NXL7bZo9XXgXdCu2vkCLkNEVtF8t4CFYpUN-dsIWs.ttf", + "600italic": "http://fonts.gstatic.com/s/petrona/v26/mtGr4_NXL7bZo9XXgXdCu2vkCLkNEVtF8jIFFYpUN-dsIWs.ttf", + "700italic": "http://fonts.gstatic.com/s/petrona/v26/mtGr4_NXL7bZo9XXgXdCu2vkCLkNEVtF8gsFFYpUN-dsIWs.ttf", + "800italic": "http://fonts.gstatic.com/s/petrona/v26/mtGr4_NXL7bZo9XXgXdCu2vkCLkNEVtF8mwFFYpUN-dsIWs.ttf", + "900italic": "http://fonts.gstatic.com/s/petrona/v26/mtGr4_NXL7bZo9XXgXdCu2vkCLkNEVtF8kUFFYpUN-dsIWs.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Philosopher", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/philosopher/v17/vEFV2_5QCwIS4_Dhez5jcVBpRUwU08qe.ttf", + "italic": "http://fonts.gstatic.com/s/philosopher/v17/vEFX2_5QCwIS4_Dhez5jcWBrT0g21tqeR7c.ttf", + "700": "http://fonts.gstatic.com/s/philosopher/v17/vEFI2_5QCwIS4_Dhez5jcWjVamgc-NaXXq7H.ttf", + "700italic": "http://fonts.gstatic.com/s/philosopher/v17/vEFK2_5QCwIS4_Dhez5jcWBrd_QZ8tK1W77HtMo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Piazzolla", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2021-09-16", + "files": { + "100": "http://fonts.gstatic.com/s/piazzolla/v16/N0b52SlTPu5rIkWIZjVKKtYtfxYqZ4RJBFzFfYUjkSDdlqZgy7LYx3Ly1AHfAAy5.ttf", + "200": "http://fonts.gstatic.com/s/piazzolla/v16/N0b52SlTPu5rIkWIZjVKKtYtfxYqZ4RJBFzFfYUjkSDdlqZgy7JYxnLy1AHfAAy5.ttf", + "300": "http://fonts.gstatic.com/s/piazzolla/v16/N0b52SlTPu5rIkWIZjVKKtYtfxYqZ4RJBFzFfYUjkSDdlqZgy7KGxnLy1AHfAAy5.ttf", + "regular": "http://fonts.gstatic.com/s/piazzolla/v16/N0b52SlTPu5rIkWIZjVKKtYtfxYqZ4RJBFzFfYUjkSDdlqZgy7LYxnLy1AHfAAy5.ttf", + "500": "http://fonts.gstatic.com/s/piazzolla/v16/N0b52SlTPu5rIkWIZjVKKtYtfxYqZ4RJBFzFfYUjkSDdlqZgy7LqxnLy1AHfAAy5.ttf", + "600": "http://fonts.gstatic.com/s/piazzolla/v16/N0b52SlTPu5rIkWIZjVKKtYtfxYqZ4RJBFzFfYUjkSDdlqZgy7IGwXLy1AHfAAy5.ttf", + "700": "http://fonts.gstatic.com/s/piazzolla/v16/N0b52SlTPu5rIkWIZjVKKtYtfxYqZ4RJBFzFfYUjkSDdlqZgy7I_wXLy1AHfAAy5.ttf", + "800": "http://fonts.gstatic.com/s/piazzolla/v16/N0b52SlTPu5rIkWIZjVKKtYtfxYqZ4RJBFzFfYUjkSDdlqZgy7JYwXLy1AHfAAy5.ttf", + "900": "http://fonts.gstatic.com/s/piazzolla/v16/N0b52SlTPu5rIkWIZjVKKtYtfxYqZ4RJBFzFfYUjkSDdlqZgy7JxwXLy1AHfAAy5.ttf", + "100italic": "http://fonts.gstatic.com/s/piazzolla/v16/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqw3gX9BRy5m5M.ttf", + "200italic": "http://fonts.gstatic.com/s/piazzolla/v16/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhRqx3gX9BRy5m5M.ttf", + "300italic": "http://fonts.gstatic.com/s/piazzolla/v16/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhcSx3gX9BRy5m5M.ttf", + "italic": "http://fonts.gstatic.com/s/piazzolla/v16/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf", + "500italic": "http://fonts.gstatic.com/s/piazzolla/v16/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhaix3gX9BRy5m5M.ttf", + "600italic": "http://fonts.gstatic.com/s/piazzolla/v16/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhUS23gX9BRy5m5M.ttf", + "700italic": "http://fonts.gstatic.com/s/piazzolla/v16/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhX223gX9BRy5m5M.ttf", + "800italic": "http://fonts.gstatic.com/s/piazzolla/v16/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhRq23gX9BRy5m5M.ttf", + "900italic": "http://fonts.gstatic.com/s/piazzolla/v16/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhTO23gX9BRy5m5M.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Piedra", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/piedra/v19/ke8kOg8aN0Bn7hTunEyHN_M3gA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Pinyon Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/pinyonscript/v14/6xKpdSJbL9-e9LuoeQiDRQR8aOLQO4bhiDY.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Pirata One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/pirataone/v20/I_urMpiDvgLdLh0fAtoftiiEr5_BdZ8.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Plaster", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/plaster/v22/DdTm79QatW80eRh4Ei5JOtLOeLI.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Play", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/play/v16/6aez4K2oVqwIjtI8Hp8Tx3A.ttf", + "700": "http://fonts.gstatic.com/s/play/v16/6ae84K2oVqwItm4TOpc423nTJTM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Playball", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/playball/v14/TK3gWksYAxQ7jbsKcj8Dl-tPKo2t.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Playfair Display", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v28", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFvD-vYSZviVYUb_rj3ij__anPXJzDwcbmjWBN2PKdFvUDQZNLo_U2r.ttf", + "500": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFvD-vYSZviVYUb_rj3ij__anPXJzDwcbmjWBN2PKd3vUDQZNLo_U2r.ttf", + "600": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFvD-vYSZviVYUb_rj3ij__anPXJzDwcbmjWBN2PKebukDQZNLo_U2r.ttf", + "700": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFvD-vYSZviVYUb_rj3ij__anPXJzDwcbmjWBN2PKeiukDQZNLo_U2r.ttf", + "800": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFvD-vYSZviVYUb_rj3ij__anPXJzDwcbmjWBN2PKfFukDQZNLo_U2r.ttf", + "900": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFvD-vYSZviVYUb_rj3ij__anPXJzDwcbmjWBN2PKfsukDQZNLo_U2r.ttf", + "italic": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFRD-vYSZviVYUb_rj3ij__anPXDTnCjmHKM4nYO7KN_qiTbtbK-F2rA0s.ttf", + "500italic": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFRD-vYSZviVYUb_rj3ij__anPXDTnCjmHKM4nYO7KN_pqTbtbK-F2rA0s.ttf", + "600italic": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFRD-vYSZviVYUb_rj3ij__anPXDTnCjmHKM4nYO7KN_naUbtbK-F2rA0s.ttf", + "700italic": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFRD-vYSZviVYUb_rj3ij__anPXDTnCjmHKM4nYO7KN_k-UbtbK-F2rA0s.ttf", + "800italic": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFRD-vYSZviVYUb_rj3ij__anPXDTnCjmHKM4nYO7KN_iiUbtbK-F2rA0s.ttf", + "900italic": "http://fonts.gstatic.com/s/playfairdisplay/v28/nuFRD-vYSZviVYUb_rj3ij__anPXDTnCjmHKM4nYO7KN_gGUbtbK-F2rA0s.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Playfair Display SC", + "variants": [ + "regular", + "italic", + "700", + "700italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/playfairdisplaysc/v14/ke85OhoaMkR6-hSn7kbHVoFf7ZfgMPr_pb4GEcM2M4s.ttf", + "italic": "http://fonts.gstatic.com/s/playfairdisplaysc/v14/ke87OhoaMkR6-hSn7kbHVoFf7ZfgMPr_lbwMFeEzI4sNKg.ttf", + "700": "http://fonts.gstatic.com/s/playfairdisplaysc/v14/ke80OhoaMkR6-hSn7kbHVoFf7ZfgMPr_nQIpNcsdL4IUMyE.ttf", + "700italic": "http://fonts.gstatic.com/s/playfairdisplaysc/v14/ke82OhoaMkR6-hSn7kbHVoFf7ZfgMPr_lbw0qc4XK6ARIyH5IA.ttf", + "900": "http://fonts.gstatic.com/s/playfairdisplaysc/v14/ke80OhoaMkR6-hSn7kbHVoFf7ZfgMPr_nTorNcsdL4IUMyE.ttf", + "900italic": "http://fonts.gstatic.com/s/playfairdisplaysc/v14/ke82OhoaMkR6-hSn7kbHVoFf7ZfgMPr_lbw0kcwXK6ARIyH5IA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Podkova", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v24", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/podkova/v24/K2FufZ1EmftJSV9VQpXb1lo9vC3nZWtFzcU4EoporSHH.ttf", + "500": "http://fonts.gstatic.com/s/podkova/v24/K2FufZ1EmftJSV9VQpXb1lo9vC3nZWt3zcU4EoporSHH.ttf", + "600": "http://fonts.gstatic.com/s/podkova/v24/K2FufZ1EmftJSV9VQpXb1lo9vC3nZWubysU4EoporSHH.ttf", + "700": "http://fonts.gstatic.com/s/podkova/v24/K2FufZ1EmftJSV9VQpXb1lo9vC3nZWuiysU4EoporSHH.ttf", + "800": "http://fonts.gstatic.com/s/podkova/v24/K2FufZ1EmftJSV9VQpXb1lo9vC3nZWvFysU4EoporSHH.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Poiret One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/poiretone/v12/UqyVK80NJXN4zfRgbdfbk5lWVscxdKE.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Poller One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/pollerone/v17/ahccv82n0TN3gia5E4Bud-lbgUS5u0s.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Poly", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/poly/v14/MQpb-W6wKNitRLCAq2Lpris.ttf", + "italic": "http://fonts.gstatic.com/s/poly/v14/MQpV-W6wKNitdLKKr0DsviuGWA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Pompiere", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/pompiere/v13/VEMyRoxis5Dwuyeov6Wt5jDtreOL.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Pontano Sans", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/pontanosans/v11/qFdD35GdgYR8EzR6oBLDHa3qwjUMg1siNQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Poor Story", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/poorstory/v18/jizfREFUsnUct9P6cDfd4OmnLD0Z4zM.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Poppins", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/poppins/v19/pxiGyp8kv8JHgFVrLPTed3FBGPaTSQ.ttf", + "100italic": "http://fonts.gstatic.com/s/poppins/v19/pxiAyp8kv8JHgFVrJJLmE3tFOvODSVFF.ttf", + "200": "http://fonts.gstatic.com/s/poppins/v19/pxiByp8kv8JHgFVrLFj_V1tvFP-KUEg.ttf", + "200italic": "http://fonts.gstatic.com/s/poppins/v19/pxiDyp8kv8JHgFVrJJLmv1plEN2PQEhcqw.ttf", + "300": "http://fonts.gstatic.com/s/poppins/v19/pxiByp8kv8JHgFVrLDz8V1tvFP-KUEg.ttf", + "300italic": "http://fonts.gstatic.com/s/poppins/v19/pxiDyp8kv8JHgFVrJJLm21llEN2PQEhcqw.ttf", + "regular": "http://fonts.gstatic.com/s/poppins/v19/pxiEyp8kv8JHgFVrFJDUc1NECPY.ttf", + "italic": "http://fonts.gstatic.com/s/poppins/v19/pxiGyp8kv8JHgFVrJJLed3FBGPaTSQ.ttf", + "500": "http://fonts.gstatic.com/s/poppins/v19/pxiByp8kv8JHgFVrLGT9V1tvFP-KUEg.ttf", + "500italic": "http://fonts.gstatic.com/s/poppins/v19/pxiDyp8kv8JHgFVrJJLmg1hlEN2PQEhcqw.ttf", + "600": "http://fonts.gstatic.com/s/poppins/v19/pxiByp8kv8JHgFVrLEj6V1tvFP-KUEg.ttf", + "600italic": "http://fonts.gstatic.com/s/poppins/v19/pxiDyp8kv8JHgFVrJJLmr19lEN2PQEhcqw.ttf", + "700": "http://fonts.gstatic.com/s/poppins/v19/pxiByp8kv8JHgFVrLCz7V1tvFP-KUEg.ttf", + "700italic": "http://fonts.gstatic.com/s/poppins/v19/pxiDyp8kv8JHgFVrJJLmy15lEN2PQEhcqw.ttf", + "800": "http://fonts.gstatic.com/s/poppins/v19/pxiByp8kv8JHgFVrLDD4V1tvFP-KUEg.ttf", + "800italic": "http://fonts.gstatic.com/s/poppins/v19/pxiDyp8kv8JHgFVrJJLm111lEN2PQEhcqw.ttf", + "900": "http://fonts.gstatic.com/s/poppins/v19/pxiByp8kv8JHgFVrLBT5V1tvFP-KUEg.ttf", + "900italic": "http://fonts.gstatic.com/s/poppins/v19/pxiDyp8kv8JHgFVrJJLm81xlEN2PQEhcqw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Port Lligat Sans", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/portlligatsans/v16/kmKmZrYrGBbdN1aV7Vokow6Lw4s4l7N0Tx4xEcQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Port Lligat Slab", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/portlligatslab/v19/LDIpaoiQNgArA8kR7ulhZ8P_NYOss7ob9yGLmfI.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Potta One", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/pottaone/v14/FeVSS05Bp6cy7xI-YfxQ3Z5nm29Gww.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Pragati Narrow", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/pragatinarrow/v11/vm8vdRf0T0bS1ffgsPB7WZ-mD17_ytN3M48a.ttf", + "700": "http://fonts.gstatic.com/s/pragatinarrow/v11/vm8sdRf0T0bS1ffgsPB7WZ-mD2ZD5fd_GJMTlo_4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Praise", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/praise/v3/qkBUXvUZ-cnFXcFyDvO67L9XmQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Prata", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/prata/v17/6xKhdSpbNNCT-vWIAG_5LWwJ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Preahvihear", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v25", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/preahvihear/v25/6NUS8F-dNQeEYhzj7uluxswE49FJf8Wv.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Press Start 2P", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/pressstart2p/v12/e3t4euO8T-267oIAQAu6jDQyK0nSgPJE4580.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Pridi", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-25", + "files": { + "200": "http://fonts.gstatic.com/s/pridi/v9/2sDdZG5JnZLfkc1SiE0jRUG0AqUc.ttf", + "300": "http://fonts.gstatic.com/s/pridi/v9/2sDdZG5JnZLfkc02i00jRUG0AqUc.ttf", + "regular": "http://fonts.gstatic.com/s/pridi/v9/2sDQZG5JnZLfkfWao2krbl29.ttf", + "500": "http://fonts.gstatic.com/s/pridi/v9/2sDdZG5JnZLfkc1uik0jRUG0AqUc.ttf", + "600": "http://fonts.gstatic.com/s/pridi/v9/2sDdZG5JnZLfkc1CjU0jRUG0AqUc.ttf", + "700": "http://fonts.gstatic.com/s/pridi/v9/2sDdZG5JnZLfkc0mjE0jRUG0AqUc.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Princess Sofia", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/princesssofia/v19/qWczB6yguIb8DZ_GXZst16n7GRz7mDUoupoI.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Prociono", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/prociono/v20/r05YGLlR-KxAf9GGO8upyDYtStiJ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Prompt", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/prompt/v9/-W_9XJnvUD7dzB2CA9oYREcjeo0k.ttf", + "100italic": "http://fonts.gstatic.com/s/prompt/v9/-W_7XJnvUD7dzB2KZeJ8TkMBf50kbiM.ttf", + "200": "http://fonts.gstatic.com/s/prompt/v9/-W_8XJnvUD7dzB2Cr_s4bmkvc5Q9dw.ttf", + "200italic": "http://fonts.gstatic.com/s/prompt/v9/-W_6XJnvUD7dzB2KZeLQb2MrUZEtdzow.ttf", + "300": "http://fonts.gstatic.com/s/prompt/v9/-W_8XJnvUD7dzB2Cy_g4bmkvc5Q9dw.ttf", + "300italic": "http://fonts.gstatic.com/s/prompt/v9/-W_6XJnvUD7dzB2KZeK0bGMrUZEtdzow.ttf", + "regular": "http://fonts.gstatic.com/s/prompt/v9/-W__XJnvUD7dzB26Z9AcZkIzeg.ttf", + "italic": "http://fonts.gstatic.com/s/prompt/v9/-W_9XJnvUD7dzB2KZdoYREcjeo0k.ttf", + "500": "http://fonts.gstatic.com/s/prompt/v9/-W_8XJnvUD7dzB2Ck_k4bmkvc5Q9dw.ttf", + "500italic": "http://fonts.gstatic.com/s/prompt/v9/-W_6XJnvUD7dzB2KZeLsbWMrUZEtdzow.ttf", + "600": "http://fonts.gstatic.com/s/prompt/v9/-W_8XJnvUD7dzB2Cv_44bmkvc5Q9dw.ttf", + "600italic": "http://fonts.gstatic.com/s/prompt/v9/-W_6XJnvUD7dzB2KZeLAamMrUZEtdzow.ttf", + "700": "http://fonts.gstatic.com/s/prompt/v9/-W_8XJnvUD7dzB2C2_84bmkvc5Q9dw.ttf", + "700italic": "http://fonts.gstatic.com/s/prompt/v9/-W_6XJnvUD7dzB2KZeKka2MrUZEtdzow.ttf", + "800": "http://fonts.gstatic.com/s/prompt/v9/-W_8XJnvUD7dzB2Cx_w4bmkvc5Q9dw.ttf", + "800italic": "http://fonts.gstatic.com/s/prompt/v9/-W_6XJnvUD7dzB2KZeK4aGMrUZEtdzow.ttf", + "900": "http://fonts.gstatic.com/s/prompt/v9/-W_8XJnvUD7dzB2C4_04bmkvc5Q9dw.ttf", + "900italic": "http://fonts.gstatic.com/s/prompt/v9/-W_6XJnvUD7dzB2KZeKcaWMrUZEtdzow.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Prosto One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/prostoone/v15/OpNJno4VhNfK-RgpwWWxpipfWhXD00c.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Proza Libre", + "variants": [ + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/prozalibre/v5/LYjGdGHgj0k1DIQRyUEyyHovftvXWYyz.ttf", + "italic": "http://fonts.gstatic.com/s/prozalibre/v5/LYjEdGHgj0k1DIQRyUEyyEotdN_1XJyz7zc.ttf", + "500": "http://fonts.gstatic.com/s/prozalibre/v5/LYjbdGHgj0k1DIQRyUEyyELbV__fcpC69i6N.ttf", + "500italic": "http://fonts.gstatic.com/s/prozalibre/v5/LYjZdGHgj0k1DIQRyUEyyEotTCvceJSY8z6Np1k.ttf", + "600": "http://fonts.gstatic.com/s/prozalibre/v5/LYjbdGHgj0k1DIQRyUEyyEL3UP_fcpC69i6N.ttf", + "600italic": "http://fonts.gstatic.com/s/prozalibre/v5/LYjZdGHgj0k1DIQRyUEyyEotTAfbeJSY8z6Np1k.ttf", + "700": "http://fonts.gstatic.com/s/prozalibre/v5/LYjbdGHgj0k1DIQRyUEyyEKTUf_fcpC69i6N.ttf", + "700italic": "http://fonts.gstatic.com/s/prozalibre/v5/LYjZdGHgj0k1DIQRyUEyyEotTGPaeJSY8z6Np1k.ttf", + "800": "http://fonts.gstatic.com/s/prozalibre/v5/LYjbdGHgj0k1DIQRyUEyyEKPUv_fcpC69i6N.ttf", + "800italic": "http://fonts.gstatic.com/s/prozalibre/v5/LYjZdGHgj0k1DIQRyUEyyEotTH_ZeJSY8z6Np1k.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Public Sans", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/publicsans/v11/ijwGs572Xtc6ZYQws9YVwllKVG8qX1oyOymuFpi5ww0pX189fg.ttf", + "200": "http://fonts.gstatic.com/s/publicsans/v11/ijwGs572Xtc6ZYQws9YVwllKVG8qX1oyOymulpm5ww0pX189fg.ttf", + "300": "http://fonts.gstatic.com/s/publicsans/v11/ijwGs572Xtc6ZYQws9YVwllKVG8qX1oyOymuSJm5ww0pX189fg.ttf", + "regular": "http://fonts.gstatic.com/s/publicsans/v11/ijwGs572Xtc6ZYQws9YVwllKVG8qX1oyOymuFpm5ww0pX189fg.ttf", + "500": "http://fonts.gstatic.com/s/publicsans/v11/ijwGs572Xtc6ZYQws9YVwllKVG8qX1oyOymuJJm5ww0pX189fg.ttf", + "600": "http://fonts.gstatic.com/s/publicsans/v11/ijwGs572Xtc6ZYQws9YVwllKVG8qX1oyOymuyJ65ww0pX189fg.ttf", + "700": "http://fonts.gstatic.com/s/publicsans/v11/ijwGs572Xtc6ZYQws9YVwllKVG8qX1oyOymu8Z65ww0pX189fg.ttf", + "800": "http://fonts.gstatic.com/s/publicsans/v11/ijwGs572Xtc6ZYQws9YVwllKVG8qX1oyOymulp65ww0pX189fg.ttf", + "900": "http://fonts.gstatic.com/s/publicsans/v11/ijwGs572Xtc6ZYQws9YVwllKVG8qX1oyOymuv565ww0pX189fg.ttf", + "100italic": "http://fonts.gstatic.com/s/publicsans/v11/ijwAs572Xtc6ZYQws9YVwnNDZpDyNjGolS673tpRgQctfVotfj7j.ttf", + "200italic": "http://fonts.gstatic.com/s/publicsans/v11/ijwAs572Xtc6ZYQws9YVwnNDZpDyNjGolS673trRgActfVotfj7j.ttf", + "300italic": "http://fonts.gstatic.com/s/publicsans/v11/ijwAs572Xtc6ZYQws9YVwnNDZpDyNjGolS673toPgActfVotfj7j.ttf", + "italic": "http://fonts.gstatic.com/s/publicsans/v11/ijwAs572Xtc6ZYQws9YVwnNDZpDyNjGolS673tpRgActfVotfj7j.ttf", + "500italic": "http://fonts.gstatic.com/s/publicsans/v11/ijwAs572Xtc6ZYQws9YVwnNDZpDyNjGolS673tpjgActfVotfj7j.ttf", + "600italic": "http://fonts.gstatic.com/s/publicsans/v11/ijwAs572Xtc6ZYQws9YVwnNDZpDyNjGolS673tqPhwctfVotfj7j.ttf", + "700italic": "http://fonts.gstatic.com/s/publicsans/v11/ijwAs572Xtc6ZYQws9YVwnNDZpDyNjGolS673tq2hwctfVotfj7j.ttf", + "800italic": "http://fonts.gstatic.com/s/publicsans/v11/ijwAs572Xtc6ZYQws9YVwnNDZpDyNjGolS673trRhwctfVotfj7j.ttf", + "900italic": "http://fonts.gstatic.com/s/publicsans/v11/ijwAs572Xtc6ZYQws9YVwnNDZpDyNjGolS673tr4hwctfVotfj7j.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Puppies Play", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/puppiesplay/v3/wlp2gwHZEV99rG6M3NR9uB9vaAJSA_JN3Q.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Puritan", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v22", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/puritan/v22/845YNMgkAJ2VTtIo9JrwRdaI50M.ttf", + "italic": "http://fonts.gstatic.com/s/puritan/v22/845aNMgkAJ2VTtIoxJj6QfSN90PfXA.ttf", + "700": "http://fonts.gstatic.com/s/puritan/v22/845dNMgkAJ2VTtIozCbfYd6j-0rGRes.ttf", + "700italic": "http://fonts.gstatic.com/s/puritan/v22/845fNMgkAJ2VTtIoxJjC_dup_2jDVevnLQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Purple Purse", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/purplepurse/v19/qWctB66gv53iAp-Vfs4My6qyeBb_ujA4ug.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Qahiri", + "variants": [ + "regular" + ], + "subsets": [ + "arabic", + "latin" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/qahiri/v5/tsssAp1RZy0C_hGuU3Chrnmupw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Quando", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/quando/v12/xMQVuFNaVa6YuW0pC6WzKX_QmA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Quantico", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/quantico/v13/rax-HiSdp9cPL3KIF4xsLjxSmlLZ.ttf", + "italic": "http://fonts.gstatic.com/s/quantico/v13/rax4HiSdp9cPL3KIF7xuJDhwn0LZ6T8.ttf", + "700": "http://fonts.gstatic.com/s/quantico/v13/rax5HiSdp9cPL3KIF7TQARhasU7Q8Cad.ttf", + "700italic": "http://fonts.gstatic.com/s/quantico/v13/rax7HiSdp9cPL3KIF7xuHIRfu0ry9TadML4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Quattrocento", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/quattrocento/v15/OZpEg_xvsDZQL_LKIF7q4jPHxGL7f4jFuA.ttf", + "700": "http://fonts.gstatic.com/s/quattrocento/v15/OZpbg_xvsDZQL_LKIF7q4jP_eE3fd6PZsXcM9w.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Quattrocento Sans", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/quattrocentosans/v17/va9c4lja2NVIDdIAAoMR5MfuElaRB3zOvU7eHGHJ.ttf", + "italic": "http://fonts.gstatic.com/s/quattrocentosans/v17/va9a4lja2NVIDdIAAoMR5MfuElaRB0zMt0r8GXHJkLI.ttf", + "700": "http://fonts.gstatic.com/s/quattrocentosans/v17/va9Z4lja2NVIDdIAAoMR5MfuElaRB0RykmrWN33AiasJ.ttf", + "700italic": "http://fonts.gstatic.com/s/quattrocentosans/v17/va9X4lja2NVIDdIAAoMR5MfuElaRB0zMj_bTPXnijLsJV7E.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Questrial", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/questrial/v17/QdVUSTchPBm7nuUeVf7EuStkm20oJA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Quicksand", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v28", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/quicksand/v28/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkKEo18G0wx40QDw.ttf", + "regular": "http://fonts.gstatic.com/s/quicksand/v28/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkP8o18G0wx40QDw.ttf", + "500": "http://fonts.gstatic.com/s/quicksand/v28/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkM0o18G0wx40QDw.ttf", + "600": "http://fonts.gstatic.com/s/quicksand/v28/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkCEv18G0wx40QDw.ttf", + "700": "http://fonts.gstatic.com/s/quicksand/v28/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkBgv18G0wx40QDw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Quintessential", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/quintessential/v18/fdNn9sOGq31Yjnh3qWU14DdtjY5wS7kmAyxM.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Qwigley", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/qwigley/v14/1cXzaU3UGJb5tGoCuVxsi1mBmcE.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Qwitcher Grypen", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/qwitchergrypen/v1/pxicypclp9tDilN9RrC5BSI1dZmrSGNAom-wpw.ttf", + "700": "http://fonts.gstatic.com/s/qwitchergrypen/v1/pxiZypclp9tDilN9RrC5BSI1dZmT9ExkqkSsrvNXiA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Racing Sans One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/racingsansone/v11/sykr-yRtm7EvTrXNxkv5jfKKyDCwL3rmWpIBtA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Radley", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/radley/v18/LYjDdGzinEIjCN19oAlEpVs3VQ.ttf", + "italic": "http://fonts.gstatic.com/s/radley/v18/LYjBdGzinEIjCN1NogNAh14nVcfe.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rajdhani", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/rajdhani/v14/LDI2apCSOBg7S-QT7pasEcOsc-bGkqIw.ttf", + "regular": "http://fonts.gstatic.com/s/rajdhani/v14/LDIxapCSOBg7S-QT7q4AOeekWPrP.ttf", + "500": "http://fonts.gstatic.com/s/rajdhani/v14/LDI2apCSOBg7S-QT7pb0EMOsc-bGkqIw.ttf", + "600": "http://fonts.gstatic.com/s/rajdhani/v14/LDI2apCSOBg7S-QT7pbYF8Osc-bGkqIw.ttf", + "700": "http://fonts.gstatic.com/s/rajdhani/v14/LDI2apCSOBg7S-QT7pa8FsOsc-bGkqIw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rakkas", + "variants": [ + "regular" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/rakkas/v15/Qw3cZQlNHiblL3j_lttPOeMcCw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Raleway", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v26", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/raleway/v26/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVvao4CPNLA3JC9c.ttf", + "200": "http://fonts.gstatic.com/s/raleway/v26/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVtaooCPNLA3JC9c.ttf", + "300": "http://fonts.gstatic.com/s/raleway/v26/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVuEooCPNLA3JC9c.ttf", + "regular": "http://fonts.gstatic.com/s/raleway/v26/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVvaooCPNLA3JC9c.ttf", + "500": "http://fonts.gstatic.com/s/raleway/v26/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVvoooCPNLA3JC9c.ttf", + "600": "http://fonts.gstatic.com/s/raleway/v26/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVsEpYCPNLA3JC9c.ttf", + "700": "http://fonts.gstatic.com/s/raleway/v26/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVs9pYCPNLA3JC9c.ttf", + "800": "http://fonts.gstatic.com/s/raleway/v26/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVtapYCPNLA3JC9c.ttf", + "900": "http://fonts.gstatic.com/s/raleway/v26/1Ptxg8zYS_SKggPN4iEgvnHyvveLxVtzpYCPNLA3JC9c.ttf", + "100italic": "http://fonts.gstatic.com/s/raleway/v26/1Pt_g8zYS_SKggPNyCgSQamb1W0lwk4S4WjNPrQVIT9c2c8.ttf", + "200italic": "http://fonts.gstatic.com/s/raleway/v26/1Pt_g8zYS_SKggPNyCgSQamb1W0lwk4S4ejMPrQVIT9c2c8.ttf", + "300italic": "http://fonts.gstatic.com/s/raleway/v26/1Pt_g8zYS_SKggPNyCgSQamb1W0lwk4S4TbMPrQVIT9c2c8.ttf", + "italic": "http://fonts.gstatic.com/s/raleway/v26/1Pt_g8zYS_SKggPNyCgSQamb1W0lwk4S4WjMPrQVIT9c2c8.ttf", + "500italic": "http://fonts.gstatic.com/s/raleway/v26/1Pt_g8zYS_SKggPNyCgSQamb1W0lwk4S4VrMPrQVIT9c2c8.ttf", + "600italic": "http://fonts.gstatic.com/s/raleway/v26/1Pt_g8zYS_SKggPNyCgSQamb1W0lwk4S4bbLPrQVIT9c2c8.ttf", + "700italic": "http://fonts.gstatic.com/s/raleway/v26/1Pt_g8zYS_SKggPNyCgSQamb1W0lwk4S4Y_LPrQVIT9c2c8.ttf", + "800italic": "http://fonts.gstatic.com/s/raleway/v26/1Pt_g8zYS_SKggPNyCgSQamb1W0lwk4S4ejLPrQVIT9c2c8.ttf", + "900italic": "http://fonts.gstatic.com/s/raleway/v26/1Pt_g8zYS_SKggPNyCgSQamb1W0lwk4S4cHLPrQVIT9c2c8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Raleway Dots", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/ralewaydots/v12/6NUR8FifJg6AfQvzpshgwJ8kyf9Fdty2ew.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Ramabhadra", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/ramabhadra/v13/EYq2maBOwqRW9P1SQ83LehNGX5uWw3o.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ramaraja", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/ramaraja/v13/SlGTmQearpYAYG1CABIkqnB6aSQU.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rambla", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/rambla/v11/snfrs0ip98hx6mr0I7IONthkwQ.ttf", + "italic": "http://fonts.gstatic.com/s/rambla/v11/snfps0ip98hx6mrEIbgKFN10wYKa.ttf", + "700": "http://fonts.gstatic.com/s/rambla/v11/snfos0ip98hx6mrMn50qPvN4yJuDYQ.ttf", + "700italic": "http://fonts.gstatic.com/s/rambla/v11/snfus0ip98hx6mrEIYC2O_l86p6TYS-Y.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rammetto One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/rammettoone/v12/LhWiMV3HOfMbMetJG3lQDpp9Mvuciu-_SQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Rampart One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/rampartone/v5/K2F1fZFGl_JSR1tAWNG9R6qgLS76ZHOM.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Ranchers", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/ranchers/v11/zrfm0H3Lx-P2Xvs2AoDYDC79XTHv.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Rancho", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/rancho/v11/46kulbzmXjLaqZRlbWXgd0RY1g.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Ranga", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/ranga/v8/C8ct4cYisGb28p6CLDwZwmGE.ttf", + "700": "http://fonts.gstatic.com/s/ranga/v8/C8cg4cYisGb28qY-AxgR6X2NZAn2.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Rasa", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "300italic", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "gujarati", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/rasa/v13/xn76YHIn1mWmVKl8ZtAM9NrJfN4YJW41fcvN2KT4.ttf", + "regular": "http://fonts.gstatic.com/s/rasa/v13/xn76YHIn1mWmVKl8ZtAM9NrJfN5GJW41fcvN2KT4.ttf", + "500": "http://fonts.gstatic.com/s/rasa/v13/xn76YHIn1mWmVKl8ZtAM9NrJfN50JW41fcvN2KT4.ttf", + "600": "http://fonts.gstatic.com/s/rasa/v13/xn76YHIn1mWmVKl8ZtAM9NrJfN6YIm41fcvN2KT4.ttf", + "700": "http://fonts.gstatic.com/s/rasa/v13/xn76YHIn1mWmVKl8ZtAM9NrJfN6hIm41fcvN2KT4.ttf", + "300italic": "http://fonts.gstatic.com/s/rasa/v13/xn78YHIn1mWmfqBOmQhln0Bne8uOZth2d8_v3bT4Ycc.ttf", + "italic": "http://fonts.gstatic.com/s/rasa/v13/xn78YHIn1mWmfqBOmQhln0Bne8uOZoZ2d8_v3bT4Ycc.ttf", + "500italic": "http://fonts.gstatic.com/s/rasa/v13/xn78YHIn1mWmfqBOmQhln0Bne8uOZrR2d8_v3bT4Ycc.ttf", + "600italic": "http://fonts.gstatic.com/s/rasa/v13/xn78YHIn1mWmfqBOmQhln0Bne8uOZlhxd8_v3bT4Ycc.ttf", + "700italic": "http://fonts.gstatic.com/s/rasa/v13/xn78YHIn1mWmfqBOmQhln0Bne8uOZmFxd8_v3bT4Ycc.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rationale", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v22", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/rationale/v22/9XUnlJ92n0_JFxHIfHcsdlFMzLC2Zw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ravi Prakash", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v17", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/raviprakash/v17/gokpH6fsDkVrF9Bv9X8SOAKHmNZEq6TTFw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Readex Pro", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/readexpro/v8/SLXYc1bJ7HE5YDoGPuzj_dh8na74KiwZQQzfm7w3bk38hTB8.ttf", + "300": "http://fonts.gstatic.com/s/readexpro/v8/SLXYc1bJ7HE5YDoGPuzj_dh8na74KiwZQQwBm7w3bk38hTB8.ttf", + "regular": "http://fonts.gstatic.com/s/readexpro/v8/SLXYc1bJ7HE5YDoGPuzj_dh8na74KiwZQQxfm7w3bk38hTB8.ttf", + "500": "http://fonts.gstatic.com/s/readexpro/v8/SLXYc1bJ7HE5YDoGPuzj_dh8na74KiwZQQxtm7w3bk38hTB8.ttf", + "600": "http://fonts.gstatic.com/s/readexpro/v8/SLXYc1bJ7HE5YDoGPuzj_dh8na74KiwZQQyBnLw3bk38hTB8.ttf", + "700": "http://fonts.gstatic.com/s/readexpro/v8/SLXYc1bJ7HE5YDoGPuzj_dh8na74KiwZQQy4nLw3bk38hTB8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Recursive", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v26", + "lastModified": "2021-07-01", + "files": { + "300": "http://fonts.gstatic.com/s/recursive/v26/8vJN7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUvz2xbXfn1uEQadDck018vwxjDJCL.ttf", + "regular": "http://fonts.gstatic.com/s/recursive/v26/8vJN7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUvz2xbXfn1uEQadCCk018vwxjDJCL.ttf", + "500": "http://fonts.gstatic.com/s/recursive/v26/8vJN7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUvz2xbXfn1uEQadCwk018vwxjDJCL.ttf", + "600": "http://fonts.gstatic.com/s/recursive/v26/8vJN7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUvz2xbXfn1uEQadBclE18vwxjDJCL.ttf", + "700": "http://fonts.gstatic.com/s/recursive/v26/8vJN7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUvz2xbXfn1uEQadBllE18vwxjDJCL.ttf", + "800": "http://fonts.gstatic.com/s/recursive/v26/8vJN7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUvz2xbXfn1uEQadAClE18vwxjDJCL.ttf", + "900": "http://fonts.gstatic.com/s/recursive/v26/8vJN7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUvz2xbXfn1uEQadArlE18vwxjDJCL.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Red Hat Display", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIf7wUr0m80wwYf0QCXZzYzUoTK8RZQvRd-D1NYbjKWckg5-Xecg3w.ttf", + "regular": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIf7wUr0m80wwYf0QCXZzYzUoTK8RZQvRd-D1NYbmyWckg5-Xecg3w.ttf", + "500": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIf7wUr0m80wwYf0QCXZzYzUoTK8RZQvRd-D1NYbl6Wckg5-Xecg3w.ttf", + "600": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIf7wUr0m80wwYf0QCXZzYzUoTK8RZQvRd-D1NYbrKRckg5-Xecg3w.ttf", + "700": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIf7wUr0m80wwYf0QCXZzYzUoTK8RZQvRd-D1NYbouRckg5-Xecg3w.ttf", + "800": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIf7wUr0m80wwYf0QCXZzYzUoTK8RZQvRd-D1NYbuyRckg5-Xecg3w.ttf", + "900": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIf7wUr0m80wwYf0QCXZzYzUoTK8RZQvRd-D1NYbsWRckg5-Xecg3w.ttf", + "300italic": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIh7wUr0m80wwYf0QCXZzYzUoTg-CSvZX4Vlf1fe6TVxAsz_VWZk3zJGg.ttf", + "italic": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIh7wUr0m80wwYf0QCXZzYzUoTg-CSvZX4Vlf1fe6TVmgsz_VWZk3zJGg.ttf", + "500italic": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIh7wUr0m80wwYf0QCXZzYzUoTg-CSvZX4Vlf1fe6TVqAsz_VWZk3zJGg.ttf", + "600italic": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIh7wUr0m80wwYf0QCXZzYzUoTg-CSvZX4Vlf1fe6TVRAwz_VWZk3zJGg.ttf", + "700italic": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIh7wUr0m80wwYf0QCXZzYzUoTg-CSvZX4Vlf1fe6TVfQwz_VWZk3zJGg.ttf", + "800italic": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIh7wUr0m80wwYf0QCXZzYzUoTg-CSvZX4Vlf1fe6TVGgwz_VWZk3zJGg.ttf", + "900italic": "http://fonts.gstatic.com/s/redhatdisplay/v11/8vIh7wUr0m80wwYf0QCXZzYzUoTg-CSvZX4Vlf1fe6TVMwwz_VWZk3zJGg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Red Hat Mono", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "300italic", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/redhatmono/v7/jVyY7nDnA2uf2zVvFAhhzEs-VMSjJpBTfgjwQQPI-7HNuW4QuKI.ttf", + "regular": "http://fonts.gstatic.com/s/redhatmono/v7/jVyY7nDnA2uf2zVvFAhhzEs-VMSjJpBTfgjwQV3I-7HNuW4QuKI.ttf", + "500": "http://fonts.gstatic.com/s/redhatmono/v7/jVyY7nDnA2uf2zVvFAhhzEs-VMSjJpBTfgjwQW_I-7HNuW4QuKI.ttf", + "600": "http://fonts.gstatic.com/s/redhatmono/v7/jVyY7nDnA2uf2zVvFAhhzEs-VMSjJpBTfgjwQYPP-7HNuW4QuKI.ttf", + "700": "http://fonts.gstatic.com/s/redhatmono/v7/jVyY7nDnA2uf2zVvFAhhzEs-VMSjJpBTfgjwQbrP-7HNuW4QuKI.ttf", + "300italic": "http://fonts.gstatic.com/s/redhatmono/v7/jVye7nDnA2uf2zVvFAhhzEsUXfZc_vk45Kb3VJWLTfLHvUwVqKIJuw.ttf", + "italic": "http://fonts.gstatic.com/s/redhatmono/v7/jVye7nDnA2uf2zVvFAhhzEsUXfZc_vk45Kb3VJWLE_LHvUwVqKIJuw.ttf", + "500italic": "http://fonts.gstatic.com/s/redhatmono/v7/jVye7nDnA2uf2zVvFAhhzEsUXfZc_vk45Kb3VJWLIfLHvUwVqKIJuw.ttf", + "600italic": "http://fonts.gstatic.com/s/redhatmono/v7/jVye7nDnA2uf2zVvFAhhzEsUXfZc_vk45Kb3VJWLzfXHvUwVqKIJuw.ttf", + "700italic": "http://fonts.gstatic.com/s/redhatmono/v7/jVye7nDnA2uf2zVvFAhhzEsUXfZc_vk45Kb3VJWL9PXHvUwVqKIJuw.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Red Hat Text", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "300italic", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/redhattext/v10/RrQCbohi_ic6B3yVSzGBrMx6ZI_cy1A6Ok2ML-ZwVrbacYVFtIY.ttf", + "regular": "http://fonts.gstatic.com/s/redhattext/v10/RrQCbohi_ic6B3yVSzGBrMx6ZI_cy1A6Ok2ML7hwVrbacYVFtIY.ttf", + "500": "http://fonts.gstatic.com/s/redhattext/v10/RrQCbohi_ic6B3yVSzGBrMx6ZI_cy1A6Ok2ML4pwVrbacYVFtIY.ttf", + "600": "http://fonts.gstatic.com/s/redhattext/v10/RrQCbohi_ic6B3yVSzGBrMx6ZI_cy1A6Ok2ML2Z3VrbacYVFtIY.ttf", + "700": "http://fonts.gstatic.com/s/redhattext/v10/RrQCbohi_ic6B3yVSzGBrMx6ZI_cy1A6Ok2ML193VrbacYVFtIY.ttf", + "300italic": "http://fonts.gstatic.com/s/redhattext/v10/RrQEbohi_ic6B3yVSzGBrMxQbb0jEzlRoOOLOnAz4PXQdadApIYv_g.ttf", + "italic": "http://fonts.gstatic.com/s/redhattext/v10/RrQEbohi_ic6B3yVSzGBrMxQbb0jEzlRoOOLOnAzvvXQdadApIYv_g.ttf", + "500italic": "http://fonts.gstatic.com/s/redhattext/v10/RrQEbohi_ic6B3yVSzGBrMxQbb0jEzlRoOOLOnAzjPXQdadApIYv_g.ttf", + "600italic": "http://fonts.gstatic.com/s/redhattext/v10/RrQEbohi_ic6B3yVSzGBrMxQbb0jEzlRoOOLOnAzYPLQdadApIYv_g.ttf", + "700italic": "http://fonts.gstatic.com/s/redhattext/v10/RrQEbohi_ic6B3yVSzGBrMxQbb0jEzlRoOOLOnAzWfLQdadApIYv_g.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Red Rose", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v12", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/redrose/v12/QdVISTYiLBjouPgEUajvsfWwDtc3MH8y8_sDcjSsYUVUjg.ttf", + "regular": "http://fonts.gstatic.com/s/redrose/v12/QdVISTYiLBjouPgEUajvsfWwDtc3MH8yrfsDcjSsYUVUjg.ttf", + "500": "http://fonts.gstatic.com/s/redrose/v12/QdVISTYiLBjouPgEUajvsfWwDtc3MH8yn_sDcjSsYUVUjg.ttf", + "600": "http://fonts.gstatic.com/s/redrose/v12/QdVISTYiLBjouPgEUajvsfWwDtc3MH8yc_wDcjSsYUVUjg.ttf", + "700": "http://fonts.gstatic.com/s/redrose/v12/QdVISTYiLBjouPgEUajvsfWwDtc3MH8ySvwDcjSsYUVUjg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Redacted", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/redacted/v3/Z9XVDmdRShme2O_7aITe4u2El6GC.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Redacted Script", + "variants": [ + "300", + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "300": "http://fonts.gstatic.com/s/redactedscript/v3/ypvEbXGRglhokR7dcC3d1-R6zmxqHUzVmbI397ldkg.ttf", + "regular": "http://fonts.gstatic.com/s/redactedscript/v3/ypvBbXGRglhokR7dcC3d1-R6zmxSsWTxkZkr_g.ttf", + "700": "http://fonts.gstatic.com/s/redactedscript/v3/ypvEbXGRglhokR7dcC3d1-R6zmxqDUvVmbI397ldkg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Redressed", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v23", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/redressed/v23/x3dickHUbrmJ7wMy9MsBfPACvy_1BA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Reem Kufi", + "variants": [ + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "arabic", + "latin" + ], + "version": "v16", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/reemkufi/v16/2sDPZGJLip7W2J7v7wQZZE1I0yCmYzzQtuZnEGGf3qGuvM4.ttf", + "500": "http://fonts.gstatic.com/s/reemkufi/v16/2sDPZGJLip7W2J7v7wQZZE1I0yCmYzzQttRnEGGf3qGuvM4.ttf", + "600": "http://fonts.gstatic.com/s/reemkufi/v16/2sDPZGJLip7W2J7v7wQZZE1I0yCmYzzQtjhgEGGf3qGuvM4.ttf", + "700": "http://fonts.gstatic.com/s/reemkufi/v16/2sDPZGJLip7W2J7v7wQZZE1I0yCmYzzQtgFgEGGf3qGuvM4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Reenie Beanie", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/reeniebeanie/v14/z7NSdR76eDkaJKZJFkkjuvWxbP2_qoOgf_w.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Reggae One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2021-11-04", + "files": { + "regular": "http://fonts.gstatic.com/s/reggaeone/v9/~CgwKClJlZ2dhZSBPbmUgACoECAEYAQ==.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Revalia", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/revalia/v18/WwkexPimBE2-4ZPEeVruNIgJSNM.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Rhodium Libre", + "variants": [ + "regular" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/rhodiumlibre/v15/1q2AY5adA0tn_ukeHcQHqpx6pETLeo2gm2U.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ribeye", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/ribeye/v19/L0x8DFMxk1MP9R3RvPCmRSlUig.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Ribeye Marrow", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/ribeyemarrow/v20/GFDsWApshnqMRO2JdtRZ2d0vEAwTVWgKdtw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Righteous", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/righteous/v9/1cXxaUPXBpj2rGoU7C9mj3uEicG01A.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Risque", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/risque/v18/VdGfAZUfHosahXxoCUYVBJ-T5g.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Road Rage", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/roadrage/v3/6NUU8F2fKAOBKjjr4ekvtMYAwdRZfw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Roboto", + "variants": [ + "100", + "100italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "700", + "700italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v29", + "lastModified": "2021-09-22", + "files": { + "100": "http://fonts.gstatic.com/s/roboto/v29/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf", + "100italic": "http://fonts.gstatic.com/s/roboto/v29/KFOiCnqEu92Fr1Mu51QrIzcXLsnzjYk.ttf", + "300": "http://fonts.gstatic.com/s/roboto/v29/KFOlCnqEu92Fr1MmSU5vAx05IsDqlA.ttf", + "300italic": "http://fonts.gstatic.com/s/roboto/v29/KFOjCnqEu92Fr1Mu51TjARc9AMX6lJBP.ttf", + "regular": "http://fonts.gstatic.com/s/roboto/v29/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf", + "italic": "http://fonts.gstatic.com/s/roboto/v29/KFOkCnqEu92Fr1Mu52xPKTM1K9nz.ttf", + "500": "http://fonts.gstatic.com/s/roboto/v29/KFOlCnqEu92Fr1MmEU9vAx05IsDqlA.ttf", + "500italic": "http://fonts.gstatic.com/s/roboto/v29/KFOjCnqEu92Fr1Mu51S7ABc9AMX6lJBP.ttf", + "700": "http://fonts.gstatic.com/s/roboto/v29/KFOlCnqEu92Fr1MmWUlvAx05IsDqlA.ttf", + "700italic": "http://fonts.gstatic.com/s/roboto/v29/KFOjCnqEu92Fr1Mu51TzBhc9AMX6lJBP.ttf", + "900": "http://fonts.gstatic.com/s/roboto/v29/KFOlCnqEu92Fr1MmYUtvAx05IsDqlA.ttf", + "900italic": "http://fonts.gstatic.com/s/roboto/v29/KFOjCnqEu92Fr1Mu51TLBBc9AMX6lJBP.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Roboto Condensed", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v24", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/robotocondensed/v24/ieVi2ZhZI2eCN5jzbjEETS9weq8-33mZKCMSbvtdYyQ.ttf", + "300italic": "http://fonts.gstatic.com/s/robotocondensed/v24/ieVg2ZhZI2eCN5jzbjEETS9weq8-19eDpCEYatlYcyRi4A.ttf", + "regular": "http://fonts.gstatic.com/s/robotocondensed/v24/ieVl2ZhZI2eCN5jzbjEETS9weq8-59WxDCs5cvI.ttf", + "italic": "http://fonts.gstatic.com/s/robotocondensed/v24/ieVj2ZhZI2eCN5jzbjEETS9weq8-19e7CAk8YvJEeg.ttf", + "700": "http://fonts.gstatic.com/s/robotocondensed/v24/ieVi2ZhZI2eCN5jzbjEETS9weq8-32meKCMSbvtdYyQ.ttf", + "700italic": "http://fonts.gstatic.com/s/robotocondensed/v24/ieVg2ZhZI2eCN5jzbjEETS9weq8-19eDtCYYatlYcyRi4A.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Roboto Mono", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2021-01-30", + "files": { + "100": "http://fonts.gstatic.com/s/robotomono/v13/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vuPQ--5Ip2sSQ.ttf", + "200": "http://fonts.gstatic.com/s/robotomono/v13/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_XvqPQ--5Ip2sSQ.ttf", + "300": "http://fonts.gstatic.com/s/robotomono/v13/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_gPqPQ--5Ip2sSQ.ttf", + "regular": "http://fonts.gstatic.com/s/robotomono/v13/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vqPQ--5Ip2sSQ.ttf", + "500": "http://fonts.gstatic.com/s/robotomono/v13/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_7PqPQ--5Ip2sSQ.ttf", + "600": "http://fonts.gstatic.com/s/robotomono/v13/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_AP2PQ--5Ip2sSQ.ttf", + "700": "http://fonts.gstatic.com/s/robotomono/v13/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_Of2PQ--5Ip2sSQ.ttf", + "100italic": "http://fonts.gstatic.com/s/robotomono/v13/L0xoDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAeW9AJi8SZwt.ttf", + "200italic": "http://fonts.gstatic.com/s/robotomono/v13/L0xoDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrnnAOW9AJi8SZwt.ttf", + "300italic": "http://fonts.gstatic.com/s/robotomono/v13/L0xoDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrk5AOW9AJi8SZwt.ttf", + "italic": "http://fonts.gstatic.com/s/robotomono/v13/L0xoDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAOW9AJi8SZwt.ttf", + "500italic": "http://fonts.gstatic.com/s/robotomono/v13/L0xoDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlVAOW9AJi8SZwt.ttf", + "600italic": "http://fonts.gstatic.com/s/robotomono/v13/L0xoDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrm5B-W9AJi8SZwt.ttf", + "700italic": "http://fonts.gstatic.com/s/robotomono/v13/L0xoDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrmAB-W9AJi8SZwt.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Roboto Slab", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/robotoslab/v22/BngbUXZYTXPIvIBgJJSb6s3BzlRRfKOFbvjojIWWaG5iddG-1A.ttf", + "200": "http://fonts.gstatic.com/s/robotoslab/v22/BngbUXZYTXPIvIBgJJSb6s3BzlRRfKOFbvjoDISWaG5iddG-1A.ttf", + "300": "http://fonts.gstatic.com/s/robotoslab/v22/BngbUXZYTXPIvIBgJJSb6s3BzlRRfKOFbvjo0oSWaG5iddG-1A.ttf", + "regular": "http://fonts.gstatic.com/s/robotoslab/v22/BngbUXZYTXPIvIBgJJSb6s3BzlRRfKOFbvjojISWaG5iddG-1A.ttf", + "500": "http://fonts.gstatic.com/s/robotoslab/v22/BngbUXZYTXPIvIBgJJSb6s3BzlRRfKOFbvjovoSWaG5iddG-1A.ttf", + "600": "http://fonts.gstatic.com/s/robotoslab/v22/BngbUXZYTXPIvIBgJJSb6s3BzlRRfKOFbvjoUoOWaG5iddG-1A.ttf", + "700": "http://fonts.gstatic.com/s/robotoslab/v22/BngbUXZYTXPIvIBgJJSb6s3BzlRRfKOFbvjoa4OWaG5iddG-1A.ttf", + "800": "http://fonts.gstatic.com/s/robotoslab/v22/BngbUXZYTXPIvIBgJJSb6s3BzlRRfKOFbvjoDIOWaG5iddG-1A.ttf", + "900": "http://fonts.gstatic.com/s/robotoslab/v22/BngbUXZYTXPIvIBgJJSb6s3BzlRRfKOFbvjoJYOWaG5iddG-1A.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rochester", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/rochester/v16/6ae-4KCqVa4Zy6Fif-Uy31vWNTMwoQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Rock 3D", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/rock3d/v5/yYLp0hrL0PCo651513SnwRnQyNI.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Rock Salt", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/rocksalt/v16/MwQ0bhv11fWD6QsAVOZbsEk7hbBWrA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "RocknRoll One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2021-11-04", + "files": { + "regular": "http://fonts.gstatic.com/s/rocknrollone/v7/kmK7ZqspGAfCeUiW6FFlmEC9guVhs7tfUxc.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rokkitt", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v27", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/rokkitt/v27/qFdb35qfgYFjGy5hukqqhw5XeRgdi1rydpDLE76HvN6n.ttf", + "200": "http://fonts.gstatic.com/s/rokkitt/v27/qFdb35qfgYFjGy5hukqqhw5XeRgdi1pyd5DLE76HvN6n.ttf", + "300": "http://fonts.gstatic.com/s/rokkitt/v27/qFdb35qfgYFjGy5hukqqhw5XeRgdi1qsd5DLE76HvN6n.ttf", + "regular": "http://fonts.gstatic.com/s/rokkitt/v27/qFdb35qfgYFjGy5hukqqhw5XeRgdi1ryd5DLE76HvN6n.ttf", + "500": "http://fonts.gstatic.com/s/rokkitt/v27/qFdb35qfgYFjGy5hukqqhw5XeRgdi1rAd5DLE76HvN6n.ttf", + "600": "http://fonts.gstatic.com/s/rokkitt/v27/qFdb35qfgYFjGy5hukqqhw5XeRgdi1oscJDLE76HvN6n.ttf", + "700": "http://fonts.gstatic.com/s/rokkitt/v27/qFdb35qfgYFjGy5hukqqhw5XeRgdi1oVcJDLE76HvN6n.ttf", + "800": "http://fonts.gstatic.com/s/rokkitt/v27/qFdb35qfgYFjGy5hukqqhw5XeRgdi1pycJDLE76HvN6n.ttf", + "900": "http://fonts.gstatic.com/s/rokkitt/v27/qFdb35qfgYFjGy5hukqqhw5XeRgdi1pbcJDLE76HvN6n.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Romanesco", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/romanesco/v19/w8gYH2ozQOY7_r_J7mSn3HwLqOqSBg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Ropa Sans", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/ropasans/v13/EYqxmaNOzLlWtsZSScyKWjloU5KP2g.ttf", + "italic": "http://fonts.gstatic.com/s/ropasans/v13/EYq3maNOzLlWtsZSScy6WDNscZef2mNE.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rosario", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "300italic", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v25", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/rosario/v25/xfuu0WDhWW_fOEoY8l_VPNZfB7jPM69GCWczd-YnOzUD.ttf", + "regular": "http://fonts.gstatic.com/s/rosario/v25/xfuu0WDhWW_fOEoY8l_VPNZfB7jPM68YCWczd-YnOzUD.ttf", + "500": "http://fonts.gstatic.com/s/rosario/v25/xfuu0WDhWW_fOEoY8l_VPNZfB7jPM68qCWczd-YnOzUD.ttf", + "600": "http://fonts.gstatic.com/s/rosario/v25/xfuu0WDhWW_fOEoY8l_VPNZfB7jPM6_GDmczd-YnOzUD.ttf", + "700": "http://fonts.gstatic.com/s/rosario/v25/xfuu0WDhWW_fOEoY8l_VPNZfB7jPM6__Dmczd-YnOzUD.ttf", + "300italic": "http://fonts.gstatic.com/s/rosario/v25/xfug0WDhWW_fOEoY2Fbnww42bCJhNLrQStFwfeIFPiUDn08.ttf", + "italic": "http://fonts.gstatic.com/s/rosario/v25/xfug0WDhWW_fOEoY2Fbnww42bCJhNLrQSo9wfeIFPiUDn08.ttf", + "500italic": "http://fonts.gstatic.com/s/rosario/v25/xfug0WDhWW_fOEoY2Fbnww42bCJhNLrQSr1wfeIFPiUDn08.ttf", + "600italic": "http://fonts.gstatic.com/s/rosario/v25/xfug0WDhWW_fOEoY2Fbnww42bCJhNLrQSlF3feIFPiUDn08.ttf", + "700italic": "http://fonts.gstatic.com/s/rosario/v25/xfug0WDhWW_fOEoY2Fbnww42bCJhNLrQSmh3feIFPiUDn08.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rosarivo", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/rosarivo/v18/PlI-Fl2lO6N9f8HaNAeC2nhMnNy5.ttf", + "italic": "http://fonts.gstatic.com/s/rosarivo/v18/PlI4Fl2lO6N9f8HaNDeA0Hxumcy5ZX8.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rouge Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/rougescript/v12/LYjFdGbiklMoCIQOw1Ep3S4PVPXbUJWq9g.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Rowdies", + "variants": [ + "300", + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2022-01-05", + "files": { + "300": "http://fonts.gstatic.com/s/rowdies/v13/ptRMTieMYPNBAK219hth5O7yKQNute8.ttf", + "regular": "http://fonts.gstatic.com/s/rowdies/v13/ptRJTieMYPNBAK21zrdJwObZNQo.ttf", + "700": "http://fonts.gstatic.com/s/rowdies/v13/ptRMTieMYPNBAK219gtm5O7yKQNute8.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Rozha One", + "variants": [ + "regular" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/rozhaone/v11/AlZy_zVFtYP12Zncg2khdXf4XB0Tow.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rubik", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "hebrew", + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/rubik/v18/iJWZBXyIfDnIV5PNhY1KTN7Z-Yh-WYi1UE80V4bVkA.ttf", + "regular": "http://fonts.gstatic.com/s/rubik/v18/iJWZBXyIfDnIV5PNhY1KTN7Z-Yh-B4i1UE80V4bVkA.ttf", + "500": "http://fonts.gstatic.com/s/rubik/v18/iJWZBXyIfDnIV5PNhY1KTN7Z-Yh-NYi1UE80V4bVkA.ttf", + "600": "http://fonts.gstatic.com/s/rubik/v18/iJWZBXyIfDnIV5PNhY1KTN7Z-Yh-2Y-1UE80V4bVkA.ttf", + "700": "http://fonts.gstatic.com/s/rubik/v18/iJWZBXyIfDnIV5PNhY1KTN7Z-Yh-4I-1UE80V4bVkA.ttf", + "800": "http://fonts.gstatic.com/s/rubik/v18/iJWZBXyIfDnIV5PNhY1KTN7Z-Yh-h4-1UE80V4bVkA.ttf", + "900": "http://fonts.gstatic.com/s/rubik/v18/iJWZBXyIfDnIV5PNhY1KTN7Z-Yh-ro-1UE80V4bVkA.ttf", + "300italic": "http://fonts.gstatic.com/s/rubik/v18/iJWbBXyIfDnIV7nEt3KSJbVDV49rz8sDE0UwdYPFkJ1O.ttf", + "italic": "http://fonts.gstatic.com/s/rubik/v18/iJWbBXyIfDnIV7nEt3KSJbVDV49rz8tdE0UwdYPFkJ1O.ttf", + "500italic": "http://fonts.gstatic.com/s/rubik/v18/iJWbBXyIfDnIV7nEt3KSJbVDV49rz8tvE0UwdYPFkJ1O.ttf", + "600italic": "http://fonts.gstatic.com/s/rubik/v18/iJWbBXyIfDnIV7nEt3KSJbVDV49rz8uDFEUwdYPFkJ1O.ttf", + "700italic": "http://fonts.gstatic.com/s/rubik/v18/iJWbBXyIfDnIV7nEt3KSJbVDV49rz8u6FEUwdYPFkJ1O.ttf", + "800italic": "http://fonts.gstatic.com/s/rubik/v18/iJWbBXyIfDnIV7nEt3KSJbVDV49rz8vdFEUwdYPFkJ1O.ttf", + "900italic": "http://fonts.gstatic.com/s/rubik/v18/iJWbBXyIfDnIV7nEt3KSJbVDV49rz8v0FEUwdYPFkJ1O.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rubik Beastly", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "hebrew", + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/rubikbeastly/v5/0QImMXRd5oOmSC2ZQ7o9653X07z8_ApHqqk.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Rubik Mono One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/rubikmonoone/v12/UqyJK8kPP3hjw6ANTdfRk9YSN-8wRqQrc_j9.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ruda", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v21", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/ruda/v21/k3kKo8YQJOpFgHQ1mQ5VkEbUKaJFsi_-2KiSGg-H.ttf", + "500": "http://fonts.gstatic.com/s/ruda/v21/k3kKo8YQJOpFgHQ1mQ5VkEbUKaJ3si_-2KiSGg-H.ttf", + "600": "http://fonts.gstatic.com/s/ruda/v21/k3kKo8YQJOpFgHQ1mQ5VkEbUKaKbtS_-2KiSGg-H.ttf", + "700": "http://fonts.gstatic.com/s/ruda/v21/k3kKo8YQJOpFgHQ1mQ5VkEbUKaKitS_-2KiSGg-H.ttf", + "800": "http://fonts.gstatic.com/s/ruda/v21/k3kKo8YQJOpFgHQ1mQ5VkEbUKaLFtS_-2KiSGg-H.ttf", + "900": "http://fonts.gstatic.com/s/ruda/v21/k3kKo8YQJOpFgHQ1mQ5VkEbUKaLstS_-2KiSGg-H.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rufina", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/rufina/v11/Yq6V-LyURyLy-aKyoxRktOdClg.ttf", + "700": "http://fonts.gstatic.com/s/rufina/v11/Yq6W-LyURyLy-aKKHztAvMxenxE0SA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ruge Boogie", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/rugeboogie/v22/JIA3UVFwbHRF_GIWSMhKNROiPzUveSxy.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Ruluko", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/ruluko/v19/xMQVuFNZVaODtm0pC6WzKX_QmA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Rum Raisin", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/rumraisin/v18/nwpRtKu3Ih8D5avB4h2uJ3-IywA7eMM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ruslan Display", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/ruslandisplay/v13/Gw6jwczl81XcIZuckK_e3UpfdzxrldyFvm1n.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Russo One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/russoone/v13/Z9XUDmZRWg6M1LvRYsH-yMOInrib9Q.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ruthie", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/ruthie/v22/gokvH63sGkdqXuU9lD53Q2u_mQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Rye", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/rye/v11/r05XGLJT86YDFpTsXOqx4w.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "STIX Two Text", + "variants": [ + "regular", + "500", + "600", + "700", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v7", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/stixtwotext/v7/YA9Gr02F12Xkf5whdwKf11l0jbKkeidMTtZ5Yihg2SOYWxFMN1WD.ttf", + "500": "http://fonts.gstatic.com/s/stixtwotext/v7/YA9Gr02F12Xkf5whdwKf11l0jbKkeidMTtZ5YihS2SOYWxFMN1WD.ttf", + "600": "http://fonts.gstatic.com/s/stixtwotext/v7/YA9Gr02F12Xkf5whdwKf11l0jbKkeidMTtZ5Yii-3iOYWxFMN1WD.ttf", + "700": "http://fonts.gstatic.com/s/stixtwotext/v7/YA9Gr02F12Xkf5whdwKf11l0jbKkeidMTtZ5YiiH3iOYWxFMN1WD.ttf", + "italic": "http://fonts.gstatic.com/s/stixtwotext/v7/YA9Er02F12Xkf5whdwKf11l0p7uWhf8lJUzXZT2omsvbURVuMkWDmSo.ttf", + "500italic": "http://fonts.gstatic.com/s/stixtwotext/v7/YA9Er02F12Xkf5whdwKf11l0p7uWhf8lJUzXZT2omvnbURVuMkWDmSo.ttf", + "600italic": "http://fonts.gstatic.com/s/stixtwotext/v7/YA9Er02F12Xkf5whdwKf11l0p7uWhf8lJUzXZT2omhXcURVuMkWDmSo.ttf", + "700italic": "http://fonts.gstatic.com/s/stixtwotext/v7/YA9Er02F12Xkf5whdwKf11l0p7uWhf8lJUzXZT2omizcURVuMkWDmSo.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sacramento", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/sacramento/v12/buEzpo6gcdjy0EiZMBUG0CoV_NxLeiw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Sahitya", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/sahitya/v15/6qLAKZkOuhnuqlJAaScFPywEDnI.ttf", + "700": "http://fonts.gstatic.com/s/sahitya/v15/6qLFKZkOuhnuqlJAUZsqGyQvEnvSexI.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sail", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/sail/v14/DPEjYwiBxwYJFBTDADYAbvw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Saira", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v8", + "lastModified": "2021-09-16", + "files": { + "100": "http://fonts.gstatic.com/s/saira/v8/memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA71rDosg7lwYmUVY.ttf", + "200": "http://fonts.gstatic.com/s/saira/v8/memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA79rCosg7lwYmUVY.ttf", + "300": "http://fonts.gstatic.com/s/saira/v8/memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA7wTCosg7lwYmUVY.ttf", + "regular": "http://fonts.gstatic.com/s/saira/v8/memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA71rCosg7lwYmUVY.ttf", + "500": "http://fonts.gstatic.com/s/saira/v8/memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA72jCosg7lwYmUVY.ttf", + "600": "http://fonts.gstatic.com/s/saira/v8/memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA74TFosg7lwYmUVY.ttf", + "700": "http://fonts.gstatic.com/s/saira/v8/memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA773Fosg7lwYmUVY.ttf", + "800": "http://fonts.gstatic.com/s/saira/v8/memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA79rFosg7lwYmUVY.ttf", + "900": "http://fonts.gstatic.com/s/saira/v8/memWYa2wxmKQyPMrZX79wwYZQMhsyuShhKMjjbU9uXuA7_PFosg7lwYmUVY.ttf", + "100italic": "http://fonts.gstatic.com/s/saira/v8/memUYa2wxmKQyNkiV50dulWP7s95AqZTzZHcVdxWI9WH-pKBSooxkyQjQVYmxA.ttf", + "200italic": "http://fonts.gstatic.com/s/saira/v8/memUYa2wxmKQyNkiV50dulWP7s95AqZTzZHcVdxWI9WH-pKByosxkyQjQVYmxA.ttf", + "300italic": "http://fonts.gstatic.com/s/saira/v8/memUYa2wxmKQyNkiV50dulWP7s95AqZTzZHcVdxWI9WH-pKBFIsxkyQjQVYmxA.ttf", + "italic": "http://fonts.gstatic.com/s/saira/v8/memUYa2wxmKQyNkiV50dulWP7s95AqZTzZHcVdxWI9WH-pKBSosxkyQjQVYmxA.ttf", + "500italic": "http://fonts.gstatic.com/s/saira/v8/memUYa2wxmKQyNkiV50dulWP7s95AqZTzZHcVdxWI9WH-pKBeIsxkyQjQVYmxA.ttf", + "600italic": "http://fonts.gstatic.com/s/saira/v8/memUYa2wxmKQyNkiV50dulWP7s95AqZTzZHcVdxWI9WH-pKBlIwxkyQjQVYmxA.ttf", + "700italic": "http://fonts.gstatic.com/s/saira/v8/memUYa2wxmKQyNkiV50dulWP7s95AqZTzZHcVdxWI9WH-pKBrYwxkyQjQVYmxA.ttf", + "800italic": "http://fonts.gstatic.com/s/saira/v8/memUYa2wxmKQyNkiV50dulWP7s95AqZTzZHcVdxWI9WH-pKByowxkyQjQVYmxA.ttf", + "900italic": "http://fonts.gstatic.com/s/saira/v8/memUYa2wxmKQyNkiV50dulWP7s95AqZTzZHcVdxWI9WH-pKB44wxkyQjQVYmxA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Saira Condensed", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/sairacondensed/v10/EJRMQgErUN8XuHNEtX81i9TmEkrnwetA2omSrzS8.ttf", + "200": "http://fonts.gstatic.com/s/sairacondensed/v10/EJRLQgErUN8XuHNEtX81i9TmEkrnbcpg8Keepi2lHw.ttf", + "300": "http://fonts.gstatic.com/s/sairacondensed/v10/EJRLQgErUN8XuHNEtX81i9TmEkrnCclg8Keepi2lHw.ttf", + "regular": "http://fonts.gstatic.com/s/sairacondensed/v10/EJROQgErUN8XuHNEtX81i9TmEkrfpeFE-IyCrw.ttf", + "500": "http://fonts.gstatic.com/s/sairacondensed/v10/EJRLQgErUN8XuHNEtX81i9TmEkrnUchg8Keepi2lHw.ttf", + "600": "http://fonts.gstatic.com/s/sairacondensed/v10/EJRLQgErUN8XuHNEtX81i9TmEkrnfc9g8Keepi2lHw.ttf", + "700": "http://fonts.gstatic.com/s/sairacondensed/v10/EJRLQgErUN8XuHNEtX81i9TmEkrnGc5g8Keepi2lHw.ttf", + "800": "http://fonts.gstatic.com/s/sairacondensed/v10/EJRLQgErUN8XuHNEtX81i9TmEkrnBc1g8Keepi2lHw.ttf", + "900": "http://fonts.gstatic.com/s/sairacondensed/v10/EJRLQgErUN8XuHNEtX81i9TmEkrnIcxg8Keepi2lHw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Saira Extra Condensed", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-13", + "files": { + "100": "http://fonts.gstatic.com/s/sairaextracondensed/v9/-nFsOHYr-vcC7h8MklGBkrvmUG9rbpkisrTri0jx9i5ss3a3.ttf", + "200": "http://fonts.gstatic.com/s/sairaextracondensed/v9/-nFvOHYr-vcC7h8MklGBkrvmUG9rbpkisrTrJ2nR3ABgum-uoQ.ttf", + "300": "http://fonts.gstatic.com/s/sairaextracondensed/v9/-nFvOHYr-vcC7h8MklGBkrvmUG9rbpkisrTrQ2rR3ABgum-uoQ.ttf", + "regular": "http://fonts.gstatic.com/s/sairaextracondensed/v9/-nFiOHYr-vcC7h8MklGBkrvmUG9rbpkisrTT70L11Ct8sw.ttf", + "500": "http://fonts.gstatic.com/s/sairaextracondensed/v9/-nFvOHYr-vcC7h8MklGBkrvmUG9rbpkisrTrG2vR3ABgum-uoQ.ttf", + "600": "http://fonts.gstatic.com/s/sairaextracondensed/v9/-nFvOHYr-vcC7h8MklGBkrvmUG9rbpkisrTrN2zR3ABgum-uoQ.ttf", + "700": "http://fonts.gstatic.com/s/sairaextracondensed/v9/-nFvOHYr-vcC7h8MklGBkrvmUG9rbpkisrTrU23R3ABgum-uoQ.ttf", + "800": "http://fonts.gstatic.com/s/sairaextracondensed/v9/-nFvOHYr-vcC7h8MklGBkrvmUG9rbpkisrTrT27R3ABgum-uoQ.ttf", + "900": "http://fonts.gstatic.com/s/sairaextracondensed/v9/-nFvOHYr-vcC7h8MklGBkrvmUG9rbpkisrTra2_R3ABgum-uoQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Saira Semi Condensed", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/sairasemicondensed/v9/U9MN6c-2-nnJkHxyCjRcnMHcWVWV1cWRRXdvaOM8rXT-8V8.ttf", + "200": "http://fonts.gstatic.com/s/sairasemicondensed/v9/U9MM6c-2-nnJkHxyCjRcnMHcWVWV1cWRRXfDScMWg3j36Ebz.ttf", + "300": "http://fonts.gstatic.com/s/sairasemicondensed/v9/U9MM6c-2-nnJkHxyCjRcnMHcWVWV1cWRRXenSsMWg3j36Ebz.ttf", + "regular": "http://fonts.gstatic.com/s/sairasemicondensed/v9/U9MD6c-2-nnJkHxyCjRcnMHcWVWV1cWRRU8LYuceqGT-.ttf", + "500": "http://fonts.gstatic.com/s/sairasemicondensed/v9/U9MM6c-2-nnJkHxyCjRcnMHcWVWV1cWRRXf_S8MWg3j36Ebz.ttf", + "600": "http://fonts.gstatic.com/s/sairasemicondensed/v9/U9MM6c-2-nnJkHxyCjRcnMHcWVWV1cWRRXfTTMMWg3j36Ebz.ttf", + "700": "http://fonts.gstatic.com/s/sairasemicondensed/v9/U9MM6c-2-nnJkHxyCjRcnMHcWVWV1cWRRXe3TcMWg3j36Ebz.ttf", + "800": "http://fonts.gstatic.com/s/sairasemicondensed/v9/U9MM6c-2-nnJkHxyCjRcnMHcWVWV1cWRRXerTsMWg3j36Ebz.ttf", + "900": "http://fonts.gstatic.com/s/sairasemicondensed/v9/U9MM6c-2-nnJkHxyCjRcnMHcWVWV1cWRRXePT8MWg3j36Ebz.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Saira Stencil One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/sairastencilone/v12/SLXSc03I6HkvZGJ1GvvipLoYSTEL9AsMawif2YQ2.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Salsa", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/salsa/v15/gNMKW3FiRpKj-imY8ncKEZez.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sanchez", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sanchez/v11/Ycm2sZJORluHnXbITm5b_BwE1l0.ttf", + "italic": "http://fonts.gstatic.com/s/sanchez/v11/Ycm0sZJORluHnXbIfmxR-D4Bxl3gkw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sancreek", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v21", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/sancreek/v21/pxiHypAnsdxUm159X7D-XV9NEe-K.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sansita", + "variants": [ + "regular", + "italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sansita/v8/QldONTRRphEb_-V7HBm7TXFf3qw.ttf", + "italic": "http://fonts.gstatic.com/s/sansita/v8/QldMNTRRphEb_-V7LBuxSVNazqx2xg.ttf", + "700": "http://fonts.gstatic.com/s/sansita/v8/QldLNTRRphEb_-V7JKWUaXl0wqVv3_g.ttf", + "700italic": "http://fonts.gstatic.com/s/sansita/v8/QldJNTRRphEb_-V7LBuJ9Xx-xodqz_joDQ.ttf", + "800": "http://fonts.gstatic.com/s/sansita/v8/QldLNTRRphEb_-V7JLmXaXl0wqVv3_g.ttf", + "800italic": "http://fonts.gstatic.com/s/sansita/v8/QldJNTRRphEb_-V7LBuJ6X9-xodqz_joDQ.ttf", + "900": "http://fonts.gstatic.com/s/sansita/v8/QldLNTRRphEb_-V7JJ2WaXl0wqVv3_g.ttf", + "900italic": "http://fonts.gstatic.com/s/sansita/v8/QldJNTRRphEb_-V7LBuJzX5-xodqz_joDQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sansita Swashed", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/sansitaswashed/v15/BXR8vFfZifTZgFlDDLgNkBydPKTt3pVCeYWqJnZSW-ppbToVehmEa4Q.ttf", + "regular": "http://fonts.gstatic.com/s/sansitaswashed/v15/BXR8vFfZifTZgFlDDLgNkBydPKTt3pVCeYWqJnZSW7RpbToVehmEa4Q.ttf", + "500": "http://fonts.gstatic.com/s/sansitaswashed/v15/BXR8vFfZifTZgFlDDLgNkBydPKTt3pVCeYWqJnZSW4ZpbToVehmEa4Q.ttf", + "600": "http://fonts.gstatic.com/s/sansitaswashed/v15/BXR8vFfZifTZgFlDDLgNkBydPKTt3pVCeYWqJnZSW2pubToVehmEa4Q.ttf", + "700": "http://fonts.gstatic.com/s/sansitaswashed/v15/BXR8vFfZifTZgFlDDLgNkBydPKTt3pVCeYWqJnZSW1NubToVehmEa4Q.ttf", + "800": "http://fonts.gstatic.com/s/sansitaswashed/v15/BXR8vFfZifTZgFlDDLgNkBydPKTt3pVCeYWqJnZSWzRubToVehmEa4Q.ttf", + "900": "http://fonts.gstatic.com/s/sansitaswashed/v15/BXR8vFfZifTZgFlDDLgNkBydPKTt3pVCeYWqJnZSWx1ubToVehmEa4Q.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sarabun", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v12", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/sarabun/v12/DtVhJx26TKEr37c9YHZJmnYI5gnOpg.ttf", + "100italic": "http://fonts.gstatic.com/s/sarabun/v12/DtVnJx26TKEr37c9aBBx_nwMxAzephhN.ttf", + "200": "http://fonts.gstatic.com/s/sarabun/v12/DtVmJx26TKEr37c9YNpoulwm6gDXvwE.ttf", + "200italic": "http://fonts.gstatic.com/s/sarabun/v12/DtVkJx26TKEr37c9aBBxUl0s7iLSrwFUlw.ttf", + "300": "http://fonts.gstatic.com/s/sarabun/v12/DtVmJx26TKEr37c9YL5rulwm6gDXvwE.ttf", + "300italic": "http://fonts.gstatic.com/s/sarabun/v12/DtVkJx26TKEr37c9aBBxNl4s7iLSrwFUlw.ttf", + "regular": "http://fonts.gstatic.com/s/sarabun/v12/DtVjJx26TKEr37c9WBJDnlQN9gk.ttf", + "italic": "http://fonts.gstatic.com/s/sarabun/v12/DtVhJx26TKEr37c9aBBJmnYI5gnOpg.ttf", + "500": "http://fonts.gstatic.com/s/sarabun/v12/DtVmJx26TKEr37c9YOZqulwm6gDXvwE.ttf", + "500italic": "http://fonts.gstatic.com/s/sarabun/v12/DtVkJx26TKEr37c9aBBxbl8s7iLSrwFUlw.ttf", + "600": "http://fonts.gstatic.com/s/sarabun/v12/DtVmJx26TKEr37c9YMptulwm6gDXvwE.ttf", + "600italic": "http://fonts.gstatic.com/s/sarabun/v12/DtVkJx26TKEr37c9aBBxQlgs7iLSrwFUlw.ttf", + "700": "http://fonts.gstatic.com/s/sarabun/v12/DtVmJx26TKEr37c9YK5sulwm6gDXvwE.ttf", + "700italic": "http://fonts.gstatic.com/s/sarabun/v12/DtVkJx26TKEr37c9aBBxJlks7iLSrwFUlw.ttf", + "800": "http://fonts.gstatic.com/s/sarabun/v12/DtVmJx26TKEr37c9YLJvulwm6gDXvwE.ttf", + "800italic": "http://fonts.gstatic.com/s/sarabun/v12/DtVkJx26TKEr37c9aBBxOlos7iLSrwFUlw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sarala", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sarala/v8/uK_y4riEZv4o1w9RCh0TMv6EXw.ttf", + "700": "http://fonts.gstatic.com/s/sarala/v8/uK_x4riEZv4o1w9ptjI3OtWYVkMpXA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sarina", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/sarina/v19/-F6wfjF3ITQwasLhLkDUriBQxw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sarpanch", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/sarpanch/v9/hESy6Xt4NCpRuk6Pzh2ARIrX_20n.ttf", + "500": "http://fonts.gstatic.com/s/sarpanch/v9/hES16Xt4NCpRuk6PziV0ba7f1HEuRHkM.ttf", + "600": "http://fonts.gstatic.com/s/sarpanch/v9/hES16Xt4NCpRuk6PziVYaq7f1HEuRHkM.ttf", + "700": "http://fonts.gstatic.com/s/sarpanch/v9/hES16Xt4NCpRuk6PziU8a67f1HEuRHkM.ttf", + "800": "http://fonts.gstatic.com/s/sarpanch/v9/hES16Xt4NCpRuk6PziUgaK7f1HEuRHkM.ttf", + "900": "http://fonts.gstatic.com/s/sarpanch/v9/hES16Xt4NCpRuk6PziUEaa7f1HEuRHkM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sassy Frass", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/sassyfrass/v3/LhWhMVrGOe0FLb97BjhsE99dGNWQg_am.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Satisfy", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/satisfy/v11/rP2Hp2yn6lkG50LoOZSCHBeHFl0.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Sawarabi Gothic", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sawarabigothic/v11/x3d4ckfVaqqa-BEj-I9mE65u3k3NBSk3E2YljQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sawarabi Mincho", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sawarabimincho/v15/8QIRdiDaitzr7brc8ahpxt6GcIJTLahP46UDUw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Scada", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/scada/v12/RLpxK5Pv5qumeWJoxzUobkvv.ttf", + "italic": "http://fonts.gstatic.com/s/scada/v12/RLp_K5Pv5qumeVJqzTEKa1vvffg.ttf", + "700": "http://fonts.gstatic.com/s/scada/v12/RLp8K5Pv5qumeVrU6BEgRVfmZOE5.ttf", + "700italic": "http://fonts.gstatic.com/s/scada/v12/RLp6K5Pv5qumeVJq9Y0lT1PEYfE5p6g.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Scheherazade New", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "arabic", + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/scheherazadenew/v8/4UaZrFhTvxVnHDvUkUiHg8jprP4DCwNsOl4p5Is.ttf", + "700": "http://fonts.gstatic.com/s/scheherazadenew/v8/4UaerFhTvxVnHDvUkUiHg8jprP4DM79DHlYC-IKnoSE.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Schoolbell", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/schoolbell/v11/92zQtBZWOrcgoe-fgnJIVxIQ6mRqfiQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Scope One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/scopeone/v12/WBLnrEXKYFlGHrOKmGD1W0_MJMGxiQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Seaweed Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/seaweedscript/v11/bx6cNx6Tne2pxOATYE8C_Rsoe0WJ-KcGVbLW.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Secular One", + "variants": [ + "regular" + ], + "subsets": [ + "hebrew", + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/secularone/v9/8QINdiTajsj_87rMuMdKypDlMul7LJpK.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sedgwick Ave", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/sedgwickave/v10/uK_04rKEYuguzAcSYRdWTJq8Xmg1Vcf5JA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Sedgwick Ave Display", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/sedgwickavedisplay/v17/xfuu0XPgU3jZPUoUo3ScvmPi-NapQ8OxM2czd-YnOzUD.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Sen", + "variants": [ + "regular", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sen/v5/6xKjdSxYI9_Hm_-MImrpLQ.ttf", + "700": "http://fonts.gstatic.com/s/sen/v5/6xKudSxYI9__J9CoKkH1JHUQSQ.ttf", + "800": "http://fonts.gstatic.com/s/sen/v5/6xKudSxYI9__O9OoKkH1JHUQSQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sevillana", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/sevillana/v19/KFOlCnWFscmDt1Bfiy1vAx05IsDqlA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Seymour One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/seymourone/v18/4iCp6Khla9xbjQpoWGGd0myIPYBvgpUI.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Shadows Into Light", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/shadowsintolight/v14/UqyNK9UOIntux_czAvDQx_ZcHqZXBNQDcsr4xzSMYA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Shadows Into Light Two", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/shadowsintolighttwo/v11/4iC86LVlZsRSjQhpWGedwyOoW-0A6_kpsyNmlAvNGLNnIF0.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Shalimar", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/shalimar/v3/uU9MCBoE6I6iNWFUvTPx8PCOg0uX.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Shanti", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/shanti/v14/t5thIREMM4uSDgzgU0ezpKfwzA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Share", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/share/v14/i7dEIFliZjKNF5VNHLq2cV5d.ttf", + "italic": "http://fonts.gstatic.com/s/share/v14/i7dKIFliZjKNF6VPFr6UdE5dWFM.ttf", + "700": "http://fonts.gstatic.com/s/share/v14/i7dJIFliZjKNF63xM56-WkJUQUq7.ttf", + "700italic": "http://fonts.gstatic.com/s/share/v14/i7dPIFliZjKNF6VPLgK7UEZ2RFq7AwU.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Share Tech", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/sharetech/v15/7cHtv4Uyi5K0OeZ7bohUwHoDmTcibrA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Share Tech Mono", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sharetechmono/v13/J7aHnp1uDWRBEqV98dVQztYldFc7pAsEIc3Xew.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Shippori Antique", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext" + ], + "version": "v6", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/shipporiantique/v6/-F6qfid3KC8pdMyzR0qRyFUht11v8ldPg-IUDNg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Shippori Antique B1", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext" + ], + "version": "v6", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/shipporiantiqueb1/v6/2Eb7L_JwClR7Zl_UAKZ0mUHw3oMKd40grRFCj9-5Y8Y.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Shippori Mincho", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2021-11-04", + "files": { + "regular": "http://fonts.gstatic.com/s/shipporimincho/v11/VdGGAZweH5EbgHY6YExcZfDoj0BA2_-C7LoS7g.ttf", + "500": "http://fonts.gstatic.com/s/shipporimincho/v11/VdGDAZweH5EbgHY6YExcZfDoj0B4L9am5JEO5--2zg.ttf", + "600": "http://fonts.gstatic.com/s/shipporimincho/v11/VdGDAZweH5EbgHY6YExcZfDoj0B4A9Gm5JEO5--2zg.ttf", + "700": "http://fonts.gstatic.com/s/shipporimincho/v11/VdGDAZweH5EbgHY6YExcZfDoj0B4Z9Cm5JEO5--2zg.ttf", + "800": "http://fonts.gstatic.com/s/shipporimincho/v11/VdGDAZweH5EbgHY6YExcZfDoj0B4e9Om5JEO5--2zg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Shippori Mincho B1", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2021-11-04", + "files": { + "regular": "http://fonts.gstatic.com/s/shipporiminchob1/v14/~ChQKElNoaXBwb3JpIE1pbmNobyBCMSAAKgQIARgB.ttf", + "500": "http://fonts.gstatic.com/s/shipporiminchob1/v14/~ChcKElNoaXBwb3JpIE1pbmNobyBCMRj0AyAAKgQIARgB.ttf", + "600": "http://fonts.gstatic.com/s/shipporiminchob1/v14/~ChcKElNoaXBwb3JpIE1pbmNobyBCMRjYBCAAKgQIARgB.ttf", + "700": "http://fonts.gstatic.com/s/shipporiminchob1/v14/~ChcKElNoaXBwb3JpIE1pbmNobyBCMRi8BSAAKgQIARgB.ttf", + "800": "http://fonts.gstatic.com/s/shipporiminchob1/v14/~ChcKElNoaXBwb3JpIE1pbmNobyBCMRigBiAAKgQIARgB.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Shizuru", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/shizuru/v5/O4ZSFGfvnxFiCA3i30IJlgUTj2A.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Shojumaru", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/shojumaru/v13/rax_HiWfutkLLnaKCtlMBBJek0vA8A.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Short Stack", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/shortstack/v13/bMrzmS2X6p0jZC6EcmPFX-SScX8D0nq6.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Shrikhand", + "variants": [ + "regular" + ], + "subsets": [ + "gujarati", + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/shrikhand/v9/a8IbNovtLWfR7T7bMJwbBIiQ0zhMtA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Siemreap", + "variants": [ + "regular" + ], + "subsets": [ + "khmer" + ], + "version": "v15", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/siemreap/v15/Gg82N5oFbgLvHAfNl2YbnA8DLXpe.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sigmar One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/sigmarone/v15/co3DmWZ8kjZuErj9Ta3dk6Pjp3Di8U0.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Signika", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/signika/v18/vEFO2_JTCgwQ5ejvMV0O96D01E8J0tIJHJbGhs_cfKe1.ttf", + "regular": "http://fonts.gstatic.com/s/signika/v18/vEFO2_JTCgwQ5ejvMV0O96D01E8J0tJXHJbGhs_cfKe1.ttf", + "500": "http://fonts.gstatic.com/s/signika/v18/vEFO2_JTCgwQ5ejvMV0O96D01E8J0tJlHJbGhs_cfKe1.ttf", + "600": "http://fonts.gstatic.com/s/signika/v18/vEFO2_JTCgwQ5ejvMV0O96D01E8J0tKJG5bGhs_cfKe1.ttf", + "700": "http://fonts.gstatic.com/s/signika/v18/vEFO2_JTCgwQ5ejvMV0O96D01E8J0tKwG5bGhs_cfKe1.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Signika Negative", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/signikanegative/v18/E21x_cfngu7HiRpPX3ZpNE4kY5zKSPmJXkF0VDD2RAr5S73st9hiuEq8.ttf", + "regular": "http://fonts.gstatic.com/s/signikanegative/v18/E21x_cfngu7HiRpPX3ZpNE4kY5zKSPmJXkF0VDD2RAqnS73st9hiuEq8.ttf", + "500": "http://fonts.gstatic.com/s/signikanegative/v18/E21x_cfngu7HiRpPX3ZpNE4kY5zKSPmJXkF0VDD2RAqVS73st9hiuEq8.ttf", + "600": "http://fonts.gstatic.com/s/signikanegative/v18/E21x_cfngu7HiRpPX3ZpNE4kY5zKSPmJXkF0VDD2RAp5TL3st9hiuEq8.ttf", + "700": "http://fonts.gstatic.com/s/signikanegative/v18/E21x_cfngu7HiRpPX3ZpNE4kY5zKSPmJXkF0VDD2RApATL3st9hiuEq8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Simonetta", + "variants": [ + "regular", + "italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v21", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/simonetta/v21/x3dickHVYrCU5BU15c4BfPACvy_1BA.ttf", + "italic": "http://fonts.gstatic.com/s/simonetta/v21/x3dkckHVYrCU5BU15c4xfvoGnSrlBBsy.ttf", + "900": "http://fonts.gstatic.com/s/simonetta/v21/x3dnckHVYrCU5BU15c45-N0mtwTpDQIrGg.ttf", + "900italic": "http://fonts.gstatic.com/s/simonetta/v21/x3d5ckHVYrCU5BU15c4xfsKCsA7tLwc7Gn88.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Single Day", + "variants": [ + "regular" + ], + "subsets": [ + "korean" + ], + "version": "v13", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/singleday/v13/LYjHdGDjlEgoAcF95EI5jVoFUNfeQJU.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sintony", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sintony/v11/XoHm2YDqR7-98cVUITQnu98ojjs.ttf", + "700": "http://fonts.gstatic.com/s/sintony/v11/XoHj2YDqR7-98cVUGYgIn9cDkjLp6C8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sirin Stencil", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/sirinstencil/v19/mem4YaWwznmLx-lzGfN7MdRydchGBq6al6o.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Six Caps", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sixcaps/v14/6ae_4KGrU7VR7bNmabcS9XXaPCop.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Skranji", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/skranji/v11/OZpDg_dtriVFNerMYzuuklTm3Ek.ttf", + "700": "http://fonts.gstatic.com/s/skranji/v11/OZpGg_dtriVFNerMW4eBtlzNwED-b4g.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Slabo 13px", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/slabo13px/v11/11hEGp_azEvXZUdSBzzRcKer2wkYnvI.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Slabo 27px", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/slabo27px/v11/mFT0WbgBwKPR_Z4hGN2qsxgJ1EJ7i90.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Slackey", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/slackey/v13/N0bV2SdQO-5yM0-dKlRaJdbWgdY.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Smokum", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/smokum/v13/TK3iWkUbAhopmrdGHjUHte5fKg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Smooch", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/smooch/v3/o-0LIps4xW8U1xUBjqp_6hVdYg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Smythe", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v21", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/smythe/v21/MwQ3bhT01--coT1BOLh_uGInjA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sniglet", + "variants": [ + "regular", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/sniglet/v15/cIf9MaFLtkE3UjaJxCmrYGkHgIs.ttf", + "800": "http://fonts.gstatic.com/s/sniglet/v15/cIf4MaFLtkE3UjaJ_ImHRGEsnIJkWL4.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Snippet", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/snippet/v12/bWt47f7XfQH9Gupu2v_Afcp9QWc.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Snowburst One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/snowburstone/v18/MQpS-WezKdujBsXY3B7I-UT7eZ-UPyacPbo.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sofadi One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/sofadione/v19/JIA2UVBxdnVBuElZaMFGcDOIETkmYDU.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sofia", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/sofia/v12/8QIHdirahM3j_vu-sowsrqjk.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Solway", + "variants": [ + "300", + "regular", + "500", + "700", + "800" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/solway/v13/AMOTz46Cs2uTAOCuLlgZms0QW3mqyg.ttf", + "regular": "http://fonts.gstatic.com/s/solway/v13/AMOQz46Cs2uTAOCWgnA9kuYMUg.ttf", + "500": "http://fonts.gstatic.com/s/solway/v13/AMOTz46Cs2uTAOCudlkZms0QW3mqyg.ttf", + "700": "http://fonts.gstatic.com/s/solway/v13/AMOTz46Cs2uTAOCuPl8Zms0QW3mqyg.ttf", + "800": "http://fonts.gstatic.com/s/solway/v13/AMOTz46Cs2uTAOCuIlwZms0QW3mqyg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Song Myung", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/songmyung/v18/1cX2aUDWAJH5-EIC7DIhr1GqhcitzeM.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sonsie One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/sonsieone/v19/PbymFmP_EAnPqbKaoc18YVu80lbp8JM.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sora", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/sora/v9/xMQOuFFYT72X5wkB_18qmnndmSdSn3-KIwNhBti0.ttf", + "200": "http://fonts.gstatic.com/s/sora/v9/xMQOuFFYT72X5wkB_18qmnndmSfSnn-KIwNhBti0.ttf", + "300": "http://fonts.gstatic.com/s/sora/v9/xMQOuFFYT72X5wkB_18qmnndmScMnn-KIwNhBti0.ttf", + "regular": "http://fonts.gstatic.com/s/sora/v9/xMQOuFFYT72X5wkB_18qmnndmSdSnn-KIwNhBti0.ttf", + "500": "http://fonts.gstatic.com/s/sora/v9/xMQOuFFYT72X5wkB_18qmnndmSdgnn-KIwNhBti0.ttf", + "600": "http://fonts.gstatic.com/s/sora/v9/xMQOuFFYT72X5wkB_18qmnndmSeMmX-KIwNhBti0.ttf", + "700": "http://fonts.gstatic.com/s/sora/v9/xMQOuFFYT72X5wkB_18qmnndmSe1mX-KIwNhBti0.ttf", + "800": "http://fonts.gstatic.com/s/sora/v9/xMQOuFFYT72X5wkB_18qmnndmSfSmX-KIwNhBti0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sorts Mill Goudy", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sortsmillgoudy/v13/Qw3GZR9MED_6PSuS_50nEaVrfzgEXH0OjpM75PE.ttf", + "italic": "http://fonts.gstatic.com/s/sortsmillgoudy/v13/Qw3AZR9MED_6PSuS_50nEaVrfzgEbH8EirE-9PGLfQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Source Code Pro", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v20", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DEyQhM5hTXUcdJg.ttf", + "300": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DJKQhM5hTXUcdJg.ttf", + "regular": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DMyQhM5hTXUcdJg.ttf", + "500": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DP6QhM5hTXUcdJg.ttf", + "600": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DBKXhM5hTXUcdJg.ttf", + "700": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DCuXhM5hTXUcdJg.ttf", + "800": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DEyXhM5hTXUcdJg.ttf", + "900": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DGWXhM5hTXUcdJg.ttf", + "200italic": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_jiYsKILxRpg3hIP6sJ7fM7PqlOPHYvDP_W9O7GQTT7I1rSVcZZJiGpw.ttf", + "300italic": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_jiYsKILxRpg3hIP6sJ7fM7PqlOPHYvDP_W9O7GQTTMo1rSVcZZJiGpw.ttf", + "italic": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_jiYsKILxRpg3hIP6sJ7fM7PqlOPHYvDP_W9O7GQTTbI1rSVcZZJiGpw.ttf", + "500italic": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_jiYsKILxRpg3hIP6sJ7fM7PqlOPHYvDP_W9O7GQTTXo1rSVcZZJiGpw.ttf", + "600italic": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_jiYsKILxRpg3hIP6sJ7fM7PqlOPHYvDP_W9O7GQTTsoprSVcZZJiGpw.ttf", + "700italic": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_jiYsKILxRpg3hIP6sJ7fM7PqlOPHYvDP_W9O7GQTTi4prSVcZZJiGpw.ttf", + "800italic": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_jiYsKILxRpg3hIP6sJ7fM7PqlOPHYvDP_W9O7GQTT7IprSVcZZJiGpw.ttf", + "900italic": "http://fonts.gstatic.com/s/sourcecodepro/v20/HI_jiYsKILxRpg3hIP6sJ7fM7PqlOPHYvDP_W9O7GQTTxYprSVcZZJiGpw.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Source Sans 3", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v4", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpBtKy2OAdR1K-IwhWudF-R9QMylBJAV3Bo8Kw461EN_io6npfB.ttf", + "300": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpBtKy2OAdR1K-IwhWudF-R9QMylBJAV3Bo8Kzm61EN_io6npfB.ttf", + "regular": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpBtKy2OAdR1K-IwhWudF-R9QMylBJAV3Bo8Ky461EN_io6npfB.ttf", + "500": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpBtKy2OAdR1K-IwhWudF-R9QMylBJAV3Bo8KyK61EN_io6npfB.ttf", + "600": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpBtKy2OAdR1K-IwhWudF-R9QMylBJAV3Bo8Kxm7FEN_io6npfB.ttf", + "700": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpBtKy2OAdR1K-IwhWudF-R9QMylBJAV3Bo8Kxf7FEN_io6npfB.ttf", + "800": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpBtKy2OAdR1K-IwhWudF-R9QMylBJAV3Bo8Kw47FEN_io6npfB.ttf", + "900": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpBtKy2OAdR1K-IwhWudF-R9QMylBJAV3Bo8KwR7FEN_io6npfB.ttf", + "200italic": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpDtKy2OAdR1K-IwhWudF-R3woAa8opPOrG97lwqDlO9C4Ym4fB3Ts.ttf", + "300italic": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpDtKy2OAdR1K-IwhWudF-R3woAa8opPOrG97lwqOdO9C4Ym4fB3Ts.ttf", + "italic": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpDtKy2OAdR1K-IwhWudF-R3woAa8opPOrG97lwqLlO9C4Ym4fB3Ts.ttf", + "500italic": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpDtKy2OAdR1K-IwhWudF-R3woAa8opPOrG97lwqItO9C4Ym4fB3Ts.ttf", + "600italic": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpDtKy2OAdR1K-IwhWudF-R3woAa8opPOrG97lwqGdJ9C4Ym4fB3Ts.ttf", + "700italic": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpDtKy2OAdR1K-IwhWudF-R3woAa8opPOrG97lwqF5J9C4Ym4fB3Ts.ttf", + "800italic": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpDtKy2OAdR1K-IwhWudF-R3woAa8opPOrG97lwqDlJ9C4Ym4fB3Ts.ttf", + "900italic": "http://fonts.gstatic.com/s/sourcesans3/v4/nwpDtKy2OAdR1K-IwhWudF-R3woAa8opPOrG97lwqBBJ9C4Ym4fB3Ts.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Source Sans Pro", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "600", + "600italic", + "700", + "700italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2021-11-10", + "files": { + "200": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xKydSBYKcSV-LCoeQqfX1RYOo3i94_AkB1v_8CGxg.ttf", + "200italic": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZYokRdr3cWWxg40.ttf", + "300": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zAkB1v_8CGxg.ttf", + "300italic": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkhdr3cWWxg40.ttf", + "regular": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xK3dSBYKcSV-LCoeQqfX1RYOo3aP6TkmDZz9g.ttf", + "italic": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPa7gujNj9tmf.ttf", + "600": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rAkB1v_8CGxg.ttf", + "600italic": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lBdr3cWWxg40.ttf", + "700": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vAkB1v_8CGxg.ttf", + "700italic": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclRdr3cWWxg40.ttf", + "900": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xKydSBYKcSV-LCoeQqfX1RYOo3iu4nAkB1v_8CGxg.ttf", + "900italic": "http://fonts.gstatic.com/s/sourcesanspro/v18/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZklxdr3cWWxg40.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Source Serif 4", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "200": "http://fonts.gstatic.com/s/sourceserif4/v1/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjipdqrhxXD-wGvjU.ttf", + "300": "http://fonts.gstatic.com/s/sourceserif4/v1/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjiklqrhxXD-wGvjU.ttf", + "regular": "http://fonts.gstatic.com/s/sourceserif4/v1/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjihdqrhxXD-wGvjU.ttf", + "500": "http://fonts.gstatic.com/s/sourceserif4/v1/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjiiVqrhxXD-wGvjU.ttf", + "600": "http://fonts.gstatic.com/s/sourceserif4/v1/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjisltrhxXD-wGvjU.ttf", + "700": "http://fonts.gstatic.com/s/sourceserif4/v1/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjivBtrhxXD-wGvjU.ttf", + "800": "http://fonts.gstatic.com/s/sourceserif4/v1/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjipdtrhxXD-wGvjU.ttf", + "900": "http://fonts.gstatic.com/s/sourceserif4/v1/vEFy2_tTDB4M7-auWDN0ahZJW3IX2ih5nk3AucvUHf6OAVIJmeUDygwjir5trhxXD-wGvjU.ttf", + "200italic": "http://fonts.gstatic.com/s/sourceserif4/v1/vEF02_tTDB4M7-auWDN0ahZJW1ge6NmXpVAHV83Bfb_US2D2QYxoUKIkn98pxl9dC84DrjXEXw.ttf", + "300italic": "http://fonts.gstatic.com/s/sourceserif4/v1/vEF02_tTDB4M7-auWDN0ahZJW1ge6NmXpVAHV83Bfb_US2D2QYxoUKIkn98pGF9dC84DrjXEXw.ttf", + "italic": "http://fonts.gstatic.com/s/sourceserif4/v1/vEF02_tTDB4M7-auWDN0ahZJW1ge6NmXpVAHV83Bfb_US2D2QYxoUKIkn98pRl9dC84DrjXEXw.ttf", + "500italic": "http://fonts.gstatic.com/s/sourceserif4/v1/vEF02_tTDB4M7-auWDN0ahZJW1ge6NmXpVAHV83Bfb_US2D2QYxoUKIkn98pdF9dC84DrjXEXw.ttf", + "600italic": "http://fonts.gstatic.com/s/sourceserif4/v1/vEF02_tTDB4M7-auWDN0ahZJW1ge6NmXpVAHV83Bfb_US2D2QYxoUKIkn98pmFhdC84DrjXEXw.ttf", + "700italic": "http://fonts.gstatic.com/s/sourceserif4/v1/vEF02_tTDB4M7-auWDN0ahZJW1ge6NmXpVAHV83Bfb_US2D2QYxoUKIkn98poVhdC84DrjXEXw.ttf", + "800italic": "http://fonts.gstatic.com/s/sourceserif4/v1/vEF02_tTDB4M7-auWDN0ahZJW1ge6NmXpVAHV83Bfb_US2D2QYxoUKIkn98pxlhdC84DrjXEXw.ttf", + "900italic": "http://fonts.gstatic.com/s/sourceserif4/v1/vEF02_tTDB4M7-auWDN0ahZJW1ge6NmXpVAHV83Bfb_US2D2QYxoUKIkn98p71hdC84DrjXEXw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Source Serif Pro", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "600", + "600italic", + "700", + "700italic", + "900", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2021-03-24", + "files": { + "200": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIXzD-0qpwxpaWvjeD0X88SAOeasbsfhSugxYUvZrI.ttf", + "200italic": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIVzD-0qpwxpaWvjeD0X88SAOeauXEGbSqqwacqdrKvbQ.ttf", + "300": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIXzD-0qpwxpaWvjeD0X88SAOeasd8chSugxYUvZrI.ttf", + "300italic": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIVzD-0qpwxpaWvjeD0X88SAOeauXEGCSmqwacqdrKvbQ.ttf", + "regular": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIQzD-0qpwxpaWvjeD0X88SAOeaiXM0oSOL2Yw.ttf", + "italic": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIWzD-0qpwxpaWvjeD0X88SAOeauXE-pQGOyYw2fw.ttf", + "600": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIXzD-0qpwxpaWvjeD0X88SAOeasasahSugxYUvZrI.ttf", + "600italic": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIVzD-0qpwxpaWvjeD0X88SAOeauXEGfS-qwacqdrKvbQ.ttf", + "700": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIXzD-0qpwxpaWvjeD0X88SAOeasc8bhSugxYUvZrI.ttf", + "700italic": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIVzD-0qpwxpaWvjeD0X88SAOeauXEGGS6qwacqdrKvbQ.ttf", + "900": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIXzD-0qpwxpaWvjeD0X88SAOeasfcZhSugxYUvZrI.ttf", + "900italic": "http://fonts.gstatic.com/s/sourceserifpro/v11/neIVzD-0qpwxpaWvjeD0X88SAOeauXEGISyqwacqdrKvbQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Space Grotesk", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/spacegrotesk/v10/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj62UUsjNsFjTDJK.ttf", + "regular": "http://fonts.gstatic.com/s/spacegrotesk/v10/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj7oUUsjNsFjTDJK.ttf", + "500": "http://fonts.gstatic.com/s/spacegrotesk/v10/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj7aUUsjNsFjTDJK.ttf", + "600": "http://fonts.gstatic.com/s/spacegrotesk/v10/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj42VksjNsFjTDJK.ttf", + "700": "http://fonts.gstatic.com/s/spacegrotesk/v10/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj4PVksjNsFjTDJK.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Space Mono", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v10", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/spacemono/v10/i7dPIFZifjKcF5UAWdDRUEZ2RFq7AwU.ttf", + "italic": "http://fonts.gstatic.com/s/spacemono/v10/i7dNIFZifjKcF5UAWdDRYER8QHi-EwWMbg.ttf", + "700": "http://fonts.gstatic.com/s/spacemono/v10/i7dMIFZifjKcF5UAWdDRaPpZYFKQHwyVd3U.ttf", + "700italic": "http://fonts.gstatic.com/s/spacemono/v10/i7dSIFZifjKcF5UAWdDRYERE_FeaGy6QZ3WfYg.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Spartan", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/spartan/v10/l7gAbjR61M69yt8Z8w6FZf9WoBxdBrGFuG6OChXtf4qS.ttf", + "200": "http://fonts.gstatic.com/s/spartan/v10/l7gAbjR61M69yt8Z8w6FZf9WoBxdBrEFuW6OChXtf4qS.ttf", + "300": "http://fonts.gstatic.com/s/spartan/v10/l7gAbjR61M69yt8Z8w6FZf9WoBxdBrHbuW6OChXtf4qS.ttf", + "regular": "http://fonts.gstatic.com/s/spartan/v10/l7gAbjR61M69yt8Z8w6FZf9WoBxdBrGFuW6OChXtf4qS.ttf", + "500": "http://fonts.gstatic.com/s/spartan/v10/l7gAbjR61M69yt8Z8w6FZf9WoBxdBrG3uW6OChXtf4qS.ttf", + "600": "http://fonts.gstatic.com/s/spartan/v10/l7gAbjR61M69yt8Z8w6FZf9WoBxdBrFbvm6OChXtf4qS.ttf", + "700": "http://fonts.gstatic.com/s/spartan/v10/l7gAbjR61M69yt8Z8w6FZf9WoBxdBrFivm6OChXtf4qS.ttf", + "800": "http://fonts.gstatic.com/s/spartan/v10/l7gAbjR61M69yt8Z8w6FZf9WoBxdBrEFvm6OChXtf4qS.ttf", + "900": "http://fonts.gstatic.com/s/spartan/v10/l7gAbjR61M69yt8Z8w6FZf9WoBxdBrEsvm6OChXtf4qS.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Special Elite", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/specialelite/v16/XLYgIZbkc4JPUL5CVArUVL0nhncESXFtUsM.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Spectral", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v11", + "lastModified": "2022-01-27", + "files": { + "200": "http://fonts.gstatic.com/s/spectral/v11/rnCs-xNNww_2s0amA9v2s13GY_etWWIJ.ttf", + "200italic": "http://fonts.gstatic.com/s/spectral/v11/rnCu-xNNww_2s0amA9M8qrXHafOPXHIJErY.ttf", + "300": "http://fonts.gstatic.com/s/spectral/v11/rnCs-xNNww_2s0amA9uSsF3GY_etWWIJ.ttf", + "300italic": "http://fonts.gstatic.com/s/spectral/v11/rnCu-xNNww_2s0amA9M8qtHEafOPXHIJErY.ttf", + "regular": "http://fonts.gstatic.com/s/spectral/v11/rnCr-xNNww_2s0amA-M-mHnOSOuk.ttf", + "italic": "http://fonts.gstatic.com/s/spectral/v11/rnCt-xNNww_2s0amA9M8kn3sTfukQHs.ttf", + "500": "http://fonts.gstatic.com/s/spectral/v11/rnCs-xNNww_2s0amA9vKsV3GY_etWWIJ.ttf", + "500italic": "http://fonts.gstatic.com/s/spectral/v11/rnCu-xNNww_2s0amA9M8qonFafOPXHIJErY.ttf", + "600": "http://fonts.gstatic.com/s/spectral/v11/rnCs-xNNww_2s0amA9vmtl3GY_etWWIJ.ttf", + "600italic": "http://fonts.gstatic.com/s/spectral/v11/rnCu-xNNww_2s0amA9M8qqXCafOPXHIJErY.ttf", + "700": "http://fonts.gstatic.com/s/spectral/v11/rnCs-xNNww_2s0amA9uCt13GY_etWWIJ.ttf", + "700italic": "http://fonts.gstatic.com/s/spectral/v11/rnCu-xNNww_2s0amA9M8qsHDafOPXHIJErY.ttf", + "800": "http://fonts.gstatic.com/s/spectral/v11/rnCs-xNNww_2s0amA9uetF3GY_etWWIJ.ttf", + "800italic": "http://fonts.gstatic.com/s/spectral/v11/rnCu-xNNww_2s0amA9M8qt3AafOPXHIJErY.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Spectral SC", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-13", + "files": { + "200": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk0ALCRZonmalTgyPmRfs1qwkTXPYeVXJZB.ttf", + "200italic": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk2ALCRZonmalTgyPmRfsWg26zWN4O3WYZB_sU.ttf", + "300": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk0ALCRZonmalTgyPmRfs0OwUTXPYeVXJZB.ttf", + "300italic": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk2ALCRZonmalTgyPmRfsWg28jVN4O3WYZB_sU.ttf", + "regular": "http://fonts.gstatic.com/s/spectralsc/v9/KtkpALCRZonmalTgyPmRfvWi6WDfFpuc.ttf", + "italic": "http://fonts.gstatic.com/s/spectralsc/v9/KtkrALCRZonmalTgyPmRfsWg42T9E4ucRY8.ttf", + "500": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk0ALCRZonmalTgyPmRfs1WwETXPYeVXJZB.ttf", + "500italic": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk2ALCRZonmalTgyPmRfsWg25DUN4O3WYZB_sU.ttf", + "600": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk0ALCRZonmalTgyPmRfs16x0TXPYeVXJZB.ttf", + "600italic": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk2ALCRZonmalTgyPmRfsWg27zTN4O3WYZB_sU.ttf", + "700": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk0ALCRZonmalTgyPmRfs0exkTXPYeVXJZB.ttf", + "700italic": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk2ALCRZonmalTgyPmRfsWg29jSN4O3WYZB_sU.ttf", + "800": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk0ALCRZonmalTgyPmRfs0CxUTXPYeVXJZB.ttf", + "800italic": "http://fonts.gstatic.com/s/spectralsc/v9/Ktk2ALCRZonmalTgyPmRfsWg28TRN4O3WYZB_sU.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Spicy Rice", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/spicyrice/v19/uK_24rSEd-Uqwk4jY1RyGv-2WkowRcc.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Spinnaker", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/spinnaker/v15/w8gYH2oyX-I0_rvR6Hmn3HwLqOqSBg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Spirax", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/spirax/v19/buE3poKgYNLy0F3cXktt-Csn-Q.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Spline Sans", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v3", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/splinesans/v3/_6_sED73Uf-2WfU2LzycEZousNzn1a1lKWRpZlnYEtvlUfE2kw.ttf", + "regular": "http://fonts.gstatic.com/s/splinesans/v3/_6_sED73Uf-2WfU2LzycEZousNzn1a1lKWRpOFnYEtvlUfE2kw.ttf", + "500": "http://fonts.gstatic.com/s/splinesans/v3/_6_sED73Uf-2WfU2LzycEZousNzn1a1lKWRpClnYEtvlUfE2kw.ttf", + "600": "http://fonts.gstatic.com/s/splinesans/v3/_6_sED73Uf-2WfU2LzycEZousNzn1a1lKWRp5l7YEtvlUfE2kw.ttf", + "700": "http://fonts.gstatic.com/s/splinesans/v3/_6_sED73Uf-2WfU2LzycEZousNzn1a1lKWRp317YEtvlUfE2kw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Squada One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/squadaone/v12/BCasqZ8XsOrx4mcOk6MtWaA8WDBkHgs.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sree Krushnadevaraya", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/sreekrushnadevaraya/v19/R70FjzQeifmPepmyQQjQ9kvwMkWYPfTA_EWb2FhQuXir.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sriracha", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v8", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/sriracha/v8/0nkrC9D4IuYBgWcI9ObYRQDioeb0.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Srisakdi", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v14", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/srisakdi/v14/yMJRMIlvdpDbkB0A-jq8fSx5i814.ttf", + "700": "http://fonts.gstatic.com/s/srisakdi/v14/yMJWMIlvdpDbkB0A-gIAUghxoNFxW0Hz.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Staatliches", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/staatliches/v10/HI_OiY8KO6hCsQSoAPmtMbectJG9O9PS.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Stalemate", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/stalemate/v18/taiIGmZ_EJq97-UfkZRpuqSs8ZQpaQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Stalinist One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v43", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/stalinistone/v43/MQpS-WezM9W4Dd7D3B7I-UT7eZ-UPyacPbo.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Stardos Stencil", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/stardosstencil/v11/X7n94bcuGPC8hrvEOHXOgaKCc2TR71R3tiSx0g.ttf", + "700": "http://fonts.gstatic.com/s/stardosstencil/v11/X7n44bcuGPC8hrvEOHXOgaKCc2TpU3tTvg-t29HSHw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Stick", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/stick/v13/Qw3TZQpMCyTtJSvfvPVDMPoF.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Stick No Bills", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext", + "sinhala" + ], + "version": "v6", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/sticknobills/v6/bWts7ffXZwHuAa9Uld-oEK4QKlxj9f9t_7uEmjcVP8Q7KriwKhcTKA.ttf", + "300": "http://fonts.gstatic.com/s/sticknobills/v6/bWts7ffXZwHuAa9Uld-oEK4QKlxj9f9t_7uEmjcV4cQ7KriwKhcTKA.ttf", + "regular": "http://fonts.gstatic.com/s/sticknobills/v6/bWts7ffXZwHuAa9Uld-oEK4QKlxj9f9t_7uEmjcVv8Q7KriwKhcTKA.ttf", + "500": "http://fonts.gstatic.com/s/sticknobills/v6/bWts7ffXZwHuAa9Uld-oEK4QKlxj9f9t_7uEmjcVjcQ7KriwKhcTKA.ttf", + "600": "http://fonts.gstatic.com/s/sticknobills/v6/bWts7ffXZwHuAa9Uld-oEK4QKlxj9f9t_7uEmjcVYcM7KriwKhcTKA.ttf", + "700": "http://fonts.gstatic.com/s/sticknobills/v6/bWts7ffXZwHuAa9Uld-oEK4QKlxj9f9t_7uEmjcVWMM7KriwKhcTKA.ttf", + "800": "http://fonts.gstatic.com/s/sticknobills/v6/bWts7ffXZwHuAa9Uld-oEK4QKlxj9f9t_7uEmjcVP8M7KriwKhcTKA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Stint Ultra Condensed", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/stintultracondensed/v19/-W_gXIrsVjjeyEnPC45qD2NoFPtBE0xCh2A-qhUO2cNvdg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Stint Ultra Expanded", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/stintultraexpanded/v18/CSRg4yNNh-GbW3o3JkwoDcdvMKMf0oBAd0qoATQkWwam.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Stoke", + "variants": [ + "300", + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/stoke/v20/z7NXdRb7aTMfKNvFVgxC_pjcTeWU.ttf", + "regular": "http://fonts.gstatic.com/s/stoke/v20/z7NadRb7aTMfKONpfihK1YTV.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Strait", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v11", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/strait/v11/DtViJxy6WaEr1LZzeDhtkl0U7w.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Style Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/stylescript/v5/vm8xdRX3SV7Z0aPa88xzW5npeFT76NZnMw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Stylish", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/stylish/v18/m8JSjfhPYriQkk7-fo35dLxEdmo.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sue Ellen Francisco", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/sueellenfrancisco/v14/wXK3E20CsoJ9j1DDkjHcQ5ZL8xRaxru9ropF2lqk9H4.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Suez One", + "variants": [ + "regular" + ], + "subsets": [ + "hebrew", + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/suezone/v8/taiJGmd_EZ6rqscQgNFJkIqg-I0w.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sulphur Point", + "variants": [ + "300", + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "300": "http://fonts.gstatic.com/s/sulphurpoint/v12/RLpkK5vv8KaycDcazWFPBj2afVU6n6kFUHPIFaU.ttf", + "regular": "http://fonts.gstatic.com/s/sulphurpoint/v12/RLp5K5vv8KaycDcazWFPBj2aRfkSu6EuTHo.ttf", + "700": "http://fonts.gstatic.com/s/sulphurpoint/v12/RLpkK5vv8KaycDcazWFPBj2afUU9n6kFUHPIFaU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sumana", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/sumana/v8/4UaDrE5TqRBjGj-G8Bji76zR4w.ttf", + "700": "http://fonts.gstatic.com/s/sumana/v8/4UaArE5TqRBjGj--TDfG54fN6ppsKg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sunflower", + "variants": [ + "300", + "500", + "700" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "300": "http://fonts.gstatic.com/s/sunflower/v12/RWmPoKeF8fUjqIj7Vc-06MfiqYsGBGBzCw.ttf", + "500": "http://fonts.gstatic.com/s/sunflower/v12/RWmPoKeF8fUjqIj7Vc-0sMbiqYsGBGBzCw.ttf", + "700": "http://fonts.gstatic.com/s/sunflower/v12/RWmPoKeF8fUjqIj7Vc-0-MDiqYsGBGBzCw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Sunshiney", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/sunshiney/v13/LDIwapGTLBwsS-wT4vcgE8moUePWkg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Supermercado One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/supermercadoone/v20/OpNXnpQWg8jc_xps_Gi14kVVEXOn60b3MClBRTs.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Sura", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/sura/v13/SZc23FL5PbyzFf5UWzXtjUM.ttf", + "700": "http://fonts.gstatic.com/s/sura/v13/SZc53FL5PbyzLUJ7fz3GkUrS8DI.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Suranna", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/suranna/v11/gokuH6ztGkFjWe58tBRZT2KmgP0.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Suravaram", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v19", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/suravaram/v19/_gP61R_usiY7SCym4xIAi261Qv9roQ.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Suwannaphum", + "variants": [ + "100", + "300", + "regular", + "700", + "900" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v27", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/suwannaphum/v27/jAnAgHV7GtDvc8jbe8hXXL3B9cSWXx2VZmk.ttf", + "300": "http://fonts.gstatic.com/s/suwannaphum/v27/jAnfgHV7GtDvc8jbe8hXXL0J1-S8cRGcf3Ai.ttf", + "regular": "http://fonts.gstatic.com/s/suwannaphum/v27/jAnCgHV7GtDvc8jbe8hXXIWl_8C0Wg2V.ttf", + "700": "http://fonts.gstatic.com/s/suwannaphum/v27/jAnfgHV7GtDvc8jbe8hXXL0Z0OS8cRGcf3Ai.ttf", + "900": "http://fonts.gstatic.com/s/suwannaphum/v27/jAnfgHV7GtDvc8jbe8hXXL0h0uS8cRGcf3Ai.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Swanky and Moo Moo", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/swankyandmoomoo/v20/flUlRrKz24IuWVI_WJYTYcqbEsMUZ3kUtbPkR64SYQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Syncopate", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/syncopate/v17/pe0sMIuPIYBCpEV5eFdyAv2-C99ycg.ttf", + "700": "http://fonts.gstatic.com/s/syncopate/v17/pe0pMIuPIYBCpEV5eFdKvtKaA_Rue1UwVg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Syne", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/syne/v12/8vIS7w4qzmVxsWxjBZRjr0FKM_04uT6kR47NCV5Z.ttf", + "500": "http://fonts.gstatic.com/s/syne/v12/8vIS7w4qzmVxsWxjBZRjr0FKM_0KuT6kR47NCV5Z.ttf", + "600": "http://fonts.gstatic.com/s/syne/v12/8vIS7w4qzmVxsWxjBZRjr0FKM_3mvj6kR47NCV5Z.ttf", + "700": "http://fonts.gstatic.com/s/syne/v12/8vIS7w4qzmVxsWxjBZRjr0FKM_3fvj6kR47NCV5Z.ttf", + "800": "http://fonts.gstatic.com/s/syne/v12/8vIS7w4qzmVxsWxjBZRjr0FKM_24vj6kR47NCV5Z.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Syne Mono", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/synemono/v13/K2FzfZNHj_FHBmRbFvHzIqCkDyvqZA.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Syne Tactile", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2021-12-01", + "files": { + "regular": "http://fonts.gstatic.com/s/synetactile/v13/11hGGpna2UTQKjMCVzjAPMKh3ysdjvKU8Q.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Tajawal", + "variants": [ + "200", + "300", + "regular", + "500", + "700", + "800", + "900" + ], + "subsets": [ + "arabic", + "latin" + ], + "version": "v8", + "lastModified": "2022-01-27", + "files": { + "200": "http://fonts.gstatic.com/s/tajawal/v8/Iurf6YBj_oCad4k1l_6gLrZjiLlJ-G0.ttf", + "300": "http://fonts.gstatic.com/s/tajawal/v8/Iurf6YBj_oCad4k1l5qjLrZjiLlJ-G0.ttf", + "regular": "http://fonts.gstatic.com/s/tajawal/v8/Iura6YBj_oCad4k1rzaLCr5IlLA.ttf", + "500": "http://fonts.gstatic.com/s/tajawal/v8/Iurf6YBj_oCad4k1l8KiLrZjiLlJ-G0.ttf", + "700": "http://fonts.gstatic.com/s/tajawal/v8/Iurf6YBj_oCad4k1l4qkLrZjiLlJ-G0.ttf", + "800": "http://fonts.gstatic.com/s/tajawal/v8/Iurf6YBj_oCad4k1l5anLrZjiLlJ-G0.ttf", + "900": "http://fonts.gstatic.com/s/tajawal/v8/Iurf6YBj_oCad4k1l7KmLrZjiLlJ-G0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Tangerine", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/tangerine/v15/IurY6Y5j_oScZZow4VOBDpxNhLBQ4Q.ttf", + "700": "http://fonts.gstatic.com/s/tangerine/v15/Iurd6Y5j_oScZZow4VO5srNpjJtM6G0t9w.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Taprom", + "variants": [ + "regular" + ], + "subsets": [ + "khmer", + "latin" + ], + "version": "v25", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/taprom/v25/UcCn3F82JHycULbFQyk3-0kvHg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Tauri", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/tauri/v14/TwMA-IISS0AM3IpVWHU_TBqO.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Taviraj", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-25", + "files": { + "100": "http://fonts.gstatic.com/s/taviraj/v9/ahcbv8Cj3ylylTXzRIorV8N1jU2gog.ttf", + "100italic": "http://fonts.gstatic.com/s/taviraj/v9/ahcdv8Cj3ylylTXzTOwTM8lxr0iwolLl.ttf", + "200": "http://fonts.gstatic.com/s/taviraj/v9/ahccv8Cj3ylylTXzRCYKd-lbgUS5u0s.ttf", + "200italic": "http://fonts.gstatic.com/s/taviraj/v9/ahcev8Cj3ylylTXzTOwTn-hRhWa8q0v8ag.ttf", + "300": "http://fonts.gstatic.com/s/taviraj/v9/ahccv8Cj3ylylTXzREIJd-lbgUS5u0s.ttf", + "300italic": "http://fonts.gstatic.com/s/taviraj/v9/ahcev8Cj3ylylTXzTOwT--tRhWa8q0v8ag.ttf", + "regular": "http://fonts.gstatic.com/s/taviraj/v9/ahcZv8Cj3ylylTXzfO4hU-FwnU0.ttf", + "italic": "http://fonts.gstatic.com/s/taviraj/v9/ahcbv8Cj3ylylTXzTOwrV8N1jU2gog.ttf", + "500": "http://fonts.gstatic.com/s/taviraj/v9/ahccv8Cj3ylylTXzRBoId-lbgUS5u0s.ttf", + "500italic": "http://fonts.gstatic.com/s/taviraj/v9/ahcev8Cj3ylylTXzTOwTo-pRhWa8q0v8ag.ttf", + "600": "http://fonts.gstatic.com/s/taviraj/v9/ahccv8Cj3ylylTXzRDYPd-lbgUS5u0s.ttf", + "600italic": "http://fonts.gstatic.com/s/taviraj/v9/ahcev8Cj3ylylTXzTOwTj-1RhWa8q0v8ag.ttf", + "700": "http://fonts.gstatic.com/s/taviraj/v9/ahccv8Cj3ylylTXzRFIOd-lbgUS5u0s.ttf", + "700italic": "http://fonts.gstatic.com/s/taviraj/v9/ahcev8Cj3ylylTXzTOwT6-xRhWa8q0v8ag.ttf", + "800": "http://fonts.gstatic.com/s/taviraj/v9/ahccv8Cj3ylylTXzRE4Nd-lbgUS5u0s.ttf", + "800italic": "http://fonts.gstatic.com/s/taviraj/v9/ahcev8Cj3ylylTXzTOwT9-9RhWa8q0v8ag.ttf", + "900": "http://fonts.gstatic.com/s/taviraj/v9/ahccv8Cj3ylylTXzRGoMd-lbgUS5u0s.ttf", + "900italic": "http://fonts.gstatic.com/s/taviraj/v9/ahcev8Cj3ylylTXzTOwT0-5RhWa8q0v8ag.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Teko", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/teko/v14/LYjCdG7kmE0gdQhfgCNqqVIuTN4.ttf", + "regular": "http://fonts.gstatic.com/s/teko/v14/LYjNdG7kmE0gTaR3pCtBtVs.ttf", + "500": "http://fonts.gstatic.com/s/teko/v14/LYjCdG7kmE0gdVBegCNqqVIuTN4.ttf", + "600": "http://fonts.gstatic.com/s/teko/v14/LYjCdG7kmE0gdXxZgCNqqVIuTN4.ttf", + "700": "http://fonts.gstatic.com/s/teko/v14/LYjCdG7kmE0gdRhYgCNqqVIuTN4.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Telex", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/telex/v12/ieVw2Y1fKWmIO9fTB1piKFIf.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Tenali Ramakrishna", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v10", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/tenaliramakrishna/v10/raxgHj6Yt9gAN3LLKs0BZVMo8jmwn1-8KJXqUFFvtA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Tenor Sans", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/tenorsans/v15/bx6ANxqUneKx06UkIXISr3JyC22IyqI.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Text Me One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/textmeone/v18/i7dOIFdlayuLUvgoFvHQFWZcalayGhyV.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Texturina", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2021-03-19", + "files": { + "100": "http://fonts.gstatic.com/s/texturina/v9/c4mM1nxpEtL3pXiAulRTkY-HGmNEX1b9NspjMwhAgliHhVrXy2eYG_Ug25riW1OD.ttf", + "200": "http://fonts.gstatic.com/s/texturina/v9/c4mM1nxpEtL3pXiAulRTkY-HGmNEX1b9NspjMwhAgliHhVrXy2cYGvUg25riW1OD.ttf", + "300": "http://fonts.gstatic.com/s/texturina/v9/c4mM1nxpEtL3pXiAulRTkY-HGmNEX1b9NspjMwhAgliHhVrXy2fGGvUg25riW1OD.ttf", + "regular": "http://fonts.gstatic.com/s/texturina/v9/c4mM1nxpEtL3pXiAulRTkY-HGmNEX1b9NspjMwhAgliHhVrXy2eYGvUg25riW1OD.ttf", + "500": "http://fonts.gstatic.com/s/texturina/v9/c4mM1nxpEtL3pXiAulRTkY-HGmNEX1b9NspjMwhAgliHhVrXy2eqGvUg25riW1OD.ttf", + "600": "http://fonts.gstatic.com/s/texturina/v9/c4mM1nxpEtL3pXiAulRTkY-HGmNEX1b9NspjMwhAgliHhVrXy2dGHfUg25riW1OD.ttf", + "700": "http://fonts.gstatic.com/s/texturina/v9/c4mM1nxpEtL3pXiAulRTkY-HGmNEX1b9NspjMwhAgliHhVrXy2d_HfUg25riW1OD.ttf", + "800": "http://fonts.gstatic.com/s/texturina/v9/c4mM1nxpEtL3pXiAulRTkY-HGmNEX1b9NspjMwhAgliHhVrXy2cYHfUg25riW1OD.ttf", + "900": "http://fonts.gstatic.com/s/texturina/v9/c4mM1nxpEtL3pXiAulRTkY-HGmNEX1b9NspjMwhAgliHhVrXy2cxHfUg25riW1OD.ttf", + "100italic": "http://fonts.gstatic.com/s/texturina/v9/c4mO1nxpEtL3pXiAulR5mL129FhZmLj7I4oiSUJyfYDu7sB5zHJQWR1i0Z7AXkODN94.ttf", + "200italic": "http://fonts.gstatic.com/s/texturina/v9/c4mO1nxpEtL3pXiAulR5mL129FhZmLj7I4oiSUJyfYDu7sB5zHJQWZ1j0Z7AXkODN94.ttf", + "300italic": "http://fonts.gstatic.com/s/texturina/v9/c4mO1nxpEtL3pXiAulR5mL129FhZmLj7I4oiSUJyfYDu7sB5zHJQWUNj0Z7AXkODN94.ttf", + "italic": "http://fonts.gstatic.com/s/texturina/v9/c4mO1nxpEtL3pXiAulR5mL129FhZmLj7I4oiSUJyfYDu7sB5zHJQWR1j0Z7AXkODN94.ttf", + "500italic": "http://fonts.gstatic.com/s/texturina/v9/c4mO1nxpEtL3pXiAulR5mL129FhZmLj7I4oiSUJyfYDu7sB5zHJQWS9j0Z7AXkODN94.ttf", + "600italic": "http://fonts.gstatic.com/s/texturina/v9/c4mO1nxpEtL3pXiAulR5mL129FhZmLj7I4oiSUJyfYDu7sB5zHJQWcNk0Z7AXkODN94.ttf", + "700italic": "http://fonts.gstatic.com/s/texturina/v9/c4mO1nxpEtL3pXiAulR5mL129FhZmLj7I4oiSUJyfYDu7sB5zHJQWfpk0Z7AXkODN94.ttf", + "800italic": "http://fonts.gstatic.com/s/texturina/v9/c4mO1nxpEtL3pXiAulR5mL129FhZmLj7I4oiSUJyfYDu7sB5zHJQWZ1k0Z7AXkODN94.ttf", + "900italic": "http://fonts.gstatic.com/s/texturina/v9/c4mO1nxpEtL3pXiAulR5mL129FhZmLj7I4oiSUJyfYDu7sB5zHJQWbRk0Z7AXkODN94.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Thasadith", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v7", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/thasadith/v7/mtG44_1TIqPYrd_f5R1YsEkU0CWuFw.ttf", + "italic": "http://fonts.gstatic.com/s/thasadith/v7/mtG-4_1TIqPYrd_f5R1oskMQ8iC-F1ZE.ttf", + "700": "http://fonts.gstatic.com/s/thasadith/v7/mtG94_1TIqPYrd_f5R1gDGYw2A6yHk9d8w.ttf", + "700italic": "http://fonts.gstatic.com/s/thasadith/v7/mtGj4_1TIqPYrd_f5R1osnus3QS2PEpN8zxA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "The Girl Next Door", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/thegirlnextdoor/v16/pe0zMJCIMIsBjFxqYBIcZ6_OI5oFHCYIV7t7w6bE2A.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "The Nautigal", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/thenautigal/v1/VdGZAZ8ZH51Lvng9fQV2bfKr5wVk09Se5Q.ttf", + "700": "http://fonts.gstatic.com/s/thenautigal/v1/VdGGAZ8ZH51Lvng9fQV2bfKTWypA2_-C7LoS7g.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Tienne", + "variants": [ + "regular", + "700", + "900" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/tienne/v18/AYCKpX7pe9YCRP0LkEPHSFNyxw.ttf", + "700": "http://fonts.gstatic.com/s/tienne/v18/AYCJpX7pe9YCRP0zLGzjQHhuzvef5Q.ttf", + "900": "http://fonts.gstatic.com/s/tienne/v18/AYCJpX7pe9YCRP0zFG7jQHhuzvef5Q.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Tillana", + "variants": [ + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/tillana/v9/VuJxdNvf35P4qJ1OeKbXOIFneRo.ttf", + "500": "http://fonts.gstatic.com/s/tillana/v9/VuJ0dNvf35P4qJ1OQFL-HIlMZRNcp0o.ttf", + "600": "http://fonts.gstatic.com/s/tillana/v9/VuJ0dNvf35P4qJ1OQH75HIlMZRNcp0o.ttf", + "700": "http://fonts.gstatic.com/s/tillana/v9/VuJ0dNvf35P4qJ1OQBr4HIlMZRNcp0o.ttf", + "800": "http://fonts.gstatic.com/s/tillana/v9/VuJ0dNvf35P4qJ1OQAb7HIlMZRNcp0o.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Timmana", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "telugu" + ], + "version": "v10", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/timmana/v10/6xKvdShfL9yK-rvpCmvbKHwJUOM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Tinos", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "hebrew", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/tinos/v22/buE4poGnedXvwgX8dGVh8TI-.ttf", + "italic": "http://fonts.gstatic.com/s/tinos/v22/buE2poGnedXvwjX-fmFD9CI-4NU.ttf", + "700": "http://fonts.gstatic.com/s/tinos/v22/buE1poGnedXvwj1AW0Fp2i43-cxL.ttf", + "700italic": "http://fonts.gstatic.com/s/tinos/v22/buEzpoGnedXvwjX-Rt1s0CoV_NxLeiw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Titan One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/titanone/v11/mFTzWbsGxbbS_J5cQcjykzIn2Etikg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Titillium Web", + "variants": [ + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "600", + "600italic", + "700", + "700italic", + "900" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "200": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPDcZTIAOhVxoMyOr9n_E7ffAzHKIx5YrSYqWM.ttf", + "200italic": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPFcZTIAOhVxoMyOr9n_E7fdMbewI1zZpaduWMmxA.ttf", + "300": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPDcZTIAOhVxoMyOr9n_E7ffGjEKIx5YrSYqWM.ttf", + "300italic": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPFcZTIAOhVxoMyOr9n_E7fdMbepI5zZpaduWMmxA.ttf", + "regular": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPecZTIAOhVxoMyOr9n_E7fRMTsDIRSfr0.ttf", + "italic": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPAcZTIAOhVxoMyOr9n_E7fdMbmCKZXbr2BsA.ttf", + "600": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPDcZTIAOhVxoMyOr9n_E7ffBzCKIx5YrSYqWM.ttf", + "600italic": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPFcZTIAOhVxoMyOr9n_E7fdMbe0IhzZpaduWMmxA.ttf", + "700": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPDcZTIAOhVxoMyOr9n_E7ffHjDKIx5YrSYqWM.ttf", + "700italic": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPFcZTIAOhVxoMyOr9n_E7fdMbetIlzZpaduWMmxA.ttf", + "900": "http://fonts.gstatic.com/s/titilliumweb/v14/NaPDcZTIAOhVxoMyOr9n_E7ffEDBKIx5YrSYqWM.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Tomorrow", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "100": "http://fonts.gstatic.com/s/tomorrow/v13/WBLgrETNbFtZCeGqgR2xe2XiKMiokE4.ttf", + "100italic": "http://fonts.gstatic.com/s/tomorrow/v13/WBLirETNbFtZCeGqgRXXQwHoLOqtgE5h0A.ttf", + "200": "http://fonts.gstatic.com/s/tomorrow/v13/WBLhrETNbFtZCeGqgR0dWkXIBsShiVd4.ttf", + "200italic": "http://fonts.gstatic.com/s/tomorrow/v13/WBLjrETNbFtZCeGqgRXXQ63JDMCDjEd4yVY.ttf", + "300": "http://fonts.gstatic.com/s/tomorrow/v13/WBLhrETNbFtZCeGqgR15WUXIBsShiVd4.ttf", + "300italic": "http://fonts.gstatic.com/s/tomorrow/v13/WBLjrETNbFtZCeGqgRXXQ8nKDMCDjEd4yVY.ttf", + "regular": "http://fonts.gstatic.com/s/tomorrow/v13/WBLmrETNbFtZCeGqgSXVcWHALdio.ttf", + "italic": "http://fonts.gstatic.com/s/tomorrow/v13/WBLgrETNbFtZCeGqgRXXe2XiKMiokE4.ttf", + "500": "http://fonts.gstatic.com/s/tomorrow/v13/WBLhrETNbFtZCeGqgR0hWEXIBsShiVd4.ttf", + "500italic": "http://fonts.gstatic.com/s/tomorrow/v13/WBLjrETNbFtZCeGqgRXXQ5HLDMCDjEd4yVY.ttf", + "600": "http://fonts.gstatic.com/s/tomorrow/v13/WBLhrETNbFtZCeGqgR0NX0XIBsShiVd4.ttf", + "600italic": "http://fonts.gstatic.com/s/tomorrow/v13/WBLjrETNbFtZCeGqgRXXQ73MDMCDjEd4yVY.ttf", + "700": "http://fonts.gstatic.com/s/tomorrow/v13/WBLhrETNbFtZCeGqgR1pXkXIBsShiVd4.ttf", + "700italic": "http://fonts.gstatic.com/s/tomorrow/v13/WBLjrETNbFtZCeGqgRXXQ9nNDMCDjEd4yVY.ttf", + "800": "http://fonts.gstatic.com/s/tomorrow/v13/WBLhrETNbFtZCeGqgR11XUXIBsShiVd4.ttf", + "800italic": "http://fonts.gstatic.com/s/tomorrow/v13/WBLjrETNbFtZCeGqgRXXQ8XODMCDjEd4yVY.ttf", + "900": "http://fonts.gstatic.com/s/tomorrow/v13/WBLhrETNbFtZCeGqgR1RXEXIBsShiVd4.ttf", + "900italic": "http://fonts.gstatic.com/s/tomorrow/v13/WBLjrETNbFtZCeGqgRXXQ-HPDMCDjEd4yVY.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Tourney", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v4", + "lastModified": "2021-12-17", + "files": { + "100": "http://fonts.gstatic.com/s/tourney/v4/AlZa_ztDtYzv1tzq1wcJnbVt7xseomk-tNs7qrzTWbyt8n7GOQByZTp1I1LcGA.ttf", + "200": "http://fonts.gstatic.com/s/tourney/v4/AlZa_ztDtYzv1tzq1wcJnbVt7xseomk-tNs7qrzTWbyt8n7GuQFyZTp1I1LcGA.ttf", + "300": "http://fonts.gstatic.com/s/tourney/v4/AlZa_ztDtYzv1tzq1wcJnbVt7xseomk-tNs7qrzTWbyt8n7GZwFyZTp1I1LcGA.ttf", + "regular": "http://fonts.gstatic.com/s/tourney/v4/AlZa_ztDtYzv1tzq1wcJnbVt7xseomk-tNs7qrzTWbyt8n7GOQFyZTp1I1LcGA.ttf", + "500": "http://fonts.gstatic.com/s/tourney/v4/AlZa_ztDtYzv1tzq1wcJnbVt7xseomk-tNs7qrzTWbyt8n7GCwFyZTp1I1LcGA.ttf", + "600": "http://fonts.gstatic.com/s/tourney/v4/AlZa_ztDtYzv1tzq1wcJnbVt7xseomk-tNs7qrzTWbyt8n7G5wZyZTp1I1LcGA.ttf", + "700": "http://fonts.gstatic.com/s/tourney/v4/AlZa_ztDtYzv1tzq1wcJnbVt7xseomk-tNs7qrzTWbyt8n7G3gZyZTp1I1LcGA.ttf", + "800": "http://fonts.gstatic.com/s/tourney/v4/AlZa_ztDtYzv1tzq1wcJnbVt7xseomk-tNs7qrzTWbyt8n7GuQZyZTp1I1LcGA.ttf", + "900": "http://fonts.gstatic.com/s/tourney/v4/AlZa_ztDtYzv1tzq1wcJnbVt7xseomk-tNs7qrzTWbyt8n7GkAZyZTp1I1LcGA.ttf", + "100italic": "http://fonts.gstatic.com/s/tourney/v4/AlZc_ztDtYzv1tzq_Q47flUUvI2wpXz29ilymEMLMNc3XHnT8UKaJzBxAVfMGOPb.ttf", + "200italic": "http://fonts.gstatic.com/s/tourney/v4/AlZc_ztDtYzv1tzq_Q47flUUvI2wpXz29ilymEMLMNc3XHnT8UIaJjBxAVfMGOPb.ttf", + "300italic": "http://fonts.gstatic.com/s/tourney/v4/AlZc_ztDtYzv1tzq_Q47flUUvI2wpXz29ilymEMLMNc3XHnT8ULEJjBxAVfMGOPb.ttf", + "italic": "http://fonts.gstatic.com/s/tourney/v4/AlZc_ztDtYzv1tzq_Q47flUUvI2wpXz29ilymEMLMNc3XHnT8UKaJjBxAVfMGOPb.ttf", + "500italic": "http://fonts.gstatic.com/s/tourney/v4/AlZc_ztDtYzv1tzq_Q47flUUvI2wpXz29ilymEMLMNc3XHnT8UKoJjBxAVfMGOPb.ttf", + "600italic": "http://fonts.gstatic.com/s/tourney/v4/AlZc_ztDtYzv1tzq_Q47flUUvI2wpXz29ilymEMLMNc3XHnT8UJEITBxAVfMGOPb.ttf", + "700italic": "http://fonts.gstatic.com/s/tourney/v4/AlZc_ztDtYzv1tzq_Q47flUUvI2wpXz29ilymEMLMNc3XHnT8UJ9ITBxAVfMGOPb.ttf", + "800italic": "http://fonts.gstatic.com/s/tourney/v4/AlZc_ztDtYzv1tzq_Q47flUUvI2wpXz29ilymEMLMNc3XHnT8UIaITBxAVfMGOPb.ttf", + "900italic": "http://fonts.gstatic.com/s/tourney/v4/AlZc_ztDtYzv1tzq_Q47flUUvI2wpXz29ilymEMLMNc3XHnT8UIzITBxAVfMGOPb.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Trade Winds", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/tradewinds/v15/AYCPpXPpYNIIT7h8-QenM3Jq7PKP5Z_G.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Train One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/trainone/v11/gyB-hwkiNtc6KnxUVjWHOqbZRY7JVQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Trirong", + "variants": [ + "100", + "100italic", + "200", + "200italic", + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic", + "800", + "800italic", + "900", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "thai", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-13", + "files": { + "100": "http://fonts.gstatic.com/s/trirong/v9/7r3EqXNgp8wxdOdOl-go3YRl6ujngw.ttf", + "100italic": "http://fonts.gstatic.com/s/trirong/v9/7r3CqXNgp8wxdOdOn44QuY5hyO33g8IY.ttf", + "200": "http://fonts.gstatic.com/s/trirong/v9/7r3DqXNgp8wxdOdOl0QJ_a5L5uH-mts.ttf", + "200italic": "http://fonts.gstatic.com/s/trirong/v9/7r3BqXNgp8wxdOdOn44QFa9B4sP7itsB5g.ttf", + "300": "http://fonts.gstatic.com/s/trirong/v9/7r3DqXNgp8wxdOdOlyAK_a5L5uH-mts.ttf", + "300italic": "http://fonts.gstatic.com/s/trirong/v9/7r3BqXNgp8wxdOdOn44QcaxB4sP7itsB5g.ttf", + "regular": "http://fonts.gstatic.com/s/trirong/v9/7r3GqXNgp8wxdOdOr4wi2aZg-ug.ttf", + "italic": "http://fonts.gstatic.com/s/trirong/v9/7r3EqXNgp8wxdOdOn44o3YRl6ujngw.ttf", + "500": "http://fonts.gstatic.com/s/trirong/v9/7r3DqXNgp8wxdOdOl3gL_a5L5uH-mts.ttf", + "500italic": "http://fonts.gstatic.com/s/trirong/v9/7r3BqXNgp8wxdOdOn44QKa1B4sP7itsB5g.ttf", + "600": "http://fonts.gstatic.com/s/trirong/v9/7r3DqXNgp8wxdOdOl1QM_a5L5uH-mts.ttf", + "600italic": "http://fonts.gstatic.com/s/trirong/v9/7r3BqXNgp8wxdOdOn44QBapB4sP7itsB5g.ttf", + "700": "http://fonts.gstatic.com/s/trirong/v9/7r3DqXNgp8wxdOdOlzAN_a5L5uH-mts.ttf", + "700italic": "http://fonts.gstatic.com/s/trirong/v9/7r3BqXNgp8wxdOdOn44QYatB4sP7itsB5g.ttf", + "800": "http://fonts.gstatic.com/s/trirong/v9/7r3DqXNgp8wxdOdOlywO_a5L5uH-mts.ttf", + "800italic": "http://fonts.gstatic.com/s/trirong/v9/7r3BqXNgp8wxdOdOn44QfahB4sP7itsB5g.ttf", + "900": "http://fonts.gstatic.com/s/trirong/v9/7r3DqXNgp8wxdOdOlwgP_a5L5uH-mts.ttf", + "900italic": "http://fonts.gstatic.com/s/trirong/v9/7r3BqXNgp8wxdOdOn44QWalB4sP7itsB5g.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Trispace", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v6", + "lastModified": "2021-03-19", + "files": { + "100": "http://fonts.gstatic.com/s/trispace/v6/Yq65-LKSQC3o56LxxgRrtA6yBqsrXL5GI5KI-IUZVGsxWFIlbH9qoQl0zHugpt0.ttf", + "200": "http://fonts.gstatic.com/s/trispace/v6/Yq65-LKSQC3o56LxxgRrtA6yBqsrXL5GI5KI-IUZVGsxWFIlbP9roQl0zHugpt0.ttf", + "300": "http://fonts.gstatic.com/s/trispace/v6/Yq65-LKSQC3o56LxxgRrtA6yBqsrXL5GI5KI-IUZVGsxWFIlbCFroQl0zHugpt0.ttf", + "regular": "http://fonts.gstatic.com/s/trispace/v6/Yq65-LKSQC3o56LxxgRrtA6yBqsrXL5GI5KI-IUZVGsxWFIlbH9roQl0zHugpt0.ttf", + "500": "http://fonts.gstatic.com/s/trispace/v6/Yq65-LKSQC3o56LxxgRrtA6yBqsrXL5GI5KI-IUZVGsxWFIlbE1roQl0zHugpt0.ttf", + "600": "http://fonts.gstatic.com/s/trispace/v6/Yq65-LKSQC3o56LxxgRrtA6yBqsrXL5GI5KI-IUZVGsxWFIlbKFsoQl0zHugpt0.ttf", + "700": "http://fonts.gstatic.com/s/trispace/v6/Yq65-LKSQC3o56LxxgRrtA6yBqsrXL5GI5KI-IUZVGsxWFIlbJhsoQl0zHugpt0.ttf", + "800": "http://fonts.gstatic.com/s/trispace/v6/Yq65-LKSQC3o56LxxgRrtA6yBqsrXL5GI5KI-IUZVGsxWFIlbP9soQl0zHugpt0.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Trocchi", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/trocchi/v12/qWcqB6WkuIDxDZLcDrtUvMeTYD0.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Trochut", + "variants": [ + "regular", + "italic", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/trochut/v18/CHyjV-fDDlP9bDIw5nSIfVIPLns.ttf", + "italic": "http://fonts.gstatic.com/s/trochut/v18/CHyhV-fDDlP9bDIw1naCeXAKPns8jw.ttf", + "700": "http://fonts.gstatic.com/s/trochut/v18/CHymV-fDDlP9bDIw3sinWVokMnIllmA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Truculenta", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v6", + "lastModified": "2021-03-19", + "files": { + "100": "http://fonts.gstatic.com/s/truculenta/v6/LhWfMVvBKusVIfNYGi1-WvRVyDdZeeiySNppcu32Mb2f06y6Oa21F6XHi0VYDX_PzOupMlAjswcFHnJMMhg.ttf", + "200": "http://fonts.gstatic.com/s/truculenta/v6/LhWfMVvBKusVIfNYGi1-WvRVyDdZeeiySNppcu32Mb2f06y6Oa21F6XHi0VYDX_PzOupMtAiswcFHnJMMhg.ttf", + "300": "http://fonts.gstatic.com/s/truculenta/v6/LhWfMVvBKusVIfNYGi1-WvRVyDdZeeiySNppcu32Mb2f06y6Oa21F6XHi0VYDX_PzOupMg4iswcFHnJMMhg.ttf", + "regular": "http://fonts.gstatic.com/s/truculenta/v6/LhWfMVvBKusVIfNYGi1-WvRVyDdZeeiySNppcu32Mb2f06y6Oa21F6XHi0VYDX_PzOupMlAiswcFHnJMMhg.ttf", + "500": "http://fonts.gstatic.com/s/truculenta/v6/LhWfMVvBKusVIfNYGi1-WvRVyDdZeeiySNppcu32Mb2f06y6Oa21F6XHi0VYDX_PzOupMmIiswcFHnJMMhg.ttf", + "600": "http://fonts.gstatic.com/s/truculenta/v6/LhWfMVvBKusVIfNYGi1-WvRVyDdZeeiySNppcu32Mb2f06y6Oa21F6XHi0VYDX_PzOupMo4lswcFHnJMMhg.ttf", + "700": "http://fonts.gstatic.com/s/truculenta/v6/LhWfMVvBKusVIfNYGi1-WvRVyDdZeeiySNppcu32Mb2f06y6Oa21F6XHi0VYDX_PzOupMrclswcFHnJMMhg.ttf", + "800": "http://fonts.gstatic.com/s/truculenta/v6/LhWfMVvBKusVIfNYGi1-WvRVyDdZeeiySNppcu32Mb2f06y6Oa21F6XHi0VYDX_PzOupMtAlswcFHnJMMhg.ttf", + "900": "http://fonts.gstatic.com/s/truculenta/v6/LhWfMVvBKusVIfNYGi1-WvRVyDdZeeiySNppcu32Mb2f06y6Oa21F6XHi0VYDX_PzOupMvklswcFHnJMMhg.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Trykker", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/trykker/v19/KtktALyWZJXudUPzhNnoOd2j22U.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Tulpen One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/tulpenone/v12/dFa6ZfeC474skLgesc0CWj0w_HyIRlE.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Turret Road", + "variants": [ + "200", + "300", + "regular", + "500", + "700", + "800" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2022-01-13", + "files": { + "200": "http://fonts.gstatic.com/s/turretroad/v5/pxidypMgpcBFjE84Zv-fE0ONEdeLYk1Mq3ap.ttf", + "300": "http://fonts.gstatic.com/s/turretroad/v5/pxidypMgpcBFjE84Zv-fE0PpEteLYk1Mq3ap.ttf", + "regular": "http://fonts.gstatic.com/s/turretroad/v5/pxiAypMgpcBFjE84Zv-fE3tFOvODSVFF.ttf", + "500": "http://fonts.gstatic.com/s/turretroad/v5/pxidypMgpcBFjE84Zv-fE0OxE9eLYk1Mq3ap.ttf", + "700": "http://fonts.gstatic.com/s/turretroad/v5/pxidypMgpcBFjE84Zv-fE0P5FdeLYk1Mq3ap.ttf", + "800": "http://fonts.gstatic.com/s/turretroad/v5/pxidypMgpcBFjE84Zv-fE0PlFteLYk1Mq3ap.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Twinkle Star", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/twinklestar/v1/pe0pMI6IL4dPoFl9LGEmY6WaA_Rue1UwVg.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Ubuntu", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/ubuntu/v19/4iCv6KVjbNBYlgoC1CzTt2aMH4V_gg.ttf", + "300italic": "http://fonts.gstatic.com/s/ubuntu/v19/4iCp6KVjbNBYlgoKejZftWyIPYBvgpUI.ttf", + "regular": "http://fonts.gstatic.com/s/ubuntu/v19/4iCs6KVjbNBYlgo6eAT3v02QFg.ttf", + "italic": "http://fonts.gstatic.com/s/ubuntu/v19/4iCu6KVjbNBYlgoKeg7znUiAFpxm.ttf", + "500": "http://fonts.gstatic.com/s/ubuntu/v19/4iCv6KVjbNBYlgoCjC3Tt2aMH4V_gg.ttf", + "500italic": "http://fonts.gstatic.com/s/ubuntu/v19/4iCp6KVjbNBYlgoKejYHtGyIPYBvgpUI.ttf", + "700": "http://fonts.gstatic.com/s/ubuntu/v19/4iCv6KVjbNBYlgoCxCvTt2aMH4V_gg.ttf", + "700italic": "http://fonts.gstatic.com/s/ubuntu/v19/4iCp6KVjbNBYlgoKejZPsmyIPYBvgpUI.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ubuntu Condensed", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/ubuntucondensed/v15/u-4k0rCzjgs5J7oXnJcM_0kACGMtf-fVqvHoJXw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ubuntu Mono", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "greek-ext", + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/ubuntumono/v14/KFOjCneDtsqEr0keqCMhbBc9AMX6lJBP.ttf", + "italic": "http://fonts.gstatic.com/s/ubuntumono/v14/KFOhCneDtsqEr0keqCMhbCc_CsHYkYBPY3o.ttf", + "700": "http://fonts.gstatic.com/s/ubuntumono/v14/KFO-CneDtsqEr0keqCMhbC-BL-Hyv4xGemO1.ttf", + "700italic": "http://fonts.gstatic.com/s/ubuntumono/v14/KFO8CneDtsqEr0keqCMhbCc_Mn33tYhkf3O1GVg.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Uchen", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "tibetan" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/uchen/v5/nKKZ-GokGZ1baIaSEQGodLxA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Ultra", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2020-09-02", + "files": { + "regular": "http://fonts.gstatic.com/s/ultra/v13/zOLy4prXmrtY-tT6yLOD6NxF.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Uncial Antiqua", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/uncialantiqua/v18/N0bM2S5WOex4OUbESzoESK-i-PfRS5VBBSSF.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Underdog", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/underdog/v20/CHygV-jCElj7diMroVSiU14GN2Il.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Unica One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v11", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/unicaone/v11/DPEuYwWHyAYGVTSmalshdtffuEY7FA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "UnifrakturCook", + "variants": [ + "700" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "700": "http://fonts.gstatic.com/s/unifrakturcook/v17/IurA6Yli8YOdcoky-0PTTdkm56n05Uw13ILXs-h6.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "UnifrakturMaguntia", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/unifrakturmaguntia/v14/WWXPlieVYwiGNomYU-ciRLRvEmK7oaVun2xNNgNa1A.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Unkempt", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/unkempt/v12/2EbnL-Z2DFZue0DSSYYf8z2Yt_c.ttf", + "700": "http://fonts.gstatic.com/s/unkempt/v12/2EbiL-Z2DFZue0DScTow1zWzq_5uT84.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Unlock", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/unlock/v20/7Au-p_8ykD-cDl7GKAjSwkUVOQ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Unna", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v19", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/unna/v19/AYCEpXzofN0NCpgBlGHCWFM.ttf", + "italic": "http://fonts.gstatic.com/s/unna/v19/AYCKpXzofN0NOpoLkEPHSFNyxw.ttf", + "700": "http://fonts.gstatic.com/s/unna/v19/AYCLpXzofN0NMiQusGnpRFpr3vc.ttf", + "700italic": "http://fonts.gstatic.com/s/unna/v19/AYCJpXzofN0NOpozLGzjQHhuzvef5Q.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Urbanist", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/urbanist/v7/L0xjDF02iFML4hGCyOCpRdycFsGxSrqDyx8fFpOrS8SlKw.ttf", + "200": "http://fonts.gstatic.com/s/urbanist/v7/L0xjDF02iFML4hGCyOCpRdycFsGxSrqDSx4fFpOrS8SlKw.ttf", + "300": "http://fonts.gstatic.com/s/urbanist/v7/L0xjDF02iFML4hGCyOCpRdycFsGxSrqDlR4fFpOrS8SlKw.ttf", + "regular": "http://fonts.gstatic.com/s/urbanist/v7/L0xjDF02iFML4hGCyOCpRdycFsGxSrqDyx4fFpOrS8SlKw.ttf", + "500": "http://fonts.gstatic.com/s/urbanist/v7/L0xjDF02iFML4hGCyOCpRdycFsGxSrqD-R4fFpOrS8SlKw.ttf", + "600": "http://fonts.gstatic.com/s/urbanist/v7/L0xjDF02iFML4hGCyOCpRdycFsGxSrqDFRkfFpOrS8SlKw.ttf", + "700": "http://fonts.gstatic.com/s/urbanist/v7/L0xjDF02iFML4hGCyOCpRdycFsGxSrqDLBkfFpOrS8SlKw.ttf", + "800": "http://fonts.gstatic.com/s/urbanist/v7/L0xjDF02iFML4hGCyOCpRdycFsGxSrqDSxkfFpOrS8SlKw.ttf", + "900": "http://fonts.gstatic.com/s/urbanist/v7/L0xjDF02iFML4hGCyOCpRdycFsGxSrqDYhkfFpOrS8SlKw.ttf", + "100italic": "http://fonts.gstatic.com/s/urbanist/v7/L0xtDF02iFML4hGCyMqgdyNEf6or5L2WA133VJmvacG1K4S1.ttf", + "200italic": "http://fonts.gstatic.com/s/urbanist/v7/L0xtDF02iFML4hGCyMqgdyNEf6or5L2WA113VZmvacG1K4S1.ttf", + "300italic": "http://fonts.gstatic.com/s/urbanist/v7/L0xtDF02iFML4hGCyMqgdyNEf6or5L2WA12pVZmvacG1K4S1.ttf", + "italic": "http://fonts.gstatic.com/s/urbanist/v7/L0xtDF02iFML4hGCyMqgdyNEf6or5L2WA133VZmvacG1K4S1.ttf", + "500italic": "http://fonts.gstatic.com/s/urbanist/v7/L0xtDF02iFML4hGCyMqgdyNEf6or5L2WA13FVZmvacG1K4S1.ttf", + "600italic": "http://fonts.gstatic.com/s/urbanist/v7/L0xtDF02iFML4hGCyMqgdyNEf6or5L2WA10pUpmvacG1K4S1.ttf", + "700italic": "http://fonts.gstatic.com/s/urbanist/v7/L0xtDF02iFML4hGCyMqgdyNEf6or5L2WA10QUpmvacG1K4S1.ttf", + "800italic": "http://fonts.gstatic.com/s/urbanist/v7/L0xtDF02iFML4hGCyMqgdyNEf6or5L2WA113UpmvacG1K4S1.ttf", + "900italic": "http://fonts.gstatic.com/s/urbanist/v7/L0xtDF02iFML4hGCyMqgdyNEf6or5L2WA11eUpmvacG1K4S1.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "VT323", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/vt323/v15/pxiKyp0ihIEF2hsYHpT2dkNE.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Vampiro One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v16", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/vampiroone/v16/gokqH6DoDl5yXvJytFsdLkqnsvhIor3K.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Varela", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v14", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/varela/v14/DPEtYwqExx0AWHXJBBQFfvzDsQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Varela Round", + "variants": [ + "regular" + ], + "subsets": [ + "hebrew", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/varelaround/v17/w8gdH283Tvk__Lua32TysjIvoMGOD9gxZw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Varta", + "variants": [ + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/varta/v15/Qw3AZQpJHj_6LzHUngWbrFkDH1x96j4EirE-9PGLfQ.ttf", + "regular": "http://fonts.gstatic.com/s/varta/v15/Qw3AZQpJHj_6LzHUngWbrFkDH1x9tD4EirE-9PGLfQ.ttf", + "500": "http://fonts.gstatic.com/s/varta/v15/Qw3AZQpJHj_6LzHUngWbrFkDH1x9hj4EirE-9PGLfQ.ttf", + "600": "http://fonts.gstatic.com/s/varta/v15/Qw3AZQpJHj_6LzHUngWbrFkDH1x9ajkEirE-9PGLfQ.ttf", + "700": "http://fonts.gstatic.com/s/varta/v15/Qw3AZQpJHj_6LzHUngWbrFkDH1x9UzkEirE-9PGLfQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Vast Shadow", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/vastshadow/v13/pe0qMImKOZ1V62ZwbVY9dfe6Kdpickwp.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Vesper Libre", + "variants": [ + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v17", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/vesperlibre/v17/bx6CNxyWnf-uxPdXDHUD_Rd4D0-N2qIWVQ.ttf", + "500": "http://fonts.gstatic.com/s/vesperlibre/v17/bx6dNxyWnf-uxPdXDHUD_RdA-2ap0okKXKvPlw.ttf", + "700": "http://fonts.gstatic.com/s/vesperlibre/v17/bx6dNxyWnf-uxPdXDHUD_RdAs2Cp0okKXKvPlw.ttf", + "900": "http://fonts.gstatic.com/s/vesperlibre/v17/bx6dNxyWnf-uxPdXDHUD_RdAi2Kp0okKXKvPlw.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Viaoda Libre", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/viaodalibre/v13/vEFW2_lWCgoR6OKuRz9kcRVJb2IY2tOHXg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Vibes", + "variants": [ + "regular" + ], + "subsets": [ + "arabic", + "latin" + ], + "version": "v12", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/vibes/v12/QdVYSTsmIB6tmbd3HpbsuBlh.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Vibur", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v21", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/vibur/v21/DPEiYwmEzw0QRjTpLjoJd-Xa.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Vidaloka", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v16", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/vidaloka/v16/7cHrv4c3ipenMKlEass8yn4hnCci.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Viga", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/viga/v12/xMQbuFFdSaiX_QIjD4e2OX8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Voces", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/voces/v18/-F6_fjJyLyU8d4PBBG7YpzlJ.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Volkhov", + "variants": [ + "regular", + "italic", + "700", + "700italic" + ], + "subsets": [ + "latin" + ], + "version": "v15", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/volkhov/v15/SlGQmQieoJcKemNeQTIOhHxzcD0.ttf", + "italic": "http://fonts.gstatic.com/s/volkhov/v15/SlGSmQieoJcKemNecTAEgF52YD0NYw.ttf", + "700": "http://fonts.gstatic.com/s/volkhov/v15/SlGVmQieoJcKemNeeY4hoHRYbDQUego.ttf", + "700italic": "http://fonts.gstatic.com/s/volkhov/v15/SlGXmQieoJcKemNecTA8PHFSaBYRagrQrA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Vollkorn", + "variants": [ + "regular", + "500", + "600", + "700", + "800", + "900", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "greek", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v19", + "lastModified": "2022-02-03", + "files": { + "regular": "http://fonts.gstatic.com/s/vollkorn/v19/0ybgGDoxxrvAnPhYGzMlQLzuMasz6Df2MHGuGWOdEbD63w.ttf", + "500": "http://fonts.gstatic.com/s/vollkorn/v19/0ybgGDoxxrvAnPhYGzMlQLzuMasz6Df2AnGuGWOdEbD63w.ttf", + "600": "http://fonts.gstatic.com/s/vollkorn/v19/0ybgGDoxxrvAnPhYGzMlQLzuMasz6Df27nauGWOdEbD63w.ttf", + "700": "http://fonts.gstatic.com/s/vollkorn/v19/0ybgGDoxxrvAnPhYGzMlQLzuMasz6Df213auGWOdEbD63w.ttf", + "800": "http://fonts.gstatic.com/s/vollkorn/v19/0ybgGDoxxrvAnPhYGzMlQLzuMasz6Df2sHauGWOdEbD63w.ttf", + "900": "http://fonts.gstatic.com/s/vollkorn/v19/0ybgGDoxxrvAnPhYGzMlQLzuMasz6Df2mXauGWOdEbD63w.ttf", + "italic": "http://fonts.gstatic.com/s/vollkorn/v19/0ybuGDoxxrvAnPhYGxksckM2WMCpRjDj-DJGWmmZM7Xq34g9.ttf", + "500italic": "http://fonts.gstatic.com/s/vollkorn/v19/0ybuGDoxxrvAnPhYGxksckM2WMCpRjDj-DJ0WmmZM7Xq34g9.ttf", + "600italic": "http://fonts.gstatic.com/s/vollkorn/v19/0ybuGDoxxrvAnPhYGxksckM2WMCpRjDj-DKYXWmZM7Xq34g9.ttf", + "700italic": "http://fonts.gstatic.com/s/vollkorn/v19/0ybuGDoxxrvAnPhYGxksckM2WMCpRjDj-DKhXWmZM7Xq34g9.ttf", + "800italic": "http://fonts.gstatic.com/s/vollkorn/v19/0ybuGDoxxrvAnPhYGxksckM2WMCpRjDj-DLGXWmZM7Xq34g9.ttf", + "900italic": "http://fonts.gstatic.com/s/vollkorn/v19/0ybuGDoxxrvAnPhYGxksckM2WMCpRjDj-DLvXWmZM7Xq34g9.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Vollkorn SC", + "variants": [ + "regular", + "600", + "700", + "900" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v9", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/vollkornsc/v9/j8_v6-zQ3rXpceZj9cqnVhF5NH-iSq_E.ttf", + "600": "http://fonts.gstatic.com/s/vollkornsc/v9/j8_y6-zQ3rXpceZj9cqnVimhGluqYbPN5Yjn.ttf", + "700": "http://fonts.gstatic.com/s/vollkornsc/v9/j8_y6-zQ3rXpceZj9cqnVinFG1uqYbPN5Yjn.ttf", + "900": "http://fonts.gstatic.com/s/vollkornsc/v9/j8_y6-zQ3rXpceZj9cqnVin9GVuqYbPN5Yjn.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Voltaire", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/voltaire/v13/1Pttg8PcRfSblAvGvQooYKVnBOif.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Vujahday Script", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/vujahdayscript/v1/RWmQoKGA8fEkrIPtSZ3_J7er2dUiDEtvAlaMKw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Waiting for the Sunrise", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v14", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/waitingforthesunrise/v14/WBL1rFvOYl9CEv2i1mO6KUW8RKWJ2zoXoz5JsYZQ9h_ZYk5J.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Wallpoet", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2020-07-23", + "files": { + "regular": "http://fonts.gstatic.com/s/wallpoet/v12/f0X10em2_8RnXVVdUNbu7cXP8L8G.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Walter Turncoat", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2021-03-19", + "files": { + "regular": "http://fonts.gstatic.com/s/walterturncoat/v13/snfys0Gs98ln43n0d-14ULoToe67YB2dQ5ZPqQ.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Warnes", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v20", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/warnes/v20/pONn1hc0GsW6sW5OpiC2o6Lkqg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Waterfall", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v1", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/waterfall/v1/MCoRzAfo293fACdFKcwY2rH8D_EZwA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Wellfleet", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v18", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/wellfleet/v18/nuF7D_LfQJb3VYgX6eyT42aLDhO2HA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Wendy One", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/wendyone/v12/2sDcZGJOipXfgfXV5wgDb2-4C7wFZQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "WindSong", + "variants": [ + "regular", + "500" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/windsong/v5/KR1WBsyu-P-GFEW57r95HdG6vjH3.ttf", + "500": "http://fonts.gstatic.com/s/windsong/v5/KR1RBsyu-P-GFEW57oeNNPWylS3-jVXm.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Wire One", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v21", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/wireone/v21/qFdH35Wah5htUhV75WGiWdrCwwcJ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Work Sans", + "variants": [ + "100", + "200", + "300", + "regular", + "500", + "600", + "700", + "800", + "900", + "100italic", + "200italic", + "300italic", + "italic", + "500italic", + "600italic", + "700italic", + "800italic", + "900italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v16", + "lastModified": "2022-02-03", + "files": { + "100": "http://fonts.gstatic.com/s/worksans/v16/QGY_z_wNahGAdqQ43RhVcIgYT2Xz5u32K0nWNigDp6_cOyA.ttf", + "200": "http://fonts.gstatic.com/s/worksans/v16/QGY_z_wNahGAdqQ43RhVcIgYT2Xz5u32K8nXNigDp6_cOyA.ttf", + "300": "http://fonts.gstatic.com/s/worksans/v16/QGY_z_wNahGAdqQ43RhVcIgYT2Xz5u32KxfXNigDp6_cOyA.ttf", + "regular": "http://fonts.gstatic.com/s/worksans/v16/QGY_z_wNahGAdqQ43RhVcIgYT2Xz5u32K0nXNigDp6_cOyA.ttf", + "500": "http://fonts.gstatic.com/s/worksans/v16/QGY_z_wNahGAdqQ43RhVcIgYT2Xz5u32K3vXNigDp6_cOyA.ttf", + "600": "http://fonts.gstatic.com/s/worksans/v16/QGY_z_wNahGAdqQ43RhVcIgYT2Xz5u32K5fQNigDp6_cOyA.ttf", + "700": "http://fonts.gstatic.com/s/worksans/v16/QGY_z_wNahGAdqQ43RhVcIgYT2Xz5u32K67QNigDp6_cOyA.ttf", + "800": "http://fonts.gstatic.com/s/worksans/v16/QGY_z_wNahGAdqQ43RhVcIgYT2Xz5u32K8nQNigDp6_cOyA.ttf", + "900": "http://fonts.gstatic.com/s/worksans/v16/QGY_z_wNahGAdqQ43RhVcIgYT2Xz5u32K-DQNigDp6_cOyA.ttf", + "100italic": "http://fonts.gstatic.com/s/worksans/v16/QGY9z_wNahGAdqQ43Rh_ebrnlwyYfEPxPoGU3moJo43ZKyDSQQ.ttf", + "200italic": "http://fonts.gstatic.com/s/worksans/v16/QGY9z_wNahGAdqQ43Rh_ebrnlwyYfEPxPoGUXmsJo43ZKyDSQQ.ttf", + "300italic": "http://fonts.gstatic.com/s/worksans/v16/QGY9z_wNahGAdqQ43Rh_ebrnlwyYfEPxPoGUgGsJo43ZKyDSQQ.ttf", + "italic": "http://fonts.gstatic.com/s/worksans/v16/QGY9z_wNahGAdqQ43Rh_ebrnlwyYfEPxPoGU3msJo43ZKyDSQQ.ttf", + "500italic": "http://fonts.gstatic.com/s/worksans/v16/QGY9z_wNahGAdqQ43Rh_ebrnlwyYfEPxPoGU7GsJo43ZKyDSQQ.ttf", + "600italic": "http://fonts.gstatic.com/s/worksans/v16/QGY9z_wNahGAdqQ43Rh_ebrnlwyYfEPxPoGUAGwJo43ZKyDSQQ.ttf", + "700italic": "http://fonts.gstatic.com/s/worksans/v16/QGY9z_wNahGAdqQ43Rh_ebrnlwyYfEPxPoGUOWwJo43ZKyDSQQ.ttf", + "800italic": "http://fonts.gstatic.com/s/worksans/v16/QGY9z_wNahGAdqQ43Rh_ebrnlwyYfEPxPoGUXmwJo43ZKyDSQQ.ttf", + "900italic": "http://fonts.gstatic.com/s/worksans/v16/QGY9z_wNahGAdqQ43Rh_ebrnlwyYfEPxPoGUd2wJo43ZKyDSQQ.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Xanh Mono", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/xanhmono/v15/R70YjykVmvKCep-vWhSYmACQXzLhTg.ttf", + "italic": "http://fonts.gstatic.com/s/xanhmono/v15/R70ejykVmvKCep-vWhSomgqUfTfxTo24.ttf" + }, + "category": "monospace", + "kind": "webfonts#webfont" + }, + { + "family": "Yaldevi", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "latin", + "latin-ext", + "sinhala" + ], + "version": "v6", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/yaldevi/v6/cY9afj6VW0NMrDWtDNzCOwlPMq9SLpfxJzvobxLCBJkS.ttf", + "300": "http://fonts.gstatic.com/s/yaldevi/v6/cY9afj6VW0NMrDWtDNzCOwlPMq9SLpcvJzvobxLCBJkS.ttf", + "regular": "http://fonts.gstatic.com/s/yaldevi/v6/cY9afj6VW0NMrDWtDNzCOwlPMq9SLpdxJzvobxLCBJkS.ttf", + "500": "http://fonts.gstatic.com/s/yaldevi/v6/cY9afj6VW0NMrDWtDNzCOwlPMq9SLpdDJzvobxLCBJkS.ttf", + "600": "http://fonts.gstatic.com/s/yaldevi/v6/cY9afj6VW0NMrDWtDNzCOwlPMq9SLpevIDvobxLCBJkS.ttf", + "700": "http://fonts.gstatic.com/s/yaldevi/v6/cY9afj6VW0NMrDWtDNzCOwlPMq9SLpeWIDvobxLCBJkS.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Yanone Kaffeesatz", + "variants": [ + "200", + "300", + "regular", + "500", + "600", + "700" + ], + "subsets": [ + "cyrillic", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v22", + "lastModified": "2022-02-03", + "files": { + "200": "http://fonts.gstatic.com/s/yanonekaffeesatz/v22/3y9I6aknfjLm_3lMKjiMgmUUYBs04aUXNxt9gW2LIftodtWpcGuLCnXkVA.ttf", + "300": "http://fonts.gstatic.com/s/yanonekaffeesatz/v22/3y9I6aknfjLm_3lMKjiMgmUUYBs04aUXNxt9gW2LIftoqNWpcGuLCnXkVA.ttf", + "regular": "http://fonts.gstatic.com/s/yanonekaffeesatz/v22/3y9I6aknfjLm_3lMKjiMgmUUYBs04aUXNxt9gW2LIfto9tWpcGuLCnXkVA.ttf", + "500": "http://fonts.gstatic.com/s/yanonekaffeesatz/v22/3y9I6aknfjLm_3lMKjiMgmUUYBs04aUXNxt9gW2LIftoxNWpcGuLCnXkVA.ttf", + "600": "http://fonts.gstatic.com/s/yanonekaffeesatz/v22/3y9I6aknfjLm_3lMKjiMgmUUYBs04aUXNxt9gW2LIftoKNKpcGuLCnXkVA.ttf", + "700": "http://fonts.gstatic.com/s/yanonekaffeesatz/v22/3y9I6aknfjLm_3lMKjiMgmUUYBs04aUXNxt9gW2LIftoEdKpcGuLCnXkVA.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Yantramanav", + "variants": [ + "100", + "300", + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-27", + "files": { + "100": "http://fonts.gstatic.com/s/yantramanav/v10/flU-Rqu5zY00QEpyWJYWN5-QXeNzDB41rZg.ttf", + "300": "http://fonts.gstatic.com/s/yantramanav/v10/flUhRqu5zY00QEpyWJYWN59Yf8NZIhI8tIHh.ttf", + "regular": "http://fonts.gstatic.com/s/yantramanav/v10/flU8Rqu5zY00QEpyWJYWN6f0V-dRCQ41.ttf", + "500": "http://fonts.gstatic.com/s/yantramanav/v10/flUhRqu5zY00QEpyWJYWN58AfsNZIhI8tIHh.ttf", + "700": "http://fonts.gstatic.com/s/yantramanav/v10/flUhRqu5zY00QEpyWJYWN59IeMNZIhI8tIHh.ttf", + "900": "http://fonts.gstatic.com/s/yantramanav/v10/flUhRqu5zY00QEpyWJYWN59wesNZIhI8tIHh.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Yatra One", + "variants": [ + "regular" + ], + "subsets": [ + "devanagari", + "latin", + "latin-ext" + ], + "version": "v12", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/yatraone/v12/C8ch4copsHzj8p7NaF0xw1OBbRDvXw.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Yellowtail", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v17", + "lastModified": "2022-01-27", + "files": { + "regular": "http://fonts.gstatic.com/s/yellowtail/v17/OZpGg_pnoDtINPfRIlLotlzNwED-b4g.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Yeon Sung", + "variants": [ + "regular" + ], + "subsets": [ + "korean", + "latin" + ], + "version": "v18", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/yeonsung/v18/QldMNTpbohAGtsJvUn6xSVNazqx2xg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Yeseva One", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "cyrillic-ext", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v18", + "lastModified": "2022-01-25", + "files": { + "regular": "http://fonts.gstatic.com/s/yesevaone/v18/OpNJno4ck8vc-xYpwWWxpipfWhXD00c.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Yesteryear", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v12", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/yesteryear/v12/dg4g_p78rroaKl8kRKo1r7wHTwonmyw.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Yomogi", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v6", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/yomogi/v6/VuJwdNrS2ZL7rpoPWIz5NIh-YA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Yrsa", + "variants": [ + "300", + "regular", + "500", + "600", + "700", + "300italic", + "italic", + "500italic", + "600italic", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext", + "vietnamese" + ], + "version": "v13", + "lastModified": "2022-02-03", + "files": { + "300": "http://fonts.gstatic.com/s/yrsa/v13/wlprgwnQFlxs_wD3CFSMYmFaaCjASNNV9rRPfrKu.ttf", + "regular": "http://fonts.gstatic.com/s/yrsa/v13/wlprgwnQFlxs_wD3CFSMYmFaaCieSNNV9rRPfrKu.ttf", + "500": "http://fonts.gstatic.com/s/yrsa/v13/wlprgwnQFlxs_wD3CFSMYmFaaCisSNNV9rRPfrKu.ttf", + "600": "http://fonts.gstatic.com/s/yrsa/v13/wlprgwnQFlxs_wD3CFSMYmFaaChAT9NV9rRPfrKu.ttf", + "700": "http://fonts.gstatic.com/s/yrsa/v13/wlprgwnQFlxs_wD3CFSMYmFaaCh5T9NV9rRPfrKu.ttf", + "300italic": "http://fonts.gstatic.com/s/yrsa/v13/wlptgwnQFlxs1QnF94zlCfv0bz1WC2UW_LBte6KuGEo.ttf", + "italic": "http://fonts.gstatic.com/s/yrsa/v13/wlptgwnQFlxs1QnF94zlCfv0bz1WCzsW_LBte6KuGEo.ttf", + "500italic": "http://fonts.gstatic.com/s/yrsa/v13/wlptgwnQFlxs1QnF94zlCfv0bz1WCwkW_LBte6KuGEo.ttf", + "600italic": "http://fonts.gstatic.com/s/yrsa/v13/wlptgwnQFlxs1QnF94zlCfv0bz1WC-UR_LBte6KuGEo.ttf", + "700italic": "http://fonts.gstatic.com/s/yrsa/v13/wlptgwnQFlxs1QnF94zlCfv0bz1WC9wR_LBte6KuGEo.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Yuji Boku", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/yujiboku/v3/P5sAzZybeNzXsA9xj1Fkjb2r2dgvJA.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Yuji Hentaigana Akari", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext" + ], + "version": "v6", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/yujihentaiganaakari/v6/cY9bfiyVT0VB6QuhWKOrpr6z58lnb_zYFnLIRTzODYALaA.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Yuji Hentaigana Akebono", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext" + ], + "version": "v6", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/yujihentaiganaakebono/v6/EJRGQhkhRNwM-RtitGUwh930GU_f5KAlkuL0wQy9NKXRzrrF.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Yuji Mai", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/yujimai/v3/ZgNQjPxdJ7DEHrS0gC38hmHmNpCO.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Yuji Syuku", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v3", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/yujisyuku/v3/BngNUXdTV3vO6Lw5ApOPqPfgwqiA-Rk.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Yusei Magic", + "variants": [ + "regular" + ], + "subsets": [ + "japanese", + "latin", + "latin-ext" + ], + "version": "v9", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/yuseimagic/v9/yYLt0hbAyuCmoo5wlhPkpjHR-tdfcIT_.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "ZCOOL KuaiLe", + "variants": [ + "regular" + ], + "subsets": [ + "chinese-simplified", + "latin" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/zcoolkuaile/v15/tssqApdaRQokwFjFJjvM6h2WpozzoXhC2g.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "ZCOOL QingKe HuangYou", + "variants": [ + "regular" + ], + "subsets": [ + "chinese-simplified", + "latin" + ], + "version": "v11", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/zcoolqingkehuangyou/v11/2Eb5L_R5IXJEWhD3AOhSvFC554MOOahI4mRIi_28c8bHWA.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "ZCOOL XiaoWei", + "variants": [ + "regular" + ], + "subsets": [ + "chinese-simplified", + "latin" + ], + "version": "v8", + "lastModified": "2022-01-11", + "files": { + "regular": "http://fonts.gstatic.com/s/zcoolxiaowei/v8/i7dMIFFrTRywPpUVX9_RJyM1YFKQHwyVd3U.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Zen Antique", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "greek", + "japanese", + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/zenantique/v7/AYCPpXPnd91Ma_Zf-Ri2JXJq7PKP5Z_G.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Zen Antique Soft", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "greek", + "japanese", + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/zenantiquesoft/v7/DtV4JwqzSL1q_KwnEWMc_3xfgW6ihwBmkui5HNg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Zen Dots", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v8", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/zendots/v8/XRXX3ICfm00IGoesQeaETM_FcCIG.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Zen Kaku Gothic Antique", + "variants": [ + "300", + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2021-12-17", + "files": { + "300": "http://fonts.gstatic.com/s/zenkakugothicantique/v7/6qLVKYkHvh-nlUpKPAdoVFBtfxDzIn1eCzpB22cM9TarWJtyZyGU.ttf", + "regular": "http://fonts.gstatic.com/s/zenkakugothicantique/v7/6qLQKYkHvh-nlUpKPAdoVFBtfxDzIn1eCzpB21-g3RKjc4d7.ttf", + "500": "http://fonts.gstatic.com/s/zenkakugothicantique/v7/6qLVKYkHvh-nlUpKPAdoVFBtfxDzIn1eCzpB22dU9DarWJtyZyGU.ttf", + "700": "http://fonts.gstatic.com/s/zenkakugothicantique/v7/6qLVKYkHvh-nlUpKPAdoVFBtfxDzIn1eCzpB22cc8jarWJtyZyGU.ttf", + "900": "http://fonts.gstatic.com/s/zenkakugothicantique/v7/6qLVKYkHvh-nlUpKPAdoVFBtfxDzIn1eCzpB22ck8DarWJtyZyGU.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Zen Kaku Gothic New", + "variants": [ + "300", + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "cyrillic", + "japanese", + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2021-12-17", + "files": { + "300": "http://fonts.gstatic.com/s/zenkakugothicnew/v7/gNMVW2drQpDw0GjzrVNFf_valaDBcznOqpdKaWTSTGlMyd8.ttf", + "regular": "http://fonts.gstatic.com/s/zenkakugothicnew/v7/gNMYW2drQpDw0GjzrVNFf_valaDBcznOkjtiTWz5UGA.ttf", + "500": "http://fonts.gstatic.com/s/zenkakugothicnew/v7/gNMVW2drQpDw0GjzrVNFf_valaDBcznOqs9LaWTSTGlMyd8.ttf", + "700": "http://fonts.gstatic.com/s/zenkakugothicnew/v7/gNMVW2drQpDw0GjzrVNFf_valaDBcznOqodNaWTSTGlMyd8.ttf", + "900": "http://fonts.gstatic.com/s/zenkakugothicnew/v7/gNMVW2drQpDw0GjzrVNFf_valaDBcznOqr9PaWTSTGlMyd8.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Zen Kurenaido", + "variants": [ + "regular" + ], + "subsets": [ + "cyrillic", + "greek", + "japanese", + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/zenkurenaido/v7/3XFsEr0515BK2u6UUptu_gWJZfz22PRLd0U.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Zen Loop", + "variants": [ + "regular", + "italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/zenloop/v5/h0GrssK16UsnJwHsEK9zqwzX5vOG.ttf", + "italic": "http://fonts.gstatic.com/s/zenloop/v5/h0GtssK16UsnJwHsEJ9xoQj14-OGJ0w.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Zen Maru Gothic", + "variants": [ + "300", + "regular", + "500", + "700", + "900" + ], + "subsets": [ + "cyrillic", + "greek", + "japanese", + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2021-12-17", + "files": { + "300": "http://fonts.gstatic.com/s/zenmarugothic/v7/o-0XIpIxzW5b-RxT-6A8jWAtCp-cQWpCPJqa_ajlvw.ttf", + "regular": "http://fonts.gstatic.com/s/zenmarugothic/v7/o-0SIpIxzW5b-RxT-6A8jWAtCp-k7UJmNLGG9A.ttf", + "500": "http://fonts.gstatic.com/s/zenmarugothic/v7/o-0XIpIxzW5b-RxT-6A8jWAtCp-cGWtCPJqa_ajlvw.ttf", + "700": "http://fonts.gstatic.com/s/zenmarugothic/v7/o-0XIpIxzW5b-RxT-6A8jWAtCp-cUW1CPJqa_ajlvw.ttf", + "900": "http://fonts.gstatic.com/s/zenmarugothic/v7/o-0XIpIxzW5b-RxT-6A8jWAtCp-caW9CPJqa_ajlvw.ttf" + }, + "category": "sans-serif", + "kind": "webfonts#webfont" + }, + { + "family": "Zen Old Mincho", + "variants": [ + "regular", + "700", + "900" + ], + "subsets": [ + "cyrillic", + "greek", + "japanese", + "latin", + "latin-ext" + ], + "version": "v7", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/zenoldmincho/v7/tss0ApVaYytLwxTqcxfMyBveyYb3g31S2s8p.ttf", + "700": "http://fonts.gstatic.com/s/zenoldmincho/v7/tss3ApVaYytLwxTqcxfMyBveyb5LrFla8dMgPgBu.ttf", + "900": "http://fonts.gstatic.com/s/zenoldmincho/v7/tss3ApVaYytLwxTqcxfMyBveyb5zrlla8dMgPgBu.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Zen Tokyo Zoo", + "variants": [ + "regular" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v5", + "lastModified": "2021-12-17", + "files": { + "regular": "http://fonts.gstatic.com/s/zentokyozoo/v5/NGSyv5ffC0J_BK6aFNtr6sRv8a1uRWe9amg.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + }, + { + "family": "Zeyada", + "variants": [ + "regular" + ], + "subsets": [ + "latin" + ], + "version": "v13", + "lastModified": "2022-01-13", + "files": { + "regular": "http://fonts.gstatic.com/s/zeyada/v13/11hAGpPTxVPUbgZDNGatWKaZ3g.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Zhi Mang Xing", + "variants": [ + "regular" + ], + "subsets": [ + "chinese-simplified", + "latin" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/zhimangxing/v15/f0Xw0ey79sErYFtWQ9a2rq-g0actfektIJ0.ttf" + }, + "category": "handwriting", + "kind": "webfonts#webfont" + }, + { + "family": "Zilla Slab", + "variants": [ + "300", + "300italic", + "regular", + "italic", + "500", + "500italic", + "600", + "600italic", + "700", + "700italic" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v10", + "lastModified": "2022-01-27", + "files": { + "300": "http://fonts.gstatic.com/s/zillaslab/v10/dFa5ZfeM_74wlPZtksIFYpEY2HSjWlhzbaw.ttf", + "300italic": "http://fonts.gstatic.com/s/zillaslab/v10/dFanZfeM_74wlPZtksIFaj8CVHapXnp2fazkfg.ttf", + "regular": "http://fonts.gstatic.com/s/zillaslab/v10/dFa6ZfeM_74wlPZtksIFWj0w_HyIRlE.ttf", + "italic": "http://fonts.gstatic.com/s/zillaslab/v10/dFa4ZfeM_74wlPZtksIFaj86-F6NVlFqdA.ttf", + "500": "http://fonts.gstatic.com/s/zillaslab/v10/dFa5ZfeM_74wlPZtksIFYskZ2HSjWlhzbaw.ttf", + "500italic": "http://fonts.gstatic.com/s/zillaslab/v10/dFanZfeM_74wlPZtksIFaj8CDHepXnp2fazkfg.ttf", + "600": "http://fonts.gstatic.com/s/zillaslab/v10/dFa5ZfeM_74wlPZtksIFYuUe2HSjWlhzbaw.ttf", + "600italic": "http://fonts.gstatic.com/s/zillaslab/v10/dFanZfeM_74wlPZtksIFaj8CIHCpXnp2fazkfg.ttf", + "700": "http://fonts.gstatic.com/s/zillaslab/v10/dFa5ZfeM_74wlPZtksIFYoEf2HSjWlhzbaw.ttf", + "700italic": "http://fonts.gstatic.com/s/zillaslab/v10/dFanZfeM_74wlPZtksIFaj8CRHGpXnp2fazkfg.ttf" + }, + "category": "serif", + "kind": "webfonts#webfont" + }, + { + "family": "Zilla Slab Highlight", + "variants": [ + "regular", + "700" + ], + "subsets": [ + "latin", + "latin-ext" + ], + "version": "v15", + "lastModified": "2022-01-05", + "files": { + "regular": "http://fonts.gstatic.com/s/zillaslabhighlight/v15/gNMbW2BrTpK8-inLtBJgMMfbm6uNVDvRxhtIY2DwSXlM.ttf", + "700": "http://fonts.gstatic.com/s/zillaslabhighlight/v15/gNMUW2BrTpK8-inLtBJgMMfbm6uNVDvRxiP0TET4YmVF0Mb6.ttf" + }, + "category": "display", + "kind": "webfonts#webfont" + } + ] +} diff --git a/backend/locales/ar.json b/backend/locales/ar.json new file mode 100644 index 000000000..7fcb0e191 --- /dev/null +++ b/backend/locales/ar.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "is loaded and", + "enabled": "مفعّل", + "disabled": "معطّل", + "usage": "الإستخدام", + "lang-selected": "تم تعيين لغة البوت حاليا إلى اللغة العربية", + "refresh-panel": "You will need to refresh UI to see changes.", + "command-parse": "عذراً، $sender، ولكن هذا الأمر غير صحيح، استخدم", + "error": "عذراً، $sender، لكن حدث خطأ ما!", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, API is not responding correctly!", + "not-available": "not available" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "year|years", + "months": "month|months", + "days": "day|days", + "hours": "hour|hours", + "minutes": "minute|minutes", + "seconds": "second|seconds", + "messages": "message|messages", + "bits": "bit|bits", + "links": "link|links", + "entries": "entry|entries", + "empty": "empty", + "isRegistered": "$sender, you cannot use !$keyword, because is already in use for another action!" + }, + "clip": { + "notCreated": "Something went wrong and clip was not created.", + "offline": "Stream is currently offline and clip cannot be created." + }, + "uptime": { + "online": "Stream is online for (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)", + "offline": "Stream is currently offline for (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)" + }, + "webpanel": { + "this-system-is-disabled": "This system is disabled", + "or": "or", + "loading": "Loading", + "this-may-take-a-while": "This may take a while", + "display-as": "Display as", + "go-to-admin": "Go to Admin", + "go-to-public": "Go to Public", + "logout": "Logout", + "popout": "Popout", + "not-logged-in": "Not logged in", + "remove-widget": "Remove $name widget", + "join-channel": "Join bot to channel", + "leave-channel": "Leave bot from channel", + "set-default": "Set default", + "add": "Add", + "placeholders": { + "text-url-generator": "Paste your text or html to generate base64 below and URL above", + "text-decode-base64": "Paste your base64 to generate URL and text above", + "creditsSpeed": "Set speed of credits rolling, lower = faster" + }, + "timers": { + "title": "Timers", + "timer": "Timer", + "messages": "messages", + "seconds": "seconds", + "badges": { + "enabled": "Enabled", + "disabled": "Disabled" + }, + "errors": { + "timer_name_must_be_compliant": "This value can contain only a-zA-Z09_", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty" + }, + "dialog": { + "timer": "Timer", + "name": "Name", + "tickOffline": "Should tick if stream offline", + "interval": "Interval", + "responses": "Responses", + "messages": "Trigger every X Messages", + "seconds": "Trigger every X Seconds", + "title": { + "new": "New timer", + "edit": "Edit timer" + }, + "placeholders": { + "name": "Set name of your timer, can contain only these characters a-zA-Z0-9_", + "messages": "Trigger timer each X messages", + "seconds": "Trigger timer each X seconds" + }, + "alerts": { + "success": "Timer was successfully saved.", + "fail": "Something went wrong." + } + }, + "buttons": { + "close": "Close", + "save-changes": "Save changes", + "disable": "Disable", + "enable": "Enable", + "edit": "Edit", + "delete": "Delete", + "yes": "Yes", + "no": "No" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Are you sure you want to delete timer" + } + }, + "events": { + "event": "Event", + "noEvents": "No events found in database.", + "whatsthis": "what's this?", + "myRewardIsNotListed": "My reward is not listed!", + "redeemAndClickRefreshToSeeReward": "If you are missing your created reward in a list, refresh by clicking on refresh icon.", + "badges": { + "enabled": "Enabled", + "disabled": "Disabled" + }, + "buttons": { + "test": "Test", + "enable": "Enable", + "disable": "Disable", + "edit": "Edit", + "delete": "Delete", + "yes": "Yes", + "no": "No" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Are you sure you want to delete event", + "example_of_user_object_data": "Example of user object data" + }, + "errors": { + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty" + }, + "dialog": { + "title": { + "new": "New event listener", + "edit": "Edit event listener" + }, + "placeholders": { + "name": "Set name of your event listener (if empty, name will be generated)" + }, + "alerts": { + "success": "Event was successfully saved.", + "fail": "Something went wrong." + }, + "close": "Close", + "save-changes": "Save changes", + "event": "Event", + "name": "Name", + "usable-events-variables": "Usable events variables", + "settings": "Settings", + "filters": "Filters", + "operations": "Operations" + }, + "definitions": { + "taskId": { + "label": "Task ID" + }, + "filter": { + "label": "Filter" + }, + "linkFilter": { + "label": "Link Overlay Filter", + "placeholder": "If using overlay, add link or id of your overlay" + }, + "hashtag": { + "label": "Hashtag or Keyword", + "placeholder": "#yourHashtagHere or Keyword" + }, + "fadeOutXCommands": { + "label": "Fade Out X Commands", + "placeholder": "Number of commands subtracted every fade out interval" + }, + "fadeOutXKeywords": { + "label": "Fade Out X Keywords", + "placeholder": "Number of keywords subtracted every fade out interval" + }, + "fadeOutInterval": { + "label": "Fade Out Interval (seconds)", + "placeholder": "Fade out interval subtracting" + }, + "runEveryXCommands": { + "label": "Run Every X Commands", + "placeholder": "Number of commands before event is triggered" + }, + "runEveryXKeywords": { + "label": "Run Every X Keywords", + "placeholder": "Number of keywords before event is triggered" + }, + "commandToWatch": { + "label": "Command To Watch", + "placeholder": "Set your !commandToWatch" + }, + "keywordToWatch": { + "label": "Keyword To Watch", + "placeholder": "Set your keywordToWatch" + }, + "resetCountEachMessage": { + "label": "Reset count each message", + "true": "Reset count", + "false": "Keep count" + }, + "viewersAtLeast": { + "label": "Viewers At Least", + "placeholder": "How many viewers at least to trigger event" + }, + "runInterval": { + "label": "Run Interval (0 = run once per stream)", + "placeholder": "Trigger event every x seconds" + }, + "runAfterXMinutes": { + "label": "Run After X Minutes", + "placeholder": "Trigger event after x minutes" + }, + "runEveryXMinutes": { + "label": "Run Every X Minutes", + "placeholder": "Trigger event every x minutes" + }, + "messageToSend": { + "label": "Message To Send", + "placeholder": "Set your message" + }, + "channel": { + "label": "Channel", + "placeholder": "Channelname or ID" + }, + "timeout": { + "label": "Timeout", + "placeholder": "Set timeout in milliseconds" + }, + "timeoutType": { + "label": "Type of timeout", + "placeholder": "Set type of timeout" + }, + "command": { + "label": "Command", + "placeholder": "Set your !command" + }, + "commandToRun": { + "label": "Command To Run", + "placeholder": "Set your !commandToRun" + }, + "isCommandQuiet": { + "label": "Mute command output" + }, + "urlOfSoundFile": { + "label": "Url Of Your Sound File", + "placeholder": "http://www.pathToYour.url/where/is/file.mp3" + }, + "emotesToExplode": { + "label": "Emotes To Explode", + "placeholder": "List of emotes to explode, e.g. Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Emotes To Firework", + "placeholder": "List of emotes to firework, e.g. Kappa PurpleHeart" + }, + "replay": { + "label": "Replay clip in overlay", + "true": "Will play in as replay in overlay/alerts", + "false": "Replay won't be played" + }, + "announce": { + "label": "Announce in chat", + "true": "Will be announced", + "false": "Will not be announced" + }, + "hasDelay": { + "label": "Clip should have slight delay (to be closer what viewer see)", + "true": "Will have delay", + "false": "Will not have delay" + }, + "durationOfCommercial": { + "label": "Duration Of Commercial", + "placeholder": "Available durations - 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Custom variable to update" + }, + "numberToIncrement": { + "label": "Number to increment", + "placeholder": "" + }, + "value": { + "label": "Value", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Number to decrement", + "placeholder": "" + }, + "": "", + "reward": { + "label": "Reward", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Followed you", + "raid": "Raided you with $viewers raiders.", + "sub": "Subscribed to you with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "has been gifted subscription from $username", + "subcommunitygift": "Gifted subscriptions for community", + "resub": "Resubscribed with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Cheered you", + "tip": "Tipped you", + "tipToCharity": "donated to $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Twitch Prediction - Title", + "outcomes": "Twitch Prediction - Outcomes", + "locksAt": "Twitch Prediction - Locks At Date", + "winningOutcomeTitle": "Twitch Prediction - Winning outcome title", + "winningOutcomeTotalPoints": "Twitch Prediction - Winning outcome total points", + "winningOutcomePercentage": "Twitch Prediction - Winning outcome percentage", + "titleOfPoll": "Twitch Poll - Title", + "bitAmountPerVote": "Twitch Poll - Amount of bits to count as 1 vote", + "bitVotingEnabled": "Twitch Poll - Is bit voting enabled (boolean)", + "channelPointsAmountPerVote": "Twitch Poll - Amount of channel points to count as 1 vote", + "channelPointsVotingEnabled": "Twitch Poll - Is channel points voting enabled (boolean)", + "votes": "Twitch Poll - votes count", + "winnerChoice": "Twitch Poll - Winner choice", + "winnerPercentage": "Twitch Poll - Winner choice percentage", + "winnerVotes": "Twitch Poll - Winner choice votes", + "goal": "Goal", + "total": "Total", + "lastContributionTotal": "Last Contribution - Total", + "lastContributionType": "Last Contribution - Type", + "lastContributionUserId": "Last Contribution - User ID", + "lastContributionUsername": "Last Contribution - Username", + "level": "Level", + "topContributionsBitsTotal": "Top Bits Contribution - Total", + "topContributionsBitsUserId": "Top Bits Contribution - User ID", + "topContributionsBitsUsername": "Top Bits Contribution - Username", + "topContributionsSubsTotal": "Top Subs Contribution - Total", + "topContributionsSubsUserId": "Top Subs Contribution - User ID", + "topContributionsSubsUsername": "Top Subs Contribution - Username", + "sender": "User who initiated", + "title": "Current title", + "game": "Current category", + "language": "Current stream language", + "viewers": "Current viewers count", + "hostViewers": "Raid viewers count", + "followers": "Current followers count", + "subscribers": "Current subscribers count", + "arg": "Argument", + "param": "Parameter (required)", + "touser": "Username parameter", + "!param": "Parameter (not required)", + "alias": "Alias", + "command": "Command", + "keyword": "Keyword", + "response": "Response", + "list": "Populated list", + "type": "Type", + "days": "Days", + "hours": "Hours", + "minutes": "Minutes", + "seconds": "Seconds", + "description": "Description", + "quiet": "Quiet (bool)", + "id": "ID", + "name": "Name", + "messages": "Messages", + "amount": "Amount", + "amountInBotCurrency": "Amount in bot currency", + "currency": "Currency", + "currencyInBot": "Currency in bot", + "pointsName": "Points name", + "points": "Points", + "rank": "Rank", + "nextrank": "Next rank", + "username": "Username", + "value": "Value", + "variable": "Variable", + "count": "Count", + "link": "Link (translated)", + "winner": "Winner", + "loser": "Loser", + "challenger": "Challenger", + "min": "Minimum", + "max": "Maximum", + "eligibility": "Eligibility", + "probability": "Probability", + "time": "Time", + "options": "Options", + "option": "Option", + "when": "When", + "diff": "Difference", + "users": "Users", + "user": "User", + "bank": "Bank", + "nextBank": "Next bank", + "cooldown": "Cooldown", + "tickets": "Tickets", + "ticketsName": "Tickets name", + "fromUsername": "From username", + "toUsername": "To username", + "items": "Items", + "bits": "Bits", + "subgifts": "Subgifts", + "subStreakShareEnabled": "Is substreak share enabled (true/false)", + "subStreak": "Current sub streak", + "subStreakName": "localized name of month (1 month, 2 months) for current sub strek", + "subCumulativeMonths": "Cumulative subscribe months", + "subCumulativeMonthsName": "localized name of month (1 month, 2 months) for cumulative subscribe months", + "message": "Message", + "reason": "Reason", + "target": "Target", + "duration": "Duration", + "method": "Method", + "tier": "Tier", + "months": "Months", + "monthsName": "localized name of month (1 month, 2 months)", + "oldGame": "Category before change", + "recipientObject": "Full recipient object", + "recipient": "Recipient", + "ytSong": "Current song on YouTube", + "spotifySong": "Current song on Spotify", + "latestFollower": "Latest Follower", + "latestSubscriber": "Latest Subscriber", + "latestSubscriberMonths": "Latest Subscriber cumulative months", + "latestSubscriberStreak": "Latest Subscriber months streak", + "latestTipAmount": "Latest Tip (amount)", + "latestTipCurrency": "Latest Tip (currency)", + "latestTipMessage": "Latest Tip (message)", + "latestTip": "Latest Tip (username)", + "toptip": { + "overall": { + "username": "Top Tip - overall (username)", + "amount": "Top Tip - overall (amount)", + "currency": "Top Tip - overall (currency)", + "message": "Top Tip - overall (message)" + }, + "stream": { + "username": "Top Tip - during stream (username)", + "amount": "Top Tip - during stream (amount)", + "currency": "Top Tip - during stream (currency)", + "message": "Top Tip - during stream (message)" + } + }, + "latestCheerAmount": "Latest Bits (amount)", + "latestCheerMessage": "Latest Bits (message)", + "latestCheer": "Latest Bits (username)", + "version": "Bot version", + "haveParam": "Have command parameter? (bool)", + "source": "Current source (twitch or discord)", + "userInput": "User input during reward redeem", + "isBotSubscriber": "Is bot subscriber (bool)", + "isStreamOnline": "Is stream online (bool)", + "uptime": "Uptime of stream", + "is": { + "moderator": "Is user mod? (bool)", + "subscriber": "Is user sub? (bool)", + "vip": "Is user vip? (bool)", + "newchatter": "Is user's first message? (bool)", + "follower": "Is user follower? (bool)", + "broadcaster": "Is user broadcaster? (bool)", + "bot": "Is user bot? (bool)", + "owner": "Is user bot owner? (bool)" + }, + "recipientis": { + "moderator": "Is recipient mod? (bool)", + "subscriber": "Is recipient sub? (bool)", + "vip": "Is recipient vip? (bool)", + "follower": "Is recipient follower? (bool)", + "broadcaster": "Is recipient broadcaster? (bool)", + "bot": "Is recipient bot? (bool)", + "owner": "Is recipient bot owner? (bool)" + }, + "sceneName": "Name of scene", + "inputName": "Name of input", + "inputMuted": "Mute state (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Others", + "currency": "Currency" + }, + "whispers": { + "title": "Whispers", + "toggle": { + "listener": "Listen commands on whisper", + "settings": "Whispers on settings change", + "raffle": "Whispers on raffle join", + "permissions": "Whispers on insufficient permissions", + "cooldowns": "Whispers on cooldown (if set as notify)" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Messages", + "follows": "Follows", + "subs": "Subs & Resubs", + "cheers": "Bits", + "responses": "Bot responses", + "whispers": "Whispers", + "bans": "Bans", + "timeouts": "Timeouts" + }, + "range": { + "day": "a day", + "week": "a week", + "month": "a month", + "year": "an year", + "all": "All time" + }, + "order": { + "asc": "Ascending", + "desc": "Descending" + }, + "labels": { + "order": "ORDER", + "range": "RANGE", + "filters": "FILTERS" + } + }, + "stats-panel": { + "show": "Show stats", + "hide": "Hide stats" + }, + "translations": "Custom translations", + "bot-responses": "Bot responses", + "duration": "Duration", + "viewers-reset-attributes": "Reset attributes", + "viewers-points-of-all-users": "Points of all users", + "viewers-watchtime-of-all-users": "Watch time of all users", + "viewers-messages-of-all-users": "Messages of all users", + "events-game-after-change": "category after change", + "events-game-before-change": "category before change", + "events-user-triggered-event": "user triggered event", + "events-method-used-to-subscribe": "method used to subscribe", + "events-months-of-subscription": "months of subscription", + "events-monthsName-of-subscription": "word 'month' by number (1 month, 2 months)", + "events-user-message": "user message", + "events-bits-user-sent": "bits user sent", + "events-reason-for-ban-timeout": "reason for ban/timeout", + "events-duration-of-timeout": "duration of timeout", + "events-duration-of-commercial": "duration of commercial", + "overlays-eventlist-resub": "resub", + "overlays-eventlist-subgift": "subgift", + "overlays-eventlist-subcommunitygift": "subcommunitygift", + "overlays-eventlist-sub": "sub", + "overlays-eventlist-follow": "follow", + "overlays-eventlist-cheer": "bits", + "overlays-eventlist-tip": "tip", + "overlays-eventlist-raid": "raid", + "requested-by": "Requested by", + "description": "Description", + "raffle-type": "Raffle type", + "raffle-type-keywords": "Only keyword", + "raffle-type-tickets": "With tickets", + "raffle-tickets-range": "Tickets range", + "video_id": "Video ID", + "highlights": "Highlights", + "cooldown-quiet-header": "Show cooldown message", + "cooldown-quiet-toggle-no": "Notify", + "cooldown-quiet-toggle-yes": "Won't notify", + "cooldown-moderators": "Moderators", + "cooldown-owners": "Owners", + "cooldown-subscribers": "Subscribers", + "cooldown-followers": "Followers", + "in-seconds": "in seconds", + "songs": "Songs", + "show-usernames-with-at": "Show usernames with @", + "send-message-as-a-bot": "Send message as a bot", + "chat-as-bot": "Chat (as bot)", + "product": "Product", + "optional": "optional", + "placeholder-search": "Search", + "placeholder-enter-product": "Enter product", + "placeholder-enter-keyword": "Enter keyword", + "credits": "Credits", + "fade-out-top": "fade up", + "fade-out-zoom": "fade zoom", + "global": "Global", + "user": "User", + "alerts": "Alerts", + "eventlist": "EventList", + "dashboard": "Dashboard", + "carousel": "Image Carousel", + "text": "Text", + "filter": "Filter", + "filters": "Filters", + "isUsed": "Is used", + "permissions": "Permissions", + "permission": "Permission", + "viewers": "Viewers", + "systems": "Systems", + "overlays": "Overlays", + "gallery": "Media gallery", + "aliases": "Aliases", + "alias": "Alias", + "command": "Command", + "cooldowns": "Cooldowns", + "title-template": "Title template", + "keyword": "Keyword", + "moderation": "Moderation", + "timer": "Timer", + "price": "Price", + "rank": "Rank", + "previous": "Previous", + "next": "Next", + "close": "Close", + "save-changes": "Save changes", + "saving": "Saving...", + "deleting": "Deleting...", + "done": "Done", + "error": "Error", + "title": "Title", + "change-title": "Change title", + "game": "category", + "tags": "Tags", + "change-game": "Change category", + "click-to-change": "click to change", + "uptime": "uptime", + "not-affiliate-or-partner": "Not affiliate/partner", + "not-available": "Not Available", + "max-viewers": "Max viewers", + "new-chatters": "New Chatters", + "chat-messages": "Chat messages", + "followers": "Followers", + "subscribers": "Subscribers", + "bits": "Bits", + "subgifts": "Subgifts", + "subStreak": "Current sub streak", + "subCumulativeMonths": "Cumulative subscribe months", + "tips": "Tips", + "tier": "Tier", + "status": "Status", + "add-widget": "Add widget", + "remove-dashboard": "Remove dashboard", + "close-bet-after": "Close bet after", + "refund": "refund", + "roll-again": "Roll again", + "no-eligible-participants": "No eligible participants", + "follower": "Follower", + "subscriber": "Subscriber", + "minutes": "minutes", + "seconds": "seconds", + "hours": "hours", + "months": "months", + "eligible-to-enter": "Eligible to enter", + "everyone": "Everyone", + "roll-a-winner": "Roll a winner", + "send-message": "Send Message", + "messages": "Messages", + "level": "Level", + "create": "Create", + "cooldown": "Cooldown", + "confirm": "Confirm", + "delete": "Delete", + "enabled": "Enabled", + "disabled": "Disabled", + "enable": "Enable", + "disable": "Disable", + "slug": "Slug", + "posted-by": "Posted by", + "time": "Time", + "type": "Type", + "response": "Response", + "cost": "Cost", + "name": "Name", + "playlist": "Playlist", + "length": "Length", + "volume": "Volume", + "start-time": "Start Time", + "end-time": "End Time", + "watched-time": "Watched time", + "currentsong": "Current song", + "group": "Group", + "followed-since": "Followed since", + "subscribed-since": "Subscribed since", + "username": "Username", + "hashtag": "Hashtag", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "Scopes", + "last-seen": "Last Seen", + "date": "Date", + "points": "Points", + "calendar": "Calendar", + "string": "string", + "interval": "Interval", + "number": "number", + "minimal-messages-required": "Minimal Messages Required", + "max-duration": "Max duration", + "shuffle": "Shuffle", + "song-request": "Song Request", + "format": "Format", + "available": "Available", + "one-record-per-line": "one record per line", + "on": "on", + "off": "off", + "search-by-username": "Search by username", + "widget-title-custom": "CUSTOM", + "widget-title-eventlist": "EVENTLIST", + "widget-title-chat": "CHAT", + "widget-title-queue": "QUEUE", + "widget-title-raffles": "RAFFLES", + "widget-title-social": "SOCIAL", + "widget-title-ytplayer": "MUSIC PLAYER", + "widget-title-monitor": "MONITOR", + "event": "event", + "operation": "operation", + "tweet-post-with-hashtag": "Tweet posted with hashtag", + "user-joined-channel": "user joined a channel", + "user-parted-channel": "user parted a channel", + "follow": "new follow", + "tip": "new tip", + "obs-scene-changed": "OBS scene changed", + "obs-input-mute-state-changed": "OBS input source mute state changed", + "unfollow": "unfollow", + "hypetrain-started": "Hype Train started", + "hypetrain-ended": "Hype Train ended", + "prediction-started": "Twitch Prediction started", + "prediction-locked": "Twitch Prediction locked", + "prediction-ended": "Twitch Prediction ended", + "poll-started": "Twitch Poll started", + "poll-ended": "Twitch Poll ended", + "hypetrain-level-reached": "Hype Train new level reached", + "subscription": "new subscription", + "subgift": "new subgift", + "subcommunitygift": "new sub given to community", + "resub": "user resubscribed", + "command-send-x-times": "command was send x times", + "keyword-send-x-times": "keyword was send x times", + "number-of-viewers-is-at-least-x": "number of viewers is at least x", + "stream-started": "stream started", + "reward-redeemed": "reward redeemed", + "stream-stopped": "stream stopped", + "stream-is-running-x-minutes": "stream is running x minutes", + "chatter-first-message": "first message of chatter", + "every-x-minutes-of-stream": "every x minutes of stream", + "game-changed": "category changed", + "cheer": "received bits", + "clearchat": "chat was cleared", + "action": "user sent /me", + "ban": "user was banned", + "raid": "your channel is raided", + "mod": "user is a new mod", + "timeout": "user was timeouted", + "create-a-new-event-listener": "Create a new event listener", + "send-discord-message": "send a discord message", + "send-chat-message": "send a twitch chat message", + "send-whisper": "send a whisper", + "run-command": "run a command", + "run-obswebsocket-command": "run an OBS Websocket command", + "do-nothing": "--- do nothing ---", + "count": "count", + "timestamp": "timestamp", + "message": "message", + "sound": "sound", + "emote-explosion": "emote explosion", + "emote-firework": "emote firework", + "quiet": "quiet", + "noisy": "noisy", + "true": "true", + "false": "false", + "light": "light theme", + "dark": "dark theme", + "gambling": "Gambling", + "seppukuTimeout": "Timeout for !seppuku", + "rouletteTimeout": "Timeout for !roulette", + "fightmeTimeout": "Timeout for !fightme", + "duelCooldown": "Cooldown for !duel", + "fightmeCooldown": "Cooldown for !fightme", + "gamblingCooldownBypass": "Bypass gambling cooldowns for mods/caster", + "click-to-highlight": "highlight", + "click-to-toggle-display": "toggle display", + "commercial": "commercial started", + "start-commercial": "run a commercial", + "bot-will-join-channel": "bot will join channel", + "bot-will-leave-channel": "bot will leave channel", + "create-a-clip": "create a clip", + "increment-custom-variable": "increment a custom variable", + "set-custom-variable": "set a custom variable", + "decrement-custom-variable": "decrement a custom variable", + "omit": "omit", + "comply": "comply", + "visible": "visible", + "hidden": "hidden", + "gamblingChanceToWin": "Chance to win !gamble", + "gamblingMinimalBet": "Minimal bet for !gamble", + "duelDuration": "Duration of !duel", + "duelMinimalBet": "Minimal bet for !duel" + }, + "raffles": { + "announceInterval": "Opened raffles will be announced every $value minute", + "eligibility-followers-item": "followers", + "eligibility-subscribers-item": "subscribers", + "eligibility-everyone-item": "everyone", + "raffle-is-running": "Raffle is running ($count $l10n_entries).", + "to-enter-raffle": "To enter type \"$keyword\". Raffle is opened for $eligibility.", + "to-enter-ticket-raffle": "To enter type \"$keyword <$min-$max>\". Raffle is opened for $eligibility.", + "added-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-raffle}", + "added-ticket-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "Your raffle messages will be deleted on join.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "Sorry, $sender, but you cannot create raffle without keyword", + "raffle-is-already-running": "Sorry, $sender, raffle is already running with keyword $keyword", + "no-raffle-is-currently-running": "$sender, no raffles without winners are currently running", + "no-participants-to-pick-winner": "$sender, nobody joined a raffle", + "raffle-winner-is": "Winner of raffle $keyword is $username! Win probability was $probability%!" + }, + "bets": { + "running": "$sender, bet is already opened! Bet options: $options. Use $command close 1-$maxIndex", + "notRunning": "No bet is currently opened, ask mods to open it!", + "opened": "New bet '$title' is opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "closeNotEnoughOptions": "$sender, you need to select winning option for bet close.", + "notEnoughOptions": "$sender, new bets needs at least 2 options!", + "info": "Bet '$title' is still opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "diffBet": "$sender, you already made a bet on $option and you cannot bet to different option!", + "undefinedBet": "Sorry, $sender, but this bet option doesn't exist, use $command to check usage", + "betPercentGain": "Bet percent gain per option was set to $value%", + "betCloseTimer": "Bets will be automatically closed after $valuemin", + "refund": "Bets were closed without a winning. All users are refunded!", + "notOption": "$sender, this option doesn't exist! Bet is not closed, check $command", + "closed": "Bets was closed and winning option was $option! $amount users won in total $points $pointsName!", + "timeUpBet": "I guess you are too late, $sender, your time for betting is up!", + "locked": "Betting time is up! No more bets.", + "zeroBet": "Oh boy, $sender, you cannot bet 0 $pointsName", + "lockedInfo": "Bet '$title' is still opened, but time for betting is up!", + "removed": "Betting time is up! No bets were sent -> automatically closing", + "error": "Sorry, $sender, this command is not correct! Use $command 1-$maxIndex . E.g. $command 0 100 will bet 100 points to item 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, alias $alias was not found in database", + "alias-was-edited": "$sender, alias $alias is changed to $command", + "alias-was-added": "$sender, alias $alias for $command was added", + "list-is-not-empty": "$sender, list of aliases: $list", + "list-is-empty": "$sender, list of aliases is empty", + "alias-was-enabled": "$sender, alias $alias was enabled", + "alias-was-disabled": "$sender, alias $alias was disabled", + "alias-was-concealed": "$sender, alias $alias was concealed", + "alias-was-exposed": "$sender, alias $alias was exposed", + "alias-was-removed": "$sender, alias $alias was removed", + "alias-group-set": "$sender, alias $alias was set to group $group", + "alias-group-unset": "$sender, alias $alias group was unset", + "alias-group-list": "$sender, list of aliases groups: $list", + "alias-group-list-aliases": "$sender, list of aliases in $group: $list", + "alias-group-list-enabled": "$sender, aliases in $group are enabled.", + "alias-group-list-disabled": "$sender, aliases in $group are disabled." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, command $command was not found in database", + "response-was-not-found": "$sender, response #$response of command $command was not found in database", + "command-was-edited": "$sender, command $command is changed to '$response'", + "command-was-added": "$sender, command $command was added", + "list-is-not-empty": "$sender, list of commands: $list", + "list-is-empty": "$sender, list of commands is empty", + "command-was-enabled": "$sender, command $command was enabled", + "command-was-disabled": "$sender, command $command was disabled", + "command-was-concealed": "$sender, command $command was concealed", + "command-was-exposed": "$sender, command $command was exposed", + "command-was-removed": "$sender, command $command was removed", + "response-was-removed": "$sender, response #$response of $command was removed", + "list-of-responses-is-empty": "$sender, $command have no responses or doesn't exists", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, keyword $keyword is ambiguous, use ID of keyword", + "keyword-was-not-found": "$sender, keyword $keyword was not found in database", + "response-was-not-found": "$sender, response #$response of keyword $keyword was not found in database", + "keyword-was-edited": "$sender, keyword $keyword is changed to '$response'", + "keyword-was-added": "$sender, keyword $keyword ($id) was added", + "list-is-not-empty": "$sender, list of keywords: $list", + "list-is-empty": "$sender, list of keywords is empty", + "keyword-was-enabled": "$sender, keyword $keyword was enabled", + "keyword-was-disabled": "$sender, keyword $keyword was disabled", + "keyword-was-removed": "$sender, keyword $keyword was removed", + "list-of-responses-is-empty": "$sender, $keyword have no responses or doesn't exists", + "response": "$keyword#$index ($permission) $after| $response" + }, + "points": { + "success": { + "undo": "$sender, points '$command' for $username was reverted ($updatedValue $updatedValuePointsLocale to $originalValue $originalValuePointsLocale).", + "set": "$username was set to $amount $pointsName", + "give": "$sender just gave his $amount $pointsName to $username", + "online": { + "positive": "All online users just received $amount $pointsName!", + "negative": "All online users just lost $amount $pointsName!" + }, + "all": { + "positive": "All users just received $amount $pointsName!", + "negative": "All users just lost $amount $pointsName!" + }, + "rain": "Make it rain! All online users just received up to $amount $pointsName!", + "add": "$username just received $amount $pointsName!", + "remove": "Ouch, $amount $pointsName was removed from $username!" + }, + "failed": { + "undo": "$sender, username wasn't found in database or user have no undo operations", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Sorry, $sender, you don't have $amount $pointsName to give it to $username", + "cannotGiveZeroPoints": "Sorry, $sender, you cannot give $amount $pointsName to $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username has currently $amount $pointsName. Your position is $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, playlist to import is empty", + "playlist-imported": "$sender, imported $imported and skipped $skipped to playlist", + "not-playing": "Not Playing", + "song-was-banned": "Song $name was banned and will never play again!", + "song-was-banned-timeout-message": "You've got timeout for posting banned song", + "song-was-unbanned": "Song was succesfully unbanned", + "song-was-not-banned": "This song was not banned", + "no-song-is-currently-playing": "No song is currently playing", + "current-song-from-playlist": "Current song is $name from playlist", + "current-song-from-songrequest": "Current song is $name requested by $username", + "songrequest-disabled": "Sorry, $sender, song requests are disabled", + "song-is-banned": "Sorry, $sender, but this song is banned", + "youtube-is-not-responding-correctly": "Sorry, $sender, but YouTube is sending unexpected responses, please try again later.", + "song-was-not-found": "Sorry, $sender, but this song was not found", + "song-is-too-long": "Sorry, $sender, but this song is too long", + "this-song-is-not-in-playlist": "Sorry, $sender, but this song is not in current playlist", + "incorrect-category": "Sorry, $sender, but this song must be music category", + "song-was-added-to-queue": "$sender, song $name was added to queue", + "song-was-added-to-playlist": "$sender, song $name was added to playlist", + "song-is-already-in-playlist": "$sender, song $name is already in playlist", + "song-was-removed-from-playlist": "$sender, song $name was removed from playlist", + "song-was-removed-from-queue": "$sender, your song $name was removed from queue", + "playlist-current": "$sender, current playlist is $playlist.", + "playlist-list": "$sender, available playlists: $list.", + "playlist-not-exist": "$sender, your requested playlist $playlist doesn't exist.", + "playlist-set": "$sender, you changed playlist to $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, price for $command was set to $amount $pointsName", + "price-was-unset": "$sender, price for $command was unset", + "price-was-not-found": "$sender, price for $command was not found", + "price-was-enabled": "$sender, price for $command was enabled", + "price-was-disabled": "$sender, price for $command was disabled", + "user-have-not-enough-points": "Sorry, $sender, but you don't have $amount $pointsName to use $command", + "user-have-not-enough-points-or-bits": "Sorry, $sender, but you don't have $amount $pointsName or redeem command by $bitsAmount bits to use $command", + "user-have-not-enough-bits": "Sorry, $sender, but you need to redeem command by $bitsAmount bits to use $command", + "list-is-empty": "$sender, list of prices is empty", + "list-is-not-empty": "$sender, list of prices: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, new rank $type $rank($hours$hlocale) was added", + "rank-was-edited": "$sender, rank for $type $hours$hlocale was changed to $rank", + "rank-was-removed": "$sender, rank for $type $hours$hlocale was removed", + "rank-already-exist": "$sender, there is already a rank for $type $hours$hlocale", + "rank-was-not-found": "$sender, rank for $type $hours$hlocale was not found", + "custom-rank-was-set-to-user": "$sender, you set $rank to $username", + "custom-rank-was-unset-for-user": "$sender, custom rank for $username was unset", + "list-is-empty": "$sender, no ranks was found", + "list-is-not-empty": "$sender, ranks list: $list", + "show-rank-without-next-rank": "$sender, you have $rank rank", + "show-rank-with-next-rank": "$sender, you have $rank rank. Next rank - $nextrank", + "user-dont-have-rank": "$sender, you don't have a rank yet" + }, + "followage": { + "success": { + "never": "$sender, $username is not a channel follower", + "time": "$sender, $username is following channel $diff" + }, + "successSameUsername": { + "never": "$sender, you are not follower of this channel", + "time": "$sender, you are following this channel for $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username is not a channel subscriber.", + "notNow": "$sender, $username is currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username is subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username is subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, you are not a channel subscriber.", + "notNow": "$sender, you are currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, you are subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, you are subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, I don't have data for $username account age", + "success": { + "withUsername": "$sender, account age for $username is $diff", + "withoutUsername": "$sender, your account age is $diff" + } + }, + "lastseen": { + "success": { + "never": "$username was never in this channel!", + "time": "$username was last seen at $when in this channel" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username watched this channel for $time hours" + }, + "failed": { + "parse": "{core.command-parse} !watched or !watched [username]" + } + }, + "permissions": { + "without-permission": "You don't have enough permissions for '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, user $username have $type immunity for $time seconds", + "user-have-immunity-parameterError": "$sender, parameter error. $command ", + "user-have-link-permit": "User $username can post a $count $link to chat", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "No links allowed, ask for !permit [$count warnings left]", + "user-is-warned-about-symbols": "No excessive symbols usage [$count warnings left]", + "user-is-warned-about-long-message": "Long messages are not allowed [$count warnings left]", + "user-is-warned-about-caps": "No excessive caps usage [$count warnings left]", + "user-is-warned-about-spam": "Spamming is not allowed [$count warnings left]", + "user-is-warned-about-color": "Italic and /me is not allowed [$count warnings left]", + "user-is-warned-about-emotes": "No emotes spamming [$count warnings left]", + "user-is-warned-about-forbidden-words": "No forbidden words [$count warnings left]", + "user-have-timeout-for-links": "No links allowed, ask for !permit", + "user-have-timeout-for-symbols": "No excessive symbols usage", + "user-have-timeout-for-long-message": "Long message are not allowed", + "user-have-timeout-for-caps": "No excessive caps usage", + "user-have-timeout-for-spam": "Spamming is not allowed", + "user-have-timeout-for-color": "Italic and /me is not allowed", + "user-have-timeout-for-emotes": "No emotes spamming", + "user-have-timeout-for-forbidden-words": "No forbidden words" + }, + "queue": { + "list": "$sender, current queue pool: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Sorry $sender, queue is currently closed", + "opened": "$sender were added into queue" + }, + "open": "Queue is currently OPENED! Join to queue with !queue join", + "close": "Queue is currently closed!", + "clear": "Queue were completely cleared", + "picked": { + "single": "This user was picked from queue: $users", + "multi": "These users were picked from queue: $users", + "none": "No users were found in queue" + } + }, + "marker": "Stream marker has been created at $time.", + "title": { + "current": "$sender, title of stream is '$title'.", + "change": { + "success": "$sender, title was set to: $title" + } + }, + "game": { + "current": "$sender, streamer is currently playing $game.", + "change": { + "success": "$sender, category was set to: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type cooldown for $command was set to $secondss", + "cooldown-was-unset": "$sender, cooldown for $command was unset", + "cooldown-triggered": "$sender, '$command' is on cooldown, remaining $secondss", + "cooldown-not-found": "$sender, cooldown for $command was not found", + "cooldown-was-enabled": "$sender, cooldown for $command was enabled", + "cooldown-was-disabled": "$sender, cooldown for $command was disabled", + "cooldown-was-enabled-for-moderators": "$sender, cooldown for $command was enabled for moderators", + "cooldown-was-disabled-for-moderators": "$sender, cooldown for $command was disabled for moderators", + "cooldown-was-enabled-for-owners": "$sender, cooldown for $command was enabled for owners", + "cooldown-was-disabled-for-owners": "$sender, cooldown for $command was disabled for owners", + "cooldown-was-enabled-for-subscribers": "$sender, cooldown for $command was enabled for subscribers", + "cooldown-was-disabled-for-subscribers": "$sender, cooldown for $command was disabled for subscribers", + "cooldown-was-enabled-for-followers": "$sender, cooldown for $command was enabled for followers", + "cooldown-was-disabled-for-followers": "$sender, cooldown for $command was disabled for followers" + }, + "timers": { + "id-must-be-defined": "$sender, response id must be defined.", + "id-or-name-must-be-defined": "$sender, response id or timer name must be defined.", + "name-must-be-defined": "$sender, timer name must be defined.", + "response-must-be-defined": "$sender, timer response must be defined.", + "cannot-set-messages-and-seconds-0": "$sender, you cannot set both messages and seconds to 0.", + "timer-was-set": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger", + "timer-was-set-with-offline-flag": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger even when stream is offline", + "timer-not-found": "$sender, timer (name: $name) was not found in database. Check timers with !timers list", + "timer-deleted": "$sender, timer $name and its responses was deleted.", + "timer-enabled": "$sender, timer (name: $name) was enabled", + "timer-disabled": "$sender, timer (name: $name) was disabled", + "timers-list": "$sender, timers list: $list", + "responses-list": "$sender, timer (name: $name) list", + "response-deleted": "$sender, response (id: $id) was deleted.", + "response-was-added": "$sender, response (id: $id) for timer (name: $name) was added - '$response'", + "response-not-found": "$sender, response (id: $id) was not found in database", + "response-enabled": "$sender, response (id: $id) was enabled", + "response-disabled": "$sender, response (id: $id) was disabled" + }, + "gambling": { + "duel": { + "bank": "$sender, current bank for $command is $points $pointsName", + "lowerThanMinimalBet": "$sender, minimal bet for $command is $points $pointsName", + "cooldown": "$sender, you cannot use $command for $cooldown $minutesName.", + "joined": "$sender, good luck with your dueling skills. You bet on yourself $points $pointsName!", + "added": "$sender really thinks he is better than others raising his bet to $points $pointsName!", + "new": "$sender is your new duel challenger! To participate use $command [points], you have $minutes $minutesName left to join.", + "zeroBet": "$sender, you cannot duel 0 $pointsName", + "notEnoughOptions": "$sender, you need to specify points to dueling", + "notEnoughPoints": "$sender, you don't have $points $pointsName to duel!", + "noContestant": "Only $winner have courage to join duel! Your bet of $points $pointsName are returned to you.", + "winner": "Congratulations to $winner! He is last man standing and he won $points $pointsName ($probability% with bet of $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender is trying his luck and pulled a trigger", + "alive": "$sender is alive! Nothing happened.", + "dead": "$sender's brain was splashed on the wall!", + "mod": "$sender is incompetent and completely missed his head!", + "broadcaster": "$sender is using blanks, boo!", + "timeout": "Roulette timeout set to $values" + }, + "gamble": { + "chanceToWin": "$sender, chance to win !gamble set to $value%", + "zeroBet": "$sender, you cannot gamble 0 $pointsName", + "minimalBet": "$sender, minimal bet for !gamble is set to $value", + "lowerThanMinimalBet": "$sender, minimal bet for !gamble is $points $pointsName", + "notEnoughOptions": "$sender, you need to specify points to gamble", + "notEnoughPoints": "$sender, you don't have $points $pointsName to gamble", + "win": "$sender, you WON! You now have $points $pointsName", + "winJackpot": "$sender, you hit JACKPOT! You won $jackpot $jackpotName in addition to your bet. You now have $points $pointsName", + "loseWithJackpot": "$sender, you LOST! You now have $points $pointsName. Jackpot increased to $jackpot $jackpotName", + "lose": "$sender, you LOST! You now have $points $pointsName", + "currentJackpot": "$sender, current jackpot for $command is $points $pointsName", + "winJackpotCount": "$sender, you won $count jackpots", + "jackpotIsDisabled": "$sender, jackpot is disabled for $command." + } + }, + "highlights": { + "saved": "$sender, highlight was saved for $hoursh$minutesm$secondss", + "list": { + "items": "$sender, list of saved highlights for latest stream: $items", + "empty": "$sender, no highlights were saved" + }, + "offline": "$sender, cannot save highlight, stream is offline" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "Bot won't send errors on insufficient permissions", + "false": "Bot won't send errors on insufficient permissions through whispers" + }, + "disableCooldownWhispers": { + "true": "Bot won't send cooldown notifications", + "false": "Bot will send cooldown notifications through whispers" + } + } + }, + "time": "Current time in streamer's timezone is $time", + "subs": "$sender, there is currently $onlineSubCount online subscribers. Last sub/resub was $lastSubUsername $lastSubAgo", + "followers": "$sender, last follow was $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, user $username is not ignored by bot" + }, + "added": "$sender, user $username is added to bot ignorelist", + "removed": "$sender, user $username is removed from bot ignorelist", + "ignored": "$sender, user $username is ignored by bot" + } + } + }, + "filters": { + "setVariable": "$sender, $variable was set to $value." + } +} diff --git a/backend/locales/ar/api.clips.json b/backend/locales/ar/api.clips.json new file mode 100644 index 000000000..21895e7a3 --- /dev/null +++ b/backend/locales/ar/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "Clip was created and is available at $link" +} \ No newline at end of file diff --git a/backend/locales/ar/core/permissions.json b/backend/locales/ar/core/permissions.json new file mode 100644 index 000000000..19d5330cf --- /dev/null +++ b/backend/locales/ar/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "قائمة الصلاحيات:", + "excludeAddSuccessful": "$sender، لقد أضفت $username لقائمة استبعاد الصلاحيات $permissionName", + "excludeRmSuccessful": "$sender، لقد قمت بإزالة $username من قائمة استبعاد الصلاحيات $permissionName", + "userNotFound": "$sender، المستخدم $username لم يتم العثور على قاعدة البيانات.", + "permissionNotFound": "$sender، لم يتم العثور على إذن $userlevel في قاعدة البيانات.", + "cannotIgnoreForCorePermission": "$sender، لا يمكنك إستبعاد المستخدم يدوياً للإذن الأساسي $userlevel" +} \ No newline at end of file diff --git a/backend/locales/ar/games.heist.json b/backend/locales/ar/games.heist.json new file mode 100644 index 000000000..86371a40f --- /dev/null +++ b/backend/locales/ar/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, cops are still searching for last heist team. Try again after $cooldown.", + "copsCooldownMessage": "Alright guys, looks like police forces are eating donuts and we can get that sweet money!", + "entryMessage": "$sender has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type $command to enter.", + "lateEntryMessage": "$sender, heist is currently in progress!", + "entryInstruction": "$sender, type $command to enter.", + "levelMessage": "With this crew, we can heist $bank! Let's see if we can get enough crew to heist $nextBank", + "maxLevelMessage": "With this crew, we can heist $bank! It cannot be any better!", + "started": "Alright guys, check your equipment, this is what we trained for. This is not a game, this is real life. We will get money from $bank!", + "noUser": "Nobody joins a crew to heist.", + "singleUserSuccess": "$user was like a ninja. Nobody noticed missing money.", + "singleUserFailed": "$user failed to get rid of police and will be spending his time in jail.", + "result": { + "0": "Everyone was mercilessly obliterated. This is slaughter.", + "33": "Only 1/3rd of team get its money from heist.", + "50": "Half of heist team was killed or catched by police.", + "99": "Some loses of heist team is nothing of what remaining crew have in theirs pockets.", + "100": "God divinity, nobody is dead, everyone won!" + }, + "levels": { + "bankVan": "Bank van", + "cityBank": "City bank", + "stateBank": "State bank", + "nationalReserve": "National reserve", + "federalReserve": "Federal reserve" + }, + "results": "The heist payouts are: $users", + "andXMore": "and $count more..." +} \ No newline at end of file diff --git a/backend/locales/ar/integrations/discord.json b/backend/locales/ar/integrations/discord.json new file mode 100644 index 000000000..beefe7451 --- /dev/null +++ b/backend/locales/ar/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "حسابك غير مرتبط ، استخدم `$command`", + "all-your-links-were-deleted": "تم حذف جميع الروابط الخاصة بك", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender، تم ربط هذا الحساب بـ $discordTag.", + "invalid-or-expired-token": "$sender, الرمز المميز غير صالح أو منتهي الصلاحية.", + "help-message": "$sender، لربط حسابك على ديسكورد: 1. اذهب إلى خادم ديسكورد وارسل $command في قناة بوت. | 2. انتظر رسالة PM من بوت| 3. أرسل الأمر من ديسكورد الخاص بك هنا في دردشة تويتش.", + "started-at": "بدأ في", + "announced-by": "Announced by sogeBot", + "streamed-at": "تم البث في", + "link-whisper": "Hello $tag, to link this Discord account with your Twitch account on $broadcaster channel, go to , login to your account and send this command to chat \n\n\t\t`$command $id`\n\nNOTE: This expires in 10 minutes.", + "check-your-dm": "تحقق من الرسائل الخاصة (DM) بك للحصول على خطوات لربط حسابك." +} \ No newline at end of file diff --git a/backend/locales/ar/integrations/lastfm.json b/backend/locales/ar/integrations/lastfm.json new file mode 100644 index 000000000..79a075396 --- /dev/null +++ b/backend/locales/ar/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "Current song is $name" +} \ No newline at end of file diff --git a/backend/locales/ar/integrations/obswebsocket.json b/backend/locales/ar/integrations/obswebsocket.json new file mode 100644 index 000000000..1058ed4b6 --- /dev/null +++ b/backend/locales/ar/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, there is no action set for id:$id!", + "ParameterError": "$sender, you need to specify id!", + "UnknownError": "$sender, something went wrong. Check bot logs for additional informations." + } +} \ No newline at end of file diff --git a/backend/locales/ar/integrations/protondb.json b/backend/locales/ar/integrations/protondb.json new file mode 100644 index 000000000..0e7df5a0f --- /dev/null +++ b/backend/locales/ar/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | $rating rated | Native on $native | Details: $url", + "responseNg": "Rating for game $game was not found on ProtonDB.", + "responseNotFound": "Game $game was not found on ProtonDB." +} \ No newline at end of file diff --git a/backend/locales/ar/integrations/pubg.json b/backend/locales/ar/integrations/pubg.json new file mode 100644 index 000000000..1cc2a2623 --- /dev/null +++ b/backend/locales/ar/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, expected one of these parameters: $list" +} \ No newline at end of file diff --git a/backend/locales/ar/integrations/spotify.json b/backend/locales/ar/integrations/spotify.json new file mode 100644 index 000000000..9085e33b0 --- /dev/null +++ b/backend/locales/ar/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "Sorry, $sender, track was not found on spotify", + "song-requested": "$sender, you requested song $name from $artist", + "not-banned-song-not-playing": "$sender, no song is currently playing to ban.", + "song-banned": "$sender, song $name from $artist is banned.", + "song-unbanned": "$sender, song $name from $artist is unbanned.", + "song-not-found-in-banlist": "$sender, song by spotifyURI $uri was not found in ban list.", + "cannot-request-song-is-banned": "$sender, cannot request banned song $name from $artist.", + "cannot-request-song-from-unapproved-artist": "$sender, cannot request song from unapproved artist.", + "no-songs-found-in-history": "$sender, there is currently no song in history list.", + "return-one-song-from-history": "$sender, previous song was $name from $artist.", + "return-multiple-song-from-history": "$sender, $count previous songs were:", + "return-multiple-song-from-history-item": "$index - $name from $artist", + "song-notify": "Current playing song is $name by $artist." +} \ No newline at end of file diff --git a/backend/locales/ar/integrations/tiltify.json b/backend/locales/ar/integrations/tiltify.json new file mode 100644 index 000000000..aa574fb09 --- /dev/null +++ b/backend/locales/ar/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, there are currently no active campaigns.", + "active_campaigns": "$sender, list of currently active campaigns:" +} \ No newline at end of file diff --git a/backend/locales/ar/systems.quotes.json b/backend/locales/ar/systems.quotes.json new file mode 100644 index 000000000..92025a17c --- /dev/null +++ b/backend/locales/ar/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, quote $id '$quote' was added. (tags: $tags)", + "error": "$sender, $command is not correct or missing -quote parameter" + }, + "remove": { + "ok": "$sender, quote $id was successfully deleted.", + "error": "$sender, quote ID is missing.", + "not-found": "$sender, quote $id was not found." + }, + "show": { + "ok": "Quote $id by $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found.", + "not-found-by-tag": "$sender, no quotes with tag $tag was not found." + } + }, + "set": { + "ok": "$sender, quote $id tags were set. (tags: $tags)", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found." + } + }, + "list": { + "ok": "$sender, You can find quote list at http://$urlBase/public/#/quotes", + "is-localhost": "$sender, quote list url is not properly specified." + } +} \ No newline at end of file diff --git a/backend/locales/ar/systems/antihateraid.json b/backend/locales/ar/systems/antihateraid.json new file mode 100644 index 000000000..7ad602a98 --- /dev/null +++ b/backend/locales/ar/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "This chat was set to $mode by $username to get rid of hate raid. Sorry for inconvenience!", + "mode": { + "0": "subs-only", + "1": "follow-only", + "2": "emotes-only" + } +} \ No newline at end of file diff --git a/backend/locales/ar/systems/howlongtobeat.json b/backend/locales/ar/systems/howlongtobeat.json new file mode 100644 index 000000000..0fcc12bd9 --- /dev/null +++ b/backend/locales/ar/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game not found in db.", + "game": "$sender, $game | Main: $currentMain/$hltbMainh - $percentMain% | Main+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Completionist: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Main: $currentMainh | Main+Extra: $currentMainExtrah | Completionist: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/ar/systems/levels.json b/backend/locales/ar/systems/levels.json new file mode 100644 index 000000000..c2c9bb7f9 --- /dev/null +++ b/backend/locales/ar/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, level: $currentLevel ($currentXP $xpName), $nextXP $xpName to next level.", + "changeXP": "$sender, you changed $xpName by $amount $xpName to $username.", + "notEnoughPointsToBuy": "Sorry $sender, but you don't have $points $pointsName to buy $amount $xpName for level $level.", + "XPBoughtByPoints": "$sender, you bought $amount $xpName with $points $pointsName and reached level $level.", + "somethingGetWrong": "$sender, something get wrong with your request." +} \ No newline at end of file diff --git a/backend/locales/ar/systems/scrim.json b/backend/locales/ar/systems/scrim.json new file mode 100644 index 000000000..45630d49b --- /dev/null +++ b/backend/locales/ar/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Snipe match ($type) starting in $time $unit", + "go": "Starting now! Go!", + "putMatchIdInChat": "Please put your match ID in the chat => $command xxx", + "currentMatches": "Current Matches: $matches", + "stopped": "Snipe match was cancelled." +} \ No newline at end of file diff --git a/backend/locales/ar/systems/top.json b/backend/locales/ar/systems/top.json new file mode 100644 index 000000000..e0f7cb149 --- /dev/null +++ b/backend/locales/ar/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Top $amount (watch time): ", + "tips": "Top $amount (tips): ", + "level": "Top $amount (level): ", + "points": "Top $amount (points): ", + "messages": "Top $amount (messages): ", + "followage": "Top $amount (followage): ", + "subage": "Top $amount (subage): ", + "submonths": "Top $amount (submonths): ", + "bits": "Top $amount (bits): ", + "gifts": "Top $amount (subgifts): " +} \ No newline at end of file diff --git a/backend/locales/ar/ui.commons.json b/backend/locales/ar/ui.commons.json new file mode 100644 index 000000000..22d3a3b5b --- /dev/null +++ b/backend/locales/ar/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Additional settings", + "never": "never", + "reset": "reset", + "moveUp": "move up", + "moveDown": "move down", + "stop-if-executed": "stop, if executed", + "continue-if-executed": "continue, if executed", + "generate": "Generate", + "thumbnail": "Thumbnail", + "yes": "Yes", + "no": "No", + "show-more": "Show more", + "show-less": "Show less", + "allowed": "Allowed", + "disallowed": "Disallowed", + "back": "Back" +} diff --git a/backend/locales/ar/ui.dialog.json b/backend/locales/ar/ui.dialog.json new file mode 100644 index 000000000..15701cc6e --- /dev/null +++ b/backend/locales/ar/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Edit", + "add": "Add" + }, + "position": { + "settings": "Position settings", + "anchorX": "Anchor X position", + "anchorY": "Anchor Y position", + "left": "Left", + "right": "Right", + "middle": "Middle", + "top": "Top", + "bottom": "Bottom", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Shift Right", + "shadowShiftDown": "Shift Down", + "shadowBlur": "Blur", + "shadowOpacity": "Opacity", + "color": "Color" + }, + "errors": { + "required": "This input cannot be empty.", + "minValue": "Lowest value of this input is $value." + }, + "buttons": { + "reorder": "Reorder", + "upload": { + "idle": "Upload", + "progress": "Uploading", + "done": "Uploaded" + }, + "cancel": "Cancel", + "close": "Close", + "test": { + "idle": "Test", + "progress": "Testing in progress", + "done": "Testing done" + }, + "saveChanges": { + "idle": "Save changes", + "invalid": "Cannot save changes", + "progress": "Saving changes", + "done": "Changes saved" + }, + "something-went-wrong": "Something went wrong", + "mark-to-delete": "Mark to delete", + "disable": "Disable", + "enable": "Enable", + "disabled": "Disabled", + "enabled": "Enabled", + "edit": "Edit", + "delete": "Delete", + "play": "Play", + "stop": "Stop", + "hold-to-delete": "Hold to delete", + "yes": "Yes", + "no": "No", + "permission": "Permission", + "group": "Group", + "visibility": "Visibility", + "reset": "Reset " + }, + "changesPending": "Your changes was not saved.", + "formNotValid": "Form is invalid.", + "nothingToShow": "Nothing to show here." +} \ No newline at end of file diff --git a/backend/locales/ar/ui.menu.json b/backend/locales/ar/ui.menu.json new file mode 100644 index 000000000..7361104a4 --- /dev/null +++ b/backend/locales/ar/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Services", + "updater": "Updater", + "index": "Dashboard", + "core": "Bot", + "users": "Users", + "tmi": "TMI", + "ui": "UI", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "General", + "timers": "Timers", + "new": "New Item", + "keywords": "Keywords", + "customcommands": "Custom commands", + "botcommands": "Bot commands", + "commands": "Commands", + "events": "Events", + "ranks": "Ranks", + "songs": "Songs", + "modules": "Modules", + "viewers": "Viewers", + "alias": "Aliases", + "cooldowns": "Cooldowns", + "cooldown": "Cooldown", + "highlights": "Highlights", + "price": "Price", + "logs": "Logs", + "systems": "Systems", + "permissions": "Permissions", + "translations": "Custom translations", + "moderation": "Moderation", + "overlays": "Overlays", + "gallery": "Media gallery", + "games": "Games", + "spotify": "Spotify", + "integrations": "Integrations", + "customvariables": "Custom variables", + "registry": "Registry", + "quotes": "Quotes", + "settings": "Settings", + "commercial": "Commercial", + "bets": "Bets", + "points": "Points", + "raffles": "Raffles", + "queue": "Queue", + "playlist": "Playlist", + "bannedsongs": "Banned songs", + "spotifybannedsongs": "Spotify banned songs", + "duel": "Duel", + "fightme": "FightMe", + "seppuku": "Seppuku", + "gamble": "Gamble", + "roulette": "Roulette", + "heist": "Heist", + "oauth": "OAuth", + "socket": "Socket", + "carouseloverlay": "Carousel overlay", + "alerts": "Alerts", + "carousel": "Image carousel", + "clips": "Clips", + "credits": "Credits", + "emotes": "Emotes", + "stats": "Stats", + "text": "Text", + "currency": "Currency", + "eventlist": "Eventlist", + "clipscarousel": "Clips carousel", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Checklist", + "bot": "Bot", + "api": "API", + "manage": "Manage", + "top": "Top", + "goals": "Goals", + "userinfo": "User info", + "scrim": "Scrim", + "commandcount": "Command count", + "profiler": "Profiler", + "howlongtobeat": "How long to beat", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Randomizer", + "tips": "Tips", + "bits": "Bits", + "discord": "Discord", + "texttospeech": "Text To Speech", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "Levels", + "obswebsocket": "OBS Websocket", + "api-explorer": "API Explorer", + "emotescombo": "Emotes Combo", + "notifications": "Notifications", + "plugins": "Plugins", + "tts": "TTS" +} diff --git a/backend/locales/ar/ui.page.settings.overlays.carousel.json b/backend/locales/ar/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..7ca51081f --- /dev/null +++ b/backend/locales/ar/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "options", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Are you sure to delete this image?" + }, + "button": { + "update": "Update", + "fix_your_errors_first": "Fix errors before save" + }, + "errors": { + "number_greater_or_equal_than_0": "Value must be a number >= 0", + "value_must_not_be_empty": "Value must not be empty" + }, + "titles": { + "waitBefore": "Wait before image show (in ms)", + "waitAfter": "Wait after image disappear (in ms)", + "duration": "How long image should be shown (in ms)", + "animationIn": "Animation In", + "animationOut": "Animation Out", + "animationInDuration": "Animation In duration (in ms)", + "animationOutDuration": "Animation Out duration (in ms)", + "showOnlyOncePerStream": "Show only once per stream" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.registry.customvariables.json b/backend/locales/ar/ui.registry.customvariables.json new file mode 100644 index 000000000..eab91fde8 --- /dev/null +++ b/backend/locales/ar/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "URLs", + "generateurl": "Generate new URL", + "show-examples": "show CURL examples", + "response": { + "show": "Show response after POST", + "name": "Response after variable set", + "default": "Default", + "default-placeholder": "Set your bot response", + "default-help": "Use $value to get new variable value", + "custom": "Custom", + "command": "Command" + }, + "useIfInCommand": "Use if you use variable in command. Will return only updated variable without response.", + "permissionToChange": "Permission to change", + "isReadOnly": "read-only in chat", + "isNotReadOnly": "can be changed through chat", + "no-variables-found": "No variables found", + "additional-info": "Additional info", + "run-script": "Run script", + "last-run": "Last run at", + "variable": { + "name": "Variable name", + "help": "Variable name must be unique, e.g. $_wins, $_loses, $_top3", + "placeholder": "Enter your unique variable name", + "error": { + "isNotUnique": "Variable must have unique name.", + "isEmpty": "Variable name must not be empty." + } + }, + "description": { + "name": "Description", + "help": "Optional description", + "placeholder": "Enter your optional description" + }, + "type": { + "name": "Type", + "error": { + "isNotSelected": "Please choose a variable type." + } + }, + "currentValue": { + "name": "Current value", + "help": "If type is set to Evaluated script, value cannot be manually changed" + }, + "usableOptions": { + "name": "Usable options", + "placeholder": "Enter, your, options, here", + "help": "Options, which can be used with this variable, example: SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "You need to set at least 1 value." + } + }, + "scriptToEvaluate": "Script to evaluate", + "runScript": { + "name": "Run script", + "error": { + "isNotSelected": "Please choose an option." + } + }, + "testCurrentScript": { + "name": "Test current script", + "help": "Click Test current script to see value in Current value input" + }, + "history": "History", + "historyIsEmpty": "History for this variable is empty!", + "warning": "Warning: All data of this variable will be discarded!", + "choose": "Choose...", + "types": { + "number": "Number", + "text": "Text", + "options": "Options", + "eval": "Script" + }, + "runEvery": { + "isUsed": "When variable is used" + } +} + diff --git a/backend/locales/ar/ui.systems.antihateraid.json b/backend/locales/ar/ui.systems.antihateraid.json new file mode 100644 index 000000000..d821c979d --- /dev/null +++ b/backend/locales/ar/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Clear Chat", + "mode": "Mode", + "minFollowTime": "Minimum follow time", + "customAnnounce": "Customize announcement on anti hate raid enable" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.bets.json b/backend/locales/ar/ui.systems.bets.json new file mode 100644 index 000000000..51b9de149 --- /dev/null +++ b/backend/locales/ar/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "betPercentGain": "Add x% to bet payout each option" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.commercial.json b/backend/locales/ar/ui.systems.commercial.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/ar/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.cooldown.json b/backend/locales/ar/ui.systems.cooldown.json new file mode 100644 index 000000000..064403519 --- /dev/null +++ b/backend/locales/ar/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Notify as whisper", + "settings": { + "enabled": "Status", + "cooldownNotifyAsWhisper": "Whisper cooldown informations", + "cooldownNotifyAsChat": "Chat message cooldown informations", + "defaultCooldownOfCommandsInSeconds": "Default cooldown for commands (in seconds)", + "defaultCooldownOfKeywordsInSeconds": "Default cooldown for keywords (in seconds)" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.customcommands.json b/backend/locales/ar/ui.systems.customcommands.json new file mode 100644 index 000000000..5c93eb931 --- /dev/null +++ b/backend/locales/ar/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "No responses", + "addResponse": "Add response", + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.highlights.json b/backend/locales/ar/ui.systems.highlights.json new file mode 100644 index 000000000..63ed31e83 --- /dev/null +++ b/backend/locales/ar/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "urls": "Generated URLs" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.moderation.json b/backend/locales/ar/ui.systems.moderation.json new file mode 100644 index 000000000..7c9c7f3e5 --- /dev/null +++ b/backend/locales/ar/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Status", + "cListsEnabled": "Enforce the rule", + "cLinksEnabled": "Enforce the rule", + "cSymbolsEnabled": "Enforce the rule", + "cLongMessageEnabled": "Enforce the rule", + "cCapsEnabled": "Enforce the rule", + "cSpamEnabled": "Enforce the rule", + "cColorEnabled": "Enforce the rule", + "cEmotesEnabled": "Enforce the rule", + "cListsWhitelist": { + "title": "Allowed words", + "help": "To allow domains use \"domain:prtzl.io\"" + }, + "autobanMessages": "Autoban Messages", + "cListsBlacklist": "Forbidden words", + "cListsTimeout": "Timeout duration", + "cLinksTimeout": "Timeout duration", + "cSymbolsTimeout": "Timeout duration", + "cLongMessageTimeout": "Timeout duration", + "cCapsTimeout": "Timeout duration", + "cSpamTimeout": "Timeout duration", + "cColorTimeout": "Timeout duration", + "cEmotesTimeout": "Timeout duration", + "cWarningsShouldClearChat": "Should clear chat (will timeout for 1s)", + "cLinksIncludeSpaces": "Include spaces", + "cLinksIncludeClips": "Include clips", + "cSymbolsTriggerLength": "Trigger length of message", + "cLongMessageTriggerLength": "Trigger length of message", + "cCapsTriggerLength": "Trigger length of message", + "cSpamTriggerLength": "Trigger length of message", + "cSymbolsMaxSymbolsConsecutively": "Max symbols consecutively", + "cSymbolsMaxSymbolsPercent": "Max symbols %", + "cCapsMaxCapsPercent": "Max caps %", + "cSpamMaxLength": "Max length", + "cEmotesMaxCount": "Max count", + "cWarningsAnnounceTimeouts": "Announce timeouts in chat for everyone", + "cWarningsAllowedCount": "Warning count", + "cEmotesEmojisAreEmotes": "Treat Emojis as Emotes" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.points.json b/backend/locales/ar/ui.systems.points.json new file mode 100644 index 000000000..b0b011374 --- /dev/null +++ b/backend/locales/ar/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Status", + "name": { + "title": "Name", + "help": "Possible formats:
point|points
bod|4:body|bodu" + }, + "isPointResetIntervalEnabled": "Interval of points reset", + "resetIntervalCron": { + "name": "Cron interval", + "help": "CronTab generator" + }, + "interval": "Minutes interval to add points to online users when stream online", + "offlineInterval": "Minutes interval to add points to online users when stream offline", + "messageInterval": "How many messages to add points", + "messageOfflineInterval": "How many messages to add points when stream offline", + "perInterval": "How many points to add per online interval", + "perOfflineInterval": "How many points to add per offline interval", + "perMessageInterval": "How many points to add per message interval", + "perMessageOfflineInterval": "How many points to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.price.json b/backend/locales/ar/ui.systems.price.json new file mode 100644 index 000000000..6dcce9ea2 --- /dev/null +++ b/backend/locales/ar/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Trigger custom alerts on bit redeem", + "price": { + "name": "price", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.queue.json b/backend/locales/ar/ui.systems.queue.json new file mode 100644 index 000000000..7edcb74b8 --- /dev/null +++ b/backend/locales/ar/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Status", + "eligibilityAll": "All", + "eligibilityFollowers": "Followers", + "eligibilitySubscribers": "Subscribers" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.quotes.json b/backend/locales/ar/ui.systems.quotes.json new file mode 100644 index 000000000..97f22bce8 --- /dev/null +++ b/backend/locales/ar/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "We're sorry, no quotes were found in database.", + "new": "Add new quote", + "empty": "List of quotes is empty, create new quote.", + "emptyAfterSearch": "List of quotes is empty in searching for \"$search\"", + "quote": { + "name": "Quote", + "placeholder": "Set your quote here" + }, + "by": { + "name": "Quoted by" + }, + "tags": { + "name": "Tags", + "placeholder": "Set your tags here", + "help": "Comma-separated tags. Example: tag 1, tag 2, tag 3" + }, + "date": { + "name": "Date" + }, + "error": { + "isEmpty": "This value cannot be empty", + "atLeastOneTag": "You need to set at least one tag" + }, + "tag-filter": "Filtering by tag", + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status", + "urlBase": { + "title": "URL base", + "help": "You should use public endpoint for quotes, to be accessible by everyone" + } + } +} diff --git a/backend/locales/ar/ui.systems.raffles.json b/backend/locales/ar/ui.systems.raffles.json new file mode 100644 index 000000000..9b8075f19 --- /dev/null +++ b/backend/locales/ar/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Subscribers luck" + }, + "settings": { + "enabled": "Status", + "announceNewEntries": { + "title": "Announce new entries", + "help": "If users joins raffle, announce message will be send to chat after while." + }, + "announceNewEntriesBatchTime": { + "title": "How long to wait before announce new entries (in seconds)", + "help": "Longer time will keep chat cleaner, entries will be aggregated together." + }, + "deleteRaffleJoinCommands": { + "title": "Delete user raffle join command", + "help": "This will delete user message if they use !yourraffle command. Should keep chat cleaner." + }, + "allowOverTicketing": { + "title": "Allow over ticketing", + "help": "Allow user join raffle with over ticket of his points. E.g. user have 10 points but can join with !raffle 100 which will use all of his points." + }, + "raffleAnnounceInterval": { + "title": "Announce interval", + "help": "Minutes" + }, + "raffleAnnounceMessageInterval": { + "title": "Announce message interval", + "help": "How many messages must be sent to chat until announce can be posted." + }, + "subscribersPercent": { + "title": "Additional subscribers luck", + "help": "in percents" + } + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.ranks.json b/backend/locales/ar/ui.systems.ranks.json new file mode 100644 index 000000000..42a6861a6 --- /dev/null +++ b/backend/locales/ar/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "New Rank", + "empty": "No ranks were created yet.", + "emptyAfterSearch": "No ranks were found by your search for \"$search\".", + "rank": { + "name": "rank", + "placeholder": "" + }, + "value": { + "name": "hours", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.songs.json b/backend/locales/ar/ui.systems.songs.json new file mode 100644 index 000000000..c1df88a05 --- /dev/null +++ b/backend/locales/ar/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Status", + "volume": "Volume", + "calculateVolumeByLoudness": "Dynamic volume by loudness", + "duration": { + "title": "Max song duration", + "help": "In minutes" + }, + "shuffle": "Shuffle", + "songrequest": "Play from song request", + "playlist": "Play from playlist", + "onlyMusicCategory": "Allow only category music", + "allowRequestsOnlyFromPlaylist": "Allow song requests only from current playlist", + "notify": "Send message on song change" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "startTime": "Start song at", + "endTime": "End song at", + "add_song": "Add song", + "add_or_import": "Add song or import from playlist", + "importing": "Importing", + "importing_done": "Importing Done", + "seconds": "Seconds", + "calculated": "Calculated", + "set_manually": "Set manually", + "bannedSongsEmptyAfterSearch": "No banned songs were found by your search for \"$search\".", + "emptyAfterSearch": "No songs were found by your search for \"$search\".", + "empty": "No songs were added yet.", + "bannedSongsEmpty": "No songs were added to banlist yet." +} \ No newline at end of file diff --git a/backend/locales/ar/ui.systems.timers.json b/backend/locales/ar/ui.systems.timers.json new file mode 100644 index 000000000..70bcf937f --- /dev/null +++ b/backend/locales/ar/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "New Timer", + "empty": "No timers were created yet.", + "emptyAfterSearch": "No timers were found by your search for \"$search\".", + "add_response": "Add Response", + "settings": { + "enabled": "Status" + }, + "warning": "This action cannot be reverted!" +} \ No newline at end of file diff --git a/backend/locales/ar/ui.widgets.customvariables.json b/backend/locales/ar/ui.widgets.customvariables.json new file mode 100644 index 000000000..761875e3b --- /dev/null +++ b/backend/locales/ar/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "No custom variables found, add at custom variables registry", + "add-variable-into-watchlist": "Add variable to watchlist", + "watchlist": "Watchlist" +} \ No newline at end of file diff --git a/backend/locales/ar/ui.widgets.randomizer.json b/backend/locales/ar/ui.widgets.randomizer.json new file mode 100644 index 000000000..17a70ebb9 --- /dev/null +++ b/backend/locales/ar/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "No randomizer found, add at randomizer registry", + "add-randomizer-to-widget": "Add randomizer to widget" +} \ No newline at end of file diff --git a/backend/locales/ar/ui/categories.json b/backend/locales/ar/ui/categories.json new file mode 100644 index 000000000..fc4be8abb --- /dev/null +++ b/backend/locales/ar/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Announcements", + "keys": "Keys", + "currency": "Currency", + "general": "General", + "settings": "Settings", + "commands": "Commands", + "bot": "Bot", + "channel": "Channel", + "connection": "Connection", + "chat": "Chat", + "graceful_exit": "Graceful exit", + "rewards": "Rewards", + "levels": "Levels", + "notifications": "Notifications", + "options": "Options", + "comboBreakMessages": "Combo Break Messages", + "hypeMessages": "Hype Messages", + "messages": "Messages", + "results": "Results", + "customization": "Customization", + "status": "Status", + "mapping": "Mapping", + "player": "Player", + "stats": "Stats", + "api": "API", + "token": "Token", + "text": "Text", + "custom_texts": "Custom texts", + "credits": "Credits", + "show": "Show", + "social": "Social", + "explosion": "Explosion", + "fireworks": "Fireworks", + "test": "Test", + "emotes": "Emotes", + "default": "Default", + "urls": "URLs", + "conversion": "Conversion", + "xp": "XP", + "caps_filter": "Caps filter", + "color_filter": "Italic (/me) filter", + "links_filter": "Links filter", + "symbols_filter": "Symbols filter", + "longMessage_filter": "Message length filter", + "spam_filter": "Spam filter", + "emotes_filter": "Emotes filter", + "warnings": "Warnings", + "reset": "Reset", + "reminder": "Reminder", + "eligibility": "Eligibility", + "join": "Join", + "luck": "Luck", + "lists": "Lists", + "me": "Me", + "emotes_combo": "Emotes combo", + "tmi": "tmi", + "oauth": "oauth", + "eventsub": "eventsub", + "rules": "rules" +} \ No newline at end of file diff --git a/backend/locales/ar/ui/core/currency.json b/backend/locales/ar/ui/core/currency.json new file mode 100644 index 000000000..4b62e85a2 --- /dev/null +++ b/backend/locales/ar/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Main currency" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/core/general.json b/backend/locales/ar/ui/core/general.json new file mode 100644 index 000000000..cfe609bf2 --- /dev/null +++ b/backend/locales/ar/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Bot language", + "numberFormat": "Format of numbers in chat", + "gracefulExitEachXHours": { + "title": "Graceful exit each X hours", + "help": "0 - disabled" + }, + "shouldGracefulExitHelp": "Enabling of graceful exit is recommended if your bot is running endlessly on server. You should have bot running on pm2 (or similar service) or have it dockerized to ensure automatic bot restart. Bot won't gracefully exit when stream is online." + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/core/oauth.json b/backend/locales/ar/ui/core/oauth.json new file mode 100644 index 000000000..76b48ccc9 --- /dev/null +++ b/backend/locales/ar/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Owners", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "Username", + "channelUsername": "Username", + "botExpectedScopes": "Scopes", + "channelExpectedScopes": "Scopes" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/core/permissions.json b/backend/locales/ar/ui/core/permissions.json new file mode 100644 index 000000000..07aec34c0 --- /dev/null +++ b/backend/locales/ar/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Add new permission group", + "higherPermissionHaveAccessToLowerPermissions": "Higher Permission have access to lower permissions.", + "typeUsernameOrIdToSearch": "Type username or ID to search", + "typeUsernameOrIdToTest": "Type username or ID to test", + "noUsersWereFound": "No users were found.", + "noUsersManuallyAddedToPermissionYet": "No users were manually added to permission yet.", + "done": "Done", + "previous": "Previous", + "next": "Next", + "loading": "loading", + "permissionNotFoundInDatabase": "Permission not found in database, please save before testing user.", + "userHaveNoAccessToThisPermissionGroup": "User $username DOESN'T have access to this permission group.", + "userHaveAccessToThisPermissionGroup": "User $username HAVE access to this permission group.", + "accessDirectlyThrough": "Direct access through", + "accessThroughHigherPermission": "Access through higher permission", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Something went wrong, user $username was not found in bot database.", + "permissionsGroups": "Permissions Groups", + "allowHigherPermissions": "Allow access through higher permission", + "type": "Type", + "value": "Value", + "watched": "Watched time in hours", + "followtime": "Follow time in months", + "points": "Points", + "tips": "Tips", + "bits": "Bits", + "messages": "Messages", + "subtier": "Sub Tier (1, 2, or 3)", + "subcumulativemonths": "Sub cumulative months", + "substreakmonths": "Current sub streak", + "ranks": "Current rank", + "level": "Current level", + "isLowerThan": "is lower than", + "isLowerThanOrEquals": "is lower than or equals", + "equals": "equals", + "isHigherThanOrEquals": "is higher than or equals", + "isHigherThan": "is higher than", + "addFilter": "Add filter", + "selectPermissionGroup": "Select permission group", + "settings": "Settings", + "name": "Name", + "baseUsersSet": "Base set of users", + "manuallyAddedUsers": "Manually added users", + "manuallyExcludedUsers": "Manually excluded users", + "filters": "Filters", + "testUser": "Test user", + "none": "- none -", + "casters": "Casters", + "moderators": "Moderators", + "subscribers": "Subscribers", + "vip": "VIP", + "viewers": "Viewers", + "followers": "Followers" +} \ No newline at end of file diff --git a/backend/locales/ar/ui/core/socket.json b/backend/locales/ar/ui/core/socket.json new file mode 100644 index 000000000..38f1582f4 --- /dev/null +++ b/backend/locales/ar/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Purge All Authenticated Connection (yours as well)", + "accessTokenExpirationTime": "Access Token Expiration Time (seconds)", + "refreshTokenExpirationTime": "Refresh Token Expiration Time (seconds)", + "socketToken": { + "title": "Socket token", + "help": "This token will give you full admin access through sockets. Don't share!" + } + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/core/tmi.json b/backend/locales/ar/ui/core/tmi.json new file mode 100644 index 000000000..49e6fd64d --- /dev/null +++ b/backend/locales/ar/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Ignore list (ID or username)", + "showWithAt": "Show users with @", + "sendWithMe": "Send messages with /me", + "sendAsReply": "Send bot messages as replies", + "mute": "Bot is muted", + "whisperListener": "Listen on commands on whispers" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/core/tts.json b/backend/locales/ar/ui/core/tts.json new file mode 100644 index 000000000..f4b8119bc --- /dev/null +++ b/backend/locales/ar/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Service" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/core/twitch.json b/backend/locales/ar/ui/core/twitch.json new file mode 100644 index 000000000..e056c6a1e --- /dev/null +++ b/backend/locales/ar/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Create stream marker on event" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/core/ui.json b/backend/locales/ar/ui/core/ui.json new file mode 100644 index 000000000..1c4778f3d --- /dev/null +++ b/backend/locales/ar/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Default theme", + "domain": { + "title": "Domain", + "help": "Format without http/https: yourdomain.com or your.domain.com" + }, + "percentage": "Percentage difference for stats", + "shortennumbers": "Short format of numbers", + "showdiff": "Show difference", + "enablePublicPage": "Enable public page" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/core/updater.json b/backend/locales/ar/ui/core/updater.json new file mode 100644 index 000000000..b93fa3738 --- /dev/null +++ b/backend/locales/ar/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Automatically update if newer version available" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/errors.json b/backend/locales/ar/ui/errors.json new file mode 100644 index 000000000..8b3e4bef8 --- /dev/null +++ b/backend/locales/ar/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Unexpected errors during validation", + "isNotEmpty": "$property is required.", + "minLength": "$property must be longer than or equal to $constraint1 characters.", + "isPositive": "$property must be greater then 0", + "isCommand": "$property must start with !", + "isCommandOrCustomVariable": "$property must start with ! or $_", + "isCustomVariable": "$property must start with $_", + "min": "$property must be at least $constraint1", + "max": "$property must be lower or equal to $constraint1", + "isInt": "$property must be an integer", + "this_value_must_be_a_positive_number_and_greater_then_0": "This value must be a positive number or greater then 0", + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty", + "minLength_of_value_is": "Minimal length is $value.", + "this_currency_is_not_supported": "This currency is not supported", + "something_went_wrong": "Something went wrong", + "permission_must_exist": "Permission must exist", + "minValue_of_value_is": "Minimal value is $value", + "value_cannot_be": "Value cannot be $value.", + "invalid_format": "Invalid value format.", + "invalid_regexp_format": "This is not valid regex.", + "owner_and_broadcaster_oauth_is_not_set": "Owner and channel oauth is not set", + "channel_is_not_set": "Channel is not set", + "please_set_your_broadcaster_oauth_or_owners": "Please set your channel oauth or owners, or all users will have access to this dashboard and will be considered as casters.", + "new_update_available": "New update available", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "One of inputs must be set" +} \ No newline at end of file diff --git a/backend/locales/ar/ui/games/duel.json b/backend/locales/ar/ui/games/duel.json new file mode 100644 index 000000000..84789a717 --- /dev/null +++ b/backend/locales/ar/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Status", + "cooldown": "Cooldown", + "duration": { + "title": "Duration", + "help": "Minutes" + }, + "minimalBet": "Minimal bet", + "bypassCooldownByOwnerAndMods": "Bypass cooldown by owner and mods" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/games/gamble.json b/backend/locales/ar/ui/games/gamble.json new file mode 100644 index 000000000..6a78309aa --- /dev/null +++ b/backend/locales/ar/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "minimalBet": "Minimal bet", + "chanceToWin": { + "title": "Chance to win", + "help": "Percent" + }, + "enableJackpot": "Enable jackpot", + "chanceToTriggerJackpot": "Chance to trigger jackpot in %", + "maxJackpotValue": "Maximum value of jackpot", + "lostPointsAddedToJackpot": "How many lost points should be added to jackpot in %" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/games/heist.json b/backend/locales/ar/ui/games/heist.json new file mode 100644 index 000000000..e0ffc9feb --- /dev/null +++ b/backend/locales/ar/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Heist", + "settings": { + "enabled": "Status", + "showMaxUsers": "Max users to show in payout", + "copsCooldownInMinutes": { + "title": "Cooldown between heists", + "help": "Minutes" + }, + "entryCooldownInSeconds": { + "title": "Time to entry heist", + "help": "Seconds" + }, + "started": "Heist start message", + "nextLevelMessage": "Message when next level is reached", + "maxLevelMessage": "Message when max level is reached", + "copsOnPatrol": "Response of bot when heist is still on cooldown", + "copsCooldown": "Bot announcement when heist can be started", + "singleUserSuccess": "Success message for one user", + "singleUserFailed": "Fail message for one user", + "noUser": "Message if no user participated" + }, + "message": "Message", + "winPercentage": "Win percentage", + "payoutMultiplier": "Payout multiplier", + "maxUsers": "Max users for level", + "percentage": "Percentage", + "noResultsFound": "No results found. Click button below to add new result.", + "noLevelsFound": "No levels found. Click button below to add new level." +} \ No newline at end of file diff --git a/backend/locales/ar/ui/games/roulette.json b/backend/locales/ar/ui/games/roulette.json new file mode 100644 index 000000000..65696d4e3 --- /dev/null +++ b/backend/locales/ar/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout duration", + "help": "Seconds" + }, + "winnerWillGet": "How many points will be added on win", + "loserWillLose": "How many points will be lost on lose" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/games/seppuku.json b/backend/locales/ar/ui/games/seppuku.json new file mode 100644 index 000000000..4d628e202 --- /dev/null +++ b/backend/locales/ar/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout duration", + "help": "Seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/integrations/discord.json b/backend/locales/ar/ui/integrations/discord.json new file mode 100644 index 000000000..baa645ab9 --- /dev/null +++ b/backend/locales/ar/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Status", + "guild": "Guild", + "listenAtChannels": "Listen for commands on this channel", + "sendOnlineAnnounceToChannel": "Send online announcement to this channel", + "onlineAnnounceMessage": "Message in online announcement (can include mentions)", + "sendAnnouncesToChannel": "Setup sending of announcements to channels", + "deleteMessagesAfterWhile": "Delete message after while", + "clientId": "ClientId", + "token": "Token", + "joinToServerBtn": "Click to join bot to your server", + "joinToServerBtnDisabled": "Please save changes to enable bot join to your server", + "cannotJoinToServerBtn": "Set token and clientId to be able to join bot to your server", + "noChannelSelected": "no channel selected", + "noRoleSelected": "no role selected", + "noGuildSelected": "no guild selected", + "noGuildSelectedBox": "Select guild where bot should work and you'll see more settings", + "onlinePresenceStatusDefault": "Default Status", + "onlinePresenceStatusDefaultName": "Default Status Message", + "onlinePresenceStatusOnStream": "Status when Streaming", + "onlinePresenceStatusOnStreamName": "Status Message when Streaming", + "ignorelist": { + "title": "Ignore list", + "help": "username, username#0000 or userID" + } + } +} diff --git a/backend/locales/ar/ui/integrations/donatello.json b/backend/locales/ar/ui/integrations/donatello.json new file mode 100644 index 000000000..75bd1598d --- /dev/null +++ b/backend/locales/ar/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Get your token at https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/integrations/donationalerts.json b/backend/locales/ar/ui/integrations/donationalerts.json new file mode 100644 index 000000000..e37a63aee --- /dev/null +++ b/backend/locales/ar/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Status", + "access_token": { + "title": "Access token", + "help": "Get your access token at https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Refresh token" + }, + "accessTokenBtn": "DonationAlerts access and refresh token generator" + } +} diff --git a/backend/locales/ar/ui/integrations/kofi.json b/backend/locales/ar/ui/integrations/kofi.json new file mode 100644 index 000000000..a8179bf1e --- /dev/null +++ b/backend/locales/ar/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Verification token", + "help": "Get your verification token at https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "Webhook URL", + "help": "Set Webhook URL at https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL must have HTTPS", + "origin": "You cannot use localhost for webhooks" + } + } + } +} diff --git a/backend/locales/ar/ui/integrations/lastfm.json b/backend/locales/ar/ui/integrations/lastfm.json new file mode 100644 index 000000000..3acc84d8a --- /dev/null +++ b/backend/locales/ar/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": "API key", + "username": "Username" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/integrations/obswebsocket.json b/backend/locales/ar/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..e681b04e4 --- /dev/null +++ b/backend/locales/ar/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Status", + "accessBy": { + "title": "Access by", + "help": "Direct - connect directly from a bot | Overlay - connect via overlay browser source" + }, + "address": "Address", + "password": "Password" + }, + "noSourceSelected": "No source selected", + "noSceneSelected": "No scene selected", + "empty": "No action sets were created yet.", + "emptyAfterSearch": "No action sets were found by your search for \"$search\".", + "command": "Command", + "new": "Create new OBS Websocket action set", + "actions": "Actions", + "name": { + "name": "Name" + }, + "mute": "Mute", + "unmute": "Unmute", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Wait X miliseconds" + }, + "Log": { + "name": "Log message" + }, + "StartRecording": { + "name": "StartRecording" + }, + "StopRecording": { + "name": "StopRecording" + }, + "PauseRecording": { + "name": "PauseRecording" + }, + "ResumeRecording": { + "name": "ResumeRecording" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/integrations/pubg.json b/backend/locales/ar/ui/integrations/pubg.json new file mode 100644 index 000000000..166aef5d9 --- /dev/null +++ b/backend/locales/ar/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "API Key", + "help": "Get your API Key at https://developer.pubg.com/" + }, + "platform": "Platform", + "playerName": "Player Name", + "playerId": "Player ID", + "seasonId": { + "title": "Season ID", + "help": "Current season ID is being fetch every hour." + }, + "rankedGameModeStatsCustomization": "Customized message for ranked stats", + "gameModeStatsCustomization": "Customized message for normal stats" + }, + "click_to_fetch": "Click to fetch", + "something_went_wrong": "Something went wrong!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Stats are automatically refreshed every 10 minutes.", + "player_stats_ranked": "Player stats (ranked)", + "player_stats": "Player stats" +} diff --git a/backend/locales/ar/ui/integrations/qiwi.json b/backend/locales/ar/ui/integrations/qiwi.json new file mode 100644 index 000000000..e5f7cb336 --- /dev/null +++ b/backend/locales/ar/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "secretToken": { + "title": "Secret token", + "help": "Get secret token at Qiwi Donate dashboard settings->click show secret token" + } + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/integrations/responsivevoice.json b/backend/locales/ar/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..fc98112a2 --- /dev/null +++ b/backend/locales/ar/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Key", + "help": "Get your key at http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/integrations/spotify.json b/backend/locales/ar/ui/integrations/spotify.json new file mode 100644 index 000000000..e3f885ae2 --- /dev/null +++ b/backend/locales/ar/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Artists", + "settings": { + "enabled": "Status", + "songRequests": "Song Requests", + "fetchCurrentSongWhenOffline": { + "title": "Fetch current song when stream is offline", + "help": "It's advised to have this disabled to avoid reach API limits" + }, + "allowApprovedArtistsOnly": "Allow approved artists only", + "approvedArtists": { + "title": "Approved artists", + "help": "Name or SpotifyURI of artist, one item per line" + }, + "queueWhenOffline": { + "title": "Queue songs when stream is offline", + "help": "It's advised to have this disabled to avoid queueing when you are just listening music" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "Forced Device ID", + "help": "Empty = disabled, force spotify device ID to be used to queue songs. Check logs for current active device or use button when playing song for at least 10 seconds." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Format", + "help": "Available variables: $song, $artist, $artists" + }, + "username": "Authorized user", + "revokeBtn": "Revoke user authorization", + "authorizeBtn": "Authorize user", + "scopes": "Scopes", + "playlistToPlay": { + "title": "Spotify URI of main playlist", + "help": "If set, after request finished this playlist will continue" + }, + "continueOnPlaylistAfterRequest": "Continue on playing of playlist after song request", + "notify": "Send message on song change" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/integrations/streamelements.json b/backend/locales/ar/ui/integrations/streamelements.json new file mode 100644 index 000000000..b983c17ff --- /dev/null +++ b/backend/locales/ar/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "jwtToken": { + "title": "JWT token", + "help": "Get JWT token at StreamElements Channels setting and toggle Show secrets" + } + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/integrations/streamlabs.json b/backend/locales/ar/ui/integrations/streamlabs.json new file mode 100644 index 000000000..a2c359f1b --- /dev/null +++ b/backend/locales/ar/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "socketToken": { + "title": "Socket token", + "help": "Get socket token from streamlabs dashboard API settings->API tokens->Your Socket API Token" + }, + "accessToken": { + "title": "Access token", + "help": "Get your access token at https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "StreamLabs access token generator" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/integrations/tipeeestream.json b/backend/locales/ar/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..880b7bcfe --- /dev/null +++ b/backend/locales/ar/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "Api key", + "help": "Get socket token from tipeeestream dashboard -> API -> Your API Key" + } + } +} diff --git a/backend/locales/ar/ui/integrations/twitter.json b/backend/locales/ar/ui/integrations/twitter.json new file mode 100644 index 000000000..940fd5589 --- /dev/null +++ b/backend/locales/ar/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "consumerKey": "Consumer Key (API Key)", + "consumerSecret": "Consumer Secret (API Secret)", + "accessToken": "Access Token", + "secretToken": "Access Token Secret" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/managers.json b/backend/locales/ar/ui/managers.json new file mode 100644 index 000000000..21055ff3e --- /dev/null +++ b/backend/locales/ar/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "User event history", + "hostAndRaidViewersCount": "Viewers: $value", + "receivedSubscribeFrom": "Received subscribe from $value", + "giftedSubscribeTo": "Gifted subscribe to $value" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/overlays/alerts.json b/backend/locales/ar/ui/overlays/alerts.json new file mode 100644 index 000000000..2c72859cb --- /dev/null +++ b/backend/locales/ar/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Cache gallery items", + "galleryCacheLimitInMb": "Max size of gallery item (in MB) to cache" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/overlays/clips.json b/backend/locales/ar/ui/overlays/clips.json new file mode 100644 index 000000000..aa6555159 --- /dev/null +++ b/backend/locales/ar/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Volume", + "cClipsFilter": "Clip filter", + "cClipsLabel": "Show 'clip' label" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/overlays/clipscarousel.json b/backend/locales/ar/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..b50a0a71d --- /dev/null +++ b/backend/locales/ar/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsTimeToNextClip": "Time to next clip (s)" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/overlays/credits.json b/backend/locales/ar/ui/overlays/credits.json new file mode 100644 index 000000000..6b2f72805 --- /dev/null +++ b/backend/locales/ar/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Speed", + "cCreditsAggregated": "Aggregated credits", + "cShowGameThumbnail": "Show game thumbnail", + "cShowFollowers": "Show followers", + "cShowRaids": "Show raids", + "cShowSubscribers": "Show subscribers", + "cShowSubgifts": "Show gifted subs", + "cShowSubcommunitygifts": "Show subs gifted to community", + "cShowResubs": "Show resubs", + "cShowCheers": "Show cheers", + "cShowClips": "Show clips", + "cShowTips": "Show tips", + "cTextLastMessage": "Last message", + "cTextLastSubMessage": "Last submessge", + "cTextStreamBy": "Streamed by", + "cTextFollow": "Follow by", + "cTextRaid": "Raided by", + "cTextCheer": "Cheer by", + "cTextSub": "Subscribe by", + "cTextResub": "Resub by", + "cTextSubgift": "Gifted subs", + "cTextSubcommunitygift": "Subs gifted to community", + "cTextTip": "Tips by", + "cClipsPeriod": "Time interval", + "cClipsCustomPeriodInDays": "Custom time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsShouldPlay": "Clips should be played", + "cClipsVolume": "Volume" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/overlays/emotes.json b/backend/locales/ar/ui/overlays/emotes.json new file mode 100644 index 000000000..8a961ad91 --- /dev/null +++ b/backend/locales/ar/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Delete cache", + "hypeMessagesEnabled": "Show hype messages in chat", + "btnTestExplosion": "Test emote explosion", + "btnTestEmote": "Test emote", + "btnTestFirework": "Test emote firework", + "cEmotesSize": "Emotes size", + "cEmotesMaxEmotesPerMessage": "Maximum of emotes per message", + "cEmotesMaxRotation": "Maximal rotation of emote", + "cEmotesOffsetX": "Maximal offset on X-axis", + "cEmotesAnimation": "Animation", + "cEmotesAnimationTime": "Animation duration", + "cExplosionNumOfEmotes": "No. of emotes", + "cExplosionNumOfEmotesPerExplosion": "No. of emotes per explosion", + "cExplosionNumOfExplosions": "No. of explosions", + "enableEmotesCombo": "Enable emotes combo", + "comboBreakMessages": "Combo break messages", + "threshold": "Threshold", + "noMessagesFound": "No messages found.", + "message": "Message", + "showEmoteInOverlayThreshold": "Minimal message threshold to show emote in overlay", + "hideEmoteInOverlayAfter": { + "title": "Hide emote in overlay after inactivity", + "help": "Will hide emote in overlay after certain time in seconds" + }, + "comboCooldown": { + "title": "Combo cooldown", + "help": "Cooldown of combo in seconds" + }, + "comboMessageMinThreshold": { + "title": "Minimal message threshold", + "help": "Minimal message threshold to count emotes as combo (until then won't trigger cooldown)" + }, + "comboMessages": "Combo messages" + }, + "hype": { + "5": "Let's go! We got $amountx $emote combo so far! SeemsGood", + "15": "Keep it going! Can we get more than $amountx $emote? TriHard" + }, + "message": { + "3": "$amountx $emote combo", + "5": "$amountx $emote combo SeemsGood", + "10": "$amountx $emote combo PogChamp", + "15": "$amountx $emote combo TriHard", + "20": "$sender ruined $amountx $emote combo! NotLikeThis" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/overlays/polls.json b/backend/locales/ar/ui/overlays/polls.json new file mode 100644 index 000000000..da094ce9b --- /dev/null +++ b/backend/locales/ar/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Theme", + "cDisplayHideAfterInactivity": "Hide on inactivity", + "cDisplayAlign": "Align", + "cDisplayInactivityTime": { + "title": "Inactivity after", + "help": "in miliseconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/overlays/texttospeech.json b/backend/locales/ar/ui/overlays/texttospeech.json new file mode 100644 index 000000000..c61ee3567 --- /dev/null +++ b/backend/locales/ar/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "You haven't properly set ResponsiveVoice key", + "voice": { + "title": "Voice", + "help": "If voices are not properly loading after ResponsiveVoice key update, try to refresh browser" + }, + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "triggerTTSByHighlightedMessage": "Text to Speech will be triggered by highlighted message" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/properties.json b/backend/locales/ar/ui/properties.json new file mode 100644 index 000000000..e6243cf72 --- /dev/null +++ b/backend/locales/ar/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Command", + "variableName": "Variable name", + "price": "Price (points)", + "priceBits": "Price (bits)", + "thisvalue": "This value", + "promo": { + "shoutoutMessage": "Shoutout message", + "enableShoutoutMessage": "Send shoutout message in chat" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/registry/alerts.json b/backend/locales/ar/ui/registry/alerts.json new file mode 100644 index 000000000..d12319c25 --- /dev/null +++ b/backend/locales/ar/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Enabled", + "testDlg": { + "alertTester": "Alert tester", + "command": "Command", + "username": "Username", + "recipient": "Recipient", + "message": "Message", + "tier": "Tier", + "amountOfViewers": "Amount of viewers", + "amountOfBits": "Amount of bits", + "amountOfGifts": "Amount of gifts", + "amountOfMonths": "Amount of months", + "amountOfTips": "Tip", + "event": "Event", + "service": "Service" + }, + "empty": "Alerts registry is empty, create new alerts.", + "emptyAfterSearch": "Alerts registry is empty in searching for \"$search\"", + "revertcode": "Revert code to defaults", + "name": { + "name": "Name", + "placeholder": "Set name of your alerts" + }, + "alertDelayInMs": { + "name": "Alert delay" + }, + "parryEnabled": { + "name": "Alert parries" + }, + "parryDelay": { + "name": "Alert parry delay" + }, + "profanityFilterType": { + "name": "Profanity filter", + "disabled": "Disabled", + "replace-with-asterisk": "Replace with asterisk", + "replace-with-happy-words": "Replace with happy words", + "hide-messages": "Hide messages", + "disable-alerts": "Disable alerts" + }, + "loadStandardProfanityList": "Load standard profanity list", + "customProfanityList": { + "name": "Custom profanity list", + "help": "Words should be separated with comma." + }, + "event": { + "follow": "Follow", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Subgift to community", + "tip": "Tip", + "raid": "Raid", + "custom": "Custom", + "promo": "Promo", + "rewardredeem": "Reward Redeem" + }, + "title": { + "name": "Variant name", + "placeholder": "Set your variant name" + }, + "variant": { + "name": "Variant occurence" + }, + "filter": { + "name": "Filter", + "operator": "Operator", + "rule": "Rule", + "addRule": "Add rule", + "addGroup": "Add group", + "comparator": "Comparator", + "value": "Value", + "valueSplitByComma": "Values split by comma (e.g. val1, val2)", + "isEven": "is even", + "isOdd": "is odd", + "lessThan": "less than", + "lessThanOrEqual": "less than or equal", + "contain": "contains", + "contains": "contains", + "equal": "equal", + "notEqual": "not equal", + "present": "is present", + "includes": "includes", + "greaterThan": "greater than", + "greaterThanOrEqual": "greater than or equal", + "noFilter": "no filter" + }, + "speed": { + "name": "Speed" + }, + "maxTimeToDecrypt": { + "name": "Max time to decrypt" + }, + "characters": { + "name": "Characters" + }, + "random": "Random", + "exact-amount": "Exact amount", + "greater-than-or-equal-to-amount": "Greater than or equal to amount", + "tier-exact-amount": "Tier is exactly", + "tier-greater-than-or-equal-to-amount": "Tier is higher or equal to", + "months-exact-amount": "Months amount is exactly", + "months-greater-than-or-equal-to-amount": "Months amount is higher or equal to", + "gifts-exact-amount": "Gifts amount is exactly", + "gifts-greater-than-or-equal-to-amount": "Gifts amount is higher or equal to", + "very-rarely": "Very rarely", + "rarely": "Rarely", + "default": "Default", + "frequently": "Frequently", + "very-frequently": "Very frequently", + "exclusive": "Exclusive", + "messageTemplate": { + "name": "Message template", + "placeholder": "Set your message template", + "help": "Available variables: {name}, {amount} (cheers, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). If | is added (see promo) then it will show those values in sequence." + }, + "ttsTemplate": { + "name": "TTS template", + "placeholder": "Set your TTS template", + "help": "Available variables: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Animation text" + }, + "animationType": { + "name": "Type of animation" + }, + "animationIn": { + "name": "Animation in" + }, + "animationOut": { + "name": "Animation out" + }, + "alertDurationInMs": { + "name": "Alert duration" + }, + "alertTextDelayInMs": { + "name": "Alert text delay" + }, + "layoutPicker": { + "name": "Layout" + }, + "loop": { + "name": "Play on loop" + }, + "scale": { + "name": "Scale" + }, + "translateY": { + "name": "Move -Up / +Down" + }, + "translateX": { + "name": "Move -Left / +Right" + }, + "image": { + "name": "Image / Video(.webm)", + "setting": "Image / Video(.webm) settings" + }, + "sound": { + "name": "Sound", + "setting": "Sound settings" + }, + "soundVolume": { + "name": "Alert volume" + }, + "enableAdvancedMode": "Enable advanced mode", + "font": { + "setting": "Font settings", + "name": "Font family", + "overrideGlobal": "Override global font settings", + "align": { + "name": "Alignment", + "left": "Left", + "center": "Center", + "right": "Right" + }, + "size": { + "name": "Font size" + }, + "weight": { + "name": "Font weight" + }, + "borderPx": { + "name": "Font border" + }, + "borderColor": { + "name": "Font border color" + }, + "color": { + "name": "Font color" + }, + "highlightcolor": { + "name": "Font highlight color" + } + }, + "minAmountToShow": { + "name": "Minimal amount to show" + }, + "minAmountToPlay": { + "name": "Minimal amount to play" + }, + "allowEmotes": { + "name": "Allow emotes" + }, + "message": { + "setting": "Message settings" + }, + "voice": "Voice", + "keepAlertShown": "Alert keeps visible during TTS", + "skipUrls": "Skip URLs during TTS", + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "test": "Test", + "tts": { + "setting": "TTS settings" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/registry/goals.json b/backend/locales/ar/ui/registry/goals.json new file mode 100644 index 000000000..8c486828d --- /dev/null +++ b/backend/locales/ar/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Add Goal Group", + "addGoal": "Add Goal", + "newGoal": "new Goal", + "newGoalGroup": "new Goal Group", + "goals": "Goals", + "general": "General", + "display": "Display", + "fontSettings": "Font Settings", + "barSettings": "Bar Settings", + "selectGoalOnLeftSide": "Select or add goal on left side", + "input": { + "description": { + "title": "Description" + }, + "goalAmount": { + "title": "Goal Amount" + }, + "countBitsAsTips": { + "title": "Count Bits as Tips" + }, + "currentAmount": { + "title": "Current Amount" + }, + "endAfter": { + "title": "End After" + }, + "endAfterIgnore": { + "title": "Goal will not expire" + }, + "borderPx": { + "title": "Border", + "help": "Border size is in pixels" + }, + "barHeight": { + "title": "Bar Height", + "help": "Bar height is in pixels" + }, + "color": { + "title": "Color" + }, + "borderColor": { + "title": "Border Color" + }, + "backgroundColor": { + "title": "Background Color" + }, + "type": { + "title": "Type" + }, + "nameGroup": { + "title": "Name of this goal group" + }, + "name": { + "title": "Name of this goal" + }, + "displayAs": { + "title": "Display as", + "help": "Sets how goal group will be shown" + }, + "durationMs": { + "title": "Duration", + "help": "This value is in milliseconds", + "placeholder": "How long goal should be shown" + }, + "animationInMs": { + "title": "Animation In duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation In duration" + }, + "animationOutMs": { + "title": "Animation Out duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation Out duration" + }, + "interval": { + "title": "What interval to count" + }, + "spaceBetweenGoalsInPx": { + "title": "Space between goals", + "help": "This value is in pixels", + "placeholder": "Set your space between goals" + } + }, + "groupSettings": "Group Settings" +} \ No newline at end of file diff --git a/backend/locales/ar/ui/registry/overlays.json b/backend/locales/ar/ui/registry/overlays.json new file mode 100644 index 000000000..f56199cb8 --- /dev/null +++ b/backend/locales/ar/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Create new overlay link mapping", + "emptyMapping": "No overlay link mapping were created yet.", + "allowedIPs": { + "name": "Allowed IPs", + "help": "Allow access from set IPs separated by new line" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/registry/plugins.json b/backend/locales/ar/ui/registry/plugins.json new file mode 100644 index 000000000..00eb1444f --- /dev/null +++ b/backend/locales/ar/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "This node needs to be linked with listeners with sender attributes" + }, + "filter": { + "permission": { + "name": "Permission filter" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Event listener", + "type": { + "twitchChatMessage": "Twitch chat message", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat cleared", + "twitchCommand": "Twitch command", + "twitchFollow": "New Twitch follower", + "twitchSubscription": "New Twitch subscription", + "twitchSubgift": "New Twitch subscription gift", + "twitchSubcommunitygift": "New Twitch subscription community gift", + "twitchResub": "New Twitch recurring subscription", + "twitchGameChanged": "Twitch category changed", + "twitchStreamStarted": "Twitch stream started", + "twitchStreamStopped": "Twitch stream stopped", + "twitchRewardRedeem": "Twitch reward redeemed", + "twitchRaid": "Twitch raid incoming", + "tip": "Tipped by user", + "botStarted": "Bot started" + }, + "command": { + "add-parameter": "Add parameter", + "parameters": "Parameters", + "order-is-important": "order is important" + } + }, + "others": { + "idle": { + "name": "Idle" + } + }, + "output": { + "log": { + "name": "Log message" + }, + "timeout-user": { + "name": "Timeout user" + }, + "ban-user": { + "name": "Ban user" + }, + "send-twitch-message": { + "name": "Send Twitch Message" + } + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/registry/randomizer.json b/backend/locales/ar/ui/registry/randomizer.json new file mode 100644 index 000000000..5f728918b --- /dev/null +++ b/backend/locales/ar/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Add Randomizer", + "form": { + "name": "Name", + "command": "Command", + "permission": "Command permission", + "simple": "Simple", + "tape": "Tape", + "wheelOfFortune": "Wheel of Fortune", + "type": "Type", + "options": "Options", + "optionsAreEmpty": "Options are empty.", + "color": "Color", + "numOfDuplicates": "No. of duplicates", + "minimalSpacing": "Minimal spacing", + "groupUp": "Group Up", + "ungroup": "Ungroup", + "groupedWithOptionAbove": "Grouped with option above", + "generatedOptionsPreview": "Preview of generated options", + "probability": "Probability", + "tick": "Tick sound during spin" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/registry/textoverlay.json b/backend/locales/ar/ui/registry/textoverlay.json new file mode 100644 index 000000000..03e974b69 --- /dev/null +++ b/backend/locales/ar/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Create new text overlay", + "title": "text overlay", + "name": { + "placeholder": "Set your text overlay name" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/stats/commandcount.json b/backend/locales/ar/ui/stats/commandcount.json new file mode 100644 index 000000000..e6fd27f4c --- /dev/null +++ b/backend/locales/ar/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Command", + "hour": "Hour", + "day": "Day", + "week": "Week", + "month": "Month", + "year": "Year", + "total": "Total" +} \ No newline at end of file diff --git a/backend/locales/ar/ui/systems/checklist.json b/backend/locales/ar/ui/systems/checklist.json new file mode 100644 index 000000000..eac70e101 --- /dev/null +++ b/backend/locales/ar/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "itemsArray": "List" + }, + "check": "Checklist" +} \ No newline at end of file diff --git a/backend/locales/ar/ui/systems/howlongtobeat.json b/backend/locales/ar/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..a9dcc7f7a --- /dev/null +++ b/backend/locales/ar/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Status" + }, + "empty": "No games were tracked yet.", + "emptyAfterSearch": "No tracked games were found by your search for \"$search\".", + "when": "When streamed", + "time": "Tracked time", + "overallTime": "Overall time", + "offset": "Offset of tracked time", + "main": "Main", + "extra": "Main+Extra", + "completionist": "Completionist", + "game": "Tracked game", + "startedAt": "Tracking started at", + "updatedAt": "Last update", + "showHistory": "Show history ($count)", + "hideHistory": "Hide history ($count)", + "searchToAddNewGame": "Search to add new game to track" +} \ No newline at end of file diff --git a/backend/locales/ar/ui/systems/keywords.json b/backend/locales/ar/ui/systems/keywords.json new file mode 100644 index 000000000..9e725400f --- /dev/null +++ b/backend/locales/ar/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "New Keyword", + "empty": "No keywords were created yet.", + "emptyAfterSearch": "No keywords were found by your search for \"$search\".", + "keyword": { + "name": "Keyword / Regular Expression", + "placeholder": "Set your keyword or regular expression to trigger keyword.", + "help": "You can use regexp (case insensitive) to use keywords, e.g. hello.*|hi" + }, + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "no-responses-set": "No responses", + "addResponse": "Add response", + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/systems/levels.json b/backend/locales/ar/ui/systems/levels.json new file mode 100644 index 000000000..91bcaa02b --- /dev/null +++ b/backend/locales/ar/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Status", + "conversionRate": "Conversion rate 1 XP for x Points", + "firstLevelStartsAt": "First level starts at XP", + "nextLevelFormula": { + "title": "Next level calculation formula", + "help": "Available variables: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "Levels example will be refreshed on save", + "xpName": "Name", + "interval": "Minutes interval to add xp to online users when stream online", + "offlineInterval": "Minutes interval to add xp to online users when stream offline", + "messageInterval": "How many messages to add xp", + "messageOfflineInterval": "How many messages to add xp when stream offline", + "perInterval": "How many xp to add per online interval", + "perOfflineInterval": "How many xp to add per offline interval", + "perMessageInterval": "How many xp to add per message interval", + "perMessageOfflineInterval": "How many xp to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/systems/polls.json b/backend/locales/ar/ui/systems/polls.json new file mode 100644 index 000000000..f31a08052 --- /dev/null +++ b/backend/locales/ar/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Total votes", + "totalPoints": "Total points", + "closedAt": "Closed at", + "activeFor": "Active for" +} \ No newline at end of file diff --git a/backend/locales/ar/ui/systems/scrim.json b/backend/locales/ar/ui/systems/scrim.json new file mode 100644 index 000000000..6b719fc3b --- /dev/null +++ b/backend/locales/ar/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "waitForMatchIdsInSeconds": { + "title": "Interval for putting match ID into chat", + "help": "Set in seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/systems/top.json b/backend/locales/ar/ui/systems/top.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/ar/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/ar/ui/systems/userinfo.json b/backend/locales/ar/ui/systems/userinfo.json new file mode 100644 index 000000000..c8bba8b80 --- /dev/null +++ b/backend/locales/ar/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "formatSeparator": "Format separator", + "order": "Format", + "lastSeenFormat": { + "title": "Time format", + "help": "Possible formats at https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs.json b/backend/locales/cs.json new file mode 100644 index 000000000..dc8a1ace8 --- /dev/null +++ b/backend/locales/cs.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "je načten a", + "enabled": "zapnuto", + "disabled": "vypnuto", + "usage": "Použití", + "lang-selected": "Jazyk bota je nyní nastaven na češtinu", + "refresh-panel": "Budete muset aktualizovat uživatelské rozhraní, abyste mohli vidět změny.", + "command-parse": "Omlouváme se, $sender, ale tento příkaz není správný, použijte", + "error": "Omlouváme se, $sender, ale něco se pokazilo!", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, API nereaguje správně!", + "not-available": "není k dispozici" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "rok|4:roky|roků", + "months": "měsíc|4:měsíce|měsíců", + "days": "den|4:dny|dnů", + "hours": "hodina|4:hodiny|hodin", + "minutes": "minuta|4:minuty|minut", + "seconds": "sekunda|4:sekundy|sekund", + "messages": "zpráva|4:zprávy|zpráv", + "bits": "bit|4:bity|bitů", + "links": "odkaz|4:odkazy|odkazů", + "entries": "vstup|4:vstupy|vstupů", + "empty": "prázdné", + "isRegistered": "$sender, nemůžeš použít $keyword, protože je již používán pro jinou akci!" + }, + "clip": { + "notCreated": "Něco se pokazilo a klip nebyl vytvořen.", + "offline": "Stream je momentálně offline a klip nelze vytvořit." + }, + "uptime": { + "online": "Stream je online (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)", + "offline": "Stream je momentálně offline (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)" + }, + "webpanel": { + "this-system-is-disabled": "Tento systém je vypnutý", + "or": "nebo", + "loading": "Načítám", + "this-may-take-a-while": "Může to chvíli trvat", + "display-as": "Zobrazit jako", + "go-to-admin": "Přejít na Admina", + "go-to-public": "Přejít na Veřejné", + "logout": "Odhlásit", + "popout": "Popout", + "not-logged-in": "Nejste přihlášen", + "remove-widget": "Odstranit $name widget", + "join-channel": "Připojit bota na kanál", + "leave-channel": "Opustit kanál", + "set-default": "Nastavit výchozí", + "add": "Přidat", + "placeholders": { + "text-url-generator": "Vložte text nebo HTML pro vytvoření base64 níže a URL výše", + "text-decode-base64": "Vložte base64 a vygenerujte URL a text výše", + "creditsSpeed": "Nastavte rychlost titulek, nižší = rychlejší" + }, + "timers": { + "title": "Časovače", + "timer": "Časovač", + "messages": "zprávy", + "seconds": "vteřiny", + "badges": { + "enabled": "Zapnuto", + "disabled": "Vypnuto" + }, + "errors": { + "timer_name_must_be_compliant": "Tato hodnota může obsahovat jen tyto znaky a-zA-Z09_", + "this_value_must_be_a_positive_number_or_0": "Tato hodnota musí být kladné číslo nebo 0", + "value_cannot_be_empty": "Hodnota nemůže být prázdná" + }, + "dialog": { + "timer": "Časovač", + "name": "Název", + "tickOffline": "Bude odpočítávat i při vypnutém streamu", + "interval": "Interval", + "responses": "Odpovědi", + "messages": "Každých x zpráv", + "seconds": "Každých x vteřin", + "title": { + "new": "Nový časovač", + "edit": "Upravit časovač" + }, + "placeholders": { + "name": "Nastavte jméno časovače, může obsahovat pouze tyto znaky a-zA-Z0-9_", + "messages": "Spustit každých x zpráv", + "seconds": "Spustit každých x vteřin" + }, + "alerts": { + "success": "Časovač byl úspěšně uložen.", + "fail": "Něco se pokazilo." + } + }, + "buttons": { + "close": "Zavřít", + "save-changes": "Uložit změny", + "disable": "Vypnout", + "enable": "Zapnout", + "edit": "Upravit", + "delete": "Smazat", + "yes": "Ano", + "no": "Ne" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Opravdu chcete smazat časovač" + } + }, + "events": { + "event": "Událost", + "noEvents": "Žádné události nebyly nalezeny v databázi.", + "whatsthis": "co je to?", + "myRewardIsNotListed": "Moje odměna není zobrazena!", + "redeemAndClickRefreshToSeeReward": "Pokud Vám chybí vytvořená odměna v seznamu obnovte seznam kliknutím na ikonu obnovení.", + "badges": { + "enabled": "Zapnuto", + "disabled": "Vypnuto" + }, + "buttons": { + "test": "Test", + "enable": "Zapnout", + "disable": "Vypnout", + "edit": "Upravit", + "delete": "Smazat", + "yes": "Ano", + "no": "Ne" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Opravdu chcete smazat událost", + "example_of_user_object_data": "Příklad dat v objektu uživatele" + }, + "errors": { + "command_must_start_with_!": "Příkaz musí začít !", + "this_value_must_be_a_positive_number_or_0": "Tato hodnota musí být kladné číslo nebo 0", + "value_cannot_be_empty": "Hodnota nemůže být prázdná" + }, + "dialog": { + "title": { + "new": "Nová událost", + "edit": "Upravit událost" + }, + "placeholders": { + "name": "Nastavte jméno události (pokud je prázdné, jméno se vygeneruje)" + }, + "alerts": { + "success": "Událost byla úspěšně uložena.", + "fail": "Něco se pokazilo." + }, + "close": "Zavřít", + "save-changes": "Uložit změny", + "event": "Událost", + "name": "Název", + "usable-events-variables": "Použitelné proměnné události", + "settings": "Nastavení", + "filters": "Filtry", + "operations": "Operace" + }, + "definitions": { + "taskId": { + "label": "ID úkolu" + }, + "filter": { + "label": "Filtr" + }, + "linkFilter": { + "label": "Odkaz nebo ID overlaye", + "placeholder": "Pokud používáte overlay, přidejte odkaz nebo ID" + }, + "hashtag": { + "label": "Hashtag nebo klíčové slovo", + "placeholder": "#yourHashtagHere nebo klíčovéSlovo" + }, + "fadeOutXCommands": { + "label": "Odečíst X příkazů", + "placeholder": "Počet příkazů, odečtených během fade out intervalu" + }, + "fadeOutXKeywords": { + "label": "Odečíst X klíčových slov", + "placeholder": "Počet klíčových slov, odečtených během fade out intervalu" + }, + "fadeOutInterval": { + "label": "Interval odečítání (sekundy)", + "placeholder": "Fade out interval odečítání" + }, + "runEveryXCommands": { + "label": "Spustit každých X příkazů", + "placeholder": "Počet příkazů před spuštěním události" + }, + "runEveryXKeywords": { + "label": "Spustit každých X klíčových slov", + "placeholder": "Počet klíčových slov před spuštěním události" + }, + "commandToWatch": { + "label": "Sledovat tento příkaz", + "placeholder": "Nastav !prikazKeSledovani" + }, + "keywordToWatch": { + "label": "Sledovat toto klíčové slovo", + "placeholder": "Nastav si klíčovéSlovo" + }, + "resetCountEachMessage": { + "label": "Resetovat počet každou zprávu", + "true": "Resetovat počet", + "false": "Ponechat počet" + }, + "viewersAtLeast": { + "label": "Nejméně diváků", + "placeholder": "Kolik diváků spustí událost" + }, + "runInterval": { + "label": "Interval spuštění (0 = spustit jednou za stream)", + "placeholder": "Spustit událost každých x sekund" + }, + "runAfterXMinutes": { + "label": "Spustit po x minutách", + "placeholder": "Spustit událost po x minutách" + }, + "runEveryXMinutes": { + "label": "Spustit každých x minut", + "placeholder": "Spustit událost každých x minut" + }, + "messageToSend": { + "label": "Poslat zprávu", + "placeholder": "Nastav svou zprávu" + }, + "channel": { + "label": "Kanál", + "placeholder": "Název kanálu nebo ID" + }, + "timeout": { + "label": "Časový limit", + "placeholder": "Nastavit časový limit v milisekundách" + }, + "timeoutType": { + "label": "Typ časového limitu", + "placeholder": "Nastavte typ časového limitu" + }, + "command": { + "label": "Příkaz", + "placeholder": "Nastav si !prikaz" + }, + "commandToRun": { + "label": "Příkaz ke spuštění", + "placeholder": "Nastav si !prikazKeSpusteni" + }, + "isCommandQuiet": { + "label": "Neposílat odpověď příkazu do chatu" + }, + "urlOfSoundFile": { + "label": "Adresa URL vašeho zvukového souboru", + "placeholder": "http://www.cesta.url/kde/je/soubor.mp3" + }, + "emotesToExplode": { + "label": "Emotes k výbuchu", + "placeholder": "Seznam emotes k výbuchu, např. Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Emotes k ohňostroji", + "placeholder": "Seznam emotes k ohňostroji, např. Kappa PurpleHeart" + }, + "replay": { + "label": "Přehrát klip v overlayi", + "true": "Přehraje záznam v overlay/alerts", + "false": "Záznam se nepřehraje" + }, + "announce": { + "label": "Oznámit v chatu", + "true": "Bude oznámeno", + "false": "Nebude oznámeno" + }, + "hasDelay": { + "label": "Klip bude mít delay (aby se přiblížil divákovi)", + "true": "Bude mít delay", + "false": "Nebude mít delay" + }, + "durationOfCommercial": { + "label": "Doba trvání reklamy", + "placeholder": "Dostupné trvání - 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Vlastní proměnná, kterou chcete aktualizovat" + }, + "numberToIncrement": { + "label": "Kolik přičíst", + "placeholder": "" + }, + "value": { + "label": "Hodnota", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Kolik odečíst", + "placeholder": "" + }, + "": "", + "reward": { + "label": "Odměna", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Tě sleduje", + "raid": "Nájezd s $viewers diváky.", + "sub": "Nový odběratel s $subType. Byli přihlášeni k odběru $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "bylo darováno předplatné od $username", + "subcommunitygift": "Darované předplatné komunitě", + "resub": "Obnoven odběr s $subType. Byli přihlášeni k odběru $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Bity", + "tip": "Spropitné", + "tipToCharity": "přispěl pro $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Twitch Predikce - Název", + "outcomes": "Twitch Predikce - Výsledky", + "locksAt": "Twitch predikce - Uzamknutí k datu", + "winningOutcomeTitle": "Twitch Predikce - Název vítězného výsledku", + "winningOutcomeTotalPoints": "Twitch Predikce - Celkový počet bodů vítězného výsledku", + "winningOutcomePercentage": "Twitch Predikce - Procentuální podíl vítězného výsledku", + "titleOfPoll": "Twitch anketa - název", + "bitAmountPerVote": "Twitch anketa - množství bitů, které se mají počítat jako 1 hlas", + "bitVotingEnabled": "Twitch anketa - je hlasování pomocí bitů povoleno (boolean)", + "channelPointsAmountPerVote": "Twitch anketa - Počet kanálových bodů, které se mají počítat jako 1 hlas", + "channelPointsVotingEnabled": "Twitch anketa - je hlasování pomocí kanálových bodů povoleno (boolean)", + "votes": "Twitch anketa - počet hlasů", + "winnerChoice": "Twitch anketa - vítězná volba", + "winnerPercentage": "Twitch anketa - procento vítězné volby", + "winnerVotes": "Twitch anketa - počet hlasů vítězné volby", + "goal": "Cíl", + "total": "Celkem", + "lastContributionTotal": "Poslední příspěvek - Celkem", + "lastContributionType": "Poslední příspěvek - Typ", + "lastContributionUserId": "Poslední příspěvek - ID uživatele", + "lastContributionUsername": "Poslední příspěvek - Uživatelské jméno", + "level": "Úroveň", + "topContributionsBitsTotal": "Nejlepší příspěvek v Bitech - Celkem", + "topContributionsBitsUserId": "Nejlepší příspěvek v Bitech - ID uživatele", + "topContributionsBitsUsername": "Nejlepší příspěvek v Bitech - Uživatelské jméno", + "topContributionsSubsTotal": "Nejlepší příspěvek v Subech - Celkem", + "topContributionsSubsUserId": "Nejlepší příspěvek v Subech - ID uživatele", + "topContributionsSubsUsername": "Nejlepší příspěvek v Subech - Uživatelské jméno", + "sender": "Uživatel, který inicioval", + "title": "Aktuální název", + "game": "Současná kategorie", + "language": "Aktuální jazyk vysílání", + "viewers": "Aktuální počet diváků", + "hostViewers": "Počet diváků z nájezdu", + "followers": "Aktuální počet followerů", + "subscribers": "Aktuální počet subscriberů", + "arg": "Parametr", + "param": "Parametr (povinný)", + "touser": "Parametr uživatelské jméno", + "!param": "Parametr (nepovinný)", + "alias": "Alias", + "command": "Příkazy", + "keyword": "Klíčové slovo", + "response": "Odpověď", + "list": "Zaplněný seznam", + "type": "Typ", + "days": "Dny", + "hours": "Hodiny", + "minutes": "Minuty", + "seconds": "Sekundy", + "description": "Popisek", + "quiet": "Potichu (bool)", + "id": "ID", + "name": "Název", + "messages": "Zprávy", + "amount": "Počet", + "amountInBotCurrency": "Počet v měně bota", + "currency": "Měna", + "currencyInBot": "Měna bota", + "pointsName": "Název bodů", + "points": "Body", + "rank": "Hodnost", + "nextrank": "Další rank", + "username": "Uživatelské jméno", + "value": "Hodnota", + "variable": "Proměnná", + "count": "Počet", + "link": "Link (přeloženo)", + "winner": "Vítěz", + "loser": "Poražený", + "challenger": "Vyzyvatel", + "min": "Minimum", + "max": "Maximum", + "eligibility": "Způsobilost", + "probability": "Pravděpodobnost", + "time": "Čas", + "options": "Volby", + "option": "Volba", + "when": "Kdy", + "diff": "Rozdíl", + "users": "Uživatelé", + "user": "Uživatel", + "bank": "Banka", + "nextBank": "Další banka", + "cooldown": "Cooldown", + "tickets": "Tikety", + "ticketsName": "Název tiketů", + "fromUsername": "Od uživatele", + "toUsername": "K uživateli", + "items": "Položky", + "bits": "Bity", + "subgifts": "Darované suby", + "subStreakShareEnabled": "Je substreak share zapnut (true/false)", + "subStreak": "Subscribů v řadě", + "subStreakName": "Název měsíce (1 měsíc, 2 měsíce) pro subscribů v řadě", + "subCumulativeMonths": "Celkový počet subscribů", + "subCumulativeMonthsName": "Název měsíce (1 měsíc, 2 měsíce) pro celkový počet subscribů", + "message": "Zpráva", + "reason": "Důvod", + "target": "Cíl", + "duration": "Doba trvání", + "method": "Metoda", + "tier": "Tier", + "months": "Počet měsíců", + "monthsName": "Název měsíce (1 měsíc, 2 měsíce)", + "oldGame": "Kategorie před změnou", + "recipientObject": "Příjemce (celý objekt)", + "recipient": "Příjemce", + "ytSong": "Aktuální skladba na YouTube", + "spotifySong": "Aktuální skladba na Spotify", + "latestFollower": "Poslední Follower", + "latestSubscriber": "Poslední Subscriber", + "latestSubscriberMonths": "Poslední Subscriber - souhrnné měsíce", + "latestSubscriberStreak": "Poslední Subscriber - série měsíců předplatného", + "latestTipAmount": "Poslední Tip (počet)", + "latestTipCurrency": "Poslední Tip (měna)", + "latestTipMessage": "Poslední Tip (zpráva)", + "latestTip": "Poslední Tip (uživatel)", + "toptip": { + "overall": { + "username": "Top Tip - celkově (uživatel)", + "amount": "Top Tip - celkově (počet)", + "currency": "Top Tip - celkově (měna)", + "message": "Top Tip - celkově (zpráva)" + }, + "stream": { + "username": "Top Tip - během vysílání (uživatel)", + "amount": "Top Tip - během vysílání (počet)", + "currency": "Top Tip - během vysílání (měna)", + "message": "Top Tip - během vysílání (zpráva)" + } + }, + "latestCheerAmount": "Poslední Bity (počet)", + "latestCheerMessage": "Poslední Bity (zpráva)", + "latestCheer": "Poslední Bity (uživatel)", + "version": "Verze bota", + "haveParam": "Příkaz má parametr? (bool)", + "source": "Aktuální zdroj (twitch nebo discord)", + "userInput": "Vstup uživatele během odměny", + "isBotSubscriber": "Je bot předplatitelem (bool)", + "isStreamOnline": "Je stream online (bool)", + "uptime": "Délka vysílaní streamu", + "is": { + "moderator": "Je uživatel mod? (bool)", + "subscriber": "Je uživatel sub? (bool)", + "vip": "Je uživatel vip? (bool)", + "newchatter": "Je to první zpráva uživatele? (bool)", + "follower": "Je uživatel follower? (bool)", + "broadcaster": "Je uživatel broadcaster? (bool)", + "bot": "Je uživatel bot? (bool)", + "owner": "Je uživatel vlastník bota? (bool)" + }, + "recipientis": { + "moderator": "Je příjemce mod? (bool)", + "subscriber": "Je příjemce sub? (bool)", + "vip": "Je příjemce vip? (bool)", + "follower": "Je příjemce follower? (bool)", + "broadcaster": "Je příjemce broadcaster? (bool)", + "bot": "Je příjemce bot? (bool)", + "owner": "Je příjemce vlastník bota? (bool)" + }, + "sceneName": "Název scény", + "inputName": "Název vstupu", + "inputMuted": "Stav ztlumení (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Ostatní", + "currency": "Měna" + }, + "whispers": { + "title": "Šeptání", + "toggle": { + "listener": "Poslouchat příkazy šeptem", + "settings": "Šeptat změny nastavení", + "raffle": "Šeptat při zapojení do tomboly", + "permissions": "Šeptat, když má uživatel nedostatečná oprávnění", + "cooldowns": "Šeptat při cooldownu (pokud nastaven jako oznámit)" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Zprávy", + "follows": "Followy", + "subs": "Odběratelé", + "cheers": "Bity", + "responses": "Odpovědi bota", + "whispers": "Šeptání", + "bans": "Bany", + "timeouts": "Timeouty" + }, + "range": { + "day": "den", + "week": "týden", + "month": "měsíc", + "year": "rok", + "all": "bez omezení" + }, + "order": { + "asc": "Vzestupně", + "desc": "Sestupně" + }, + "labels": { + "order": "SEŘADIT", + "range": "ROZSAH", + "filters": "FILTRY" + } + }, + "stats-panel": { + "show": "Ukázat statistiky", + "hide": "Skrýt statistiky" + }, + "translations": "Vlastní překlad", + "bot-responses": "Odpovědi bota", + "duration": "Trvání", + "viewers-reset-attributes": "Vymazat data", + "viewers-points-of-all-users": "Body všech uživatelů", + "viewers-watchtime-of-all-users": "Čas sledování všech uživatelů", + "viewers-messages-of-all-users": "Zprávy všech uživatelů", + "events-game-after-change": "kategorie po změně", + "events-game-before-change": "kategorie před změnou", + "events-user-triggered-event": "uživatel, který spustil event", + "events-method-used-to-subscribe": "metoda použita pro subscribe", + "events-months-of-subscription": "počet měsíců", + "events-monthsName-of-subscription": "skloňované slovo 'měsíc' (1 měsíc, 2 měsíce)", + "events-user-message": "zpráva uživatele", + "events-bits-user-sent": "počet poslaných bitů", + "events-reason-for-ban-timeout": "důvod pro ban/timeout", + "events-duration-of-timeout": "doba trvání timeoutu", + "events-duration-of-commercial": "doba trvání reklamy", + "overlays-eventlist-resub": "resub", + "overlays-eventlist-subgift": "subgift", + "overlays-eventlist-subcommunitygift": "subcommunitygift", + "overlays-eventlist-sub": "sub", + "overlays-eventlist-follow": "follow", + "overlays-eventlist-cheer": "bity", + "overlays-eventlist-tip": "tip", + "overlays-eventlist-raid": "raid", + "requested-by": "Požadoval", + "description": "Popisek", + "raffle-type": "Typ tomboly", + "raffle-type-keywords": "Pouze klíčové slovo", + "raffle-type-tickets": "S tickety", + "raffle-tickets-range": "Rozsah ticketu", + "video_id": "ID Videa", + "highlights": "Highlighty", + "cooldown-quiet-header": "Zobrazí cooldown zprávu", + "cooldown-quiet-toggle-no": "Upozornit", + "cooldown-quiet-toggle-yes": "Neupozornit", + "cooldown-moderators": "Moderátoři", + "cooldown-owners": "Vlastník bota", + "cooldown-subscribers": "Subscribeři", + "cooldown-followers": "Followeři", + "in-seconds": "v sekundách", + "songs": "Písničky", + "show-usernames-with-at": "Zobrazit uživatelské jména s @", + "send-message-as-a-bot": "Poslat zprávu jako bot", + "chat-as-bot": "Chat (jako bot)", + "product": "Produkt", + "optional": "nepovinný", + "placeholder-search": "Hledat", + "placeholder-enter-product": "Vložte produkt", + "placeholder-enter-keyword": "Vložte klíčové slovo", + "credits": "Titulky", + "fade-out-top": "slábnout směrem nahoru", + "fade-out-zoom": "slábnout přiblížením", + "global": "Globální", + "user": "Divák", + "alerts": "Alerty", + "eventlist": "Kanál aktivit", + "dashboard": "Nástěnka", + "carousel": "Obrázky", + "text": "Text", + "filter": "Filtr", + "filters": "Filtry", + "isUsed": "Je používán", + "permissions": "Oprávnění", + "permission": "Oprávnění", + "viewers": "Diváci", + "systems": "Moduly", + "overlays": "Overlaye", + "gallery": "Galerie médií", + "aliases": "Aliasy", + "alias": "Alias", + "command": "Příkaz", + "cooldowns": "Cooldowny", + "title-template": "Šablona nadpisu", + "keyword": "Klíčové slovo", + "moderation": "Pravidla chatu", + "timer": "Časovač", + "price": "Cena příkazů", + "rank": "Hodnost", + "previous": "Předchozí", + "next": "Další", + "close": "Zavřít", + "save-changes": "Uložit změny", + "saving": "Ukládám...", + "deleting": "Mazání...", + "done": "Hotovo", + "error": "Chyba", + "title": "Název", + "change-title": "Změnit název", + "game": "kategorie", + "tags": "Tagy", + "change-game": "Změnit kategorii", + "click-to-change": "kliknutím změňte", + "uptime": "uptime", + "not-affiliate-or-partner": "Nejsi affiliate/partner", + "not-available": "Není k dispozici", + "max-viewers": "Nejvíce diváků", + "new-chatters": "Nových diváků", + "chat-messages": "Zpráv v chatu", + "followers": "Followeři", + "subscribers": "Subscribeři", + "bits": "Bity", + "subgifts": "Darované suby", + "subStreak": "Subscribů v řadě", + "subCumulativeMonths": "Celkový počet subscribů", + "tips": "Spropitné", + "tier": "Tier", + "status": "Status", + "add-widget": "Přidat widget", + "remove-dashboard": "Odstranit nástěnku", + "close-bet-after": "Uzavřít sázku za", + "refund": "refundovat", + "roll-again": "Losuj znovu", + "no-eligible-participants": "Žádní další oprávnění účastníci", + "follower": "Sledující", + "subscriber": "Odběratel", + "minutes": "minuty", + "seconds": "vteřiny", + "hours": "hodiny", + "months": "měsíce", + "eligible-to-enter": "Oprávněni se zúčastnit", + "everyone": "Všichni", + "roll-a-winner": "Losovat vítěze", + "send-message": "Poslat zprávu", + "messages": "Zprávy", + "level": "Úroveň", + "create": "Vytvořit", + "cooldown": "Cooldown", + "confirm": "Potvrdit", + "delete": "Smazat", + "enabled": "Zapnuto", + "disabled": "Vypnuto", + "enable": "Zapnout", + "disable": "Vypnout", + "slug": "Slug", + "posted-by": "Vložil", + "time": "Čas", + "type": "Typ", + "response": "Odpověď", + "cost": "Cena", + "name": "Název", + "playlist": "Seznam skladeb", + "length": "Doba trvání", + "volume": "Hlasitost", + "start-time": "Čas zahájení", + "end-time": "Čas ukončení", + "watched-time": "Čas sledování", + "currentsong": "Právě hraje", + "group": "Skupina", + "followed-since": "Follower od", + "subscribed-since": "Subscriber od", + "username": "Uživatelské jméno", + "hashtag": "Hashtag", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "Scopes", + "last-seen": "Naposledy viděn", + "date": "Data", + "points": "Body", + "calendar": "Kalendář", + "string": "string", + "interval": "Interval", + "number": "číslo", + "minimal-messages-required": "Minimální počet zpráv", + "max-duration": "Maximální délka", + "shuffle": "Hrát náhodně", + "song-request": "Požadavek na skladbu", + "format": "Formát", + "available": "Dostupné", + "one-record-per-line": "jeden záznam na řádek", + "on": "zapnuto", + "off": "vypnuto", + "search-by-username": "Hledat podle uživatelského jména", + "widget-title-custom": "VLASTNÍ", + "widget-title-eventlist": "KANÁL AKTIVIT", + "widget-title-chat": "CHAT", + "widget-title-queue": "FRONTA", + "widget-title-raffles": "TOMBOLA", + "widget-title-social": "SOCIAL", + "widget-title-ytplayer": "PŘEHRÁVAČ HUDBY", + "widget-title-monitor": "MONITOR", + "event": "událost", + "operation": "operace", + "tweet-post-with-hashtag": "Tweet zveřejněn s hashtagem", + "user-joined-channel": "uživatel se připojil do chatu", + "user-parted-channel": "uživatel se odešel z chatu", + "follow": "nový follower", + "tip": "nový tip", + "obs-scene-changed": "OBS scéna se změnila", + "obs-input-mute-state-changed": "Stav ztlumení zdroje v OBS bylo změněno", + "unfollow": "ztráta followera", + "hypetrain-started": "Hype Train spuštěn", + "hypetrain-ended": "Konec Hype Train", + "prediction-started": "Twitch predikce byla spuštěna", + "prediction-locked": "Twitch predikce byla uzamčena", + "prediction-ended": "Twitch predikce byla ukončena", + "poll-started": "Twitch anketa spuštěna", + "poll-ended": "Twitch anketa ukončena", + "hypetrain-level-reached": "Dosažení nové úrovně Hype Train", + "subscription": "nový subscription", + "subgift": "nový subgift", + "subcommunitygift": "nový sub darovaný komunitě", + "resub": "uživatel resubscribed", + "command-send-x-times": "příkaz poslán x-krát", + "keyword-send-x-times": "klíčové slovo posláno x-krát", + "number-of-viewers-is-at-least-x": "počet diváků je alespoň x", + "stream-started": "vysílání zahájeno", + "reward-redeemed": "odměna vyplacena", + "stream-stopped": "vysílání zastaveno", + "stream-is-running-x-minutes": "vysílání běží x minut", + "chatter-first-message": "první zpráva chattera", + "every-x-minutes-of-stream": "každých x minut vysílání", + "game-changed": "kategorie změněna", + "cheer": "přijaté bity", + "clearchat": "chat byl vymazán", + "action": "uživatel poslal /me", + "ban": "uživatel byl zabanován", + "raid": "tvůj channel dostal raid", + "mod": "uživatel se stal novým modem", + "timeout": "uživatel dostal timeout", + "create-a-new-event-listener": "Vytvoř nový event listener", + "send-discord-message": "odeslat Discord zprávu", + "send-chat-message": "odeslat zprávu na twitch chat", + "send-whisper": "poslat whisper", + "run-command": "spusť příkaz", + "run-obswebsocket-command": "spustit příkaz OBS Websocketu", + "do-nothing": "--- nedělat nic ---", + "count": "počet", + "timestamp": "časové razítko", + "message": "zpráva", + "sound": "zvuk", + "emote-explosion": "exploze smajlíků", + "emote-firework": "ohňostroj emotikonů", + "quiet": "tichý", + "noisy": "hlasitý", + "true": "ano", + "false": "ne", + "light": "světlý theme", + "dark": "tmavý theme", + "gambling": "Sázení", + "seppukuTimeout": "Timeout pro !seppuku", + "rouletteTimeout": "Timeout pro !roulette", + "fightmeTimeout": "Timeout pro !fightme", + "duelCooldown": "Cooldown pro !duel", + "fightmeCooldown": "Cooldown pro !fightme", + "gamblingCooldownBypass": "Ignorovat herní cooldowny pro mody/castera", + "click-to-highlight": "highlight", + "click-to-toggle-display": "přepnout zobrazení", + "commercial": "reklama zahájena", + "start-commercial": "spustit reklamu", + "bot-will-join-channel": "bot se připojí k kanálu", + "bot-will-leave-channel": "bot opustí kanál", + "create-a-clip": "vytvořit klip", + "increment-custom-variable": "přičíst do vlastní proměnné", + "set-custom-variable": "nastavit vlastní proměnnou", + "decrement-custom-variable": "odečíst z vlastní proměnné", + "omit": "vynechat", + "comply": "dodržovat", + "visible": "viditelné", + "hidden": "skryté", + "gamblingChanceToWin": "Šance na výhru !gamble", + "gamblingMinimalBet": "Minimální sázka pro !gamble", + "duelDuration": "Trvání !duel", + "duelMinimalBet": "Minimální sázka pro !duel" + }, + "raffles": { + "announceInterval": "Otevřené tomboly budou oznámeny každých $value minut", + "eligibility-followers-item": "followery", + "eligibility-subscribers-item": "subscribery", + "eligibility-everyone-item": "všechny", + "raffle-is-running": "Tombola právě probíhá ($count $l10n_entries).", + "to-enter-raffle": "Pro účast napište \"$keyword\". Tombola je otevřena pro $eligibility.", + "to-enter-ticket-raffle": "Pro účast napište \"$keyword <$min-$max>\". Tombola je otevřena pro $eligibility.", + "added-entries": "Do tomboly bylo přidáno $count $l10n_entries (celkově $countTotal). {raffles.to-enter-raffle}", + "added-ticket-entries": "Do tomboly bylo přidáno $count $l10n_entries (celkově $countTotal). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "Příkazy pro vstup do tomboly budou odstraněny.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "Omlouváme se, $sender, ale bez klíčového slova nelze vytvořit tombolu", + "raffle-is-already-running": "Omlouváme se, $sender, tombola již běží s klíčovým slovem $keyword", + "no-raffle-is-currently-running": "$sender, žádná tombola bez vítězů není v současné době spuštěna", + "no-participants-to-pick-winner": "$sender, nikdo se nepřipojil do tomboly", + "raffle-winner-is": "Vítězem raffle $keyword se stává $username! Pravděpodobnost výhry byla $probability%!" + }, + "bets": { + "running": "$sender, vsazení je již otevřeno! Možnosti sázky: $options. Použijte $command close 1-$maxIndex", + "notRunning": "V současné době není otevřena žádná sázka. Požádejte mody o otevření!", + "opened": "Nová sázka '$title' je otevřena! Možnosti sázky: $options. Použijte $command 1-$maxIndex k výhře! Máte pouze $minutesmin k sázce!", + "closeNotEnoughOptions": "$sender, pro ukončení sázky musíte vybrat vítěznou možnost.", + "notEnoughOptions": "$sender, nové sázky potřebují alespoň 2 možnosti!", + "info": "Sázka '$title' je stále otevřená! Možnosti sázky jsou: $options. Použijte $command 1-$maxIndex k výhře! Máte pouze $minutesmin k sázce!", + "diffBet": "$sender, již jste vsadili na $option a nemůžete sázet na jinou možnost!", + "undefinedBet": "Omlouváme se, $sender, ale tato volba neexistuje, použijte $command pro kontrolu použití", + "betPercentGain": "Procentuální zisk na jednu možnost byl nastaven na $value%", + "betCloseTimer": "Sázky budou automaticky uzavřeny po $valuemin", + "refund": "Sázky byly uzavřeny bez výhry. Všichni uživatelé jsou vyplaceni!", + "notOption": "$sender, tato možnost neexistuje! Sázka není uzavřena, zkontrolujte $command", + "closed": "Sázka byl uzavřena a vítězná možnost byla $option! $amount uživatelů celkem vyhrálo $points $pointsName!", + "timeUpBet": "Přišel si pozdě, $sender, čas na sázky vypršel!", + "locked": "Čas na sázku vypršel! Už žádné sázky.", + "zeroBet": "Ale ale, $sender, nelze vsadit 0 $pointsName", + "lockedInfo": "Sázka '$title' je stále otevřená, ale čas na sázeni již vypršel!", + "removed": "Vypršel čas na sázku! Nikdo si nevsadil -> automaticky uzavírám", + "error": "Omlouváme se, $sender, tento příkaz není správný! Použijte $command 1-$maxIndex . Např. $command 0 100 vsadí 100 bodů na položku 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, alias $alias nebyl nalezen v databázi", + "alias-was-edited": "$sender, alias $alias byl změněn na $command", + "alias-was-added": "$sender, alias $alias pro $command byl přidán", + "list-is-not-empty": "$sender, seznam aliasů: $list", + "list-is-empty": "$sender, seznam aliasů je prázdný", + "alias-was-enabled": "$sender, alias $alias byl zapnut", + "alias-was-disabled": "$sender, alias $alias byl vypnut", + "alias-was-concealed": "$sender, alias $alias byl skryt", + "alias-was-exposed": "$sender, alias $alias byl odkryt", + "alias-was-removed": "$sender, alias $alias byl odebrán", + "alias-group-set": "$sender, alias $alias byl nastaven na skupinu $group", + "alias-group-unset": "$sender, skupina aliasu $alias byla zrušena", + "alias-group-list": "$sender, seznam skupin aliasů: $list", + "alias-group-list-aliases": "$sender, seznam aliasů v $group: $list", + "alias-group-list-enabled": "$sender, aliasy v $group jsou zapnuty.", + "alias-group-list-disabled": "$sender, aliasy v $group jsou vypnuty." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, příkaz $command nebyl nalezen v databázi", + "response-was-not-found": "$sender, odpověď #$response příkazu $command nebyla v databázi nalezena", + "command-was-edited": "$sender, příkaz $command je změněn na '$response'", + "command-was-added": "$sender, příkaz $command byl přidán", + "list-is-not-empty": "$sender, seznam příkazů: $list", + "list-is-empty": "$sender, seznam příkazů je prázdný", + "command-was-enabled": "$sender, příkaz $command byl aktivován", + "command-was-disabled": "$sender, příkaz $command byl deaktivován", + "command-was-concealed": "$sender, příkaz $command byl skryt", + "command-was-exposed": "$sender, příkaz $command byl odhalen", + "command-was-removed": "$sender, příkaz $command byl odebrán", + "response-was-removed": "$sender, odpověď #$response z $command byla odebrána", + "list-of-responses-is-empty": "$sender, $command nemá žádné odpovědi nebo neexistuje", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, klíčové slovo $keyword je nejednoznačné, použijte ID klíčového slova", + "keyword-was-not-found": "$sender, klíčové slovo $keyword nebylo nalezeno v databázi", + "response-was-not-found": "$sender, odpověď #$response klíčového slova $keyword nebyla nalezena", + "keyword-was-edited": "$sender, klíčové slovo $keyword je změněno na '$response'", + "keyword-was-added": "$sender, klíčové slovo $keyword ($id) bylo přidáno", + "list-is-not-empty": "$sender, seznam klíčových slov: $list", + "list-is-empty": "$sender, seznam klíčových slov je prázdný", + "keyword-was-enabled": "$sender, keyword $keyword byl zapnut", + "keyword-was-disabled": "$sender, keyword $keyword byl vypnut", + "keyword-was-removed": "$sender, klíčové slovo $keyword bylo smazáno", + "list-of-responses-is-empty": "$sender, $keyword nemá žádné odpovědi nebo neexistuje", + "response": "$keyword#$index ($permission) $after| $response" + }, + "points": { + "success": { + "undo": "$sender, příkaz '$command' pro body $username byly vráceny ($updatedValue $updatedValuePointsLocale to $originalValue $originalValuePointsLocale).", + "set": "$username má nyní nastaveno $amount $pointsName", + "give": "$sender právě dal svých $amount $pointsName uživateli $username", + "online": { + "positive": "Všichni online uživatelé právě obdrželi $amount $pointsName!", + "negative": "Všichni online uživatelé právě ztratili $amount $pointsName!" + }, + "all": { + "positive": "Všichni uživatelé právě obdrželi $amount $pointsName!", + "negative": "Všichni uživatelé právě ztratili $amount $pointsName!" + }, + "rain": "Jen ať prší! Všichni online uživatelé právě získali až $amount $pointsName!", + "add": "$username právě obdržel $amount $pointsName!", + "remove": "Jejda, $amount $pointsName bylo odebráno z $username!" + }, + "failed": { + "undo": "$sender, uživatel nenalezen v databázi nebo uživatel nemá body k vrácení.", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Omlouváme se, $sender, nemáte $amount $pointsName k darování pro $username", + "cannotGiveZeroPoints": "Promiň, $sender, nemůžeš dát $amount $pointsName uživateli $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username má momentálně $amount $pointsName. Vaše pozice je $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, seznam skladeb k importu je prázdný", + "playlist-imported": "$sender, importováno $imported a přeskočeno $skipped do seznamu skladeb", + "not-playing": "Nic nehraje", + "song-was-banned": "Pisen $name byla zabanovana a nebude uz nikdy hrat!", + "song-was-banned-timeout-message": "Dostal si timeout za zabanovanou skladbu", + "song-was-unbanned": "Skladba byla úspěšně odbanována", + "song-was-not-banned": "Tato skladba nebyla zabanovaná", + "no-song-is-currently-playing": "Žádné skladby momentálně nehrají", + "current-song-from-playlist": "Aktuální skladba je $name ze seznamu skladeb", + "current-song-from-songrequest": "Aktuální skladba je $name požadovaná uživatelem $username", + "songrequest-disabled": "Omlouváme se, $sender, požadavky na skladbu jsou vypnuty.", + "song-is-banned": "Omlouváme se, $sender, ale tato skladba je zakázána", + "youtube-is-not-responding-correctly": "Omlouváme se, $sender, ale YouTube odesílá neočekávané odpovědi, zkuste to prosím později.", + "song-was-not-found": "Omlouváme se, $sender, ale tato skladba nebyla nalezena", + "song-is-too-long": "Omlouváme se, $sender, ale tato skladba je příliš dlouhá", + "this-song-is-not-in-playlist": "Omlouváme se, $sender, ale tato skladba není v aktuálním seznamu skladeb", + "incorrect-category": "Omlouváme se, $sender, ale tato skladba musí být kategorie hudby", + "song-was-added-to-queue": "$sender, skladba $name byla přidána do fronty", + "song-was-added-to-playlist": "$sender, skladba $name byla přidána do seznamu skladeb", + "song-is-already-in-playlist": "$sender, skladba $name je již v seznamu skladeb", + "song-was-removed-from-playlist": "$sender, skladba $name byla odebrána ze seznamu skladeb", + "song-was-removed-from-queue": "$sender, vaše skladba $name byla odebrána z fronty", + "playlist-current": "$sender, aktuální seznam skladeb je $playlist.", + "playlist-list": "$sender, dostupné seznamy skladeb: $list.", + "playlist-not-exist": "$sender, Váš požadovaný seznam skladeb $playlist neexistuje.", + "playlist-set": "$sender, změnili jste seznam skladeb na $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, cena za $command byla nastavena na $amount $pointsName", + "price-was-unset": "$sender, cena za $command byla zrušena", + "price-was-not-found": "$sender, cena za $command nebyla nalezena", + "price-was-enabled": "$sender, cena za $command byla zapnuta", + "price-was-disabled": "$sender, cena za $command byla vypnuta", + "user-have-not-enough-points": "Omlouváme se, $sender, ale nemáš $amount $pointsName k použití $command", + "user-have-not-enough-points-or-bits": "Omlouváme se, $sender, ale nemáš $amount $pointsName nebo $bitsAmount bitů k použití $command", + "user-have-not-enough-bits": "Omlouváme se, $sender, ale k použití $command musíte použít $bitsAmount bitů", + "list-is-empty": "$sender, seznam cen je prázdný", + "list-is-not-empty": "$sender, seznam cen: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, nová hodnost $type $rank($hours$hlocale) byla přidána", + "rank-was-edited": "$sender, hodnost pro $type $hours$hlocale byla změněna na $rank", + "rank-was-removed": "$sender, hodnost pro $type $hours$hlocale byla odstraněna", + "rank-already-exist": "$sender, již existuje hodnost pro $type $hours$hlocale", + "rank-was-not-found": "$sender, rank pro $type $hours$hlocale nebyl nalezen", + "custom-rank-was-set-to-user": "$sender, nastavil jsi hodnost $rank uživateli $username", + "custom-rank-was-unset-for-user": "$sender, vlastní hodnost pro $username byla zrušena", + "list-is-empty": "$sender, nebyly nalezeny žádné hodnosti", + "list-is-not-empty": "$sender, seznam hodností: $list", + "show-rank-without-next-rank": "$sender, tvá současná hodnost je $rank", + "show-rank-with-next-rank": "$sender, tvá současná hodnost je $rank. Další hodnost - $nextrank", + "user-dont-have-rank": "$sender, zatím nemáte hodnost" + }, + "followage": { + "success": { + "never": "$sender, $username není followerem kanálu", + "time": "$sender, $username sleduje tento kanál již $diff" + }, + "successSameUsername": { + "never": "$sender, nejste sledující tohoto kanálu", + "time": "$sender, sledujete tento kanál již $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username není subscriberem kanálu.", + "notNow": "$sender, $username není momentálně subscriberem kanálu. Celkově $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username je subscriberem tohoto kanálu. Současný substreak od $diff ($subStreak $subStreakMonthsName) a celkově $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username je subscriberem tohoto kanálu. Celkově $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, nejsi subscriberem kanálu.", + "notNow": "$sender, nejsi subscriberem kanálu. Celkově $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, je subscriberem tohoto kanálu. Současný substreak od $diff ($subStreak $subStreakMonthsName) a celkově $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, je subscriberem tohoto kanálu. Celkově $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, nemám zatím data o staří účtu $username", + "success": { + "withUsername": "$sender, staří účtu $username je $diff", + "withoutUsername": "$sender, staří tvého účtu je $diff" + } + }, + "lastseen": { + "success": { + "never": "$username nikdy nebyl na tomto kanálu!", + "time": "$username byl naposledy spatřen v $when v tomto kanálu" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username sledoval tento kanál po dobu $time hodin" + }, + "failed": { + "parse": "{core.command-parse} !watched nebo !watched [username]" + } + }, + "permissions": { + "without-permission": "Nemáte dostatečná oprávnění pro '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, uživatel $username má $type imunitu po dobu $time vteřin", + "user-have-immunity-parameterError": "$sender, chyba parametru. $command ", + "user-have-link-permit": "Uživatel $username může odeslat $count $link do chatu", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "Nejsou povoleny žádné odkazy, požádejte o povolení [$count varování zbývá]", + "user-is-warned-about-symbols": "Přílišné používání symbolů není povoleno [$count varování zbývá]", + "user-is-warned-about-long-message": "Dlouhé zprávy nejsou povoleny [$count varování zbývá]", + "user-is-warned-about-caps": "Přílišné používání velkých písmen není povoleno [$count varování zbývá]", + "user-is-warned-about-spam": "Spamování není povoleno [$count varování zbývá]", + "user-is-warned-about-color": "Používání /me není povoleno [$count varování zbývá]", + "user-is-warned-about-emotes": "Žádné spamování smajlíkama! [$count varování zbývá]", + "user-is-warned-about-forbidden-words": "Žádná zakázaná slova [$count varování zbývá]", + "user-have-timeout-for-links": "Nejsou povoleny žádné odkazy, požádejte o povolení", + "user-have-timeout-for-symbols": "Přílišné používání symbolů není povoleno", + "user-have-timeout-for-long-message": "Dlouhé zprávy nejsou povoleny", + "user-have-timeout-for-caps": "Přílišné používání velkých písmen není povoleno", + "user-have-timeout-for-spam": "Spamování není povoleno", + "user-have-timeout-for-color": "Používání /me není povoleno", + "user-have-timeout-for-emotes": "Žádné spamování smajlíkama", + "user-have-timeout-for-forbidden-words": "Žádná zakázaná slova" + }, + "queue": { + "list": "$sender, ve frontě jsou přihlášení: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Omlouváme se $sender, fronta je momentálně uzavřena", + "opened": "$sender byl přidán do fronty" + }, + "open": "Queue je právě OTEVŘENA! Pro přidání do fronty zadej !queue join", + "close": "Fronta je momentálně uzavřena!", + "clear": "Fronta byla promazána", + "picked": { + "single": "Tento uživatel byl vybrán z fronty: $users", + "multi": "Tito uživatelé byli vybráni z fronty: $users", + "none": "Nebyli nalezeni žádní uživatelé ve frontě" + } + }, + "marker": "Značka streamu byla vytvořena v $time.", + "title": { + "current": "$sender, název vysílání je '$title'.", + "change": { + "success": "$sender, název byl nastaven na: $title" + } + }, + "game": { + "current": "$sender, streamer právě hraje $game.", + "change": { + "success": "$sender, kategorie byla nastavena na: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type cooldown pro $command byl nastaven na $secondss", + "cooldown-was-unset": "$sender, cooldown pro $command byl zrušen", + "cooldown-triggered": "$sender, '$command' má cooldown, zbývá $secondss", + "cooldown-not-found": "$sender, cooldown pro $command nebyl nalezen", + "cooldown-was-enabled": "$sender, cooldown pro $command byl zapnut", + "cooldown-was-disabled": "$sender, cooldown pro $command byl vypnut", + "cooldown-was-enabled-for-moderators": "$sender, cooldown pro $command je nastaven i pro moderátory", + "cooldown-was-disabled-for-moderators": "$sender, cooldown pro $command ignoruje moderátory", + "cooldown-was-enabled-for-owners": "$sender, cooldown pro $command je nastaven i pro vlastníka", + "cooldown-was-disabled-for-owners": "$sender, cooldown pro $command ignoruje vlastníka", + "cooldown-was-enabled-for-subscribers": "$sender, cooldown pro $command je nastaven i pro subscribery", + "cooldown-was-disabled-for-subscribers": "$sender, cooldown pro $command ignoruje subscribery", + "cooldown-was-enabled-for-followers": "$sender, cooldown pro $command je nastaven i pro followery", + "cooldown-was-disabled-for-followers": "$sender, cooldown pro $command ignoruje followery" + }, + "timers": { + "id-must-be-defined": "$sender, id odpovědi musí být definováno.", + "id-or-name-must-be-defined": "$sender, id odpovědi nebo název časovače musí být definován.", + "name-must-be-defined": "$sender, název časovače musí být definován.", + "response-must-be-defined": "$sender, odpověď časovače musí být definována.", + "cannot-set-messages-and-seconds-0": "$sender, nemůžete nastavit zprávy i sekundy na 0.", + "timer-was-set": "$sender, časovač $name byl nastaven na $messages zpráv a $seconds sekund ke spuštění", + "timer-was-set-with-offline-flag": "$sender, časovač $name byl nastaven na $messages zpráv a $seconds sekund ke spuštění, i když je stream offline", + "timer-not-found": "$sender, časovač (název: $name) nebyl nalezen v databázi. Pro seznam časovačů -> !timers list", + "timer-deleted": "$sender, časovač $name a jeho odpovědi byly odstraněny.", + "timer-enabled": "$sender, časovač (název: $name) byl povolen", + "timer-disabled": "$sender, časovač (jméno: $name) byl deaktivován", + "timers-list": "$sender, seznam časovačů: $list", + "responses-list": "$sender, seznam pro časovač (name: $name)", + "response-deleted": "$sender, odpověď (id: $id) byla smazána.", + "response-was-added": "$sender, odpověď (id: $id) na časovač (jméno: $name) byla přidána - '$response'", + "response-not-found": "$sender, odpověď (id: $id) nebyla nalezena v databázi", + "response-enabled": "$sender, odpověď (id: $id) byla zapnuta", + "response-disabled": "$sender, odpověď (id: $id) byla vypnuta" + }, + "gambling": { + "duel": { + "bank": "$sender, současný bank pro $command je $points $pointsName", + "lowerThanMinimalBet": "$sender, minimální sázka pro $command je $points $pointsName", + "cooldown": "$sender, nemůžete použít $command ještě $cooldown $minutesName.", + "joined": "$sender, hodně štěstí během duelu. Vsadil sis na sebe $points $pointsName!", + "added": "$sender si myslí, že je lepší než ostatní a zvyšuje sázku na $points $pointsName!", + "new": "$sender je váš nový vyzyvatel pro duel! Zúčastni se také pomocí $command [points], zbývá ti $minutes $minutesName pro účast.", + "zeroBet": "$sender, nemůžeš na sebe vsadit 0 $pointsName", + "notEnoughOptions": "$sender, k duelu musíš něco vsadit", + "notEnoughPoints": "$sender, nemáš $points $pointsName pro duel!", + "noContestant": "Pouze $winner se odvážil zúčastnit duelu! Jeho sázka $points $pointsName mu byla vrácena.", + "winner": "Gratulace $winner! Je posledním preživším a získal $points $pointsName ($probability% se sázkou $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender pokouší svůj osud a stiskl spoušt", + "alive": "$sender je naživu! Vůbec nic se nestalo.", + "dead": "$sender, tvůj mozek se shledal s kulkou, která ho rozmáznula po zdi!", + "mod": "$sender je nešika, vůbec svou hlavu netrefil!", + "broadcaster": "$sender použil slepé náboje, buu!", + "timeout": "Roulette timeout nastaven na $values" + }, + "gamble": { + "chanceToWin": "$sender, šance na výhru !gamble nastavena na $value%", + "zeroBet": "$sender, nemůžete hrát o 0 $pointsName", + "minimalBet": "$sender, minimální sázka pro !gamble je nastavena na $value", + "lowerThanMinimalBet": "$sender, minimální sázka pro !gamble je $points $pointsName", + "notEnoughOptions": "$sender, musíš hrát o nějaké body", + "notEnoughPoints": "$sender, nemáš $points $pointsName abys o ně hrál", + "win": "$sender, VYHRÁLS! Aktuálně máš $points $pointsName", + "winJackpot": "$sender, vyhrál jste JACKPOT! Získal jsi $jackpot $jackpotName navíc ke své sázce. Nyní máte $points $pointsName", + "loseWithJackpot": "$sender, PROHRÁLS! Aktuálně máš $points $pointsName. Jackpot se zvýšil na $jackpot $jackpotName", + "lose": "$sender, PROHRÁLS! Aktuálně máš $points $pointsName", + "currentJackpot": "$sender, současný jackpot pro $command je $points $pointsName", + "winJackpotCount": "$sender, vyhrál/a jsi $count jackpotů", + "jackpotIsDisabled": "$sender, jackpot je vypnutý pro $command." + } + }, + "highlights": { + "saved": "$sender, highlight byl uložen pro $hoursh$minutesm$secondss", + "list": { + "items": "$sender, seznam uložených highlightů pro poslední vysílání: $items", + "empty": "$sender, žádné highlighty nebyly uloženy" + }, + "offline": "$sender, nelze uložit highlight, vysílání je offline" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "Bot nebude odesílat chyby při nedostatečném oprávnění", + "false": "Bot nebude šeptat chyby při nedostatečném oprávnění" + }, + "disableCooldownWhispers": { + "true": "Bot nebude posílat oznámení o cooldownu", + "false": "Bot bude posílat oznámení o cooldownu přes whisper" + } + } + }, + "time": "Aktuální čas v časovém pásmu streamu je $time", + "subs": "$sender, je zde právě $onlineSubCount online subscriberů. Poslední sub/resub byl $lastSubUsername $lastSubAgo", + "followers": "$sender, poslední sledující byl $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, uživatel $username není botem ignorován" + }, + "added": "$sender, uživatel $username byl přidán do botova ignorelistu", + "removed": "$sender, uživatel $username je odebrán z botova ignorelistu", + "ignored": "$sender, uživatel $username je botem ignorován" + } + } + }, + "filters": { + "setVariable": "$sender, $variable bylo nastaveno na $value." + } +} diff --git a/backend/locales/cs/api.clips.json b/backend/locales/cs/api.clips.json new file mode 100644 index 000000000..c2a0af4e5 --- /dev/null +++ b/backend/locales/cs/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "Klip byl vytvořen a je k dispozici na $link" +} \ No newline at end of file diff --git a/backend/locales/cs/core/permissions.json b/backend/locales/cs/core/permissions.json new file mode 100644 index 000000000..392d1edaf --- /dev/null +++ b/backend/locales/cs/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "Seznam vašich oprávnění:", + "excludeAddSuccessful": "$sender, přidal si $username do seznamu vyloučených pro oprávnění $permissionName", + "excludeRmSuccessful": "$sender, odebral si $username ze seznamu vyloučených pro oprávnění $permissionName", + "userNotFound": "$sender, uživatel $username nebyl nalezen v databázi.", + "permissionNotFound": "$sender, oprávnění $userlevel nebylo nalezeno v databázi.", + "cannotIgnoreForCorePermission": "$sender, uživatele nelze vyloučit ručně ze základního oprávnění $userlevel" +} \ No newline at end of file diff --git a/backend/locales/cs/games.heist.json b/backend/locales/cs/games.heist.json new file mode 100644 index 000000000..d53134987 --- /dev/null +++ b/backend/locales/cs/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, poldové pořád hledají minulý tým! Zkus to až za $cooldown.", + "copsCooldownMessage": "Vypadá to, že poldove mají nohy hore. Můžeme plánovat další loupež!", + "entryMessage": "$sender začal plánovat přepadení banky! Hledají se parťáci do akce pro větší lup. Přidej se i ty! Napiš $command aby ses připojil.", + "lateEntryMessage": "$sender, loupež právě probíhá!", + "entryInstruction": "$sender, napiš $command pro vstup.", + "levelMessage": "S touhle partou si troufnem na $bank! Seženeme více lidí, abychom vyloupili $nextBank", + "maxLevelMessage": "S touhle partou si troufnem na $bank! Lepší už to být nemůže!", + "started": "Dobrá chlapi, poslední kontrola vybavení, tohle je to, na co jsme se připravovali. Toto není žádné cvičení, jde se na to! Jdem vyloupit $bank!", + "noUser": "Nikdo se nepřidal k loupeži.", + "singleUserSuccess": "$user byl jako ninja. Nikdo si nevšiml chybějících peněz.", + "singleUserFailed": "$user se nepodařilo zbavit policie a stráví svůj čas ve vězení.", + "result": { + "0": "Někdo žvanil. Čekal na vás celý SWAT tým. Byl to masakr a nikomu se nepodařilo utéct.", + "33": "Ostraha byla tentokrát ostrá. Jen 1/3 týmu se dokázala dostat s lupem ven.", + "50": "Dlouhé vyjednávání s poldy zajistilo půlce týmu čas na to, aby stihli utéct s lupem.", + "99": "Vypadalo to jako snadná akce, avšak některým z vás se nepodařilo utéct s lupem. Ostatní si mnou ruce!", + "100": "Je to vůbec možné? Precizně naplánovaná akce, precizní provedení. 100% úspěšnost. Takhle se to dělá!" + }, + "levels": { + "bankVan": "Směnárnu", + "cityBank": "Poštu", + "stateBank": "Státní banka", + "nationalReserve": "Národní rezerva", + "federalReserve": "Národní klenoty" + }, + "results": "Lup si odnesli: $users", + "andXMore": "a dalších $count..." +} \ No newline at end of file diff --git a/backend/locales/cs/integrations/discord.json b/backend/locales/cs/integrations/discord.json new file mode 100644 index 000000000..0262f9a9c --- /dev/null +++ b/backend/locales/cs/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "váš účet není propojen, použijte `$command`", + "all-your-links-were-deleted": "všechna vaše propojení byla odstraněna", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender, tento účet byl propojen s $discordTag.", + "invalid-or-expired-token": "$sender, neplatný nebo prošlý token.", + "help-message": "$sender, pro propojení účtu na Discordu: 1. Jděte na Discord server a pošlete $command v kanálu pro příkazy bota. | 2. Počkejte na SZ na Discordu od bota | 3. Pošlete příkaz z SZ na Discordu zde v Twitch chatu.", + "started-at": "Zahájeno v", + "announced-by": "Oznámení od sogeBot", + "streamed-at": "Streamováno v", + "link-whisper": "Ahoj $tag, pro propojení tohoto Discord účtu s tvým Twitch účtem na kanálu $broadcaster, přejdi na , přihlaste se do svého účtu a pošlete tento příkaz do chatu \n\n\t\t`$command $id`\n\nPOZNÁMKA: Platnost vyprší za 10 minut.", + "check-your-dm": "zkontrolujte své SZ pro kroky k propojení účtů." +} \ No newline at end of file diff --git a/backend/locales/cs/integrations/lastfm.json b/backend/locales/cs/integrations/lastfm.json new file mode 100644 index 000000000..9917b4187 --- /dev/null +++ b/backend/locales/cs/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "Aktuální skladba je $name" +} \ No newline at end of file diff --git a/backend/locales/cs/integrations/obswebsocket.json b/backend/locales/cs/integrations/obswebsocket.json new file mode 100644 index 000000000..caaeabf42 --- /dev/null +++ b/backend/locales/cs/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, žádné akce nejsou nastaveny pro id:$id!", + "ParameterError": "$sender, je třeba zadat id!", + "UnknownError": "$sender, něco se pokazilo. Pro další informace zkontrolujte logy bota." + } +} \ No newline at end of file diff --git a/backend/locales/cs/integrations/protondb.json b/backend/locales/cs/integrations/protondb.json new file mode 100644 index 000000000..55921520a --- /dev/null +++ b/backend/locales/cs/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | Hodnocení: $rating | Nativní na $native | Detaily: $url", + "responseNg": "Hodnocení hry $game nebylo v ProtonDB nalezeno.", + "responseNotFound": "Hra $game nebyla v ProtonDB nalezena." +} \ No newline at end of file diff --git a/backend/locales/cs/integrations/pubg.json b/backend/locales/cs/integrations/pubg.json new file mode 100644 index 000000000..c0d684ed5 --- /dev/null +++ b/backend/locales/cs/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, je potřeba jeden z těchto parametrů: $list" +} \ No newline at end of file diff --git a/backend/locales/cs/integrations/spotify.json b/backend/locales/cs/integrations/spotify.json new file mode 100644 index 000000000..ea8c196b4 --- /dev/null +++ b/backend/locales/cs/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "Omlouváme se, $sender, skladba nebyla nalezena na spotify", + "song-requested": "$sender, požádal jste o skladbu $name od $artist", + "not-banned-song-not-playing": "$sender, žádná skladba momentálně nehraje k zabanování.", + "song-banned": "$sender, skladba $name od $artist je zabanována.", + "song-unbanned": "$sender, skladba $name od $artist je odbanována.", + "song-not-found-in-banlist": "$sender, skladba podle spotifyURI $uri nebyla nalezena v seznamu zakázaných položek.", + "cannot-request-song-is-banned": "$sender, nelze žádat o zabanovanou skladbu $name od $artist.", + "cannot-request-song-from-unapproved-artist": "$sender, nemůže požádat o písničku od neschváleného umělce.", + "no-songs-found-in-history": "$sender, momentálně není žádná skladba v seznamu historie.", + "return-one-song-from-history": "$sender, předchozí skladba byla $name od $artist.", + "return-multiple-song-from-history": "$sender, $count předchozích skladeb bylo:", + "return-multiple-song-from-history-item": "$index - $name od $artist", + "song-notify": "Aktuální přehrávaná skladba je $name od $artist." +} \ No newline at end of file diff --git a/backend/locales/cs/integrations/tiltify.json b/backend/locales/cs/integrations/tiltify.json new file mode 100644 index 000000000..7a867ce87 --- /dev/null +++ b/backend/locales/cs/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, momentálně nejsou žádné aktivní kampaně.", + "active_campaigns": "$sender, seznam aktuálně aktivních kampaní:" +} \ No newline at end of file diff --git a/backend/locales/cs/systems.quotes.json b/backend/locales/cs/systems.quotes.json new file mode 100644 index 000000000..fdaf390c6 --- /dev/null +++ b/backend/locales/cs/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, citace $id '$quote' byla přidána. (tagy: $tags)", + "error": "$sender, $command není správný nebo chybí -quote parametr" + }, + "remove": { + "ok": "$sender, citace $id byla úspěšně smazána.", + "error": "$sender, ID citace chybí.", + "not-found": "$sender, citace $id nebyla nalezena." + }, + "show": { + "ok": "Citace $id od $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command chybí -id nebo -tag.", + "not-found-by-id": "$sender, citace $id nebyla nalezena.", + "not-found-by-tag": "$sender, žádné citace s tagem $tag nebyly nalezeny." + } + }, + "set": { + "ok": "$sender, Tagy citace $id byly nastaveny. (tagy: $tags)", + "error": { + "no-parameters": "$sender, $command chybí -id nebo -tag.", + "not-found-by-id": "$sender, citace $id nebyla nalezena." + } + }, + "list": { + "ok": "$sender, dalsi citace lze najit na http://$urlBase/public/#/quotes", + "is-localhost": "$sender, url citaci nebyla nastavena." + } +} \ No newline at end of file diff --git a/backend/locales/cs/systems/antihateraid.json b/backend/locales/cs/systems/antihateraid.json new file mode 100644 index 000000000..54e1d2574 --- /dev/null +++ b/backend/locales/cs/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "Tento chat byl nastaven na $mode od $username , aby se zbavil hate raidu. Omlouváme se za nepříjemnosti!", + "mode": { + "0": "pouze pro předplatitele", + "1": "pouze pro sledující", + "2": "pouze pro emotikony" + } +} \ No newline at end of file diff --git a/backend/locales/cs/systems/howlongtobeat.json b/backend/locales/cs/systems/howlongtobeat.json new file mode 100644 index 000000000..517d1a2b8 --- /dev/null +++ b/backend/locales/cs/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, hra $game nebyla nalezena v databázi.", + "game": "$sender, $game | Hlavní příběh: $currentMain/$hltbMainh - $percentMain% | Hlavní příběh+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Kompletně vše: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Hlavní příběh: $currentMainh | Hlavní příběh+Extra: $currentMainExtrah | Kompletně vše: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/cs/systems/levels.json b/backend/locales/cs/systems/levels.json new file mode 100644 index 000000000..3d0609a58 --- /dev/null +++ b/backend/locales/cs/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, úroveň: $currentLevel ($currentXP $xpName), $nextXP $xpName do další úrovně.", + "changeXP": "$sender, změnili jste $xpName o $amount $xpName pro $username.", + "notEnoughPointsToBuy": "Omlouváme se, $sender, ale nemáte $points $pointsName k nákupu $amount $xpName pro úroveň $level.", + "XPBoughtByPoints": "$sender, koupili jste $amount $xpName s $points $pointsName a dosáhli jste úrovně $level.", + "somethingGetWrong": "$sender, něco se pokazilo s vaším požadavkem." +} \ No newline at end of file diff --git a/backend/locales/cs/systems/scrim.json b/backend/locales/cs/systems/scrim.json new file mode 100644 index 000000000..b6b28759a --- /dev/null +++ b/backend/locales/cs/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Snipe hra ($type) začíná za $time $unit", + "go": "Začínáme teď! Go!", + "putMatchIdInChat": "Prosím, zadejte ID zápasu v chatu => $command xxx", + "currentMatches": "Aktuální zápasy: $matches", + "stopped": "Snipe hra byla zrušena." +} \ No newline at end of file diff --git a/backend/locales/cs/systems/top.json b/backend/locales/cs/systems/top.json new file mode 100644 index 000000000..e36fa3ea1 --- /dev/null +++ b/backend/locales/cs/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Top $amount (čas sledováni): ", + "tips": "Top $amount (spropitné): ", + "level": "Top $amount (úroveň): ", + "points": "Top $amount (body): ", + "messages": "Top $amount (zprávy): ", + "followage": "Top $amount (followage): ", + "subage": "Top $amount (subage): ", + "submonths": "Top $amount (submonths): ", + "bits": "Top $amount (bity): ", + "gifts": "Top $amount (subgifts): " +} \ No newline at end of file diff --git a/backend/locales/cs/ui.commons.json b/backend/locales/cs/ui.commons.json new file mode 100644 index 000000000..6bf4569f5 --- /dev/null +++ b/backend/locales/cs/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Doplňující nastavení", + "never": "nikdy", + "reset": "resetovat", + "moveUp": "posunout nahoru", + "moveDown": "posunout dolů", + "stop-if-executed": "po provedení nepokračuje", + "continue-if-executed": "po provedení pokračuje", + "generate": "Vygenerovat", + "thumbnail": "Miniatura", + "yes": "Ano", + "no": "Ne", + "show-more": "Zobrazit více", + "show-less": "Zobrazit méně", + "allowed": "Povoleno", + "disallowed": "Zakázáno", + "back": "Zpět" +} diff --git a/backend/locales/cs/ui.dialog.json b/backend/locales/cs/ui.dialog.json new file mode 100644 index 000000000..5e1947f99 --- /dev/null +++ b/backend/locales/cs/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Upravit", + "add": "Přidat" + }, + "position": { + "settings": "Nastavení pozice", + "anchorX": "Pozice ukotvení X", + "anchorY": "Pozice ukotvení Y", + "left": "Vlevo", + "right": "Vpravo", + "middle": "Střed", + "top": "Nahoře", + "bottom": "Dole", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Posunout doprava", + "shadowShiftDown": "Posunout dolů", + "shadowBlur": "Rozostření", + "shadowOpacity": "Průhlednost", + "color": "Barva" + }, + "errors": { + "required": "Toto pole nesmí být prázdné.", + "minValue": "Nejnižší hodnota tohoto pole je $value." + }, + "buttons": { + "reorder": "Uspořádat", + "upload": { + "idle": "Nahrát", + "progress": "Nahrávání", + "done": "Nahráno" + }, + "cancel": "Zrušit", + "close": "Zavřít", + "test": { + "idle": "Otestovat", + "progress": "Testování probíhá", + "done": "Testování dokončeno" + }, + "saveChanges": { + "idle": "Uložit změny", + "invalid": "Nelze uložit změny", + "progress": "Ukládání změn", + "done": "Změny uloženy" + }, + "something-went-wrong": "Něco se pokazilo", + "mark-to-delete": "Označit ke smazání", + "disable": "Vypnout", + "enable": "Zapnout", + "disabled": "Vypnuto", + "enabled": "Zapnuto", + "edit": "Upravit", + "delete": "Smazat", + "play": "Přehrát", + "stop": "Zastavit", + "hold-to-delete": "Podržte pro smazání", + "yes": "Ano", + "no": "Ne", + "permission": "Oprávnění", + "group": "Skupina", + "visibility": "Viditelnost", + "reset": "Reset" + }, + "changesPending": "Vaše změny nebyly uloženy.", + "formNotValid": "Opravte chyby ve formuláři.", + "nothingToShow": "Zde není nic k vidění" +} \ No newline at end of file diff --git a/backend/locales/cs/ui.menu.json b/backend/locales/cs/ui.menu.json new file mode 100644 index 000000000..0bb664eb4 --- /dev/null +++ b/backend/locales/cs/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Služby", + "updater": "Updater", + "index": "Nástěnka", + "core": "Bot", + "users": "Uživatelé", + "tmi": "TMI", + "ui": "Uživatelské rozhraní", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "Obecné", + "timers": "Časovače", + "new": "Nová položka", + "keywords": "Klíčová slova", + "customcommands": "Vlastní příkazy", + "botcommands": "Příkazy bota", + "commands": "Příkazy", + "events": "Eventy", + "ranks": "Hodnosti", + "songs": "Písničky", + "modules": "Moduly", + "viewers": "Diváci", + "alias": "Aliasy", + "cooldowns": "Cooldowny", + "cooldown": "Cooldown", + "highlights": "Highlighty", + "price": "Cena", + "logs": "Logy", + "systems": "Systémy", + "permissions": "Oprávnění", + "translations": "Vlastní překlady", + "moderation": "Pravidla v chatu", + "overlays": "Overlaye", + "gallery": "Galerie médií", + "games": "Hry", + "spotify": "Spotify", + "integrations": "Integrace", + "customvariables": "Vlastní proměnné", + "registry": "Registr", + "quotes": "Citáty", + "settings": "Nastavení", + "commercial": "Reklamy", + "bets": "Sázky", + "points": "Body", + "raffles": "Tombola", + "queue": "Fronta", + "playlist": "Seznam skladeb", + "bannedsongs": "Zakázané písně", + "spotifybannedsongs": "Spotify zabanované skladby", + "duel": "Duel", + "fightme": "Boj", + "seppuku": "Seppuku", + "gamble": "Hazard", + "roulette": "Ruleta", + "heist": "Loupež", + "oauth": "OAuth", + "socket": "Socket", + "carouseloverlay": "Carousel overlay", + "alerts": "Alerty", + "carousel": "Promítačka obrázků", + "clips": "Klipy", + "credits": "Závěrečné titulky", + "emotes": "Emotikony", + "stats": "Statistiky", + "text": "Text", + "currency": "Měna", + "eventlist": "Kanál aktivit", + "clipscarousel": "Promítačka klipů", + "streamlabs": "StreamLabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Checklist", + "bot": "Bot", + "api": "API", + "manage": "Správa", + "top": "Top", + "goals": "Cíle", + "userinfo": "Uživatelské informace", + "scrim": "Scrim", + "commandcount": "Počet užití příkazů", + "profiler": "Profiler", + "howlongtobeat": "How long to beat", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Náhodný výběr", + "tips": "Spropitné", + "bits": "Bity", + "discord": "Discord", + "texttospeech": "Text na řeč", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "Úrovně", + "obswebsocket": "OBS Websocket", + "api-explorer": "Průzkumník API", + "emotescombo": "Kombo emotů", + "notifications": "Oznámení", + "plugins": "Pluginy", + "tts": "TTS" +} diff --git a/backend/locales/cs/ui.page.settings.overlays.carousel.json b/backend/locales/cs/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..3ffe53b1f --- /dev/null +++ b/backend/locales/cs/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "možnosti", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Jste si jistý, že chcete smazat tento obrázek?" + }, + "button": { + "update": "Aktualizovat", + "fix_your_errors_first": "Nejdřív opravte chyby" + }, + "errors": { + "number_greater_or_equal_than_0": "Hodnota musí být >= 0", + "value_must_not_be_empty": "Hodnota nesmí být prázdná" + }, + "titles": { + "waitBefore": "Doba čekání před zobrazením obrázku (v ms)", + "waitAfter": "Doba čekání po zobrazení obrázku (v ms)", + "duration": "Jak dlouho obrázek zobrazit (v ms)", + "animationIn": "Animace zobrazení", + "animationOut": "Animace skrytí", + "animationInDuration": "Délka animace zobrazení (v ms)", + "animationOutDuration": "Délka animace skrytí (v ms)", + "showOnlyOncePerStream": "Zobrazit pouze jednou během streamu" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.registry.customvariables.json b/backend/locales/cs/ui.registry.customvariables.json new file mode 100644 index 000000000..ec5d24308 --- /dev/null +++ b/backend/locales/cs/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "Adresy URL", + "generateurl": "Vygenerovat novou URL", + "show-examples": "Ukázat CURL příklady", + "response": { + "show": "Zobrazit odpověď po POST", + "name": "Odpověď po nastavení", + "default": "Výchozí", + "default-placeholder": "Nastavte svou odpověď", + "default-help": "Použijte $value pro získání nové hodnoty proměnné", + "custom": "Vlastní", + "command": "Příkaz" + }, + "useIfInCommand": "Použijte, pokud používáte proměnnou v příkazu. Vrátí pouze aktualizovanou proměnnou bez odpovědi.", + "permissionToChange": "Oprávnění ke změně", + "isReadOnly": "v chatu pouze pro čtení", + "isNotReadOnly": "lze měnit prostřednictvím chatu", + "no-variables-found": "Nebyly nalezeny žádné proměnné", + "additional-info": "Doplňující informace", + "run-script": "Spustit skript", + "last-run": "Poslední spuštění v", + "variable": { + "name": "Název proměnné", + "help": "Název proměnné musí být jedinečný, například $_wins, $_loses, $_top3", + "placeholder": "Zadejte jedinečný název proměnné", + "error": { + "isNotUnique": "Proměnná musí mít jedinečný název.", + "isEmpty": "Název proměnné nesmí být prázdný." + } + }, + "description": { + "name": "Popisek", + "help": "Volitelný popis", + "placeholder": "Zadejte volitelný popis" + }, + "type": { + "name": "Typ", + "error": { + "isNotSelected": "Zvolte typ proměnné." + } + }, + "currentValue": { + "name": "Aktuální hodnota", + "help": "Pokud je typ nastaven na skript, nelze hodnotu změnit ručně" + }, + "usableOptions": { + "name": "Použitelné možnosti", + "placeholder": "Zadejte, své, možnosti, zde", + "help": "Možnosti, které lze použít s touto proměnnou, například SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "Musíte nastavit alespoň jednu hodnotu." + } + }, + "scriptToEvaluate": "Skript, který chcete vyhodnotit", + "runScript": { + "name": "Spustit skript", + "error": { + "isNotSelected": "Prosím vyberte možnost." + } + }, + "testCurrentScript": { + "name": "Otestovat aktuální skript", + "help": "Kliknutím na tlačítko Otestovat aktuální skript zobrazte hodnotu v poli Aktuální hodnota" + }, + "history": "Historie", + "historyIsEmpty": "Historie této proměnné je prázdná!", + "warning": "Upozornění: Všechna data této proměnné budou smazána!", + "choose": "Vybrat...", + "types": { + "number": "Číslo", + "text": "Text", + "options": "Možnosti", + "eval": "Skript" + }, + "runEvery": { + "isUsed": "Při použití proměnné" + } +} + diff --git a/backend/locales/cs/ui.systems.antihateraid.json b/backend/locales/cs/ui.systems.antihateraid.json new file mode 100644 index 000000000..7ad2c2470 --- /dev/null +++ b/backend/locales/cs/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Vyčistit chat", + "mode": "Režim", + "minFollowTime": "Minimální čas sledování", + "customAnnounce": "Přizpůsobit oznámení při zapnutí anti hate raid" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.bets.json b/backend/locales/cs/ui.systems.bets.json new file mode 100644 index 000000000..dd989d776 --- /dev/null +++ b/backend/locales/cs/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "betPercentGain": "Přidejte x% do výplaty sázek každé možnosti" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.commercial.json b/backend/locales/cs/ui.systems.commercial.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/cs/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.cooldown.json b/backend/locales/cs/ui.systems.cooldown.json new file mode 100644 index 000000000..636139315 --- /dev/null +++ b/backend/locales/cs/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Oznámit šepotem", + "settings": { + "enabled": "Status", + "cooldownNotifyAsWhisper": "Oznámit informace o cooldown přes whisper", + "cooldownNotifyAsChat": "Oznámit informace o cooldown přes chat", + "defaultCooldownOfCommandsInSeconds": "Výchozí nastavení pro příkazy (v sekundách)", + "defaultCooldownOfKeywordsInSeconds": "Výchozí nastavení pro klíčová slova (v sekundách)" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.customcommands.json b/backend/locales/cs/ui.systems.customcommands.json new file mode 100644 index 000000000..e144a2de7 --- /dev/null +++ b/backend/locales/cs/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "Bez odpovědí", + "addResponse": "Nová odpověď", + "response": { + "name": "Odpověď", + "placeholder": "Nastavte odpověď bota zde." + }, + "filter": { + "name": "filtr", + "placeholder": "Přidat filtr k této odpovědi" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.highlights.json b/backend/locales/cs/ui.systems.highlights.json new file mode 100644 index 000000000..c1b85563f --- /dev/null +++ b/backend/locales/cs/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "urls": "Vygenerované URL" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.moderation.json b/backend/locales/cs/ui.systems.moderation.json new file mode 100644 index 000000000..dc3cf0a41 --- /dev/null +++ b/backend/locales/cs/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Status", + "cListsEnabled": "Vynutit pravidlo", + "cLinksEnabled": "Vynutit pravidlo", + "cSymbolsEnabled": "Vynutit pravidlo", + "cLongMessageEnabled": "Vynutit pravidlo", + "cCapsEnabled": "Vynutit pravidlo", + "cSpamEnabled": "Vynutit pravidlo", + "cColorEnabled": "Vynutit pravidlo", + "cEmotesEnabled": "Vynutit pravidlo", + "cListsWhitelist": { + "title": "Povolená slova", + "help": "Pro povolení domén použijte \"domain:prtzl.io\"" + }, + "autobanMessages": "Autoban zprávy", + "cListsBlacklist": "Zakázaná slova", + "cListsTimeout": "Čas trvání timeoutu", + "cLinksTimeout": "Čas trvání timeoutu", + "cSymbolsTimeout": "Čas trvání timeoutu", + "cLongMessageTimeout": "Čas trvání timeoutu", + "cCapsTimeout": "Čas trvání timeoutu", + "cSpamTimeout": "Čas trvání timeoutu", + "cColorTimeout": "Čas trvání timeoutu", + "cEmotesTimeout": "Čas trvání timeoutu", + "cWarningsShouldClearChat": "Promaže chat (dostane timeout 1s)", + "cLinksIncludeSpaces": "Zahrnout mezeru", + "cLinksIncludeClips": "Zahrnout klipy", + "cSymbolsTriggerLength": "Kontrolovat od této délky zprávy", + "cLongMessageTriggerLength": "Kontrolovat od této délky zprávy", + "cCapsTriggerLength": "Kontrolovat od této délky zprávy", + "cSpamTriggerLength": "Kontrolovat od této délky zprávy", + "cSymbolsMaxSymbolsConsecutively": "Maximální počet symbolů po sobě", + "cSymbolsMaxSymbolsPercent": "Maximálně symbolů v %", + "cCapsMaxCapsPercent": "Maximálně velkých písmen v %", + "cSpamMaxLength": "Maximální délka", + "cEmotesMaxCount": "Maximální počet", + "cWarningsAnnounceTimeouts": "Oznámit timeout v chatu pro každého", + "cWarningsAllowedCount": "Počet výstrah", + "cEmotesEmojisAreEmotes": "Považovat emoji za emotikony" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.points.json b/backend/locales/cs/ui.systems.points.json new file mode 100644 index 000000000..d36325736 --- /dev/null +++ b/backend/locales/cs/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Status", + "name": { + "title": "Název", + "help": "Možné formáty:
point|points
bod|4:body|bodu" + }, + "isPointResetIntervalEnabled": "Interval mazání bodů", + "resetIntervalCron": { + "name": "Interval cronu", + "help": "CronTab generátor" + }, + "interval": "Minutový interval pro přidání bodů online uživatelům při online streamu", + "offlineInterval": "Minutový interval pro přidání bodů online uživatelům při offline streamu", + "messageInterval": "Kolik zpráv pro přidání body", + "messageOfflineInterval": "Kolik zpráv pro přidání body při offline streamu", + "perInterval": "Kolik bodů přidat na online interval", + "perOfflineInterval": "Kolik bodů přidat na offline interval", + "perMessageInterval": "Kolik bodů přidat na interval zpráv", + "perMessageOfflineInterval": "Kolik bodů přidat na offline interval zpráv" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.price.json b/backend/locales/cs/ui.systems.price.json new file mode 100644 index 000000000..f0c8a52be --- /dev/null +++ b/backend/locales/cs/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Spustit vlastní upozornění při zakoupení bity", + "price": { + "name": "cena", + "placeholder": "" + }, + "error": { + "isEmpty": "Tato hodnota nesmí být prázdná" + }, + "warning": "Tato akce nemůže být vrácena!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.queue.json b/backend/locales/cs/ui.systems.queue.json new file mode 100644 index 000000000..355b8fbaa --- /dev/null +++ b/backend/locales/cs/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Status", + "eligibilityAll": "Všichni", + "eligibilityFollowers": "Followeři", + "eligibilitySubscribers": "Subscribeři" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.quotes.json b/backend/locales/cs/ui.systems.quotes.json new file mode 100644 index 000000000..6b76971c0 --- /dev/null +++ b/backend/locales/cs/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "Je nám líto, ale v databázi nebyly nalezeny žádné citace.", + "new": "Přidat nový citát", + "empty": "Seznam citací je prázdný, vytvořte novou.", + "emptyAfterSearch": "Seznam citací je prázdný při hledání \"$search\"", + "quote": { + "name": "Citát", + "placeholder": "Nastavte zde svou citaci" + }, + "by": { + "name": "Citát od" + }, + "tags": { + "name": "Tagy", + "placeholder": "Nastavte zde své tagy", + "help": "Tagy oddělené čárkou. Příklad: tag 1, tag 2, tag 3" + }, + "date": { + "name": "Datum" + }, + "error": { + "isEmpty": "Tato hodnota nesmí být prázdná", + "atLeastOneTag": "Musíte nastavit alespoň jeden tag" + }, + "tag-filter": "Filtrování dle tagu", + "warning": "Tato akce nemůže být vrácena!", + "settings": { + "enabled": "Status", + "urlBase": { + "title": "Adresa URL", + "help": "Pro citace byste měli použít veřejný koncový bod, který bude přístupný všem" + } + } +} diff --git a/backend/locales/cs/ui.systems.raffles.json b/backend/locales/cs/ui.systems.raffles.json new file mode 100644 index 000000000..dca1b871a --- /dev/null +++ b/backend/locales/cs/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Štěstí Subscribera" + }, + "settings": { + "enabled": "Status", + "announceNewEntries": { + "title": "Oznamovat nové vstupy", + "help": "Pokud se uživatelé připojí k tombole, oznámení bude po chvíli odesláno do chatu." + }, + "announceNewEntriesBatchTime": { + "title": "Jak dlouho čekat před oznámením nových vstupů (v sekundách)", + "help": "Delší doba zachová čistší chat, vstupy budou sloučeny." + }, + "deleteRaffleJoinCommands": { + "title": "Odstranit příkaz pro připojení uživatele do tomboly", + "help": "Tímto odstraníte zprávu uživatele při použití příkazu !yourraffle. Toto pomůže mít čistší chat." + }, + "allowOverTicketing": { + "title": "Povolit vstup do raffle při použití více bodů", + "help": "Povolí vstup do raffle při použití více bodů než uživatel má. Např. uživatel má 10 bodů, ale může vstoupit do raffle pomocí !raffle 100, které použije všechny jeho body." + }, + "raffleAnnounceInterval": { + "title": "Interval oznámení", + "help": "Minuty" + }, + "raffleAnnounceMessageInterval": { + "title": "Interval oznámení - zprávy", + "help": "Kolik zpráv musí být odesláno do chatu, dokud nebude možné odeslat oznámení." + }, + "subscribersPercent": { + "title": "Dodatečné štěstí subscribera", + "help": "v procentech" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.ranks.json b/backend/locales/cs/ui.systems.ranks.json new file mode 100644 index 000000000..1408ea1bd --- /dev/null +++ b/backend/locales/cs/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "Nová Hodnost", + "empty": "Žádná hodnost nebyla vytvořena.", + "emptyAfterSearch": "Žádná hodnost nebyla nalezena pro tvé hledání \"$search\".", + "rank": { + "name": "hodnost", + "placeholder": "" + }, + "value": { + "name": "hodiny", + "placeholder": "" + }, + "error": { + "isEmpty": "Tato hodnota nesmí být prázdná" + }, + "warning": "Tato akce nemůže být vrácena!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.songs.json b/backend/locales/cs/ui.systems.songs.json new file mode 100644 index 000000000..76223b799 --- /dev/null +++ b/backend/locales/cs/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Status", + "volume": "Hlasitost", + "calculateVolumeByLoudness": "Vypočítat hlasitost podle loudness", + "duration": { + "title": "Maximální délka skladby", + "help": "v minutách" + }, + "shuffle": "Zamíchat", + "songrequest": "Přehrávání ze song requestu", + "playlist": "Přehrávání z playlistu", + "onlyMusicCategory": "Přehrávání pouze z kategorie Hudba", + "allowRequestsOnlyFromPlaylist": "Povolit požadavky na skladby pouze z aktuálního seznamu skladeb", + "notify": "Odeslat zprávu při změně skladby" + }, + "error": { + "isEmpty": "Tato hodnota nesmí být prázdná" + }, + "startTime": "Začít skladbu v", + "endTime": "Ukončit skladbu v", + "add_song": "Přidat píseň", + "add_or_import": "Přidat skladbu nebo import ze seznamu skladeb", + "importing": "Importuji", + "importing_done": "Importování Hotovo", + "seconds": "Vteřiny", + "calculated": "Vypočítáno", + "set_manually": "Nastavit ručně", + "bannedSongsEmptyAfterSearch": "Nebyly nalezeny žádné zabanované skladby pro tvé hledání \"$search\".", + "emptyAfterSearch": "Nebyly nalezeny žádné skladby pro tvé hledání \"$search\".", + "empty": "Zatím nebyly přidány žádné skladby.", + "bannedSongsEmpty": "Zatím nebyly přidány žádné skladby do seznamu zakázaných." +} \ No newline at end of file diff --git a/backend/locales/cs/ui.systems.timers.json b/backend/locales/cs/ui.systems.timers.json new file mode 100644 index 000000000..9c2169007 --- /dev/null +++ b/backend/locales/cs/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "Nový časovač", + "empty": "Žádný časovač nebyl vytvořen.", + "emptyAfterSearch": "Žádný časovač nebyl nalezen pro tvé hledání \"$search\".", + "add_response": "Přidat odpověď", + "settings": { + "enabled": "Status" + }, + "warning": "Tato akce nemůže být vrácena!" +} \ No newline at end of file diff --git a/backend/locales/cs/ui.widgets.customvariables.json b/backend/locales/cs/ui.widgets.customvariables.json new file mode 100644 index 000000000..ff440ba3a --- /dev/null +++ b/backend/locales/cs/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "Vlastní proměnné nenalezeny, přidejte je v registru vlastních proměnných", + "add-variable-into-watchlist": "Přidat proměnnou do seznamu sledování", + "watchlist": "Seznam sledování" +} \ No newline at end of file diff --git a/backend/locales/cs/ui.widgets.randomizer.json b/backend/locales/cs/ui.widgets.randomizer.json new file mode 100644 index 000000000..a06a165b7 --- /dev/null +++ b/backend/locales/cs/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "Náhodný výběr nenalezen, přidejte je v registru náhodného výběru", + "add-randomizer-to-widget": "Přidat náhodný výběr do widgetu" +} \ No newline at end of file diff --git a/backend/locales/cs/ui/categories.json b/backend/locales/cs/ui/categories.json new file mode 100644 index 000000000..4198a3c6b --- /dev/null +++ b/backend/locales/cs/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Oznámení", + "keys": "Klíče", + "currency": "Měna", + "general": "Obecné", + "settings": "Nastavení", + "commands": "Příkazy", + "bot": "Bot", + "channel": "Kanál", + "connection": "Připojení", + "chat": "Chat", + "graceful_exit": "Plánované vypnutí", + "rewards": "Odměny", + "levels": "Úrovně", + "notifications": "Oznámení", + "options": "Možnosti", + "comboBreakMessages": "Zprávy přerušení komba", + "hypeMessages": "Hype zprávy", + "messages": "Zprávy", + "results": "Výsledky", + "customization": "Přizpůsobení", + "status": "Status", + "mapping": "Mapování", + "player": "Hráč", + "stats": "Statistiky", + "api": "API", + "token": "Token", + "text": "Text", + "custom_texts": "Vlastní texty", + "credits": "Závěrečné titulky", + "show": "Zobrazit", + "social": "Sociální sítě", + "explosion": "Exploze", + "fireworks": "Ohňostroj", + "test": "Otestovat", + "emotes": "Emotikony", + "default": "Výchozí", + "urls": "Adresy URL", + "conversion": "Konverze", + "xp": "XP", + "caps_filter": "Filtr kapitálek", + "color_filter": "Filtr kurzívy (/me)", + "links_filter": "Filtr odkazů", + "symbols_filter": "Filtr symbolů", + "longMessage_filter": "Filtr délky zprávy", + "spam_filter": "Filtr spamování", + "emotes_filter": "Filtr emotikonů", + "warnings": "Varování", + "reset": "Reset", + "reminder": "Upozornění", + "eligibility": "Způsobilost", + "join": "Přidat se", + "luck": "Štěstí", + "lists": "Seznamy", + "me": "!me", + "emotes_combo": "Kombo emotů", + "tmi": "tmi", + "oauth": "oAuth", + "eventsub": "eventSub", + "rules": "pravidla" +} \ No newline at end of file diff --git a/backend/locales/cs/ui/core/currency.json b/backend/locales/cs/ui/core/currency.json new file mode 100644 index 000000000..38128be6d --- /dev/null +++ b/backend/locales/cs/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Základní měna" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/core/general.json b/backend/locales/cs/ui/core/general.json new file mode 100644 index 000000000..dfb9b8bce --- /dev/null +++ b/backend/locales/cs/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Jazyk bota", + "numberFormat": "Formát čísel v chatu", + "gracefulExitEachXHours": { + "title": "Automatické vypnutí každých X hodin", + "help": "0 - vypnuto" + }, + "shouldGracefulExitHelp": "Zapnutí automatického vypnutí bota je doporučeno pokud váš bot běží neomezeně na vašem serveru. Bot by měl být spuštěn přes pm2 (nebo podobnou službu) nebo přes docker, abyste zajistili automatický restart. Bot se nevypne při online streamu." + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/core/oauth.json b/backend/locales/cs/ui/core/oauth.json new file mode 100644 index 000000000..96216543b --- /dev/null +++ b/backend/locales/cs/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Vlastníci", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "Uživatelské jméno", + "channelUsername": "Uživatelské jméno", + "botExpectedScopes": "Scopes", + "channelExpectedScopes": "Scopes" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/core/permissions.json b/backend/locales/cs/ui/core/permissions.json new file mode 100644 index 000000000..06800e83c --- /dev/null +++ b/backend/locales/cs/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Přidat novou skupinu oprávnění", + "higherPermissionHaveAccessToLowerPermissions": "Vyšší opravnění mají přistup k oprávněním nižším.", + "typeUsernameOrIdToSearch": "Vyhledej uživatelské jméno nebo ID", + "typeUsernameOrIdToTest": "Otestuj uživatelské jméno nebo ID", + "noUsersWereFound": "Nebyli nalezeni žádní uživatelé.", + "noUsersManuallyAddedToPermissionYet": "Žádný uživatel nebyl prozatím manuálně přiřazen k tomuto oprávnění.", + "done": "Hotovo", + "previous": "Předchozí", + "next": "Další", + "loading": "načítám", + "permissionNotFoundInDatabase": "Oprávnění nebylo nalezeno v databázi, před testováním uživatele uložte.", + "userHaveNoAccessToThisPermissionGroup": "Uživatel $username NEMÁ přístup k tomuto oprávnění.", + "userHaveAccessToThisPermissionGroup": "Uživatel $username MÁ přístup k tomuto oprávnění.", + "accessDirectlyThrough": "Přímý přístup přes", + "accessThroughHigherPermission": "Přístup přes vyšší oprávnění", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Něco se pokazilo, uživatel $username nebyl nalezen v databázi", + "permissionsGroups": "Skupiny oprávnění", + "allowHigherPermissions": "Povolit přístup přes vyšší oprávnění", + "type": "Typ", + "value": "Hodnota", + "watched": "Čas sledování v hodinách", + "followtime": "Sledovaný čas v měsících", + "points": "Body", + "tips": "Tipy", + "bits": "Bity", + "messages": "Zprávy", + "subtier": "Sub Tier (1, 2, nebo 3)", + "subcumulativemonths": "Souhrný počet sub měsíců", + "substreakmonths": "Subscribů v řadě", + "ranks": "Aktuální hodnost", + "level": "Aktuální úroveň", + "isLowerThan": "méně než", + "isLowerThanOrEquals": "méně než nebo rovno", + "equals": "rovno", + "isHigherThanOrEquals": "více než nebo rovno", + "isHigherThan": "více než", + "addFilter": "Přidat filtr", + "selectPermissionGroup": "Vyberte skupinu oprávnění", + "settings": "Nastavení", + "name": "Název", + "baseUsersSet": "Zákládní set uživatelů", + "manuallyAddedUsers": "Ručně přidaní uživatelé", + "manuallyExcludedUsers": "Ručně vyloučení uživatelé", + "filters": "Filtry", + "testUser": "Otestovat uživatele", + "none": "- žádné -", + "casters": "Vysílající", + "moderators": "Moderátoři", + "subscribers": "Subscribeři", + "vip": "VIP", + "viewers": "Diváci", + "followers": "Sledující" +} \ No newline at end of file diff --git a/backend/locales/cs/ui/core/socket.json b/backend/locales/cs/ui/core/socket.json new file mode 100644 index 000000000..b07185cf1 --- /dev/null +++ b/backend/locales/cs/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Zrušit všechna současná připojení (i to tvé)", + "accessTokenExpirationTime": "Čas expirace access tokenu (ve vteřinách)", + "refreshTokenExpirationTime": "Čas expirace refresh tokenu (ve vteřinách)", + "socketToken": { + "title": "Socket token", + "help": "Tento token vám dá plný admin přístup přes socket. Nesdílejte!" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/core/tmi.json b/backend/locales/cs/ui/core/tmi.json new file mode 100644 index 000000000..32549315d --- /dev/null +++ b/backend/locales/cs/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Seznam ignorovaných (ID nebo uživatelské jméno)", + "showWithAt": "Zobraz uzivatele s @", + "sendWithMe": "Posílat zprávy s /me", + "sendAsReply": "Odeslat zprávy bota jako odpovědi", + "mute": "Bot je ztišen", + "whisperListener": "Provádět příkazy pres whisper" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/core/tts.json b/backend/locales/cs/ui/core/tts.json new file mode 100644 index 000000000..d98c16adf --- /dev/null +++ b/backend/locales/cs/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Služba" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/core/twitch.json b/backend/locales/cs/ui/core/twitch.json new file mode 100644 index 000000000..7b1867f89 --- /dev/null +++ b/backend/locales/cs/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Vytvořit stream značku na událost" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/core/ui.json b/backend/locales/cs/ui/core/ui.json new file mode 100644 index 000000000..0ad0c4c4d --- /dev/null +++ b/backend/locales/cs/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Výchozí motiv", + "domain": { + "title": "Doména", + "help": "Formát bez http/https: yourdomain.com nebo your.domain.com" + }, + "percentage": "Rozdíl v procentech u statistik", + "shortennumbers": "Zkrácený formát čísel", + "showdiff": "Zobraz rozdíly", + "enablePublicPage": "Povolit veřejnou stránku" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/core/updater.json b/backend/locales/cs/ui/core/updater.json new file mode 100644 index 000000000..08b84e3d8 --- /dev/null +++ b/backend/locales/cs/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Automaticky aktualizovat, pokud je k dispozici novější verze" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/errors.json b/backend/locales/cs/ui/errors.json new file mode 100644 index 000000000..fcbed210f --- /dev/null +++ b/backend/locales/cs/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Neočekávané chyby při ověření", + "isNotEmpty": "$property je povinný údaj.", + "minLength": "$property musí být delší nebo rovna $constraint1 znakům.", + "isPositive": "$property musí být větší než 0", + "isCommand": "$property musí začínat s !", + "isCommandOrCustomVariable": "$property musí začínat s ! nebo $_", + "isCustomVariable": "$property musí začínat $_", + "min": "$property musí být minimálně $constraint1", + "max": "$property musí být menší nebo rovno $constraint1", + "isInt": "$property musí být celé číslo", + "this_value_must_be_a_positive_number_and_greater_then_0": "Tato hodnota musí být kladné číslo nebo větší než 0", + "command_must_start_with_!": "Příkaz musí začít !", + "this_value_must_be_a_positive_number_or_0": "Tato hodnota musí být kladné číslo nebo 0", + "value_cannot_be_empty": "Hodnota nemůže být prázdná", + "minLength_of_value_is": "Minimální délka je $value.", + "this_currency_is_not_supported": "Tato měna není podporována", + "something_went_wrong": "Něco se pokazilo", + "permission_must_exist": "Oprávnění musí existovat", + "minValue_of_value_is": "Minimální hodnota je $value.", + "value_cannot_be": "Hodnota nemůže být $value.", + "invalid_format": "Nesprávný formát hodnoty.", + "invalid_regexp_format": "Toto není validní regex.", + "owner_and_broadcaster_oauth_is_not_set": "Vlastník a caster nemá nastaven správně oAuth", + "channel_is_not_set": "Kanál není nastaven", + "please_set_your_broadcaster_oauth_or_owners": "Prosím nastavte oauth castera nebo vlastníky, nebo všichni uživatelé budou mít přístup k tomuto dashboardu a budou považováni za castery.", + "new_update_available": "Nová aktualizace k dispozici", + "new_bot_version_available_at": "Nová verze bota {version} je k dispozici na {link}.", + "one_of_inputs_must_be_set": "Alespoň jedno vstupní pole musí být nastaveno" +} \ No newline at end of file diff --git a/backend/locales/cs/ui/games/duel.json b/backend/locales/cs/ui/games/duel.json new file mode 100644 index 000000000..c1ce938aa --- /dev/null +++ b/backend/locales/cs/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Status", + "cooldown": "Cooldown", + "duration": { + "title": "Čas trvání", + "help": "Minuty" + }, + "minimalBet": "Minimální sázka", + "bypassCooldownByOwnerAndMods": "Bypass cooldownu majitelem a moderátory" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/games/gamble.json b/backend/locales/cs/ui/games/gamble.json new file mode 100644 index 000000000..b7aaccf62 --- /dev/null +++ b/backend/locales/cs/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "minimalBet": "Minimální sázka", + "chanceToWin": { + "title": "Šance na výhru", + "help": "Procenta" + }, + "enableJackpot": "Povolit jackpot", + "chanceToTriggerJackpot": "Šance na výhru jackpotu v %", + "maxJackpotValue": "Maximální hodnota jackpotu", + "lostPointsAddedToJackpot": "Kolik ztracených bodů by mělo být přidáno do jackpotu v %" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/games/heist.json b/backend/locales/cs/ui/games/heist.json new file mode 100644 index 000000000..ab207879b --- /dev/null +++ b/backend/locales/cs/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Loupež", + "settings": { + "enabled": "Status", + "showMaxUsers": "Maximální počet uživatelů, kteří se mají zobrazit ve výplatě", + "copsCooldownInMinutes": { + "title": "Čas mezi loupeži", + "help": "Minuty" + }, + "entryCooldownInSeconds": { + "title": "Čas na vstup do loupeže", + "help": "Sekundy" + }, + "started": "Zpráva o startu loupeže", + "nextLevelMessage": "Zpráva při dosažení další úrovně", + "maxLevelMessage": "Zpráva při dosažení maximální úrovně", + "copsOnPatrol": "Zpráva, když je loupež v cooldownu", + "copsCooldown": "Oznámení bota, kdy může být spuštěna loupež", + "singleUserSuccess": "Zpráva o úspěchu jednoho uživatele", + "singleUserFailed": "Zpráva o neúspěchu jednoho uživatele", + "noUser": "Zpráva, když se nikdo nezúčastnil loupeže" + }, + "message": "Zpráva", + "winPercentage": "Šance výhry", + "payoutMultiplier": "Násobitel výplaty", + "maxUsers": "Maximální počet uživatelů pro úroveň", + "percentage": "Procento výher", + "noResultsFound": "Nebyly nalezeny žádné výsledky. Kliknutím na tlačítko níže přidáte nový výsledek.", + "noLevelsFound": "Nebyly nalezeny žádné úrovně. Klepnutím na tlačítko níže přidáte novou úroveň." +} \ No newline at end of file diff --git a/backend/locales/cs/ui/games/roulette.json b/backend/locales/cs/ui/games/roulette.json new file mode 100644 index 000000000..dc177cd65 --- /dev/null +++ b/backend/locales/cs/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Čas trvání timeoutu", + "help": "Sekundy" + }, + "winnerWillGet": "Kolik bodů bude přidáno při výhře", + "loserWillLose": "Kolik bodů bude ztraceno při prohře" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/games/seppuku.json b/backend/locales/cs/ui/games/seppuku.json new file mode 100644 index 000000000..f0d2ab1a1 --- /dev/null +++ b/backend/locales/cs/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Čas trvání timeoutu", + "help": "Sekundy" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/integrations/discord.json b/backend/locales/cs/ui/integrations/discord.json new file mode 100644 index 000000000..348f3eacb --- /dev/null +++ b/backend/locales/cs/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Status", + "guild": "Server", + "listenAtChannels": "Poslouchat příkazy na tomto kanálu", + "sendOnlineAnnounceToChannel": "Posílat online oznámení do tohoto kanálu", + "onlineAnnounceMessage": "Zpráva v online oznámení (může obsahovat zmínky)", + "sendAnnouncesToChannel": "Nastavení odesílání oznámení do kanálů", + "deleteMessagesAfterWhile": "Po chvíli smazat zprávy", + "clientId": "ClientId", + "token": "Token", + "joinToServerBtn": "Kliknutím připojíte bota na váš server", + "joinToServerBtnDisabled": "Uložte změny pro připojení bota k serveru", + "cannotJoinToServerBtn": "Nastavte token a clientId, abyste mohli připojit bota na váš server", + "noChannelSelected": "není vybrán žádný kanál", + "noRoleSelected": "není vybrána žádná role", + "noGuildSelected": "nebyl vybrán žádný server", + "noGuildSelectedBox": "Vyberte server, který bot bude používat a zobrazí se vám další nastavení", + "onlinePresenceStatusDefault": "Výchozí stav", + "onlinePresenceStatusDefaultName": "Výchozí stavová zpráva", + "onlinePresenceStatusOnStream": "Stav při vysílání", + "onlinePresenceStatusOnStreamName": "Stavová zpráva při vysílání", + "ignorelist": { + "title": "Seznam ignorovaných", + "help": "jméno, jméno#0000 nebo ID" + } + } +} diff --git a/backend/locales/cs/ui/integrations/donatello.json b/backend/locales/cs/ui/integrations/donatello.json new file mode 100644 index 000000000..14aa06400 --- /dev/null +++ b/backend/locales/cs/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Získej svůj token na https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/integrations/donationalerts.json b/backend/locales/cs/ui/integrations/donationalerts.json new file mode 100644 index 000000000..5d9d919fe --- /dev/null +++ b/backend/locales/cs/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Status", + "access_token": { + "title": "Access Token", + "help": "Získej svůj access token na https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Refresh token" + }, + "accessTokenBtn": "Generátor access a refresh tokenu pro DonationAlerts" + } +} diff --git a/backend/locales/cs/ui/integrations/kofi.json b/backend/locales/cs/ui/integrations/kofi.json new file mode 100644 index 000000000..67d96947d --- /dev/null +++ b/backend/locales/cs/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Ověřovací token", + "help": "Získejte ověřovací token na https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "URL webhooku", + "help": "Nastavte URL webhooku na https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL musí mít HTTPS", + "origin": "Nelze použít localhost pro webhooky" + } + } + } +} diff --git a/backend/locales/cs/ui/integrations/lastfm.json b/backend/locales/cs/ui/integrations/lastfm.json new file mode 100644 index 000000000..025f89b8b --- /dev/null +++ b/backend/locales/cs/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": "API klíč", + "username": "Uživatelské jméno" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/integrations/obswebsocket.json b/backend/locales/cs/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..dfee064de --- /dev/null +++ b/backend/locales/cs/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Status", + "accessBy": { + "title": "Přístup přes", + "help": "Direct - přímé připojení z bota | Overlay - připojení přes overlay z browser source" + }, + "address": "Adresa", + "password": "Heslo" + }, + "noSourceSelected": "Není vybrán žádný zdroj", + "noSceneSelected": "Není vybrána žádná scéna", + "empty": "Zatím nebyly vytvořeny žádné sady akcí.", + "emptyAfterSearch": "Při hledání \"$search\" nenalezeny žádné sady akcí.", + "command": "Příkaz", + "new": "Vytvořit novou sadu akcí OBS Websocketu", + "actions": "Akce", + "name": { + "name": "Název" + }, + "mute": "Ztlumit", + "unmute": "Zrušit ztlumení", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Počkat X milisekund" + }, + "Log": { + "name": "Zaznamenat zprávu" + }, + "StartRecording": { + "name": "StartRecording" + }, + "StopRecording": { + "name": "StopRecording" + }, + "PauseRecording": { + "name": "PauseRecording" + }, + "ResumeRecording": { + "name": "ResumeRecording" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/integrations/pubg.json b/backend/locales/cs/ui/integrations/pubg.json new file mode 100644 index 000000000..767694cc5 --- /dev/null +++ b/backend/locales/cs/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "API klíč", + "help": "Získej svůj klíč na https://developer.pubg.com/" + }, + "platform": "Platforma", + "playerName": "Jméno hráče", + "playerId": "ID hráče", + "seasonId": { + "title": "ID sezóny", + "help": "ID aktuální sezóny se načítá každou hodinu." + }, + "rankedGameModeStatsCustomization": "Přizpůsobená zpráva pro hodnocené statistiky", + "gameModeStatsCustomization": "Přizpůsobená zpráva pro normální statistiky" + }, + "click_to_fetch": "Klikněte pro načtení", + "something_went_wrong": "Něco se pokazilo!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Statistiky se automaticky aktualizují každých 10 minut.", + "player_stats_ranked": "Statistiky hráče (hodnocené)", + "player_stats": "Statistiky hráče" +} diff --git a/backend/locales/cs/ui/integrations/qiwi.json b/backend/locales/cs/ui/integrations/qiwi.json new file mode 100644 index 000000000..e623529a6 --- /dev/null +++ b/backend/locales/cs/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "secretToken": { + "title": "Secret token", + "help": "Získej secret token na Qiwi Donate dashboardu settings->click show secret token" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/integrations/responsivevoice.json b/backend/locales/cs/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..55e79d584 --- /dev/null +++ b/backend/locales/cs/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Klíč (key)", + "help": "Získej svůj klíč na http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/integrations/spotify.json b/backend/locales/cs/ui/integrations/spotify.json new file mode 100644 index 000000000..8e329367c --- /dev/null +++ b/backend/locales/cs/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Umělci", + "settings": { + "enabled": "Status", + "songRequests": "Požadavky na skladbu", + "fetchCurrentSongWhenOffline": { + "title": "Načíst aktuální skladbu, i když je stream offline", + "help": "Doporučuje se, aby byla tato funkce vypnuta, aby nedošlo k dosažení limitů API" + }, + "allowApprovedArtistsOnly": "Povolit pouze schválené umělce", + "approvedArtists": { + "title": "Schválení umělci", + "help": "Jméno nebo SpotifyURI umělce, jedna položka na řádek" + }, + "queueWhenOffline": { + "title": "Zařadit skladbu do fronty, i když je stream offline", + "help": "Doporučujeme mít toto vypnuto, abyste se vyhnuli přidáváním skladeb do fronty jen při poslechu hudby" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "Vynucené ID zařízení", + "help": "Prázdné = vypnuté, vynucené spotify ID zařízení, které bude použito pro skladby ve frontě. Zkontrolujte logy aktuálního aktivního zařízení nebo použijte tlačítko při přehrávání písně po dobu nejméně 10 sekund." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Formát", + "help": "Dostupné proměnné: $song, $artist, $artists" + }, + "username": "Autorizovaný uživatel", + "revokeBtn": "Zrušit autorizaci uživatele", + "authorizeBtn": "Autorizovat uživatele", + "scopes": "Scopes", + "playlistToPlay": { + "title": "Spotify URI hlavního playlistu", + "help": "Pokud bude nastaveno, po requestu bude hrát tento playlist" + }, + "continueOnPlaylistAfterRequest": "Pokračovat v přehrávání playlistu po požadavku na skladbu", + "notify": "Odeslat zprávu při změně skladby" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/integrations/streamelements.json b/backend/locales/cs/ui/integrations/streamelements.json new file mode 100644 index 000000000..db9f6d66d --- /dev/null +++ b/backend/locales/cs/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "jwtToken": { + "title": "JWT token", + "help": "Získej JWT token na StreamElements Channels setting a přepni Show secrets" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/integrations/streamlabs.json b/backend/locales/cs/ui/integrations/streamlabs.json new file mode 100644 index 000000000..daf8b4da3 --- /dev/null +++ b/backend/locales/cs/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "socketToken": { + "title": "Socket token", + "help": "Získej socket token z Streamlabs dashboard API settings->API tokens->Your Socket API Token" + }, + "accessToken": { + "title": "Access token", + "help": "Získej svůj access token na https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "Generátor access token pro StreamLabs" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/integrations/tipeeestream.json b/backend/locales/cs/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..f2d3d6715 --- /dev/null +++ b/backend/locales/cs/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "API klíč", + "help": "Získejte socket token z tipeeestream dashboard -> API -> Your API key" + } + } +} diff --git a/backend/locales/cs/ui/integrations/twitter.json b/backend/locales/cs/ui/integrations/twitter.json new file mode 100644 index 000000000..940fd5589 --- /dev/null +++ b/backend/locales/cs/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "consumerKey": "Consumer Key (API Key)", + "consumerSecret": "Consumer Secret (API Secret)", + "accessToken": "Access Token", + "secretToken": "Access Token Secret" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/managers.json b/backend/locales/cs/ui/managers.json new file mode 100644 index 000000000..8da03171e --- /dev/null +++ b/backend/locales/cs/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "Historie událostí uživatele", + "hostAndRaidViewersCount": "Diváků: $value", + "receivedSubscribeFrom": "Přijaté předplatné od $value", + "giftedSubscribeTo": "Darované předplatné $value" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/overlays/alerts.json b/backend/locales/cs/ui/overlays/alerts.json new file mode 100644 index 000000000..4b6f68136 --- /dev/null +++ b/backend/locales/cs/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Udělat cache galerie", + "galleryCacheLimitInMb": "Maximální velikost položky (v MB)" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/overlays/clips.json b/backend/locales/cs/ui/overlays/clips.json new file mode 100644 index 000000000..63065acaf --- /dev/null +++ b/backend/locales/cs/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Hlasitost", + "cClipsFilter": "Filter klipu", + "cClipsLabel": "Ukazát 'clip' popisek" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/overlays/clipscarousel.json b/backend/locales/cs/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..0fa074a82 --- /dev/null +++ b/backend/locales/cs/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Za časové období (dny)", + "cClipsNumOfClips": "Počet klipů", + "cClipsTimeToNextClip": "Čas do dalšího klipu (s)" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/overlays/credits.json b/backend/locales/cs/ui/overlays/credits.json new file mode 100644 index 000000000..5c0db3f57 --- /dev/null +++ b/backend/locales/cs/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Rychlost titulek", + "cCreditsAggregated": "Souhrnné titulky", + "cShowGameThumbnail": "Zobrazit miniaturu hry", + "cShowFollowers": "Zobrazit followery", + "cShowRaids": "Zobrazit raidy", + "cShowSubscribers": "Zobrazit subscribery", + "cShowSubgifts": "Zobrazit darované suby", + "cShowSubcommunitygifts": "Zobrazit suby darované komunitě", + "cShowResubs": "Zobrazit resuby", + "cShowCheers": "Zobrazit cheery", + "cShowClips": "Zobrazit klipy", + "cShowTips": "Zobrazit tipy", + "cTextLastMessage": "Poslední zpráva", + "cTextLastSubMessage": "Poslední podzpráva", + "cTextStreamBy": "Streamoval", + "cTextFollow": "Follow od", + "cTextRaid": "Raidování", + "cTextCheer": "Cheer od", + "cTextSub": "Subscribe od", + "cTextResub": "Resub od", + "cTextSubgift": "Darovaný sub", + "cTextSubcommunitygift": "Sub darovaný komunitě", + "cTextTip": "Tip od", + "cClipsPeriod": "Časové období", + "cClipsCustomPeriodInDays": "Vlastní časové období (dny)", + "cClipsNumOfClips": "Počet klipů", + "cClipsShouldPlay": "Bude klipy přehrávány", + "cClipsVolume": "Hlasitost" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/overlays/emotes.json b/backend/locales/cs/ui/overlays/emotes.json new file mode 100644 index 000000000..a7a1d605d --- /dev/null +++ b/backend/locales/cs/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Smazat cache", + "hypeMessagesEnabled": "Zobrazit hype zprávy v chatu", + "btnTestExplosion": "Otestovat výbuch emotikonů", + "btnTestEmote": "Testovat emote", + "btnTestFirework": "Otestovat ohňostroj emotikonů", + "cEmotesSize": "Velikost emotikonů", + "cEmotesMaxEmotesPerMessage": "Maximální počet emotikonů na zprávu", + "cEmotesMaxRotation": "Maximální rotace emotu", + "cEmotesOffsetX": "Maximální posun osy X", + "cEmotesAnimation": "Animace", + "cEmotesAnimationTime": "Doba trvání animace", + "cExplosionNumOfEmotes": "Počet emotikonů", + "cExplosionNumOfEmotesPerExplosion": "Počet emotikonů za explozi", + "cExplosionNumOfExplosions": "Počet explozí", + "enableEmotesCombo": "Povolit kombo emote", + "comboBreakMessages": "Zprávy přerušení komba", + "threshold": "Práh", + "noMessagesFound": "Žádné zprávy nenalezeny.", + "message": "Zpráva", + "showEmoteInOverlayThreshold": "Minimální práh počtu zpráv pro zobrazení emote v overlayi", + "hideEmoteInOverlayAfter": { + "title": "Skrýt emote v overlayi po nečinnosti", + "help": "Skryje emote v overlayi po určité době v sekundách" + }, + "comboCooldown": { + "title": "Cooldown komba", + "help": "Cooldown komba ve vteřinách" + }, + "comboMessageMinThreshold": { + "title": "Minimální práh počtu zpráv", + "help": "Minimální práh počtu zpráv pro počítání emote jako kombo (do té doby se nespustí cooldown)" + }, + "comboMessages": "Kombo zprávy" + }, + "hype": { + "5": "Pojďme do toho! Zatím máme kombo $amountx $emote! SeemsGood", + "15": "Pokračujte! Získáme více než $amountx $emote? TriHard" + }, + "message": { + "3": "$amountx $emote kombo", + "5": "$amountx $emote kombo SeemsGood", + "10": "$amountx $emote kombo PogChamp", + "15": "$amountx $emote kombo TriHard", + "20": "$sender zničil $amountx $emote kombo! NotLikeThis" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/overlays/polls.json b/backend/locales/cs/ui/overlays/polls.json new file mode 100644 index 000000000..dc63c37a0 --- /dev/null +++ b/backend/locales/cs/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Motiv", + "cDisplayHideAfterInactivity": "Skrýt při neaktivitě", + "cDisplayAlign": "Zarovnání", + "cDisplayInactivityTime": { + "title": "Neaktivita po", + "help": "v milisekundách" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/overlays/texttospeech.json b/backend/locales/cs/ui/overlays/texttospeech.json new file mode 100644 index 000000000..d63288238 --- /dev/null +++ b/backend/locales/cs/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "Nenastavili jste správně ResponiveVoice key", + "voice": { + "title": "Hlas", + "help": "Pokud po aktualizaci klíče ResponsiveVoice nejsou hlasy správně načteny, zkuste obnovit prohlížeč" + }, + "volume": "Hlasitost", + "rate": "Tempo", + "pitch": "Výška", + "triggerTTSByHighlightedMessage": "Text na řeč bude spuštěn zvýrazněnou zprávou" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/properties.json b/backend/locales/cs/ui/properties.json new file mode 100644 index 000000000..75117b5ea --- /dev/null +++ b/backend/locales/cs/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Příkaz", + "variableName": "Název proměnné", + "price": "Cena (body)", + "priceBits": "Cena (bity)", + "thisvalue": "Tato hodnota", + "promo": { + "shoutoutMessage": "Zpráva vyhlášení", + "enableShoutoutMessage": "Poslat zprávu vyhlášení do chatu" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/registry/alerts.json b/backend/locales/cs/ui/registry/alerts.json new file mode 100644 index 000000000..bf9b9a0de --- /dev/null +++ b/backend/locales/cs/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Status", + "testDlg": { + "alertTester": "Tester upozornění", + "command": "Příkaz", + "username": "Uživatelské jméno", + "recipient": "Příjemce", + "message": "Zpráva", + "tier": "Tier", + "amountOfViewers": "Počet diváků", + "amountOfBits": "Počet bitů", + "amountOfGifts": "Počet obdarovaných", + "amountOfMonths": "Počet měsíců", + "amountOfTips": "Spropitné", + "event": "Událost", + "service": "Služba" + }, + "empty": "Registr alertů je prázdný, vytvořte nový.", + "emptyAfterSearch": "Registr alertů je prázdný při hledání \"$search\"", + "revertcode": "Vrátit kód do výchozího stavu", + "name": { + "name": "Název", + "placeholder": "Zadejte název svých alertů" + }, + "alertDelayInMs": { + "name": "Zpoždění alertů" + }, + "parryEnabled": { + "name": "Parírování alertů" + }, + "parryDelay": { + "name": "Zpoždění parírování alertu" + }, + "profanityFilterType": { + "name": "Filtr vulgarit", + "disabled": "Vypnuto", + "replace-with-asterisk": "Zaměnit za hvězdičky", + "replace-with-happy-words": "Zaměnit za šťastná slova", + "hide-messages": "Schovat zprávu", + "disable-alerts": "Vypnout alerty" + }, + "loadStandardProfanityList": "Nahrát standardní seznam vulgarit", + "customProfanityList": { + "name": "Vlastní list vulgarit", + "help": "Slova musí být oddělena čárkou." + }, + "event": { + "follow": "Follow", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Subgift to community", + "tip": "Spropitné", + "raid": "Raid", + "custom": "Vlastní", + "promo": "Promo", + "rewardredeem": "Vyplácení odměn" + }, + "title": { + "name": "Název varianty", + "placeholder": "Zadejte název varianty" + }, + "variant": { + "name": "Výskyt varianty" + }, + "filter": { + "name": "Filtr", + "operator": "Operátor", + "rule": "Pravidlo", + "addRule": "Přidat pravidlo", + "addGroup": "Přidat skupinu", + "comparator": "Porovnání", + "value": "Hodnota", + "valueSplitByComma": "Hodnoty rozdělené čárkou (např. val1, val2)", + "isEven": "je sudé", + "isOdd": "je liché", + "lessThan": "menší než", + "lessThanOrEqual": "menší nebo rovno", + "contain": "obsahuje", + "contains": "obsahuje", + "equal": "rovná se", + "notEqual": "nerovná se", + "present": "je přítomen", + "includes": "zahrnuje", + "greaterThan": "větší než", + "greaterThanOrEqual": "větší nebo rovno", + "noFilter": "žádný filtr" + }, + "speed": { + "name": "Rychlost" + }, + "maxTimeToDecrypt": { + "name": "Maximální čas dešifrování" + }, + "characters": { + "name": "Znaky" + }, + "random": "Náhodně", + "exact-amount": "Přesný počet", + "greater-than-or-equal-to-amount": "Více nebo stejně jako počet", + "tier-exact-amount": "Tier je přesně", + "tier-greater-than-or-equal-to-amount": "Tier je vyšší nebo roven", + "months-exact-amount": "Počet měsíců je přesně", + "months-greater-than-or-equal-to-amount": "Počet měsíců je vyšší nebo rovno", + "gifts-exact-amount": "Počet obdarovaných je přesně", + "gifts-greater-than-or-equal-to-amount": "Počet obdarovaných je vyšší nebo roven", + "very-rarely": "Velmi zřídka", + "rarely": "Zřídka", + "default": "Normálně", + "frequently": "Často", + "very-frequently": "Velmi často", + "exclusive": "Výhradní", + "messageTemplate": { + "name": "Šablona zprávy", + "placeholder": "Zadejte svou šablonu zprávy", + "help": "Dostupné proměnné: {name}, {amount} (bits, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). Pokud přidáte | (viz promo), tak se zobrazí v sekvenci za sebou." + }, + "ttsTemplate": { + "name": "TTS šablona", + "placeholder": "Nastavte si šablonu TTS", + "help": "Dostupné proměnné: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Animace textu" + }, + "animationType": { + "name": "Typ animace" + }, + "animationIn": { + "name": "Vstupní animace" + }, + "animationOut": { + "name": "Výstupní animace" + }, + "alertDurationInMs": { + "name": "Trvání alertu" + }, + "alertTextDelayInMs": { + "name": "Zpoždění textu alertu" + }, + "layoutPicker": { + "name": "Rozvržení" + }, + "loop": { + "name": "Přehrát ve smyčce" + }, + "scale": { + "name": "Měřítko" + }, + "translateY": { + "name": "Posunout -nahoru / +dolů" + }, + "translateX": { + "name": "Posunout -vlevo / +vpravo" + }, + "image": { + "name": "Obrázek / Video(.webm)", + "setting": "Nastavení obrázku / videa (.webm)" + }, + "sound": { + "name": "Zvuk", + "setting": "Nastavení zvuku" + }, + "soundVolume": { + "name": "Hlasitost upozornění" + }, + "enableAdvancedMode": "Zapnout pokročilý mód", + "font": { + "setting": "Nastavení fontu", + "name": "Font", + "overrideGlobal": "Přepsat globální nastavení písma", + "align": { + "name": "Zarovnání", + "left": "Vlevo", + "center": "Uprostřed", + "right": "Vpravo" + }, + "size": { + "name": "Velikost fontu" + }, + "weight": { + "name": "Tloušťka fontu" + }, + "borderPx": { + "name": "Ohraničení fontu" + }, + "borderColor": { + "name": "Barva ohraničení fontu" + }, + "color": { + "name": "Barva fontu" + }, + "highlightcolor": { + "name": "Barva zvýraznění" + } + }, + "minAmountToShow": { + "name": "Minimální počet k zobrazení" + }, + "minAmountToPlay": { + "name": "Minimální počet k přehrání" + }, + "allowEmotes": { + "name": "Povolit emotikony" + }, + "message": { + "setting": "Nastavení zprávy" + }, + "voice": "Hlas", + "keepAlertShown": "Alert zůstane zobrazen během TTS", + "skipUrls": "Přeskočit URL během TTS", + "volume": "Hlasitost", + "rate": "Tempo", + "pitch": "Výška", + "test": "Otestovat", + "tts": { + "setting": "Nastavení TTS" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/registry/goals.json b/backend/locales/cs/ui/registry/goals.json new file mode 100644 index 000000000..3d257f2a9 --- /dev/null +++ b/backend/locales/cs/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Přidat skupinu cílů", + "addGoal": "Přidat cíl", + "newGoal": "Nový cíl", + "newGoalGroup": "Nová skupina cílů", + "goals": "Cíle", + "general": "Obecné", + "display": "Zobrazení", + "fontSettings": "Nastavení písma", + "barSettings": "Nastavení sloupce", + "selectGoalOnLeftSide": "Vyberte nebo přidejte cíl na levé straně", + "input": { + "description": { + "title": "Popis" + }, + "goalAmount": { + "title": "Částka cíle" + }, + "countBitsAsTips": { + "title": "Počítat Bity s Tipy" + }, + "currentAmount": { + "title": "Aktuální částka" + }, + "endAfter": { + "title": "Konec po" + }, + "endAfterIgnore": { + "title": "Tento cíl nevyprší" + }, + "borderPx": { + "title": "Ohraničení", + "help": "Velikost ohraničení je v pixelech" + }, + "barHeight": { + "title": "Výška sloupce", + "help": "Výška sloupce je v pixelech" + }, + "color": { + "title": "Barva" + }, + "borderColor": { + "title": "Barva ohraničení" + }, + "backgroundColor": { + "title": "Barva pozadí" + }, + "type": { + "title": "Typ" + }, + "nameGroup": { + "title": "Název této skupiny" + }, + "name": { + "title": "Název tohoto cíle" + }, + "displayAs": { + "title": "Zobrazit jako", + "help": "Nastaví, jak se má zobrazit cílová skupina" + }, + "durationMs": { + "title": "Doba trvání", + "help": "Tato hodnota je v milisekundách", + "placeholder": "Jak dlouho má být cíl zobrazen" + }, + "animationInMs": { + "title": "Doba trvání vstupní animace", + "help": "Tato hodnota je v milisekundách", + "placeholder": "Nastavte dobu trvání vstupní animace" + }, + "animationOutMs": { + "title": "Doba trvání výstupní animace", + "help": "Tato hodnota je v milisekundách", + "placeholder": "Nastavte dobu trvání výstupní animace" + }, + "interval": { + "title": "Jaký interval se má počítat" + }, + "spaceBetweenGoalsInPx": { + "title": "Prostor mezi cíly", + "help": "Tato hodnota je v pixelech", + "placeholder": "Nastavte prostor mezi cíly" + } + }, + "groupSettings": "Nastavení skupiny" +} \ No newline at end of file diff --git a/backend/locales/cs/ui/registry/overlays.json b/backend/locales/cs/ui/registry/overlays.json new file mode 100644 index 000000000..7cc16018c --- /dev/null +++ b/backend/locales/cs/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Vytvořit nové mapování overlaye", + "emptyMapping": "Zatím nebylo vytvořeno žádné mapování odkazů pro overlaye.", + "allowedIPs": { + "name": "Povolené IP adresy", + "help": "Povolit přístup ze sady IP oddělených novým řádkem" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/registry/plugins.json b/backend/locales/cs/ui/registry/plugins.json new file mode 100644 index 000000000..42599b8b4 --- /dev/null +++ b/backend/locales/cs/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "Tento uzel musí být propojen s posluchači s atributy odesílatele" + }, + "filter": { + "permission": { + "name": "Filtr oprávnění" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Posluchač událostí", + "type": { + "twitchChatMessage": "Twitch zpráva chatu", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat vymazán", + "twitchCommand": "Příkaz na Twitch", + "twitchFollow": "Nový Twitch sledující", + "twitchSubscription": "Nový Twitch odběr", + "twitchSubgift": "Nový dárek odběru na Twitch", + "twitchSubcommunitygift": "Nový komunitní dárek odběrů na Twitch", + "twitchResub": "Nové opakující se předplatné Twitch", + "twitchGameChanged": "Změna kategorie kanálu", + "twitchStreamStarted": "Twitch stream byl spuštěn", + "twitchStreamStopped": "Twitch stream byl zastaven", + "twitchRewardRedeem": "Twitch odměna byla vyplacena", + "twitchRaid": "Příchozí Twitch nájezd", + "tip": "Spropitné od uživatele", + "botStarted": "Bot spuštěn" + }, + "command": { + "add-parameter": "Přidat parametr", + "parameters": "Parametry", + "order-is-important": "pořadí je důležité" + } + }, + "others": { + "idle": { + "name": "Nečinný" + } + }, + "output": { + "log": { + "name": "Zaznamenat zprávu" + }, + "timeout-user": { + "name": "Vykopnout uživatele" + }, + "ban-user": { + "name": "Zabanovat uživatele" + }, + "send-twitch-message": { + "name": "Odeslat Twitch zprávu" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/registry/randomizer.json b/backend/locales/cs/ui/registry/randomizer.json new file mode 100644 index 000000000..612dda359 --- /dev/null +++ b/backend/locales/cs/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Přidat náhodný generátor", + "form": { + "name": "Název", + "command": "Příkaz", + "permission": "Oprávnění příkazu", + "simple": "Jednoduchý výběr", + "tape": "Páska", + "wheelOfFortune": "Kolo štěstí", + "type": "Typ", + "options": "Možnosti", + "optionsAreEmpty": "Možnosti jsou zatím prázdné.", + "color": "Barva", + "numOfDuplicates": "Počet duplikátů", + "minimalSpacing": "Minimální rozestup", + "groupUp": "Seskupit", + "ungroup": "Rozdělit", + "groupedWithOptionAbove": "Seskupeno s předchozí možností", + "generatedOptionsPreview": "Náhled vygenerovaných možností", + "probability": "Pravděpodobnost", + "tick": "Zvuk točícího kola" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/registry/textoverlay.json b/backend/locales/cs/ui/registry/textoverlay.json new file mode 100644 index 000000000..f7572ee7f --- /dev/null +++ b/backend/locales/cs/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Vytvořte nový textový overlay", + "title": "textový overlay", + "name": { + "placeholder": "Nastavte název textového overlaye" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/stats/commandcount.json b/backend/locales/cs/ui/stats/commandcount.json new file mode 100644 index 000000000..7ce00244e --- /dev/null +++ b/backend/locales/cs/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Příkaz", + "hour": "Hodina", + "day": "Den", + "week": "Týden", + "month": "Měsíc", + "year": "Rok", + "total": "Celkově" +} \ No newline at end of file diff --git a/backend/locales/cs/ui/systems/checklist.json b/backend/locales/cs/ui/systems/checklist.json new file mode 100644 index 000000000..0fd8d5633 --- /dev/null +++ b/backend/locales/cs/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "itemsArray": "Seznam" + }, + "check": "Checklist" +} \ No newline at end of file diff --git a/backend/locales/cs/ui/systems/howlongtobeat.json b/backend/locales/cs/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..aa7580e75 --- /dev/null +++ b/backend/locales/cs/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Status" + }, + "empty": "Zatím nebyly sledovány žádné hry.", + "emptyAfterSearch": "Žádné sledované hry nebyly nalezeny pro tvé hledání \"$search\".", + "when": "Vysíláno v", + "time": "Zaznamenaný čas", + "overallTime": "Celkový čas", + "offset": "Kompenzace sledovaného času", + "main": "Hlavní příběh", + "extra": "Hlavní příběh+Extra", + "completionist": "Kompletně vše", + "game": "Sledovaná hra", + "startedAt": "Sledování zahájeno v", + "updatedAt": "Poslední aktualizace", + "showHistory": "Zobrazit historii ($count)", + "hideHistory": "Skrýt historii ($count)", + "searchToAddNewGame": "Hledat pro přidání nové hry ke sledování" +} \ No newline at end of file diff --git a/backend/locales/cs/ui/systems/keywords.json b/backend/locales/cs/ui/systems/keywords.json new file mode 100644 index 000000000..ed42d2e6d --- /dev/null +++ b/backend/locales/cs/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "Nové Klíčové Slovo", + "empty": "Žádná klíčová slova nebyla vytvořena.", + "emptyAfterSearch": "Žádná klíčová slova nebyla nalezena pro tvé hledání \"$search\".", + "keyword": { + "name": "Klíčové slovo / Regulární výraz", + "placeholder": "Nastavte klíčové slovo nebo regulární výraz.", + "help": "Můžete použít regulární výrazy (bez ohledu na malá a velká písmena), např. hello.*|hi" + }, + "response": { + "name": "Odpověď", + "placeholder": "Nastavte odpověď bota zde." + }, + "error": { + "isEmpty": "Tato hodnota nesmí být prázdná" + }, + "no-responses-set": "Bez odpovědí", + "addResponse": "Nová odpověď", + "filter": { + "name": "filtr", + "placeholder": "Přidat filtr k této odpovědi" + }, + "warning": "Tato akce nemůže být vrácena!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/systems/levels.json b/backend/locales/cs/ui/systems/levels.json new file mode 100644 index 000000000..461eb5315 --- /dev/null +++ b/backend/locales/cs/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Status", + "conversionRate": "Konverzní kurz 1 XP pro x bodů", + "firstLevelStartsAt": "První úroveň začíná na XP", + "nextLevelFormula": { + "title": "Vzorec pro výpočet další úrovně", + "help": "Dostupné proměnné: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "Příklad úrovní bude obnoven po uložení", + "xpName": "Název", + "interval": "Minutový interval pro přidání XP online uživatelům při online streamu", + "offlineInterval": "Minutový interval pro přidání XP online uživatelům při offline streamu", + "messageInterval": "Kolik zpráv pro přidání XP", + "messageOfflineInterval": "Kolik zpráv pro přidání XP při offline streamu", + "perInterval": "Kolik XP přidat na online interval", + "perOfflineInterval": "Kolik XP přidat na offline interval", + "perMessageInterval": "Kolik XP přidat na interval zpráv", + "perMessageOfflineInterval": "Kolik XP přidat na offline interval zpráv" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/systems/polls.json b/backend/locales/cs/ui/systems/polls.json new file mode 100644 index 000000000..36ff8c0d3 --- /dev/null +++ b/backend/locales/cs/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Celkem hlasů", + "totalPoints": "Celkový počet bodů", + "closedAt": "Uzavřeno v", + "activeFor": "Aktivní po" +} \ No newline at end of file diff --git a/backend/locales/cs/ui/systems/scrim.json b/backend/locales/cs/ui/systems/scrim.json new file mode 100644 index 000000000..9a97a31d2 --- /dev/null +++ b/backend/locales/cs/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "waitForMatchIdsInSeconds": { + "title": "Interval k zadání ID zápasu", + "help": "Zadejte v sekundách" + } + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/systems/top.json b/backend/locales/cs/ui/systems/top.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/cs/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/cs/ui/systems/userinfo.json b/backend/locales/cs/ui/systems/userinfo.json new file mode 100644 index 000000000..4981798af --- /dev/null +++ b/backend/locales/cs/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "formatSeparator": "Oddělovač formátu", + "order": "Formát", + "lastSeenFormat": { + "title": "Zobrazení času", + "help": "Možné hodnoty naleznete na https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/da.json b/backend/locales/da.json new file mode 100644 index 000000000..bde8c140b --- /dev/null +++ b/backend/locales/da.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "er indlæst og", + "enabled": "slået til", + "disabled": "slået fra", + "usage": "Anvendelse", + "lang-selected": "Bot-sprog er i øjeblikket sat til dansk", + "refresh-panel": "Du er nødsaget til at opdatere brugergrænsefladen for at se ændringerne.", + "command-parse": "Beklager, $sender, men denne kommando er ikke korrekt, brug", + "error": "Desværre, $sender, men noget gik galt!", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, API svare ikke korrekt!", + "not-available": "ikke tilgængelig" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "år|år", + "months": "måned|måneder", + "days": "dag|dage", + "hours": "time|timer", + "minutes": "minut|minutter", + "seconds": "sekund|sekunder", + "messages": "besked|beskeder", + "bits": "bit|bits", + "links": "link|links", + "entries": "indtastning|indtastninger", + "empty": "tom", + "isRegistered": "$sender, du kan ikke bruge !$keyword, fordi den allerede er i brug i en anden handling!" + }, + "clip": { + "notCreated": "Something went wrong and clip was not created.", + "offline": "Stream is currently offline and clip cannot be created." + }, + "uptime": { + "online": "Stream har været online i (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)", + "offline": "Stream har været OFFLINE i (if $days>0|$daysd )(if $hours>0|$hours )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)" + }, + "webpanel": { + "this-system-is-disabled": "Dette system er deaktiveret", + "or": "or", + "loading": "Indlæser", + "this-may-take-a-while": "Dette kan tage et stykke tid", + "display-as": "Vis som", + "go-to-admin": "Gå til Administrator", + "go-to-public": "Gå til Offentlig", + "logout": "Log ud", + "popout": "Popout", + "not-logged-in": "Ikke logget ind", + "remove-widget": "Remove $name widget", + "join-channel": "Tilslut bot til kanal", + "leave-channel": "Fjern bot fra kanal", + "set-default": "Angiv som standard", + "add": "Tilføj", + "placeholders": { + "text-url-generator": "Indsæt din tekst eller html for at generere base64 nedenfor og URL ovenfor", + "text-decode-base64": "Indsæt din base64 for at generere URL og tekst ovenfor", + "creditsSpeed": "Set speed of credits rolling, lower = faster" + }, + "timers": { + "title": "Tidsur", + "timer": "Timer", + "messages": "beskeder", + "seconds": "sekunder", + "badges": { + "enabled": "Aktiveret", + "disabled": "Deaktiveret" + }, + "errors": { + "timer_name_must_be_compliant": "This value can contain only a-zA-Z09_", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty" + }, + "dialog": { + "timer": "Timer", + "name": "Navn", + "tickOffline": "Should tick if stream offline", + "interval": "Interval", + "responses": "Svar", + "messages": "Udløs hver X beskeder", + "seconds": "Udløs hver X sekunder", + "title": { + "new": "Ny timer", + "edit": "Rediger timer" + }, + "placeholders": { + "name": "Set name of your timer, can contain only these characters a-zA-Z0-9_", + "messages": "Trigger timer each X messages", + "seconds": "Trigger timer each X seconds" + }, + "alerts": { + "success": "Timer was successfully saved.", + "fail": "Noget gik galt." + } + }, + "buttons": { + "close": "Luk", + "save-changes": "Gem ændringer", + "disable": "Deaktivér", + "enable": "Aktivér", + "edit": "Rediger", + "delete": "Slet", + "yes": "Ja", + "no": "Nej" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Are you sure you want to delete timer" + } + }, + "events": { + "event": "Hændelse", + "noEvents": "Ingen hændelser fundet i database.", + "whatsthis": "Hvad er det her?", + "myRewardIsNotListed": "My reward is not listed!", + "redeemAndClickRefreshToSeeReward": "If you are missing your created reward in a list, refresh by clicking on refresh icon.", + "badges": { + "enabled": "Aktiveret", + "disabled": "Deaktiveret" + }, + "buttons": { + "test": "Test", + "enable": "Aktivér", + "disable": "Deaktivér", + "edit": "Redigér", + "delete": "Slet", + "yes": "Ja", + "no": "Nej" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Are you sure you want to delete event", + "example_of_user_object_data": "Example of user object data" + }, + "errors": { + "command_must_start_with_!": "Kommando skal starte med !", + "this_value_must_be_a_positive_number_or_0": "Denne værdi skal være et positivt tal eller 0", + "value_cannot_be_empty": "Value cannot be empty" + }, + "dialog": { + "title": { + "new": "New event listener", + "edit": "Edit event listener" + }, + "placeholders": { + "name": "Set name of your event listener (if empty, name will be generated)" + }, + "alerts": { + "success": "Event was successfully saved.", + "fail": "Something went wrong." + }, + "close": "Luk", + "save-changes": "Save changes", + "event": "Event", + "name": "Navn", + "usable-events-variables": "Usable events variables", + "settings": "Indstillinger", + "filters": "Filters", + "operations": "Opgaver" + }, + "definitions": { + "taskId": { + "label": "Task ID" + }, + "filter": { + "label": "Filter" + }, + "linkFilter": { + "label": "Link Overlay Filter", + "placeholder": "If using overlay, add link or id of your overlay" + }, + "hashtag": { + "label": "Hashtag eller Keyword", + "placeholder": "#yourHashtagHere or Keyword" + }, + "fadeOutXCommands": { + "label": "Fade Out X Commands", + "placeholder": "Number of commands subtracted every fade out interval" + }, + "fadeOutXKeywords": { + "label": "Fade Out X Keywords", + "placeholder": "Number of keywords subtracted every fade out interval" + }, + "fadeOutInterval": { + "label": "Fade Out Interval (seconds)", + "placeholder": "Fade out interval subtracting" + }, + "runEveryXCommands": { + "label": "Run Every X Commands", + "placeholder": "Number of commands before event is triggered" + }, + "runEveryXKeywords": { + "label": "Run Every X Keywords", + "placeholder": "Number of keywords before event is triggered" + }, + "commandToWatch": { + "label": "Command To Watch", + "placeholder": "Set your !commandToWatch" + }, + "keywordToWatch": { + "label": "Keyword To Watch", + "placeholder": "Set your keywordToWatch" + }, + "resetCountEachMessage": { + "label": "Reset count each message", + "true": "Nulstil tæller", + "false": "Behold tælling" + }, + "viewersAtLeast": { + "label": "Seere Er Mindst", + "placeholder": "How many viewers at least to trigger event" + }, + "runInterval": { + "label": "Run Interval (0 = run once per stream)", + "placeholder": "Trigger event every x seconds" + }, + "runAfterXMinutes": { + "label": "Run After X Minutes", + "placeholder": "Trigger event after x minutes" + }, + "runEveryXMinutes": { + "label": "Run Every X Minutes", + "placeholder": "Trigger event every x minutes" + }, + "messageToSend": { + "label": "Besked At sende", + "placeholder": "Indstil din besked" + }, + "channel": { + "label": "Kanal", + "placeholder": "Kanalnavn eller ID" + }, + "timeout": { + "label": "Timeout", + "placeholder": "Set timeout in milliseconds" + }, + "timeoutType": { + "label": "Type of timeout", + "placeholder": "Set type of timeout" + }, + "command": { + "label": "Command", + "placeholder": "Set your !command" + }, + "commandToRun": { + "label": "Kommando at kører", + "placeholder": "Sæt din !KommandoTilKørsel" + }, + "isCommandQuiet": { + "label": "Mute command output" + }, + "urlOfSoundFile": { + "label": "Url på din lydfil", + "placeholder": "http://www.pathToYour.url/where/is/file.mp3" + }, + "emotesToExplode": { + "label": "Emotes At Eksplodere", + "placeholder": "List of emotes to explode, e.g. Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Emotes Til Fyrværkeri", + "placeholder": "List of emotes to firework, e.g. Kappa PurpleHeart" + }, + "replay": { + "label": "Replay clip in overlay", + "true": "Will play in as replay in overlay/alerts", + "false": "Replay won't be played" + }, + "announce": { + "label": "Annoncer i chat", + "true": "Vil blive annonceret", + "false": "Vil ikke blive annonceret" + }, + "hasDelay": { + "label": "Clip should have slight delay (to be closer what viewer see)", + "true": "Vil have forsinkelse", + "false": "Vil ikke have forsinkelse" + }, + "durationOfCommercial": { + "label": "Duration Of Commercial", + "placeholder": "Available durations - 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Brugerdefineret variabel til opdatering" + }, + "numberToIncrement": { + "label": "Number to increment", + "placeholder": "" + }, + "value": { + "label": "Værdi", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Number to decrement", + "placeholder": "" + }, + "": "", + "reward": { + "label": "Reward", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Followed you", + "raid": "Raided you with $viewers raiders.", + "sub": "Subscribed to you with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "has been gifted subscription from $username", + "subcommunitygift": "Gifted subscriptions for community", + "resub": "Resubscribed with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Cheered you", + "tip": "Tipped you", + "tipToCharity": "donated to $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Twitch Prediction - Title", + "outcomes": "Twitch Prediction - Outcomes", + "locksAt": "Twitch Prediction - Locks At Date", + "winningOutcomeTitle": "Twitch Prediction - Winning outcome title", + "winningOutcomeTotalPoints": "Twitch Prediction - Winning outcome total points", + "winningOutcomePercentage": "Twitch Prediction - Winning outcome percentage", + "titleOfPoll": "Twitch Poll - Title", + "bitAmountPerVote": "Twitch Poll - Amount of bits to count as 1 vote", + "bitVotingEnabled": "Twitch Poll - Is bit voting enabled (boolean)", + "channelPointsAmountPerVote": "Twitch Poll - Amount of channel points to count as 1 vote", + "channelPointsVotingEnabled": "Twitch Poll - Is channel points voting enabled (boolean)", + "votes": "Twitch Poll - votes count", + "winnerChoice": "Twitch Poll - Winner choice", + "winnerPercentage": "Twitch Poll - Winner choice percentage", + "winnerVotes": "Twitch Poll - Winner choice votes", + "goal": "Goal", + "total": "Total", + "lastContributionTotal": "Last Contribution - Total", + "lastContributionType": "Last Contribution - Type", + "lastContributionUserId": "Last Contribution - User ID", + "lastContributionUsername": "Last Contribution - Username", + "level": "Level", + "topContributionsBitsTotal": "Top Bits Contribution - Total", + "topContributionsBitsUserId": "Top Bits Contribution - User ID", + "topContributionsBitsUsername": "Top Bits Contribution - Username", + "topContributionsSubsTotal": "Top Subs Contribution - Total", + "topContributionsSubsUserId": "Top Subs Contribution - User ID", + "topContributionsSubsUsername": "Top Subs Contribution - Username", + "sender": "User who initiated", + "title": "Nuværende titel", + "game": "Current category", + "language": "Current stream language", + "viewers": "Antal aktuelle seere", + "hostViewers": "Raid viewers count", + "followers": "Current followers count", + "subscribers": "Antal nuværende abonnenter", + "arg": "Argument", + "param": "Parameter (påkrævet)", + "touser": "Brugernavns parameter", + "!param": "Parameter (ikke påkrævet)", + "alias": "Alias", + "command": "Kommando", + "keyword": "Keyword", + "response": "Svar", + "list": "Populated list", + "type": "Type", + "days": "Dage", + "hours": "Timer", + "minutes": "Minutter", + "seconds": "Sekunder", + "description": "Beskrivelse", + "quiet": "Quiet (bool)", + "id": "ID", + "name": "Navn", + "messages": "Beskeder", + "amount": "Antal", + "amountInBotCurrency": "Valuta i bot", + "currency": "Valuta", + "currencyInBot": "Valuta i bot", + "pointsName": "Points name", + "points": "Points", + "rank": "Rank", + "nextrank": "Next rank", + "username": "Brugernavn", + "value": "Værdi", + "variable": "Variabel", + "count": "Antal", + "link": "Link (translated)", + "winner": "Vinder", + "loser": "Taber", + "challenger": "Challenger", + "min": "Minimum", + "max": "Maksimum", + "eligibility": "Eligibility", + "probability": "Probability", + "time": "Time", + "options": "Indstillinger", + "option": "Option", + "when": "Når", + "diff": "Forskel", + "users": "Brugere", + "user": "User", + "bank": "Bank", + "nextBank": "Next bank", + "cooldown": "Nedkøling", + "tickets": "Sager", + "ticketsName": "Sagens navn", + "fromUsername": "Fra brugernavn", + "toUsername": "Til brugernavn", + "items": "Elementer", + "bits": "Bits", + "subgifts": "Subgifts", + "subStreakShareEnabled": "Is substreak share enabled (true/false)", + "subStreak": "Current sub streak", + "subStreakName": "localized name of month (1 month, 2 months) for current sub strek", + "subCumulativeMonths": "Cumulative subscribe months", + "subCumulativeMonthsName": "localized name of month (1 month, 2 months) for cumulative subscribe months", + "message": "Besked", + "reason": "Årsag", + "target": "Destination", + "duration": "Varighed", + "method": "Metode", + "tier": "Niveau", + "months": "Måneder", + "monthsName": "lokalt navn på måneden (1 måned, 2 måneder)", + "oldGame": "Category before change", + "recipientObject": "Full recipient object", + "recipient": "Modtager", + "ytSong": "Nuværende sang på YouTube", + "spotifySong": "Nuværende sang på Spotify", + "latestFollower": "Seneste Følger", + "latestSubscriber": "Seneste Subscriber", + "latestSubscriberMonths": "Seneste abonnent samlede måneder", + "latestSubscriberStreak": "Seneste abonnent måneder i træk", + "latestTipAmount": "Seneste Tip (beløb)", + "latestTipCurrency": "Seneste Tip (valuta)", + "latestTipMessage": "Seneste Tip (besked)", + "latestTip": "Seneste Tip (brugernavn)", + "toptip": { + "overall": { + "username": "Top Tip - overall (username)", + "amount": "Top Tip - overall (amount)", + "currency": "Top Tip - overall (currency)", + "message": "Top Tip - overall (message)" + }, + "stream": { + "username": "Top Tip - during stream (username)", + "amount": "Top Tip - during stream (amount)", + "currency": "Top Tip - during stream (currency)", + "message": "Top Tip - during stream (message)" + } + }, + "latestCheerAmount": "Seneste Bits (beløb)", + "latestCheerMessage": "Seneste Bits (besked)", + "latestCheer": "Seneste Bits (brugernavn)", + "version": "Bot version", + "haveParam": "Har kommando parameter? (bool)", + "source": "Nuværende kilde (Twitch eller Discord)", + "userInput": "User input during reward redeem", + "isBotSubscriber": "Er bot sub? (bool)", + "isStreamOnline": "Is stream online (bool)", + "uptime": "Uptime of stream", + "is": { + "moderator": "Er bruger mod? (bool)", + "subscriber": "Er bruger sub? (bool)", + "vip": "Er bruger VIP? (bool)", + "newchatter": "Is user's first message? (bool)", + "follower": "Er bruger følger? (bool)", + "broadcaster": "Er bruger broadcaster? (bool)", + "bot": "Er bruger bot? (bool)", + "owner": "Er bruger ejer af bot? (bool)" + }, + "recipientis": { + "moderator": "Er modtageren mod? (bool)", + "subscriber": "Er modtageren sub? (bool)", + "vip": "Er modtageren VIP? (bool)", + "follower": "Er modtageren følger? (bool)", + "broadcaster": "Er modtageren broadcaster? (bool)", + "bot": "Er modtageren bot? (bool)", + "owner": "Er modtageren ejer af bot? (bool)" + }, + "sceneName": "Navn på scene", + "inputName": "Name of input", + "inputMuted": "Mute state (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Andet", + "currency": "Valuta" + }, + "whispers": { + "title": "Whispers", + "toggle": { + "listener": "Lyt til kommandoer på Whisper", + "settings": "Whispers ved ændring af indstillinger", + "raffle": "Whispers på Raffle-tilmelding", + "permissions": "Whispers on insufficient permissions", + "cooldowns": "Whispers on cooldown (if set as notify)" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Messages", + "follows": "Follows", + "subs": "Subs & Resubs", + "cheers": "Bits", + "responses": "Bot svar", + "whispers": "Whispers", + "bans": "Udelukninger", + "timeouts": "Timeout" + }, + "range": { + "day": "en dag", + "week": "en uge", + "month": "en måned", + "year": "et år", + "all": "Altid" + }, + "order": { + "asc": "Ascending", + "desc": "Descending" + }, + "labels": { + "order": "ORDER", + "range": "RANGE", + "filters": "FILTERS" + } + }, + "stats-panel": { + "show": "Show stats", + "hide": "Hide stats" + }, + "translations": "Custom translations", + "bot-responses": "Bot svar", + "duration": "Varighed", + "viewers-reset-attributes": "Nulstil attributter", + "viewers-points-of-all-users": "Points of all users", + "viewers-watchtime-of-all-users": "Watch time of all users", + "viewers-messages-of-all-users": "Messages of all users", + "events-game-after-change": "category after change", + "events-game-before-change": "category before change", + "events-user-triggered-event": "user triggered event", + "events-method-used-to-subscribe": "method used to subscribe", + "events-months-of-subscription": "måneder som abonnent", + "events-monthsName-of-subscription": "ord 'måned' efter antal (1 måned, 2 måneder)", + "events-user-message": "bruger besked", + "events-bits-user-sent": "bits user sent", + "events-reason-for-ban-timeout": "årsag til ban/timeout", + "events-duration-of-timeout": "varighed af timeout", + "events-duration-of-commercial": "varighed af reklame", + "overlays-eventlist-resub": "gentegning af abonnement", + "overlays-eventlist-subgift": "abonnements-gave", + "overlays-eventlist-subcommunitygift": "fællesskabs-abonnements-gave", + "overlays-eventlist-sub": "abonnent", + "overlays-eventlist-follow": "følg", + "overlays-eventlist-cheer": "bits", + "overlays-eventlist-tip": "tip", + "overlays-eventlist-raid": "raid", + "requested-by": "Anmodet af", + "description": "Beskrivelse", + "raffle-type": "Raffle type", + "raffle-type-keywords": "Kun nøgleord", + "raffle-type-tickets": "Med billetter", + "raffle-tickets-range": "Billetters rækkevidde", + "video_id": "Video ID", + "highlights": "Fremhævninger", + "cooldown-quiet-header": "Vis nedkølingsmeddelelse", + "cooldown-quiet-toggle-no": "Underret", + "cooldown-quiet-toggle-yes": "Ingen underretning", + "cooldown-moderators": "Moderatoroere", + "cooldown-owners": "Ejere", + "cooldown-subscribers": "Abonnenter", + "cooldown-followers": "Følgere", + "in-seconds": "i sekunder", + "songs": "Sange", + "show-usernames-with-at": "Vis brugere med @", + "send-message-as-a-bot": "Send besked som a bot", + "chat-as-bot": "Chat (som bot)", + "product": "Produkt", + "optional": "valgfrit", + "placeholder-search": "Søg", + "placeholder-enter-product": "Indtast produkt", + "placeholder-enter-keyword": "Indtast søgeord", + "credits": "Kreditter", + "fade-out-top": "fade op", + "fade-out-zoom": "fade zoom", + "global": "Global", + "user": "Bruger", + "alerts": "Advarsler", + "eventlist": "Begivenhedliste", + "dashboard": "Kontrolpanel", + "carousel": "Billede Karrusel", + "text": "Tekst", + "filter": "Filter", + "filters": "Filters", + "isUsed": "Is used", + "permissions": "Tilladelser", + "permission": "Tilladelse", + "viewers": "Seere", + "systems": "Systemer", + "overlays": "Overlag", + "gallery": "Mediegalleri", + "aliases": "Aliaser", + "alias": "Alias", + "command": "Kommando", + "cooldowns": "Nedkøling", + "title-template": "Titel skabelon", + "keyword": "Keyword", + "moderation": "Moderation", + "timer": "Timer", + "price": "Pris", + "rank": "Rangering", + "previous": "Forrige", + "next": "Næste", + "close": "Luk", + "save-changes": "Gem ændringer", + "saving": "Gemmer...", + "deleting": "Sletter…", + "done": "Færdig", + "error": "Fejl", + "title": "Titel", + "change-title": "Skift titel", + "game": "category", + "tags": "Tags", + "change-game": "Change category", + "click-to-change": "klik for at ændre", + "uptime": "uptime", + "not-affiliate-or-partner": "Ikke affiliate/partner", + "not-available": "Ikke tilgængelig", + "max-viewers": "Max seere", + "new-chatters": "Nye Chattere", + "chat-messages": "Chatbeskeder", + "followers": "Følgere", + "subscribers": "Abonnenter", + "bits": "Bits", + "subgifts": "Abonnements-gaver", + "subStreak": "Nuværende abonnement forløb", + "subCumulativeMonths": "Samlede abonnements-måneder", + "tips": "Tips", + "tier": "Niveau", + "status": "Status", + "add-widget": "Tilføj widget", + "remove-dashboard": "Fjern kontrolpanel", + "close-bet-after": "Luk indsats efter", + "refund": "refundering", + "roll-again": "Rul igen", + "no-eligible-participants": "Ingen kvalificerede deltagere", + "follower": "Følger", + "subscriber": "Abonnent", + "minutes": "minutter", + "seconds": "sekunder", + "hours": "timer", + "months": "måneder", + "eligible-to-enter": "Eligible to enter", + "everyone": "Alle", + "roll-a-winner": "Rul en vinder", + "send-message": "Send Besked", + "messages": "Beskeder", + "level": "Level", + "create": "Opret", + "cooldown": "Nedkøling", + "confirm": "Bekræft", + "delete": "Slet", + "enabled": "Aktiveret", + "disabled": "Deaktiveret", + "enable": "Aktivér", + "disable": "Deaktivér", + "slug": "Slug", + "posted-by": "Indsendt af", + "time": "Tidspunkt", + "type": "Type", + "response": "Svar", + "cost": "Pris", + "name": "Navn", + "playlist": "Spilleliste", + "length": "Længde", + "volume": "Lydstyrke", + "start-time": "Starttidspunkt", + "end-time": "Sluttidspunkt", + "watched-time": "Set tid", + "currentsong": "Nuværende sang", + "group": "Gruppe", + "followed-since": "Følger siden", + "subscribed-since": "Abonnent siden", + "username": "Brugernavn", + "hashtag": "Hashtag", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "Områder", + "last-seen": "Sidst set", + "date": "Dato", + "points": "Points", + "calendar": "Kalender", + "string": "streng", + "interval": "Interval", + "number": "nummer", + "minimal-messages-required": "Minimum Beskeder Nødvendige", + "max-duration": "Maks. varighed", + "shuffle": "Bland", + "song-request": "Sang-Ønske", + "format": "Format", + "available": "Tilgængelig", + "one-record-per-line": "én post pr. linje", + "on": "til", + "off": "fra", + "search-by-username": "Søg på brugernavn", + "widget-title-custom": "CUSTOM", + "widget-title-eventlist": "EVENTLISTE", + "widget-title-chat": "CHAT", + "widget-title-queue": "KØ", + "widget-title-raffles": "RAFFLER", + "widget-title-social": "SOCIAL", + "widget-title-ytplayer": "MUSIC PLAYER", + "widget-title-monitor": "MONITOR", + "event": "hændelse", + "operation": "opgave", + "tweet-post-with-hashtag": "Tweet sendt med hashtag", + "user-joined-channel": "bruger tilsluttede sig kanalen", + "user-parted-channel": "bruger forlod kanalen", + "follow": "ny følger", + "tip": "nyt tip", + "obs-scene-changed": "OBS-scene skiftet", + "obs-input-mute-state-changed": "OBS input source mute state changed", + "unfollow": "stop med at følge", + "hypetrain-started": "Hype Train started", + "hypetrain-ended": "Hype Train ended", + "prediction-started": "Twitch Prediction started", + "prediction-locked": "Twitch Prediction locked", + "prediction-ended": "Twitch Prediction ended", + "poll-started": "Twitch Poll started", + "poll-ended": "Twitch Poll ended", + "hypetrain-level-reached": "Hype Train new level reached", + "subscription": "ny abonnent", + "subgift": "ny abonnements-gave", + "subcommunitygift": "ny abonnements-gave givet til fællesskabet", + "resub": "bruger gentegnede abonnement", + "command-send-x-times": "kommando blev sendt x gange", + "keyword-send-x-times": "keyword was send x times", + "number-of-viewers-is-at-least-x": "number of viewers is at least x", + "stream-started": "stream startet", + "reward-redeemed": "belønning indløst", + "stream-stopped": "stream afsluttet", + "stream-is-running-x-minutes": "stream kører x minutter", + "chatter-first-message": "first message of chatter", + "every-x-minutes-of-stream": "hvert x minutter af stream", + "game-changed": "category changed", + "cheer": "bit modtaget", + "clearchat": "chatten blev ryddet", + "action": "bruger brugte /me", + "ban": "bruger blev udelukket", + "raid": "din kanal blev raided", + "mod": "bruger blev ny mod", + "timeout": "bruger fik timeout", + "create-a-new-event-listener": "Opret en ny begivenheds-lytter", + "send-discord-message": "send en Discord-besked", + "send-chat-message": "send en Twitch-chatbesked", + "send-whisper": "send en whisper", + "run-command": "kør en kommando", + "run-obswebsocket-command": "run an OBS Websocket command", + "do-nothing": "--- gør intet ---", + "count": "tæller", + "timestamp": "tidsstempel", + "message": "besked", + "sound": "lyd", + "emote-explosion": "emote eksplosion", + "emote-firework": "emote fyrværkeri", + "quiet": "stille", + "noisy": "støjende", + "true": "sand", + "false": "falsk", + "light": "lyst tema", + "dark": "mørkt tema", + "gambling": "Gambling", + "seppukuTimeout": "Timeout for !seppuku", + "rouletteTimeout": "Timeout for !roulette", + "fightmeTimeout": "Timeout for !roulette", + "duelCooldown": "Nedkøling for !duel", + "fightmeCooldown": "Nedkøling for !fightme", + "gamblingCooldownBypass": "Omgå gambling cooldown for mods/caster", + "click-to-highlight": "fremhæv", + "click-to-toggle-display": "slå visning til/fra", + "commercial": "reklame påbegyndt", + "start-commercial": "kør en reklame", + "bot-will-join-channel": "bot vil tilslutte kanal", + "bot-will-leave-channel": "bot vil forlade kanal", + "create-a-clip": "opret et klip", + "increment-custom-variable": "increment a custom variable", + "set-custom-variable": "sæt en brugerdefineret variabel", + "decrement-custom-variable": "decrement a custom variable", + "omit": "omgå", + "comply": "comply", + "visible": "synlig", + "hidden": "skjult", + "gamblingChanceToWin": "Chance for at vinde !gamble", + "gamblingMinimalBet": "Minimal indsats for !gamble", + "duelDuration": "Varighed af !duel", + "duelMinimalBet": "Minimal indsats for !duel" + }, + "raffles": { + "announceInterval": "Opened raffles will be announced every $value minute", + "eligibility-followers-item": "følgere", + "eligibility-subscribers-item": "abonnenter", + "eligibility-everyone-item": "alle", + "raffle-is-running": "Raffle is running ($count $l10n_entries).", + "to-enter-raffle": "To enter type \"$keyword\". Raffle is opened for $eligibility.", + "to-enter-ticket-raffle": "To enter type \"$keyword <$min-$max>\". Raffle is opened for $eligibility.", + "added-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-raffle}", + "added-ticket-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "Your raffle messages will be deleted on join.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "Sorry, $sender, but you cannot create raffle without keyword", + "raffle-is-already-running": "Sorry, $sender, raffle is already running with keyword $keyword", + "no-raffle-is-currently-running": "$sender, no raffles without winners are currently running", + "no-participants-to-pick-winner": "$sender, nobody joined a raffle", + "raffle-winner-is": "Winner of raffle $keyword is $username! Win probability was $probability%!" + }, + "bets": { + "running": "$sender, bet is already opened! Bet options: $options. Use $command close 1-$maxIndex", + "notRunning": "No bet is currently opened, ask mods to open it!", + "opened": "New bet '$title' is opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "closeNotEnoughOptions": "$sender, you need to select winning option for bet close.", + "notEnoughOptions": "$sender, new bets needs at least 2 options!", + "info": "Bet '$title' is still opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "diffBet": "$sender, you already made a bet on $option and you cannot bet to different option!", + "undefinedBet": "Sorry, $sender, but this bet option doesn't exist, use $command to check usage", + "betPercentGain": "Bet percent gain per option was set to $value%", + "betCloseTimer": "Bets will be automatically closed after $valuemin", + "refund": "Bets were closed without a winning. All users are refunded!", + "notOption": "$sender, this option doesn't exist! Bet is not closed, check $command", + "closed": "Bets was closed and winning option was $option! $amount users won in total $points $pointsName!", + "timeUpBet": "I guess you are too late, $sender, your time for betting is up!", + "locked": "Betting time is up! No more bets.", + "zeroBet": "Oh boy, $sender, you cannot bet 0 $pointsName", + "lockedInfo": "Bet '$title' is still opened, but time for betting is up!", + "removed": "Betting time is up! No bets were sent -> automatically closing", + "error": "Sorry, $sender, this command is not correct! Use $command 1-$maxIndex . E.g. $command 0 100 will bet 100 points to item 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, alias $alias was not found in database", + "alias-was-edited": "$sender, alias $alias is changed to $command", + "alias-was-added": "$sender, alias $alias for $command was added", + "list-is-not-empty": "$sender, list of aliases: $list", + "list-is-empty": "$sender, list of aliases is empty", + "alias-was-enabled": "$sender, alias $alias was enabled", + "alias-was-disabled": "$sender, alias $alias was disabled", + "alias-was-concealed": "$sender, alias $alias was concealed", + "alias-was-exposed": "$sender, alias $alias was exposed", + "alias-was-removed": "$sender, alias $alias was removed", + "alias-group-set": "$sender, alias $alias was set to group $group", + "alias-group-unset": "$sender, alias $alias group was unset", + "alias-group-list": "$sender, list of aliases groups: $list", + "alias-group-list-aliases": "$sender, list of aliases in $group: $list", + "alias-group-list-enabled": "$sender, aliases in $group are enabled.", + "alias-group-list-disabled": "$sender, aliases in $group are disabled." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, command $command was not found in database", + "response-was-not-found": "$sender, response #$response of command $command was not found in database", + "command-was-edited": "$sender, command $command is changed to '$response'", + "command-was-added": "$sender, command $command was added", + "list-is-not-empty": "$sender, list of commands: $list", + "list-is-empty": "$sender, list of commands is empty", + "command-was-enabled": "$sender, command $command was enabled", + "command-was-disabled": "$sender, command $command was disabled", + "command-was-concealed": "$sender, command $command was concealed", + "command-was-exposed": "$sender, command $command was exposed", + "command-was-removed": "$sender, command $command was removed", + "response-was-removed": "$sender, response #$response of $command was removed", + "list-of-responses-is-empty": "$sender, $command have no responses or doesn't exists", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, keyword $keyword is ambiguous, use ID of keyword", + "keyword-was-not-found": "$sender, keyword $keyword was not found in database", + "response-was-not-found": "$sender, response #$response of keyword $keyword was not found in database", + "keyword-was-edited": "$sender, keyword $keyword is changed to '$response'", + "keyword-was-added": "$sender, keyword $keyword ($id) was added", + "list-is-not-empty": "$sender, list of keywords: $list", + "list-is-empty": "$sender, list of keywords is empty", + "keyword-was-enabled": "$sender, keyword $keyword was enabled", + "keyword-was-disabled": "$sender, keyword $keyword was disabled", + "keyword-was-removed": "$sender, keyword $keyword was removed", + "list-of-responses-is-empty": "$sender, $keyword have no responses or doesn't exists", + "response": "$keyword#$index ($permission) $after| $response" + }, + "points": { + "success": { + "undo": "$sender, points '$command' for $username was reverted ($updatedValue $updatedValuePointsLocale to $originalValue $originalValuePointsLocale).", + "set": "$username was set to $amount $pointsName", + "give": "$sender just gave his $amount $pointsName to $username", + "online": { + "positive": "All online users just received $amount $pointsName!", + "negative": "All online users just lost $amount $pointsName!" + }, + "all": { + "positive": "All users just received $amount $pointsName!", + "negative": "All users just lost $amount $pointsName!" + }, + "rain": "Make it rain! All online users just received up to $amount $pointsName!", + "add": "$username just received $amount $pointsName!", + "remove": "Ouch, $amount $pointsName was removed from $username!" + }, + "failed": { + "undo": "$sender, username wasn't found in database or user have no undo operations", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Sorry, $sender, you don't have $amount $pointsName to give it to $username", + "cannotGiveZeroPoints": "Sorry, $sender, you cannot give $amount $pointsName to $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username has currently $amount $pointsName. Your position is $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, playlist to import is empty", + "playlist-imported": "$sender, imported $imported and skipped $skipped to playlist", + "not-playing": "Afspiller Ikke", + "song-was-banned": "Song $name was banned and will never play again!", + "song-was-banned-timeout-message": "You've got timeout for posting banned song", + "song-was-unbanned": "Song was succesfully unbanned", + "song-was-not-banned": "Denne sang blev ikke udelukket", + "no-song-is-currently-playing": "Ingen sang spiller i øjeblikket", + "current-song-from-playlist": "Current song is $name from playlist", + "current-song-from-songrequest": "Current song is $name requested by $username", + "songrequest-disabled": "Sorry, $sender, song requests are disabled", + "song-is-banned": "Sorry, $sender, but this song is banned", + "youtube-is-not-responding-correctly": "Sorry, $sender, but YouTube is sending unexpected responses, please try again later.", + "song-was-not-found": "Sorry, $sender, but this song was not found", + "song-is-too-long": "Sorry, $sender, but this song is too long", + "this-song-is-not-in-playlist": "Sorry, $sender, but this song is not in current playlist", + "incorrect-category": "Sorry, $sender, but this song must be music category", + "song-was-added-to-queue": "$sender, song $name was added to queue", + "song-was-added-to-playlist": "$sender, song $name was added to playlist", + "song-is-already-in-playlist": "$sender, song $name is already in playlist", + "song-was-removed-from-playlist": "$sender, song $name was removed from playlist", + "song-was-removed-from-queue": "$sender, your song $name was removed from queue", + "playlist-current": "$sender, current playlist is $playlist.", + "playlist-list": "$sender, available playlists: $list.", + "playlist-not-exist": "$sender, your requested playlist $playlist doesn't exist.", + "playlist-set": "$sender, you changed playlist to $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, price for $command was set to $amount $pointsName", + "price-was-unset": "$sender, price for $command was unset", + "price-was-not-found": "$sender, price for $command was not found", + "price-was-enabled": "$sender, price for $command was enabled", + "price-was-disabled": "$sender, price for $command was disabled", + "user-have-not-enough-points": "Sorry, $sender, but you don't have $amount $pointsName to use $command", + "user-have-not-enough-points-or-bits": "Sorry, $sender, but you don't have $amount $pointsName or redeem command by $bitsAmount bits to use $command", + "user-have-not-enough-bits": "Sorry, $sender, but you need to redeem command by $bitsAmount bits to use $command", + "list-is-empty": "$sender, list of prices is empty", + "list-is-not-empty": "$sender, list of prices: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, new rank $type $rank($hours$hlocale) was added", + "rank-was-edited": "$sender, rank for $type $hours$hlocale was changed to $rank", + "rank-was-removed": "$sender, rank for $type $hours$hlocale was removed", + "rank-already-exist": "$sender, there is already a rank for $type $hours$hlocale", + "rank-was-not-found": "$sender, rank for $type $hours$hlocale was not found", + "custom-rank-was-set-to-user": "$sender, you set $rank to $username", + "custom-rank-was-unset-for-user": "$sender, custom rank for $username was unset", + "list-is-empty": "$sender, no ranks was found", + "list-is-not-empty": "$sender, ranks list: $list", + "show-rank-without-next-rank": "$sender, you have $rank rank", + "show-rank-with-next-rank": "$sender, you have $rank rank. Next rank - $nextrank", + "user-dont-have-rank": "$sender, you don't have a rank yet" + }, + "followage": { + "success": { + "never": "$sender, $username følger ikke denne kanal", + "time": "$sender, $username har fulgt denne kanal i $diff" + }, + "successSameUsername": { + "never": "$sender, du er ikke følger af denne kanal", + "time": "$sender, du har fulgt denne kanal i $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username er ikke abonnent på kanalen.", + "notNow": "$sender, $username er på nuværende tidspunkt ikke abonnent på kanalen, og har i alt været abonnent på kanalen i $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username er abonnent på kanalen og har været det i $diff ($subStreak $subStreakMonthsName) i træk, og har i alt været abonnent på kanalen i $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username er abonnent på kanalen, og har i alt været abonnent på kanalen i $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, du er ikke abonnent på denne kanal.", + "notNow": "$sender, du er på nuværende tidspunkt ikke abonnent på kanalen, og har i alt været abonnent på kanalen i $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, du er abonnent på kanalen og har været det i $diff ($subStreak $subStreakMonthsName) i træk, og har i alt været abonnent på kanalen i $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, er abonnent på kanalen, og har i alt været abonnent på kanalen i $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, jeg har ingen data for alderen på kontoen tilhørende $username", + "success": { + "withUsername": "$sender, alderen på kontoen tilhørende $username er $diff", + "withoutUsername": "$sender, alderen på din konto er $diff" + } + }, + "lastseen": { + "success": { + "never": "$username har aldrig været forbi denne kanal!", + "time": "$username blev sidst set $when på denne kanal" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username har set denne kanal i $time timer" + }, + "failed": { + "parse": "{core.command-parse} !watched or !watched [username]" + } + }, + "permissions": { + "without-permission": "Du har ikke nok tilladelser til '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, bruger $username har $type immunitet i $time sekunder", + "user-have-immunity-parameterError": "$sender, parameterfejl. $command ", + "user-have-link-permit": "Brugeren $username kan sende $count $link i chatten", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "Links er ikke tilladt, bed om tilladelse først (!permit) [$count advarsler tilbage]", + "user-is-warned-about-symbols": "Ingen overdreven brug af store bogstaver [$count advarsler tilbage]", + "user-is-warned-about-long-message": "Lange beskeder er ikke tilladt [$count advarsler tilbage]", + "user-is-warned-about-caps": "Ingen overdreven brug af store bogstaver [$count advarsler tilbage]", + "user-is-warned-about-spam": "Spamming er ikke tilladt [$count advarsler tilbage]", + "user-is-warned-about-color": "Italic and /me is not allowed [$count warnings left]", + "user-is-warned-about-emotes": "Ingen emotes spamming [$count advarsler tilbage]", + "user-is-warned-about-forbidden-words": "Ingen forbudte ord [$count advarsler tilbage]", + "user-have-timeout-for-links": "Links er ikke tilladt, spørg om tilladelse først (!permit)", + "user-have-timeout-for-symbols": "Ingen overdreven brug af symboler", + "user-have-timeout-for-long-message": "Lange beskeder er ikke tilladt", + "user-have-timeout-for-caps": "Ingen overdreven brug af store bogstaver", + "user-have-timeout-for-spam": "Spamming er ikke tilladt", + "user-have-timeout-for-color": "Italic and /me is not allowed", + "user-have-timeout-for-emotes": "Ingen emotes spamming", + "user-have-timeout-for-forbidden-words": "Ingen forbudte ord" + }, + "queue": { + "list": "$sender, current queue pool: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Sorry $sender, queue is currently closed", + "opened": "$sender were added into queue" + }, + "open": "Queue is currently OPENED! Join to queue with !queue join", + "close": "Queue is currently closed!", + "clear": "Queue were completely cleared", + "picked": { + "single": "This user was picked from queue: $users", + "multi": "These users were picked from queue: $users", + "none": "No users were found in queue" + } + }, + "marker": "Stream marker has been created at $time.", + "title": { + "current": "$sender, nuværende titel på stream er '$title'.", + "change": { + "success": "$sender, titlen er nu ændret til: $title" + } + }, + "game": { + "current": "$sender, streamer spiller i øjeblikket $game.", + "change": { + "success": "$sender, category was set to: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type cooldown for $command er nu sat til $secondss", + "cooldown-was-unset": "$sender, cooldown for $command blev ikke sat", + "cooldown-triggered": "$sender, '$command' is on cooldown, remaining $secondss", + "cooldown-not-found": "$sender, cooldown for $command blev ikke fundet", + "cooldown-was-enabled": "$sender, cooldown for $command blev slået til", + "cooldown-was-disabled": "$sender, cooldown for $command blev slået fra", + "cooldown-was-enabled-for-moderators": "$sender, nedkøling for $command blev slået til for moderatorer", + "cooldown-was-disabled-for-moderators": "$sender, nedkøling for $command blev slået fra for moderatorer", + "cooldown-was-enabled-for-owners": "$sender, nedkøling for $command blev slået til for ejere", + "cooldown-was-disabled-for-owners": "$sender, nedkøling for $command blev slået fra for ejere", + "cooldown-was-enabled-for-subscribers": "$sender, nedkøling for $command blev slået til for abonnenter", + "cooldown-was-disabled-for-subscribers": "$sender, nedkøling for $command blev slået fra for abonnenter", + "cooldown-was-enabled-for-followers": "$sender, nedkøling for $command blev slået til for følgere", + "cooldown-was-disabled-for-followers": "$sender, nedkøling for $command blev slået fra for følgere" + }, + "timers": { + "id-must-be-defined": "$sender, svar-id skal defineres.", + "id-or-name-must-be-defined": "$sender, response id or timer name must be defined.", + "name-must-be-defined": "$sender, timer name must be defined.", + "response-must-be-defined": "$sender, timer response must be defined.", + "cannot-set-messages-and-seconds-0": "$sender, you cannot set both messages and seconds to 0.", + "timer-was-set": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger", + "timer-was-set-with-offline-flag": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger even when stream is offline", + "timer-not-found": "$sender, timer (name: $name) was not found in database. Check timers with !timers list", + "timer-deleted": "$sender, timer $name and its responses was deleted.", + "timer-enabled": "$sender, timer (name: $name) was enabled", + "timer-disabled": "$sender, timer (name: $name) was disabled", + "timers-list": "$sender, timers list: $list", + "responses-list": "$sender, timer (name: $name) list", + "response-deleted": "$sender, response (id: $id) was deleted.", + "response-was-added": "$sender, response (id: $id) for timer (name: $name) was added - '$response'", + "response-not-found": "$sender, response (id: $id) was not found in database", + "response-enabled": "$sender, svar (id: $id) blev aktiveret", + "response-disabled": "$sender, svar (id: $id) blev deaktiveret" + }, + "gambling": { + "duel": { + "bank": "$sender, current bank for $command is $points $pointsName", + "lowerThanMinimalBet": "$sender, minimal bet for $command is $points $pointsName", + "cooldown": "$sender, you cannot use $command for $cooldown $minutesName.", + "joined": "$sender, good luck with your dueling skills. You bet on yourself $points $pointsName!", + "added": "$sender really thinks he is better than others raising his bet to $points $pointsName!", + "new": "$sender is your new duel challenger! To participate use $command [points], you have $minutes $minutesName left to join.", + "zeroBet": "$sender, you cannot duel 0 $pointsName", + "notEnoughOptions": "$sender, you need to specify points to dueling", + "notEnoughPoints": "$sender, you don't have $points $pointsName to duel!", + "noContestant": "Only $winner have courage to join duel! Your bet of $points $pointsName are returned to you.", + "winner": "Congratulations to $winner! He is last man standing and he won $points $pointsName ($probability% with bet of $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender is trying his luck and pulled a trigger", + "alive": "$sender er i live! Intet skete.", + "dead": "$sender's brain was splashed on the wall!", + "mod": "$sender is incompetent and completely missed his head!", + "broadcaster": "$sender is using blanks, boo!", + "timeout": "Roulette timeout set to $values" + }, + "gamble": { + "chanceToWin": "$sender, chance to win !gamble set to $value%", + "zeroBet": "$sender, you cannot gamble 0 $pointsName", + "minimalBet": "$sender, minimal bet for !gamble is set to $value", + "lowerThanMinimalBet": "$sender, minimal bet for !gamble is $points $pointsName", + "notEnoughOptions": "$sender, you need to specify points to gamble", + "notEnoughPoints": "$sender, you don't have $points $pointsName to gamble", + "win": "$sender, you WON! You now have $points $pointsName", + "winJackpot": "$sender, you hit JACKPOT! You won $jackpot $jackpotName in addition to your bet. You now have $points $pointsName", + "loseWithJackpot": "$sender, you LOST! You now have $points $pointsName. Jackpot increased to $jackpot $jackpotName", + "lose": "$sender, you LOST! You now have $points $pointsName", + "currentJackpot": "$sender, current jackpot for $command is $points $pointsName", + "winJackpotCount": "$sender, you won $count jackpots", + "jackpotIsDisabled": "$sender, jackpot is disabled for $command." + } + }, + "highlights": { + "saved": "$sender, highlight was saved for $hoursh$minutesm$secondss", + "list": { + "items": "$sender, list of saved highlights for latest stream: $items", + "empty": "$sender, no highlights were saved" + }, + "offline": "$sender, cannot save highlight, stream is offline" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "Bot won't send errors on insufficient permissions", + "false": "Bot won't send errors on insufficient permissions through whispers" + }, + "disableCooldownWhispers": { + "true": "Bot sender ikke nedkølingsmeddelelser", + "false": "Bot vil sende nedkølingsmeddelelser via whispers" + } + } + }, + "time": "Current time in streamer's timezone is $time", + "subs": "$sender, der er i øjeblikket $onlineSubCount abonnenter online. Sidste abonnerende var $lastSubUsername $lastSubAgo", + "followers": "$sender, last follow was $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, bruger $username ignoreres ikke af bot" + }, + "added": "$sender, bruger $username er tilføjet til bottens ignoreringsliste", + "removed": "$sender, bruger $username er fjernet fra bottens ignoreringsliste", + "ignored": "$sender, bruger $username ignoreres af bot" + } + } + }, + "filters": { + "setVariable": "$sender, $variable blev sat til $value." + } +} diff --git a/backend/locales/da/api.clips.json b/backend/locales/da/api.clips.json new file mode 100644 index 000000000..f9a88d24b --- /dev/null +++ b/backend/locales/da/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "Klip blev oprettet og er tilgængelig på $link" +} \ No newline at end of file diff --git a/backend/locales/da/core/permissions.json b/backend/locales/da/core/permissions.json new file mode 100644 index 000000000..b7f99b830 --- /dev/null +++ b/backend/locales/da/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "Liste over tilladelser:", + "excludeAddSuccessful": "$sender, du tilføjede $username til 'udelukke-listen' for tilladelse $permissionName", + "excludeRmSuccessful": "$sender, du fjernede $username fra 'udelukke-listen' for tilladelse $permissionName", + "userNotFound": "$sender, bruger $username blev ikke fundet i databasen.", + "permissionNotFound": "$sender, tilladelse $userlevel blev ikke fundet i databasen.", + "cannotIgnoreForCorePermission": "$sender, du kan ikke manuelt udelukke bruger fra kerne-tilladelse $userlevel" +} \ No newline at end of file diff --git a/backend/locales/da/games.heist.json b/backend/locales/da/games.heist.json new file mode 100644 index 000000000..86371a40f --- /dev/null +++ b/backend/locales/da/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, cops are still searching for last heist team. Try again after $cooldown.", + "copsCooldownMessage": "Alright guys, looks like police forces are eating donuts and we can get that sweet money!", + "entryMessage": "$sender has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type $command to enter.", + "lateEntryMessage": "$sender, heist is currently in progress!", + "entryInstruction": "$sender, type $command to enter.", + "levelMessage": "With this crew, we can heist $bank! Let's see if we can get enough crew to heist $nextBank", + "maxLevelMessage": "With this crew, we can heist $bank! It cannot be any better!", + "started": "Alright guys, check your equipment, this is what we trained for. This is not a game, this is real life. We will get money from $bank!", + "noUser": "Nobody joins a crew to heist.", + "singleUserSuccess": "$user was like a ninja. Nobody noticed missing money.", + "singleUserFailed": "$user failed to get rid of police and will be spending his time in jail.", + "result": { + "0": "Everyone was mercilessly obliterated. This is slaughter.", + "33": "Only 1/3rd of team get its money from heist.", + "50": "Half of heist team was killed or catched by police.", + "99": "Some loses of heist team is nothing of what remaining crew have in theirs pockets.", + "100": "God divinity, nobody is dead, everyone won!" + }, + "levels": { + "bankVan": "Bank van", + "cityBank": "City bank", + "stateBank": "State bank", + "nationalReserve": "National reserve", + "federalReserve": "Federal reserve" + }, + "results": "The heist payouts are: $users", + "andXMore": "and $count more..." +} \ No newline at end of file diff --git a/backend/locales/da/integrations/discord.json b/backend/locales/da/integrations/discord.json new file mode 100644 index 000000000..b175b15ea --- /dev/null +++ b/backend/locales/da/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "din konto er ikke forbundet, brug `$command`", + "all-your-links-were-deleted": "alle dine links blev slettet", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender, denne konto blev forbundet med $discordTag.", + "invalid-or-expired-token": "$sender, ugyldig eller udløbet token.", + "help-message": "$sender, for at forbinde din konto på Discord: 1. Gå til Discord serveren og send $command i bot-kanalen. | 2. Vent på PM fra bot | 3. Send kommando fra din Discord PM her i twitch chatten.", + "started-at": "Startede den", + "announced-by": "Announced by sogeBot", + "streamed-at": "Streamed Den", + "link-whisper": "Hej $tag, for at forbinde denne Discord konto med din Twitch konto på $broadcaster kanal, gå til , log ind på din konto og send denne kommando i chatten \n\n\t\t- `$command $id`\n\nBEMÆRK: Denne udløber om 10 minutter.", + "check-your-dm": "tjek dine DMs for vejledning til at linke din konto." +} \ No newline at end of file diff --git a/backend/locales/da/integrations/lastfm.json b/backend/locales/da/integrations/lastfm.json new file mode 100644 index 000000000..79a075396 --- /dev/null +++ b/backend/locales/da/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "Current song is $name" +} \ No newline at end of file diff --git a/backend/locales/da/integrations/obswebsocket.json b/backend/locales/da/integrations/obswebsocket.json new file mode 100644 index 000000000..36540b8cd --- /dev/null +++ b/backend/locales/da/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, der er ingen handling sat for id:$id!", + "ParameterError": "$sender, du skal angive et ID!", + "UnknownError": "$sender, noget gik galt. Tjek logs for botten, for yderligere informationer." + } +} \ No newline at end of file diff --git a/backend/locales/da/integrations/protondb.json b/backend/locales/da/integrations/protondb.json new file mode 100644 index 000000000..0e7df5a0f --- /dev/null +++ b/backend/locales/da/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | $rating rated | Native on $native | Details: $url", + "responseNg": "Rating for game $game was not found on ProtonDB.", + "responseNotFound": "Game $game was not found on ProtonDB." +} \ No newline at end of file diff --git a/backend/locales/da/integrations/pubg.json b/backend/locales/da/integrations/pubg.json new file mode 100644 index 000000000..45b4022fa --- /dev/null +++ b/backend/locales/da/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, en af disse parametre forventes: $list" +} \ No newline at end of file diff --git a/backend/locales/da/integrations/spotify.json b/backend/locales/da/integrations/spotify.json new file mode 100644 index 000000000..9085e33b0 --- /dev/null +++ b/backend/locales/da/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "Sorry, $sender, track was not found on spotify", + "song-requested": "$sender, you requested song $name from $artist", + "not-banned-song-not-playing": "$sender, no song is currently playing to ban.", + "song-banned": "$sender, song $name from $artist is banned.", + "song-unbanned": "$sender, song $name from $artist is unbanned.", + "song-not-found-in-banlist": "$sender, song by spotifyURI $uri was not found in ban list.", + "cannot-request-song-is-banned": "$sender, cannot request banned song $name from $artist.", + "cannot-request-song-from-unapproved-artist": "$sender, cannot request song from unapproved artist.", + "no-songs-found-in-history": "$sender, there is currently no song in history list.", + "return-one-song-from-history": "$sender, previous song was $name from $artist.", + "return-multiple-song-from-history": "$sender, $count previous songs were:", + "return-multiple-song-from-history-item": "$index - $name from $artist", + "song-notify": "Current playing song is $name by $artist." +} \ No newline at end of file diff --git a/backend/locales/da/integrations/tiltify.json b/backend/locales/da/integrations/tiltify.json new file mode 100644 index 000000000..aa574fb09 --- /dev/null +++ b/backend/locales/da/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, there are currently no active campaigns.", + "active_campaigns": "$sender, list of currently active campaigns:" +} \ No newline at end of file diff --git a/backend/locales/da/systems.quotes.json b/backend/locales/da/systems.quotes.json new file mode 100644 index 000000000..92025a17c --- /dev/null +++ b/backend/locales/da/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, quote $id '$quote' was added. (tags: $tags)", + "error": "$sender, $command is not correct or missing -quote parameter" + }, + "remove": { + "ok": "$sender, quote $id was successfully deleted.", + "error": "$sender, quote ID is missing.", + "not-found": "$sender, quote $id was not found." + }, + "show": { + "ok": "Quote $id by $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found.", + "not-found-by-tag": "$sender, no quotes with tag $tag was not found." + } + }, + "set": { + "ok": "$sender, quote $id tags were set. (tags: $tags)", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found." + } + }, + "list": { + "ok": "$sender, You can find quote list at http://$urlBase/public/#/quotes", + "is-localhost": "$sender, quote list url is not properly specified." + } +} \ No newline at end of file diff --git a/backend/locales/da/systems/antihateraid.json b/backend/locales/da/systems/antihateraid.json new file mode 100644 index 000000000..7ad602a98 --- /dev/null +++ b/backend/locales/da/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "This chat was set to $mode by $username to get rid of hate raid. Sorry for inconvenience!", + "mode": { + "0": "subs-only", + "1": "follow-only", + "2": "emotes-only" + } +} \ No newline at end of file diff --git a/backend/locales/da/systems/howlongtobeat.json b/backend/locales/da/systems/howlongtobeat.json new file mode 100644 index 000000000..0fcc12bd9 --- /dev/null +++ b/backend/locales/da/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game not found in db.", + "game": "$sender, $game | Main: $currentMain/$hltbMainh - $percentMain% | Main+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Completionist: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Main: $currentMainh | Main+Extra: $currentMainExtrah | Completionist: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/da/systems/levels.json b/backend/locales/da/systems/levels.json new file mode 100644 index 000000000..d60efd758 --- /dev/null +++ b/backend/locales/da/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, niveau: $currentLevel ($currentXP $xpName), $nextXP $xpName til næste niveau.", + "changeXP": "$sender, du ændrede $xpName af $amount $xpName til $username.", + "notEnoughPointsToBuy": "Beklager $sender, men du har ikke $points $pointsName til at købe $amount $xpName for niveau $level.", + "XPBoughtByPoints": "$sender, du købte $amount $xpName med $points $pointsName og nåede niveau $level.", + "somethingGetWrong": "$sender, noget er gået galt med din forespørgsel." +} \ No newline at end of file diff --git a/backend/locales/da/systems/scrim.json b/backend/locales/da/systems/scrim.json new file mode 100644 index 000000000..45630d49b --- /dev/null +++ b/backend/locales/da/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Snipe match ($type) starting in $time $unit", + "go": "Starting now! Go!", + "putMatchIdInChat": "Please put your match ID in the chat => $command xxx", + "currentMatches": "Current Matches: $matches", + "stopped": "Snipe match was cancelled." +} \ No newline at end of file diff --git a/backend/locales/da/systems/top.json b/backend/locales/da/systems/top.json new file mode 100644 index 000000000..148916ccb --- /dev/null +++ b/backend/locales/da/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Top $amount (tid i chatten): ", + "tips": "Top $amount (tips): ", + "level": "Top $amount (level): ", + "points": "Top $amount (points): ", + "messages": "Top $amount (beskeder): ", + "followage": "Top $amount (tid som følger): ", + "subage": "Top $amount (tid som subscriber): ", + "submonths": "Top $amount (antal sub-måneder): ", + "bits": "Top $amount (bits): ", + "gifts": "Top $amount (gifted subs): " +} \ No newline at end of file diff --git a/backend/locales/da/ui.commons.json b/backend/locales/da/ui.commons.json new file mode 100644 index 000000000..22d3a3b5b --- /dev/null +++ b/backend/locales/da/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Additional settings", + "never": "never", + "reset": "reset", + "moveUp": "move up", + "moveDown": "move down", + "stop-if-executed": "stop, if executed", + "continue-if-executed": "continue, if executed", + "generate": "Generate", + "thumbnail": "Thumbnail", + "yes": "Yes", + "no": "No", + "show-more": "Show more", + "show-less": "Show less", + "allowed": "Allowed", + "disallowed": "Disallowed", + "back": "Back" +} diff --git a/backend/locales/da/ui.dialog.json b/backend/locales/da/ui.dialog.json new file mode 100644 index 000000000..15701cc6e --- /dev/null +++ b/backend/locales/da/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Edit", + "add": "Add" + }, + "position": { + "settings": "Position settings", + "anchorX": "Anchor X position", + "anchorY": "Anchor Y position", + "left": "Left", + "right": "Right", + "middle": "Middle", + "top": "Top", + "bottom": "Bottom", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Shift Right", + "shadowShiftDown": "Shift Down", + "shadowBlur": "Blur", + "shadowOpacity": "Opacity", + "color": "Color" + }, + "errors": { + "required": "This input cannot be empty.", + "minValue": "Lowest value of this input is $value." + }, + "buttons": { + "reorder": "Reorder", + "upload": { + "idle": "Upload", + "progress": "Uploading", + "done": "Uploaded" + }, + "cancel": "Cancel", + "close": "Close", + "test": { + "idle": "Test", + "progress": "Testing in progress", + "done": "Testing done" + }, + "saveChanges": { + "idle": "Save changes", + "invalid": "Cannot save changes", + "progress": "Saving changes", + "done": "Changes saved" + }, + "something-went-wrong": "Something went wrong", + "mark-to-delete": "Mark to delete", + "disable": "Disable", + "enable": "Enable", + "disabled": "Disabled", + "enabled": "Enabled", + "edit": "Edit", + "delete": "Delete", + "play": "Play", + "stop": "Stop", + "hold-to-delete": "Hold to delete", + "yes": "Yes", + "no": "No", + "permission": "Permission", + "group": "Group", + "visibility": "Visibility", + "reset": "Reset " + }, + "changesPending": "Your changes was not saved.", + "formNotValid": "Form is invalid.", + "nothingToShow": "Nothing to show here." +} \ No newline at end of file diff --git a/backend/locales/da/ui.menu.json b/backend/locales/da/ui.menu.json new file mode 100644 index 000000000..7361104a4 --- /dev/null +++ b/backend/locales/da/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Services", + "updater": "Updater", + "index": "Dashboard", + "core": "Bot", + "users": "Users", + "tmi": "TMI", + "ui": "UI", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "General", + "timers": "Timers", + "new": "New Item", + "keywords": "Keywords", + "customcommands": "Custom commands", + "botcommands": "Bot commands", + "commands": "Commands", + "events": "Events", + "ranks": "Ranks", + "songs": "Songs", + "modules": "Modules", + "viewers": "Viewers", + "alias": "Aliases", + "cooldowns": "Cooldowns", + "cooldown": "Cooldown", + "highlights": "Highlights", + "price": "Price", + "logs": "Logs", + "systems": "Systems", + "permissions": "Permissions", + "translations": "Custom translations", + "moderation": "Moderation", + "overlays": "Overlays", + "gallery": "Media gallery", + "games": "Games", + "spotify": "Spotify", + "integrations": "Integrations", + "customvariables": "Custom variables", + "registry": "Registry", + "quotes": "Quotes", + "settings": "Settings", + "commercial": "Commercial", + "bets": "Bets", + "points": "Points", + "raffles": "Raffles", + "queue": "Queue", + "playlist": "Playlist", + "bannedsongs": "Banned songs", + "spotifybannedsongs": "Spotify banned songs", + "duel": "Duel", + "fightme": "FightMe", + "seppuku": "Seppuku", + "gamble": "Gamble", + "roulette": "Roulette", + "heist": "Heist", + "oauth": "OAuth", + "socket": "Socket", + "carouseloverlay": "Carousel overlay", + "alerts": "Alerts", + "carousel": "Image carousel", + "clips": "Clips", + "credits": "Credits", + "emotes": "Emotes", + "stats": "Stats", + "text": "Text", + "currency": "Currency", + "eventlist": "Eventlist", + "clipscarousel": "Clips carousel", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Checklist", + "bot": "Bot", + "api": "API", + "manage": "Manage", + "top": "Top", + "goals": "Goals", + "userinfo": "User info", + "scrim": "Scrim", + "commandcount": "Command count", + "profiler": "Profiler", + "howlongtobeat": "How long to beat", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Randomizer", + "tips": "Tips", + "bits": "Bits", + "discord": "Discord", + "texttospeech": "Text To Speech", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "Levels", + "obswebsocket": "OBS Websocket", + "api-explorer": "API Explorer", + "emotescombo": "Emotes Combo", + "notifications": "Notifications", + "plugins": "Plugins", + "tts": "TTS" +} diff --git a/backend/locales/da/ui.page.settings.overlays.carousel.json b/backend/locales/da/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..7ca51081f --- /dev/null +++ b/backend/locales/da/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "options", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Are you sure to delete this image?" + }, + "button": { + "update": "Update", + "fix_your_errors_first": "Fix errors before save" + }, + "errors": { + "number_greater_or_equal_than_0": "Value must be a number >= 0", + "value_must_not_be_empty": "Value must not be empty" + }, + "titles": { + "waitBefore": "Wait before image show (in ms)", + "waitAfter": "Wait after image disappear (in ms)", + "duration": "How long image should be shown (in ms)", + "animationIn": "Animation In", + "animationOut": "Animation Out", + "animationInDuration": "Animation In duration (in ms)", + "animationOutDuration": "Animation Out duration (in ms)", + "showOnlyOncePerStream": "Show only once per stream" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.registry.customvariables.json b/backend/locales/da/ui.registry.customvariables.json new file mode 100644 index 000000000..eab91fde8 --- /dev/null +++ b/backend/locales/da/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "URLs", + "generateurl": "Generate new URL", + "show-examples": "show CURL examples", + "response": { + "show": "Show response after POST", + "name": "Response after variable set", + "default": "Default", + "default-placeholder": "Set your bot response", + "default-help": "Use $value to get new variable value", + "custom": "Custom", + "command": "Command" + }, + "useIfInCommand": "Use if you use variable in command. Will return only updated variable without response.", + "permissionToChange": "Permission to change", + "isReadOnly": "read-only in chat", + "isNotReadOnly": "can be changed through chat", + "no-variables-found": "No variables found", + "additional-info": "Additional info", + "run-script": "Run script", + "last-run": "Last run at", + "variable": { + "name": "Variable name", + "help": "Variable name must be unique, e.g. $_wins, $_loses, $_top3", + "placeholder": "Enter your unique variable name", + "error": { + "isNotUnique": "Variable must have unique name.", + "isEmpty": "Variable name must not be empty." + } + }, + "description": { + "name": "Description", + "help": "Optional description", + "placeholder": "Enter your optional description" + }, + "type": { + "name": "Type", + "error": { + "isNotSelected": "Please choose a variable type." + } + }, + "currentValue": { + "name": "Current value", + "help": "If type is set to Evaluated script, value cannot be manually changed" + }, + "usableOptions": { + "name": "Usable options", + "placeholder": "Enter, your, options, here", + "help": "Options, which can be used with this variable, example: SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "You need to set at least 1 value." + } + }, + "scriptToEvaluate": "Script to evaluate", + "runScript": { + "name": "Run script", + "error": { + "isNotSelected": "Please choose an option." + } + }, + "testCurrentScript": { + "name": "Test current script", + "help": "Click Test current script to see value in Current value input" + }, + "history": "History", + "historyIsEmpty": "History for this variable is empty!", + "warning": "Warning: All data of this variable will be discarded!", + "choose": "Choose...", + "types": { + "number": "Number", + "text": "Text", + "options": "Options", + "eval": "Script" + }, + "runEvery": { + "isUsed": "When variable is used" + } +} + diff --git a/backend/locales/da/ui.systems.antihateraid.json b/backend/locales/da/ui.systems.antihateraid.json new file mode 100644 index 000000000..d821c979d --- /dev/null +++ b/backend/locales/da/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Clear Chat", + "mode": "Mode", + "minFollowTime": "Minimum follow time", + "customAnnounce": "Customize announcement on anti hate raid enable" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.bets.json b/backend/locales/da/ui.systems.bets.json new file mode 100644 index 000000000..d317f37a0 --- /dev/null +++ b/backend/locales/da/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "betPercentGain": "Tilføj x% for at satse udbetaling ved hver mulighed" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.commercial.json b/backend/locales/da/ui.systems.commercial.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/da/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.cooldown.json b/backend/locales/da/ui.systems.cooldown.json new file mode 100644 index 000000000..064403519 --- /dev/null +++ b/backend/locales/da/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Notify as whisper", + "settings": { + "enabled": "Status", + "cooldownNotifyAsWhisper": "Whisper cooldown informations", + "cooldownNotifyAsChat": "Chat message cooldown informations", + "defaultCooldownOfCommandsInSeconds": "Default cooldown for commands (in seconds)", + "defaultCooldownOfKeywordsInSeconds": "Default cooldown for keywords (in seconds)" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.customcommands.json b/backend/locales/da/ui.systems.customcommands.json new file mode 100644 index 000000000..5c93eb931 --- /dev/null +++ b/backend/locales/da/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "No responses", + "addResponse": "Add response", + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.highlights.json b/backend/locales/da/ui.systems.highlights.json new file mode 100644 index 000000000..200028a41 --- /dev/null +++ b/backend/locales/da/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "urls": "Genererede URL'er" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.moderation.json b/backend/locales/da/ui.systems.moderation.json new file mode 100644 index 000000000..7c9c7f3e5 --- /dev/null +++ b/backend/locales/da/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Status", + "cListsEnabled": "Enforce the rule", + "cLinksEnabled": "Enforce the rule", + "cSymbolsEnabled": "Enforce the rule", + "cLongMessageEnabled": "Enforce the rule", + "cCapsEnabled": "Enforce the rule", + "cSpamEnabled": "Enforce the rule", + "cColorEnabled": "Enforce the rule", + "cEmotesEnabled": "Enforce the rule", + "cListsWhitelist": { + "title": "Allowed words", + "help": "To allow domains use \"domain:prtzl.io\"" + }, + "autobanMessages": "Autoban Messages", + "cListsBlacklist": "Forbidden words", + "cListsTimeout": "Timeout duration", + "cLinksTimeout": "Timeout duration", + "cSymbolsTimeout": "Timeout duration", + "cLongMessageTimeout": "Timeout duration", + "cCapsTimeout": "Timeout duration", + "cSpamTimeout": "Timeout duration", + "cColorTimeout": "Timeout duration", + "cEmotesTimeout": "Timeout duration", + "cWarningsShouldClearChat": "Should clear chat (will timeout for 1s)", + "cLinksIncludeSpaces": "Include spaces", + "cLinksIncludeClips": "Include clips", + "cSymbolsTriggerLength": "Trigger length of message", + "cLongMessageTriggerLength": "Trigger length of message", + "cCapsTriggerLength": "Trigger length of message", + "cSpamTriggerLength": "Trigger length of message", + "cSymbolsMaxSymbolsConsecutively": "Max symbols consecutively", + "cSymbolsMaxSymbolsPercent": "Max symbols %", + "cCapsMaxCapsPercent": "Max caps %", + "cSpamMaxLength": "Max length", + "cEmotesMaxCount": "Max count", + "cWarningsAnnounceTimeouts": "Announce timeouts in chat for everyone", + "cWarningsAllowedCount": "Warning count", + "cEmotesEmojisAreEmotes": "Treat Emojis as Emotes" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.points.json b/backend/locales/da/ui.systems.points.json new file mode 100644 index 000000000..b0b011374 --- /dev/null +++ b/backend/locales/da/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Status", + "name": { + "title": "Name", + "help": "Possible formats:
point|points
bod|4:body|bodu" + }, + "isPointResetIntervalEnabled": "Interval of points reset", + "resetIntervalCron": { + "name": "Cron interval", + "help": "CronTab generator" + }, + "interval": "Minutes interval to add points to online users when stream online", + "offlineInterval": "Minutes interval to add points to online users when stream offline", + "messageInterval": "How many messages to add points", + "messageOfflineInterval": "How many messages to add points when stream offline", + "perInterval": "How many points to add per online interval", + "perOfflineInterval": "How many points to add per offline interval", + "perMessageInterval": "How many points to add per message interval", + "perMessageOfflineInterval": "How many points to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.price.json b/backend/locales/da/ui.systems.price.json new file mode 100644 index 000000000..6dcce9ea2 --- /dev/null +++ b/backend/locales/da/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Trigger custom alerts on bit redeem", + "price": { + "name": "price", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.queue.json b/backend/locales/da/ui.systems.queue.json new file mode 100644 index 000000000..266228fb8 --- /dev/null +++ b/backend/locales/da/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Status", + "eligibilityAll": "Alle", + "eligibilityFollowers": "Følgere", + "eligibilitySubscribers": "Abonnenter" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.quotes.json b/backend/locales/da/ui.systems.quotes.json new file mode 100644 index 000000000..97f22bce8 --- /dev/null +++ b/backend/locales/da/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "We're sorry, no quotes were found in database.", + "new": "Add new quote", + "empty": "List of quotes is empty, create new quote.", + "emptyAfterSearch": "List of quotes is empty in searching for \"$search\"", + "quote": { + "name": "Quote", + "placeholder": "Set your quote here" + }, + "by": { + "name": "Quoted by" + }, + "tags": { + "name": "Tags", + "placeholder": "Set your tags here", + "help": "Comma-separated tags. Example: tag 1, tag 2, tag 3" + }, + "date": { + "name": "Date" + }, + "error": { + "isEmpty": "This value cannot be empty", + "atLeastOneTag": "You need to set at least one tag" + }, + "tag-filter": "Filtering by tag", + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status", + "urlBase": { + "title": "URL base", + "help": "You should use public endpoint for quotes, to be accessible by everyone" + } + } +} diff --git a/backend/locales/da/ui.systems.raffles.json b/backend/locales/da/ui.systems.raffles.json new file mode 100644 index 000000000..9b8075f19 --- /dev/null +++ b/backend/locales/da/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Subscribers luck" + }, + "settings": { + "enabled": "Status", + "announceNewEntries": { + "title": "Announce new entries", + "help": "If users joins raffle, announce message will be send to chat after while." + }, + "announceNewEntriesBatchTime": { + "title": "How long to wait before announce new entries (in seconds)", + "help": "Longer time will keep chat cleaner, entries will be aggregated together." + }, + "deleteRaffleJoinCommands": { + "title": "Delete user raffle join command", + "help": "This will delete user message if they use !yourraffle command. Should keep chat cleaner." + }, + "allowOverTicketing": { + "title": "Allow over ticketing", + "help": "Allow user join raffle with over ticket of his points. E.g. user have 10 points but can join with !raffle 100 which will use all of his points." + }, + "raffleAnnounceInterval": { + "title": "Announce interval", + "help": "Minutes" + }, + "raffleAnnounceMessageInterval": { + "title": "Announce message interval", + "help": "How many messages must be sent to chat until announce can be posted." + }, + "subscribersPercent": { + "title": "Additional subscribers luck", + "help": "in percents" + } + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.ranks.json b/backend/locales/da/ui.systems.ranks.json new file mode 100644 index 000000000..42a6861a6 --- /dev/null +++ b/backend/locales/da/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "New Rank", + "empty": "No ranks were created yet.", + "emptyAfterSearch": "No ranks were found by your search for \"$search\".", + "rank": { + "name": "rank", + "placeholder": "" + }, + "value": { + "name": "hours", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.songs.json b/backend/locales/da/ui.systems.songs.json new file mode 100644 index 000000000..c1df88a05 --- /dev/null +++ b/backend/locales/da/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Status", + "volume": "Volume", + "calculateVolumeByLoudness": "Dynamic volume by loudness", + "duration": { + "title": "Max song duration", + "help": "In minutes" + }, + "shuffle": "Shuffle", + "songrequest": "Play from song request", + "playlist": "Play from playlist", + "onlyMusicCategory": "Allow only category music", + "allowRequestsOnlyFromPlaylist": "Allow song requests only from current playlist", + "notify": "Send message on song change" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "startTime": "Start song at", + "endTime": "End song at", + "add_song": "Add song", + "add_or_import": "Add song or import from playlist", + "importing": "Importing", + "importing_done": "Importing Done", + "seconds": "Seconds", + "calculated": "Calculated", + "set_manually": "Set manually", + "bannedSongsEmptyAfterSearch": "No banned songs were found by your search for \"$search\".", + "emptyAfterSearch": "No songs were found by your search for \"$search\".", + "empty": "No songs were added yet.", + "bannedSongsEmpty": "No songs were added to banlist yet." +} \ No newline at end of file diff --git a/backend/locales/da/ui.systems.timers.json b/backend/locales/da/ui.systems.timers.json new file mode 100644 index 000000000..70bcf937f --- /dev/null +++ b/backend/locales/da/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "New Timer", + "empty": "No timers were created yet.", + "emptyAfterSearch": "No timers were found by your search for \"$search\".", + "add_response": "Add Response", + "settings": { + "enabled": "Status" + }, + "warning": "This action cannot be reverted!" +} \ No newline at end of file diff --git a/backend/locales/da/ui.widgets.customvariables.json b/backend/locales/da/ui.widgets.customvariables.json new file mode 100644 index 000000000..761875e3b --- /dev/null +++ b/backend/locales/da/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "No custom variables found, add at custom variables registry", + "add-variable-into-watchlist": "Add variable to watchlist", + "watchlist": "Watchlist" +} \ No newline at end of file diff --git a/backend/locales/da/ui.widgets.randomizer.json b/backend/locales/da/ui.widgets.randomizer.json new file mode 100644 index 000000000..17a70ebb9 --- /dev/null +++ b/backend/locales/da/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "No randomizer found, add at randomizer registry", + "add-randomizer-to-widget": "Add randomizer to widget" +} \ No newline at end of file diff --git a/backend/locales/da/ui/categories.json b/backend/locales/da/ui/categories.json new file mode 100644 index 000000000..fc4be8abb --- /dev/null +++ b/backend/locales/da/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Announcements", + "keys": "Keys", + "currency": "Currency", + "general": "General", + "settings": "Settings", + "commands": "Commands", + "bot": "Bot", + "channel": "Channel", + "connection": "Connection", + "chat": "Chat", + "graceful_exit": "Graceful exit", + "rewards": "Rewards", + "levels": "Levels", + "notifications": "Notifications", + "options": "Options", + "comboBreakMessages": "Combo Break Messages", + "hypeMessages": "Hype Messages", + "messages": "Messages", + "results": "Results", + "customization": "Customization", + "status": "Status", + "mapping": "Mapping", + "player": "Player", + "stats": "Stats", + "api": "API", + "token": "Token", + "text": "Text", + "custom_texts": "Custom texts", + "credits": "Credits", + "show": "Show", + "social": "Social", + "explosion": "Explosion", + "fireworks": "Fireworks", + "test": "Test", + "emotes": "Emotes", + "default": "Default", + "urls": "URLs", + "conversion": "Conversion", + "xp": "XP", + "caps_filter": "Caps filter", + "color_filter": "Italic (/me) filter", + "links_filter": "Links filter", + "symbols_filter": "Symbols filter", + "longMessage_filter": "Message length filter", + "spam_filter": "Spam filter", + "emotes_filter": "Emotes filter", + "warnings": "Warnings", + "reset": "Reset", + "reminder": "Reminder", + "eligibility": "Eligibility", + "join": "Join", + "luck": "Luck", + "lists": "Lists", + "me": "Me", + "emotes_combo": "Emotes combo", + "tmi": "tmi", + "oauth": "oauth", + "eventsub": "eventsub", + "rules": "rules" +} \ No newline at end of file diff --git a/backend/locales/da/ui/core/currency.json b/backend/locales/da/ui/core/currency.json new file mode 100644 index 000000000..112000002 --- /dev/null +++ b/backend/locales/da/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Primær valuta" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/core/general.json b/backend/locales/da/ui/core/general.json new file mode 100644 index 000000000..15ea3350b --- /dev/null +++ b/backend/locales/da/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Bot sprog", + "numberFormat": "Format of numbers in chat", + "gracefulExitEachXHours": { + "title": "Hensynsfuld nedlukning hver X time", + "help": "0 - deaktiveret" + }, + "shouldGracefulExitHelp": "Aktivering af hensynsfuld nedlukning anbefales på det kraftigste hvis din bot kører konstant på en server. Du skal have din bot til at kører på pm2 (eller lignende service), eller have den som docker for at sikre automatisk genstart. Bot genstarter IKKE, hvis stream er online." + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/core/oauth.json b/backend/locales/da/ui/core/oauth.json new file mode 100644 index 000000000..8f4ecb05b --- /dev/null +++ b/backend/locales/da/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Ejere", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "Brugernavn", + "channelUsername": "Username", + "botExpectedScopes": "Områder", + "channelExpectedScopes": "Scopes" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/core/permissions.json b/backend/locales/da/ui/core/permissions.json new file mode 100644 index 000000000..07aec34c0 --- /dev/null +++ b/backend/locales/da/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Add new permission group", + "higherPermissionHaveAccessToLowerPermissions": "Higher Permission have access to lower permissions.", + "typeUsernameOrIdToSearch": "Type username or ID to search", + "typeUsernameOrIdToTest": "Type username or ID to test", + "noUsersWereFound": "No users were found.", + "noUsersManuallyAddedToPermissionYet": "No users were manually added to permission yet.", + "done": "Done", + "previous": "Previous", + "next": "Next", + "loading": "loading", + "permissionNotFoundInDatabase": "Permission not found in database, please save before testing user.", + "userHaveNoAccessToThisPermissionGroup": "User $username DOESN'T have access to this permission group.", + "userHaveAccessToThisPermissionGroup": "User $username HAVE access to this permission group.", + "accessDirectlyThrough": "Direct access through", + "accessThroughHigherPermission": "Access through higher permission", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Something went wrong, user $username was not found in bot database.", + "permissionsGroups": "Permissions Groups", + "allowHigherPermissions": "Allow access through higher permission", + "type": "Type", + "value": "Value", + "watched": "Watched time in hours", + "followtime": "Follow time in months", + "points": "Points", + "tips": "Tips", + "bits": "Bits", + "messages": "Messages", + "subtier": "Sub Tier (1, 2, or 3)", + "subcumulativemonths": "Sub cumulative months", + "substreakmonths": "Current sub streak", + "ranks": "Current rank", + "level": "Current level", + "isLowerThan": "is lower than", + "isLowerThanOrEquals": "is lower than or equals", + "equals": "equals", + "isHigherThanOrEquals": "is higher than or equals", + "isHigherThan": "is higher than", + "addFilter": "Add filter", + "selectPermissionGroup": "Select permission group", + "settings": "Settings", + "name": "Name", + "baseUsersSet": "Base set of users", + "manuallyAddedUsers": "Manually added users", + "manuallyExcludedUsers": "Manually excluded users", + "filters": "Filters", + "testUser": "Test user", + "none": "- none -", + "casters": "Casters", + "moderators": "Moderators", + "subscribers": "Subscribers", + "vip": "VIP", + "viewers": "Viewers", + "followers": "Followers" +} \ No newline at end of file diff --git a/backend/locales/da/ui/core/socket.json b/backend/locales/da/ui/core/socket.json new file mode 100644 index 000000000..642e2ea4b --- /dev/null +++ b/backend/locales/da/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Ryd Alle Godkendte Forbindelser (også din egen)", + "accessTokenExpirationTime": "AccessToken udløbstid (sekunder)", + "refreshTokenExpirationTime": "RefreshToken udløbstid (sekunder)", + "socketToken": { + "title": "SocketToken", + "help": "Dette token vil give dig fuld admin-adgang ved brug af sockets. Del den ikke med andre!" + } + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/core/tmi.json b/backend/locales/da/ui/core/tmi.json new file mode 100644 index 000000000..6dab0ce4b --- /dev/null +++ b/backend/locales/da/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Ignorér liste (ID eller brugernavn)", + "showWithAt": "Vis brugere med @", + "sendWithMe": "Send beskeder med /me", + "sendAsReply": "Send bot messages as replies", + "mute": "Bot er lydløs", + "whisperListener": "Lyt til kommandoer på Whisper" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/core/tts.json b/backend/locales/da/ui/core/tts.json new file mode 100644 index 000000000..f4b8119bc --- /dev/null +++ b/backend/locales/da/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Service" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/core/twitch.json b/backend/locales/da/ui/core/twitch.json new file mode 100644 index 000000000..e056c6a1e --- /dev/null +++ b/backend/locales/da/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Create stream marker on event" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/core/ui.json b/backend/locales/da/ui/core/ui.json new file mode 100644 index 000000000..c0b99ce2f --- /dev/null +++ b/backend/locales/da/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Standardtema", + "domain": { + "title": "Domain", + "help": "Format uden http/https: ditdomain.dk eller dit.domain.dk" + }, + "percentage": "Procentsats forskel i statistik", + "shortennumbers": "Kort format for numre", + "showdiff": "Vis forskel", + "enablePublicPage": "Enable public page" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/core/updater.json b/backend/locales/da/ui/core/updater.json new file mode 100644 index 000000000..b93fa3738 --- /dev/null +++ b/backend/locales/da/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Automatically update if newer version available" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/errors.json b/backend/locales/da/ui/errors.json new file mode 100644 index 000000000..8b3e4bef8 --- /dev/null +++ b/backend/locales/da/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Unexpected errors during validation", + "isNotEmpty": "$property is required.", + "minLength": "$property must be longer than or equal to $constraint1 characters.", + "isPositive": "$property must be greater then 0", + "isCommand": "$property must start with !", + "isCommandOrCustomVariable": "$property must start with ! or $_", + "isCustomVariable": "$property must start with $_", + "min": "$property must be at least $constraint1", + "max": "$property must be lower or equal to $constraint1", + "isInt": "$property must be an integer", + "this_value_must_be_a_positive_number_and_greater_then_0": "This value must be a positive number or greater then 0", + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty", + "minLength_of_value_is": "Minimal length is $value.", + "this_currency_is_not_supported": "This currency is not supported", + "something_went_wrong": "Something went wrong", + "permission_must_exist": "Permission must exist", + "minValue_of_value_is": "Minimal value is $value", + "value_cannot_be": "Value cannot be $value.", + "invalid_format": "Invalid value format.", + "invalid_regexp_format": "This is not valid regex.", + "owner_and_broadcaster_oauth_is_not_set": "Owner and channel oauth is not set", + "channel_is_not_set": "Channel is not set", + "please_set_your_broadcaster_oauth_or_owners": "Please set your channel oauth or owners, or all users will have access to this dashboard and will be considered as casters.", + "new_update_available": "New update available", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "One of inputs must be set" +} \ No newline at end of file diff --git a/backend/locales/da/ui/games/duel.json b/backend/locales/da/ui/games/duel.json new file mode 100644 index 000000000..abe582add --- /dev/null +++ b/backend/locales/da/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Status", + "cooldown": "Nedkøling", + "duration": { + "title": "Varighed", + "help": "Minutter" + }, + "minimalBet": "Minimal indsats", + "bypassCooldownByOwnerAndMods": "Bypass nedkøling af ejer og mods" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/games/gamble.json b/backend/locales/da/ui/games/gamble.json new file mode 100644 index 000000000..3dadf1b15 --- /dev/null +++ b/backend/locales/da/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "minimalBet": "Minimal indsats", + "chanceToWin": { + "title": "Chance for at vinde", + "help": "Procent" + }, + "enableJackpot": "Aktivér jackpot", + "chanceToTriggerJackpot": "Chance for at udløse jackpot i %", + "maxJackpotValue": "Maksimal jackpotværdi", + "lostPointsAddedToJackpot": "Hvor mange mistede point skal tilføjes til jackpot i %" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/games/heist.json b/backend/locales/da/ui/games/heist.json new file mode 100644 index 000000000..e0ffc9feb --- /dev/null +++ b/backend/locales/da/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Heist", + "settings": { + "enabled": "Status", + "showMaxUsers": "Max users to show in payout", + "copsCooldownInMinutes": { + "title": "Cooldown between heists", + "help": "Minutes" + }, + "entryCooldownInSeconds": { + "title": "Time to entry heist", + "help": "Seconds" + }, + "started": "Heist start message", + "nextLevelMessage": "Message when next level is reached", + "maxLevelMessage": "Message when max level is reached", + "copsOnPatrol": "Response of bot when heist is still on cooldown", + "copsCooldown": "Bot announcement when heist can be started", + "singleUserSuccess": "Success message for one user", + "singleUserFailed": "Fail message for one user", + "noUser": "Message if no user participated" + }, + "message": "Message", + "winPercentage": "Win percentage", + "payoutMultiplier": "Payout multiplier", + "maxUsers": "Max users for level", + "percentage": "Percentage", + "noResultsFound": "No results found. Click button below to add new result.", + "noLevelsFound": "No levels found. Click button below to add new level." +} \ No newline at end of file diff --git a/backend/locales/da/ui/games/roulette.json b/backend/locales/da/ui/games/roulette.json new file mode 100644 index 000000000..64fec3ede --- /dev/null +++ b/backend/locales/da/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout varighed", + "help": "Sekunder" + }, + "winnerWillGet": "Hvor mange point vil blive tilføjet ved gevinst", + "loserWillLose": "Hvor mange point vil gå tabt ved tab" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/games/seppuku.json b/backend/locales/da/ui/games/seppuku.json new file mode 100644 index 000000000..529b686a0 --- /dev/null +++ b/backend/locales/da/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout varighed", + "help": "Sekunder" + } + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/integrations/discord.json b/backend/locales/da/ui/integrations/discord.json new file mode 100644 index 000000000..baa645ab9 --- /dev/null +++ b/backend/locales/da/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Status", + "guild": "Guild", + "listenAtChannels": "Listen for commands on this channel", + "sendOnlineAnnounceToChannel": "Send online announcement to this channel", + "onlineAnnounceMessage": "Message in online announcement (can include mentions)", + "sendAnnouncesToChannel": "Setup sending of announcements to channels", + "deleteMessagesAfterWhile": "Delete message after while", + "clientId": "ClientId", + "token": "Token", + "joinToServerBtn": "Click to join bot to your server", + "joinToServerBtnDisabled": "Please save changes to enable bot join to your server", + "cannotJoinToServerBtn": "Set token and clientId to be able to join bot to your server", + "noChannelSelected": "no channel selected", + "noRoleSelected": "no role selected", + "noGuildSelected": "no guild selected", + "noGuildSelectedBox": "Select guild where bot should work and you'll see more settings", + "onlinePresenceStatusDefault": "Default Status", + "onlinePresenceStatusDefaultName": "Default Status Message", + "onlinePresenceStatusOnStream": "Status when Streaming", + "onlinePresenceStatusOnStreamName": "Status Message when Streaming", + "ignorelist": { + "title": "Ignore list", + "help": "username, username#0000 or userID" + } + } +} diff --git a/backend/locales/da/ui/integrations/donatello.json b/backend/locales/da/ui/integrations/donatello.json new file mode 100644 index 000000000..75bd1598d --- /dev/null +++ b/backend/locales/da/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Get your token at https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/integrations/donationalerts.json b/backend/locales/da/ui/integrations/donationalerts.json new file mode 100644 index 000000000..67cf81420 --- /dev/null +++ b/backend/locales/da/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Status", + "access_token": { + "title": "Access token", + "help": "Få din access token her https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Refresh token" + }, + "accessTokenBtn": "DonationAlerts access and refresh token generator" + } +} diff --git a/backend/locales/da/ui/integrations/kofi.json b/backend/locales/da/ui/integrations/kofi.json new file mode 100644 index 000000000..a8179bf1e --- /dev/null +++ b/backend/locales/da/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Verification token", + "help": "Get your verification token at https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "Webhook URL", + "help": "Set Webhook URL at https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL must have HTTPS", + "origin": "You cannot use localhost for webhooks" + } + } + } +} diff --git a/backend/locales/da/ui/integrations/lastfm.json b/backend/locales/da/ui/integrations/lastfm.json new file mode 100644 index 000000000..a9fe017e9 --- /dev/null +++ b/backend/locales/da/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": "API-nøgle", + "username": "Brugernavn" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/integrations/obswebsocket.json b/backend/locales/da/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..e744a37c8 --- /dev/null +++ b/backend/locales/da/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Status", + "accessBy": { + "title": "Tilgængelig for", + "help": "Direkte - tilslut direkte fra en bot | Overlag - tilslut via overlag browserkilde" + }, + "address": "Adresse", + "password": "Adgangskode" + }, + "noSourceSelected": "Ingen kilde valgt", + "noSceneSelected": "Ingen scene valgt", + "empty": "Ingen handlingssæt er blevet oprettet endnu.", + "emptyAfterSearch": "Ingen handlingsssæt blev fundet ved din søgning efter \"$search\".", + "command": "Kommando", + "new": "Opret nyt OBS Websocket handlingssæt", + "actions": "Handlinger", + "name": { + "name": "Navn" + }, + "mute": "Lyd fra", + "unmute": "Lyd til", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Vent X millisekunder" + }, + "Log": { + "name": "Log meddelelser" + }, + "StartRecording": { + "name": "StartOptagelse" + }, + "StopRecording": { + "name": "StopOptagelse" + }, + "PauseRecording": { + "name": "PauseOptagelse" + }, + "ResumeRecording": { + "name": "FortsætOptagelse" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/integrations/pubg.json b/backend/locales/da/ui/integrations/pubg.json new file mode 100644 index 000000000..166aef5d9 --- /dev/null +++ b/backend/locales/da/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "API Key", + "help": "Get your API Key at https://developer.pubg.com/" + }, + "platform": "Platform", + "playerName": "Player Name", + "playerId": "Player ID", + "seasonId": { + "title": "Season ID", + "help": "Current season ID is being fetch every hour." + }, + "rankedGameModeStatsCustomization": "Customized message for ranked stats", + "gameModeStatsCustomization": "Customized message for normal stats" + }, + "click_to_fetch": "Click to fetch", + "something_went_wrong": "Something went wrong!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Stats are automatically refreshed every 10 minutes.", + "player_stats_ranked": "Player stats (ranked)", + "player_stats": "Player stats" +} diff --git a/backend/locales/da/ui/integrations/qiwi.json b/backend/locales/da/ui/integrations/qiwi.json new file mode 100644 index 000000000..e5f7cb336 --- /dev/null +++ b/backend/locales/da/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "secretToken": { + "title": "Secret token", + "help": "Get secret token at Qiwi Donate dashboard settings->click show secret token" + } + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/integrations/responsivevoice.json b/backend/locales/da/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..5d6ed16d6 --- /dev/null +++ b/backend/locales/da/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Nøgle", + "help": "Få din nøgle på http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/integrations/spotify.json b/backend/locales/da/ui/integrations/spotify.json new file mode 100644 index 000000000..e3f885ae2 --- /dev/null +++ b/backend/locales/da/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Artists", + "settings": { + "enabled": "Status", + "songRequests": "Song Requests", + "fetchCurrentSongWhenOffline": { + "title": "Fetch current song when stream is offline", + "help": "It's advised to have this disabled to avoid reach API limits" + }, + "allowApprovedArtistsOnly": "Allow approved artists only", + "approvedArtists": { + "title": "Approved artists", + "help": "Name or SpotifyURI of artist, one item per line" + }, + "queueWhenOffline": { + "title": "Queue songs when stream is offline", + "help": "It's advised to have this disabled to avoid queueing when you are just listening music" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "Forced Device ID", + "help": "Empty = disabled, force spotify device ID to be used to queue songs. Check logs for current active device or use button when playing song for at least 10 seconds." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Format", + "help": "Available variables: $song, $artist, $artists" + }, + "username": "Authorized user", + "revokeBtn": "Revoke user authorization", + "authorizeBtn": "Authorize user", + "scopes": "Scopes", + "playlistToPlay": { + "title": "Spotify URI of main playlist", + "help": "If set, after request finished this playlist will continue" + }, + "continueOnPlaylistAfterRequest": "Continue on playing of playlist after song request", + "notify": "Send message on song change" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/integrations/streamelements.json b/backend/locales/da/ui/integrations/streamelements.json new file mode 100644 index 000000000..b983c17ff --- /dev/null +++ b/backend/locales/da/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "jwtToken": { + "title": "JWT token", + "help": "Get JWT token at StreamElements Channels setting and toggle Show secrets" + } + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/integrations/streamlabs.json b/backend/locales/da/ui/integrations/streamlabs.json new file mode 100644 index 000000000..a2c359f1b --- /dev/null +++ b/backend/locales/da/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "socketToken": { + "title": "Socket token", + "help": "Get socket token from streamlabs dashboard API settings->API tokens->Your Socket API Token" + }, + "accessToken": { + "title": "Access token", + "help": "Get your access token at https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "StreamLabs access token generator" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/integrations/tipeeestream.json b/backend/locales/da/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..880b7bcfe --- /dev/null +++ b/backend/locales/da/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "Api key", + "help": "Get socket token from tipeeestream dashboard -> API -> Your API Key" + } + } +} diff --git a/backend/locales/da/ui/integrations/twitter.json b/backend/locales/da/ui/integrations/twitter.json new file mode 100644 index 000000000..940fd5589 --- /dev/null +++ b/backend/locales/da/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "consumerKey": "Consumer Key (API Key)", + "consumerSecret": "Consumer Secret (API Secret)", + "accessToken": "Access Token", + "secretToken": "Access Token Secret" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/managers.json b/backend/locales/da/ui/managers.json new file mode 100644 index 000000000..c19542bfa --- /dev/null +++ b/backend/locales/da/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "Vis begivenhedshistorik", + "hostAndRaidViewersCount": "Seere: $value", + "receivedSubscribeFrom": "Modtog abonnement fra $value", + "giftedSubscribeTo": "Modtog gave-abonnement fra $value" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/overlays/alerts.json b/backend/locales/da/ui/overlays/alerts.json new file mode 100644 index 000000000..f12d94794 --- /dev/null +++ b/backend/locales/da/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Husk (cache) galleri emner", + "galleryCacheLimitInMb": "Maks. størrelse på galleri-emner (i MB) til hukommelse (cache)" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/overlays/clips.json b/backend/locales/da/ui/overlays/clips.json new file mode 100644 index 000000000..99304c7c5 --- /dev/null +++ b/backend/locales/da/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Lydstyrke", + "cClipsFilter": "Klip filter", + "cClipsLabel": "Vis 'klip' etiket" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/overlays/clipscarousel.json b/backend/locales/da/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..b50a0a71d --- /dev/null +++ b/backend/locales/da/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsTimeToNextClip": "Time to next clip (s)" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/overlays/credits.json b/backend/locales/da/ui/overlays/credits.json new file mode 100644 index 000000000..88ff2e6d9 --- /dev/null +++ b/backend/locales/da/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Hastighed", + "cCreditsAggregated": "Sammenlæg credits", + "cShowGameThumbnail": "Show game thumbnail", + "cShowFollowers": "Vis følgere", + "cShowRaids": "Vis raids", + "cShowSubscribers": "Vis abonnementer", + "cShowSubgifts": "Vis gave-abonnementer", + "cShowSubcommunitygifts": "Vis gave-abonnementer til fællesskabet", + "cShowResubs": "Vis gentegning af abonnementer", + "cShowCheers": "Show cheers", + "cShowClips": "Vis klips", + "cShowTips": "Vis tips", + "cTextLastMessage": "Sidste besked", + "cTextLastSubMessage": "Seneste abonnement-besked", + "cTextStreamBy": "Streamed af", + "cTextFollow": "Fulgt af", + "cTextRaid": "Raided af", + "cTextCheer": "Cheer by", + "cTextSub": "Abonnementer fra", + "cTextResub": "Gentegnede abonnementer fra", + "cTextSubgift": "Gave-abonnementer", + "cTextSubcommunitygift": "Gave-abonnementer til fællesskabet", + "cTextTip": "Tips fra", + "cClipsPeriod": "Tidsinterval", + "cClipsCustomPeriodInDays": "Tilpasset tidsinterval (dage)", + "cClipsNumOfClips": "Antal klip", + "cClipsShouldPlay": "Klip skal afspilles", + "cClipsVolume": "Lydstyrke" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/overlays/emotes.json b/backend/locales/da/ui/overlays/emotes.json new file mode 100644 index 000000000..8a961ad91 --- /dev/null +++ b/backend/locales/da/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Delete cache", + "hypeMessagesEnabled": "Show hype messages in chat", + "btnTestExplosion": "Test emote explosion", + "btnTestEmote": "Test emote", + "btnTestFirework": "Test emote firework", + "cEmotesSize": "Emotes size", + "cEmotesMaxEmotesPerMessage": "Maximum of emotes per message", + "cEmotesMaxRotation": "Maximal rotation of emote", + "cEmotesOffsetX": "Maximal offset on X-axis", + "cEmotesAnimation": "Animation", + "cEmotesAnimationTime": "Animation duration", + "cExplosionNumOfEmotes": "No. of emotes", + "cExplosionNumOfEmotesPerExplosion": "No. of emotes per explosion", + "cExplosionNumOfExplosions": "No. of explosions", + "enableEmotesCombo": "Enable emotes combo", + "comboBreakMessages": "Combo break messages", + "threshold": "Threshold", + "noMessagesFound": "No messages found.", + "message": "Message", + "showEmoteInOverlayThreshold": "Minimal message threshold to show emote in overlay", + "hideEmoteInOverlayAfter": { + "title": "Hide emote in overlay after inactivity", + "help": "Will hide emote in overlay after certain time in seconds" + }, + "comboCooldown": { + "title": "Combo cooldown", + "help": "Cooldown of combo in seconds" + }, + "comboMessageMinThreshold": { + "title": "Minimal message threshold", + "help": "Minimal message threshold to count emotes as combo (until then won't trigger cooldown)" + }, + "comboMessages": "Combo messages" + }, + "hype": { + "5": "Let's go! We got $amountx $emote combo so far! SeemsGood", + "15": "Keep it going! Can we get more than $amountx $emote? TriHard" + }, + "message": { + "3": "$amountx $emote combo", + "5": "$amountx $emote combo SeemsGood", + "10": "$amountx $emote combo PogChamp", + "15": "$amountx $emote combo TriHard", + "20": "$sender ruined $amountx $emote combo! NotLikeThis" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/overlays/polls.json b/backend/locales/da/ui/overlays/polls.json new file mode 100644 index 000000000..da094ce9b --- /dev/null +++ b/backend/locales/da/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Theme", + "cDisplayHideAfterInactivity": "Hide on inactivity", + "cDisplayAlign": "Align", + "cDisplayInactivityTime": { + "title": "Inactivity after", + "help": "in miliseconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/overlays/texttospeech.json b/backend/locales/da/ui/overlays/texttospeech.json new file mode 100644 index 000000000..8c9271893 --- /dev/null +++ b/backend/locales/da/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "Du har ikke angivet korrekt ResponsiveVoice key", + "voice": { + "title": "Stemme", + "help": "Hvis stemmer ikke indlæses korrekt efter opdatering af ResponsiveVoice-nøgle, så prøv at opdatere browseren" + }, + "volume": "Lydstyrke", + "rate": "Hastighed", + "pitch": "Toneleje", + "triggerTTSByHighlightedMessage": "Tekst-til-Tale vil blive udløst af fremhævet besked" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/properties.json b/backend/locales/da/ui/properties.json new file mode 100644 index 000000000..e6243cf72 --- /dev/null +++ b/backend/locales/da/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Command", + "variableName": "Variable name", + "price": "Price (points)", + "priceBits": "Price (bits)", + "thisvalue": "This value", + "promo": { + "shoutoutMessage": "Shoutout message", + "enableShoutoutMessage": "Send shoutout message in chat" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/registry/alerts.json b/backend/locales/da/ui/registry/alerts.json new file mode 100644 index 000000000..d12319c25 --- /dev/null +++ b/backend/locales/da/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Enabled", + "testDlg": { + "alertTester": "Alert tester", + "command": "Command", + "username": "Username", + "recipient": "Recipient", + "message": "Message", + "tier": "Tier", + "amountOfViewers": "Amount of viewers", + "amountOfBits": "Amount of bits", + "amountOfGifts": "Amount of gifts", + "amountOfMonths": "Amount of months", + "amountOfTips": "Tip", + "event": "Event", + "service": "Service" + }, + "empty": "Alerts registry is empty, create new alerts.", + "emptyAfterSearch": "Alerts registry is empty in searching for \"$search\"", + "revertcode": "Revert code to defaults", + "name": { + "name": "Name", + "placeholder": "Set name of your alerts" + }, + "alertDelayInMs": { + "name": "Alert delay" + }, + "parryEnabled": { + "name": "Alert parries" + }, + "parryDelay": { + "name": "Alert parry delay" + }, + "profanityFilterType": { + "name": "Profanity filter", + "disabled": "Disabled", + "replace-with-asterisk": "Replace with asterisk", + "replace-with-happy-words": "Replace with happy words", + "hide-messages": "Hide messages", + "disable-alerts": "Disable alerts" + }, + "loadStandardProfanityList": "Load standard profanity list", + "customProfanityList": { + "name": "Custom profanity list", + "help": "Words should be separated with comma." + }, + "event": { + "follow": "Follow", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Subgift to community", + "tip": "Tip", + "raid": "Raid", + "custom": "Custom", + "promo": "Promo", + "rewardredeem": "Reward Redeem" + }, + "title": { + "name": "Variant name", + "placeholder": "Set your variant name" + }, + "variant": { + "name": "Variant occurence" + }, + "filter": { + "name": "Filter", + "operator": "Operator", + "rule": "Rule", + "addRule": "Add rule", + "addGroup": "Add group", + "comparator": "Comparator", + "value": "Value", + "valueSplitByComma": "Values split by comma (e.g. val1, val2)", + "isEven": "is even", + "isOdd": "is odd", + "lessThan": "less than", + "lessThanOrEqual": "less than or equal", + "contain": "contains", + "contains": "contains", + "equal": "equal", + "notEqual": "not equal", + "present": "is present", + "includes": "includes", + "greaterThan": "greater than", + "greaterThanOrEqual": "greater than or equal", + "noFilter": "no filter" + }, + "speed": { + "name": "Speed" + }, + "maxTimeToDecrypt": { + "name": "Max time to decrypt" + }, + "characters": { + "name": "Characters" + }, + "random": "Random", + "exact-amount": "Exact amount", + "greater-than-or-equal-to-amount": "Greater than or equal to amount", + "tier-exact-amount": "Tier is exactly", + "tier-greater-than-or-equal-to-amount": "Tier is higher or equal to", + "months-exact-amount": "Months amount is exactly", + "months-greater-than-or-equal-to-amount": "Months amount is higher or equal to", + "gifts-exact-amount": "Gifts amount is exactly", + "gifts-greater-than-or-equal-to-amount": "Gifts amount is higher or equal to", + "very-rarely": "Very rarely", + "rarely": "Rarely", + "default": "Default", + "frequently": "Frequently", + "very-frequently": "Very frequently", + "exclusive": "Exclusive", + "messageTemplate": { + "name": "Message template", + "placeholder": "Set your message template", + "help": "Available variables: {name}, {amount} (cheers, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). If | is added (see promo) then it will show those values in sequence." + }, + "ttsTemplate": { + "name": "TTS template", + "placeholder": "Set your TTS template", + "help": "Available variables: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Animation text" + }, + "animationType": { + "name": "Type of animation" + }, + "animationIn": { + "name": "Animation in" + }, + "animationOut": { + "name": "Animation out" + }, + "alertDurationInMs": { + "name": "Alert duration" + }, + "alertTextDelayInMs": { + "name": "Alert text delay" + }, + "layoutPicker": { + "name": "Layout" + }, + "loop": { + "name": "Play on loop" + }, + "scale": { + "name": "Scale" + }, + "translateY": { + "name": "Move -Up / +Down" + }, + "translateX": { + "name": "Move -Left / +Right" + }, + "image": { + "name": "Image / Video(.webm)", + "setting": "Image / Video(.webm) settings" + }, + "sound": { + "name": "Sound", + "setting": "Sound settings" + }, + "soundVolume": { + "name": "Alert volume" + }, + "enableAdvancedMode": "Enable advanced mode", + "font": { + "setting": "Font settings", + "name": "Font family", + "overrideGlobal": "Override global font settings", + "align": { + "name": "Alignment", + "left": "Left", + "center": "Center", + "right": "Right" + }, + "size": { + "name": "Font size" + }, + "weight": { + "name": "Font weight" + }, + "borderPx": { + "name": "Font border" + }, + "borderColor": { + "name": "Font border color" + }, + "color": { + "name": "Font color" + }, + "highlightcolor": { + "name": "Font highlight color" + } + }, + "minAmountToShow": { + "name": "Minimal amount to show" + }, + "minAmountToPlay": { + "name": "Minimal amount to play" + }, + "allowEmotes": { + "name": "Allow emotes" + }, + "message": { + "setting": "Message settings" + }, + "voice": "Voice", + "keepAlertShown": "Alert keeps visible during TTS", + "skipUrls": "Skip URLs during TTS", + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "test": "Test", + "tts": { + "setting": "TTS settings" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/registry/goals.json b/backend/locales/da/ui/registry/goals.json new file mode 100644 index 000000000..8c486828d --- /dev/null +++ b/backend/locales/da/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Add Goal Group", + "addGoal": "Add Goal", + "newGoal": "new Goal", + "newGoalGroup": "new Goal Group", + "goals": "Goals", + "general": "General", + "display": "Display", + "fontSettings": "Font Settings", + "barSettings": "Bar Settings", + "selectGoalOnLeftSide": "Select or add goal on left side", + "input": { + "description": { + "title": "Description" + }, + "goalAmount": { + "title": "Goal Amount" + }, + "countBitsAsTips": { + "title": "Count Bits as Tips" + }, + "currentAmount": { + "title": "Current Amount" + }, + "endAfter": { + "title": "End After" + }, + "endAfterIgnore": { + "title": "Goal will not expire" + }, + "borderPx": { + "title": "Border", + "help": "Border size is in pixels" + }, + "barHeight": { + "title": "Bar Height", + "help": "Bar height is in pixels" + }, + "color": { + "title": "Color" + }, + "borderColor": { + "title": "Border Color" + }, + "backgroundColor": { + "title": "Background Color" + }, + "type": { + "title": "Type" + }, + "nameGroup": { + "title": "Name of this goal group" + }, + "name": { + "title": "Name of this goal" + }, + "displayAs": { + "title": "Display as", + "help": "Sets how goal group will be shown" + }, + "durationMs": { + "title": "Duration", + "help": "This value is in milliseconds", + "placeholder": "How long goal should be shown" + }, + "animationInMs": { + "title": "Animation In duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation In duration" + }, + "animationOutMs": { + "title": "Animation Out duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation Out duration" + }, + "interval": { + "title": "What interval to count" + }, + "spaceBetweenGoalsInPx": { + "title": "Space between goals", + "help": "This value is in pixels", + "placeholder": "Set your space between goals" + } + }, + "groupSettings": "Group Settings" +} \ No newline at end of file diff --git a/backend/locales/da/ui/registry/overlays.json b/backend/locales/da/ui/registry/overlays.json new file mode 100644 index 000000000..f56199cb8 --- /dev/null +++ b/backend/locales/da/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Create new overlay link mapping", + "emptyMapping": "No overlay link mapping were created yet.", + "allowedIPs": { + "name": "Allowed IPs", + "help": "Allow access from set IPs separated by new line" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/registry/plugins.json b/backend/locales/da/ui/registry/plugins.json new file mode 100644 index 000000000..00eb1444f --- /dev/null +++ b/backend/locales/da/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "This node needs to be linked with listeners with sender attributes" + }, + "filter": { + "permission": { + "name": "Permission filter" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Event listener", + "type": { + "twitchChatMessage": "Twitch chat message", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat cleared", + "twitchCommand": "Twitch command", + "twitchFollow": "New Twitch follower", + "twitchSubscription": "New Twitch subscription", + "twitchSubgift": "New Twitch subscription gift", + "twitchSubcommunitygift": "New Twitch subscription community gift", + "twitchResub": "New Twitch recurring subscription", + "twitchGameChanged": "Twitch category changed", + "twitchStreamStarted": "Twitch stream started", + "twitchStreamStopped": "Twitch stream stopped", + "twitchRewardRedeem": "Twitch reward redeemed", + "twitchRaid": "Twitch raid incoming", + "tip": "Tipped by user", + "botStarted": "Bot started" + }, + "command": { + "add-parameter": "Add parameter", + "parameters": "Parameters", + "order-is-important": "order is important" + } + }, + "others": { + "idle": { + "name": "Idle" + } + }, + "output": { + "log": { + "name": "Log message" + }, + "timeout-user": { + "name": "Timeout user" + }, + "ban-user": { + "name": "Ban user" + }, + "send-twitch-message": { + "name": "Send Twitch Message" + } + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/registry/randomizer.json b/backend/locales/da/ui/registry/randomizer.json new file mode 100644 index 000000000..5f728918b --- /dev/null +++ b/backend/locales/da/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Add Randomizer", + "form": { + "name": "Name", + "command": "Command", + "permission": "Command permission", + "simple": "Simple", + "tape": "Tape", + "wheelOfFortune": "Wheel of Fortune", + "type": "Type", + "options": "Options", + "optionsAreEmpty": "Options are empty.", + "color": "Color", + "numOfDuplicates": "No. of duplicates", + "minimalSpacing": "Minimal spacing", + "groupUp": "Group Up", + "ungroup": "Ungroup", + "groupedWithOptionAbove": "Grouped with option above", + "generatedOptionsPreview": "Preview of generated options", + "probability": "Probability", + "tick": "Tick sound during spin" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/registry/textoverlay.json b/backend/locales/da/ui/registry/textoverlay.json new file mode 100644 index 000000000..03e974b69 --- /dev/null +++ b/backend/locales/da/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Create new text overlay", + "title": "text overlay", + "name": { + "placeholder": "Set your text overlay name" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/stats/commandcount.json b/backend/locales/da/ui/stats/commandcount.json new file mode 100644 index 000000000..edbbc4ad7 --- /dev/null +++ b/backend/locales/da/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Kommando", + "hour": "Time", + "day": "Dag", + "week": "Uge", + "month": "Måned", + "year": "År", + "total": "I alt" +} \ No newline at end of file diff --git a/backend/locales/da/ui/systems/checklist.json b/backend/locales/da/ui/systems/checklist.json new file mode 100644 index 000000000..74df71322 --- /dev/null +++ b/backend/locales/da/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "itemsArray": "Oversigt" + }, + "check": "Tjekliste" +} \ No newline at end of file diff --git a/backend/locales/da/ui/systems/howlongtobeat.json b/backend/locales/da/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..a9dcc7f7a --- /dev/null +++ b/backend/locales/da/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Status" + }, + "empty": "No games were tracked yet.", + "emptyAfterSearch": "No tracked games were found by your search for \"$search\".", + "when": "When streamed", + "time": "Tracked time", + "overallTime": "Overall time", + "offset": "Offset of tracked time", + "main": "Main", + "extra": "Main+Extra", + "completionist": "Completionist", + "game": "Tracked game", + "startedAt": "Tracking started at", + "updatedAt": "Last update", + "showHistory": "Show history ($count)", + "hideHistory": "Hide history ($count)", + "searchToAddNewGame": "Search to add new game to track" +} \ No newline at end of file diff --git a/backend/locales/da/ui/systems/keywords.json b/backend/locales/da/ui/systems/keywords.json new file mode 100644 index 000000000..9ba109888 --- /dev/null +++ b/backend/locales/da/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "Nyt Keyword", + "empty": "Ingen keywords er blevet oprettet endnu.", + "emptyAfterSearch": "Ingen keywords blev fundet ved din søgning efter \"$search\".", + "keyword": { + "name": "Keyword / Regular Expression", + "placeholder": "Sæt dit keyword eller regular expression for at aktivere keyword.", + "help": "Du kan bruge regexp (ingen forskel på store og små-bogstaver) til keywords, f.eks hello.*|hi" + }, + "response": { + "name": "Svar", + "placeholder": "Sæt dit svar her." + }, + "error": { + "isEmpty": "Dette input må ikke være tomt" + }, + "no-responses-set": "Intet svar", + "addResponse": "Tilføj svar", + "filter": { + "name": "filter", + "placeholder": "Tilføj filter for dette svar" + }, + "warning": "Denne handling kan ikke fortrydes!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/systems/levels.json b/backend/locales/da/ui/systems/levels.json new file mode 100644 index 000000000..91bcaa02b --- /dev/null +++ b/backend/locales/da/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Status", + "conversionRate": "Conversion rate 1 XP for x Points", + "firstLevelStartsAt": "First level starts at XP", + "nextLevelFormula": { + "title": "Next level calculation formula", + "help": "Available variables: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "Levels example will be refreshed on save", + "xpName": "Name", + "interval": "Minutes interval to add xp to online users when stream online", + "offlineInterval": "Minutes interval to add xp to online users when stream offline", + "messageInterval": "How many messages to add xp", + "messageOfflineInterval": "How many messages to add xp when stream offline", + "perInterval": "How many xp to add per online interval", + "perOfflineInterval": "How many xp to add per offline interval", + "perMessageInterval": "How many xp to add per message interval", + "perMessageOfflineInterval": "How many xp to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/systems/polls.json b/backend/locales/da/ui/systems/polls.json new file mode 100644 index 000000000..f31a08052 --- /dev/null +++ b/backend/locales/da/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Total votes", + "totalPoints": "Total points", + "closedAt": "Closed at", + "activeFor": "Active for" +} \ No newline at end of file diff --git a/backend/locales/da/ui/systems/scrim.json b/backend/locales/da/ui/systems/scrim.json new file mode 100644 index 000000000..6b719fc3b --- /dev/null +++ b/backend/locales/da/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "waitForMatchIdsInSeconds": { + "title": "Interval for putting match ID into chat", + "help": "Set in seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/systems/top.json b/backend/locales/da/ui/systems/top.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/da/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/da/ui/systems/userinfo.json b/backend/locales/da/ui/systems/userinfo.json new file mode 100644 index 000000000..a952a67d5 --- /dev/null +++ b/backend/locales/da/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "formatSeparator": "Format separator", + "order": "Format", + "lastSeenFormat": { + "title": "Tidsformat", + "help": "Mulige formater kan findes på https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/de.json b/backend/locales/de.json new file mode 100644 index 000000000..fb77a1d88 --- /dev/null +++ b/backend/locales/de.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "wird geladen und", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "usage": "Nutzung", + "lang-selected": "Bot Sprache ist derzeit auf Deutsch gesetzt", + "refresh-panel": "Du musst die Benutzeroberfläche aktualisieren, um Änderungen zu sehen.", + "command-parse": "Sorry, $sender, aber dieser Befehl ist nicht korrekt, verwende", + "error": "Entschuldigung $sender, aber etwas ist schief gelaufen!", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, API reagiert nicht korrekt!", + "not-available": "nicht verfügbar" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "Jahr|Jahre", + "months": "Monat|Monate", + "days": "Tag|Tage", + "hours": "Stunde|Stunden", + "minutes": "Minute|Minuten", + "seconds": "Sekunde|Sekunden", + "messages": "Nachricht|Nachrichten", + "bits": "Bit|Bits", + "links": "Link|Links", + "entries": "eintrag|Einträge", + "empty": "leer", + "isRegistered": "$sender, Du kannst !$keyword nicht verwenden, da es bereits für eine andere Aktion verwendet wird!" + }, + "clip": { + "notCreated": "Etwas ist schief gelaufen und Clip wurde nicht erstellt.", + "offline": "Stream ist momentan offline und Clip kann nicht erstellt werden." + }, + "uptime": { + "online": "Stream ist online seit (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)", + "offline": "Stream ist momentan offline seit (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)" + }, + "webpanel": { + "this-system-is-disabled": "Dieses System ist deaktiviert", + "or": "oder", + "loading": "Wird geladen", + "this-may-take-a-while": "Das könnte ein wenig dauern", + "display-as": "Anzeigen als", + "go-to-admin": "Zurück zur Admin Oberfläche", + "go-to-public": "Gehe zur Öffentlichen Seite", + "logout": "Abmelden", + "popout": "Popout", + "not-logged-in": "Nicht angemeldet", + "remove-widget": "Entferne $name Widget", + "join-channel": "Verbinde den Bot zum Kanal", + "leave-channel": "Entferne den Bot vom Kanal", + "set-default": "Als Standard festlegen", + "add": "Hinzufügen", + "placeholders": { + "text-url-generator": "Füge deinen Text oder HTML-Code ein, um base64 unten und die URL oben zu generieren", + "text-decode-base64": "Füge Deinen Base64 ein, um URL und Text oben zu generieren", + "creditsSpeed": "Setze die Roll-Geschwindigkeit der Credits. niedriger = schneller" + }, + "timers": { + "title": "Timers", + "timer": "Timer", + "messages": "Nachrichten", + "seconds": "Sekunden", + "badges": { + "enabled": "Aktiviert", + "disabled": "Deaktiviert" + }, + "errors": { + "timer_name_must_be_compliant": "Dieser Wert kann nur a-zA-Z09_ enthalten", + "this_value_must_be_a_positive_number_or_0": "Dieser Wert muss eine positive Zahl haben oder größer als 0 sein", + "value_cannot_be_empty": "Dieser Wert darf nicht leer sein" + }, + "dialog": { + "timer": "Timer", + "name": "Name", + "tickOffline": "Soll markiert werden, wenn Stream offline ist", + "interval": "Intervall", + "responses": "Rückmeldungen", + "messages": "Alle X Nachrichten auslösen", + "seconds": "Alle X Sekunden auslösen", + "title": { + "new": "Neuer Timer", + "edit": "Timer bearbeiten" + }, + "placeholders": { + "name": "Name des Timers festlegen, darf nur diese Zeichen a-zA-Z0-9_ enthalten", + "messages": "Timer alle X Nachrichten auslösen", + "seconds": "Timer alle X Sekunden auslösen" + }, + "alerts": { + "success": "Timer wurde erfolgreich gespeichert.", + "fail": "Etwas ist schief gelaufen." + } + }, + "buttons": { + "close": "Schließen", + "save-changes": "Änderungen speichern", + "disable": "Deaktivieren", + "enable": "Aktivieren", + "edit": "Bearbeiten", + "delete": "Löschen", + "yes": "Ja", + "no": "Nein" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Bist du dir sicher, dass du diesen Timer löschen willst?" + } + }, + "events": { + "event": "Ereignis", + "noEvents": "Keine Ereignisse in der Datenbank gefunden.", + "whatsthis": "Was ist das?", + "myRewardIsNotListed": "Meine Belohnung ist nicht gelistet!", + "redeemAndClickRefreshToSeeReward": "Wenn Du Deine erstellte Belohnung in einer Liste vermisst, aktualisiere sie indem Du auf das Symbol klickst.", + "badges": { + "enabled": "Aktiviert", + "disabled": "Deaktiviert" + }, + "buttons": { + "test": "Test", + "enable": "Aktivieren", + "disable": "Deaktivieren", + "edit": "Bearbeiten", + "delete": "Löschen", + "yes": "Ja", + "no": "Nein" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Bist du dir sicher, dass du dieses Event löschen willst?", + "example_of_user_object_data": "Beispiel für Benutzerobjekt Daten" + }, + "errors": { + "command_must_start_with_!": "Der Befehl muss mit einem ! beginnen", + "this_value_must_be_a_positive_number_or_0": "Dieser Wert muss entweder eine positive Zahl oder 0 sein", + "value_cannot_be_empty": "Variable darf nicht leer sein!" + }, + "dialog": { + "title": { + "new": "Neuer Event-Listener", + "edit": "Event-Listener bearbeiten" + }, + "placeholders": { + "name": "Setze den Namen des Ereignis-Listener fest (falls leer, wird ein Name generiert)" + }, + "alerts": { + "success": "Event wurde erfolgreich gespeichert.", + "fail": "Etwas ist schief gelaufen." + }, + "close": "Schließen", + "save-changes": "Änderungen speichern", + "event": "Ereignis", + "name": "Name", + "usable-events-variables": "Verwendbare Ereignis-Variablen", + "settings": "Einstellungen", + "filters": "Filter", + "operations": "Operationen" + }, + "definitions": { + "taskId": { + "label": "Task ID" + }, + "filter": { + "label": "Filter" + }, + "linkFilter": { + "label": "Link Overlay Filter", + "placeholder": "Wenn Overlay verwendet wird, Link oder ID des Overlays hinzufügen" + }, + "hashtag": { + "label": "Hashtag oder Schlüsselwort", + "placeholder": "#DeinHashtagHier oder Schlüsselwort" + }, + "fadeOutXCommands": { + "label": "Ausblenden alle X Befehle", + "placeholder": "Anzahl der Befehle, die in jedem Ausblendintervall abgezogen wurden" + }, + "fadeOutXKeywords": { + "label": "X Schlüsselwörter ausblenden", + "placeholder": "Anzahl der Schlüsselwörter, die in jedem Ausblendintervall abgezogen wurden" + }, + "fadeOutInterval": { + "label": "Ausblenden Intervall (Sekunden)", + "placeholder": "Ausblendenintervall subtrahieren" + }, + "runEveryXCommands": { + "label": "Alle X Befehle ausführen", + "placeholder": "Anzahl der Befehle, bevor das Ereignis ausgelöst wird" + }, + "runEveryXKeywords": { + "label": "Alle X Schlüsselwörter ausführen", + "placeholder": "Anzahl der Schlüsselwörter, bevor das Ereignis ausgelöst wird" + }, + "commandToWatch": { + "label": "Befehl zum Beobachten", + "placeholder": "Setze deinen !commandToWatch" + }, + "keywordToWatch": { + "label": "Stichwort zum Beobachten", + "placeholder": "Setze dein !keywordToWatch" + }, + "resetCountEachMessage": { + "label": "Zähler jede Nachricht zurücksetzen", + "true": "Zähler zurücksetzen", + "false": "Zähler beibehalten" + }, + "viewersAtLeast": { + "label": "Mindestens X Zuschauer", + "placeholder": "Wie viele Zuschauer mindestens um das Event auszulösen" + }, + "runInterval": { + "label": "Ausführungsintervall (0 = einmal pro Stream ausführen)", + "placeholder": "Ereignis alle X Sekunden auslösen" + }, + "runAfterXMinutes": { + "label": "Nach X Minuten ausführen", + "placeholder": "Ereignis nach x Minuten auslösen" + }, + "runEveryXMinutes": { + "label": "Alle X Minuten ausführen", + "placeholder": "Ereignis alle X Minuten auslösen" + }, + "messageToSend": { + "label": "Zu sendende Nachricht", + "placeholder": "Nachricht festlegen" + }, + "channel": { + "label": "Channel", + "placeholder": "Channelname oder ID" + }, + "timeout": { + "label": "Auszeit, Unterbrechung / Zeitüberschreitung", + "placeholder": "Timeout in Millisekunden festlegen" + }, + "timeoutType": { + "label": "Art des Timeouts", + "placeholder": "Art des Timeouts festlegen" + }, + "command": { + "label": "Befehl", + "placeholder": "!Befehl festlegen" + }, + "commandToRun": { + "label": "Befehl zum Starten", + "placeholder": "Setze deinen !commandToRun" + }, + "isCommandQuiet": { + "label": "Stummschaltung der Befehlsausgabe" + }, + "urlOfSoundFile": { + "label": "Url der Sounddatei", + "placeholder": "http://www.pathToYour.url/where/is/file.mp3" + }, + "emotesToExplode": { + "label": "Emotes in Explode Overlay", + "placeholder": "Liste der Emotes, z.B. Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Emote-Feuerwerk", + "placeholder": "Liste der Emotes, z.B. Kappa PurpleHeart" + }, + "replay": { + "label": "Videoclip im Overlay wiederholen", + "true": "Wird als Wiederholung in Overlay/Alarmen abgespielt", + "false": "Wiedergabe wird nicht abgespielt" + }, + "announce": { + "label": "Im Chat ankündigen", + "true": "Wird angekündigt", + "false": "Wird nicht angekündigt" + }, + "hasDelay": { + "label": "Clip sollte eine leichte Verzögerung haben (um näher zu sein, was der Betrachter sieht)", + "true": "Wird Verzögerung haben", + "false": "Wird keine Verzögerung haben" + }, + "durationOfCommercial": { + "label": "Dauer der Werbung", + "placeholder": "Verfügbare Dauer: 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Benutzerdefinierte Variable zum aktualisieren" + }, + "numberToIncrement": { + "label": "Zu erhöhende Zahl", + "placeholder": "" + }, + "value": { + "label": "Wert", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Zu verringernde Zahl", + "placeholder": "" + }, + "": "", + "reward": { + "label": "Belohnung", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "folgt dir", + "raid": "raided you with $viewers raiders.", + "sub": "hat dich abonniert $subType. Sie abonnieren dich bereits seit $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "wurde mit von $username verschenkt", + "subcommunitygift": "Gönnete Abonnements für Community", + "resub": "Resubscribed with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Cheered you", + "tip": "Tipped you", + "tipToCharity": "donated to $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Twitter Vorhersage - Titel", + "outcomes": "Twitch Vorhersage - Ergebnisse", + "locksAt": "Twitch Vorhersage - Datum der Sperre", + "winningOutcomeTitle": "Twitch Vorhersage - Gewinnergebnis Titel", + "winningOutcomeTotalPoints": "Twitch Vorhersage - Gewinnergebnis Gesamtzahlpunkte", + "winningOutcomePercentage": "Twitch Vorhersage - Gewinnergebnis Prozentsatz", + "titleOfPoll": "Twitch Umfrage - Titel", + "bitAmountPerVote": "Twitch-Umfrage - Anzahl der Bits, die als 1 Stimme zählen", + "bitVotingEnabled": "Twitch Umfrage - Ist Bit-Voting aktiviert (boolesch)", + "channelPointsAmountPerVote": "Twitch-Umfrage - Anzahl der Kanalpunkte, die als 1 Stimme zählen", + "channelPointsVotingEnabled": "Twitch-Umfrage - Ist die Abstimmung über Kanalpunkte aktiviert (boolesch)", + "votes": "Twitch-Umfrage - Stimmenanzahl", + "winnerChoice": "Twitch-Umfrage - Wahl des Siegers", + "winnerPercentage": "Twitch Umfrage - Prozentsatz der Wahl des Siegers", + "winnerVotes": "Twitch Umfrage - Wählerstimmen für Sieger", + "goal": "Ziel", + "total": "Gesamt", + "lastContributionTotal": "Letzter Beitrag - Gesamt", + "lastContributionType": "Letzter Beitrag - Typ", + "lastContributionUserId": "Letzter Beitrag - Benutzer-ID", + "lastContributionUsername": "Letzter Beitrag - Benutzername", + "level": "Level", + "topContributionsBitsTotal": "Top Bits Beitrag - Gesamt", + "topContributionsBitsUserId": "Top-Bits Beitrag - Benutzer-ID", + "topContributionsBitsUsername": "Top-Bits Beitrag - Benutzername", + "topContributionsSubsTotal": "Top-Subs - Gesamt", + "topContributionsSubsUserId": "Top-Subs - Benutzer-ID", + "topContributionsSubsUsername": "Top Subs Beitrag - Benutzername", + "sender": "Benutzer der initiiert hat", + "title": "Aktueller Titel", + "game": "Aktuelle Kategorie", + "language": "Aktuelle Sprache des Stream", + "viewers": "Anzahl der aktuellen Zuschauer", + "hostViewers": "Anzahl der Raid-Zuschauer", + "followers": "Aktuelle Follower Anzahl", + "subscribers": "Anzahl aktueller Abonnenten", + "arg": "Argument", + "param": "Parameter (erforderlich)", + "touser": "Benutzername Parameter", + "!param": "Parameter (nicht erforderlich)", + "alias": "Alias", + "command": "Befehl", + "keyword": "Schlüsselwort", + "response": "Antwort", + "list": "Liste füllen", + "type": "Typ", + "days": "Tage", + "hours": "Stunden", + "minutes": "Minuten", + "seconds": "Sekunden", + "description": "Beschreibung", + "quiet": "Ruhe (Bool)", + "id": "ID", + "name": "Name", + "messages": "Nachrichten", + "amount": "Höhe", + "amountInBotCurrency": "Betrag in Bot Währung", + "currency": "Währung", + "currencyInBot": "Währung im Bot", + "pointsName": "Name der Punkte", + "points": "Punkte", + "rank": "Rang", + "nextrank": "Nächster Rang", + "username": "Benutzername", + "value": "Wert", + "variable": "Variable", + "count": "Anzahl", + "link": "Link (übersetzt)", + "winner": "Sieger", + "loser": "Verlierer", + "challenger": "Herausforderer", + "min": "Minimum", + "max": "Maximum", + "eligibility": "Voraussetzungen", + "probability": "Wahrscheinlichkeit", + "time": "Zeit", + "options": "Optionen", + "option": "Option", + "when": "Wenn", + "diff": "Differenz", + "users": "Benutzer", + "user": "Benutzer", + "bank": "Bank", + "nextBank": "Nächste Bank", + "cooldown": "Cooldown", + "tickets": "Tickets", + "ticketsName": "Ticket Name", + "fromUsername": "Von Benutzername", + "toUsername": "An Benutzername", + "items": "Artikel", + "bits": "Bits", + "subgifts": "Verschenkte Subs", + "subStreakShareEnabled": "Ist die Teilstrichfreigabe aktiviert (true/false)", + "subStreak": "Aktuelle Sub Reihe", + "subStreakName": "lokalisierter Name des Monats (1 Monat, 2 Monate) für die aktuelle Substrek", + "subCumulativeMonths": "Gesamte abonnierte Monate", + "subCumulativeMonthsName": "Lokalisierter Name des Monats (1 Monat, 2 Monate) für kumulative Abonnements Monate", + "message": "Nachricht", + "reason": "Grund", + "target": "Ziel", + "duration": "Dauer", + "method": "Methode", + "tier": "Stufe", + "months": "Monate", + "monthsName": "Lokalisierter Name des Monats (1 Monat, 2 Monate)", + "oldGame": "Category before change", + "recipientObject": "Vollständiges Empfängerobjekt", + "recipient": "Empfänger", + "ytSong": "Aktueller Song auf YouTube", + "spotifySong": "Aktueller Song auf Spotify", + "latestFollower": "Neuester Follower", + "latestSubscriber": "Neuester Abonnent", + "latestSubscriberMonths": "Letzter Abonnent kumulierter Monate", + "latestSubscriberStreak": "Neueste Abonnenten Monats Strähne", + "latestTipAmount": "Letzte Spende (Anzahl)", + "latestTipCurrency": "Letzte Spende (Währung)", + "latestTipMessage": "Letzte Spende (Nachricht)", + "latestTip": "Letzte Spende (Benutzername)", + "toptip": { + "overall": { + "username": "Top Spende - Gesamt (Benutzername)", + "amount": "Top Spende - Gesamt (Anzahl)", + "currency": "Top Spende - Gesamt (Währung)", + "message": "Top Spende - Gesamt (Nachricht)" + }, + "stream": { + "username": "Top Spende - Während Stream (Benutzername)", + "amount": "Top Spende - Während Stream (Anzahl)", + "currency": "Top Spende - Während Stream (Währung)", + "message": "Top Spende - Während Stream (Nachricht)" + } + }, + "latestCheerAmount": "Letzten Bits (Anzahl)", + "latestCheerMessage": "Letzten Bits (Nachricht)", + "latestCheer": "Letzten Bits (Benutzername)", + "version": "Bot Version", + "haveParam": "Haben Sie Befehlsparameter? (bool)", + "source": "Aktuelle Quelle (twitch oder discord)", + "userInput": "Benutzereingabe beim Einlösen der Belohnung", + "isBotSubscriber": "Ist Bot Abonnent (bool)", + "isStreamOnline": "Ist der Stream online (bool)", + "uptime": "Laufzeit des Stream", + "is": { + "moderator": "Ist Benutzer Mod? (Bool)", + "subscriber": "Ist Benutzer Sub? (Bool)", + "vip": "Ist Benutzer VIP? (bool)", + "newchatter": "Ist die erste Nachricht des Benutzers (Bool)", + "follower": "Ist Benutzer Follower? (Bool)", + "broadcaster": "Ist Benutzer Broadcast? (Bool)", + "bot": "Ist Benutzer Bot? (Bool)", + "owner": "Ist Benutzer der Bot-Besitzer? (Bool)" + }, + "recipientis": { + "moderator": "Ist Teilnehmer ein Mod? (Bool)", + "subscriber": "Ist Teilnehmer ein Abonennt? (Bool)", + "vip": "Ist Teilnehmer ein VIP? (Bool)", + "follower": "Ist Teilnehmer ein Follower? (Bool)", + "broadcaster": "Ist Empfänger Broadcaster? (Bool)", + "bot": "Ist Teilnehmer ein Bot? (Bool)", + "owner": "Ist Teilnehmer der Bot-Besitzer? (Bool)" + }, + "sceneName": "Name der Szene", + "inputName": "Name of input", + "inputMuted": "Mute state (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Sonstiges", + "currency": "Währung" + }, + "whispers": { + "title": "Flüstern", + "toggle": { + "listener": "Auf Befehle per Flüstern achten", + "settings": "Flüsternachricht bei geänderten Einstellungen", + "raffle": "Flüsternachricht bei teilnahme", + "permissions": "Unzureichende Berechtigungen zum Flüstern", + "cooldowns": "Flüster bei Abklingzeit (wenn als Benachrichtigung gesetzt)" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Nachrichten", + "follows": "Follows", + "subs": "Subs & ReSubs", + "cheers": "Bits", + "responses": "Bot Antworten", + "whispers": "Whisper", + "bans": "Banns", + "timeouts": "Zeigüberschreitung" + }, + "range": { + "day": "ein Tag", + "week": "eine Woche", + "month": "ein Monat", + "year": "ein Jahr", + "all": "Gesamte Zeit" + }, + "order": { + "asc": "Aufsteigend", + "desc": "Absteigend" + }, + "labels": { + "order": "REIHENFOLGE", + "range": "Bereich", + "filters": "FILTER" + } + }, + "stats-panel": { + "show": "Stats zeigen", + "hide": "Statistiken ausblenden" + }, + "translations": "Benutzerdefinierte Übersetzungen", + "bot-responses": "Bot Antworten", + "duration": "Dauer", + "viewers-reset-attributes": "Attribute zurücksetzen", + "viewers-points-of-all-users": "Punkte aller Benutzer", + "viewers-watchtime-of-all-users": "Zugeschaute Zeit aller Benutzer ansehen", + "viewers-messages-of-all-users": "Nachrichten von allen Benutzern", + "events-game-after-change": "category after change", + "events-game-before-change": "category before change", + "events-user-triggered-event": "vom Benutzer ausgelöstes Ereignis", + "events-method-used-to-subscribe": "zum Abonnieren verwendete Methode", + "events-months-of-subscription": "Monate des Abonnements", + "events-monthsName-of-subscription": "Wort 'Monat' nach Nummer (1 Monat, 2 Monate)", + "events-user-message": "Benutzermitteilung", + "events-bits-user-sent": "vom Benutzer gesendete Bits", + "events-reason-for-ban-timeout": "Grund für Bann/Timeout", + "events-duration-of-timeout": "Dauer des Timeouts", + "events-duration-of-commercial": "dauer der Werbung", + "overlays-eventlist-resub": "re-Subs", + "overlays-eventlist-subgift": "verschenkter Sub", + "overlays-eventlist-subcommunitygift": "subcommunitygeschenk", + "overlays-eventlist-sub": "Sub", + "overlays-eventlist-follow": "Follow", + "overlays-eventlist-cheer": "Bits", + "overlays-eventlist-tip": "Spende", + "overlays-eventlist-raid": "Raid", + "requested-by": "Angefordert von", + "description": "Beschreibung", + "raffle-type": "Verlosungs Typ", + "raffle-type-keywords": "Nur Schlüsselwort", + "raffle-type-tickets": "Mit Tickets", + "raffle-tickets-range": "Ticketbereich", + "video_id": "Video ID", + "highlights": "Highlights", + "cooldown-quiet-header": "Cooldown-Nachricht anzeigen", + "cooldown-quiet-toggle-no": "Benachrichtigen", + "cooldown-quiet-toggle-yes": "Wird nicht benachrichtigt", + "cooldown-moderators": "Moderatoren", + "cooldown-owners": "Besitzer", + "cooldown-subscribers": "Abonnenten", + "cooldown-followers": "Followers", + "in-seconds": "in Sekunden", + "songs": "Songs", + "show-usernames-with-at": "Benutzernamen mit @ anzeigen", + "send-message-as-a-bot": "Nachricht als Bot senden", + "chat-as-bot": "Chat (als Bot)", + "product": "Produkt", + "optional": "Optional", + "placeholder-search": "Suche", + "placeholder-enter-product": "Produkt eintragen", + "placeholder-enter-keyword": "Suchbegriff eingeben", + "credits": "Credits", + "fade-out-top": "aufblenden", + "fade-out-zoom": "verblasst Zoom", + "global": "Global", + "user": "Benutzer", + "alerts": "Alert", + "eventlist": "Event-Liste", + "dashboard": "Dashboard", + "carousel": "BilderKarussell", + "text": "Text", + "filter": "Filter", + "filters": "Filters", + "isUsed": "Is used", + "permissions": "Berechtigungen", + "permission": "Berechtigung", + "viewers": "Zuschauer", + "systems": "Systeme", + "overlays": "Overlays", + "gallery": "Medien Galerie", + "aliases": "Aliase", + "alias": "Alias", + "command": "Befehl", + "cooldowns": "Cooldowns", + "title-template": "Titel-Vorlage", + "keyword": "Schlüsselwort", + "moderation": "Moderation", + "timer": "Timer", + "price": "Preis", + "rank": "Rang", + "previous": "Zurück", + "next": "Weiter", + "close": "Schließen", + "save-changes": "Änderungen speichern", + "saving": "Speichert...", + "deleting": "Lösche...", + "done": "Fertig", + "error": "Fehler", + "title": "Titel", + "change-title": "Titel ändern", + "game": "category", + "tags": "Tags", + "change-game": "Change category", + "click-to-change": "Zum Ändern anklicken", + "uptime": "Uptime", + "not-affiliate-or-partner": "Kein Affiliate/Partner", + "not-available": "Nicht verfügbar", + "max-viewers": "Max. Zuschauer", + "new-chatters": "Neue Chatter", + "chat-messages": "Chat-Nachrichten", + "followers": "Followers", + "subscribers": "Abonnenten", + "bits": "Bits", + "subgifts": "Verschenkte Subs", + "subStreak": "Aktuelle Sub Reihe", + "subCumulativeMonths": "Gesamte abonnierte Monate", + "tips": "Spenden", + "tier": "Stufe", + "status": "Status", + "add-widget": "Widget hinzufügen", + "remove-dashboard": "Dashboard entfernen", + "close-bet-after": "Schließe Wette nach", + "refund": "Rückerstattung", + "roll-again": "Nochmal würfeln", + "no-eligible-participants": "Keine berechtigten Teilnehmer", + "follower": "Follower", + "subscriber": "Abonnent", + "minutes": "Minuten", + "seconds": "Sekunden", + "hours": "Stunden", + "months": "Monate", + "eligible-to-enter": "Zum Beitreten berechtigt", + "everyone": "Jeder", + "roll-a-winner": "Einen Gewinner würfeln", + "send-message": "Nachricht senden", + "messages": "Nachrichten", + "level": "Level", + "create": "Erstellen", + "cooldown": "Cooldown", + "confirm": "Bestätigen", + "delete": "Löschen", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "enable": "Aktivieren", + "disable": "Deaktivieren", + "slug": "Slug", + "posted-by": "Gepostet von", + "time": "Zeit", + "type": "Typ", + "response": "Antworten", + "cost": "Kosten", + "name": "Name", + "playlist": "Playlist", + "length": "Länge", + "volume": "Lautstärke", + "start-time": "Startzeit", + "end-time": "Endzeit", + "watched-time": "Zugeschaute Zeit", + "currentsong": "Aktueller Titel", + "group": "Gruppen", + "followed-since": "Folgt seit", + "subscribed-since": "Abonniert seit", + "username": "Benutzername", + "hashtag": "Hashtag", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "Scopes", + "last-seen": "Zuletzt gesehen", + "date": "Datum", + "points": "Punkte", + "calendar": "Kalender", + "string": "Zeichenkette", + "interval": "Intervall", + "number": "Nummer", + "minimal-messages-required": "Minimale Nachrichten erforderlich", + "max-duration": "Max. Dauer", + "shuffle": "Zufallswiedergabe", + "song-request": "Song-Request", + "format": "Format", + "available": "Verfügbar", + "one-record-per-line": "ein Eintrag pro Zeile", + "on": "an", + "off": "aus", + "search-by-username": "Nach Benutzernamen suchen", + "widget-title-custom": "Benutzerdefiniert", + "widget-title-eventlist": "EVENTLISTE", + "widget-title-chat": "CHAT", + "widget-title-queue": "WARTESCHLANGE", + "widget-title-raffles": "RAFFLES", + "widget-title-social": "SOCIAL", + "widget-title-ytplayer": "MUSIC-SPIELER", + "widget-title-monitor": "MONITOR", + "event": "Ereignis", + "operation": "Operation", + "tweet-post-with-hashtag": "Tweet mit Hashtag veröffentlicht", + "user-joined-channel": "Benutzer ist dem Kanal beigetreten", + "user-parted-channel": "Benutzer hat einen Kanal verlassen", + "follow": "Neuer Follower", + "tip": "Neue Spende", + "obs-scene-changed": "OBS Szene geändert", + "obs-input-mute-state-changed": "OBS input source mute state changed", + "unfollow": "Entfolgen", + "hypetrain-started": "Hype Zug gestartet", + "hypetrain-ended": "Hype Zug beendet", + "prediction-started": "Twitch Vorhersage gestartet", + "prediction-locked": "Twitch-Vorhersage gesperrt", + "prediction-ended": "Twitch Vorhersage beendet", + "poll-started": "Twitch-Umfrage gestartet", + "poll-ended": "Twitch-Umfrage beendet", + "hypetrain-level-reached": "Hype Zug hat ein neues Level erreicht", + "subscription": "Neuer Abonnent", + "subgift": "Neuer verschenkter Sub", + "subcommunitygift": "Neuer verschenkter Community-Sub", + "resub": "Benutzer hat erneut abonniert", + "command-send-x-times": "Befehl wurde x mal gesendet", + "keyword-send-x-times": "Schlüsselwort wurde x mal gesendet", + "number-of-viewers-is-at-least-x": "Anzahl der Zuschauer ist mindestens x", + "stream-started": "Stream gestartet", + "reward-redeemed": "Belohnung eingelöst", + "stream-stopped": "Stream gestoppt", + "stream-is-running-x-minutes": "Stream läuft seit x Minuten", + "chatter-first-message": "erste Nachricht des Chatters", + "every-x-minutes-of-stream": "alle X Minuten des Streams", + "game-changed": "category changed", + "cheer": "empfangene Bits", + "clearchat": "Chat wurde geleert", + "action": "Benutzer verwendete /me", + "ban": "Benutzer wurde gebannt", + "raid": "Dein Kanal wird geraided", + "mod": "Benutzer ist ein neuer Mod", + "timeout": "der Benutzer hatte einen Timeout", + "create-a-new-event-listener": "Neuen Event Listener erstellen", + "send-discord-message": "sende eine Discord Nachricht", + "send-chat-message": "sende eine Twitch Chat Nachricht", + "send-whisper": "einen Whisper senden", + "run-command": "Einen Befehl ausführen", + "run-obswebsocket-command": "einen OBS Websocket Befehl ausführen", + "do-nothing": "--- nichts tun ---", + "count": "Anzahl", + "timestamp": "Zeitstempel", + "message": "Nachricht", + "sound": "Sound", + "emote-explosion": "Emote Explosion", + "emote-firework": "Emote-Feuerwerk", + "quiet": "Leise", + "noisy": "Laut", + "true": "richtig", + "false": "falsch", + "light": "Helles Design", + "dark": "Dunkles Design", + "gambling": "Glücksspiel", + "seppukuTimeout": "Timeout für !seppuku", + "rouletteTimeout": "Timeout für !roulette", + "fightmeTimeout": "Timeout für !fightme", + "duelCooldown": "Cooldown für !duel", + "fightmeCooldown": "Cooldown für !fightme", + "gamblingCooldownBypass": "Abklingzeiten für Glücksspiele umgehen für Mods/Streamer", + "click-to-highlight": "Highlight", + "click-to-toggle-display": "Anzeige umschalten", + "commercial": "Werbung gestartet", + "start-commercial": "Werbung abspielen", + "bot-will-join-channel": "Bot tritt dem Kanal bei", + "bot-will-leave-channel": "Bot wird den Kanal verlassen", + "create-a-clip": "Clip erstellen", + "increment-custom-variable": "Erhöhe eine benutzerdefinierte Variable", + "set-custom-variable": "eine eigene Variable setzen", + "decrement-custom-variable": "Verringern Sie eine benutzerdefinierte Variable", + "omit": "weglassen", + "comply": "erfüllen", + "visible": "Sichtbar", + "hidden": "versteckt", + "gamblingChanceToWin": "Chance zu gewinnen !gamble", + "gamblingMinimalBet": "Minimaler Einsatz für !gamble", + "duelDuration": "Dauer von !duel", + "duelMinimalBet": "Minimaler Einsatz für !duel" + }, + "raffles": { + "announceInterval": "Eröffnete Verlosungen werden alle $value Minuten bekannt gegeben", + "eligibility-followers-item": "Followers", + "eligibility-subscribers-item": "Abonnenten", + "eligibility-everyone-item": "Jeder", + "raffle-is-running": "Verlosung läuft ($count $l10n_entries).", + "to-enter-raffle": "Verlosung wird ausgeführt. Um mitzumachen, einfach $keyword eintippen. Verlosung ist offen für $eligibility.", + "to-enter-ticket-raffle": "Verlosung wird ausgeführt. Um mitzumachen, einfach \"$keyword <$min-$max>\" eintippen. Verlosung ist offen für $eligibility.", + "added-entries": "$count $l10n_entries zur Verlosung hinzugefügt ($countTotal insgesamt). {raffles.to-enter-raffle}", + "added-ticket-entries": "$count $l10n_entries zur Verlosung hinzugefügt ($countTotal insgesamt). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "Ihre Verlosungsnachrichten werden beim Beitritt gelöscht.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "Entschuldigung, $sender, aber du kannst keine Verlosung ohne Schlüsselwort erstellen", + "raffle-is-already-running": "Sorry, $sender, die Verlosung läuft bereits mit dem Schlüsselwort $keyword", + "no-raffle-is-currently-running": "$sender, es laufen derzeit keine Verlosungen ohne Gewinner", + "no-participants-to-pick-winner": "$sender, niemand nimmt an der Verlosung teil.", + "raffle-winner-is": "Gewinner der Verlosung $keyword ist $username! Gewinnerwahrscheinlichkeit war $probability%!" + }, + "bets": { + "running": "$sender, Wette ist bereits geöffnet! Wett-Optionen: $options. Verwende $command einsatz-$maxIndex", + "notRunning": "Derzeit ist keine Wette geöffnet. Frage die Mods, um eine zu eröffnen!", + "opened": "Neue Wette '$title' ist eröffnet! Wett-Optionen: $options. Verwende $command 1-$maxIndex , um zu gewinnen! Du hast nur $minutesmin, um zu setzen!", + "closeNotEnoughOptions": "$sender, Du musst eine Gewinnoption auswählen, um den Einsatz zu schließen.", + "notEnoughOptions": "$sender, neue Wetten benötigen mindestens 2 Optionen!", + "info": "Wetten '$title' ist noch geöffnet! Wetten-Optionen: $options. Verwenden Sie $command 1-$maxIndex , um zu gewinnen! Sie haben nur $minutesmin zum Wetten!", + "diffBet": "$sender, Sie haben bereits auf $option gesetzt und können nicht auf eine andere Option setzen!", + "undefinedBet": "Entschuldigung, $sender, aber diese Wette Option existiert nicht, benutze $command , um die Nutzung zu überprüfen", + "betPercentGain": "Prozentualer Gewinn pro Option wurde auf $value % gesetzt", + "betCloseTimer": "Wetten werden nach $valuemin automatisch geschlossen", + "refund": "Wetten wurden ohne Gewinn geschlossen, alle Benutzer werden erstattet!", + "notOption": "$sender, diese Option existiert nicht! Wetten ist nicht geschlossen, überprüfen Sie $command", + "closed": "Wetten wurden geschlossen und gewonnene Option war $option! $amount Nutzer haben insgesamt $points $pointsName gewonnen!", + "timeUpBet": "Ich vermute, Sie sind zu spät, $sender, Ihre Zeit zum Wetten ist abgelaufen!", + "locked": "Die Wettzeiten ist abgelaufen! Keine Wetten mehr.", + "zeroBet": "Oh Junge, $sender du kannst nicht 0 $pointsName setzten", + "lockedInfo": "Wetten '$title' ist noch geöffnet, aber die Zeit für Wetten ist abgelaufen!", + "removed": "Wettzeit ist abgelaufen! Keine Wetten wurden gesetzt -> automatisch geschlossen", + "error": "Entschuldigung, $sender, dieser Befehl ist nicht korrekt! Verwenden Sie $command 1-$maxIndex . Z.B. $command 0 100 setzt 100 Punkte auf Geganstand 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, Alias $alias wurde nicht in der Datenbank gefunden", + "alias-was-edited": "$sender, Alias $alias wurde zu $command geändert", + "alias-was-added": "$sender, der Alias $alias für $command wurde hinzugefügt", + "list-is-not-empty": "$sender, Liste von Aliasen: $list", + "list-is-empty": "$sender, Liste der Aliase ist leer", + "alias-was-enabled": "$sender, Alias $alias wurde aktiviert", + "alias-was-disabled": "$sender, Alias $alias wurde deaktiviert", + "alias-was-concealed": "$sender, Alias $alias wurde versteckt", + "alias-was-exposed": "$sender, Alias $alias wurde ausgesetzt", + "alias-was-removed": "$sender, Alias $alias wurde entfernt", + "alias-group-set": "$sender, Alias $alias wurde für Gruppe $group gesetzt", + "alias-group-unset": "$sender, Alias $alias Gruppe wurde entfernt", + "alias-group-list": "$sender, Liste der Aliasgruppen: $list", + "alias-group-list-aliases": "$sender, Liste der Aliase in $group: $list", + "alias-group-list-enabled": "$sender, Aliase in $group sind aktiviert.", + "alias-group-list-disabled": "$sender, Aliase in $group sind deaktiviert." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, Befehl $command wurde nicht in der Datenbank gefunden", + "response-was-not-found": "$sender, Antwort #$response des Befehls $command wurde nicht in der Datenbank gefunden", + "command-was-edited": "$sender, Befehl $command wurde zu '$response ' geändert", + "command-was-added": "$sender, Befehl $command wurde hinzugefügt", + "list-is-not-empty": "$sender, Liste der Befehle: $list", + "list-is-empty": "$sender, Liste der Befehle ist leer", + "command-was-enabled": "$sender, Befehl $command wurde aktiviert", + "command-was-disabled": "$sender, Befehl $command wurde deaktiviert", + "command-was-concealed": "$sender, Befehl $command wurde verborgen", + "command-was-exposed": "$sender, Befehl $command wurde freigegeben", + "command-was-removed": "$sender, Befehl $command wurde entfernt", + "response-was-removed": "$sender, Antwort #$response von $command wurde entfernt", + "list-of-responses-is-empty": "$sender, $command haben keine Antworten oder existiert nicht", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, Stichwort $keyword ist zweideutig, benutze ID des Schlüsselworts", + "keyword-was-not-found": "$sender, Schlüsselwort $keyword wurde in der Datenbank nicht gefunden", + "response-was-not-found": "$sender, Antwort #$response des Schlüsselwortes $keyword wurde nicht in der Datenbank gefunden", + "keyword-was-edited": "$sender, Stichwort $keyword wurde in '$response ' geändert", + "keyword-was-added": "$sender, keyword $keyword ($id) wurde hinzugefügt", + "list-is-not-empty": "$sender, Liste der Schlüsselwörter: $list", + "list-is-empty": "$sender, Liste der Schlüsselwörter ist leer", + "keyword-was-enabled": "$sender, Stichwort $keyword wurde aktiviert", + "keyword-was-disabled": "$sender, Schlüsselwort $keyword wurde deaktiviert", + "keyword-was-removed": "$sender, Stichwort $keyword wurde entfernt", + "list-of-responses-is-empty": "$sender, $keyword haben keine Antworten oder existiert nicht", + "response": "$keyword#$index ($permission) $after| $response" + }, + "points": { + "success": { + "undo": "$sender, Punkte '$command' für $username wurden zurückgesetzt ($updatedValue $updatedValuePointsLocale zu $originalValue $originalValuePointsLocale).", + "set": "$username wurde auf $amount $pointsName gesetzt", + "give": "$sender hat seinen $amount $pointsName an $username gegeben", + "online": { + "positive": "Alle Online-Nutzer haben soeben $amount $pointsName erhalten!", + "negative": "Alle Online-Benutzer haben $amount $pointsName verloren!" + }, + "all": { + "positive": "Alle Benutzer haben $amount $pointsName erhalten!", + "negative": "Alle Benutzer haben $amount $pointsName verloren!" + }, + "rain": "Lass es regen! Alle Online-Nutzer erhalten bis zu $amount $pointsName!", + "add": "$username hat $amount $pointsName erhalten!", + "remove": "Autsch, $amount $pointsName wurden von $username entfernt!" + }, + "failed": { + "undo": "$sender, Benutzername wurde nicht in der Datenbank gefunden oder Benutzer haben keine Rückgängig Operationen", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Entschuldigung, $sender, Sie haben nicht genug $amount $pointsName um sie an $username zu geben", + "cannotGiveZeroPoints": "Sorry, $sender, du kannst $amount $pointsName nicht an $username geben", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username hat derzeit $amount $pointsName. Ihre Position ist $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, die zu importierende Playlist ist leer", + "playlist-imported": "$sender importierte $imported und übersprungen $skipped zur Wiedergabeliste", + "not-playing": "Keiner", + "song-was-banned": "Song $name wurde gebannt und wird nie wieder gespielt!", + "song-was-banned-timeout-message": "Du hast einen Timeout für das Posten von gesperrten Lied bekommen", + "song-was-unbanned": "Song wurde erfolgreich entsperrt", + "song-was-not-banned": "Dieser Song wurde nicht gesperrt", + "no-song-is-currently-playing": "Derzeit wird kein Lied abgespielt.", + "current-song-from-playlist": "Aktueller Song ist $name von der Playlist", + "current-song-from-songrequest": "Aktueller Song ist $name und wurde gewünscht von $username", + "songrequest-disabled": "Entschuldigung, $sender, Songanfragen sind deaktiviert", + "song-is-banned": "Sorry, $sender, aber dieser Song ist gesperrt", + "youtube-is-not-responding-correctly": "Entschuldigung, $sender, aber YouTube sendet unerwartete Antworten, bitte versuchen Sie es später erneut.", + "song-was-not-found": "Sorry, $sender, aber dieser Song wurde nicht gefunden", + "song-is-too-long": "Sorry, $sender, aber dieser Song ist zu lang", + "this-song-is-not-in-playlist": "Sorry, $sender, aber dieser Song ist nicht in der aktuellen Wiedergabeliste", + "incorrect-category": "Sorry, $sender, aber der Song muss eine Musikkategorie sein", + "song-was-added-to-queue": "$sender, Song $name wurde zur Warteschlange hinzugefügt", + "song-was-added-to-playlist": "$sender, Song $name wurde zur Wiedergabeliste hinzugefügt", + "song-is-already-in-playlist": "$sender, Song $name ist bereits in der Playlist", + "song-was-removed-from-playlist": "$sender, Song $name wurde von der Wiedergabeliste entfernt", + "song-was-removed-from-queue": "$sender, dein Song $name wurde aus der Warteschlange entfernt", + "playlist-current": "$sender, die aktuelle Playlist ist $playlist.", + "playlist-list": "$sender, verfügbare Playlists: $list.", + "playlist-not-exist": "$sender, deine angeforderte Playlist $playlist existiert nicht.", + "playlist-set": "$sender, du hast die Playlist zu $playlist gewechselt." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, Preis für $command wurde auf $amount $pointsName gesetzt", + "price-was-unset": "$sender, Preis für $command wurde entfernt", + "price-was-not-found": "$sender, Preis für $command wurde nicht gefunden", + "price-was-enabled": "$sender, Preis für $command wurde aktiviert", + "price-was-disabled": "$sender, der Preis für $command wurde deaktiviert", + "user-have-not-enough-points": "Entschuldigung, $sender, aber du hast nicht $amount $pointsName um $command zu benutzen", + "user-have-not-enough-points-or-bits": "Sorry, $sender, aber Sie haben nicht $amount $pointsName oder einlösen Befehl von $bitsAmount Bits um $command zu verwenden", + "user-have-not-enough-bits": "Sorry, $sender, aber du musse für den Befehl $bitsAmount Bits einlösen, um $command zu verwenden", + "list-is-empty": "$sender, Liste der Preise ist leer", + "list-is-not-empty": "$sender, Liste der Preise: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, neuer Rang $type $rank ($hours$hlocale) wurde hinzugefügt", + "rank-was-edited": "$sender, Rang für $type $hours$hlocale wurde geändert zu $rank", + "rank-was-removed": "$sender, Rang für $type $hours$hlocale wurde entfernt", + "rank-already-exist": "$sender, Es gibt bereits einen Rang für $type $hours$hlocale", + "rank-was-not-found": "$sender, Rang für $type $hours$hlocale wurde nicht gefunden", + "custom-rank-was-set-to-user": "$sender, du setzt $rank für $username", + "custom-rank-was-unset-for-user": "$sender, benutzerdefinierter Rang für $username wurde nicht gesetzt", + "list-is-empty": "$sender, kein Rang wurde gefunden", + "list-is-not-empty": "$sender, Rangliste: $list", + "show-rank-without-next-rank": "$sender, du hast den Rank: $rank", + "show-rank-with-next-rank": "$sender, du hast den Rang: $rank. Nächster Rang ist - $nextrank", + "user-dont-have-rank": "$sender, du hast noch keinen Rang" + }, + "followage": { + "success": { + "never": "$sender, $username ist kein Follower", + "time": "$sender, $username folgt dem Kanal $diff" + }, + "successSameUsername": { + "never": "$sender, sie sind kein follower dieses Kanals", + "time": "$sender, sie folgen diesem Kanal seit $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username ist kein Kanal subscriber.", + "notNow": "$sender, $username ist derzeit kein Kanal subscriber. Insgesamt $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username ist subscriber auf dem Kanal. Aktuelle Teilsträhne für $diff ($subStreak $subStreakMonthsName) und insgesamt von $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username ist Abonnent des Kanals. Insgesamt $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, Du bist kein Kanal-Abonnent.", + "notNow": "$sender, Sie sind derzeit kein Kanal Abonnent. Insgesamt $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, Sie sind Abonnent des Kanals. Aktuelle Abostähne für $diff ($subStreak $subStreakMonthsName) und insgesamt $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, Sie sind Abonnent des Kanals. Insgesamt seit $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, Ich habe keine Daten für das Konto $username", + "success": { + "withUsername": "$sender, Kontoalter für $username ist $diff", + "withoutUsername": "$sender, Ihr Kontoalter ist $diff" + } + }, + "lastseen": { + "success": { + "never": "$username war nie in diesem Kanal!", + "time": "$username wurde das letzte mal gesehe am $when auf deisem Kanal" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username hat den Channel bereits $time Stunden zugeschaut" + }, + "failed": { + "parse": "{core.command-parse} !watched oder !watched [username]" + } + }, + "permissions": { + "without-permission": "Du hast nicht genügend Berechtigungen für '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, Benutzer $username haben $type Immunität für $time Sekunden", + "user-have-immunity-parameterError": "$sender, Parameterfehler. $command ", + "user-have-link-permit": "Benutzer $username kann $count $link im Chat schreiben", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "Keine Links erlaubt, frage nach !permit [$count warnungen übrig]", + "user-is-warned-about-symbols": "Keine übermäßige Verwendung von Symbolen [$count Warnungen übrig]", + "user-is-warned-about-long-message": "Lange Nachrichten sind nicht erlaubt [$count warnungen übrig]", + "user-is-warned-about-caps": "Keine übermäßige Verwendung von Caps [$count Warnungen übrig]", + "user-is-warned-about-spam": "Spamming ist nicht erlaubt [$count Warnungen übrig]", + "user-is-warned-about-color": "Kursiv und /me ist nicht erlaubt [$count Warnungen übrig]", + "user-is-warned-about-emotes": "Keine Emotes Spamming [$count Warnungen übrig]", + "user-is-warned-about-forbidden-words": "Keine verbotenen Wörter [$count Warnungen übrig]", + "user-have-timeout-for-links": "Keine Links erlaubt. Frage nach !permit", + "user-have-timeout-for-symbols": "Keine übermäßige Verwendung von Symbolen", + "user-have-timeout-for-long-message": "Lange Nachricht ist nicht erlaubt", + "user-have-timeout-for-caps": "Keine übermäßige Verwendung von Caps", + "user-have-timeout-for-spam": "Spammen ist nicht erlaubt", + "user-have-timeout-for-color": "Kursiv und /me ist nicht erlaubt", + "user-have-timeout-for-emotes": "Kein Emotes Spamming", + "user-have-timeout-for-forbidden-words": "Keine verbotenen Wörter" + }, + "queue": { + "list": "$sender, aktueller Warteschlangen pool: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Sorry, $senderist die Warteschlange geschlossen", + "opened": "$sender wurde zur Warteschlange hinzugefügt" + }, + "open": "Warteschlange ist zur Zeit geöffnet! Schließe dich der Warteschlange mit !queue join an", + "close": "Warteschlange ist momentan geschlossen!", + "clear": "Warteschlange wurde vollständig gelöscht", + "picked": { + "single": "Dieser Benutzer wurde aus der Warteschlange ausgewählt: $users", + "multi": "Diese Benutzer wurden aus der Warteschlange ausgewählt: $users", + "none": "Keine Benutzer in der Warteschlange gefunden" + } + }, + "marker": "Stream marker has been created at $time.", + "title": { + "current": "$sender, Titel des Stream ist '$title'.", + "change": { + "success": "$sender, der Titel wurde auf $title gesetzt" + } + }, + "game": { + "current": "$sender, Streamer spielt derzeit $game.", + "change": { + "success": "$sender, category was set to: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type Abklingzeit für $command wurde auf $secondss gesetzt", + "cooldown-was-unset": "$sender, Abklingzeit für $command wurde nicht gesetzt", + "cooldown-triggered": "$sender, '$command' ist auf Abklingzeit, verbleibend $secondss", + "cooldown-not-found": "$sender, Abklingzeit für $command wurde nicht gefunden", + "cooldown-was-enabled": "$sender, Abklingzeit für $command wurde aktiviert", + "cooldown-was-disabled": "$sender, Abklingzeit für $command wurde deaktiviert", + "cooldown-was-enabled-for-moderators": "$sender, Abklingzeit für $command ist für Moderatoren aktiviert", + "cooldown-was-disabled-for-moderators": "$sender, die Abklingzeit für $command wurde für Moderatoren deaktiviert", + "cooldown-was-enabled-for-owners": "$sender, Abklingzeit für $command wurde für Besitzer aktiviert", + "cooldown-was-disabled-for-owners": "$sender, Abklingzeit für $command wurde für Besitzer deaktiviert", + "cooldown-was-enabled-for-subscribers": "$sender, Abklingzeit für $command wurde für Abonnenten aktiviert", + "cooldown-was-disabled-for-subscribers": "$sender, Abklingzeit für $command wurde für Abonnenten deaktiviert", + "cooldown-was-enabled-for-followers": "$sender, Abklingzeit für $command wurde für Follower aktiviert", + "cooldown-was-disabled-for-followers": "$sender, die Abklingzeit für $command wurde für Follower deaktiviert" + }, + "timers": { + "id-must-be-defined": "$sender, Antwort ID muss definiert werden.", + "id-or-name-must-be-defined": "$sender, Antwort ID oder Timer Name müssen definiert werden.", + "name-must-be-defined": "$sender, Timer Name muss definiert werden.", + "response-must-be-defined": "$sender, Timer Antwort muss definiert werden.", + "cannot-set-messages-and-seconds-0": "$sender, Sie können nicht beide Nachrichten und Sekunden auf 0 setzen.", + "timer-was-set": "$sender, der Timer $name wurde mit der Nachricht $messages und $seconds Sekunden gesetzt, um ihn auszulösen", + "timer-was-set-with-offline-flag": "$sender, der Timer $name wurde mit $messages Nachrichten und $seconds Sekunden gesetzt, um ihn auszulösen, auch wenn der Stream offline ist", + "timer-not-found": "$sender, Timer (Name: $name) wurde in der Datenbank nicht gefunden. Überprüfe Timer mit der !timers list", + "timer-deleted": "$sender, Timer $name und seine Antworten wurden gelöscht.", + "timer-enabled": "$sender, timer (name: $name) wurde aktiviert", + "timer-disabled": "$sender, timer (name: $name) wurde deaktiviert", + "timers-list": "$sender, timer liste: $list", + "responses-list": "$sender, timer (name: $name) liste", + "response-deleted": "$sender, Antwort (ID: $id) wurde gelöscht.", + "response-was-added": "$sender, Antwort(Id: $id) füt timer (name: $name) wurde hinzugefühgt - $response", + "response-not-found": "$sender, antwort (ID: $id) wurd nicht in der Datenbank gefunden", + "response-enabled": "$sender, antwort (ID: $id) wurde aktiviert", + "response-disabled": "$sender, antwort (ID: $id) wurde deaktiviert" + }, + "gambling": { + "duel": { + "bank": "$sender, aktuelle Bank für $command ist $points $pointsName", + "lowerThanMinimalBet": "$sender, minimaler Einsatz für $command ist $points $pointsName", + "cooldown": "$sender, sie könne nicht $command nutzen für $cooldown $minutesName.", + "joined": "$sender, Viel Glück mit deinen Duellfähigkeiten. Du setzt auf dich $points $pointsName!", + "added": "$sender denkt wirklich, er ist besser als andere, die seine Wette erhöhen auf $points $pointsName!", + "new": "$sender ist dein neuer Duell-Herausforderer! Zur Teilnahme verwenden $command [points], du hast $minutes $minutesName übrig zum beitreten.", + "zeroBet": "$sender, du kannst dich nicht mit 0 $pointsName duellieren", + "notEnoughOptions": "$sender, Sie müssen Punkte für das Duell angeben", + "notEnoughPoints": "$sender, Sie haben nicht $points $pointsName für ein duell!", + "noContestant": "Nur $winner haben den Mut, dem Duell beizutreten! Ihre Wette von $points $pointsName wird Ihnen zurückgeschickt.", + "winner": "Herzlichen Glückwunsch an $winner! Er ist der letzte Mann und er hat gewonnen $points $pointsName ($probability% mit einem Einsatz von $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender versucht sein Glück und drückte den Abzug", + "alive": "$sender ist am Leben! Nichts ist passiert.", + "dead": "Das Gehirn von $sender wurde auf der Wand verteilt!", + "mod": "$sender ist inkompetent und hat seinen Kopf völlig verfehlt!", + "broadcaster": "$sender benutzt eine leere Waffe, Boo!", + "timeout": "Roulette Timeout auf $values gesetzt" + }, + "gamble": { + "chanceToWin": "$sender, Chance zu gewinnen !gamble gesetzt auf $value%", + "zeroBet": "$sender, du kannst mit 0 $pointsName nicht spielen", + "minimalBet": "$sender, Mindestwette für !gamble ist auf $value gesetzt", + "lowerThanMinimalBet": "$sender, minimaler Einsatz für !gamble ist $points $pointsName", + "notEnoughOptions": "$sender, Sie müssen Punkte angeben, um zu spielen", + "notEnoughPoints": "$sender, Sie haben nicht $points $pointsName um zu spielen", + "win": "$sender, du hast gewonnen! Du hast jetzt $points $pointsName", + "winJackpot": "$sender, Sie haben JACKPOT getroffen! Sie haben $jackpot $jackpotName zusätzlich zu Ihrem Einsatz gewonnen. Sie haben jetzt $points $pointsName", + "loseWithJackpot": "$sender, du hast verloren! Du hast jetzt $points $pointsName. Der Jackpot wurde auf $jackpot $jackpotName erhöht", + "lose": "$sender, du hast verloren! Du hast jetzt $points $pointsName", + "currentJackpot": "$sender, aktueller Jackpot für $command ist $points $pointsName", + "winJackpotCount": "$sender, du hast $count Jackpots gewonnen", + "jackpotIsDisabled": "$sender, Jackpot ist für $command deaktiviert." + } + }, + "highlights": { + "saved": "$sender, Hervorhebung wurde $hoursh$ Minuten lang $secondss gespeichert", + "list": { + "items": "$sender, Liste der gespeicherten Highlights für den neuesten Stream: $items", + "empty": "$sender, es wurden keine Highlights gespeichert" + }, + "offline": "$sender, kann das Highlight nicht speichern, Stream ist offline" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "Der Bot sendet keine Fehler bei unzureichenden Berechtigungen", + "false": "Der Bot sendet keine Fehler bei unzureichenden Berechtigungen über Whispers" + }, + "disableCooldownWhispers": { + "true": "Bot sendet keine Cooldown-Benachrichtigungen", + "false": "Bot wird Cooldown-Benachrichtigungen durch Whisper senden" + } + } + }, + "time": "Aktuelle Zeit in der Streamer's Zeitzone ist $time", + "subs": "$sender, es gibt derzeit $onlineSubCount Online Abonnenten. Letzte Sub/Resub war $lastSubUsername $lastSubAgo", + "followers": "$sender, last follow was $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, Nutzer $username wird vom Bot nicht ignoriert" + }, + "added": "$sender, Benutzer $username wird zur Bot-Ignorierliste hinzugefügt", + "removed": "$sender, Benutzer $username wurde von der Bot-Ignorierliste entfernt", + "ignored": "$sender, Benutzer $username wird vom Bot ignoriert" + } + } + }, + "filters": { + "setVariable": "$sender, $variable wurde auf $value gesetzt." + } +} diff --git a/backend/locales/de/api.clips.json b/backend/locales/de/api.clips.json new file mode 100644 index 000000000..5c46c9ac4 --- /dev/null +++ b/backend/locales/de/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "Clip wurde erstellt und ist verfügbar unter $link" +} \ No newline at end of file diff --git a/backend/locales/de/core/permissions.json b/backend/locales/de/core/permissions.json new file mode 100644 index 000000000..bd2649b78 --- /dev/null +++ b/backend/locales/de/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "Liste deiner Berechtigungen:", + "excludeAddSuccessful": "$sender, Du hast $username hinzugefügt, um die Liste für die Berechtigung $permissionName auszuschließen", + "excludeRmSuccessful": "$sender, Du hast $username von der Ausschlussliste für Berechtigung $permissionName entfernt", + "userNotFound": "$sender, Benutzer $username wurde in der Datenbank nicht gefunden.", + "permissionNotFound": "$sender, Berechtigung $userlevel wurde in der Datenbank nicht gefunden.", + "cannotIgnoreForCorePermission": "$sender, Du kannst Benutzer für Kernberechtigungen nicht manuell ausschließen $userlevel" +} \ No newline at end of file diff --git a/backend/locales/de/games.heist.json b/backend/locales/de/games.heist.json new file mode 100644 index 000000000..3763ae633 --- /dev/null +++ b/backend/locales/de/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, Bullen suchen noch immer nach dem letzten Überfall-Team. Versuchen Sie es nach $cooldown erneut.", + "copsCooldownMessage": "Gut Jungs, sieht so aus, als würden die Polizeikräfte Donuts essen und wir können das süße Geld bekommen!", + "entryMessage": "$sender hat damit begonnen, einen Raub zu planen! Suche nach einer größeren Mannschaft für eine größere Punktzahl. Mach mit! Tippe $command um beizutreten.", + "lateEntryMessage": "$sender, Raub ist gerade im Gange!", + "entryInstruction": "$sender, gib $command ein, um beizutreten.", + "levelMessage": "Mit dieser Mannschaft können wir $bank ausrauben! Lass uns sehen, ob wir genug Crewmitglieder bekommen können, um $nextBank auszurauben", + "maxLevelMessage": "Mit dieser Mannschaft können wir $bank ausrauben! Es könnte nicht besser sein!", + "started": "Also gut Leute, überprüft eure Ausrüstung. Das ist das, wofür wir ausgebildet wurden. Dies ist kein Spiel, das ist das echte Leben. Wir werden das Geld von $bank bekommen!", + "noUser": "Niemand schließt sich der Mannschaft an, um einen Raub zu gehen.", + "singleUserSuccess": "$user war wie ein Ninja. Niemand hat das fehlende Geld bemerkt.", + "singleUserFailed": "$user hat es versäumt, die Polizei loszuwerden und wird seine Zeit im Gefängnis verbringen müssen.", + "result": { + "0": "Jeder wurde gnadenlos ausgelöscht. Das war eine Schlacht.", + "33": "Nur 1/3 der Mannschaft bekommt ihr Geld von Raub.", + "50": "Die Hälfte der Mannschaft wurde von der Polizei getötet oder gefangen.", + "99": "Einige Verluste der Raubüberfallmannschaft sind nichts im Vergleich zu dem, was die verbleibende Mannschaft in ihren Taschen hat.", + "100": "Oh mein Gott, niemand ist tot und jeder hat gewonnen!" + }, + "levels": { + "bankVan": "Geldtransporter", + "cityBank": "Städtische Bank", + "stateBank": "Staatsbank", + "nationalReserve": "Nationale Reserve", + "federalReserve": "Bundesreserve" + }, + "results": "Die Heist-Auszahlungen sind: $users", + "andXMore": "und $count mehr..." +} \ No newline at end of file diff --git a/backend/locales/de/integrations/discord.json b/backend/locales/de/integrations/discord.json new file mode 100644 index 000000000..77b7f2860 --- /dev/null +++ b/backend/locales/de/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "dein Konto ist nicht verknüpft, benutze `$command`", + "all-your-links-were-deleted": "Alle Ihre Links wurden gelöscht", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender, dieses Konto wurde mit $discordTag verknüpft.", + "invalid-or-expired-token": "$sender, ungültiger oder abgelaufener Token.", + "help-message": "$sender, um deinen Account auf Discord zu verlinken. 1. Gehe zum Discord Server und sende $command im Bot channel. | 2. Warte auf eine PM vom Bot | 3. Senden den Befehl von deiner Discord PM hier im twitch Chat.", + "started-at": "Gestartet am", + "announced-by": "Benachrichtigung durch", + "streamed-at": "Streamed am", + "link-whisper": "Hallo $tag, um diesen Discord Account mit deinem Twitch Account auf dem Kanal von $broadcaster zu verknüpfen, gehe auf , Melden Sie sich an und senden Sie diesen Befehl an den Chat \n\n\t\t`$command $id`\n\nHINWEIS: Dies läuft in 10 Minuten ab.", + "check-your-dm": "Überprüfen Sie Ihre Direktnachrichten auf Schritte zum Verknüpfen Ihres Kontos." +} \ No newline at end of file diff --git a/backend/locales/de/integrations/lastfm.json b/backend/locales/de/integrations/lastfm.json new file mode 100644 index 000000000..34e93eba3 --- /dev/null +++ b/backend/locales/de/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "Aktueller Song ist $name" +} \ No newline at end of file diff --git a/backend/locales/de/integrations/obswebsocket.json b/backend/locales/de/integrations/obswebsocket.json new file mode 100644 index 000000000..8ff7ea968 --- /dev/null +++ b/backend/locales/de/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, es gibt keine Aktion für Id:$id!", + "ParameterError": "$sender, Sie müssen Id angeben!", + "UnknownError": "$sender, etwas ist schief gelaufen. Überprüfen Sie die Bot-Logs auf zusätzliche Informationen." + } +} \ No newline at end of file diff --git a/backend/locales/de/integrations/protondb.json b/backend/locales/de/integrations/protondb.json new file mode 100644 index 000000000..0b71f7c3f --- /dev/null +++ b/backend/locales/de/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | $rating bewertet | Native auf $native | Details: $url", + "responseNg": "Bewertung für Spiel $game wurde nicht auf ProtonDB gefunden.", + "responseNotFound": "Bewertung für Spiel $game wurde nicht auf ProtonDB gefunden." +} \ No newline at end of file diff --git a/backend/locales/de/integrations/pubg.json b/backend/locales/de/integrations/pubg.json new file mode 100644 index 000000000..3a844d7ca --- /dev/null +++ b/backend/locales/de/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, erwartete einen dieser Parameter: $list" +} \ No newline at end of file diff --git a/backend/locales/de/integrations/spotify.json b/backend/locales/de/integrations/spotify.json new file mode 100644 index 000000000..650832e8d --- /dev/null +++ b/backend/locales/de/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "Verzeihung, $sender, Titel wurde nicht auf Spotify gefunden", + "song-requested": "$sender, Du hast dir den Song $name von $artist gewünscht", + "not-banned-song-not-playing": "$sender, kein Lied wird derzeit gesperrt.", + "song-banned": "$sender, Song $name von $artist ist gebannt.", + "song-unbanned": "$sender, song $name von $artist ist entbannt.", + "song-not-found-in-banlist": "$sender, song über spotifyURI $uri wurde in der ban Liste nicht gefunden.", + "cannot-request-song-is-banned": "$sender, kann nicht den gebannten Song $name von $artist anfordern.", + "cannot-request-song-from-unapproved-artist": "$sender, kann kein Lied von nicht genehmigten Künstlern anfordern.", + "no-songs-found-in-history": "$sender, es gibt derzeit kein Lied in der Verlaufsliste.", + "return-one-song-from-history": "$sender, vorheriger Song war $name von $artist.", + "return-multiple-song-from-history": "$sender, $count vorherige Lieder waren:", + "return-multiple-song-from-history-item": "$index - $name von $artist", + "song-notify": "Aktueller Song ist $name von $artist." +} \ No newline at end of file diff --git a/backend/locales/de/integrations/tiltify.json b/backend/locales/de/integrations/tiltify.json new file mode 100644 index 000000000..18facdd9f --- /dev/null +++ b/backend/locales/de/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, es gibt derzeit keine aktiven Kampagnen.", + "active_campaigns": "$sender, Liste der derzeit aktiven Kampagne:" +} \ No newline at end of file diff --git a/backend/locales/de/systems.quotes.json b/backend/locales/de/systems.quotes.json new file mode 100644 index 000000000..50ed46ae6 --- /dev/null +++ b/backend/locales/de/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, Zitat $id '$quote' wurde hinzugefügt. (Tags: $tags)", + "error": "$sender, $command ist nicht korrekt oder fehlendes -quote-Parameter" + }, + "remove": { + "ok": "$sender, Zitat $id wurde erfolgreich gelöscht.", + "error": "$sender, Zitat-ID fehlt.", + "not-found": "$sender, Zitat $id wurde nicht gefunden." + }, + "show": { + "ok": "Zitat $id von $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command es fehlt -id oder -tag.", + "not-found-by-id": "$sender, Zitat $id wurde nicht gefunden.", + "not-found-by-tag": "$sender, es wurden keine Zitate mit dem Tag $tag gefunden." + } + }, + "set": { + "ok": "$sender, Zitat $id Tags wurden gesetzt. (Tags: $tags)", + "error": { + "no-parameters": "$sender, $command es fehlt -id oder -tag.", + "not-found-by-id": "$sender, Zitat $id wurde nicht gefunden." + } + }, + "list": { + "ok": "$sender, Du findest die Zitatliste unter http://$urlBase/public/#/quotes", + "is-localhost": "$sender, Zitatlisten URL ist nicht korrekt angegeben." + } +} \ No newline at end of file diff --git a/backend/locales/de/systems/antihateraid.json b/backend/locales/de/systems/antihateraid.json new file mode 100644 index 000000000..e0305e091 --- /dev/null +++ b/backend/locales/de/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "Dieser Chat wurde von $username auf $mode gesetzt, um den Hass-Raid loszuwerden. Entschuldigen Sie die Unannehmlichkeiten!", + "mode": { + "0": "Nur Subs", + "1": "nur Follower", + "2": "nur Emotes" + } +} \ No newline at end of file diff --git a/backend/locales/de/systems/howlongtobeat.json b/backend/locales/de/systems/howlongtobeat.json new file mode 100644 index 000000000..b3e73941e --- /dev/null +++ b/backend/locales/de/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game nicht in Datenbank gefunden.", + "game": "$sender, $game | Main: $currentMain/$hltbMainh - $percentMain% | Main+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Fertigsteller: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Main: $currentMainh | Main+Extra: $currentMainExtrah | Fertigsteller: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/de/systems/levels.json b/backend/locales/de/systems/levels.json new file mode 100644 index 000000000..cf020d005 --- /dev/null +++ b/backend/locales/de/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, Level: $currentLevel ($currentXP $xpName), $nextXP $xpName zum nächsten Level.", + "changeXP": "$sender, Sie haben $xpName von $amount $xpName zu $username geändert.", + "notEnoughPointsToBuy": "Entschuldige $sender, aber du hast nicht $points $pointsName um $amount $xpName für Level $level zu kaufen.", + "XPBoughtByPoints": "$sender, du hast $amount $xpName mit $points $pointsName gekauft und Level $level erreicht.", + "somethingGetWrong": "$sender, mit deiner Anfrage ist etwas schief gelaufen." +} \ No newline at end of file diff --git a/backend/locales/de/systems/scrim.json b/backend/locales/de/systems/scrim.json new file mode 100644 index 000000000..96a28ab89 --- /dev/null +++ b/backend/locales/de/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Snipe Match ($type) beginnt in $time $unit", + "go": "Starte jetzt! Los Los Los!", + "putMatchIdInChat": "Bitte gib deine Match-ID im Chat ein => $command xxx", + "currentMatches": "Aktuelle Spiele: $matches", + "stopped": "Snipe Match wurde abgebrochen." +} \ No newline at end of file diff --git a/backend/locales/de/systems/top.json b/backend/locales/de/systems/top.json new file mode 100644 index 000000000..780a4709d --- /dev/null +++ b/backend/locales/de/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Top $amount (watch time): ", + "tips": "Top $amount (Spenden): ", + "level": "Top $amount (Level): ", + "points": "Top $amount (Punkte): ", + "messages": "Top $amount (Nachrichten): ", + "followage": "Top $amount (followage): ", + "subage": "Top $amount (Sub Alter): ", + "submonths": "Top $amount (Sub Monate): ", + "bits": "Top $amount (Bits): ", + "gifts": "Top $amount (SubGifts): " +} \ No newline at end of file diff --git a/backend/locales/de/ui.commons.json b/backend/locales/de/ui.commons.json new file mode 100644 index 000000000..2edae43c6 --- /dev/null +++ b/backend/locales/de/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Erweiterte Einstellungen", + "never": "nie", + "reset": "Zurücksetzen", + "moveUp": "Nach oben", + "moveDown": "Nach unten", + "stop-if-executed": "stoppen, falls ausgeführt", + "continue-if-executed": "fortsetzen, wenn ausgeführt", + "generate": "Generieren", + "thumbnail": "Vorschaubild", + "yes": "Ja", + "no": "Nein", + "show-more": "Mehr...", + "show-less": "Weniger anzeigen", + "allowed": "Erlaubt", + "disallowed": "Nicht erlaubt", + "back": "Zurück" +} diff --git a/backend/locales/de/ui.dialog.json b/backend/locales/de/ui.dialog.json new file mode 100644 index 000000000..790950159 --- /dev/null +++ b/backend/locales/de/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Bearbeiten", + "add": "Hinzufügen" + }, + "position": { + "settings": "Positionseinstellungen", + "anchorX": "Cursor X-Position", + "anchorY": "Cursor Y-Position", + "left": "Links", + "right": "Rechts", + "middle": "Mitte", + "top": "Oben", + "bottom": "Unten", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Nach rechts verschieben", + "shadowShiftDown": "Nach unten verschieben", + "shadowBlur": "Unscharf", + "shadowOpacity": "Deckkraft", + "color": "Farbe" + }, + "errors": { + "required": "Dieser Wert darf nicht leer sein.", + "minValue": "Der niedrigste Wert dieser Eingabe ist $value." + }, + "buttons": { + "reorder": "Umsortieren", + "upload": { + "idle": "Hochladen", + "progress": "Wird hochgeladen", + "done": "Hochgeladen" + }, + "cancel": "Abbrechen", + "close": "Schließen", + "test": { + "idle": "Test", + "progress": "Test läuft", + "done": "Test abgeschlossen" + }, + "saveChanges": { + "idle": "Änderungen speichern", + "invalid": "Änderungen können nicht gespeichert werden", + "progress": "Speichere Änderungen…", + "done": "Änderungen gespeichert" + }, + "something-went-wrong": "Etwas ist schiefgegangen", + "mark-to-delete": "Als „Gelöscht“ markieren", + "disable": "Deaktivieren", + "enable": "Aktivieren", + "disabled": "Deaktiviert", + "enabled": "Aktiviert", + "edit": "Bearbeiten", + "delete": "Löschen", + "play": "Abspielen", + "stop": "Stop", + "hold-to-delete": "Halten zum Löschen", + "yes": "Ja", + "no": "Nein", + "permission": "Berechtigung", + "group": "Gruppe", + "visibility": "Sichtbarkeit", + "reset": "Zurücksetzen" + }, + "changesPending": "Deine Änderungen wurden nicht gespeichert.", + "formNotValid": "Formular ist ungültig.", + "nothingToShow": "Hier gibt es nichts zu sehen." +} \ No newline at end of file diff --git a/backend/locales/de/ui.menu.json b/backend/locales/de/ui.menu.json new file mode 100644 index 000000000..a0de86b4b --- /dev/null +++ b/backend/locales/de/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Dienste", + "updater": "Updater", + "index": "Dashboard", + "core": "Bot", + "users": "Benutzer", + "tmi": "TMI", + "ui": "UI", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "Allgemein", + "timers": "Timers", + "new": "Neues Item", + "keywords": "Schlüsselwörter", + "customcommands": "Benutzerdefinierte Befehle", + "botcommands": "Bot-Befehle", + "commands": "Befehle", + "events": "Ereignisse", + "ranks": "Ränge", + "songs": "Songs", + "modules": "Module", + "viewers": "Zuschauer", + "alias": "Aliase", + "cooldowns": "Cooldowns", + "cooldown": "Cooldown", + "highlights": "Highlights", + "price": "Preis", + "logs": "Logs", + "systems": "Systeme", + "permissions": "Berechtigungen", + "translations": "Benutzerdefinierte Übersetzungen", + "moderation": "Moderation", + "overlays": "Overlays", + "gallery": "Mediathek", + "games": "Spiele", + "spotify": "Spotify", + "integrations": "Integrationen", + "customvariables": "Benutzerdefinierte Variablen", + "registry": "Registry", + "quotes": "Zitate", + "settings": "Einstellungen", + "commercial": "Kommerziell", + "bets": "Wetten", + "points": "Punkte", + "raffles": "Gewinnspiele", + "queue": "Warteschlange", + "playlist": "Playlist", + "bannedsongs": "Gesperrte Songs", + "spotifybannedsongs": "Spotify gesperrte Songs", + "duel": "Duell", + "fightme": "FightMe", + "seppuku": "Seppuku", + "gamble": "Glücksspiel", + "roulette": "Roulette", + "heist": "Heist", + "oauth": "OAuth", + "socket": "Socket", + "carouseloverlay": "Karussell Overlay", + "alerts": "Alerts", + "carousel": "Bilderkarussell", + "clips": "Clips", + "credits": "Credits", + "emotes": "Emotes", + "stats": "Statistiken", + "text": "Text", + "currency": "Währung", + "eventlist": "Event-Liste", + "clipscarousel": "Clips-Karussell", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Checkliste", + "bot": "Bot", + "api": "API", + "manage": "Verwalten", + "top": "Top", + "goals": "Ziele", + "userinfo": "Benutzer-Info", + "scrim": "Scrim", + "commandcount": "Anzahl Befehle", + "profiler": "Profiler", + "howlongtobeat": "Zeit zum Durchspielen", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Zufallsgenerator", + "tips": "Spenden", + "bits": "Bits", + "discord": "Discord", + "texttospeech": "Text zu Sprache", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "Levels", + "obswebsocket": "OBS Websocket", + "api-explorer": "API-Explorer", + "emotescombo": "Emotes Combo", + "notifications": "Benachrichtigung", + "plugins": "Plugins", + "tts": "TTS" +} diff --git a/backend/locales/de/ui.page.settings.overlays.carousel.json b/backend/locales/de/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..8b88189d2 --- /dev/null +++ b/backend/locales/de/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "Optionen", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Bist du dir sicher, dass du dieses Bild löschen willst?" + }, + "button": { + "update": "Aktualisieren", + "fix_your_errors_first": "Behebe alle Fehler vor dem Speichern" + }, + "errors": { + "number_greater_or_equal_than_0": "Wert muss eine Zahl >= 0 sein", + "value_must_not_be_empty": "Wert darf nicht leer sein" + }, + "titles": { + "waitBefore": "Wartezeit in Millisekunden bevor Bild gezeigt wird", + "waitAfter": "Wartezeit nach dem Verschwinden des Bildes (in ms)", + "duration": "Wie lange das Bild angezeigt werden soll (in ms)", + "animationIn": "Eingehende Animation", + "animationOut": "Ausgehende Animation", + "animationInDuration": "Dauer der eingehenden Animation (in ms)", + "animationOutDuration": "Dauer der ausgehenden Animation (in ms)", + "showOnlyOncePerStream": "Nur einmal pro Stream anzeigen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.registry.customvariables.json b/backend/locales/de/ui.registry.customvariables.json new file mode 100644 index 000000000..c9be3bfa7 --- /dev/null +++ b/backend/locales/de/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "URLs", + "generateurl": "Neue URL generieren", + "show-examples": "CURL-Beispiele anzeigen", + "response": { + "show": "Antwort nach POST anzeigen", + "name": "Antwort nach Variable gesetzt", + "default": "Standard", + "default-placeholder": "Bot Antwort festlegen", + "default-help": "Verwende $value , um neuen Variablenwert zu erhalten", + "custom": "Benutzerdefiniert", + "command": "Befehl" + }, + "useIfInCommand": "Verwende diese Option, wenn eine Variable im Befehl verwendet wird. Gibt nur die aktualisierte Variable ohne Antwort zurück.", + "permissionToChange": "Änderungsberechtigung", + "isReadOnly": "Nur-Lesen in Chat", + "isNotReadOnly": "kann über den Chat geändert werden", + "no-variables-found": "Keine Variablen gefunden", + "additional-info": "Zusätzliche Infos", + "run-script": "Script ausführen", + "last-run": "Letzte Ausführung:", + "variable": { + "name": "Variablenname", + "help": "Name der Variable muss eindeutig sein, z.B. $_wins, $_loses, $_top3", + "placeholder": "Gebe einen eindeutigen Variablennamen ein", + "error": { + "isNotUnique": "Variable muss einen eindeutigen Namen haben.", + "isEmpty": "Variablenname darf nicht leer sein." + } + }, + "description": { + "name": "Beschreibung", + "help": "Optionale Beschreibung", + "placeholder": "Gebe eine optionale Beschreibung ein" + }, + "type": { + "name": "Typ", + "error": { + "isNotSelected": "Bitte wähle einen Variablentyp aus." + } + }, + "currentValue": { + "name": "Aktueller Wert", + "help": "Wenn der Typ auf evaluiertes Skript eingestellt ist, kann der Wert nicht manuell geändert werden" + }, + "usableOptions": { + "name": "Verfügbare Optionen", + "placeholder": "Gib, deine, Optionen, hier, ein", + "help": "Optionen, die mit dieser Variable verwendet werden können, Beispiel: SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "Es muss mindestens 1 Wert eingestellt werden." + } + }, + "scriptToEvaluate": "Skript zur Evaluierung", + "runScript": { + "name": "Script ausführen", + "error": { + "isNotSelected": "Bitte eine Option auswählen." + } + }, + "testCurrentScript": { + "name": "Aktuelles Skript testen", + "help": "Klicke auf Aktuelles Skript testen, um den Wert im Aktueller Wert-Eingabefeld zu sehen" + }, + "history": "Historie", + "historyIsEmpty": "Die Historie für diese Variable ist leer!", + "warning": "Warnung: Alle Daten dieser Variable werden verworfen!", + "choose": "Auswählen...", + "types": { + "number": "Nummer", + "text": "Text", + "options": "Optionen", + "eval": "Skript" + }, + "runEvery": { + "isUsed": "Wenn Variable verwendet wird" + } +} + diff --git a/backend/locales/de/ui.systems.antihateraid.json b/backend/locales/de/ui.systems.antihateraid.json new file mode 100644 index 000000000..b10d03ad1 --- /dev/null +++ b/backend/locales/de/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Chat leeren", + "mode": "Modus", + "minFollowTime": "Minimale Folgezeit", + "customAnnounce": "Ankündigungen für Anti-Hass-Schlachtzug anpassen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.bets.json b/backend/locales/de/ui.systems.bets.json new file mode 100644 index 000000000..d73601977 --- /dev/null +++ b/backend/locales/de/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "betPercentGain": "Füge x% hinzu, um Auszahlung in jeder Option zu setzen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.commercial.json b/backend/locales/de/ui.systems.commercial.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/de/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.cooldown.json b/backend/locales/de/ui.systems.cooldown.json new file mode 100644 index 000000000..5a8b1bf64 --- /dev/null +++ b/backend/locales/de/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Antwort über Privat Nachricht", + "settings": { + "enabled": "Status", + "cooldownNotifyAsWhisper": "Informationen zur Abklingzeit von Whisper-Nachrichten", + "cooldownNotifyAsChat": "Informationen zur Abklingzeit von Chat-Nachrichten", + "defaultCooldownOfCommandsInSeconds": "Standard-Abklingzeit für Befehle (in Sekunden)", + "defaultCooldownOfKeywordsInSeconds": "Standard Abklingzeit für Keywords (in Sekunden)" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.customcommands.json b/backend/locales/de/ui.systems.customcommands.json new file mode 100644 index 000000000..64d0c6d0c --- /dev/null +++ b/backend/locales/de/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "Keine Antworten", + "addResponse": "Antwort hinzufügen", + "response": { + "name": "Antwort", + "placeholder": "Lege hier deine Antwort fest." + }, + "filter": { + "name": "Filter", + "placeholder": "Filter für diese Antwort hinzufügen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.highlights.json b/backend/locales/de/ui.systems.highlights.json new file mode 100644 index 000000000..91a640c96 --- /dev/null +++ b/backend/locales/de/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "urls": "Generierte URLs" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.moderation.json b/backend/locales/de/ui.systems.moderation.json new file mode 100644 index 000000000..3ef099cc0 --- /dev/null +++ b/backend/locales/de/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Status", + "cListsEnabled": "Erzwinge die Regel", + "cLinksEnabled": "Erzwinge die Regel", + "cSymbolsEnabled": "Erzwinge die Regel", + "cLongMessageEnabled": "Erzwinge die Regel", + "cCapsEnabled": "Erzwinge die Regel", + "cSpamEnabled": "Erzwinge die Regel", + "cColorEnabled": "Erzwinge die Regel", + "cEmotesEnabled": "Erzwinge die Regel", + "cListsWhitelist": { + "title": "Erlaubte Wörter", + "help": "Um Domains zu erlauben, verwenden Sie \"domain:prtzl.io\"" + }, + "autobanMessages": "Autoban-Nachrichten", + "cListsBlacklist": "Verbotene Wörter", + "cListsTimeout": "Dauer der Zeitüberschreitung", + "cLinksTimeout": "Dauer der Zeitüberschreitung", + "cSymbolsTimeout": "Dauer der Zeitüberschreitung", + "cLongMessageTimeout": "Dauer der Zeitüberschreitung", + "cCapsTimeout": "Dauer der Zeitüberschreitung", + "cSpamTimeout": "Dauer der Zeitüberschreitung", + "cColorTimeout": "Dauer der Zeitüberschreitung", + "cEmotesTimeout": "Dauer der Zeitüberschreitung", + "cWarningsShouldClearChat": "Soll Chat löschen (Timeout für 1s)", + "cLinksIncludeSpaces": "Leerzeichen einbeziehen", + "cLinksIncludeClips": "Clips einbeziehen", + "cSymbolsTriggerLength": "Auslöserlänge der Nachricht", + "cLongMessageTriggerLength": "Auslöserlänge der Nachricht", + "cCapsTriggerLength": "Auslöserlänge der Nachricht", + "cSpamTriggerLength": "Auslöserlänge der Nachricht", + "cSymbolsMaxSymbolsConsecutively": "Maximale Zeichen hintereinander", + "cSymbolsMaxSymbolsPercent": "Max. Symbole %", + "cCapsMaxCapsPercent": "Max. Caps %", + "cSpamMaxLength": "Maximale Länge", + "cEmotesMaxCount": "Maximale Anzahl", + "cWarningsAnnounceTimeouts": "Timeouts im Chat für alle ankündigen", + "cWarningsAllowedCount": "Anzahl der Warnungen", + "cEmotesEmojisAreEmotes": "Emojis als Emotes behandeln" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.points.json b/backend/locales/de/ui.systems.points.json new file mode 100644 index 000000000..fe169f7be --- /dev/null +++ b/backend/locales/de/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Status", + "name": { + "title": "Name", + "help": "Einzahl/Mehrzahl durch | getrennt. Beispiel: Punkt|Punkte" + }, + "isPointResetIntervalEnabled": "Automatisches Punkte zurücksetzen", + "resetIntervalCron": { + "name": "Cron-Intervall", + "help": "CronTab generator" + }, + "interval": "Intervall in Minuten, in denen Zuschauer Punkte erhalten, während der Stream läuft", + "offlineInterval": "Intervall in Minuten, in denen Zuschauer Punkte erhalten, während der Stream nicht läuft", + "messageInterval": "Alle x Nachrichten erhalten Zuschauer Punkte", + "messageOfflineInterval": "Alle x Nachrichten erhalten Zuschauer Punkte, während der Stream nicht läuft", + "perInterval": "Punkte, die Zuschauer innerhalb eines Intervalls erhalten", + "perOfflineInterval": "Punkte, die Zuschauer innerhalb eines Intervalls, während der Stream nicht läuft, erhalten", + "perMessageInterval": "Punkteanzahl, die Zuschauer nach x Nachrichten erhalten", + "perMessageOfflineInterval": "Punkteanzahl die Zuschauer erhalten nach x Nachrichten während der Stream nicht läuft" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.price.json b/backend/locales/de/ui.systems.price.json new file mode 100644 index 000000000..b34deafd6 --- /dev/null +++ b/backend/locales/de/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Trigger custom alerts on bit redeem", + "price": { + "name": "Preis", + "placeholder": "" + }, + "error": { + "isEmpty": "Dieser Wert darf nicht leer sein" + }, + "warning": "Diese Aktion kann nicht rückgängig gemacht werden!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.queue.json b/backend/locales/de/ui.systems.queue.json new file mode 100644 index 000000000..d11034483 --- /dev/null +++ b/backend/locales/de/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Status", + "eligibilityAll": "Alle", + "eligibilityFollowers": "Follower", + "eligibilitySubscribers": "Abonnenten" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.quotes.json b/backend/locales/de/ui.systems.quotes.json new file mode 100644 index 000000000..a77cef921 --- /dev/null +++ b/backend/locales/de/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "Leider wurden in der Datenbank keine Zitate gefunden.", + "new": "Neues Zitat hinzufügen", + "empty": "Liste der Zitate ist leer, neues Zitat erstellen.", + "emptyAfterSearch": "Bei der Suche nach \"$search\" wurden keine Zitate gefunden", + "quote": { + "name": "Zitat", + "placeholder": "Setze hier dein Zitat" + }, + "by": { + "name": "Zitat von" + }, + "tags": { + "name": "Tags", + "placeholder": "Setze hier deine Tags", + "help": "Durch Komma getrennte Tags. Beispiel: Tag 1, Tag 2, Tag 3" + }, + "date": { + "name": "Datum" + }, + "error": { + "isEmpty": "Dieser Wert darf nicht leer sein", + "atLeastOneTag": "Es muss mindestens ein Tag gesetzt werden" + }, + "tag-filter": "Nach Tag filtern", + "warning": "Diese Aktion kann nicht rückgängig gemacht werden!", + "settings": { + "enabled": "Status", + "urlBase": { + "title": "URL Basis", + "help": "Es sollte ein öffentlicher Endpoint für Zitate verwenden werden, damit jeder darauf zugreifen kann" + } + } +} diff --git a/backend/locales/de/ui.systems.raffles.json b/backend/locales/de/ui.systems.raffles.json new file mode 100644 index 000000000..ec791850c --- /dev/null +++ b/backend/locales/de/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Glück der Abonnenten" + }, + "settings": { + "enabled": "Status", + "announceNewEntries": { + "title": "Neue Eintrag bekannt geben", + "help": "Wenn Benutzer an der Tombola teilnehmen, wird die Nachricht nach einer Weile an den Chat geschickt." + }, + "announceNewEntriesBatchTime": { + "title": "Dauer bis zur Bekanntgabe neuer Einträge (in Sekunden)", + "help": "Längere Zeit sorgt dafür, dass der Chat sauberer bleibt, die Einträge werden zusammengefasst." + }, + "deleteRaffleJoinCommands": { + "title": "Lösche Benutzer Tombolen join Befehl", + "help": "Dies wird Benutzernachricht löschen, wenn sie !yourraffle Befehl verwenden. Soll den Chat sauberer halten." + }, + "allowOverTicketing": { + "title": "Überschreitung der maximalen Tickets erlauben", + "help": "Erlaube dem Benutzer, mit mehr Tickets an der Verlosung teilzunehmen. Ein Benutzer hat z.B. 10 Tickets, kann aber mit !raffle 100 teilnehmen, wobei alle seine Tickets verwendet werden." + }, + "raffleAnnounceInterval": { + "title": "Intervall ankündigen", + "help": "Minuten" + }, + "raffleAnnounceMessageInterval": { + "title": "Nachrichtenintervall ankündigen", + "help": "Wie viele Nachrichten in den Chat geschickt werden müssen, bis die Ankündigungen veröffentlicht werden." + }, + "subscribersPercent": { + "title": "Zusätzliches Glück der Abonnenten", + "help": "in Prozent" + } + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.ranks.json b/backend/locales/de/ui.systems.ranks.json new file mode 100644 index 000000000..fb261e3a1 --- /dev/null +++ b/backend/locales/de/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "Neuer Rang", + "empty": "Es wurden noch keine Ränge erstellt.", + "emptyAfterSearch": "Bei der Suche nach \"$search\" wurden keine Ränge gefunden.", + "rank": { + "name": "Rang", + "placeholder": "" + }, + "value": { + "name": "Stunden", + "placeholder": "" + }, + "error": { + "isEmpty": "Dieser Wert darf nicht leer sein" + }, + "warning": "Diese Aktion kann nicht rückgängig gemacht werden!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.songs.json b/backend/locales/de/ui.systems.songs.json new file mode 100644 index 000000000..aa8fe8a73 --- /dev/null +++ b/backend/locales/de/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Status", + "volume": "Lautstärke", + "calculateVolumeByLoudness": "Dynamische Lautstärke in Abhängigkeit zur Lautheit", + "duration": { + "title": "Maximale Länge eines Songs", + "help": "In Minuten" + }, + "shuffle": "Zufallswiedergabe", + "songrequest": "Von Song-Request wiedergeben", + "playlist": "Von Wiedergabeliste wiedergeben", + "onlyMusicCategory": "Nur Musik aus Kategorien zulassen", + "allowRequestsOnlyFromPlaylist": "Liedanfragen nur von der aktuellen Wiedergabeliste zulassen", + "notify": "Nachricht bei Songwechsel senden" + }, + "error": { + "isEmpty": "Dieser Wert darf nicht leer sein" + }, + "startTime": "Song starten bei", + "endTime": "Song beenden bei", + "add_song": "Song hinzufügen", + "add_or_import": "Song hinzufügen oder aus Wiedergabeliste importieren", + "importing": "Importieren", + "importing_done": "Importieren abgeschlossen", + "seconds": "Sekunden", + "calculated": "Berechnet", + "set_manually": "Manuell einstellen", + "bannedSongsEmptyAfterSearch": "Ihre Suche nach \"$search\" hat keine gesperrten Titel gefunden.", + "emptyAfterSearch": "Ihre Suche nach \"$search\" hat keine Titel gefunden.", + "empty": "Es wurden noch keine Titel hinzugefügt.", + "bannedSongsEmpty": "Es wurden noch keine Titel zur Bannliste hinzugefügt." +} \ No newline at end of file diff --git a/backend/locales/de/ui.systems.timers.json b/backend/locales/de/ui.systems.timers.json new file mode 100644 index 000000000..a229d9565 --- /dev/null +++ b/backend/locales/de/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "Neuer Timer", + "empty": "Bisher wurden noch keine Timer erstellt.", + "emptyAfterSearch": "Bei der Suche nach \"$search\" wurden keine Timer gefunden.", + "add_response": "Antwort hinzufügen", + "settings": { + "enabled": "Status" + }, + "warning": "Diese Aktion kann nicht rückgängig gemacht werden!" +} \ No newline at end of file diff --git a/backend/locales/de/ui.widgets.customvariables.json b/backend/locales/de/ui.widgets.customvariables.json new file mode 100644 index 000000000..4b7d45bb9 --- /dev/null +++ b/backend/locales/de/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "Keine benutzerdefinierten Variablen gefunden. Füge welche im Register für benutzerdefinierte Variablen hinzu", + "add-variable-into-watchlist": "Variable zur Watchlist hinzufügen", + "watchlist": "Watchlist" +} \ No newline at end of file diff --git a/backend/locales/de/ui.widgets.randomizer.json b/backend/locales/de/ui.widgets.randomizer.json new file mode 100644 index 000000000..7db53fbd3 --- /dev/null +++ b/backend/locales/de/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "Kein Zufallsgenerator gefunden! Bitte füge einen im Zufallsgenerator-Register hinzu", + "add-randomizer-to-widget": "Zufallsgenerator zum Widget hinzufügen" +} \ No newline at end of file diff --git a/backend/locales/de/ui/categories.json b/backend/locales/de/ui/categories.json new file mode 100644 index 000000000..41938cd52 --- /dev/null +++ b/backend/locales/de/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Ankündigungen", + "keys": "Schlüssel", + "currency": "Währung", + "general": "Allgemein", + "settings": "Einstellung", + "commands": "Befehle", + "bot": "Bot", + "channel": "Kanal", + "connection": "Verbindung", + "chat": "Chat", + "graceful_exit": "Anmutiger Ausgang", + "rewards": "Belohnung", + "levels": "Levels", + "notifications": "Benachrichtigung", + "options": "Optionen", + "comboBreakMessages": "Combo Break Nachrichten", + "hypeMessages": "Hype Nachrichten", + "messages": "Nachrichten", + "results": "Ergebnis", + "customization": "Anpassung", + "status": "Status", + "mapping": "Zuordnung", + "player": "Spieler", + "stats": "Statistiken", + "api": "API", + "token": "Token", + "text": "Text", + "custom_texts": "Eigener Text", + "credits": "Credits", + "show": "Anzeigen", + "social": "Social", + "explosion": "Explosion", + "fireworks": "Feuerwerk", + "test": "Test", + "emotes": "Emotes", + "default": "Standard", + "urls": "URLs", + "conversion": "Konversion", + "xp": "XP", + "caps_filter": "Caps Filter", + "color_filter": "Italic (/me) filter", + "links_filter": "Link Filter", + "symbols_filter": "Symbol Filter", + "longMessage_filter": "Nachrichtenlängen Filter", + "spam_filter": "Spam Filter", + "emotes_filter": "Emote Filter", + "warnings": "Warnungen", + "reset": "Zurücksetzen", + "reminder": "Erinnerungen", + "eligibility": "Berechtigung", + "join": "Beitreten", + "luck": "Glück", + "lists": "Listen", + "me": "Ich", + "emotes_combo": "Emotes Combo", + "tmi": "tmi", + "oauth": "oauth", + "eventsub": "eventsub", + "rules": "Regeln" +} \ No newline at end of file diff --git a/backend/locales/de/ui/core/currency.json b/backend/locales/de/ui/core/currency.json new file mode 100644 index 000000000..b76ad3587 --- /dev/null +++ b/backend/locales/de/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Hauptwährung" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/core/general.json b/backend/locales/de/ui/core/general.json new file mode 100644 index 000000000..29ff82a2c --- /dev/null +++ b/backend/locales/de/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Bot-Sprache", + "numberFormat": "Format der Nummern im Chat", + "gracefulExitEachXHours": { + "title": "Geplantes beenden alle X Stunden", + "help": "0 - Deaktiviert" + }, + "shouldGracefulExitHelp": "Die Aktivierung von geplantem Beenden wird empfohlen, wenn der Bot ununterbrochen auf einem Server läuft. Der Bot sollte auf pm2 (oder einem ähnlichen Service) oder im Docker laufen, um einen automatischen Neustart des Bots zu gewährleisten. Der Bot wird nicht beendet, wenn der Stream online ist." + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/core/oauth.json b/backend/locales/de/ui/core/oauth.json new file mode 100644 index 000000000..da820ec72 --- /dev/null +++ b/backend/locales/de/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Besitzer", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "Benutzername", + "channelUsername": "Benutzername", + "botExpectedScopes": "Scopes", + "channelExpectedScopes": "Scopes" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/core/permissions.json b/backend/locales/de/ui/core/permissions.json new file mode 100644 index 000000000..f45f2aed8 --- /dev/null +++ b/backend/locales/de/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Neue Berechtigungsgruppe", + "higherPermissionHaveAccessToLowerPermissions": "Höhere Berechtigungen erben Berechtigungen von niedrigeren Gruppen.", + "typeUsernameOrIdToSearch": "Suche nach Benutzername oder ID", + "typeUsernameOrIdToTest": "Benutzername oder ID zum Testen eingeben", + "noUsersWereFound": "Es wurden keine Benutzer gefunden.", + "noUsersManuallyAddedToPermissionYet": "Es wurden noch keine Benutzer manuell zur Gruppe hinzugefügt.", + "done": "Fertig", + "previous": "Zurück", + "next": "Weiter", + "loading": "Wird geladen", + "permissionNotFoundInDatabase": "Berechtigung in der Datenbank nicht gefunden, bitte speichern, bevor du den Benutzer testst.", + "userHaveNoAccessToThisPermissionGroup": "Benutzer $username hat KEINEN Zugriff auf diese Berechtigungsgruppe.", + "userHaveAccessToThisPermissionGroup": "Benutzer $username hat Zugriff auf diese Berechtigungen.", + "accessDirectlyThrough": "Direkter Zugriff über", + "accessThroughHigherPermission": "Zugriff durch höhere Berechtigung", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Etwas ist schief gelaufen. Benutzer $username wurde in der Bot-Datenbank nicht gefunden.", + "permissionsGroups": "Berechtigungsgruppen", + "allowHigherPermissions": "Zugriff durch höhere Berechtigung erlauben", + "type": "Typ", + "value": "Wert", + "watched": "Watchtime in Stunden", + "followtime": "Follow Zeit in Monaten", + "points": "Punkte", + "tips": "Spenden", + "bits": "Bits", + "messages": "Nachrichten", + "subtier": "Abonnement Stufe (1, 2 oder 3)", + "subcumulativemonths": "Abonnement Monate", + "substreakmonths": "Aktuelle Sub Reihe", + "ranks": "Aktueller Rang", + "level": "Aktuelles Level", + "isLowerThan": "ist kleiner als", + "isLowerThanOrEquals": "ist kleiner oder gleich als", + "equals": "ist gleich", + "isHigherThanOrEquals": "ist größer als oder gleich", + "isHigherThan": "ist größer als", + "addFilter": "Filter hinzufügen", + "selectPermissionGroup": "Berechtigungsgruppe auswählen", + "settings": "Einstellungen", + "name": "Name", + "baseUsersSet": "Rechte ausgehend von Gruppe", + "manuallyAddedUsers": "Selbst hinzugefügte Benutzer", + "manuallyExcludedUsers": "Manuell ausgeschlossene Benutzer", + "filters": "Filter", + "testUser": "Benutzer testen", + "none": "-Keine-", + "casters": "Streamer", + "moderators": "Moderatoren", + "subscribers": "Abonnenten", + "vip": "VIP", + "viewers": "Zuschauer", + "followers": "Follower" +} \ No newline at end of file diff --git a/backend/locales/de/ui/core/socket.json b/backend/locales/de/ui/core/socket.json new file mode 100644 index 000000000..2cd22e54e --- /dev/null +++ b/backend/locales/de/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Lösche alle authentifizierte Verbindung (auch Deine)", + "accessTokenExpirationTime": "Zugriffstoken Ablaufzeit (Sekunden)", + "refreshTokenExpirationTime": "Aktualisierungs-Token Ablaufzeit (Sekunden)", + "socketToken": { + "title": "Socket-Token", + "help": "Dieses Token gibt Dir vollen Admin-Zugriff über Sockets. Nicht teilen!" + } + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/core/tmi.json b/backend/locales/de/ui/core/tmi.json new file mode 100644 index 000000000..be69c8f0e --- /dev/null +++ b/backend/locales/de/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Ignorierliste (ID oder Benutzername)", + "showWithAt": "Benutzer mit @ anzeigen", + "sendWithMe": "Nachrichten mit /me senden", + "sendAsReply": "Bot-Nachrichten als Antworten senden", + "mute": "Bot ist stumm", + "whisperListener": "Auf Befehle per Flüstern achten" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/core/tts.json b/backend/locales/de/ui/core/tts.json new file mode 100644 index 000000000..8d8671f2d --- /dev/null +++ b/backend/locales/de/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Dienst" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/core/twitch.json b/backend/locales/de/ui/core/twitch.json new file mode 100644 index 000000000..17b7a675c --- /dev/null +++ b/backend/locales/de/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Streammarker bei Ereignis erstellen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/core/ui.json b/backend/locales/de/ui/core/ui.json new file mode 100644 index 000000000..90a510578 --- /dev/null +++ b/backend/locales/de/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Standard-Design", + "domain": { + "title": "Domain", + "help": "Format ohne http/https: yourdomain.com oder dein.domain.com" + }, + "percentage": "Prozentsatz Differenz für Statistiken", + "shortennumbers": "Kurzes Format der Nummern", + "showdiff": "Unterschiede anzeigen", + "enablePublicPage": "Öffentliche Seite aktivieren" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/core/updater.json b/backend/locales/de/ui/core/updater.json new file mode 100644 index 000000000..59a0fa04f --- /dev/null +++ b/backend/locales/de/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Automatisch aktualisieren, wenn neuere Version verfügbar ist" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/errors.json b/backend/locales/de/ui/errors.json new file mode 100644 index 000000000..8b3e4bef8 --- /dev/null +++ b/backend/locales/de/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Unexpected errors during validation", + "isNotEmpty": "$property is required.", + "minLength": "$property must be longer than or equal to $constraint1 characters.", + "isPositive": "$property must be greater then 0", + "isCommand": "$property must start with !", + "isCommandOrCustomVariable": "$property must start with ! or $_", + "isCustomVariable": "$property must start with $_", + "min": "$property must be at least $constraint1", + "max": "$property must be lower or equal to $constraint1", + "isInt": "$property must be an integer", + "this_value_must_be_a_positive_number_and_greater_then_0": "This value must be a positive number or greater then 0", + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty", + "minLength_of_value_is": "Minimal length is $value.", + "this_currency_is_not_supported": "This currency is not supported", + "something_went_wrong": "Something went wrong", + "permission_must_exist": "Permission must exist", + "minValue_of_value_is": "Minimal value is $value", + "value_cannot_be": "Value cannot be $value.", + "invalid_format": "Invalid value format.", + "invalid_regexp_format": "This is not valid regex.", + "owner_and_broadcaster_oauth_is_not_set": "Owner and channel oauth is not set", + "channel_is_not_set": "Channel is not set", + "please_set_your_broadcaster_oauth_or_owners": "Please set your channel oauth or owners, or all users will have access to this dashboard and will be considered as casters.", + "new_update_available": "New update available", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "One of inputs must be set" +} \ No newline at end of file diff --git a/backend/locales/de/ui/games/duel.json b/backend/locales/de/ui/games/duel.json new file mode 100644 index 000000000..3dac40aa9 --- /dev/null +++ b/backend/locales/de/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Status", + "cooldown": "Cooldown", + "duration": { + "title": "Dauer", + "help": "Minuten" + }, + "minimalBet": "Minimaler Einsatz", + "bypassCooldownByOwnerAndMods": "Cooldown durch Streamer und Mods umgehen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/games/gamble.json b/backend/locales/de/ui/games/gamble.json new file mode 100644 index 000000000..d39689fdc --- /dev/null +++ b/backend/locales/de/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "minimalBet": "Minimaler Einsatz", + "chanceToWin": { + "title": "Chance auf einen Sieg", + "help": "Prozent" + }, + "enableJackpot": "Aktiviere Jackpot", + "chanceToTriggerJackpot": "Chance in %, den Jackpot auszulösen", + "maxJackpotValue": "Maximaler Wert des Jackpots", + "lostPointsAddedToJackpot": "Wie viele verlorene Punkte in % zum Jackpot hinzugefügt werden sollen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/games/heist.json b/backend/locales/de/ui/games/heist.json new file mode 100644 index 000000000..c6bacdf6a --- /dev/null +++ b/backend/locales/de/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Heist", + "settings": { + "enabled": "Status", + "showMaxUsers": "Max Benutzer bei der Auszahlung anzeigen", + "copsCooldownInMinutes": { + "title": "Abklingzeit zwischen Heists", + "help": "Minuten" + }, + "entryCooldownInSeconds": { + "title": "Zeit zum Beitritt", + "help": "Sekunden" + }, + "started": "Heist Startnachricht", + "nextLevelMessage": "Nachricht, wenn die nächste Stufe erreicht ist", + "maxLevelMessage": "Nachricht, wenn die maximale Stufe erreicht ist", + "copsOnPatrol": "Antwort des Bots wenn Heist immer noch Abklingzeit hat", + "copsCooldown": "Bot Ankündigung wenn Heist gestartet werden kann", + "singleUserSuccess": "Erfolgsmeldung für einen Benutzer", + "singleUserFailed": "Fehlermeldung für einen Benutzer", + "noUser": "Nachricht, wenn kein Benutzer teilgenommen hat" + }, + "message": "Nachricht", + "winPercentage": "Sieges-Prozentsatz", + "payoutMultiplier": "Auszahlungs-Multiplikator", + "maxUsers": "Max Benutzer für Stufe", + "percentage": "Prozentsatz", + "noResultsFound": "Keine Ergebnisse gefunden. Zum Hinzufügen eines neuen Ergebnisses auf die Schaltfläche unten klicken.", + "noLevelsFound": "Keine Stufen gefunden. Zum Hinzufügen einer neuen Stufe auf die Schaltfläche unten klicken." +} \ No newline at end of file diff --git a/backend/locales/de/ui/games/roulette.json b/backend/locales/de/ui/games/roulette.json new file mode 100644 index 000000000..c3ffaff2f --- /dev/null +++ b/backend/locales/de/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Dauer der Zeitüberschreitung", + "help": "Sekunden" + }, + "winnerWillGet": "Wie viele Punkte bei einem Sieg hinzugefügt werden", + "loserWillLose": "Wie viele Punkte bei einer Niederlage verloren gehen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/games/seppuku.json b/backend/locales/de/ui/games/seppuku.json new file mode 100644 index 000000000..8ac0ed54a --- /dev/null +++ b/backend/locales/de/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Dauer der Zeitüberschreitung", + "help": "Sekunden" + } + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/integrations/discord.json b/backend/locales/de/ui/integrations/discord.json new file mode 100644 index 000000000..f34173c54 --- /dev/null +++ b/backend/locales/de/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Status", + "guild": "Discord Server", + "listenAtChannels": "Diese Kanäle überwachen", + "sendOnlineAnnounceToChannel": "Stream online Benachrichtigung in folgendem Channel", + "onlineAnnounceMessage": "Nachricht in Online-Ankündigung (kann Erwähnungen enthalten)", + "sendAnnouncesToChannel": "Senden von Ankündigungen an Kanäle einrichten", + "deleteMessagesAfterWhile": "Nach einiger Zeit, Nachrichten löschen", + "clientId": "Client ID", + "token": "Token", + "joinToServerBtn": "Hier klicken, um den Bot mit dem Server zu verbinden", + "joinToServerBtnDisabled": "Bitte speichern Sie die Änderungen, um den Bot-Beitritt zu Ihrem Server zu ermöglichen", + "cannotJoinToServerBtn": "Token und Client-Id angeben, damit der Bot sich mit dem Server verbinden kann", + "noChannelSelected": "Keine Channel ausgewählt", + "noRoleSelected": "Keine Rolle ausgewählt", + "noGuildSelected": "kein Server ausgewählt", + "noGuildSelectedBox": "Wähle Server aus, wo der Bot beitreten soll und du wirst mehr Einstellungen sehen", + "onlinePresenceStatusDefault": "Standard Status", + "onlinePresenceStatusDefaultName": "Standard Status Nachricht", + "onlinePresenceStatusOnStream": "Status beim Streamen", + "onlinePresenceStatusOnStreamName": "Statusnachricht beim Streaming", + "ignorelist": { + "title": "Liste ignorieren", + "help": "username, username#0000 oder userID" + } + } +} diff --git a/backend/locales/de/ui/integrations/donatello.json b/backend/locales/de/ui/integrations/donatello.json new file mode 100644 index 000000000..69659881f --- /dev/null +++ b/backend/locales/de/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Erhalte deinen Schlüssel unter https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/integrations/donationalerts.json b/backend/locales/de/ui/integrations/donationalerts.json new file mode 100644 index 000000000..c0ce34b15 --- /dev/null +++ b/backend/locales/de/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Status", + "access_token": { + "title": "Zugangstoken", + "help": "Erhalten den Zugangs-Token unter https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Token aktualisieren" + }, + "accessTokenBtn": "DonationAlerts access and refresh token generator" + } +} diff --git a/backend/locales/de/ui/integrations/kofi.json b/backend/locales/de/ui/integrations/kofi.json new file mode 100644 index 000000000..529b882ab --- /dev/null +++ b/backend/locales/de/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Bestätigungstoken", + "help": "Holen Sie sich Ihren Verifizierungs-Token auf https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "Webhook URL", + "help": "Set Webhook URL at https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL muss HTTPS haben", + "origin": "Sie können localhost nicht für Webhooks verwenden" + } + } + } +} diff --git a/backend/locales/de/ui/integrations/lastfm.json b/backend/locales/de/ui/integrations/lastfm.json new file mode 100644 index 000000000..03d4d58a6 --- /dev/null +++ b/backend/locales/de/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": "API Schlüssel", + "username": "Benutzername" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/integrations/obswebsocket.json b/backend/locales/de/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..e22c2b9b8 --- /dev/null +++ b/backend/locales/de/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Status", + "accessBy": { + "title": "Zugriff durch", + "help": "Direkt - direkt von einem Bot verbinden | Overlay - via Overlay Browser Quelle verbinden" + }, + "address": "Adresse", + "password": "Passwort" + }, + "noSourceSelected": "Keine Quelle ausgewählt", + "noSceneSelected": "Keine Szene ausgewählt", + "empty": "Es wurden noch keine Aktionssätze erstellt.", + "emptyAfterSearch": "Ihre Suche nach \"$search\" hat keine Aktionssätze gefunden.", + "command": "Befehl", + "new": "Neue OBS Websocket Aktion erstellen", + "actions": "Aktionen", + "name": { + "name": "Name" + }, + "mute": "Stumm schalten", + "unmute": "Stummschaltung aufheben", + "SetCurrentScene": { + "name": "Aktuelle Szene setzen" + }, + "StartReplayBuffer": { + "name": "Replaypuffer starten" + }, + "StopReplayBuffer": { + "name": "Replaypuffer stoppen" + }, + "SaveReplayBuffer": { + "name": "Replaypuffer speichern" + }, + "WaitMs": { + "name": "Warte X Millisekunden" + }, + "Log": { + "name": "Log-Meldung" + }, + "StartRecording": { + "name": "Aufnahme starten" + }, + "StopRecording": { + "name": "Aufnahme stoppen" + }, + "PauseRecording": { + "name": "Aufnahme pausieren" + }, + "ResumeRecording": { + "name": "Aufnahme fortsetzen" + }, + "SetMute": { + "name": "Stummschaltung setzen" + }, + "SetVolume": { + "name": "Lautstärke setzen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/integrations/pubg.json b/backend/locales/de/ui/integrations/pubg.json new file mode 100644 index 000000000..7e4a8d6e9 --- /dev/null +++ b/backend/locales/de/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "API Schlüssel", + "help": "Erhalte deinen Schlüssel unter https://developer.pubg.com/" + }, + "platform": "Plattform", + "playerName": "Spielername", + "playerId": "Spieler ID", + "seasonId": { + "title": "Sitzungs ID", + "help": "Aktuelle Sitzungs ID wird jede Stunde abgerufen." + }, + "rankedGameModeStatsCustomization": "Benutzerdefinierte Nachricht für Ranked Statistiken", + "gameModeStatsCustomization": "Benutzerdefinierte Nachricht für normale Statistiken" + }, + "click_to_fetch": "Klicke zum Abrufen", + "something_went_wrong": "Etwas ist schiefgegangen!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Die Statistiken werden alle 10 Minuten automatisch aktualisiert.", + "player_stats_ranked": "Spielerstatistiken (Rangliste)", + "player_stats": "Spielerstatistiken" +} diff --git a/backend/locales/de/ui/integrations/qiwi.json b/backend/locales/de/ui/integrations/qiwi.json new file mode 100644 index 000000000..8748d55bc --- /dev/null +++ b/backend/locales/de/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "secretToken": { + "title": "Geheimer Token", + "help": "Geheimes Token bei Qiwi im Spenden Dashboard-Einstellungen -> Klicke auf Geheimes Token anzeigen" + } + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/integrations/responsivevoice.json b/backend/locales/de/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..a5f19e936 --- /dev/null +++ b/backend/locales/de/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Schlüssel", + "help": "Erhalte deinen Schlüssel unter http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/integrations/spotify.json b/backend/locales/de/ui/integrations/spotify.json new file mode 100644 index 000000000..157e4ee77 --- /dev/null +++ b/backend/locales/de/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Artists", + "settings": { + "enabled": "Status", + "songRequests": "Song-Requests", + "fetchCurrentSongWhenOffline": { + "title": "Aktuellen Song abrufen, wenn der Stream offline ist", + "help": "Es wird empfohlen, dies deaktiviert zu lassen, um API-Limitierungen zu vermeiden." + }, + "allowApprovedArtistsOnly": "Nur genehmigte Künstler erlauben", + "approvedArtists": { + "title": "Genehmigte Künstler", + "help": "Name oder SpotifyURI des Künstlers; ein Element pro Zeile" + }, + "queueWhenOffline": { + "title": "Stellt Songs in die Warteschlange, wenn der Stream offline ist.", + "help": "Es wird empfohlen, diese Option zu deaktivieren, um Warteschlangen zu vermeiden, wenn Sie nur Musik hören." + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "Erzwungene Geräte-ID", + "help": "Leer = deaktiviert, erzwinge die ID des Geräts für die Warteschlange von Songs. Prüfen Sie Logs für das aktuelle aktive Gerät oder verwenden Sie die Schaltfläche beim Abspielen von Liedes für mindestens 10 Sekunden." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Format", + "help": "Verfügbare Variablen: $song, $artist, $artists" + }, + "username": "Autorisierter Benutzer", + "revokeBtn": "Benutzerautorisierung widerrufen", + "authorizeBtn": "Autorisierter Benutzer", + "scopes": "Scopes", + "playlistToPlay": { + "title": "URI der Hauptwiedergabeliste von Spotify", + "help": "Falls festgelegt, wird diese Playlist nach Abschluss des Song-Requests fortgesetzt" + }, + "continueOnPlaylistAfterRequest": "Wiedergabe der Playlist nach Song-Request fortsetzen", + "notify": "Nachricht bei Änderung des Liedes senden" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/integrations/streamelements.json b/backend/locales/de/ui/integrations/streamelements.json new file mode 100644 index 000000000..a273a5fd6 --- /dev/null +++ b/backend/locales/de/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "jwtToken": { + "title": "JWT Token", + "help": "JWT-Token abrufen unter StreamElements Channels setting und schalte die Option Show secrets ein" + } + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/integrations/streamlabs.json b/backend/locales/de/ui/integrations/streamlabs.json new file mode 100644 index 000000000..3d2dcacb7 --- /dev/null +++ b/backend/locales/de/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "socketToken": { + "title": "Socket-Token", + "help": "Socket Token in Streamlabs Dashboard -> API Einstellungen -> API Tokens -> Your Socket API Token" + }, + "accessToken": { + "title": "Access Token", + "help": "Erhalten den Access-Token unter https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "StreamLabs Access-Token-Generator" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/integrations/tipeeestream.json b/backend/locales/de/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..ef4807ccc --- /dev/null +++ b/backend/locales/de/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "API-Schlüssel", + "help": "Socket Token von Tipeeestream Dashboard -> API -> Your API Key" + } + } +} diff --git a/backend/locales/de/ui/integrations/twitter.json b/backend/locales/de/ui/integrations/twitter.json new file mode 100644 index 000000000..a566690ee --- /dev/null +++ b/backend/locales/de/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "consumerKey": "Consumer Key (API-Schlüssel)", + "consumerSecret": "Consumer Secret (API Secret)", + "accessToken": "Access Token", + "secretToken": "Access Token Secret" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/managers.json b/backend/locales/de/ui/managers.json new file mode 100644 index 000000000..6ec224c23 --- /dev/null +++ b/backend/locales/de/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "Benutzerereignis Verlauf", + "hostAndRaidViewersCount": "Zuschauer: $value", + "receivedSubscribeFrom": "Sub von $value erhalten", + "giftedSubscribeTo": "Geschenkter Sub von $value erhalten" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/overlays/alerts.json b/backend/locales/de/ui/overlays/alerts.json new file mode 100644 index 000000000..9029607ca --- /dev/null +++ b/backend/locales/de/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Galerie zwischenspeichern", + "galleryCacheLimitInMb": "Maximale Größe des zwischenzuspeichernden Galerie-Elements (in MB)" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/overlays/clips.json b/backend/locales/de/ui/overlays/clips.json new file mode 100644 index 000000000..9b4e08e79 --- /dev/null +++ b/backend/locales/de/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Lautstärke", + "cClipsFilter": "Clip-Filter", + "cClipsLabel": "'Clip' Label anzeigen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/overlays/clipscarousel.json b/backend/locales/de/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..2623534a5 --- /dev/null +++ b/backend/locales/de/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Zeitspanne (Tage)", + "cClipsNumOfClips": "Anzahl an Clips", + "cClipsTimeToNextClip": "Zeit bis zum nächsten Clip (s)" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/overlays/credits.json b/backend/locales/de/ui/overlays/credits.json new file mode 100644 index 000000000..8d3602698 --- /dev/null +++ b/backend/locales/de/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Geschwindigkeit", + "cCreditsAggregated": "Zusammengefasste Credits", + "cShowGameThumbnail": "Show game thumbnail", + "cShowFollowers": "Follower anzeigen", + "cShowRaids": "Raids anzeigen", + "cShowSubscribers": "Abonnenten anzeigen", + "cShowSubgifts": "Verschenkte Subs anzeigen", + "cShowSubcommunitygifts": "Verschenkte Community-Subs anzeigen", + "cShowResubs": "Re-Subs anzeigen", + "cShowCheers": "Zeige Cheers", + "cShowClips": "Clips anzeigen", + "cShowTips": "Spenden anzeigen", + "cTextLastMessage": "Letzte Nachricht", + "cTextLastSubMessage": "Letzte Sub-Nachricht", + "cTextStreamBy": "Gestreamt von", + "cTextFollow": "Gefolgt von", + "cTextRaid": "Raided von", + "cTextCheer": "Cheer von", + "cTextSub": "Abonniert von", + "cTextResub": "Re-Sub von", + "cTextSubgift": "Verschenkte Subs", + "cTextSubcommunitygift": "Verschenkte Community-Subs", + "cTextTip": "Spenden von", + "cClipsPeriod": "Zeitintervall", + "cClipsCustomPeriodInDays": "Benutzerdefinierter Zeitintervall (Tage)", + "cClipsNumOfClips": "Anzahl der Clips", + "cClipsShouldPlay": "Clips sollen abgespielt werden", + "cClipsVolume": "Lautstärke" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/overlays/emotes.json b/backend/locales/de/ui/overlays/emotes.json new file mode 100644 index 000000000..1d8634598 --- /dev/null +++ b/backend/locales/de/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Cache leeren", + "hypeMessagesEnabled": "Hype Nachrichten im Chat anzeigen", + "btnTestExplosion": "Emote-Explosion testen", + "btnTestEmote": "Emote testen", + "btnTestFirework": "Teste Emote-Feuerwerk", + "cEmotesSize": "Größe der Emotes", + "cEmotesMaxEmotesPerMessage": "Maximale Anzahl von Emotes pro Nachricht", + "cEmotesMaxRotation": "Maximale Rotation des Emotes", + "cEmotesOffsetX": "Maximaler Offset auf X-Achse", + "cEmotesAnimation": "Animation", + "cEmotesAnimationTime": "Dauer der Animation", + "cExplosionNumOfEmotes": "Anzahl von Emotes", + "cExplosionNumOfEmotesPerExplosion": "Anzahl von Emotes pro Explosion", + "cExplosionNumOfExplosions": "Anzahl von Explosionen", + "enableEmotesCombo": "Emotes Combo aktivieren", + "comboBreakMessages": "Combopreak Nachrichten", + "threshold": "Schwellenwert", + "noMessagesFound": "Keine Nachrichten gefunden.", + "message": "Nachricht", + "showEmoteInOverlayThreshold": "Minimaler Nachrichtenschwellenwert um Emote im Overlay anzuzeigen", + "hideEmoteInOverlayAfter": { + "title": "Emote im Overlay nach Inaktivität ausblenden", + "help": "Versteckt Emote in Overlay nach einer bestimmten Zeit in Sekunden" + }, + "comboCooldown": { + "title": "Combo Abklingzeit", + "help": "Abklingzeit der Combo in Sekunden" + }, + "comboMessageMinThreshold": { + "title": "Minimaler Nachrichtenschwellenwert", + "help": "Minimaler Nachrichtenschwellenwert, um Emotes als combo zu zählen (bis dahin wird keine Abklingzeit ausgelöst)" + }, + "comboMessages": "Combo Nachrichten" + }, + "hype": { + "5": "Los geht's! Wir haben bisher $amountx $emote Combo bekommen! Sieh gut aus", + "15": "Go Go Go! Können wir mehr als $amountx $emote bekommen? TriHard" + }, + "message": { + "3": "$amountx $emote Combo", + "5": "$amountx $emote Combo SeemsGood", + "10": "$amountx $emote Combo PogChamp", + "15": "$amountx $emote Combo TriHard", + "20": "$sender ruinierte $amountx $emote Combo! NotLikeDies" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/overlays/polls.json b/backend/locales/de/ui/overlays/polls.json new file mode 100644 index 000000000..c1033a8f2 --- /dev/null +++ b/backend/locales/de/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Design", + "cDisplayHideAfterInactivity": "Bei Inaktivität ausblenden", + "cDisplayAlign": "Ausrichten", + "cDisplayInactivityTime": { + "title": "Inaktivität nach", + "help": "in Milisekunden" + } + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/overlays/texttospeech.json b/backend/locales/de/ui/overlays/texttospeech.json new file mode 100644 index 000000000..e17d0fdac --- /dev/null +++ b/backend/locales/de/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "Du hast den ResponsiveVoice Key unter ResponsiveVoice-Taste nicht gesetzt", + "voice": { + "title": "Stimme", + "help": "Wenn die Auswahl der Stimmen nicht angezeigt wird lade bitte die Seite neu." + }, + "volume": "Lautstärke", + "rate": "Geschwindigkeit", + "pitch": "Tonhöhe", + "triggerTTSByHighlightedMessage": "TTS wird bei hervorgehobenen Nachrichten ausgelöst" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/properties.json b/backend/locales/de/ui/properties.json new file mode 100644 index 000000000..e6243cf72 --- /dev/null +++ b/backend/locales/de/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Command", + "variableName": "Variable name", + "price": "Price (points)", + "priceBits": "Price (bits)", + "thisvalue": "This value", + "promo": { + "shoutoutMessage": "Shoutout message", + "enableShoutoutMessage": "Send shoutout message in chat" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/registry/alerts.json b/backend/locales/de/ui/registry/alerts.json new file mode 100644 index 000000000..6c761a6c4 --- /dev/null +++ b/backend/locales/de/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Aktiviert", + "testDlg": { + "alertTester": "Alert Tester", + "command": "Befehl", + "username": "Benutzername", + "recipient": "Empfänger", + "message": "Nachricht", + "tier": "Stufe", + "amountOfViewers": "Anzahl der Zuschauer", + "amountOfBits": "Anzahl der Bits", + "amountOfGifts": "Anzahl der Geschenke", + "amountOfMonths": "Anzahl der Monate", + "amountOfTips": "Trinkgeld", + "event": "Ereignis", + "service": "Service" + }, + "empty": "Die Alert-Registry ist leer, erstelle einen neuen Alert.", + "emptyAfterSearch": "Alert Registry leer! Bei der Suche nach \"$search\" wurden keine Alerts gefunden", + "revertcode": "Auf Standard zurücksetzen", + "name": { + "name": "Name", + "placeholder": "Namen des Alerts festlegen" + }, + "alertDelayInMs": { + "name": "Alert Verzögerung" + }, + "parryEnabled": { + "name": "Alert pariert" + }, + "parryDelay": { + "name": "Verzögerung des Alert Textes" + }, + "profanityFilterType": { + "name": "Schimpfwort-Filter", + "disabled": "Deaktiviert", + "replace-with-asterisk": "Mit Sternchen ersetzen", + "replace-with-happy-words": "Mit freundlichen Wörtern ersetzen", + "hide-messages": "Nachrichten ausblenden", + "disable-alerts": "Alerts deaktivieren" + }, + "loadStandardProfanityList": "Standardliste für Schimpfwörter laden", + "customProfanityList": { + "name": "Benutzerdefinierte Schimpfwort-Liste", + "help": "Wörter müssen durch Kommas getrennt werden." + }, + "event": { + "follow": "Folgen", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Verschenkte Community-Subs", + "tip": "Trinkgeld", + "raid": "Raid", + "custom": "Custom", + "promo": "Promo", + "rewardredeem": "Reward Redeem" + }, + "title": { + "name": "Variantenname", + "placeholder": "Variantenname festlegen" + }, + "variant": { + "name": "Variantenvorkommen" + }, + "filter": { + "name": "Filter", + "operator": "Operator", + "rule": "Regel", + "addRule": "Neue Regel", + "addGroup": "Neue Gruppe", + "comparator": "Vergleicher", + "value": "Wert", + "valueSplitByComma": "Werte durch Komma getrennt (z.B. val1, val2)", + "isEven": "ist gleich", + "isOdd": "ist ungerade", + "lessThan": "kleiner als", + "lessThanOrEqual": "kleiner oder gleich", + "contain": "enthält", + "contains": "contains", + "equal": "ist gleich", + "notEqual": "ungleich", + "present": "ist vorhanden", + "includes": "enthält", + "greaterThan": "größer als", + "greaterThanOrEqual": "größer als oder gleich", + "noFilter": "kein filter" + }, + "speed": { + "name": "Geschwindigkeit" + }, + "maxTimeToDecrypt": { + "name": "Maximale Zeit zum Entschlüsseln" + }, + "characters": { + "name": "Zeichen" + }, + "random": "Zufällig", + "exact-amount": "Genauer Betrag", + "greater-than-or-equal-to-amount": "Größer als oder gleich dem Betrag", + "tier-exact-amount": "Stufe ist", + "tier-greater-than-or-equal-to-amount": "Stufe ist höher oder gleich", + "months-exact-amount": "Anzahl der Monate ist exakt", + "months-greater-than-or-equal-to-amount": "Anzahl der Monate ist höher oder gleich", + "gifts-exact-amount": "Gif Subs betrage ist gleich", + "gifts-greater-than-or-equal-to-amount": "Gif Subs betrage ist höher oder gleich", + "very-rarely": "Sehr selten", + "rarely": "Selten", + "default": "Standard", + "frequently": "Häufig", + "very-frequently": "Sehr häufig", + "exclusive": "Exklusiv", + "messageTemplate": { + "name": "Nachrichtenvorlage", + "placeholder": "Nachrichtenvorlage festlegen", + "help": "Available variables: {name}, {amount} (cheers, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). If | is added (see promo) then it will show those values in sequence." + }, + "ttsTemplate": { + "name": "TTS Vorlage", + "placeholder": "TTS-Vorlage festlegen", + "help": "Verfügbare Variablen: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Animationstext" + }, + "animationType": { + "name": "Typ der Animation" + }, + "animationIn": { + "name": "Eingehende Animation" + }, + "animationOut": { + "name": "Ausgehende Animation" + }, + "alertDurationInMs": { + "name": "Dauer des Alerts" + }, + "alertTextDelayInMs": { + "name": "Verzögerung des Alert Textes" + }, + "layoutPicker": { + "name": "Layout" + }, + "loop": { + "name": "In Schleife abspielen" + }, + "scale": { + "name": "Skalieren" + }, + "translateY": { + "name": "Nach oben / +Runter" + }, + "translateX": { + "name": "Bewege -links / +Rechts" + }, + "image": { + "name": "Bild / Video(.webm)", + "setting": "Bild / Video(.webm) Einstellungen" + }, + "sound": { + "name": "Sound", + "setting": "Sound-Einstellungen" + }, + "soundVolume": { + "name": "Alert Lautstärke" + }, + "enableAdvancedMode": "Erweiterten Modus aktivieren", + "font": { + "setting": "Schrifteinstellungen", + "name": "Schriftartfamilie", + "overrideGlobal": "Globale Schriftarteinstellungen überschreiben", + "align": { + "name": "Ausrichtung", + "left": "Links", + "center": "Mitte", + "right": "Rechts" + }, + "size": { + "name": "Schriftgröße" + }, + "weight": { + "name": "Schriftdicke" + }, + "borderPx": { + "name": "Schriftumrandung" + }, + "borderColor": { + "name": "Farbe Schriftumrandung" + }, + "color": { + "name": "Schriftfarbe" + }, + "highlightcolor": { + "name": "Farbe der hervorgehobenen Schrift" + } + }, + "minAmountToShow": { + "name": "Minimaler Betrag zum Anzeigen" + }, + "minAmountToPlay": { + "name": "Minimaler Betrag zum Abspielen" + }, + "allowEmotes": { + "name": "Emotes zulassen" + }, + "message": { + "setting": "Nachrichteneinstellungen" + }, + "voice": "Stimme", + "keepAlertShown": "Alert bleibt während TTS sichtbar", + "skipUrls": "URLs in TTS überspringen", + "volume": "Lautstärke", + "rate": "Rate", + "pitch": "Tonhöhe", + "test": "Test", + "tts": { + "setting": "TTS-Einstellungen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/registry/goals.json b/backend/locales/de/ui/registry/goals.json new file mode 100644 index 000000000..fa5115fe7 --- /dev/null +++ b/backend/locales/de/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Zielgruppe hinzufügen", + "addGoal": "Ziel hinzufügen", + "newGoal": "Neues Ziel", + "newGoalGroup": "Neue Zielgruppe", + "goals": "Ziele", + "general": "Allgemein", + "display": "Anzeige", + "fontSettings": "Schrifteinstellungen", + "barSettings": "Balkeneinstellungen", + "selectGoalOnLeftSide": "Ziel auf der linken Seite auswählen oder hinzufügen", + "input": { + "description": { + "title": "Beschreibung" + }, + "goalAmount": { + "title": "Zielmenge" + }, + "countBitsAsTips": { + "title": "Bits als Spenden zählen" + }, + "currentAmount": { + "title": "Aktueller Betrag" + }, + "endAfter": { + "title": "Beenden nach" + }, + "endAfterIgnore": { + "title": "Ziel läuft nicht ab" + }, + "borderPx": { + "title": "Rahmen", + "help": "Rahmengröße ist in Pixeln" + }, + "barHeight": { + "title": "Balkenhöhe", + "help": "Balkenhöhe in Pixeln" + }, + "color": { + "title": "Farbe" + }, + "borderColor": { + "title": "Rahmenfarbe" + }, + "backgroundColor": { + "title": "Hintergrundfarbe" + }, + "type": { + "title": "Typ" + }, + "nameGroup": { + "title": "Name dieser Zielgruppe" + }, + "name": { + "title": "Name des Ziels" + }, + "displayAs": { + "title": "Anzeigen als", + "help": "Legt fest, wie Zielgruppe angezeigt wird" + }, + "durationMs": { + "title": "Dauer", + "help": "Dieser Wert ist in Millisekunden", + "placeholder": "Wie lange das Ziel angezeigt werden soll" + }, + "animationInMs": { + "title": "Dauer der eingehenden Animation", + "help": "Dieser Wert ist in Millisekunden", + "placeholder": "Eingehende Animationsdauer festlegen" + }, + "animationOutMs": { + "title": "Dauer der ausgehenden Animation", + "help": "Dieser Wert ist in Millisekunden", + "placeholder": "Dauer der ausgehenden Animation festlegen" + }, + "interval": { + "title": "Welches Zeitintervall zu zählen" + }, + "spaceBetweenGoalsInPx": { + "title": "Abstand zwischen Zielen", + "help": "Dieser Wert ist in Pixeln", + "placeholder": "Legen Sie Ihren Raum zwischen den Balken fest" + } + }, + "groupSettings": "Gruppeneinstellungen" +} \ No newline at end of file diff --git a/backend/locales/de/ui/registry/overlays.json b/backend/locales/de/ui/registry/overlays.json new file mode 100644 index 000000000..376cc9d41 --- /dev/null +++ b/backend/locales/de/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Neue Overlay-Link-Mapping erstellen", + "emptyMapping": "Es wurde noch kein Overlay-Link-Mapping erstellt.", + "allowedIPs": { + "name": "Zulässige IPs", + "help": "Erlaube Zugriff von festgelegten IPs getrennt durch neue Zeile" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/registry/plugins.json b/backend/locales/de/ui/registry/plugins.json new file mode 100644 index 000000000..62d1ec6b0 --- /dev/null +++ b/backend/locales/de/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "This node needs to be linked with listeners with sender attributes" + }, + "filter": { + "permission": { + "name": "Permission filter" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Event listener", + "type": { + "twitchChatMessage": "Twitch Chat-Nachricht", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat cleared", + "twitchCommand": "Twitch Befehl", + "twitchFollow": "Neuer Twitch Follower", + "twitchSubscription": "New Twitch subscription", + "twitchSubgift": "New Twitch subscription gift", + "twitchSubcommunitygift": "New Twitch subscription community gift", + "twitchResub": "New Twitch recurring subscription", + "twitchGameChanged": "Twitch category changed", + "twitchStreamStarted": "Twitch-Stream gestartet", + "twitchStreamStopped": "Twitch-Stream gestoppt", + "twitchRewardRedeem": "Twitch reward redeemed", + "twitchRaid": "Twitch raid incoming", + "tip": "Trinkgeld vom Nutzer", + "botStarted": "Bot gestartet" + }, + "command": { + "add-parameter": "Add parameter", + "parameters": "Parameters", + "order-is-important": "order is important" + } + }, + "others": { + "idle": { + "name": "Inaktiv" + } + }, + "output": { + "log": { + "name": "Log message" + }, + "timeout-user": { + "name": "Timeout user" + }, + "ban-user": { + "name": "Benutzer sperren" + }, + "send-twitch-message": { + "name": "Twitch Nachricht senden" + } + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/registry/randomizer.json b/backend/locales/de/ui/registry/randomizer.json new file mode 100644 index 000000000..79e45d975 --- /dev/null +++ b/backend/locales/de/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Randomizer hinzufügen", + "form": { + "name": "Name", + "command": "Befehl", + "permission": "Befehlsberechtigung", + "simple": "Einfach", + "tape": "Band", + "wheelOfFortune": "Glücksrad", + "type": "Typ", + "options": "Optionen", + "optionsAreEmpty": "Optionen sind leer.", + "color": "Farbe", + "numOfDuplicates": "Anzahl der Duplikate", + "minimalSpacing": "Minimaler Abstand", + "groupUp": "Gruppieren", + "ungroup": "Gruppierung aufheben", + "groupedWithOptionAbove": "Gruppieren mit vorherigem Element", + "generatedOptionsPreview": "Vorschau der generierten Optionen", + "probability": "Wahrscheinlichkeit", + "tick": "Klickender Ton während der Drehung" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/registry/textoverlay.json b/backend/locales/de/ui/registry/textoverlay.json new file mode 100644 index 000000000..7b53410f4 --- /dev/null +++ b/backend/locales/de/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Neues Text-Overlay erstellen", + "title": "Text-Overlay", + "name": { + "placeholder": "Name des Text-Overlays festlegen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/stats/commandcount.json b/backend/locales/de/ui/stats/commandcount.json new file mode 100644 index 000000000..d2702d5f5 --- /dev/null +++ b/backend/locales/de/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Befehl", + "hour": "Stunde", + "day": "Tag", + "week": "Woche", + "month": "Monat", + "year": "Jahr", + "total": "Gesamt" +} \ No newline at end of file diff --git a/backend/locales/de/ui/systems/checklist.json b/backend/locales/de/ui/systems/checklist.json new file mode 100644 index 000000000..148ce5ec4 --- /dev/null +++ b/backend/locales/de/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "itemsArray": "Liste" + }, + "check": "Checkliste" +} \ No newline at end of file diff --git a/backend/locales/de/ui/systems/howlongtobeat.json b/backend/locales/de/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..1815bc896 --- /dev/null +++ b/backend/locales/de/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Status" + }, + "empty": "Es wurden noch keine Spiele verfolgt.", + "emptyAfterSearch": "Ihre Suche nach \"$search\" hat keine getrackten Spiele gefunden.", + "when": "Beim Streamen", + "time": "Getrackte Zeit", + "overallTime": "Overall time", + "offset": "Versatz der getrackten Zeit", + "main": "Main", + "extra": "Main+Extra", + "completionist": "Vervollständiger", + "game": "Getrackten Spiel", + "startedAt": "Tracking gestartet um", + "updatedAt": "Letzte Aktualisierung", + "showHistory": "Verlauf anzeigen ($count)", + "hideHistory": "Verlauf ausblenden ($count)", + "searchToAddNewGame": "Suche um ein neues Spiel zum Tracking hinzuzufügen" +} \ No newline at end of file diff --git a/backend/locales/de/ui/systems/keywords.json b/backend/locales/de/ui/systems/keywords.json new file mode 100644 index 000000000..511f6b2fe --- /dev/null +++ b/backend/locales/de/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "Neues Schlüsselwort", + "empty": "Bisher wurden noch keine Schlüsselwörter erstellt.", + "emptyAfterSearch": "Bei der Suche nach \"$search\" wurden keine Schlüsselwörter gefunden.", + "keyword": { + "name": "Schlüsselwort / Regulärer Ausdruck", + "placeholder": "Setzen Sie Ihr Schlüsselwort oder Ihren regulären Ausdruck als Schlüsselwort ein.", + "help": "Sie können regexp (Groß- und Kleinschreibung) verwenden, um Schlüsselwörter zu verwenden, z.B. Hallo.*|hi" + }, + "response": { + "name": "Reaktion", + "placeholder": "Legen Sie hier die Reaktion fest." + }, + "error": { + "isEmpty": "Dieser Wert darf nicht leer sein" + }, + "no-responses-set": "Keine Antworten", + "addResponse": "Antwort hinzufügen", + "filter": { + "name": "Filter", + "placeholder": "Filter für diese Antwort hinzufügen" + }, + "warning": "Diese Aktion kann nicht rückgängig gemacht werden!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/systems/levels.json b/backend/locales/de/ui/systems/levels.json new file mode 100644 index 000000000..5cfeca7e2 --- /dev/null +++ b/backend/locales/de/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Status", + "conversionRate": "Umrechnungsrate 1 XP für x Punkte", + "firstLevelStartsAt": "Erste Stufe beginnt bei XP", + "nextLevelFormula": { + "title": "Berechnungsformel für nächste Ebene", + "help": "Verfügbare Variablen: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "Beispiel für Levels wird beim Speichern aktualisiert", + "xpName": "Name", + "interval": "Intervall in Minuten, in denen Zuschauer Punkte erhalten, während der Stream läuft", + "offlineInterval": "Intervall in Minuten, in denen Zuschauer Punkte erhalten, während der Stream nicht läuft", + "messageInterval": "Anzahl der Nachrichten die xp hinzufügen", + "messageOfflineInterval": "Anzahl der Nachrichten die xp hinzufügen wenn der Stream offline ist", + "perInterval": "Punkte pro Intervall wenn der Stream Online ist", + "perOfflineInterval": "Punkte pro Intervall wenn der Stream Offline ist", + "perMessageInterval": "Wie viele XP pro Nachrichtenintervall hinzugefügt werden sollen", + "perMessageOfflineInterval": "Wie viele XP pro Nachrichten Offline Intervall hinzugefügt werden sollen" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/systems/polls.json b/backend/locales/de/ui/systems/polls.json new file mode 100644 index 000000000..9ea0b74de --- /dev/null +++ b/backend/locales/de/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Stimmen insgesamt", + "totalPoints": "Punkte gesamt", + "closedAt": "Geschlossen am", + "activeFor": "Aktiv für" +} \ No newline at end of file diff --git a/backend/locales/de/ui/systems/scrim.json b/backend/locales/de/ui/systems/scrim.json new file mode 100644 index 000000000..aa3be454f --- /dev/null +++ b/backend/locales/de/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "waitForMatchIdsInSeconds": { + "title": "Intervall zum Eingeben der Spiel-ID in den Chat", + "help": "In Sekunden festlegen" + } + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/systems/top.json b/backend/locales/de/ui/systems/top.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/de/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/de/ui/systems/userinfo.json b/backend/locales/de/ui/systems/userinfo.json new file mode 100644 index 000000000..9e68d18ff --- /dev/null +++ b/backend/locales/de/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "formatSeparator": "Format-Trennzeichen", + "order": "Format", + "lastSeenFormat": { + "title": "Zeitformat", + "help": "Mögliche Formate unter https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/en.json b/backend/locales/en.json new file mode 100644 index 000000000..e3c4b832b --- /dev/null +++ b/backend/locales/en.json @@ -0,0 +1,1211 @@ +{ + "core": { + "loaded": "is loaded and", + "enabled": "enabled", + "disabled": "disabled", + "usage": "Usage", + "lang-selected": "Bot language is currently set to english", + "refresh-panel": "You will need to refresh UI to see changes.", + "command-parse": "Sorry, $sender, but this command is not correct, use", + "error": "Sorry, $sender, but something went wrong!", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, API is not responding correctly!", + "not-available": "not available" + }, + "percentage": { + "true": "", "false": "" + }, + "years": "year|years", + "months": "month|months", + "days": "day|days", + "hours": "hour|hours", + "minutes": "minute|minutes", + "seconds": "second|seconds", + "messages": "message|messages", + "bits": "bit|bits", + "links": "link|links", + "entries": "entry|entries", + "empty": "empty", + "isRegistered": "$sender, you cannot use !$keyword, because is already in use for another action!" + }, + "clip": { + "notCreated": "Something went wrong and clip was not created.", + "offline": "Stream is currently offline and clip cannot be created." + }, + "uptime": { + "online": "Stream is online for (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)", + "offline": "Stream is currently offline for (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)" + }, + "webpanel": { + "this-system-is-disabled": "This system is disabled", + "or": "or", + "loading": "Loading", + "this-may-take-a-while": "This may take a while", + "display-as": "Display as", + "go-to-admin": "Go to Admin", + "go-to-public": "Go to Public", + "logout": "Logout", + "popout": "Popout", + "not-logged-in": "Not logged in", + "remove-widget": "Remove $name widget", + "join-channel": "Join bot to channel", + "leave-channel": "Leave bot from channel", + "set-default": "Set default", + "add": "Add", + "placeholders": { + "text-url-generator": "Paste your text or html to generate base64 below and URL above", + "text-decode-base64": "Paste your base64 to generate URL and text above", + "creditsSpeed": "Set speed of credits rolling, lower = faster" + }, + "timers": { + "title": "Timers", + "timer": "Timer", + "messages": "messages", + "seconds": "seconds", + "badges": { + "enabled": "Enabled", + "disabled": "Disabled" + }, + "errors": { + "timer_name_must_be_compliant": "This value can contain only a-zA-Z09_", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty" + }, + "dialog": { + "timer": "Timer", + "name": "Name", + "tickOffline": "Should tick if stream offline", + "interval": "Interval", + "responses": "Responses", + "messages": "Trigger every X Messages", + "seconds": "Trigger every X Seconds", + "title": { + "new": "New timer", + "edit": "Edit timer" + }, + "placeholders": { + "name": "Set name of your timer, can contain only these characters a-zA-Z0-9_", + "messages": "Trigger timer each X messages", + "seconds": "Trigger timer each X seconds" + }, + "alerts": { + "success": "Timer was successfully saved.", + "fail": "Something went wrong." + } + }, + "buttons": { + "close": "Close", + "save-changes": "Save changes", + "disable": "Disable", + "enable": "Enable", + "edit": "Edit", + "delete": "Delete", + "yes": "Yes", + "no": "No" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Are you sure you want to delete timer" + } + }, + "events": { + "event": "Event", + "noEvents": "No events found in database.", + "whatsthis": "what's this?", + "myRewardIsNotListed": "My reward is not listed!", + "redeemAndClickRefreshToSeeReward": "If you are missing your created reward in a list, refresh by clicking on refresh icon.", + "badges": { + "enabled": "Enabled", + "disabled": "Disabled" + }, + "buttons": { + "test": "Test", + "enable": "Enable", + "disable": "Disable", + "edit": "Edit", + "delete": "Delete", + "yes": "Yes", + "no": "No" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Are you sure you want to delete event", + "example_of_user_object_data": "Example of user object data" + }, + "errors": { + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty" + }, + "dialog": { + "title": { + "new": "New event listener", + "edit": "Edit event listener" + }, + "placeholders": { + "name": "Set name of your event listener (if empty, name will be generated)" + }, + "alerts": { + "success": "Event was successfully saved.", + "fail": "Something went wrong." + }, + "close": "Close", + "save-changes": "Save changes", + "event": "Event", + "name": "Name", + "usable-events-variables": "Usable events variables", + "settings": "Settings", + "filters": "Filters", + "operations": "Operations" + }, + "definitions": { + "taskId": { + "label": "Task ID" + }, + "filter": { + "label": "Filter" + }, + "linkFilter": { + "label": "Link Overlay Filter", + "placeholder": "If using overlay, add link or id of your overlay" + }, + "hashtag": { + "label": "Hashtag or Keyword", + "placeholder": "#yourHashtagHere or Keyword" + }, + "fadeOutXCommands": { + "label": "Fade Out X Commands", + "placeholder": "Number of commands subtracted every fade out interval" + }, + "fadeOutXKeywords": { + "label": "Fade Out X Keywords", + "placeholder": "Number of keywords subtracted every fade out interval" + }, + "fadeOutInterval": { + "label": "Fade Out Interval (seconds)", + "placeholder": "Fade out interval subtracting" + }, + "runEveryXCommands": { + "label": "Run Every X Commands", + "placeholder": "Number of commands before event is triggered" + }, + "runEveryXKeywords": { + "label": "Run Every X Keywords", + "placeholder": "Number of keywords before event is triggered" + }, + "commandToWatch": { + "label": "Command To Watch", + "placeholder": "Set your !commandToWatch" + }, + "keywordToWatch": { + "label": "Keyword To Watch", + "placeholder": "Set your keywordToWatch" + }, + "resetCountEachMessage": { + "label": "Reset count each message", + "true": "Reset count", + "false": "Keep count" + }, + "viewersAtLeast": { + "label": "Viewers At Least", + "placeholder": "How many viewers at least to trigger event" + }, + "runInterval": { + "label": "Run Interval (0 = run once per stream)", + "placeholder": "Trigger event every x seconds" + }, + "runAfterXMinutes": { + "label": "Run After X Minutes", + "placeholder": "Trigger event after x minutes" + }, + "runEveryXMinutes": { + "label": "Run Every X Minutes", + "placeholder": "Trigger event every x minutes" + }, + "messageToSend": { + "label": "Message To Send", + "placeholder": "Set your message" + }, + "channel": { + "label": "Channel", + "placeholder": "Channelname or ID" + }, + "timeout": { + "label": "Timeout", + "placeholder": "Set timeout in milliseconds" + }, + "timeoutType": { + "label": "Type of timeout", + "placeholder": "Set type of timeout" + }, + "command": { + "label": "Command", + "placeholder": "Set your !command" + }, + "commandToRun": { + "label": "Command To Run", + "placeholder": "Set your !commandToRun" + }, + "isCommandQuiet": { + "label": "Mute command output" + }, + "urlOfSoundFile": { + "label": "Url Of Your Sound File", + "placeholder": "http://www.pathToYour.url/where/is/file.mp3" + }, + "emotesToExplode": { + "label": "Emotes To Explode", + "placeholder": "List of emotes to explode, e.g. Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Emotes To Firework", + "placeholder": "List of emotes to firework, e.g. Kappa PurpleHeart" + }, + "replay": { + "label": "Replay clip in overlay", + "true": "Will play in as replay in overlay/alerts", + "false": "Replay won't be played" + }, + "announce": { + "label": "Announce in chat", + "true": "Will be announced", + "false": "Will not be announced" + }, + "hasDelay": { + "label": "Clip should have slight delay (to be closer what viewer see)", + "true": "Will have delay", + "false": "Will not have delay" + }, + "durationOfCommercial": { + "label": "Duration Of Commercial", + "placeholder": "Available durations - 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Custom variable to update" + }, + "numberToIncrement": { + "label": "Number to increment", + "placeholder": "" + }, + "value": { + "label": "Value", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Number to decrement", + "placeholder": "" + }, + "":"", + "reward": { + "label": "Reward", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Followed you", + "raid": "Raided you with $viewers raiders.", + "sub": "Subscribed to you with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "has been gifted subscription from $username", + "subcommunitygift": "Gifted subscriptions for community", + "resub": "Resubscribed with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Cheered you", + "tip": "Tipped you", + "tipToCharity": "donated to $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Twitch Prediction - Title", + "outcomes": "Twitch Prediction - Outcomes", + "locksAt": "Twitch Prediction - Locks At Date", + "winningOutcomeTitle": "Twitch Prediction - Winning outcome title", + "winningOutcomeTotalPoints": "Twitch Prediction - Winning outcome total points", + "winningOutcomePercentage": "Twitch Prediction - Winning outcome percentage", + "titleOfPoll": "Twitch Poll - Title", + "bitAmountPerVote": "Twitch Poll - Amount of bits to count as 1 vote", + "bitVotingEnabled": "Twitch Poll - Is bit voting enabled (boolean)", + "channelPointsAmountPerVote": "Twitch Poll - Amount of channel points to count as 1 vote", + "channelPointsVotingEnabled": "Twitch Poll - Is channel points voting enabled (boolean)", + "votes": "Twitch Poll - votes count", + "winnerChoice": "Twitch Poll - Winner choice", + "winnerPercentage": "Twitch Poll - Winner choice percentage", + "winnerVotes": "Twitch Poll - Winner choice votes", + "goal": "Goal", + "total": "Total", + "lastContributionTotal": "Last Contribution - Total", + "lastContributionType": "Last Contribution - Type", + "lastContributionUserId": "Last Contribution - User ID", + "lastContributionUsername": "Last Contribution - Username", + "level": "Level", + "topContributionsBitsTotal": "Top Bits Contribution - Total", + "topContributionsBitsUserId": "Top Bits Contribution - User ID", + "topContributionsBitsUsername": "Top Bits Contribution - Username", + "topContributionsSubsTotal": "Top Subs Contribution - Total", + "topContributionsSubsUserId": "Top Subs Contribution - User ID", + "topContributionsSubsUsername": "Top Subs Contribution - Username", + "sender": "User who initiated", + "title": "Current title", + "game": "Current category", + "language": "Current stream language", + "viewers": "Current viewers count", + "hostViewers": "Raid viewers count", + "followers": "Current followers count", + "subscribers": "Current subscribers count", + "arg": "Argument", + "param": "Parameter (required)", + "touser": "Username parameter", + "!param": "Parameter (not required)", + "alias": "Alias", + "command": "Command", + "keyword": "Keyword", + "response": "Response", + "list": "Populated list", + "type": "Type", + "days": "Days", + "hours": "Hours", + "minutes": "Minutes", + "seconds": "Seconds", + "description": "Description", + "quiet": "Quiet (bool)", + "id": "ID", + "name": "Name", + "messages": "Messages", + "amount": "Amount", + "amountInBotCurrency": "Amount in bot currency", + "currency": "Currency", + "currencyInBot": "Currency in bot", + "pointsName": "Points name", + "points": "Points", + "rank": "Rank", + "nextrank": "Next rank", + "username": "Username", + "value": "Value", + "variable": "Variable", + "count": "Count", + "link": "Link (translated)", + "winner": "Winner", + "loser": "Loser", + "challenger": "Challenger", + "min": "Minimum", + "max": "Maximum", + "eligibility": "Eligibility", + "probability": "Probability", + "time": "Time", + "options": "Options", + "option": "Option", + "when": "When", + "diff": "Difference", + "users": "Users", + "user": "User", + "bank": "Bank", + "nextBank": "Next bank", + "cooldown": "Cooldown", + "tickets": "Tickets", + "ticketsName": "Tickets name", + "fromUsername": "From username", + "toUsername": "To username", + "items": "Items", + "bits": "Bits", + "subgifts": "Subgifts", + "subStreakShareEnabled" : "Is substreak share enabled (true/false)", + "subStreak": "Current sub streak", + "subStreakName": "localized name of month (1 month, 2 months) for current sub strek", + "subCumulativeMonths": "Cumulative subscribe months", + "subCumulativeMonthsName": "localized name of month (1 month, 2 months) for cumulative subscribe months", + "message": "Message", + "reason": "Reason", + "target": "Target", + "duration": "Duration", + "method": "Method", + "tier": "Tier", + "months": "Months", + "monthsName": "localized name of month (1 month, 2 months)", + "oldGame": "Category before change", + "recipientObject": "Full recipient object", + "recipient": "Recipient", + "ytSong": "Current song on YouTube", + "spotifySong": "Current song on Spotify", + "latestFollower": "Latest Follower", + "latestSubscriber": "Latest Subscriber", + "latestSubscriberMonths": "Latest Subscriber cumulative months", + "latestSubscriberStreak": "Latest Subscriber months streak", + "latestTipAmount": "Latest Tip (amount)", + "latestTipCurrency": "Latest Tip (currency)", + "latestTipMessage": "Latest Tip (message)", + "latestTip": "Latest Tip (username)", + "toptip": { + "overall": { + "username": "Top Tip - overall (username)", + "amount": "Top Tip - overall (amount)", + "currency": "Top Tip - overall (currency)", + "message": "Top Tip - overall (message)" + }, + "stream": { + "username": "Top Tip - during stream (username)", + "amount": "Top Tip - during stream (amount)", + "currency": "Top Tip - during stream (currency)", + "message": "Top Tip - during stream (message)" + } + }, + "latestCheerAmount": "Latest Bits (amount)", + "latestCheerMessage": "Latest Bits (message)", + "latestCheer": "Latest Bits (username)", + "version": "Bot version", + "haveParam": "Have command parameter? (bool)", + "source": "Current source (twitch or discord)", + "userInput": "User input during reward redeem", + "isBotSubscriber": "Is bot subscriber (bool)", + "isStreamOnline": "Is stream online (bool)", + "uptime": "Uptime of stream", + "is": { + "moderator": "Is user mod? (bool)", + "subscriber": "Is user sub? (bool)", + "vip": "Is user vip? (bool)", + "newchatter": "Is user's first message? (bool)", + "follower": "Is user follower? (bool)", + "broadcaster": "Is user broadcaster? (bool)", + "bot": "Is user bot? (bool)", + "owner": "Is user bot owner? (bool)" + }, + "recipientis": { + "moderator": "Is recipient mod? (bool)", + "subscriber": "Is recipient sub? (bool)", + "vip": "Is recipient vip? (bool)", + "follower": "Is recipient follower? (bool)", + "broadcaster": "Is recipient broadcaster? (bool)", + "bot": "Is recipient bot? (bool)", + "owner": "Is recipient bot owner? (bool)" + }, + "sceneName": "Name of scene", + "inputName": "Name of input", + "inputMuted": "Mute state (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Others", + "currency": "Currency" + }, + "whispers": { + "title": "Whispers", + "toggle": { + "listener": "Listen commands on whisper", + "settings": "Whispers on settings change", + "raffle": "Whispers on raffle join", + "permissions": "Whispers on insufficient permissions", + "cooldowns": "Whispers on cooldown (if set as notify)" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Messages", + "follows": "Follows", + "subs": "Subs & Resubs", + "cheers": "Bits", + "responses": "Bot responses", + "whispers": "Whispers", + "bans": "Bans", + "timeouts": "Timeouts" + }, + "range": { + "day": "a day", + "week": "a week", + "month": "a month", + "year": "an year", + "all": "All time" + }, + "order": { + "asc": "Ascending", + "desc": "Descending" + }, + "labels": { + "order": "ORDER", + "range": "RANGE", + "filters": "FILTERS" + } + }, + "stats-panel": { + "show": "Show stats", + "hide": "Hide stats" + }, + "translations": "Custom translations", + "bot-responses": "Bot responses", + "duration": "Duration", + "viewers-reset-attributes": "Reset attributes", + "viewers-points-of-all-users": "Points of all users", + "viewers-watchtime-of-all-users": "Watch time of all users", + "viewers-messages-of-all-users": "Messages of all users", + "events-game-after-change": "category after change", + "events-game-before-change": "category before change", + "events-user-triggered-event": "user triggered event", + "events-method-used-to-subscribe": "method used to subscribe", + "events-months-of-subscription": "months of subscription", + "events-monthsName-of-subscription": "word 'month' by number (1 month, 2 months)", + "events-user-message": "user message", + "events-bits-user-sent": "bits user sent", + "events-reason-for-ban-timeout": "reason for ban/timeout", + "events-duration-of-timeout": "duration of timeout", + "events-duration-of-commercial": "duration of commercial", + "overlays-eventlist-resub": "resub", + "overlays-eventlist-subgift": "subgift", + "overlays-eventlist-subcommunitygift": "subcommunitygift", + "overlays-eventlist-sub": "sub", + "overlays-eventlist-follow": "follow", + "overlays-eventlist-cheer": "bits", + "overlays-eventlist-tip": "tip", + "overlays-eventlist-raid": "raid", + "requested-by": "Requested by", + "description": "Description", + "raffle-type": "Raffle type", + "raffle-type-keywords": "Only keyword", + "raffle-type-tickets": "With tickets", + "raffle-tickets-range": "Tickets range", + "video_id": "Video ID", + "highlights": "Highlights", + "cooldown-quiet-header": "Show cooldown message", + "cooldown-quiet-toggle-no": "Notify", + "cooldown-quiet-toggle-yes": "Won't notify", + "cooldown-moderators": "Moderators", + "cooldown-owners": "Owners", + "cooldown-subscribers": "Subscribers", + "cooldown-followers": "Followers", + "in-seconds": "in seconds", + "songs": "Songs", + "show-usernames-with-at": "Show usernames with @", + "send-message-as-a-bot": "Send message as a bot", + "chat-as-bot": "Chat (as bot)", + "product": "Product", + "optional": "optional", + "placeholder-search": "Search", + "placeholder-enter-product": "Enter product", + "placeholder-enter-keyword": "Enter keyword", + "credits": "Credits", + "fade-out-top": "fade up", + "fade-out-zoom": "fade zoom", + "global": "Global", + "user": "User", + "alerts": "Alerts", + "eventlist": "EventList", + "dashboard": "Dashboard", + "carousel": "Image Carousel", + "text": "Text", + "filter": "Filter", + "filters": "Filters", + "isUsed": "Is used", + "permissions": "Permissions", + "permission": "Permission", + "viewers": "Viewers", + "systems": "Systems", + "overlays": "Overlays", + "gallery": "Media gallery", + "aliases": "Aliases", + "alias": "Alias", + "command": "Command", + "cooldowns": "Cooldowns", + "title-template": "Title template", + "keyword": "Keyword", + "moderation": "Moderation", + "timer": "Timer", + "price": "Price", + "rank": "Rank", + "previous": "Previous", + "next": "Next", + "close": "Close", + "save-changes": "Save changes", + "saving": "Saving...", + "deleting": "Deleting...", + "done": "Done", + "error": "Error", + "title": "Title", + "change-title": "Change title", + "game": "category", + "tags": "Tags", + "change-game": "Change category", + "click-to-change": "click to change", + "uptime": "uptime", + "not-affiliate-or-partner": "Not affiliate/partner", + "not-available": "Not Available", + "max-viewers": "Max viewers", + "new-chatters": "New Chatters", + "chat-messages": "Chat messages", + "followers": "Followers", + "subscribers": "Subscribers", + "bits": "Bits", + "subgifts": "Subgifts", + "subStreak": "Current sub streak", + "subCumulativeMonths": "Cumulative subscribe months", + "tips": "Tips", + "tier": "Tier", + "status": "Status", + "add-widget": "Add widget", + "remove-dashboard": "Remove dashboard", + "close-bet-after": "Close bet after", + "refund": "refund", + "roll-again": "Roll again", + "no-eligible-participants": "No eligible participants", + "follower": "Follower", + "subscriber": "Subscriber", + "minutes": "minutes", + "seconds": "seconds", + "hours": "hours", + "months": "months", + "eligible-to-enter": "Eligible to enter", + "everyone": "Everyone", + "roll-a-winner": "Roll a winner", + "send-message": "Send Message", + "messages": "Messages", + "level": "Level", + "create": "Create", + "cooldown": "Cooldown", + "confirm": "Confirm", + "delete": "Delete", + "enabled": "Enabled", + "disabled": "Disabled", + "enable": "Enable", + "disable": "Disable", + "slug": "Slug", + "posted-by": "Posted by", + "time": "Time", + "type": "Type", + "response": "Response", + "cost": "Cost", + "name": "Name", + "playlist": "Playlist", + "length": "Length", + "volume": "Volume", + "start-time": "Start Time", + "end-time": "End Time", + "watched-time": "Watched time", + "currentsong": "Current song", + "group": "Group", + "followed-since": "Followed since", + "subscribed-since": "Subscribed since", + "username": "Username", + "hashtag": "Hashtag", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "Scopes", + "last-seen": "Last Seen", + "date": "Date", + "points": "Points", + "calendar": "Calendar", + "string": "string", + "interval": "Interval", + "number": "number", + "minimal-messages-required": "Minimal Messages Required", + "max-duration": "Max duration", + "shuffle": "Shuffle", + "song-request": "Song Request", + "format": "Format", + "available": "Available", + "one-record-per-line": "one record per line", + "on": "on", + "off": "off", + "search-by-username": "Search by username", + "widget-title-custom": "CUSTOM", + "widget-title-eventlist": "EVENTLIST", + "widget-title-chat": "CHAT", + "widget-title-queue": "QUEUE", + "widget-title-raffles": "RAFFLES", + "widget-title-social": "SOCIAL", + "widget-title-ytplayer": "MUSIC PLAYER", + "widget-title-monitor": "MONITOR", + "event": "event", + "operation": "operation", + "tweet-post-with-hashtag": "Tweet posted with hashtag", + "user-joined-channel": "user joined a channel", + "user-parted-channel": "user parted a channel", + "follow": "new follow", + "tip": "new tip", + "obs-scene-changed": "OBS scene changed", + "obs-input-mute-state-changed": "OBS input source mute state changed", + "unfollow": "unfollow", + "hypetrain-started": "Hype Train started", + "hypetrain-ended": "Hype Train ended", + "prediction-started": "Twitch Prediction started", + "prediction-locked": "Twitch Prediction locked", + "prediction-ended": "Twitch Prediction ended", + "poll-started": "Twitch Poll started", + "poll-ended": "Twitch Poll ended", + "hypetrain-level-reached": "Hype Train new level reached", + "subscription": "new subscription", + "subgift": "new subgift", + "subcommunitygift": "new sub given to community", + "resub": "user resubscribed", + "command-send-x-times": "command was send x times", + "keyword-send-x-times": "keyword was send x times", + "number-of-viewers-is-at-least-x": "number of viewers is at least x", + "stream-started": "stream started", + "reward-redeemed": "reward redeemed", + "stream-stopped": "stream stopped", + "stream-is-running-x-minutes": "stream is running x minutes", + "chatter-first-message": "first message of chatter", + "every-x-minutes-of-stream": "every x minutes of stream", + "game-changed": "category changed", + "cheer": "received bits", + "clearchat": "chat was cleared", + "action": "user sent /me", + "ban": "user was banned", + "raid": "your channel is raided", + "mod": "user is a new mod", + "timeout": "user was timeouted", + "create-a-new-event-listener": "Create a new event listener", + "send-discord-message": "send a discord message", + "send-chat-message": "send a twitch chat message", + "send-whisper": "send a whisper", + "run-command": "run a command", + "run-obswebsocket-command": "run an OBS Websocket command", + "do-nothing": "--- do nothing ---", + "count": "count", + "timestamp": "timestamp", + "message": "message", + "sound": "sound", + "emote-explosion": "emote explosion", + "emote-firework": "emote firework", + "quiet": "quiet", + "noisy": "noisy", + "true": "true", + "false": "false", + "light": "light theme", + "dark": "dark theme", + "gambling": "Gambling", + "seppukuTimeout": "Timeout for !seppuku", + "rouletteTimeout": "Timeout for !roulette", + "fightmeTimeout": "Timeout for !fightme", + "duelCooldown": "Cooldown for !duel", + "fightmeCooldown": "Cooldown for !fightme", + "gamblingCooldownBypass": "Bypass gambling cooldowns for mods/caster", + "click-to-highlight": "highlight", + "click-to-toggle-display": "toggle display", + "commercial": "commercial started", + "start-commercial": "run a commercial", + "bot-will-join-channel": "bot will join channel", + "bot-will-leave-channel": "bot will leave channel", + "create-a-clip": "create a clip", + "increment-custom-variable": "increment a custom variable", + "set-custom-variable": "set a custom variable", + "decrement-custom-variable": "decrement a custom variable", + "omit": "omit", + "comply": "comply", + "visible": "visible", + "hidden": "hidden", + "gamblingChanceToWin": "Chance to win !gamble", + "gamblingMinimalBet": "Minimal bet for !gamble", + "duelDuration": "Duration of !duel", + "duelMinimalBet": "Minimal bet for !duel" + }, + "raffles": { + "announceInterval": "Opened raffles will be announced every $value minute", + + "eligibility-followers-item": "followers", + "eligibility-subscribers-item": "subscribers", + "eligibility-everyone-item": "everyone", + + "raffle-is-running": "Raffle is running ($count $l10n_entries).", + "to-enter-raffle": "To enter type \"$keyword\". Raffle is opened for $eligibility.", + "to-enter-ticket-raffle": "To enter type \"$keyword <$min-$max>\". Raffle is opened for $eligibility.", + "added-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-raffle}", + "added-ticket-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "Your raffle messages will be deleted on join.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + + "cannot-create-raffle-without-keyword": "Sorry, $sender, but you cannot create raffle without keyword", + "raffle-is-already-running": "Sorry, $sender, raffle is already running with keyword $keyword", + "no-raffle-is-currently-running": "$sender, no raffles without winners are currently running", + + "no-participants-to-pick-winner": "$sender, nobody joined a raffle", + "raffle-winner-is": "Winner of raffle $keyword is $username! Win probability was $probability%!" + }, + "bets": { + "running": "$sender, bet is already opened! Bet options: $options. Use $command close 1-$maxIndex", + "notRunning": "No bet is currently opened, ask mods to open it!", + "opened": "New bet '$title' is opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "closeNotEnoughOptions": "$sender, you need to select winning option for bet close.", + "notEnoughOptions": "$sender, new bets needs at least 2 options!", + "info": "Bet '$title' is still opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "diffBet": "$sender, you already made a bet on $option and you cannot bet to different option!", + "undefinedBet": "Sorry, $sender, but this bet option doesn't exist, use $command to check usage", + "betPercentGain": "Bet percent gain per option was set to $value%", + "betCloseTimer": "Bets will be automatically closed after $valuemin", + "refund": "Bets were closed without a winning. All users are refunded!", + "notOption": "$sender, this option doesn't exist! Bet is not closed, check $command", + "closed": "Bets was closed and winning option was $option! $amount users won in total $points $pointsName!", + "timeUpBet": "I guess you are too late, $sender, your time for betting is up!", + "locked": "Betting time is up! No more bets.", + "zeroBet": "Oh boy, $sender, you cannot bet 0 $pointsName", + "lockedInfo": "Bet '$title' is still opened, but time for betting is up!", + "removed": "Betting time is up! No bets were sent -> automatically closing", + "error": "Sorry, $sender, this command is not correct! Use $command 1-$maxIndex . E.g. $command 0 100 will bet 100 points to item 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, alias $alias was not found in database", + "alias-was-edited": "$sender, alias $alias is changed to $command", + "alias-was-added": "$sender, alias $alias for $command was added", + "list-is-not-empty": "$sender, list of aliases: $list", + "list-is-empty": "$sender, list of aliases is empty", + "alias-was-enabled": "$sender, alias $alias was enabled", + "alias-was-disabled": "$sender, alias $alias was disabled", + "alias-was-concealed": "$sender, alias $alias was concealed", + "alias-was-exposed": "$sender, alias $alias was exposed", + "alias-was-removed": "$sender, alias $alias was removed", + "alias-group-set": "$sender, alias $alias was set to group $group", + "alias-group-unset": "$sender, alias $alias group was unset", + "alias-group-list": "$sender, list of aliases groups: $list", + "alias-group-list-aliases": "$sender, list of aliases in $group: $list", + "alias-group-list-enabled": "$sender, aliases in $group are enabled.", + "alias-group-list-disabled": "$sender, aliases in $group are disabled." + + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, command $command was not found in database", + "response-was-not-found": "$sender, response #$response of command $command was not found in database", + "command-was-edited": "$sender, command $command is changed to '$response'", + "command-was-added": "$sender, command $command was added", + "list-is-not-empty": "$sender, list of commands: $list", + "list-is-empty": "$sender, list of commands is empty", + "command-was-enabled": "$sender, command $command was enabled", + "command-was-disabled": "$sender, command $command was disabled", + "command-was-concealed": "$sender, command $command was concealed", + "command-was-exposed": "$sender, command $command was exposed", + "command-was-removed": "$sender, command $command was removed", + "response-was-removed": "$sender, response #$response of $command was removed", + "list-of-responses-is-empty": "$sender, $command have no responses or doesn't exists", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, keyword $keyword is ambiguous, use ID of keyword", + "keyword-was-not-found": "$sender, keyword $keyword was not found in database", + "response-was-not-found": "$sender, response #$response of keyword $keyword was not found in database", + "keyword-was-edited": "$sender, keyword $keyword is changed to '$response'", + "keyword-was-added": "$sender, keyword $keyword ($id) was added", + "list-is-not-empty": "$sender, list of keywords: $list", + "list-is-empty": "$sender, list of keywords is empty", + "keyword-was-enabled": "$sender, keyword $keyword was enabled", + "keyword-was-disabled": "$sender, keyword $keyword was disabled", + "keyword-was-removed": "$sender, keyword $keyword was removed", + "list-of-responses-is-empty": "$sender, $keyword have no responses or doesn't exists", + "response": "$keyword#$index ($permission) $after| $response" + + }, + "points": { + "success": { + "undo": "$sender, points '$command' for $username was reverted ($updatedValue $updatedValuePointsLocale to $originalValue $originalValuePointsLocale).", + "set": "$username was set to $amount $pointsName", + "give": "$sender just gave his $amount $pointsName to $username", + "online": { + "positive": "All online users just received $amount $pointsName!", + "negative": "All online users just lost $amount $pointsName!" + }, + "all": { + "positive": "All users just received $amount $pointsName!", + "negative": "All users just lost $amount $pointsName!" + }, + "rain": "Make it rain! All online users just received up to $amount $pointsName!", + "add": "$username just received $amount $pointsName!", + "remove": "Ouch, $amount $pointsName was removed from $username!" + }, + "failed": { + "undo": "$sender, username wasn't found in database or user have no undo operations", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Sorry, $sender, you don't have $amount $pointsName to give it to $username", + "cannotGiveZeroPoints": "Sorry, $sender, you cannot give $amount $pointsName to $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username has currently $amount $pointsName. Your position is $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, playlist to import is empty", + "playlist-imported": "$sender, imported $imported and skipped $skipped to playlist", + "not-playing": "Not Playing", + "song-was-banned": "Song $name was banned and will never play again!", + "song-was-banned-timeout-message": "You've got timeout for posting banned song", + "song-was-unbanned": "Song was succesfully unbanned", + "song-was-not-banned": "This song was not banned", + "no-song-is-currently-playing": "No song is currently playing", + "current-song-from-playlist": "Current song is $name from playlist", + "current-song-from-songrequest": "Current song is $name requested by $username", + "songrequest-disabled": "Sorry, $sender, song requests are disabled", + "song-is-banned": "Sorry, $sender, but this song is banned", + "youtube-is-not-responding-correctly": "Sorry, $sender, but YouTube is sending unexpected responses, please try again later.", + "song-was-not-found": "Sorry, $sender, but this song was not found", + "song-is-too-long": "Sorry, $sender, but this song is too long", + "this-song-is-not-in-playlist": "Sorry, $sender, but this song is not in current playlist", + "incorrect-category": "Sorry, $sender, but this song must be music category", + "song-was-added-to-queue": "$sender, song $name was added to queue", + "song-was-added-to-playlist": "$sender, song $name was added to playlist", + "song-is-already-in-playlist": "$sender, song $name is already in playlist", + "song-was-removed-from-playlist": "$sender, song $name was removed from playlist", + "song-was-removed-from-queue": "$sender, your song $name was removed from queue", + "playlist-current": "$sender, current playlist is $playlist.", + "playlist-list": "$sender, available playlists: $list.", + "playlist-not-exist": "$sender, your requested playlist $playlist doesn't exist.", + "playlist-set": "$sender, you changed playlist to $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, price for $command was set to $amount $pointsName", + "price-was-unset": "$sender, price for $command was unset", + "price-was-not-found": "$sender, price for $command was not found", + "price-was-enabled": "$sender, price for $command was enabled", + "price-was-disabled": "$sender, price for $command was disabled", + "user-have-not-enough-points": "Sorry, $sender, but you don't have $amount $pointsName to use $command", + "user-have-not-enough-points-or-bits": "Sorry, $sender, but you don't have $amount $pointsName or redeem command by $bitsAmount bits to use $command", + "user-have-not-enough-bits": "Sorry, $sender, but you need to redeem command by $bitsAmount bits to use $command", + "list-is-empty": "$sender, list of prices is empty", + "list-is-not-empty": "$sender, list of prices: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, new rank $type $rank($hours$hlocale) was added", + "rank-was-edited": "$sender, rank for $type $hours$hlocale was changed to $rank", + "rank-was-removed": "$sender, rank for $type $hours$hlocale was removed", + "rank-already-exist": "$sender, there is already a rank for $type $hours$hlocale", + "rank-was-not-found": "$sender, rank for $type $hours$hlocale was not found", + "custom-rank-was-set-to-user": "$sender, you set $rank to $username", + "custom-rank-was-unset-for-user": "$sender, custom rank for $username was unset", + "list-is-empty": "$sender, no ranks was found", + "list-is-not-empty": "$sender, ranks list: $list", + "show-rank-without-next-rank": "$sender, you have $rank rank", + "show-rank-with-next-rank": "$sender, you have $rank rank. Next rank - $nextrank", + "user-dont-have-rank": "$sender, you don't have a rank yet" + }, + "followage": { + "success": { + "never": "$sender, $username is not a channel follower", + "time": "$sender, $username is following channel $diff" + }, + "successSameUsername": { + "never": "$sender, you are not follower of this channel", + "time": "$sender, you are following this channel for $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username is not a channel subscriber.", + "notNow": "$sender, $username is currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username is subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username is subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, you are not a channel subscriber.", + "notNow": "$sender, you are currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, you are subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, you are subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, I don't have data for $username account age", + "success": { + "withUsername": "$sender, account age for $username is $diff", + "withoutUsername": "$sender, your account age is $diff" + } + }, + "lastseen": { + "success": { + "never": "$username was never in this channel!", + "time": "$username was last seen at $when in this channel" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username watched this channel for $time hours" + }, + "failed": { + "parse": "{core.command-parse} !watched or !watched [username]" + } + }, + "permissions": { + "without-permission": "You don't have enough permissions for '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, user $username have $type immunity for $time seconds", + "user-have-immunity-parameterError": "$sender, parameter error. $command ", + "user-have-link-permit": "User $username can post a $count $link to chat", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "No links allowed, ask for !permit [$count warnings left]", + "user-is-warned-about-symbols": "No excessive symbols usage [$count warnings left]", + "user-is-warned-about-long-message": "Long messages are not allowed [$count warnings left]", + "user-is-warned-about-caps": "No excessive caps usage [$count warnings left]", + "user-is-warned-about-spam": "Spamming is not allowed [$count warnings left]", + "user-is-warned-about-color": "Italic and /me is not allowed [$count warnings left]", + "user-is-warned-about-emotes": "No emotes spamming [$count warnings left]", + "user-is-warned-about-forbidden-words": "No forbidden words [$count warnings left]", + "user-have-timeout-for-links": "No links allowed, ask for !permit", + "user-have-timeout-for-symbols": "No excessive symbols usage", + "user-have-timeout-for-long-message": "Long message are not allowed", + "user-have-timeout-for-caps": "No excessive caps usage", + "user-have-timeout-for-spam": "Spamming is not allowed", + "user-have-timeout-for-color": "Italic and /me is not allowed", + "user-have-timeout-for-emotes": "No emotes spamming", + "user-have-timeout-for-forbidden-words": "No forbidden words" + }, + "queue": { + "list": "$sender, current queue pool: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Sorry $sender, queue is currently closed", + "opened": "$sender were added into queue" + }, + "open": "Queue is currently OPENED! Join to queue with !queue join", + "close": "Queue is currently closed!", + "clear": "Queue were completely cleared", + "picked": { + "single": "This user was picked from queue: $users", + "multi": "These users were picked from queue: $users", + "none": "No users were found in queue" + } + }, + "marker": "Stream marker has been created at $time.", + "title": { + "current": "$sender, title of stream is '$title'.", + "change": { + "success": "$sender, title was set to: $title" + } + }, + "game": { + "current": "$sender, streamer is currently playing $game.", + "change": { + "success": "$sender, category was set to: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type cooldown for $command was set to $secondss", + "cooldown-was-unset": "$sender, cooldown for $command was unset", + "cooldown-triggered": "$sender, '$command' is on cooldown, remaining $secondss", + "cooldown-not-found": "$sender, cooldown for $command was not found", + "cooldown-was-enabled": "$sender, cooldown for $command was enabled", + "cooldown-was-disabled": "$sender, cooldown for $command was disabled", + "cooldown-was-enabled-for-moderators": "$sender, cooldown for $command was enabled for moderators", + "cooldown-was-disabled-for-moderators": "$sender, cooldown for $command was disabled for moderators", + "cooldown-was-enabled-for-owners": "$sender, cooldown for $command was enabled for owners", + "cooldown-was-disabled-for-owners": "$sender, cooldown for $command was disabled for owners", + "cooldown-was-enabled-for-subscribers": "$sender, cooldown for $command was enabled for subscribers", + "cooldown-was-disabled-for-subscribers": "$sender, cooldown for $command was disabled for subscribers", + "cooldown-was-enabled-for-followers": "$sender, cooldown for $command was enabled for followers", + "cooldown-was-disabled-for-followers": "$sender, cooldown for $command was disabled for followers" + }, + "timers": { + "id-must-be-defined": "$sender, response id must be defined.", + "id-or-name-must-be-defined": "$sender, response id or timer name must be defined.", + "name-must-be-defined": "$sender, timer name must be defined.", + "response-must-be-defined": "$sender, timer response must be defined.", + "cannot-set-messages-and-seconds-0": "$sender, you cannot set both messages and seconds to 0.", + "timer-was-set": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger", + "timer-was-set-with-offline-flag": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger even when stream is offline", + "timer-not-found": "$sender, timer (name: $name) was not found in database. Check timers with !timers list", + "timer-deleted": "$sender, timer $name and its responses was deleted.", + "timer-enabled": "$sender, timer (name: $name) was enabled", + "timer-disabled": "$sender, timer (name: $name) was disabled", + "timers-list": "$sender, timers list: $list", + "responses-list": "$sender, timer (name: $name) list", + "response-deleted": "$sender, response (id: $id) was deleted.", + "response-was-added": "$sender, response (id: $id) for timer (name: $name) was added - '$response'", + "response-not-found": "$sender, response (id: $id) was not found in database", + "response-enabled": "$sender, response (id: $id) was enabled", + "response-disabled": "$sender, response (id: $id) was disabled" + }, + "gambling": { + "duel": { + "bank": "$sender, current bank for $command is $points $pointsName", + "lowerThanMinimalBet": "$sender, minimal bet for $command is $points $pointsName", + "cooldown": "$sender, you cannot use $command for $cooldown $minutesName.", + "joined": "$sender, good luck with your dueling skills. You bet on yourself $points $pointsName!", + "added": "$sender really thinks he is better than others raising his bet to $points $pointsName!", + "new": "$sender is your new duel challenger! To participate use $command [points], you have $minutes $minutesName left to join.", + "zeroBet": "$sender, you cannot duel 0 $pointsName", + "notEnoughOptions": "$sender, you need to specify points to dueling", + "notEnoughPoints": "$sender, you don't have $points $pointsName to duel!", + "noContestant": "Only $winner have courage to join duel! Your bet of $points $pointsName are returned to you.", + "winner": "Congratulations to $winner! He is last man standing and he won $points $pointsName ($probability% with bet of $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender is trying his luck and pulled a trigger", + "alive": "$sender is alive! Nothing happened.", + "dead": "$sender's brain was splashed on the wall!", + "mod": "$sender is incompetent and completely missed his head!", + "broadcaster": "$sender is using blanks, boo!", + "timeout": "Roulette timeout set to $values" + }, + "gamble": { + "chanceToWin": "$sender, chance to win !gamble set to $value%", + "zeroBet": "$sender, you cannot gamble 0 $pointsName", + "minimalBet": "$sender, minimal bet for !gamble is set to $value", + "lowerThanMinimalBet": "$sender, minimal bet for !gamble is $points $pointsName", + "notEnoughOptions": "$sender, you need to specify points to gamble", + "notEnoughPoints": "$sender, you don't have $points $pointsName to gamble", + "win": "$sender, you WON! You now have $points $pointsName", + "winJackpot": "$sender, you hit JACKPOT! You won $jackpot $jackpotName in addition to your bet. You now have $points $pointsName", + "loseWithJackpot": "$sender, you LOST! You now have $points $pointsName. Jackpot increased to $jackpot $jackpotName", + "lose": "$sender, you LOST! You now have $points $pointsName", + "currentJackpot": "$sender, current jackpot for $command is $points $pointsName", + "winJackpotCount": "$sender, you won $count jackpots", + "jackpotIsDisabled": "$sender, jackpot is disabled for $command." + } + }, + "highlights": { + "saved": "$sender, highlight was saved for $hoursh$minutesm$secondss", + "list": { + "items": "$sender, list of saved highlights for latest stream: $items", + "empty": "$sender, no highlights were saved" + }, + "offline": "$sender, cannot save highlight, stream is offline" + }, + "whisper" : { + "settings": { + "disablePermissionWhispers": { + "true": "Bot won't send errors on insufficient permissions", + "false": "Bot won't send errors on insufficient permissions through whispers" + }, + "disableCooldownWhispers": { + "true": "Bot won't send cooldown notifications", + "false": "Bot will send cooldown notifications through whispers" + } + } + }, + "time": "Current time in streamer's timezone is $time", + "subs": "$sender, there is currently $onlineSubCount online subscribers. Last sub/resub was $lastSubUsername $lastSubAgo", + "followers": "$sender, last follow was $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, user $username is not ignored by bot" + }, + "added": "$sender, user $username is added to bot ignorelist", + "removed": "$sender, user $username is removed from bot ignorelist", + "ignored": "$sender, user $username is ignored by bot" + } + } + }, + "filters": { + "setVariable": "$sender, $variable was set to $value." + } +} diff --git a/backend/locales/en/api.clips.json b/backend/locales/en/api.clips.json new file mode 100644 index 000000000..21895e7a3 --- /dev/null +++ b/backend/locales/en/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "Clip was created and is available at $link" +} \ No newline at end of file diff --git a/backend/locales/en/core/permissions.json b/backend/locales/en/core/permissions.json new file mode 100644 index 000000000..b6ac08e99 --- /dev/null +++ b/backend/locales/en/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "List of your permissions:", + "excludeAddSuccessful": "$sender, you added $username to exclude list for permission $permissionName", + "excludeRmSuccessful": "$sender, you removed $username from exclude list for permission $permissionName", + "userNotFound": "$sender, user $username was not found in database.", + "permissionNotFound": "$sender, permission $userlevel was not found in database.", + "cannotIgnoreForCorePermission": "$sender, you cannot manually exclude user for core permission $userlevel" +} \ No newline at end of file diff --git a/backend/locales/en/games.heist.json b/backend/locales/en/games.heist.json new file mode 100644 index 000000000..86371a40f --- /dev/null +++ b/backend/locales/en/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, cops are still searching for last heist team. Try again after $cooldown.", + "copsCooldownMessage": "Alright guys, looks like police forces are eating donuts and we can get that sweet money!", + "entryMessage": "$sender has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type $command to enter.", + "lateEntryMessage": "$sender, heist is currently in progress!", + "entryInstruction": "$sender, type $command to enter.", + "levelMessage": "With this crew, we can heist $bank! Let's see if we can get enough crew to heist $nextBank", + "maxLevelMessage": "With this crew, we can heist $bank! It cannot be any better!", + "started": "Alright guys, check your equipment, this is what we trained for. This is not a game, this is real life. We will get money from $bank!", + "noUser": "Nobody joins a crew to heist.", + "singleUserSuccess": "$user was like a ninja. Nobody noticed missing money.", + "singleUserFailed": "$user failed to get rid of police and will be spending his time in jail.", + "result": { + "0": "Everyone was mercilessly obliterated. This is slaughter.", + "33": "Only 1/3rd of team get its money from heist.", + "50": "Half of heist team was killed or catched by police.", + "99": "Some loses of heist team is nothing of what remaining crew have in theirs pockets.", + "100": "God divinity, nobody is dead, everyone won!" + }, + "levels": { + "bankVan": "Bank van", + "cityBank": "City bank", + "stateBank": "State bank", + "nationalReserve": "National reserve", + "federalReserve": "Federal reserve" + }, + "results": "The heist payouts are: $users", + "andXMore": "and $count more..." +} \ No newline at end of file diff --git a/backend/locales/en/integrations/discord.json b/backend/locales/en/integrations/discord.json new file mode 100644 index 000000000..25176852b --- /dev/null +++ b/backend/locales/en/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "your account is not linked, use `$command`", + "all-your-links-were-deleted": "all your links were deleted", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender, this account was linked with $discordTag.", + "invalid-or-expired-token": "$sender, invalid or expired token.", + "help-message": "$sender, to link your account on Discord: 1. Go to Discord server and send $command in bot channel. | 2. Wait for PM from bot | 3. Send command from your Discord PM here in twitch chat.", + "started-at": "Started At", + "announced-by": "Announced by sogeBot", + "streamed-at": "Streamed At", + "link-whisper": "Hello $tag, to link this Discord account with your Twitch account on $broadcaster channel, go to , login to your account and send this command to chat \n\n\t\t`$command $id`\n\nNOTE: This expires in 10 minutes.", + "check-your-dm": "check your DMs for steps to link your account." +} \ No newline at end of file diff --git a/backend/locales/en/integrations/lastfm.json b/backend/locales/en/integrations/lastfm.json new file mode 100644 index 000000000..79a075396 --- /dev/null +++ b/backend/locales/en/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "Current song is $name" +} \ No newline at end of file diff --git a/backend/locales/en/integrations/obswebsocket.json b/backend/locales/en/integrations/obswebsocket.json new file mode 100644 index 000000000..1058ed4b6 --- /dev/null +++ b/backend/locales/en/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, there is no action set for id:$id!", + "ParameterError": "$sender, you need to specify id!", + "UnknownError": "$sender, something went wrong. Check bot logs for additional informations." + } +} \ No newline at end of file diff --git a/backend/locales/en/integrations/protondb.json b/backend/locales/en/integrations/protondb.json new file mode 100644 index 000000000..0e7df5a0f --- /dev/null +++ b/backend/locales/en/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | $rating rated | Native on $native | Details: $url", + "responseNg": "Rating for game $game was not found on ProtonDB.", + "responseNotFound": "Game $game was not found on ProtonDB." +} \ No newline at end of file diff --git a/backend/locales/en/integrations/pubg.json b/backend/locales/en/integrations/pubg.json new file mode 100644 index 000000000..1cc2a2623 --- /dev/null +++ b/backend/locales/en/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, expected one of these parameters: $list" +} \ No newline at end of file diff --git a/backend/locales/en/integrations/spotify.json b/backend/locales/en/integrations/spotify.json new file mode 100644 index 000000000..9085e33b0 --- /dev/null +++ b/backend/locales/en/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "Sorry, $sender, track was not found on spotify", + "song-requested": "$sender, you requested song $name from $artist", + "not-banned-song-not-playing": "$sender, no song is currently playing to ban.", + "song-banned": "$sender, song $name from $artist is banned.", + "song-unbanned": "$sender, song $name from $artist is unbanned.", + "song-not-found-in-banlist": "$sender, song by spotifyURI $uri was not found in ban list.", + "cannot-request-song-is-banned": "$sender, cannot request banned song $name from $artist.", + "cannot-request-song-from-unapproved-artist": "$sender, cannot request song from unapproved artist.", + "no-songs-found-in-history": "$sender, there is currently no song in history list.", + "return-one-song-from-history": "$sender, previous song was $name from $artist.", + "return-multiple-song-from-history": "$sender, $count previous songs were:", + "return-multiple-song-from-history-item": "$index - $name from $artist", + "song-notify": "Current playing song is $name by $artist." +} \ No newline at end of file diff --git a/backend/locales/en/integrations/tiltify.json b/backend/locales/en/integrations/tiltify.json new file mode 100644 index 000000000..aa574fb09 --- /dev/null +++ b/backend/locales/en/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, there are currently no active campaigns.", + "active_campaigns": "$sender, list of currently active campaigns:" +} \ No newline at end of file diff --git a/backend/locales/en/systems.quotes.json b/backend/locales/en/systems.quotes.json new file mode 100644 index 000000000..92025a17c --- /dev/null +++ b/backend/locales/en/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, quote $id '$quote' was added. (tags: $tags)", + "error": "$sender, $command is not correct or missing -quote parameter" + }, + "remove": { + "ok": "$sender, quote $id was successfully deleted.", + "error": "$sender, quote ID is missing.", + "not-found": "$sender, quote $id was not found." + }, + "show": { + "ok": "Quote $id by $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found.", + "not-found-by-tag": "$sender, no quotes with tag $tag was not found." + } + }, + "set": { + "ok": "$sender, quote $id tags were set. (tags: $tags)", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found." + } + }, + "list": { + "ok": "$sender, You can find quote list at http://$urlBase/public/#/quotes", + "is-localhost": "$sender, quote list url is not properly specified." + } +} \ No newline at end of file diff --git a/backend/locales/en/systems/antihateraid.json b/backend/locales/en/systems/antihateraid.json new file mode 100644 index 000000000..7ad602a98 --- /dev/null +++ b/backend/locales/en/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "This chat was set to $mode by $username to get rid of hate raid. Sorry for inconvenience!", + "mode": { + "0": "subs-only", + "1": "follow-only", + "2": "emotes-only" + } +} \ No newline at end of file diff --git a/backend/locales/en/systems/howlongtobeat.json b/backend/locales/en/systems/howlongtobeat.json new file mode 100644 index 000000000..0fcc12bd9 --- /dev/null +++ b/backend/locales/en/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game not found in db.", + "game": "$sender, $game | Main: $currentMain/$hltbMainh - $percentMain% | Main+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Completionist: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Main: $currentMainh | Main+Extra: $currentMainExtrah | Completionist: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/en/systems/levels.json b/backend/locales/en/systems/levels.json new file mode 100644 index 000000000..c2c9bb7f9 --- /dev/null +++ b/backend/locales/en/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, level: $currentLevel ($currentXP $xpName), $nextXP $xpName to next level.", + "changeXP": "$sender, you changed $xpName by $amount $xpName to $username.", + "notEnoughPointsToBuy": "Sorry $sender, but you don't have $points $pointsName to buy $amount $xpName for level $level.", + "XPBoughtByPoints": "$sender, you bought $amount $xpName with $points $pointsName and reached level $level.", + "somethingGetWrong": "$sender, something get wrong with your request." +} \ No newline at end of file diff --git a/backend/locales/en/systems/scrim.json b/backend/locales/en/systems/scrim.json new file mode 100644 index 000000000..45630d49b --- /dev/null +++ b/backend/locales/en/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Snipe match ($type) starting in $time $unit", + "go": "Starting now! Go!", + "putMatchIdInChat": "Please put your match ID in the chat => $command xxx", + "currentMatches": "Current Matches: $matches", + "stopped": "Snipe match was cancelled." +} \ No newline at end of file diff --git a/backend/locales/en/systems/top.json b/backend/locales/en/systems/top.json new file mode 100644 index 000000000..e0f7cb149 --- /dev/null +++ b/backend/locales/en/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Top $amount (watch time): ", + "tips": "Top $amount (tips): ", + "level": "Top $amount (level): ", + "points": "Top $amount (points): ", + "messages": "Top $amount (messages): ", + "followage": "Top $amount (followage): ", + "subage": "Top $amount (subage): ", + "submonths": "Top $amount (submonths): ", + "bits": "Top $amount (bits): ", + "gifts": "Top $amount (subgifts): " +} \ No newline at end of file diff --git a/backend/locales/en/ui.commons.json b/backend/locales/en/ui.commons.json new file mode 100644 index 000000000..22d3a3b5b --- /dev/null +++ b/backend/locales/en/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Additional settings", + "never": "never", + "reset": "reset", + "moveUp": "move up", + "moveDown": "move down", + "stop-if-executed": "stop, if executed", + "continue-if-executed": "continue, if executed", + "generate": "Generate", + "thumbnail": "Thumbnail", + "yes": "Yes", + "no": "No", + "show-more": "Show more", + "show-less": "Show less", + "allowed": "Allowed", + "disallowed": "Disallowed", + "back": "Back" +} diff --git a/backend/locales/en/ui.dialog.json b/backend/locales/en/ui.dialog.json new file mode 100644 index 000000000..15701cc6e --- /dev/null +++ b/backend/locales/en/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Edit", + "add": "Add" + }, + "position": { + "settings": "Position settings", + "anchorX": "Anchor X position", + "anchorY": "Anchor Y position", + "left": "Left", + "right": "Right", + "middle": "Middle", + "top": "Top", + "bottom": "Bottom", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Shift Right", + "shadowShiftDown": "Shift Down", + "shadowBlur": "Blur", + "shadowOpacity": "Opacity", + "color": "Color" + }, + "errors": { + "required": "This input cannot be empty.", + "minValue": "Lowest value of this input is $value." + }, + "buttons": { + "reorder": "Reorder", + "upload": { + "idle": "Upload", + "progress": "Uploading", + "done": "Uploaded" + }, + "cancel": "Cancel", + "close": "Close", + "test": { + "idle": "Test", + "progress": "Testing in progress", + "done": "Testing done" + }, + "saveChanges": { + "idle": "Save changes", + "invalid": "Cannot save changes", + "progress": "Saving changes", + "done": "Changes saved" + }, + "something-went-wrong": "Something went wrong", + "mark-to-delete": "Mark to delete", + "disable": "Disable", + "enable": "Enable", + "disabled": "Disabled", + "enabled": "Enabled", + "edit": "Edit", + "delete": "Delete", + "play": "Play", + "stop": "Stop", + "hold-to-delete": "Hold to delete", + "yes": "Yes", + "no": "No", + "permission": "Permission", + "group": "Group", + "visibility": "Visibility", + "reset": "Reset " + }, + "changesPending": "Your changes was not saved.", + "formNotValid": "Form is invalid.", + "nothingToShow": "Nothing to show here." +} \ No newline at end of file diff --git a/backend/locales/en/ui.menu.json b/backend/locales/en/ui.menu.json new file mode 100644 index 000000000..7361104a4 --- /dev/null +++ b/backend/locales/en/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Services", + "updater": "Updater", + "index": "Dashboard", + "core": "Bot", + "users": "Users", + "tmi": "TMI", + "ui": "UI", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "General", + "timers": "Timers", + "new": "New Item", + "keywords": "Keywords", + "customcommands": "Custom commands", + "botcommands": "Bot commands", + "commands": "Commands", + "events": "Events", + "ranks": "Ranks", + "songs": "Songs", + "modules": "Modules", + "viewers": "Viewers", + "alias": "Aliases", + "cooldowns": "Cooldowns", + "cooldown": "Cooldown", + "highlights": "Highlights", + "price": "Price", + "logs": "Logs", + "systems": "Systems", + "permissions": "Permissions", + "translations": "Custom translations", + "moderation": "Moderation", + "overlays": "Overlays", + "gallery": "Media gallery", + "games": "Games", + "spotify": "Spotify", + "integrations": "Integrations", + "customvariables": "Custom variables", + "registry": "Registry", + "quotes": "Quotes", + "settings": "Settings", + "commercial": "Commercial", + "bets": "Bets", + "points": "Points", + "raffles": "Raffles", + "queue": "Queue", + "playlist": "Playlist", + "bannedsongs": "Banned songs", + "spotifybannedsongs": "Spotify banned songs", + "duel": "Duel", + "fightme": "FightMe", + "seppuku": "Seppuku", + "gamble": "Gamble", + "roulette": "Roulette", + "heist": "Heist", + "oauth": "OAuth", + "socket": "Socket", + "carouseloverlay": "Carousel overlay", + "alerts": "Alerts", + "carousel": "Image carousel", + "clips": "Clips", + "credits": "Credits", + "emotes": "Emotes", + "stats": "Stats", + "text": "Text", + "currency": "Currency", + "eventlist": "Eventlist", + "clipscarousel": "Clips carousel", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Checklist", + "bot": "Bot", + "api": "API", + "manage": "Manage", + "top": "Top", + "goals": "Goals", + "userinfo": "User info", + "scrim": "Scrim", + "commandcount": "Command count", + "profiler": "Profiler", + "howlongtobeat": "How long to beat", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Randomizer", + "tips": "Tips", + "bits": "Bits", + "discord": "Discord", + "texttospeech": "Text To Speech", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "Levels", + "obswebsocket": "OBS Websocket", + "api-explorer": "API Explorer", + "emotescombo": "Emotes Combo", + "notifications": "Notifications", + "plugins": "Plugins", + "tts": "TTS" +} diff --git a/backend/locales/en/ui.page.settings.overlays.carousel.json b/backend/locales/en/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..7ca51081f --- /dev/null +++ b/backend/locales/en/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "options", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Are you sure to delete this image?" + }, + "button": { + "update": "Update", + "fix_your_errors_first": "Fix errors before save" + }, + "errors": { + "number_greater_or_equal_than_0": "Value must be a number >= 0", + "value_must_not_be_empty": "Value must not be empty" + }, + "titles": { + "waitBefore": "Wait before image show (in ms)", + "waitAfter": "Wait after image disappear (in ms)", + "duration": "How long image should be shown (in ms)", + "animationIn": "Animation In", + "animationOut": "Animation Out", + "animationInDuration": "Animation In duration (in ms)", + "animationOutDuration": "Animation Out duration (in ms)", + "showOnlyOncePerStream": "Show only once per stream" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.registry.customvariables.json b/backend/locales/en/ui.registry.customvariables.json new file mode 100644 index 000000000..eab91fde8 --- /dev/null +++ b/backend/locales/en/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "URLs", + "generateurl": "Generate new URL", + "show-examples": "show CURL examples", + "response": { + "show": "Show response after POST", + "name": "Response after variable set", + "default": "Default", + "default-placeholder": "Set your bot response", + "default-help": "Use $value to get new variable value", + "custom": "Custom", + "command": "Command" + }, + "useIfInCommand": "Use if you use variable in command. Will return only updated variable without response.", + "permissionToChange": "Permission to change", + "isReadOnly": "read-only in chat", + "isNotReadOnly": "can be changed through chat", + "no-variables-found": "No variables found", + "additional-info": "Additional info", + "run-script": "Run script", + "last-run": "Last run at", + "variable": { + "name": "Variable name", + "help": "Variable name must be unique, e.g. $_wins, $_loses, $_top3", + "placeholder": "Enter your unique variable name", + "error": { + "isNotUnique": "Variable must have unique name.", + "isEmpty": "Variable name must not be empty." + } + }, + "description": { + "name": "Description", + "help": "Optional description", + "placeholder": "Enter your optional description" + }, + "type": { + "name": "Type", + "error": { + "isNotSelected": "Please choose a variable type." + } + }, + "currentValue": { + "name": "Current value", + "help": "If type is set to Evaluated script, value cannot be manually changed" + }, + "usableOptions": { + "name": "Usable options", + "placeholder": "Enter, your, options, here", + "help": "Options, which can be used with this variable, example: SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "You need to set at least 1 value." + } + }, + "scriptToEvaluate": "Script to evaluate", + "runScript": { + "name": "Run script", + "error": { + "isNotSelected": "Please choose an option." + } + }, + "testCurrentScript": { + "name": "Test current script", + "help": "Click Test current script to see value in Current value input" + }, + "history": "History", + "historyIsEmpty": "History for this variable is empty!", + "warning": "Warning: All data of this variable will be discarded!", + "choose": "Choose...", + "types": { + "number": "Number", + "text": "Text", + "options": "Options", + "eval": "Script" + }, + "runEvery": { + "isUsed": "When variable is used" + } +} + diff --git a/backend/locales/en/ui.systems.antihateraid.json b/backend/locales/en/ui.systems.antihateraid.json new file mode 100644 index 000000000..d821c979d --- /dev/null +++ b/backend/locales/en/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Clear Chat", + "mode": "Mode", + "minFollowTime": "Minimum follow time", + "customAnnounce": "Customize announcement on anti hate raid enable" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.bets.json b/backend/locales/en/ui.systems.bets.json new file mode 100644 index 000000000..51b9de149 --- /dev/null +++ b/backend/locales/en/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "betPercentGain": "Add x% to bet payout each option" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.commercial.json b/backend/locales/en/ui.systems.commercial.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/en/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.cooldown.json b/backend/locales/en/ui.systems.cooldown.json new file mode 100644 index 000000000..064403519 --- /dev/null +++ b/backend/locales/en/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Notify as whisper", + "settings": { + "enabled": "Status", + "cooldownNotifyAsWhisper": "Whisper cooldown informations", + "cooldownNotifyAsChat": "Chat message cooldown informations", + "defaultCooldownOfCommandsInSeconds": "Default cooldown for commands (in seconds)", + "defaultCooldownOfKeywordsInSeconds": "Default cooldown for keywords (in seconds)" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.customcommands.json b/backend/locales/en/ui.systems.customcommands.json new file mode 100644 index 000000000..5c93eb931 --- /dev/null +++ b/backend/locales/en/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "No responses", + "addResponse": "Add response", + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.highlights.json b/backend/locales/en/ui.systems.highlights.json new file mode 100644 index 000000000..63ed31e83 --- /dev/null +++ b/backend/locales/en/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "urls": "Generated URLs" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.moderation.json b/backend/locales/en/ui.systems.moderation.json new file mode 100644 index 000000000..7c9c7f3e5 --- /dev/null +++ b/backend/locales/en/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Status", + "cListsEnabled": "Enforce the rule", + "cLinksEnabled": "Enforce the rule", + "cSymbolsEnabled": "Enforce the rule", + "cLongMessageEnabled": "Enforce the rule", + "cCapsEnabled": "Enforce the rule", + "cSpamEnabled": "Enforce the rule", + "cColorEnabled": "Enforce the rule", + "cEmotesEnabled": "Enforce the rule", + "cListsWhitelist": { + "title": "Allowed words", + "help": "To allow domains use \"domain:prtzl.io\"" + }, + "autobanMessages": "Autoban Messages", + "cListsBlacklist": "Forbidden words", + "cListsTimeout": "Timeout duration", + "cLinksTimeout": "Timeout duration", + "cSymbolsTimeout": "Timeout duration", + "cLongMessageTimeout": "Timeout duration", + "cCapsTimeout": "Timeout duration", + "cSpamTimeout": "Timeout duration", + "cColorTimeout": "Timeout duration", + "cEmotesTimeout": "Timeout duration", + "cWarningsShouldClearChat": "Should clear chat (will timeout for 1s)", + "cLinksIncludeSpaces": "Include spaces", + "cLinksIncludeClips": "Include clips", + "cSymbolsTriggerLength": "Trigger length of message", + "cLongMessageTriggerLength": "Trigger length of message", + "cCapsTriggerLength": "Trigger length of message", + "cSpamTriggerLength": "Trigger length of message", + "cSymbolsMaxSymbolsConsecutively": "Max symbols consecutively", + "cSymbolsMaxSymbolsPercent": "Max symbols %", + "cCapsMaxCapsPercent": "Max caps %", + "cSpamMaxLength": "Max length", + "cEmotesMaxCount": "Max count", + "cWarningsAnnounceTimeouts": "Announce timeouts in chat for everyone", + "cWarningsAllowedCount": "Warning count", + "cEmotesEmojisAreEmotes": "Treat Emojis as Emotes" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.points.json b/backend/locales/en/ui.systems.points.json new file mode 100644 index 000000000..b0b011374 --- /dev/null +++ b/backend/locales/en/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Status", + "name": { + "title": "Name", + "help": "Possible formats:
point|points
bod|4:body|bodu" + }, + "isPointResetIntervalEnabled": "Interval of points reset", + "resetIntervalCron": { + "name": "Cron interval", + "help": "CronTab generator" + }, + "interval": "Minutes interval to add points to online users when stream online", + "offlineInterval": "Minutes interval to add points to online users when stream offline", + "messageInterval": "How many messages to add points", + "messageOfflineInterval": "How many messages to add points when stream offline", + "perInterval": "How many points to add per online interval", + "perOfflineInterval": "How many points to add per offline interval", + "perMessageInterval": "How many points to add per message interval", + "perMessageOfflineInterval": "How many points to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.price.json b/backend/locales/en/ui.systems.price.json new file mode 100644 index 000000000..6dcce9ea2 --- /dev/null +++ b/backend/locales/en/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Trigger custom alerts on bit redeem", + "price": { + "name": "price", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.queue.json b/backend/locales/en/ui.systems.queue.json new file mode 100644 index 000000000..7edcb74b8 --- /dev/null +++ b/backend/locales/en/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Status", + "eligibilityAll": "All", + "eligibilityFollowers": "Followers", + "eligibilitySubscribers": "Subscribers" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.quotes.json b/backend/locales/en/ui.systems.quotes.json new file mode 100644 index 000000000..97f22bce8 --- /dev/null +++ b/backend/locales/en/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "We're sorry, no quotes were found in database.", + "new": "Add new quote", + "empty": "List of quotes is empty, create new quote.", + "emptyAfterSearch": "List of quotes is empty in searching for \"$search\"", + "quote": { + "name": "Quote", + "placeholder": "Set your quote here" + }, + "by": { + "name": "Quoted by" + }, + "tags": { + "name": "Tags", + "placeholder": "Set your tags here", + "help": "Comma-separated tags. Example: tag 1, tag 2, tag 3" + }, + "date": { + "name": "Date" + }, + "error": { + "isEmpty": "This value cannot be empty", + "atLeastOneTag": "You need to set at least one tag" + }, + "tag-filter": "Filtering by tag", + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status", + "urlBase": { + "title": "URL base", + "help": "You should use public endpoint for quotes, to be accessible by everyone" + } + } +} diff --git a/backend/locales/en/ui.systems.raffles.json b/backend/locales/en/ui.systems.raffles.json new file mode 100644 index 000000000..9b8075f19 --- /dev/null +++ b/backend/locales/en/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Subscribers luck" + }, + "settings": { + "enabled": "Status", + "announceNewEntries": { + "title": "Announce new entries", + "help": "If users joins raffle, announce message will be send to chat after while." + }, + "announceNewEntriesBatchTime": { + "title": "How long to wait before announce new entries (in seconds)", + "help": "Longer time will keep chat cleaner, entries will be aggregated together." + }, + "deleteRaffleJoinCommands": { + "title": "Delete user raffle join command", + "help": "This will delete user message if they use !yourraffle command. Should keep chat cleaner." + }, + "allowOverTicketing": { + "title": "Allow over ticketing", + "help": "Allow user join raffle with over ticket of his points. E.g. user have 10 points but can join with !raffle 100 which will use all of his points." + }, + "raffleAnnounceInterval": { + "title": "Announce interval", + "help": "Minutes" + }, + "raffleAnnounceMessageInterval": { + "title": "Announce message interval", + "help": "How many messages must be sent to chat until announce can be posted." + }, + "subscribersPercent": { + "title": "Additional subscribers luck", + "help": "in percents" + } + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.ranks.json b/backend/locales/en/ui.systems.ranks.json new file mode 100644 index 000000000..42a6861a6 --- /dev/null +++ b/backend/locales/en/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "New Rank", + "empty": "No ranks were created yet.", + "emptyAfterSearch": "No ranks were found by your search for \"$search\".", + "rank": { + "name": "rank", + "placeholder": "" + }, + "value": { + "name": "hours", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.songs.json b/backend/locales/en/ui.systems.songs.json new file mode 100644 index 000000000..c1df88a05 --- /dev/null +++ b/backend/locales/en/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Status", + "volume": "Volume", + "calculateVolumeByLoudness": "Dynamic volume by loudness", + "duration": { + "title": "Max song duration", + "help": "In minutes" + }, + "shuffle": "Shuffle", + "songrequest": "Play from song request", + "playlist": "Play from playlist", + "onlyMusicCategory": "Allow only category music", + "allowRequestsOnlyFromPlaylist": "Allow song requests only from current playlist", + "notify": "Send message on song change" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "startTime": "Start song at", + "endTime": "End song at", + "add_song": "Add song", + "add_or_import": "Add song or import from playlist", + "importing": "Importing", + "importing_done": "Importing Done", + "seconds": "Seconds", + "calculated": "Calculated", + "set_manually": "Set manually", + "bannedSongsEmptyAfterSearch": "No banned songs were found by your search for \"$search\".", + "emptyAfterSearch": "No songs were found by your search for \"$search\".", + "empty": "No songs were added yet.", + "bannedSongsEmpty": "No songs were added to banlist yet." +} \ No newline at end of file diff --git a/backend/locales/en/ui.systems.timers.json b/backend/locales/en/ui.systems.timers.json new file mode 100644 index 000000000..70bcf937f --- /dev/null +++ b/backend/locales/en/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "New Timer", + "empty": "No timers were created yet.", + "emptyAfterSearch": "No timers were found by your search for \"$search\".", + "add_response": "Add Response", + "settings": { + "enabled": "Status" + }, + "warning": "This action cannot be reverted!" +} \ No newline at end of file diff --git a/backend/locales/en/ui.widgets.customvariables.json b/backend/locales/en/ui.widgets.customvariables.json new file mode 100644 index 000000000..761875e3b --- /dev/null +++ b/backend/locales/en/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "No custom variables found, add at custom variables registry", + "add-variable-into-watchlist": "Add variable to watchlist", + "watchlist": "Watchlist" +} \ No newline at end of file diff --git a/backend/locales/en/ui.widgets.randomizer.json b/backend/locales/en/ui.widgets.randomizer.json new file mode 100644 index 000000000..17a70ebb9 --- /dev/null +++ b/backend/locales/en/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "No randomizer found, add at randomizer registry", + "add-randomizer-to-widget": "Add randomizer to widget" +} \ No newline at end of file diff --git a/backend/locales/en/ui/categories.json b/backend/locales/en/ui/categories.json new file mode 100644 index 000000000..fc4be8abb --- /dev/null +++ b/backend/locales/en/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Announcements", + "keys": "Keys", + "currency": "Currency", + "general": "General", + "settings": "Settings", + "commands": "Commands", + "bot": "Bot", + "channel": "Channel", + "connection": "Connection", + "chat": "Chat", + "graceful_exit": "Graceful exit", + "rewards": "Rewards", + "levels": "Levels", + "notifications": "Notifications", + "options": "Options", + "comboBreakMessages": "Combo Break Messages", + "hypeMessages": "Hype Messages", + "messages": "Messages", + "results": "Results", + "customization": "Customization", + "status": "Status", + "mapping": "Mapping", + "player": "Player", + "stats": "Stats", + "api": "API", + "token": "Token", + "text": "Text", + "custom_texts": "Custom texts", + "credits": "Credits", + "show": "Show", + "social": "Social", + "explosion": "Explosion", + "fireworks": "Fireworks", + "test": "Test", + "emotes": "Emotes", + "default": "Default", + "urls": "URLs", + "conversion": "Conversion", + "xp": "XP", + "caps_filter": "Caps filter", + "color_filter": "Italic (/me) filter", + "links_filter": "Links filter", + "symbols_filter": "Symbols filter", + "longMessage_filter": "Message length filter", + "spam_filter": "Spam filter", + "emotes_filter": "Emotes filter", + "warnings": "Warnings", + "reset": "Reset", + "reminder": "Reminder", + "eligibility": "Eligibility", + "join": "Join", + "luck": "Luck", + "lists": "Lists", + "me": "Me", + "emotes_combo": "Emotes combo", + "tmi": "tmi", + "oauth": "oauth", + "eventsub": "eventsub", + "rules": "rules" +} \ No newline at end of file diff --git a/backend/locales/en/ui/core/currency.json b/backend/locales/en/ui/core/currency.json new file mode 100644 index 000000000..4b62e85a2 --- /dev/null +++ b/backend/locales/en/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Main currency" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/core/general.json b/backend/locales/en/ui/core/general.json new file mode 100644 index 000000000..cfe609bf2 --- /dev/null +++ b/backend/locales/en/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Bot language", + "numberFormat": "Format of numbers in chat", + "gracefulExitEachXHours": { + "title": "Graceful exit each X hours", + "help": "0 - disabled" + }, + "shouldGracefulExitHelp": "Enabling of graceful exit is recommended if your bot is running endlessly on server. You should have bot running on pm2 (or similar service) or have it dockerized to ensure automatic bot restart. Bot won't gracefully exit when stream is online." + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/core/oauth.json b/backend/locales/en/ui/core/oauth.json new file mode 100644 index 000000000..76b48ccc9 --- /dev/null +++ b/backend/locales/en/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Owners", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "Username", + "channelUsername": "Username", + "botExpectedScopes": "Scopes", + "channelExpectedScopes": "Scopes" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/core/permissions.json b/backend/locales/en/ui/core/permissions.json new file mode 100644 index 000000000..07aec34c0 --- /dev/null +++ b/backend/locales/en/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Add new permission group", + "higherPermissionHaveAccessToLowerPermissions": "Higher Permission have access to lower permissions.", + "typeUsernameOrIdToSearch": "Type username or ID to search", + "typeUsernameOrIdToTest": "Type username or ID to test", + "noUsersWereFound": "No users were found.", + "noUsersManuallyAddedToPermissionYet": "No users were manually added to permission yet.", + "done": "Done", + "previous": "Previous", + "next": "Next", + "loading": "loading", + "permissionNotFoundInDatabase": "Permission not found in database, please save before testing user.", + "userHaveNoAccessToThisPermissionGroup": "User $username DOESN'T have access to this permission group.", + "userHaveAccessToThisPermissionGroup": "User $username HAVE access to this permission group.", + "accessDirectlyThrough": "Direct access through", + "accessThroughHigherPermission": "Access through higher permission", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Something went wrong, user $username was not found in bot database.", + "permissionsGroups": "Permissions Groups", + "allowHigherPermissions": "Allow access through higher permission", + "type": "Type", + "value": "Value", + "watched": "Watched time in hours", + "followtime": "Follow time in months", + "points": "Points", + "tips": "Tips", + "bits": "Bits", + "messages": "Messages", + "subtier": "Sub Tier (1, 2, or 3)", + "subcumulativemonths": "Sub cumulative months", + "substreakmonths": "Current sub streak", + "ranks": "Current rank", + "level": "Current level", + "isLowerThan": "is lower than", + "isLowerThanOrEquals": "is lower than or equals", + "equals": "equals", + "isHigherThanOrEquals": "is higher than or equals", + "isHigherThan": "is higher than", + "addFilter": "Add filter", + "selectPermissionGroup": "Select permission group", + "settings": "Settings", + "name": "Name", + "baseUsersSet": "Base set of users", + "manuallyAddedUsers": "Manually added users", + "manuallyExcludedUsers": "Manually excluded users", + "filters": "Filters", + "testUser": "Test user", + "none": "- none -", + "casters": "Casters", + "moderators": "Moderators", + "subscribers": "Subscribers", + "vip": "VIP", + "viewers": "Viewers", + "followers": "Followers" +} \ No newline at end of file diff --git a/backend/locales/en/ui/core/socket.json b/backend/locales/en/ui/core/socket.json new file mode 100644 index 000000000..38f1582f4 --- /dev/null +++ b/backend/locales/en/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Purge All Authenticated Connection (yours as well)", + "accessTokenExpirationTime": "Access Token Expiration Time (seconds)", + "refreshTokenExpirationTime": "Refresh Token Expiration Time (seconds)", + "socketToken": { + "title": "Socket token", + "help": "This token will give you full admin access through sockets. Don't share!" + } + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/core/tmi.json b/backend/locales/en/ui/core/tmi.json new file mode 100644 index 000000000..49e6fd64d --- /dev/null +++ b/backend/locales/en/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Ignore list (ID or username)", + "showWithAt": "Show users with @", + "sendWithMe": "Send messages with /me", + "sendAsReply": "Send bot messages as replies", + "mute": "Bot is muted", + "whisperListener": "Listen on commands on whispers" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/core/tts.json b/backend/locales/en/ui/core/tts.json new file mode 100644 index 000000000..f4b8119bc --- /dev/null +++ b/backend/locales/en/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Service" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/core/twitch.json b/backend/locales/en/ui/core/twitch.json new file mode 100644 index 000000000..e056c6a1e --- /dev/null +++ b/backend/locales/en/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Create stream marker on event" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/core/ui.json b/backend/locales/en/ui/core/ui.json new file mode 100644 index 000000000..1c4778f3d --- /dev/null +++ b/backend/locales/en/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Default theme", + "domain": { + "title": "Domain", + "help": "Format without http/https: yourdomain.com or your.domain.com" + }, + "percentage": "Percentage difference for stats", + "shortennumbers": "Short format of numbers", + "showdiff": "Show difference", + "enablePublicPage": "Enable public page" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/core/updater.json b/backend/locales/en/ui/core/updater.json new file mode 100644 index 000000000..b93fa3738 --- /dev/null +++ b/backend/locales/en/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Automatically update if newer version available" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/errors.json b/backend/locales/en/ui/errors.json new file mode 100644 index 000000000..8b3e4bef8 --- /dev/null +++ b/backend/locales/en/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Unexpected errors during validation", + "isNotEmpty": "$property is required.", + "minLength": "$property must be longer than or equal to $constraint1 characters.", + "isPositive": "$property must be greater then 0", + "isCommand": "$property must start with !", + "isCommandOrCustomVariable": "$property must start with ! or $_", + "isCustomVariable": "$property must start with $_", + "min": "$property must be at least $constraint1", + "max": "$property must be lower or equal to $constraint1", + "isInt": "$property must be an integer", + "this_value_must_be_a_positive_number_and_greater_then_0": "This value must be a positive number or greater then 0", + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty", + "minLength_of_value_is": "Minimal length is $value.", + "this_currency_is_not_supported": "This currency is not supported", + "something_went_wrong": "Something went wrong", + "permission_must_exist": "Permission must exist", + "minValue_of_value_is": "Minimal value is $value", + "value_cannot_be": "Value cannot be $value.", + "invalid_format": "Invalid value format.", + "invalid_regexp_format": "This is not valid regex.", + "owner_and_broadcaster_oauth_is_not_set": "Owner and channel oauth is not set", + "channel_is_not_set": "Channel is not set", + "please_set_your_broadcaster_oauth_or_owners": "Please set your channel oauth or owners, or all users will have access to this dashboard and will be considered as casters.", + "new_update_available": "New update available", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "One of inputs must be set" +} \ No newline at end of file diff --git a/backend/locales/en/ui/games/duel.json b/backend/locales/en/ui/games/duel.json new file mode 100644 index 000000000..84789a717 --- /dev/null +++ b/backend/locales/en/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Status", + "cooldown": "Cooldown", + "duration": { + "title": "Duration", + "help": "Minutes" + }, + "minimalBet": "Minimal bet", + "bypassCooldownByOwnerAndMods": "Bypass cooldown by owner and mods" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/games/gamble.json b/backend/locales/en/ui/games/gamble.json new file mode 100644 index 000000000..6a78309aa --- /dev/null +++ b/backend/locales/en/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "minimalBet": "Minimal bet", + "chanceToWin": { + "title": "Chance to win", + "help": "Percent" + }, + "enableJackpot": "Enable jackpot", + "chanceToTriggerJackpot": "Chance to trigger jackpot in %", + "maxJackpotValue": "Maximum value of jackpot", + "lostPointsAddedToJackpot": "How many lost points should be added to jackpot in %" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/games/heist.json b/backend/locales/en/ui/games/heist.json new file mode 100644 index 000000000..e0ffc9feb --- /dev/null +++ b/backend/locales/en/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Heist", + "settings": { + "enabled": "Status", + "showMaxUsers": "Max users to show in payout", + "copsCooldownInMinutes": { + "title": "Cooldown between heists", + "help": "Minutes" + }, + "entryCooldownInSeconds": { + "title": "Time to entry heist", + "help": "Seconds" + }, + "started": "Heist start message", + "nextLevelMessage": "Message when next level is reached", + "maxLevelMessage": "Message when max level is reached", + "copsOnPatrol": "Response of bot when heist is still on cooldown", + "copsCooldown": "Bot announcement when heist can be started", + "singleUserSuccess": "Success message for one user", + "singleUserFailed": "Fail message for one user", + "noUser": "Message if no user participated" + }, + "message": "Message", + "winPercentage": "Win percentage", + "payoutMultiplier": "Payout multiplier", + "maxUsers": "Max users for level", + "percentage": "Percentage", + "noResultsFound": "No results found. Click button below to add new result.", + "noLevelsFound": "No levels found. Click button below to add new level." +} \ No newline at end of file diff --git a/backend/locales/en/ui/games/roulette.json b/backend/locales/en/ui/games/roulette.json new file mode 100644 index 000000000..65696d4e3 --- /dev/null +++ b/backend/locales/en/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout duration", + "help": "Seconds" + }, + "winnerWillGet": "How many points will be added on win", + "loserWillLose": "How many points will be lost on lose" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/games/seppuku.json b/backend/locales/en/ui/games/seppuku.json new file mode 100644 index 000000000..4d628e202 --- /dev/null +++ b/backend/locales/en/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout duration", + "help": "Seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/integrations/discord.json b/backend/locales/en/ui/integrations/discord.json new file mode 100644 index 000000000..baa645ab9 --- /dev/null +++ b/backend/locales/en/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Status", + "guild": "Guild", + "listenAtChannels": "Listen for commands on this channel", + "sendOnlineAnnounceToChannel": "Send online announcement to this channel", + "onlineAnnounceMessage": "Message in online announcement (can include mentions)", + "sendAnnouncesToChannel": "Setup sending of announcements to channels", + "deleteMessagesAfterWhile": "Delete message after while", + "clientId": "ClientId", + "token": "Token", + "joinToServerBtn": "Click to join bot to your server", + "joinToServerBtnDisabled": "Please save changes to enable bot join to your server", + "cannotJoinToServerBtn": "Set token and clientId to be able to join bot to your server", + "noChannelSelected": "no channel selected", + "noRoleSelected": "no role selected", + "noGuildSelected": "no guild selected", + "noGuildSelectedBox": "Select guild where bot should work and you'll see more settings", + "onlinePresenceStatusDefault": "Default Status", + "onlinePresenceStatusDefaultName": "Default Status Message", + "onlinePresenceStatusOnStream": "Status when Streaming", + "onlinePresenceStatusOnStreamName": "Status Message when Streaming", + "ignorelist": { + "title": "Ignore list", + "help": "username, username#0000 or userID" + } + } +} diff --git a/backend/locales/en/ui/integrations/donatello.json b/backend/locales/en/ui/integrations/donatello.json new file mode 100644 index 000000000..75bd1598d --- /dev/null +++ b/backend/locales/en/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Get your token at https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/integrations/donationalerts.json b/backend/locales/en/ui/integrations/donationalerts.json new file mode 100644 index 000000000..e37a63aee --- /dev/null +++ b/backend/locales/en/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Status", + "access_token": { + "title": "Access token", + "help": "Get your access token at https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Refresh token" + }, + "accessTokenBtn": "DonationAlerts access and refresh token generator" + } +} diff --git a/backend/locales/en/ui/integrations/kofi.json b/backend/locales/en/ui/integrations/kofi.json new file mode 100644 index 000000000..a8179bf1e --- /dev/null +++ b/backend/locales/en/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Verification token", + "help": "Get your verification token at https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "Webhook URL", + "help": "Set Webhook URL at https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL must have HTTPS", + "origin": "You cannot use localhost for webhooks" + } + } + } +} diff --git a/backend/locales/en/ui/integrations/lastfm.json b/backend/locales/en/ui/integrations/lastfm.json new file mode 100644 index 000000000..3acc84d8a --- /dev/null +++ b/backend/locales/en/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": "API key", + "username": "Username" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/integrations/obswebsocket.json b/backend/locales/en/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..e681b04e4 --- /dev/null +++ b/backend/locales/en/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Status", + "accessBy": { + "title": "Access by", + "help": "Direct - connect directly from a bot | Overlay - connect via overlay browser source" + }, + "address": "Address", + "password": "Password" + }, + "noSourceSelected": "No source selected", + "noSceneSelected": "No scene selected", + "empty": "No action sets were created yet.", + "emptyAfterSearch": "No action sets were found by your search for \"$search\".", + "command": "Command", + "new": "Create new OBS Websocket action set", + "actions": "Actions", + "name": { + "name": "Name" + }, + "mute": "Mute", + "unmute": "Unmute", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Wait X miliseconds" + }, + "Log": { + "name": "Log message" + }, + "StartRecording": { + "name": "StartRecording" + }, + "StopRecording": { + "name": "StopRecording" + }, + "PauseRecording": { + "name": "PauseRecording" + }, + "ResumeRecording": { + "name": "ResumeRecording" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/integrations/pubg.json b/backend/locales/en/ui/integrations/pubg.json new file mode 100644 index 000000000..166aef5d9 --- /dev/null +++ b/backend/locales/en/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "API Key", + "help": "Get your API Key at https://developer.pubg.com/" + }, + "platform": "Platform", + "playerName": "Player Name", + "playerId": "Player ID", + "seasonId": { + "title": "Season ID", + "help": "Current season ID is being fetch every hour." + }, + "rankedGameModeStatsCustomization": "Customized message for ranked stats", + "gameModeStatsCustomization": "Customized message for normal stats" + }, + "click_to_fetch": "Click to fetch", + "something_went_wrong": "Something went wrong!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Stats are automatically refreshed every 10 minutes.", + "player_stats_ranked": "Player stats (ranked)", + "player_stats": "Player stats" +} diff --git a/backend/locales/en/ui/integrations/qiwi.json b/backend/locales/en/ui/integrations/qiwi.json new file mode 100644 index 000000000..e5f7cb336 --- /dev/null +++ b/backend/locales/en/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "secretToken": { + "title": "Secret token", + "help": "Get secret token at Qiwi Donate dashboard settings->click show secret token" + } + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/integrations/responsivevoice.json b/backend/locales/en/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..fc98112a2 --- /dev/null +++ b/backend/locales/en/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Key", + "help": "Get your key at http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/integrations/spotify.json b/backend/locales/en/ui/integrations/spotify.json new file mode 100644 index 000000000..e3f885ae2 --- /dev/null +++ b/backend/locales/en/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Artists", + "settings": { + "enabled": "Status", + "songRequests": "Song Requests", + "fetchCurrentSongWhenOffline": { + "title": "Fetch current song when stream is offline", + "help": "It's advised to have this disabled to avoid reach API limits" + }, + "allowApprovedArtistsOnly": "Allow approved artists only", + "approvedArtists": { + "title": "Approved artists", + "help": "Name or SpotifyURI of artist, one item per line" + }, + "queueWhenOffline": { + "title": "Queue songs when stream is offline", + "help": "It's advised to have this disabled to avoid queueing when you are just listening music" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "Forced Device ID", + "help": "Empty = disabled, force spotify device ID to be used to queue songs. Check logs for current active device or use button when playing song for at least 10 seconds." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Format", + "help": "Available variables: $song, $artist, $artists" + }, + "username": "Authorized user", + "revokeBtn": "Revoke user authorization", + "authorizeBtn": "Authorize user", + "scopes": "Scopes", + "playlistToPlay": { + "title": "Spotify URI of main playlist", + "help": "If set, after request finished this playlist will continue" + }, + "continueOnPlaylistAfterRequest": "Continue on playing of playlist after song request", + "notify": "Send message on song change" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/integrations/streamelements.json b/backend/locales/en/ui/integrations/streamelements.json new file mode 100644 index 000000000..b983c17ff --- /dev/null +++ b/backend/locales/en/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "jwtToken": { + "title": "JWT token", + "help": "Get JWT token at StreamElements Channels setting and toggle Show secrets" + } + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/integrations/streamlabs.json b/backend/locales/en/ui/integrations/streamlabs.json new file mode 100644 index 000000000..a2c359f1b --- /dev/null +++ b/backend/locales/en/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "socketToken": { + "title": "Socket token", + "help": "Get socket token from streamlabs dashboard API settings->API tokens->Your Socket API Token" + }, + "accessToken": { + "title": "Access token", + "help": "Get your access token at https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "StreamLabs access token generator" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/integrations/tipeeestream.json b/backend/locales/en/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..880b7bcfe --- /dev/null +++ b/backend/locales/en/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "Api key", + "help": "Get socket token from tipeeestream dashboard -> API -> Your API Key" + } + } +} diff --git a/backend/locales/en/ui/integrations/twitter.json b/backend/locales/en/ui/integrations/twitter.json new file mode 100644 index 000000000..940fd5589 --- /dev/null +++ b/backend/locales/en/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "consumerKey": "Consumer Key (API Key)", + "consumerSecret": "Consumer Secret (API Secret)", + "accessToken": "Access Token", + "secretToken": "Access Token Secret" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/managers.json b/backend/locales/en/ui/managers.json new file mode 100644 index 000000000..21055ff3e --- /dev/null +++ b/backend/locales/en/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "User event history", + "hostAndRaidViewersCount": "Viewers: $value", + "receivedSubscribeFrom": "Received subscribe from $value", + "giftedSubscribeTo": "Gifted subscribe to $value" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/overlays/alerts.json b/backend/locales/en/ui/overlays/alerts.json new file mode 100644 index 000000000..2c72859cb --- /dev/null +++ b/backend/locales/en/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Cache gallery items", + "galleryCacheLimitInMb": "Max size of gallery item (in MB) to cache" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/overlays/clips.json b/backend/locales/en/ui/overlays/clips.json new file mode 100644 index 000000000..aa6555159 --- /dev/null +++ b/backend/locales/en/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Volume", + "cClipsFilter": "Clip filter", + "cClipsLabel": "Show 'clip' label" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/overlays/clipscarousel.json b/backend/locales/en/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..b50a0a71d --- /dev/null +++ b/backend/locales/en/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsTimeToNextClip": "Time to next clip (s)" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/overlays/credits.json b/backend/locales/en/ui/overlays/credits.json new file mode 100644 index 000000000..6b2f72805 --- /dev/null +++ b/backend/locales/en/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Speed", + "cCreditsAggregated": "Aggregated credits", + "cShowGameThumbnail": "Show game thumbnail", + "cShowFollowers": "Show followers", + "cShowRaids": "Show raids", + "cShowSubscribers": "Show subscribers", + "cShowSubgifts": "Show gifted subs", + "cShowSubcommunitygifts": "Show subs gifted to community", + "cShowResubs": "Show resubs", + "cShowCheers": "Show cheers", + "cShowClips": "Show clips", + "cShowTips": "Show tips", + "cTextLastMessage": "Last message", + "cTextLastSubMessage": "Last submessge", + "cTextStreamBy": "Streamed by", + "cTextFollow": "Follow by", + "cTextRaid": "Raided by", + "cTextCheer": "Cheer by", + "cTextSub": "Subscribe by", + "cTextResub": "Resub by", + "cTextSubgift": "Gifted subs", + "cTextSubcommunitygift": "Subs gifted to community", + "cTextTip": "Tips by", + "cClipsPeriod": "Time interval", + "cClipsCustomPeriodInDays": "Custom time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsShouldPlay": "Clips should be played", + "cClipsVolume": "Volume" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/overlays/emotes.json b/backend/locales/en/ui/overlays/emotes.json new file mode 100644 index 000000000..8a961ad91 --- /dev/null +++ b/backend/locales/en/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Delete cache", + "hypeMessagesEnabled": "Show hype messages in chat", + "btnTestExplosion": "Test emote explosion", + "btnTestEmote": "Test emote", + "btnTestFirework": "Test emote firework", + "cEmotesSize": "Emotes size", + "cEmotesMaxEmotesPerMessage": "Maximum of emotes per message", + "cEmotesMaxRotation": "Maximal rotation of emote", + "cEmotesOffsetX": "Maximal offset on X-axis", + "cEmotesAnimation": "Animation", + "cEmotesAnimationTime": "Animation duration", + "cExplosionNumOfEmotes": "No. of emotes", + "cExplosionNumOfEmotesPerExplosion": "No. of emotes per explosion", + "cExplosionNumOfExplosions": "No. of explosions", + "enableEmotesCombo": "Enable emotes combo", + "comboBreakMessages": "Combo break messages", + "threshold": "Threshold", + "noMessagesFound": "No messages found.", + "message": "Message", + "showEmoteInOverlayThreshold": "Minimal message threshold to show emote in overlay", + "hideEmoteInOverlayAfter": { + "title": "Hide emote in overlay after inactivity", + "help": "Will hide emote in overlay after certain time in seconds" + }, + "comboCooldown": { + "title": "Combo cooldown", + "help": "Cooldown of combo in seconds" + }, + "comboMessageMinThreshold": { + "title": "Minimal message threshold", + "help": "Minimal message threshold to count emotes as combo (until then won't trigger cooldown)" + }, + "comboMessages": "Combo messages" + }, + "hype": { + "5": "Let's go! We got $amountx $emote combo so far! SeemsGood", + "15": "Keep it going! Can we get more than $amountx $emote? TriHard" + }, + "message": { + "3": "$amountx $emote combo", + "5": "$amountx $emote combo SeemsGood", + "10": "$amountx $emote combo PogChamp", + "15": "$amountx $emote combo TriHard", + "20": "$sender ruined $amountx $emote combo! NotLikeThis" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/overlays/polls.json b/backend/locales/en/ui/overlays/polls.json new file mode 100644 index 000000000..da094ce9b --- /dev/null +++ b/backend/locales/en/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Theme", + "cDisplayHideAfterInactivity": "Hide on inactivity", + "cDisplayAlign": "Align", + "cDisplayInactivityTime": { + "title": "Inactivity after", + "help": "in miliseconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/overlays/texttospeech.json b/backend/locales/en/ui/overlays/texttospeech.json new file mode 100644 index 000000000..c61ee3567 --- /dev/null +++ b/backend/locales/en/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "You haven't properly set ResponsiveVoice key", + "voice": { + "title": "Voice", + "help": "If voices are not properly loading after ResponsiveVoice key update, try to refresh browser" + }, + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "triggerTTSByHighlightedMessage": "Text to Speech will be triggered by highlighted message" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/properties.json b/backend/locales/en/ui/properties.json new file mode 100644 index 000000000..e6243cf72 --- /dev/null +++ b/backend/locales/en/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Command", + "variableName": "Variable name", + "price": "Price (points)", + "priceBits": "Price (bits)", + "thisvalue": "This value", + "promo": { + "shoutoutMessage": "Shoutout message", + "enableShoutoutMessage": "Send shoutout message in chat" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/registry/alerts.json b/backend/locales/en/ui/registry/alerts.json new file mode 100644 index 000000000..d12319c25 --- /dev/null +++ b/backend/locales/en/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Enabled", + "testDlg": { + "alertTester": "Alert tester", + "command": "Command", + "username": "Username", + "recipient": "Recipient", + "message": "Message", + "tier": "Tier", + "amountOfViewers": "Amount of viewers", + "amountOfBits": "Amount of bits", + "amountOfGifts": "Amount of gifts", + "amountOfMonths": "Amount of months", + "amountOfTips": "Tip", + "event": "Event", + "service": "Service" + }, + "empty": "Alerts registry is empty, create new alerts.", + "emptyAfterSearch": "Alerts registry is empty in searching for \"$search\"", + "revertcode": "Revert code to defaults", + "name": { + "name": "Name", + "placeholder": "Set name of your alerts" + }, + "alertDelayInMs": { + "name": "Alert delay" + }, + "parryEnabled": { + "name": "Alert parries" + }, + "parryDelay": { + "name": "Alert parry delay" + }, + "profanityFilterType": { + "name": "Profanity filter", + "disabled": "Disabled", + "replace-with-asterisk": "Replace with asterisk", + "replace-with-happy-words": "Replace with happy words", + "hide-messages": "Hide messages", + "disable-alerts": "Disable alerts" + }, + "loadStandardProfanityList": "Load standard profanity list", + "customProfanityList": { + "name": "Custom profanity list", + "help": "Words should be separated with comma." + }, + "event": { + "follow": "Follow", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Subgift to community", + "tip": "Tip", + "raid": "Raid", + "custom": "Custom", + "promo": "Promo", + "rewardredeem": "Reward Redeem" + }, + "title": { + "name": "Variant name", + "placeholder": "Set your variant name" + }, + "variant": { + "name": "Variant occurence" + }, + "filter": { + "name": "Filter", + "operator": "Operator", + "rule": "Rule", + "addRule": "Add rule", + "addGroup": "Add group", + "comparator": "Comparator", + "value": "Value", + "valueSplitByComma": "Values split by comma (e.g. val1, val2)", + "isEven": "is even", + "isOdd": "is odd", + "lessThan": "less than", + "lessThanOrEqual": "less than or equal", + "contain": "contains", + "contains": "contains", + "equal": "equal", + "notEqual": "not equal", + "present": "is present", + "includes": "includes", + "greaterThan": "greater than", + "greaterThanOrEqual": "greater than or equal", + "noFilter": "no filter" + }, + "speed": { + "name": "Speed" + }, + "maxTimeToDecrypt": { + "name": "Max time to decrypt" + }, + "characters": { + "name": "Characters" + }, + "random": "Random", + "exact-amount": "Exact amount", + "greater-than-or-equal-to-amount": "Greater than or equal to amount", + "tier-exact-amount": "Tier is exactly", + "tier-greater-than-or-equal-to-amount": "Tier is higher or equal to", + "months-exact-amount": "Months amount is exactly", + "months-greater-than-or-equal-to-amount": "Months amount is higher or equal to", + "gifts-exact-amount": "Gifts amount is exactly", + "gifts-greater-than-or-equal-to-amount": "Gifts amount is higher or equal to", + "very-rarely": "Very rarely", + "rarely": "Rarely", + "default": "Default", + "frequently": "Frequently", + "very-frequently": "Very frequently", + "exclusive": "Exclusive", + "messageTemplate": { + "name": "Message template", + "placeholder": "Set your message template", + "help": "Available variables: {name}, {amount} (cheers, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). If | is added (see promo) then it will show those values in sequence." + }, + "ttsTemplate": { + "name": "TTS template", + "placeholder": "Set your TTS template", + "help": "Available variables: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Animation text" + }, + "animationType": { + "name": "Type of animation" + }, + "animationIn": { + "name": "Animation in" + }, + "animationOut": { + "name": "Animation out" + }, + "alertDurationInMs": { + "name": "Alert duration" + }, + "alertTextDelayInMs": { + "name": "Alert text delay" + }, + "layoutPicker": { + "name": "Layout" + }, + "loop": { + "name": "Play on loop" + }, + "scale": { + "name": "Scale" + }, + "translateY": { + "name": "Move -Up / +Down" + }, + "translateX": { + "name": "Move -Left / +Right" + }, + "image": { + "name": "Image / Video(.webm)", + "setting": "Image / Video(.webm) settings" + }, + "sound": { + "name": "Sound", + "setting": "Sound settings" + }, + "soundVolume": { + "name": "Alert volume" + }, + "enableAdvancedMode": "Enable advanced mode", + "font": { + "setting": "Font settings", + "name": "Font family", + "overrideGlobal": "Override global font settings", + "align": { + "name": "Alignment", + "left": "Left", + "center": "Center", + "right": "Right" + }, + "size": { + "name": "Font size" + }, + "weight": { + "name": "Font weight" + }, + "borderPx": { + "name": "Font border" + }, + "borderColor": { + "name": "Font border color" + }, + "color": { + "name": "Font color" + }, + "highlightcolor": { + "name": "Font highlight color" + } + }, + "minAmountToShow": { + "name": "Minimal amount to show" + }, + "minAmountToPlay": { + "name": "Minimal amount to play" + }, + "allowEmotes": { + "name": "Allow emotes" + }, + "message": { + "setting": "Message settings" + }, + "voice": "Voice", + "keepAlertShown": "Alert keeps visible during TTS", + "skipUrls": "Skip URLs during TTS", + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "test": "Test", + "tts": { + "setting": "TTS settings" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/registry/goals.json b/backend/locales/en/ui/registry/goals.json new file mode 100644 index 000000000..8c486828d --- /dev/null +++ b/backend/locales/en/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Add Goal Group", + "addGoal": "Add Goal", + "newGoal": "new Goal", + "newGoalGroup": "new Goal Group", + "goals": "Goals", + "general": "General", + "display": "Display", + "fontSettings": "Font Settings", + "barSettings": "Bar Settings", + "selectGoalOnLeftSide": "Select or add goal on left side", + "input": { + "description": { + "title": "Description" + }, + "goalAmount": { + "title": "Goal Amount" + }, + "countBitsAsTips": { + "title": "Count Bits as Tips" + }, + "currentAmount": { + "title": "Current Amount" + }, + "endAfter": { + "title": "End After" + }, + "endAfterIgnore": { + "title": "Goal will not expire" + }, + "borderPx": { + "title": "Border", + "help": "Border size is in pixels" + }, + "barHeight": { + "title": "Bar Height", + "help": "Bar height is in pixels" + }, + "color": { + "title": "Color" + }, + "borderColor": { + "title": "Border Color" + }, + "backgroundColor": { + "title": "Background Color" + }, + "type": { + "title": "Type" + }, + "nameGroup": { + "title": "Name of this goal group" + }, + "name": { + "title": "Name of this goal" + }, + "displayAs": { + "title": "Display as", + "help": "Sets how goal group will be shown" + }, + "durationMs": { + "title": "Duration", + "help": "This value is in milliseconds", + "placeholder": "How long goal should be shown" + }, + "animationInMs": { + "title": "Animation In duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation In duration" + }, + "animationOutMs": { + "title": "Animation Out duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation Out duration" + }, + "interval": { + "title": "What interval to count" + }, + "spaceBetweenGoalsInPx": { + "title": "Space between goals", + "help": "This value is in pixels", + "placeholder": "Set your space between goals" + } + }, + "groupSettings": "Group Settings" +} \ No newline at end of file diff --git a/backend/locales/en/ui/registry/overlays.json b/backend/locales/en/ui/registry/overlays.json new file mode 100644 index 000000000..f56199cb8 --- /dev/null +++ b/backend/locales/en/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Create new overlay link mapping", + "emptyMapping": "No overlay link mapping were created yet.", + "allowedIPs": { + "name": "Allowed IPs", + "help": "Allow access from set IPs separated by new line" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/registry/plugins.json b/backend/locales/en/ui/registry/plugins.json new file mode 100644 index 000000000..00eb1444f --- /dev/null +++ b/backend/locales/en/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "This node needs to be linked with listeners with sender attributes" + }, + "filter": { + "permission": { + "name": "Permission filter" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Event listener", + "type": { + "twitchChatMessage": "Twitch chat message", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat cleared", + "twitchCommand": "Twitch command", + "twitchFollow": "New Twitch follower", + "twitchSubscription": "New Twitch subscription", + "twitchSubgift": "New Twitch subscription gift", + "twitchSubcommunitygift": "New Twitch subscription community gift", + "twitchResub": "New Twitch recurring subscription", + "twitchGameChanged": "Twitch category changed", + "twitchStreamStarted": "Twitch stream started", + "twitchStreamStopped": "Twitch stream stopped", + "twitchRewardRedeem": "Twitch reward redeemed", + "twitchRaid": "Twitch raid incoming", + "tip": "Tipped by user", + "botStarted": "Bot started" + }, + "command": { + "add-parameter": "Add parameter", + "parameters": "Parameters", + "order-is-important": "order is important" + } + }, + "others": { + "idle": { + "name": "Idle" + } + }, + "output": { + "log": { + "name": "Log message" + }, + "timeout-user": { + "name": "Timeout user" + }, + "ban-user": { + "name": "Ban user" + }, + "send-twitch-message": { + "name": "Send Twitch Message" + } + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/registry/randomizer.json b/backend/locales/en/ui/registry/randomizer.json new file mode 100644 index 000000000..5f728918b --- /dev/null +++ b/backend/locales/en/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Add Randomizer", + "form": { + "name": "Name", + "command": "Command", + "permission": "Command permission", + "simple": "Simple", + "tape": "Tape", + "wheelOfFortune": "Wheel of Fortune", + "type": "Type", + "options": "Options", + "optionsAreEmpty": "Options are empty.", + "color": "Color", + "numOfDuplicates": "No. of duplicates", + "minimalSpacing": "Minimal spacing", + "groupUp": "Group Up", + "ungroup": "Ungroup", + "groupedWithOptionAbove": "Grouped with option above", + "generatedOptionsPreview": "Preview of generated options", + "probability": "Probability", + "tick": "Tick sound during spin" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/registry/textoverlay.json b/backend/locales/en/ui/registry/textoverlay.json new file mode 100644 index 000000000..03e974b69 --- /dev/null +++ b/backend/locales/en/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Create new text overlay", + "title": "text overlay", + "name": { + "placeholder": "Set your text overlay name" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/stats/commandcount.json b/backend/locales/en/ui/stats/commandcount.json new file mode 100644 index 000000000..e6fd27f4c --- /dev/null +++ b/backend/locales/en/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Command", + "hour": "Hour", + "day": "Day", + "week": "Week", + "month": "Month", + "year": "Year", + "total": "Total" +} \ No newline at end of file diff --git a/backend/locales/en/ui/systems/checklist.json b/backend/locales/en/ui/systems/checklist.json new file mode 100644 index 000000000..eac70e101 --- /dev/null +++ b/backend/locales/en/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "itemsArray": "List" + }, + "check": "Checklist" +} \ No newline at end of file diff --git a/backend/locales/en/ui/systems/howlongtobeat.json b/backend/locales/en/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..a9dcc7f7a --- /dev/null +++ b/backend/locales/en/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Status" + }, + "empty": "No games were tracked yet.", + "emptyAfterSearch": "No tracked games were found by your search for \"$search\".", + "when": "When streamed", + "time": "Tracked time", + "overallTime": "Overall time", + "offset": "Offset of tracked time", + "main": "Main", + "extra": "Main+Extra", + "completionist": "Completionist", + "game": "Tracked game", + "startedAt": "Tracking started at", + "updatedAt": "Last update", + "showHistory": "Show history ($count)", + "hideHistory": "Hide history ($count)", + "searchToAddNewGame": "Search to add new game to track" +} \ No newline at end of file diff --git a/backend/locales/en/ui/systems/keywords.json b/backend/locales/en/ui/systems/keywords.json new file mode 100644 index 000000000..9e725400f --- /dev/null +++ b/backend/locales/en/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "New Keyword", + "empty": "No keywords were created yet.", + "emptyAfterSearch": "No keywords were found by your search for \"$search\".", + "keyword": { + "name": "Keyword / Regular Expression", + "placeholder": "Set your keyword or regular expression to trigger keyword.", + "help": "You can use regexp (case insensitive) to use keywords, e.g. hello.*|hi" + }, + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "no-responses-set": "No responses", + "addResponse": "Add response", + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/systems/levels.json b/backend/locales/en/ui/systems/levels.json new file mode 100644 index 000000000..91bcaa02b --- /dev/null +++ b/backend/locales/en/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Status", + "conversionRate": "Conversion rate 1 XP for x Points", + "firstLevelStartsAt": "First level starts at XP", + "nextLevelFormula": { + "title": "Next level calculation formula", + "help": "Available variables: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "Levels example will be refreshed on save", + "xpName": "Name", + "interval": "Minutes interval to add xp to online users when stream online", + "offlineInterval": "Minutes interval to add xp to online users when stream offline", + "messageInterval": "How many messages to add xp", + "messageOfflineInterval": "How many messages to add xp when stream offline", + "perInterval": "How many xp to add per online interval", + "perOfflineInterval": "How many xp to add per offline interval", + "perMessageInterval": "How many xp to add per message interval", + "perMessageOfflineInterval": "How many xp to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/systems/polls.json b/backend/locales/en/ui/systems/polls.json new file mode 100644 index 000000000..f31a08052 --- /dev/null +++ b/backend/locales/en/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Total votes", + "totalPoints": "Total points", + "closedAt": "Closed at", + "activeFor": "Active for" +} \ No newline at end of file diff --git a/backend/locales/en/ui/systems/scrim.json b/backend/locales/en/ui/systems/scrim.json new file mode 100644 index 000000000..6b719fc3b --- /dev/null +++ b/backend/locales/en/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "waitForMatchIdsInSeconds": { + "title": "Interval for putting match ID into chat", + "help": "Set in seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/systems/top.json b/backend/locales/en/ui/systems/top.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/en/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/en/ui/systems/userinfo.json b/backend/locales/en/ui/systems/userinfo.json new file mode 100644 index 000000000..c8bba8b80 --- /dev/null +++ b/backend/locales/en/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "formatSeparator": "Format separator", + "order": "Format", + "lastSeenFormat": { + "title": "Time format", + "help": "Possible formats at https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/es.json b/backend/locales/es.json new file mode 100644 index 000000000..fe6a6608f --- /dev/null +++ b/backend/locales/es.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "está cargado y", + "enabled": "habilitado", + "disabled": "deshabilitado", + "usage": "Uso", + "lang-selected": "El idioma del bot está establecido en español", + "refresh-panel": "Es necesario actualizar la interfaz para ver los cambios.", + "command-parse": "Lo siento, $sender, pero este comando no es correcto, usa", + "error": "Lo siento, $sender, pero algo salió mal!", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, la API no responde correctamente!", + "not-available": "no disponible" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "año|años", + "months": "mes|meses", + "days": "día|días", + "hours": "hora|horas", + "minutes": "minuto|minutos", + "seconds": "segundo|segundos", + "messages": "mensaje|mensajes", + "bits": "bit|bits", + "links": "link|links", + "entries": "entrada|entradas", + "empty": "vacío", + "isRegistered": "$sender, no puedes usar !$keyword, ¡porque ya está en uso para otra acción!" + }, + "clip": { + "notCreated": "Algo salió mal y no se creó el clip.", + "offline": "La transmisión está actualmente fuera de línea y no se puede crear el clip." + }, + "uptime": { + "online": "La transmisión ha estado online durante (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)", + "offline": "La transmisión ha estado offline durante (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)" + }, + "webpanel": { + "this-system-is-disabled": "Esta configuración está desactivada", + "or": "o", + "loading": "Cargando", + "this-may-take-a-while": "Este puede tardar un poco", + "display-as": "Mostrar como", + "go-to-admin": "Ingresar a Admin", + "go-to-public": "Ingresar a Público", + "logout": "Cerrar sesión", + "popout": "Popout", + "not-logged-in": "Sesión no iniciada", + "remove-widget": "Quitar el widget $name", + "join-channel": "Unir el bot al canal", + "leave-channel": "Quitar el bot del canal", + "set-default": "Establecer predeterminado", + "add": "Añadir", + "placeholders": { + "text-url-generator": "Pega tu texto o html para generar base64 abajo y la URL anterior", + "text-decode-base64": "Pega tu base64 para generar URL y texto arriba", + "creditsSpeed": "Define la velocidad de desplazamiento de créditos, más baja = más rápido" + }, + "timers": { + "title": "Temporizadores", + "timer": "Temporizador", + "messages": "mensajes", + "seconds": "segundos", + "badges": { + "enabled": "Habilitado", + "disabled": "Deshabilitado" + }, + "errors": { + "timer_name_must_be_compliant": "Este valor sólo puede contener a-zA-Z09_", + "this_value_must_be_a_positive_number_or_0": "El valor debe ser un número positivo o 0", + "value_cannot_be_empty": "El valor no puede estar vacío" + }, + "dialog": { + "timer": "Temporizador", + "name": "Nombre", + "tickOffline": "Debería marcar si se transmite sin conexión", + "interval": "Intervalo", + "responses": "Respuestas", + "messages": "Activar cada X Mensajes", + "seconds": "Activar cada X Segundos", + "title": { + "new": "Nuevo temporizador", + "edit": "Editar temporizador" + }, + "placeholders": { + "name": "Configure el nombre de su temporizador, solo puede contener estos caracteres a-zA-Z0-9_", + "messages": "Activar temporizador cada X mensajes", + "seconds": "Activar temporizador cada X segundos" + }, + "alerts": { + "success": "El temporizador se guardó correctamente .", + "fail": "Algo salió mal ." + } + }, + "buttons": { + "close": "Cerrar", + "save-changes": "Guardar cambios", + "disable": "Desactivar", + "enable": "Activar", + "edit": "Editar", + "delete": "Borrar", + "yes": "Si", + "no": "No" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Estás seguro de que deseas eliminar el temporizador" + } + }, + "events": { + "event": "Evento", + "noEvents": "No se encontraron eventos en la base de datos.", + "whatsthis": "¿Qué es esto?", + "myRewardIsNotListed": "¡Mi recompensa no está en la lista!", + "redeemAndClickRefreshToSeeReward": "Si le falta la recompensa creada en una lista, actualice haciendo clic en el icono de actualización.", + "badges": { + "enabled": "Activado", + "disabled": "Deshabilitado" + }, + "buttons": { + "test": "Probar", + "enable": "Activar", + "disable": "Inhabilitar", + "edit": "Editar", + "delete": "Borrar", + "yes": "Si", + "no": "No" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Estás seguro de que deseas eliminar el evento", + "example_of_user_object_data": "Ejemplo de datos de objetos de usuario" + }, + "errors": { + "command_must_start_with_!": "El comando debe comenzar con !", + "this_value_must_be_a_positive_number_or_0": "El valor debe ser un número positivo o 0", + "value_cannot_be_empty": "El valor no puede estar vacío" + }, + "dialog": { + "title": { + "new": "Nuevo detector de eventos", + "edit": "Editar detector de eventos" + }, + "placeholders": { + "name": "Establece el nombre del detector de eventos (si está vacío, se generará el nombre)" + }, + "alerts": { + "success": "El evento se ha guardado con éxito.", + "fail": "Algo salió mal ." + }, + "close": "Cerrar", + "save-changes": "Guardar cambios", + "event": "Evento", + "name": "Nombre", + "usable-events-variables": "Variables de eventos utilizables", + "settings": "Configuración", + "filters": "Filtros", + "operations": "Operaciones" + }, + "definitions": { + "taskId": { + "label": "Id de tarea" + }, + "filter": { + "label": "Filtro" + }, + "linkFilter": { + "label": "Filtro de superposición de enlaces", + "placeholder": "Si usa superposición, agregue el enlace o la identificación de su superposición" + }, + "hashtag": { + "label": "Hashtag o Palabra Clave", + "placeholder": "#TuHashtagAquí o palabra clave" + }, + "fadeOutXCommands": { + "label": "Desaparecer X Comandos", + "placeholder": "Número de comandos restados a cada intervalo de desvanecimiento" + }, + "fadeOutXKeywords": { + "label": "Desvanecimiento X Palabras clave", + "placeholder": "Número de palabras clave restadas en cada intervalo de atenuación" + }, + "fadeOutInterval": { + "label": "Intervalo de desvanecimiento (segundos)", + "placeholder": "Restar intervalo de desvanecimiento" + }, + "runEveryXCommands": { + "label": "Ejecutar cada X comandos", + "placeholder": "Número de comandos antes de que se active el evento" + }, + "runEveryXKeywords": { + "label": "Ejecutar cada X palabras clave", + "placeholder": "Número de palabras clave antes de que se active el evento Número de palabras clave antes de que se active el evento" + }, + "commandToWatch": { + "label": "Comando a ver", + "placeholder": "Establecer su !ComandoParaMirar" + }, + "keywordToWatch": { + "label": "Palabra clave para ver", + "placeholder": "Establecer su PalabraClaveParaMirar" + }, + "resetCountEachMessage": { + "label": "Restablecer el recuento de cada mensaje", + "true": "Reiniciar contador", + "false": "Mantener contador" + }, + "viewersAtLeast": { + "label": "Espectadores al menos", + "placeholder": "Cuántos espectadores como mínimo para activar el evento" + }, + "runInterval": { + "label": "Ejecutar Intervalo (0 = ejecutar una vez por stream)", + "placeholder": "Activar evento cada x segundos" + }, + "runAfterXMinutes": { + "label": "Ejecutar después de X minutos", + "placeholder": "Activar evento después de x minutos" + }, + "runEveryXMinutes": { + "label": "Ejecutar cada X minutos", + "placeholder": "Activar evento cada X minutos" + }, + "messageToSend": { + "label": "Mensaje para enviar", + "placeholder": "Configura tu mensaje" + }, + "channel": { + "label": "Canal", + "placeholder": "ID o nombre del canal" + }, + "timeout": { + "label": "Timeout", + "placeholder": "Establecer timeout en milisegundos" + }, + "timeoutType": { + "label": "Type of timeout", + "placeholder": "Set type of timeout" + }, + "command": { + "label": "Comando", + "placeholder": "Set your !command" + }, + "commandToRun": { + "label": "Comando para ejecutar", + "placeholder": "Configura tu !ComandoParaEjecutar" + }, + "isCommandQuiet": { + "label": "Mute command output" + }, + "urlOfSoundFile": { + "label": "Url Of Your Sound File", + "placeholder": "http://www.pathToYour.url/where/is/file.mp3" + }, + "emotesToExplode": { + "label": "Emotes To Explode", + "placeholder": "List of emotes to explode, e.g. Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Emotes To Firework", + "placeholder": "List of emotes to firework, e.g. Kappa PurpleHeart" + }, + "replay": { + "label": "Replay clip in overlay", + "true": "Will play in as replay in overlay/alerts", + "false": "Replay won't be played" + }, + "announce": { + "label": "Anuncio en el chat", + "true": "Will be announced", + "false": "Will not be announced" + }, + "hasDelay": { + "label": "Clip should have slight delay (to be closer what viewer see)", + "true": "Will have delay", + "false": "Will not have delay" + }, + "durationOfCommercial": { + "label": "Duration Of Commercial", + "placeholder": "Available durations - 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Custom variable to update" + }, + "numberToIncrement": { + "label": "Number to increment", + "placeholder": "" + }, + "value": { + "label": "Value", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Number to decrement", + "placeholder": "" + }, + "": "", + "reward": { + "label": "Reward", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Followed you", + "raid": "Raided you with $viewers raiders.", + "sub": "Subscribed to you with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "has been gifted subscription from $username", + "subcommunitygift": "Gifted subscriptions for community", + "resub": "Resubscribed with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Cheered you", + "tip": "Tipped you", + "tipToCharity": "donated to $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Twitch Prediction - Title", + "outcomes": "Twitch Prediction - Outcomes", + "locksAt": "Twitch Prediction - Locks At Date", + "winningOutcomeTitle": "Twitch Prediction - Winning outcome title", + "winningOutcomeTotalPoints": "Twitch Prediction - Winning outcome total points", + "winningOutcomePercentage": "Twitch Prediction - Winning outcome percentage", + "titleOfPoll": "Twitch Poll - Title", + "bitAmountPerVote": "Twitch Poll - Amount of bits to count as 1 vote", + "bitVotingEnabled": "Twitch Poll - Is bit voting enabled (boolean)", + "channelPointsAmountPerVote": "Twitch Poll - Amount of channel points to count as 1 vote", + "channelPointsVotingEnabled": "Twitch Poll - Is channel points voting enabled (boolean)", + "votes": "Twitch Poll - votes count", + "winnerChoice": "Twitch Poll - Winner choice", + "winnerPercentage": "Encuesta de Twitch - Porcentaje de elección del ganador", + "winnerVotes": "Encuesta de Twitch - Votos de elección del ganador", + "goal": "Objetivo", + "total": "Total", + "lastContributionTotal": "Última contribución - Total", + "lastContributionType": "Last Contribution - Type", + "lastContributionUserId": "Last Contribution - User ID", + "lastContributionUsername": "Última contribución - Nombre de usuario", + "level": "Level", + "topContributionsBitsTotal": "Contribución de bits principales - Total", + "topContributionsBitsUserId": "Principales Bits Contribution - ID de usuario", + "topContributionsBitsUsername": "Contribución de bits principales - nombre de usuario", + "topContributionsSubsTotal": "Contribución de suscripciones principales - Total", + "topContributionsSubsUserId": "Contribución principal de suscriptores - ID de usuario", + "topContributionsSubsUsername": "Contribución principal de suscriptores - nombre de usuario", + "sender": "Usuario que inició", + "title": "Título actual", + "game": "Current category", + "language": "Idioma actual de transmisión", + "viewers": "Número de espectadores actuales", + "hostViewers": "Raid viewers count", + "followers": "Current followers count", + "subscribers": "Current subscribers count", + "arg": "Argument", + "param": "Parameter (required)", + "touser": "Username parameter", + "!param": "Parameter (not required)", + "alias": "Alias", + "command": "Command", + "keyword": "Keyword", + "response": "Response", + "list": "Populated list", + "type": "Type", + "days": "Days", + "hours": "Hours", + "minutes": "Minutes", + "seconds": "Seconds", + "description": "Description", + "quiet": "Quiet (bool)", + "id": "ID", + "name": "Name", + "messages": "Messages", + "amount": "Amount", + "amountInBotCurrency": "Amount in bot currency", + "currency": "Currency", + "currencyInBot": "Currency in bot", + "pointsName": "Points name", + "points": "Points", + "rank": "Rank", + "nextrank": "Next rank", + "username": "Username", + "value": "Value", + "variable": "Variable", + "count": "Count", + "link": "Link (translated)", + "winner": "Winner", + "loser": "Loser", + "challenger": "Challenger", + "min": "Minimum", + "max": "Maximum", + "eligibility": "Eligibility", + "probability": "Probability", + "time": "Time", + "options": "Options", + "option": "Option", + "when": "When", + "diff": "Difference", + "users": "Users", + "user": "User", + "bank": "Bank", + "nextBank": "Next bank", + "cooldown": "Cooldown", + "tickets": "Tickets", + "ticketsName": "Tickets name", + "fromUsername": "From username", + "toUsername": "To username", + "items": "Items", + "bits": "Bits", + "subgifts": "Subgifts", + "subStreakShareEnabled": "Is substreak share enabled (true/false)", + "subStreak": "Current sub streak", + "subStreakName": "localized name of month (1 month, 2 months) for current sub strek", + "subCumulativeMonths": "Cumulative subscribe months", + "subCumulativeMonthsName": "localized name of month (1 month, 2 months) for cumulative subscribe months", + "message": "Message", + "reason": "Reason", + "target": "Target", + "duration": "Duration", + "method": "Method", + "tier": "Tier", + "months": "Months", + "monthsName": "localized name of month (1 month, 2 months)", + "oldGame": "Category before change", + "recipientObject": "Full recipient object", + "recipient": "Recipient", + "ytSong": "Current song on YouTube", + "spotifySong": "Current song on Spotify", + "latestFollower": "Latest Follower", + "latestSubscriber": "Latest Subscriber", + "latestSubscriberMonths": "Latest Subscriber cumulative months", + "latestSubscriberStreak": "Latest Subscriber months streak", + "latestTipAmount": "Latest Tip (amount)", + "latestTipCurrency": "Latest Tip (currency)", + "latestTipMessage": "Latest Tip (message)", + "latestTip": "Latest Tip (username)", + "toptip": { + "overall": { + "username": "Top Tip - overall (username)", + "amount": "Top Tip - overall (amount)", + "currency": "Top Tip - overall (currency)", + "message": "Top Tip - overall (message)" + }, + "stream": { + "username": "Top Tip - during stream (username)", + "amount": "Top Tip - during stream (amount)", + "currency": "Top Tip - during stream (currency)", + "message": "Top Tip - during stream (message)" + } + }, + "latestCheerAmount": "Latest Bits (amount)", + "latestCheerMessage": "Latest Bits (message)", + "latestCheer": "Latest Bits (username)", + "version": "Bot version", + "haveParam": "Have command parameter? (bool)", + "source": "Current source (twitch or discord)", + "userInput": "User input during reward redeem", + "isBotSubscriber": "Is bot subscriber (bool)", + "isStreamOnline": "Is stream online (bool)", + "uptime": "Uptime of stream", + "is": { + "moderator": "Is user mod? (bool)", + "subscriber": "Is user sub? (bool)", + "vip": "Is user vip? (bool)", + "newchatter": "Is user's first message? (bool)", + "follower": "Is user follower? (bool)", + "broadcaster": "Is user broadcaster? (bool)", + "bot": "Is user bot? (bool)", + "owner": "Is user bot owner? (bool)" + }, + "recipientis": { + "moderator": "Is recipient mod? (bool)", + "subscriber": "Is recipient sub? (bool)", + "vip": "Is recipient vip? (bool)", + "follower": "Is recipient follower? (bool)", + "broadcaster": "Is recipient broadcaster? (bool)", + "bot": "Is recipient bot? (bool)", + "owner": "Is recipient bot owner? (bool)" + }, + "sceneName": "Name of scene", + "inputName": "Name of input", + "inputMuted": "Mute state (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Others", + "currency": "Currency" + }, + "whispers": { + "title": "Whispers", + "toggle": { + "listener": "Listen commands on whisper", + "settings": "Whispers on settings change", + "raffle": "Whispers on raffle join", + "permissions": "Whispers on insufficient permissions", + "cooldowns": "Whispers on cooldown (if set as notify)" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Messages", + "follows": "Follows", + "subs": "Subs & Resubs", + "cheers": "Bits", + "responses": "Bot responses", + "whispers": "Whispers", + "bans": "Bans", + "timeouts": "Timeouts" + }, + "range": { + "day": "a day", + "week": "a week", + "month": "a month", + "year": "an year", + "all": "All time" + }, + "order": { + "asc": "Ascending", + "desc": "Descending" + }, + "labels": { + "order": "ORDER", + "range": "RANGE", + "filters": "FILTERS" + } + }, + "stats-panel": { + "show": "Show stats", + "hide": "Hide stats" + }, + "translations": "Custom translations", + "bot-responses": "Bot responses", + "duration": "Duration", + "viewers-reset-attributes": "Reset attributes", + "viewers-points-of-all-users": "Points of all users", + "viewers-watchtime-of-all-users": "Watch time of all users", + "viewers-messages-of-all-users": "Messages of all users", + "events-game-after-change": "category after change", + "events-game-before-change": "category before change", + "events-user-triggered-event": "user triggered event", + "events-method-used-to-subscribe": "method used to subscribe", + "events-months-of-subscription": "months of subscription", + "events-monthsName-of-subscription": "word 'month' by number (1 month, 2 months)", + "events-user-message": "user message", + "events-bits-user-sent": "bits user sent", + "events-reason-for-ban-timeout": "reason for ban/timeout", + "events-duration-of-timeout": "duration of timeout", + "events-duration-of-commercial": "duration of commercial", + "overlays-eventlist-resub": "resub", + "overlays-eventlist-subgift": "subgift", + "overlays-eventlist-subcommunitygift": "subcommunitygift", + "overlays-eventlist-sub": "sub", + "overlays-eventlist-follow": "follow", + "overlays-eventlist-cheer": "bits", + "overlays-eventlist-tip": "tip", + "overlays-eventlist-raid": "raid", + "requested-by": "Requested by", + "description": "Description", + "raffle-type": "Raffle type", + "raffle-type-keywords": "Only keyword", + "raffle-type-tickets": "With tickets", + "raffle-tickets-range": "Tickets range", + "video_id": "Video ID", + "highlights": "Highlights", + "cooldown-quiet-header": "Show cooldown message", + "cooldown-quiet-toggle-no": "Notify", + "cooldown-quiet-toggle-yes": "Won't notify", + "cooldown-moderators": "Moderators", + "cooldown-owners": "Owners", + "cooldown-subscribers": "Subscribers", + "cooldown-followers": "Followers", + "in-seconds": "in seconds", + "songs": "Songs", + "show-usernames-with-at": "Show usernames with @", + "send-message-as-a-bot": "Send message as a bot", + "chat-as-bot": "Chat (as bot)", + "product": "Product", + "optional": "optional", + "placeholder-search": "Search", + "placeholder-enter-product": "Enter product", + "placeholder-enter-keyword": "Enter keyword", + "credits": "Credits", + "fade-out-top": "fade up", + "fade-out-zoom": "fade zoom", + "global": "Global", + "user": "User", + "alerts": "Alerts", + "eventlist": "EventList", + "dashboard": "Dashboard", + "carousel": "Image Carousel", + "text": "Text", + "filter": "Filter", + "filters": "Filters", + "isUsed": "Is used", + "permissions": "Permissions", + "permission": "Permission", + "viewers": "Viewers", + "systems": "Systems", + "overlays": "Overlays", + "gallery": "Media gallery", + "aliases": "Aliases", + "alias": "Alias", + "command": "Command", + "cooldowns": "Cooldowns", + "title-template": "Title template", + "keyword": "Keyword", + "moderation": "Moderation", + "timer": "Timer", + "price": "Price", + "rank": "Rank", + "previous": "Previous", + "next": "Next", + "close": "Close", + "save-changes": "Save changes", + "saving": "Saving...", + "deleting": "Deleting...", + "done": "Done", + "error": "Error", + "title": "Title", + "change-title": "Change title", + "game": "category", + "tags": "Tags", + "change-game": "Change category", + "click-to-change": "click to change", + "uptime": "uptime", + "not-affiliate-or-partner": "Not affiliate/partner", + "not-available": "Not Available", + "max-viewers": "Max viewers", + "new-chatters": "New Chatters", + "chat-messages": "Chat messages", + "followers": "Followers", + "subscribers": "Subscribers", + "bits": "Bits", + "subgifts": "Subgifts", + "subStreak": "Current sub streak", + "subCumulativeMonths": "Cumulative subscribe months", + "tips": "Tips", + "tier": "Tier", + "status": "Status", + "add-widget": "Add widget", + "remove-dashboard": "Remove dashboard", + "close-bet-after": "Close bet after", + "refund": "refund", + "roll-again": "Roll again", + "no-eligible-participants": "No eligible participants", + "follower": "Follower", + "subscriber": "Subscriber", + "minutes": "minutes", + "seconds": "seconds", + "hours": "hours", + "months": "months", + "eligible-to-enter": "Eligible to enter", + "everyone": "Everyone", + "roll-a-winner": "Roll a winner", + "send-message": "Send Message", + "messages": "Messages", + "level": "Level", + "create": "Create", + "cooldown": "Cooldown", + "confirm": "Confirm", + "delete": "Delete", + "enabled": "Enabled", + "disabled": "Disabled", + "enable": "Enable", + "disable": "Disable", + "slug": "Slug", + "posted-by": "Posted by", + "time": "Time", + "type": "Type", + "response": "Response", + "cost": "Cost", + "name": "Name", + "playlist": "Playlist", + "length": "Length", + "volume": "Volume", + "start-time": "Start Time", + "end-time": "End Time", + "watched-time": "Watched time", + "currentsong": "Current song", + "group": "Group", + "followed-since": "Followed since", + "subscribed-since": "Subscribed since", + "username": "Username", + "hashtag": "Hashtag", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "Scopes", + "last-seen": "Last Seen", + "date": "Date", + "points": "Points", + "calendar": "Calendar", + "string": "string", + "interval": "Interval", + "number": "number", + "minimal-messages-required": "Minimal Messages Required", + "max-duration": "Max duration", + "shuffle": "Shuffle", + "song-request": "Song Request", + "format": "Format", + "available": "Available", + "one-record-per-line": "one record per line", + "on": "on", + "off": "off", + "search-by-username": "Search by username", + "widget-title-custom": "CUSTOM", + "widget-title-eventlist": "EVENTLIST", + "widget-title-chat": "CHAT", + "widget-title-queue": "QUEUE", + "widget-title-raffles": "RAFFLES", + "widget-title-social": "SOCIAL", + "widget-title-ytplayer": "MUSIC PLAYER", + "widget-title-monitor": "MONITOR", + "event": "event", + "operation": "operation", + "tweet-post-with-hashtag": "Tweet posted with hashtag", + "user-joined-channel": "user joined a channel", + "user-parted-channel": "user parted a channel", + "follow": "new follow", + "tip": "new tip", + "obs-scene-changed": "OBS scene changed", + "obs-input-mute-state-changed": "OBS input source mute state changed", + "unfollow": "unfollow", + "hypetrain-started": "Hype Train started", + "hypetrain-ended": "Hype Train ended", + "prediction-started": "Twitch Prediction started", + "prediction-locked": "Twitch Prediction locked", + "prediction-ended": "Twitch Prediction ended", + "poll-started": "Twitch Poll started", + "poll-ended": "Twitch Poll ended", + "hypetrain-level-reached": "Hype Train new level reached", + "subscription": "new subscription", + "subgift": "new subgift", + "subcommunitygift": "new sub given to community", + "resub": "user resubscribed", + "command-send-x-times": "command was send x times", + "keyword-send-x-times": "keyword was send x times", + "number-of-viewers-is-at-least-x": "number of viewers is at least x", + "stream-started": "stream started", + "reward-redeemed": "reward redeemed", + "stream-stopped": "stream stopped", + "stream-is-running-x-minutes": "stream is running x minutes", + "chatter-first-message": "first message of chatter", + "every-x-minutes-of-stream": "every x minutes of stream", + "game-changed": "category changed", + "cheer": "received bits", + "clearchat": "chat was cleared", + "action": "user sent /me", + "ban": "user was banned", + "raid": "your channel is raided", + "mod": "user is a new mod", + "timeout": "user was timeouted", + "create-a-new-event-listener": "Create a new event listener", + "send-discord-message": "send a discord message", + "send-chat-message": "send a twitch chat message", + "send-whisper": "send a whisper", + "run-command": "run a command", + "run-obswebsocket-command": "run an OBS Websocket command", + "do-nothing": "--- do nothing ---", + "count": "count", + "timestamp": "timestamp", + "message": "message", + "sound": "sound", + "emote-explosion": "emote explosion", + "emote-firework": "emote firework", + "quiet": "quiet", + "noisy": "noisy", + "true": "true", + "false": "false", + "light": "light theme", + "dark": "dark theme", + "gambling": "Gambling", + "seppukuTimeout": "Timeout for !seppuku", + "rouletteTimeout": "Timeout for !roulette", + "fightmeTimeout": "Timeout for !fightme", + "duelCooldown": "Cooldown for !duel", + "fightmeCooldown": "Cooldown for !fightme", + "gamblingCooldownBypass": "Bypass gambling cooldowns for mods/caster", + "click-to-highlight": "highlight", + "click-to-toggle-display": "toggle display", + "commercial": "commercial started", + "start-commercial": "run a commercial", + "bot-will-join-channel": "bot will join channel", + "bot-will-leave-channel": "bot will leave channel", + "create-a-clip": "create a clip", + "increment-custom-variable": "increment a custom variable", + "set-custom-variable": "set a custom variable", + "decrement-custom-variable": "decrement a custom variable", + "omit": "omit", + "comply": "comply", + "visible": "visible", + "hidden": "hidden", + "gamblingChanceToWin": "Chance to win !gamble", + "gamblingMinimalBet": "Minimal bet for !gamble", + "duelDuration": "Duration of !duel", + "duelMinimalBet": "Minimal bet for !duel" + }, + "raffles": { + "announceInterval": "Opened raffles will be announced every $value minute", + "eligibility-followers-item": "followers", + "eligibility-subscribers-item": "subscribers", + "eligibility-everyone-item": "everyone", + "raffle-is-running": "Raffle is running ($count $l10n_entries).", + "to-enter-raffle": "To enter type \"$keyword\". Raffle is opened for $eligibility.", + "to-enter-ticket-raffle": "To enter type \"$keyword <$min-$max>\". Raffle is opened for $eligibility.", + "added-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-raffle}", + "added-ticket-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "Your raffle messages will be deleted on join.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "Sorry, $sender, but you cannot create raffle without keyword", + "raffle-is-already-running": "Sorry, $sender, raffle is already running with keyword $keyword", + "no-raffle-is-currently-running": "$sender, no raffles without winners are currently running", + "no-participants-to-pick-winner": "$sender, nobody joined a raffle", + "raffle-winner-is": "Winner of raffle $keyword is $username! Win probability was $probability%!" + }, + "bets": { + "running": "$sender, bet is already opened! Bet options: $options. Use $command close 1-$maxIndex", + "notRunning": "No bet is currently opened, ask mods to open it!", + "opened": "New bet '$title' is opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "closeNotEnoughOptions": "$sender, you need to select winning option for bet close.", + "notEnoughOptions": "$sender, new bets needs at least 2 options!", + "info": "Bet '$title' is still opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "diffBet": "$sender, you already made a bet on $option and you cannot bet to different option!", + "undefinedBet": "Sorry, $sender, but this bet option doesn't exist, use $command to check usage", + "betPercentGain": "Bet percent gain per option was set to $value%", + "betCloseTimer": "Bets will be automatically closed after $valuemin", + "refund": "Bets were closed without a winning. All users are refunded!", + "notOption": "$sender, this option doesn't exist! Bet is not closed, check $command", + "closed": "Bets was closed and winning option was $option! $amount users won in total $points $pointsName!", + "timeUpBet": "I guess you are too late, $sender, your time for betting is up!", + "locked": "Betting time is up! No more bets.", + "zeroBet": "Oh boy, $sender, you cannot bet 0 $pointsName", + "lockedInfo": "Bet '$title' is still opened, but time for betting is up!", + "removed": "Betting time is up! No bets were sent -> automatically closing", + "error": "Sorry, $sender, this command is not correct! Use $command 1-$maxIndex . E.g. $command 0 100 will bet 100 points to item 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, alias $alias was not found in database", + "alias-was-edited": "$sender, alias $alias is changed to $command", + "alias-was-added": "$sender, alias $alias for $command was added", + "list-is-not-empty": "$sender, list of aliases: $list", + "list-is-empty": "$sender, list of aliases is empty", + "alias-was-enabled": "$sender, alias $alias was enabled", + "alias-was-disabled": "$sender, alias $alias was disabled", + "alias-was-concealed": "$sender, alias $alias was concealed", + "alias-was-exposed": "$sender, alias $alias was exposed", + "alias-was-removed": "$sender, alias $alias was removed", + "alias-group-set": "$sender, alias $alias was set to group $group", + "alias-group-unset": "$sender, alias $alias group was unset", + "alias-group-list": "$sender, list of aliases groups: $list", + "alias-group-list-aliases": "$sender, list of aliases in $group: $list", + "alias-group-list-enabled": "$sender, aliases in $group are enabled.", + "alias-group-list-disabled": "$sender, aliases in $group are disabled." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, command $command was not found in database", + "response-was-not-found": "$sender, response #$response of command $command was not found in database", + "command-was-edited": "$sender, command $command is changed to '$response'", + "command-was-added": "$sender, command $command was added", + "list-is-not-empty": "$sender, list of commands: $list", + "list-is-empty": "$sender, list of commands is empty", + "command-was-enabled": "$sender, command $command was enabled", + "command-was-disabled": "$sender, command $command was disabled", + "command-was-concealed": "$sender, command $command was concealed", + "command-was-exposed": "$sender, command $command was exposed", + "command-was-removed": "$sender, command $command was removed", + "response-was-removed": "$sender, response #$response of $command was removed", + "list-of-responses-is-empty": "$sender, $command have no responses or doesn't exists", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, keyword $keyword is ambiguous, use ID of keyword", + "keyword-was-not-found": "$sender, keyword $keyword was not found in database", + "response-was-not-found": "$sender, response #$response of keyword $keyword was not found in database", + "keyword-was-edited": "$sender, keyword $keyword is changed to '$response'", + "keyword-was-added": "$sender, keyword $keyword ($id) was added", + "list-is-not-empty": "$sender, list of keywords: $list", + "list-is-empty": "$sender, list of keywords is empty", + "keyword-was-enabled": "$sender, keyword $keyword was enabled", + "keyword-was-disabled": "$sender, keyword $keyword was disabled", + "keyword-was-removed": "$sender, keyword $keyword was removed", + "list-of-responses-is-empty": "$sender, $keyword have no responses or doesn't exists", + "response": "$keyword#$index ($permission) $after| $response" + }, + "points": { + "success": { + "undo": "$sender, points '$command' for $username was reverted ($updatedValue $updatedValuePointsLocale to $originalValue $originalValuePointsLocale).", + "set": "$username was set to $amount $pointsName", + "give": "$sender just gave his $amount $pointsName to $username", + "online": { + "positive": "All online users just received $amount $pointsName!", + "negative": "All online users just lost $amount $pointsName!" + }, + "all": { + "positive": "All users just received $amount $pointsName!", + "negative": "All users just lost $amount $pointsName!" + }, + "rain": "Make it rain! All online users just received up to $amount $pointsName!", + "add": "$username just received $amount $pointsName!", + "remove": "Ouch, $amount $pointsName was removed from $username!" + }, + "failed": { + "undo": "$sender, username wasn't found in database or user have no undo operations", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Sorry, $sender, you don't have $amount $pointsName to give it to $username", + "cannotGiveZeroPoints": "Sorry, $sender, you cannot give $amount $pointsName to $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username has currently $amount $pointsName. Your position is $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, playlist to import is empty", + "playlist-imported": "$sender, imported $imported and skipped $skipped to playlist", + "not-playing": "Not Playing", + "song-was-banned": "Song $name was banned and will never play again!", + "song-was-banned-timeout-message": "You've got timeout for posting banned song", + "song-was-unbanned": "Song was succesfully unbanned", + "song-was-not-banned": "This song was not banned", + "no-song-is-currently-playing": "No song is currently playing", + "current-song-from-playlist": "Current song is $name from playlist", + "current-song-from-songrequest": "Current song is $name requested by $username", + "songrequest-disabled": "Sorry, $sender, song requests are disabled", + "song-is-banned": "Sorry, $sender, but this song is banned", + "youtube-is-not-responding-correctly": "Sorry, $sender, but YouTube is sending unexpected responses, please try again later.", + "song-was-not-found": "Sorry, $sender, but this song was not found", + "song-is-too-long": "Sorry, $sender, but this song is too long", + "this-song-is-not-in-playlist": "Sorry, $sender, but this song is not in current playlist", + "incorrect-category": "Sorry, $sender, but this song must be music category", + "song-was-added-to-queue": "$sender, song $name was added to queue", + "song-was-added-to-playlist": "$sender, song $name was added to playlist", + "song-is-already-in-playlist": "$sender, song $name is already in playlist", + "song-was-removed-from-playlist": "$sender, song $name was removed from playlist", + "song-was-removed-from-queue": "$sender, your song $name was removed from queue", + "playlist-current": "$sender, current playlist is $playlist.", + "playlist-list": "$sender, available playlists: $list.", + "playlist-not-exist": "$sender, your requested playlist $playlist doesn't exist.", + "playlist-set": "$sender, you changed playlist to $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, price for $command was set to $amount $pointsName", + "price-was-unset": "$sender, price for $command was unset", + "price-was-not-found": "$sender, price for $command was not found", + "price-was-enabled": "$sender, price for $command was enabled", + "price-was-disabled": "$sender, price for $command was disabled", + "user-have-not-enough-points": "Sorry, $sender, but you don't have $amount $pointsName to use $command", + "user-have-not-enough-points-or-bits": "Sorry, $sender, but you don't have $amount $pointsName or redeem command by $bitsAmount bits to use $command", + "user-have-not-enough-bits": "Sorry, $sender, but you need to redeem command by $bitsAmount bits to use $command", + "list-is-empty": "$sender, list of prices is empty", + "list-is-not-empty": "$sender, list of prices: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, new rank $type $rank($hours$hlocale) was added", + "rank-was-edited": "$sender, rank for $type $hours$hlocale was changed to $rank", + "rank-was-removed": "$sender, rank for $type $hours$hlocale was removed", + "rank-already-exist": "$sender, there is already a rank for $type $hours$hlocale", + "rank-was-not-found": "$sender, rank for $type $hours$hlocale was not found", + "custom-rank-was-set-to-user": "$sender, you set $rank to $username", + "custom-rank-was-unset-for-user": "$sender, custom rank for $username was unset", + "list-is-empty": "$sender, no ranks was found", + "list-is-not-empty": "$sender, ranks list: $list", + "show-rank-without-next-rank": "$sender, you have $rank rank", + "show-rank-with-next-rank": "$sender, you have $rank rank. Next rank - $nextrank", + "user-dont-have-rank": "$sender, you don't have a rank yet" + }, + "followage": { + "success": { + "never": "$sender, $username is not a channel follower", + "time": "$sender, $username is following channel $diff" + }, + "successSameUsername": { + "never": "$sender, you are not follower of this channel", + "time": "$sender, you are following this channel for $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username is not a channel subscriber.", + "notNow": "$sender, $username is currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username is subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username is subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, you are not a channel subscriber.", + "notNow": "$sender, you are currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, you are subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, you are subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, I don't have data for $username account age", + "success": { + "withUsername": "$sender, account age for $username is $diff", + "withoutUsername": "$sender, your account age is $diff" + } + }, + "lastseen": { + "success": { + "never": "$username was never in this channel!", + "time": "$username was last seen at $when in this channel" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username watched this channel for $time hours" + }, + "failed": { + "parse": "{core.command-parse} !watched or !watched [username]" + } + }, + "permissions": { + "without-permission": "You don't have enough permissions for '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, user $username have $type immunity for $time seconds", + "user-have-immunity-parameterError": "$sender, parameter error. $command ", + "user-have-link-permit": "User $username can post a $count $link to chat", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "No links allowed, ask for !permit [$count warnings left]", + "user-is-warned-about-symbols": "No excessive symbols usage [$count warnings left]", + "user-is-warned-about-long-message": "Long messages are not allowed [$count warnings left]", + "user-is-warned-about-caps": "No excessive caps usage [$count warnings left]", + "user-is-warned-about-spam": "Spamming is not allowed [$count warnings left]", + "user-is-warned-about-color": "Italic and /me is not allowed [$count warnings left]", + "user-is-warned-about-emotes": "No emotes spamming [$count warnings left]", + "user-is-warned-about-forbidden-words": "No forbidden words [$count warnings left]", + "user-have-timeout-for-links": "No links allowed, ask for !permit", + "user-have-timeout-for-symbols": "No excessive symbols usage", + "user-have-timeout-for-long-message": "Long message are not allowed", + "user-have-timeout-for-caps": "No excessive caps usage", + "user-have-timeout-for-spam": "Spamming is not allowed", + "user-have-timeout-for-color": "Italic and /me is not allowed", + "user-have-timeout-for-emotes": "No emotes spamming", + "user-have-timeout-for-forbidden-words": "No forbidden words" + }, + "queue": { + "list": "$sender, current queue pool: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Sorry $sender, queue is currently closed", + "opened": "$sender were added into queue" + }, + "open": "Queue is currently OPENED! Join to queue with !queue join", + "close": "Queue is currently closed!", + "clear": "Queue were completely cleared", + "picked": { + "single": "This user was picked from queue: $users", + "multi": "These users were picked from queue: $users", + "none": "No users were found in queue" + } + }, + "marker": "Stream marker has been created at $time.", + "title": { + "current": "$sender, title of stream is '$title'.", + "change": { + "success": "$sender, title was set to: $title" + } + }, + "game": { + "current": "$sender, streamer is currently playing $game.", + "change": { + "success": "$sender, category was set to: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type cooldown for $command was set to $secondss", + "cooldown-was-unset": "$sender, cooldown for $command was unset", + "cooldown-triggered": "$sender, '$command' is on cooldown, remaining $secondss", + "cooldown-not-found": "$sender, cooldown for $command was not found", + "cooldown-was-enabled": "$sender, cooldown for $command was enabled", + "cooldown-was-disabled": "$sender, cooldown for $command was disabled", + "cooldown-was-enabled-for-moderators": "$sender, cooldown for $command was enabled for moderators", + "cooldown-was-disabled-for-moderators": "$sender, cooldown for $command was disabled for moderators", + "cooldown-was-enabled-for-owners": "$sender, cooldown for $command was enabled for owners", + "cooldown-was-disabled-for-owners": "$sender, cooldown for $command was disabled for owners", + "cooldown-was-enabled-for-subscribers": "$sender, cooldown for $command was enabled for subscribers", + "cooldown-was-disabled-for-subscribers": "$sender, cooldown for $command was disabled for subscribers", + "cooldown-was-enabled-for-followers": "$sender, cooldown for $command was enabled for followers", + "cooldown-was-disabled-for-followers": "$sender, cooldown for $command was disabled for followers" + }, + "timers": { + "id-must-be-defined": "$sender, response id must be defined.", + "id-or-name-must-be-defined": "$sender, response id or timer name must be defined.", + "name-must-be-defined": "$sender, timer name must be defined.", + "response-must-be-defined": "$sender, timer response must be defined.", + "cannot-set-messages-and-seconds-0": "$sender, you cannot set both messages and seconds to 0.", + "timer-was-set": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger", + "timer-was-set-with-offline-flag": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger even when stream is offline", + "timer-not-found": "$sender, timer (name: $name) was not found in database. Check timers with !timers list", + "timer-deleted": "$sender, timer $name and its responses was deleted.", + "timer-enabled": "$sender, timer (name: $name) was enabled", + "timer-disabled": "$sender, timer (name: $name) was disabled", + "timers-list": "$sender, timers list: $list", + "responses-list": "$sender, timer (name: $name) list", + "response-deleted": "$sender, response (id: $id) was deleted.", + "response-was-added": "$sender, response (id: $id) for timer (name: $name) was added - '$response'", + "response-not-found": "$sender, response (id: $id) was not found in database", + "response-enabled": "$sender, response (id: $id) was enabled", + "response-disabled": "$sender, response (id: $id) was disabled" + }, + "gambling": { + "duel": { + "bank": "$sender, current bank for $command is $points $pointsName", + "lowerThanMinimalBet": "$sender, minimal bet for $command is $points $pointsName", + "cooldown": "$sender, you cannot use $command for $cooldown $minutesName.", + "joined": "$sender, good luck with your dueling skills. You bet on yourself $points $pointsName!", + "added": "$sender really thinks he is better than others raising his bet to $points $pointsName!", + "new": "$sender is your new duel challenger! To participate use $command [points], you have $minutes $minutesName left to join.", + "zeroBet": "$sender, you cannot duel 0 $pointsName", + "notEnoughOptions": "$sender, you need to specify points to dueling", + "notEnoughPoints": "$sender, you don't have $points $pointsName to duel!", + "noContestant": "Only $winner have courage to join duel! Your bet of $points $pointsName are returned to you.", + "winner": "Congratulations to $winner! He is last man standing and he won $points $pointsName ($probability% with bet of $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender is trying his luck and pulled a trigger", + "alive": "$sender is alive! Nothing happened.", + "dead": "$sender's brain was splashed on the wall!", + "mod": "$sender is incompetent and completely missed his head!", + "broadcaster": "$sender is using blanks, boo!", + "timeout": "Roulette timeout set to $values" + }, + "gamble": { + "chanceToWin": "$sender, chance to win !gamble set to $value%", + "zeroBet": "$sender, you cannot gamble 0 $pointsName", + "minimalBet": "$sender, minimal bet for !gamble is set to $value", + "lowerThanMinimalBet": "$sender, minimal bet for !gamble is $points $pointsName", + "notEnoughOptions": "$sender, you need to specify points to gamble", + "notEnoughPoints": "$sender, you don't have $points $pointsName to gamble", + "win": "$sender, you WON! You now have $points $pointsName", + "winJackpot": "$sender, you hit JACKPOT! You won $jackpot $jackpotName in addition to your bet. You now have $points $pointsName", + "loseWithJackpot": "$sender, you LOST! You now have $points $pointsName. Jackpot increased to $jackpot $jackpotName", + "lose": "$sender, you LOST! You now have $points $pointsName", + "currentJackpot": "$sender, current jackpot for $command is $points $pointsName", + "winJackpotCount": "$sender, you won $count jackpots", + "jackpotIsDisabled": "$sender, jackpot is disabled for $command." + } + }, + "highlights": { + "saved": "$sender, highlight was saved for $hoursh$minutesm$secondss", + "list": { + "items": "$sender, list of saved highlights for latest stream: $items", + "empty": "$sender, no highlights were saved" + }, + "offline": "$sender, cannot save highlight, stream is offline" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "Bot won't send errors on insufficient permissions", + "false": "Bot won't send errors on insufficient permissions through whispers" + }, + "disableCooldownWhispers": { + "true": "Bot won't send cooldown notifications", + "false": "Bot will send cooldown notifications through whispers" + } + } + }, + "time": "Current time in streamer's timezone is $time", + "subs": "$sender, there is currently $onlineSubCount online subscribers. Last sub/resub was $lastSubUsername $lastSubAgo", + "followers": "$sender, last follow was $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, user $username is not ignored by bot" + }, + "added": "$sender, user $username is added to bot ignorelist", + "removed": "$sender, user $username is removed from bot ignorelist", + "ignored": "$sender, user $username is ignored by bot" + } + } + }, + "filters": { + "setVariable": "$sender, $variable was set to $value." + } +} diff --git a/backend/locales/es/api.clips.json b/backend/locales/es/api.clips.json new file mode 100644 index 000000000..21895e7a3 --- /dev/null +++ b/backend/locales/es/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "Clip was created and is available at $link" +} \ No newline at end of file diff --git a/backend/locales/es/core/permissions.json b/backend/locales/es/core/permissions.json new file mode 100644 index 000000000..b6ac08e99 --- /dev/null +++ b/backend/locales/es/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "List of your permissions:", + "excludeAddSuccessful": "$sender, you added $username to exclude list for permission $permissionName", + "excludeRmSuccessful": "$sender, you removed $username from exclude list for permission $permissionName", + "userNotFound": "$sender, user $username was not found in database.", + "permissionNotFound": "$sender, permission $userlevel was not found in database.", + "cannotIgnoreForCorePermission": "$sender, you cannot manually exclude user for core permission $userlevel" +} \ No newline at end of file diff --git a/backend/locales/es/games.heist.json b/backend/locales/es/games.heist.json new file mode 100644 index 000000000..86371a40f --- /dev/null +++ b/backend/locales/es/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, cops are still searching for last heist team. Try again after $cooldown.", + "copsCooldownMessage": "Alright guys, looks like police forces are eating donuts and we can get that sweet money!", + "entryMessage": "$sender has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type $command to enter.", + "lateEntryMessage": "$sender, heist is currently in progress!", + "entryInstruction": "$sender, type $command to enter.", + "levelMessage": "With this crew, we can heist $bank! Let's see if we can get enough crew to heist $nextBank", + "maxLevelMessage": "With this crew, we can heist $bank! It cannot be any better!", + "started": "Alright guys, check your equipment, this is what we trained for. This is not a game, this is real life. We will get money from $bank!", + "noUser": "Nobody joins a crew to heist.", + "singleUserSuccess": "$user was like a ninja. Nobody noticed missing money.", + "singleUserFailed": "$user failed to get rid of police and will be spending his time in jail.", + "result": { + "0": "Everyone was mercilessly obliterated. This is slaughter.", + "33": "Only 1/3rd of team get its money from heist.", + "50": "Half of heist team was killed or catched by police.", + "99": "Some loses of heist team is nothing of what remaining crew have in theirs pockets.", + "100": "God divinity, nobody is dead, everyone won!" + }, + "levels": { + "bankVan": "Bank van", + "cityBank": "City bank", + "stateBank": "State bank", + "nationalReserve": "National reserve", + "federalReserve": "Federal reserve" + }, + "results": "The heist payouts are: $users", + "andXMore": "and $count more..." +} \ No newline at end of file diff --git a/backend/locales/es/integrations/discord.json b/backend/locales/es/integrations/discord.json new file mode 100644 index 000000000..25176852b --- /dev/null +++ b/backend/locales/es/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "your account is not linked, use `$command`", + "all-your-links-were-deleted": "all your links were deleted", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender, this account was linked with $discordTag.", + "invalid-or-expired-token": "$sender, invalid or expired token.", + "help-message": "$sender, to link your account on Discord: 1. Go to Discord server and send $command in bot channel. | 2. Wait for PM from bot | 3. Send command from your Discord PM here in twitch chat.", + "started-at": "Started At", + "announced-by": "Announced by sogeBot", + "streamed-at": "Streamed At", + "link-whisper": "Hello $tag, to link this Discord account with your Twitch account on $broadcaster channel, go to , login to your account and send this command to chat \n\n\t\t`$command $id`\n\nNOTE: This expires in 10 minutes.", + "check-your-dm": "check your DMs for steps to link your account." +} \ No newline at end of file diff --git a/backend/locales/es/integrations/lastfm.json b/backend/locales/es/integrations/lastfm.json new file mode 100644 index 000000000..79a075396 --- /dev/null +++ b/backend/locales/es/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "Current song is $name" +} \ No newline at end of file diff --git a/backend/locales/es/integrations/obswebsocket.json b/backend/locales/es/integrations/obswebsocket.json new file mode 100644 index 000000000..1058ed4b6 --- /dev/null +++ b/backend/locales/es/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, there is no action set for id:$id!", + "ParameterError": "$sender, you need to specify id!", + "UnknownError": "$sender, something went wrong. Check bot logs for additional informations." + } +} \ No newline at end of file diff --git a/backend/locales/es/integrations/protondb.json b/backend/locales/es/integrations/protondb.json new file mode 100644 index 000000000..0e7df5a0f --- /dev/null +++ b/backend/locales/es/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | $rating rated | Native on $native | Details: $url", + "responseNg": "Rating for game $game was not found on ProtonDB.", + "responseNotFound": "Game $game was not found on ProtonDB." +} \ No newline at end of file diff --git a/backend/locales/es/integrations/pubg.json b/backend/locales/es/integrations/pubg.json new file mode 100644 index 000000000..1cc2a2623 --- /dev/null +++ b/backend/locales/es/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, expected one of these parameters: $list" +} \ No newline at end of file diff --git a/backend/locales/es/integrations/spotify.json b/backend/locales/es/integrations/spotify.json new file mode 100644 index 000000000..9085e33b0 --- /dev/null +++ b/backend/locales/es/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "Sorry, $sender, track was not found on spotify", + "song-requested": "$sender, you requested song $name from $artist", + "not-banned-song-not-playing": "$sender, no song is currently playing to ban.", + "song-banned": "$sender, song $name from $artist is banned.", + "song-unbanned": "$sender, song $name from $artist is unbanned.", + "song-not-found-in-banlist": "$sender, song by spotifyURI $uri was not found in ban list.", + "cannot-request-song-is-banned": "$sender, cannot request banned song $name from $artist.", + "cannot-request-song-from-unapproved-artist": "$sender, cannot request song from unapproved artist.", + "no-songs-found-in-history": "$sender, there is currently no song in history list.", + "return-one-song-from-history": "$sender, previous song was $name from $artist.", + "return-multiple-song-from-history": "$sender, $count previous songs were:", + "return-multiple-song-from-history-item": "$index - $name from $artist", + "song-notify": "Current playing song is $name by $artist." +} \ No newline at end of file diff --git a/backend/locales/es/integrations/tiltify.json b/backend/locales/es/integrations/tiltify.json new file mode 100644 index 000000000..aa574fb09 --- /dev/null +++ b/backend/locales/es/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, there are currently no active campaigns.", + "active_campaigns": "$sender, list of currently active campaigns:" +} \ No newline at end of file diff --git a/backend/locales/es/systems.quotes.json b/backend/locales/es/systems.quotes.json new file mode 100644 index 000000000..92025a17c --- /dev/null +++ b/backend/locales/es/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, quote $id '$quote' was added. (tags: $tags)", + "error": "$sender, $command is not correct or missing -quote parameter" + }, + "remove": { + "ok": "$sender, quote $id was successfully deleted.", + "error": "$sender, quote ID is missing.", + "not-found": "$sender, quote $id was not found." + }, + "show": { + "ok": "Quote $id by $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found.", + "not-found-by-tag": "$sender, no quotes with tag $tag was not found." + } + }, + "set": { + "ok": "$sender, quote $id tags were set. (tags: $tags)", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found." + } + }, + "list": { + "ok": "$sender, You can find quote list at http://$urlBase/public/#/quotes", + "is-localhost": "$sender, quote list url is not properly specified." + } +} \ No newline at end of file diff --git a/backend/locales/es/systems/antihateraid.json b/backend/locales/es/systems/antihateraid.json new file mode 100644 index 000000000..7ad602a98 --- /dev/null +++ b/backend/locales/es/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "This chat was set to $mode by $username to get rid of hate raid. Sorry for inconvenience!", + "mode": { + "0": "subs-only", + "1": "follow-only", + "2": "emotes-only" + } +} \ No newline at end of file diff --git a/backend/locales/es/systems/howlongtobeat.json b/backend/locales/es/systems/howlongtobeat.json new file mode 100644 index 000000000..0fcc12bd9 --- /dev/null +++ b/backend/locales/es/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game not found in db.", + "game": "$sender, $game | Main: $currentMain/$hltbMainh - $percentMain% | Main+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Completionist: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Main: $currentMainh | Main+Extra: $currentMainExtrah | Completionist: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/es/systems/levels.json b/backend/locales/es/systems/levels.json new file mode 100644 index 000000000..c2c9bb7f9 --- /dev/null +++ b/backend/locales/es/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, level: $currentLevel ($currentXP $xpName), $nextXP $xpName to next level.", + "changeXP": "$sender, you changed $xpName by $amount $xpName to $username.", + "notEnoughPointsToBuy": "Sorry $sender, but you don't have $points $pointsName to buy $amount $xpName for level $level.", + "XPBoughtByPoints": "$sender, you bought $amount $xpName with $points $pointsName and reached level $level.", + "somethingGetWrong": "$sender, something get wrong with your request." +} \ No newline at end of file diff --git a/backend/locales/es/systems/scrim.json b/backend/locales/es/systems/scrim.json new file mode 100644 index 000000000..45630d49b --- /dev/null +++ b/backend/locales/es/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Snipe match ($type) starting in $time $unit", + "go": "Starting now! Go!", + "putMatchIdInChat": "Please put your match ID in the chat => $command xxx", + "currentMatches": "Current Matches: $matches", + "stopped": "Snipe match was cancelled." +} \ No newline at end of file diff --git a/backend/locales/es/systems/top.json b/backend/locales/es/systems/top.json new file mode 100644 index 000000000..e0f7cb149 --- /dev/null +++ b/backend/locales/es/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Top $amount (watch time): ", + "tips": "Top $amount (tips): ", + "level": "Top $amount (level): ", + "points": "Top $amount (points): ", + "messages": "Top $amount (messages): ", + "followage": "Top $amount (followage): ", + "subage": "Top $amount (subage): ", + "submonths": "Top $amount (submonths): ", + "bits": "Top $amount (bits): ", + "gifts": "Top $amount (subgifts): " +} \ No newline at end of file diff --git a/backend/locales/es/ui.commons.json b/backend/locales/es/ui.commons.json new file mode 100644 index 000000000..22d3a3b5b --- /dev/null +++ b/backend/locales/es/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Additional settings", + "never": "never", + "reset": "reset", + "moveUp": "move up", + "moveDown": "move down", + "stop-if-executed": "stop, if executed", + "continue-if-executed": "continue, if executed", + "generate": "Generate", + "thumbnail": "Thumbnail", + "yes": "Yes", + "no": "No", + "show-more": "Show more", + "show-less": "Show less", + "allowed": "Allowed", + "disallowed": "Disallowed", + "back": "Back" +} diff --git a/backend/locales/es/ui.dialog.json b/backend/locales/es/ui.dialog.json new file mode 100644 index 000000000..15701cc6e --- /dev/null +++ b/backend/locales/es/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Edit", + "add": "Add" + }, + "position": { + "settings": "Position settings", + "anchorX": "Anchor X position", + "anchorY": "Anchor Y position", + "left": "Left", + "right": "Right", + "middle": "Middle", + "top": "Top", + "bottom": "Bottom", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Shift Right", + "shadowShiftDown": "Shift Down", + "shadowBlur": "Blur", + "shadowOpacity": "Opacity", + "color": "Color" + }, + "errors": { + "required": "This input cannot be empty.", + "minValue": "Lowest value of this input is $value." + }, + "buttons": { + "reorder": "Reorder", + "upload": { + "idle": "Upload", + "progress": "Uploading", + "done": "Uploaded" + }, + "cancel": "Cancel", + "close": "Close", + "test": { + "idle": "Test", + "progress": "Testing in progress", + "done": "Testing done" + }, + "saveChanges": { + "idle": "Save changes", + "invalid": "Cannot save changes", + "progress": "Saving changes", + "done": "Changes saved" + }, + "something-went-wrong": "Something went wrong", + "mark-to-delete": "Mark to delete", + "disable": "Disable", + "enable": "Enable", + "disabled": "Disabled", + "enabled": "Enabled", + "edit": "Edit", + "delete": "Delete", + "play": "Play", + "stop": "Stop", + "hold-to-delete": "Hold to delete", + "yes": "Yes", + "no": "No", + "permission": "Permission", + "group": "Group", + "visibility": "Visibility", + "reset": "Reset " + }, + "changesPending": "Your changes was not saved.", + "formNotValid": "Form is invalid.", + "nothingToShow": "Nothing to show here." +} \ No newline at end of file diff --git a/backend/locales/es/ui.menu.json b/backend/locales/es/ui.menu.json new file mode 100644 index 000000000..7361104a4 --- /dev/null +++ b/backend/locales/es/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Services", + "updater": "Updater", + "index": "Dashboard", + "core": "Bot", + "users": "Users", + "tmi": "TMI", + "ui": "UI", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "General", + "timers": "Timers", + "new": "New Item", + "keywords": "Keywords", + "customcommands": "Custom commands", + "botcommands": "Bot commands", + "commands": "Commands", + "events": "Events", + "ranks": "Ranks", + "songs": "Songs", + "modules": "Modules", + "viewers": "Viewers", + "alias": "Aliases", + "cooldowns": "Cooldowns", + "cooldown": "Cooldown", + "highlights": "Highlights", + "price": "Price", + "logs": "Logs", + "systems": "Systems", + "permissions": "Permissions", + "translations": "Custom translations", + "moderation": "Moderation", + "overlays": "Overlays", + "gallery": "Media gallery", + "games": "Games", + "spotify": "Spotify", + "integrations": "Integrations", + "customvariables": "Custom variables", + "registry": "Registry", + "quotes": "Quotes", + "settings": "Settings", + "commercial": "Commercial", + "bets": "Bets", + "points": "Points", + "raffles": "Raffles", + "queue": "Queue", + "playlist": "Playlist", + "bannedsongs": "Banned songs", + "spotifybannedsongs": "Spotify banned songs", + "duel": "Duel", + "fightme": "FightMe", + "seppuku": "Seppuku", + "gamble": "Gamble", + "roulette": "Roulette", + "heist": "Heist", + "oauth": "OAuth", + "socket": "Socket", + "carouseloverlay": "Carousel overlay", + "alerts": "Alerts", + "carousel": "Image carousel", + "clips": "Clips", + "credits": "Credits", + "emotes": "Emotes", + "stats": "Stats", + "text": "Text", + "currency": "Currency", + "eventlist": "Eventlist", + "clipscarousel": "Clips carousel", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Checklist", + "bot": "Bot", + "api": "API", + "manage": "Manage", + "top": "Top", + "goals": "Goals", + "userinfo": "User info", + "scrim": "Scrim", + "commandcount": "Command count", + "profiler": "Profiler", + "howlongtobeat": "How long to beat", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Randomizer", + "tips": "Tips", + "bits": "Bits", + "discord": "Discord", + "texttospeech": "Text To Speech", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "Levels", + "obswebsocket": "OBS Websocket", + "api-explorer": "API Explorer", + "emotescombo": "Emotes Combo", + "notifications": "Notifications", + "plugins": "Plugins", + "tts": "TTS" +} diff --git a/backend/locales/es/ui.page.settings.overlays.carousel.json b/backend/locales/es/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..7ca51081f --- /dev/null +++ b/backend/locales/es/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "options", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Are you sure to delete this image?" + }, + "button": { + "update": "Update", + "fix_your_errors_first": "Fix errors before save" + }, + "errors": { + "number_greater_or_equal_than_0": "Value must be a number >= 0", + "value_must_not_be_empty": "Value must not be empty" + }, + "titles": { + "waitBefore": "Wait before image show (in ms)", + "waitAfter": "Wait after image disappear (in ms)", + "duration": "How long image should be shown (in ms)", + "animationIn": "Animation In", + "animationOut": "Animation Out", + "animationInDuration": "Animation In duration (in ms)", + "animationOutDuration": "Animation Out duration (in ms)", + "showOnlyOncePerStream": "Show only once per stream" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.registry.customvariables.json b/backend/locales/es/ui.registry.customvariables.json new file mode 100644 index 000000000..eab91fde8 --- /dev/null +++ b/backend/locales/es/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "URLs", + "generateurl": "Generate new URL", + "show-examples": "show CURL examples", + "response": { + "show": "Show response after POST", + "name": "Response after variable set", + "default": "Default", + "default-placeholder": "Set your bot response", + "default-help": "Use $value to get new variable value", + "custom": "Custom", + "command": "Command" + }, + "useIfInCommand": "Use if you use variable in command. Will return only updated variable without response.", + "permissionToChange": "Permission to change", + "isReadOnly": "read-only in chat", + "isNotReadOnly": "can be changed through chat", + "no-variables-found": "No variables found", + "additional-info": "Additional info", + "run-script": "Run script", + "last-run": "Last run at", + "variable": { + "name": "Variable name", + "help": "Variable name must be unique, e.g. $_wins, $_loses, $_top3", + "placeholder": "Enter your unique variable name", + "error": { + "isNotUnique": "Variable must have unique name.", + "isEmpty": "Variable name must not be empty." + } + }, + "description": { + "name": "Description", + "help": "Optional description", + "placeholder": "Enter your optional description" + }, + "type": { + "name": "Type", + "error": { + "isNotSelected": "Please choose a variable type." + } + }, + "currentValue": { + "name": "Current value", + "help": "If type is set to Evaluated script, value cannot be manually changed" + }, + "usableOptions": { + "name": "Usable options", + "placeholder": "Enter, your, options, here", + "help": "Options, which can be used with this variable, example: SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "You need to set at least 1 value." + } + }, + "scriptToEvaluate": "Script to evaluate", + "runScript": { + "name": "Run script", + "error": { + "isNotSelected": "Please choose an option." + } + }, + "testCurrentScript": { + "name": "Test current script", + "help": "Click Test current script to see value in Current value input" + }, + "history": "History", + "historyIsEmpty": "History for this variable is empty!", + "warning": "Warning: All data of this variable will be discarded!", + "choose": "Choose...", + "types": { + "number": "Number", + "text": "Text", + "options": "Options", + "eval": "Script" + }, + "runEvery": { + "isUsed": "When variable is used" + } +} + diff --git a/backend/locales/es/ui.systems.antihateraid.json b/backend/locales/es/ui.systems.antihateraid.json new file mode 100644 index 000000000..d821c979d --- /dev/null +++ b/backend/locales/es/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Clear Chat", + "mode": "Mode", + "minFollowTime": "Minimum follow time", + "customAnnounce": "Customize announcement on anti hate raid enable" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.bets.json b/backend/locales/es/ui.systems.bets.json new file mode 100644 index 000000000..51b9de149 --- /dev/null +++ b/backend/locales/es/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "betPercentGain": "Add x% to bet payout each option" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.commercial.json b/backend/locales/es/ui.systems.commercial.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/es/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.cooldown.json b/backend/locales/es/ui.systems.cooldown.json new file mode 100644 index 000000000..064403519 --- /dev/null +++ b/backend/locales/es/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Notify as whisper", + "settings": { + "enabled": "Status", + "cooldownNotifyAsWhisper": "Whisper cooldown informations", + "cooldownNotifyAsChat": "Chat message cooldown informations", + "defaultCooldownOfCommandsInSeconds": "Default cooldown for commands (in seconds)", + "defaultCooldownOfKeywordsInSeconds": "Default cooldown for keywords (in seconds)" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.customcommands.json b/backend/locales/es/ui.systems.customcommands.json new file mode 100644 index 000000000..5c93eb931 --- /dev/null +++ b/backend/locales/es/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "No responses", + "addResponse": "Add response", + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.highlights.json b/backend/locales/es/ui.systems.highlights.json new file mode 100644 index 000000000..63ed31e83 --- /dev/null +++ b/backend/locales/es/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "urls": "Generated URLs" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.moderation.json b/backend/locales/es/ui.systems.moderation.json new file mode 100644 index 000000000..7c9c7f3e5 --- /dev/null +++ b/backend/locales/es/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Status", + "cListsEnabled": "Enforce the rule", + "cLinksEnabled": "Enforce the rule", + "cSymbolsEnabled": "Enforce the rule", + "cLongMessageEnabled": "Enforce the rule", + "cCapsEnabled": "Enforce the rule", + "cSpamEnabled": "Enforce the rule", + "cColorEnabled": "Enforce the rule", + "cEmotesEnabled": "Enforce the rule", + "cListsWhitelist": { + "title": "Allowed words", + "help": "To allow domains use \"domain:prtzl.io\"" + }, + "autobanMessages": "Autoban Messages", + "cListsBlacklist": "Forbidden words", + "cListsTimeout": "Timeout duration", + "cLinksTimeout": "Timeout duration", + "cSymbolsTimeout": "Timeout duration", + "cLongMessageTimeout": "Timeout duration", + "cCapsTimeout": "Timeout duration", + "cSpamTimeout": "Timeout duration", + "cColorTimeout": "Timeout duration", + "cEmotesTimeout": "Timeout duration", + "cWarningsShouldClearChat": "Should clear chat (will timeout for 1s)", + "cLinksIncludeSpaces": "Include spaces", + "cLinksIncludeClips": "Include clips", + "cSymbolsTriggerLength": "Trigger length of message", + "cLongMessageTriggerLength": "Trigger length of message", + "cCapsTriggerLength": "Trigger length of message", + "cSpamTriggerLength": "Trigger length of message", + "cSymbolsMaxSymbolsConsecutively": "Max symbols consecutively", + "cSymbolsMaxSymbolsPercent": "Max symbols %", + "cCapsMaxCapsPercent": "Max caps %", + "cSpamMaxLength": "Max length", + "cEmotesMaxCount": "Max count", + "cWarningsAnnounceTimeouts": "Announce timeouts in chat for everyone", + "cWarningsAllowedCount": "Warning count", + "cEmotesEmojisAreEmotes": "Treat Emojis as Emotes" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.points.json b/backend/locales/es/ui.systems.points.json new file mode 100644 index 000000000..b0b011374 --- /dev/null +++ b/backend/locales/es/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Status", + "name": { + "title": "Name", + "help": "Possible formats:
point|points
bod|4:body|bodu" + }, + "isPointResetIntervalEnabled": "Interval of points reset", + "resetIntervalCron": { + "name": "Cron interval", + "help": "CronTab generator" + }, + "interval": "Minutes interval to add points to online users when stream online", + "offlineInterval": "Minutes interval to add points to online users when stream offline", + "messageInterval": "How many messages to add points", + "messageOfflineInterval": "How many messages to add points when stream offline", + "perInterval": "How many points to add per online interval", + "perOfflineInterval": "How many points to add per offline interval", + "perMessageInterval": "How many points to add per message interval", + "perMessageOfflineInterval": "How many points to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.price.json b/backend/locales/es/ui.systems.price.json new file mode 100644 index 000000000..6dcce9ea2 --- /dev/null +++ b/backend/locales/es/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Trigger custom alerts on bit redeem", + "price": { + "name": "price", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.queue.json b/backend/locales/es/ui.systems.queue.json new file mode 100644 index 000000000..7edcb74b8 --- /dev/null +++ b/backend/locales/es/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Status", + "eligibilityAll": "All", + "eligibilityFollowers": "Followers", + "eligibilitySubscribers": "Subscribers" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.quotes.json b/backend/locales/es/ui.systems.quotes.json new file mode 100644 index 000000000..97f22bce8 --- /dev/null +++ b/backend/locales/es/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "We're sorry, no quotes were found in database.", + "new": "Add new quote", + "empty": "List of quotes is empty, create new quote.", + "emptyAfterSearch": "List of quotes is empty in searching for \"$search\"", + "quote": { + "name": "Quote", + "placeholder": "Set your quote here" + }, + "by": { + "name": "Quoted by" + }, + "tags": { + "name": "Tags", + "placeholder": "Set your tags here", + "help": "Comma-separated tags. Example: tag 1, tag 2, tag 3" + }, + "date": { + "name": "Date" + }, + "error": { + "isEmpty": "This value cannot be empty", + "atLeastOneTag": "You need to set at least one tag" + }, + "tag-filter": "Filtering by tag", + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status", + "urlBase": { + "title": "URL base", + "help": "You should use public endpoint for quotes, to be accessible by everyone" + } + } +} diff --git a/backend/locales/es/ui.systems.raffles.json b/backend/locales/es/ui.systems.raffles.json new file mode 100644 index 000000000..9b8075f19 --- /dev/null +++ b/backend/locales/es/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Subscribers luck" + }, + "settings": { + "enabled": "Status", + "announceNewEntries": { + "title": "Announce new entries", + "help": "If users joins raffle, announce message will be send to chat after while." + }, + "announceNewEntriesBatchTime": { + "title": "How long to wait before announce new entries (in seconds)", + "help": "Longer time will keep chat cleaner, entries will be aggregated together." + }, + "deleteRaffleJoinCommands": { + "title": "Delete user raffle join command", + "help": "This will delete user message if they use !yourraffle command. Should keep chat cleaner." + }, + "allowOverTicketing": { + "title": "Allow over ticketing", + "help": "Allow user join raffle with over ticket of his points. E.g. user have 10 points but can join with !raffle 100 which will use all of his points." + }, + "raffleAnnounceInterval": { + "title": "Announce interval", + "help": "Minutes" + }, + "raffleAnnounceMessageInterval": { + "title": "Announce message interval", + "help": "How many messages must be sent to chat until announce can be posted." + }, + "subscribersPercent": { + "title": "Additional subscribers luck", + "help": "in percents" + } + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.ranks.json b/backend/locales/es/ui.systems.ranks.json new file mode 100644 index 000000000..42a6861a6 --- /dev/null +++ b/backend/locales/es/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "New Rank", + "empty": "No ranks were created yet.", + "emptyAfterSearch": "No ranks were found by your search for \"$search\".", + "rank": { + "name": "rank", + "placeholder": "" + }, + "value": { + "name": "hours", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.songs.json b/backend/locales/es/ui.systems.songs.json new file mode 100644 index 000000000..c1df88a05 --- /dev/null +++ b/backend/locales/es/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Status", + "volume": "Volume", + "calculateVolumeByLoudness": "Dynamic volume by loudness", + "duration": { + "title": "Max song duration", + "help": "In minutes" + }, + "shuffle": "Shuffle", + "songrequest": "Play from song request", + "playlist": "Play from playlist", + "onlyMusicCategory": "Allow only category music", + "allowRequestsOnlyFromPlaylist": "Allow song requests only from current playlist", + "notify": "Send message on song change" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "startTime": "Start song at", + "endTime": "End song at", + "add_song": "Add song", + "add_or_import": "Add song or import from playlist", + "importing": "Importing", + "importing_done": "Importing Done", + "seconds": "Seconds", + "calculated": "Calculated", + "set_manually": "Set manually", + "bannedSongsEmptyAfterSearch": "No banned songs were found by your search for \"$search\".", + "emptyAfterSearch": "No songs were found by your search for \"$search\".", + "empty": "No songs were added yet.", + "bannedSongsEmpty": "No songs were added to banlist yet." +} \ No newline at end of file diff --git a/backend/locales/es/ui.systems.timers.json b/backend/locales/es/ui.systems.timers.json new file mode 100644 index 000000000..70bcf937f --- /dev/null +++ b/backend/locales/es/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "New Timer", + "empty": "No timers were created yet.", + "emptyAfterSearch": "No timers were found by your search for \"$search\".", + "add_response": "Add Response", + "settings": { + "enabled": "Status" + }, + "warning": "This action cannot be reverted!" +} \ No newline at end of file diff --git a/backend/locales/es/ui.widgets.customvariables.json b/backend/locales/es/ui.widgets.customvariables.json new file mode 100644 index 000000000..761875e3b --- /dev/null +++ b/backend/locales/es/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "No custom variables found, add at custom variables registry", + "add-variable-into-watchlist": "Add variable to watchlist", + "watchlist": "Watchlist" +} \ No newline at end of file diff --git a/backend/locales/es/ui.widgets.randomizer.json b/backend/locales/es/ui.widgets.randomizer.json new file mode 100644 index 000000000..17a70ebb9 --- /dev/null +++ b/backend/locales/es/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "No randomizer found, add at randomizer registry", + "add-randomizer-to-widget": "Add randomizer to widget" +} \ No newline at end of file diff --git a/backend/locales/es/ui/categories.json b/backend/locales/es/ui/categories.json new file mode 100644 index 000000000..fc4be8abb --- /dev/null +++ b/backend/locales/es/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Announcements", + "keys": "Keys", + "currency": "Currency", + "general": "General", + "settings": "Settings", + "commands": "Commands", + "bot": "Bot", + "channel": "Channel", + "connection": "Connection", + "chat": "Chat", + "graceful_exit": "Graceful exit", + "rewards": "Rewards", + "levels": "Levels", + "notifications": "Notifications", + "options": "Options", + "comboBreakMessages": "Combo Break Messages", + "hypeMessages": "Hype Messages", + "messages": "Messages", + "results": "Results", + "customization": "Customization", + "status": "Status", + "mapping": "Mapping", + "player": "Player", + "stats": "Stats", + "api": "API", + "token": "Token", + "text": "Text", + "custom_texts": "Custom texts", + "credits": "Credits", + "show": "Show", + "social": "Social", + "explosion": "Explosion", + "fireworks": "Fireworks", + "test": "Test", + "emotes": "Emotes", + "default": "Default", + "urls": "URLs", + "conversion": "Conversion", + "xp": "XP", + "caps_filter": "Caps filter", + "color_filter": "Italic (/me) filter", + "links_filter": "Links filter", + "symbols_filter": "Symbols filter", + "longMessage_filter": "Message length filter", + "spam_filter": "Spam filter", + "emotes_filter": "Emotes filter", + "warnings": "Warnings", + "reset": "Reset", + "reminder": "Reminder", + "eligibility": "Eligibility", + "join": "Join", + "luck": "Luck", + "lists": "Lists", + "me": "Me", + "emotes_combo": "Emotes combo", + "tmi": "tmi", + "oauth": "oauth", + "eventsub": "eventsub", + "rules": "rules" +} \ No newline at end of file diff --git a/backend/locales/es/ui/core/currency.json b/backend/locales/es/ui/core/currency.json new file mode 100644 index 000000000..4b62e85a2 --- /dev/null +++ b/backend/locales/es/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Main currency" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/core/general.json b/backend/locales/es/ui/core/general.json new file mode 100644 index 000000000..cfe609bf2 --- /dev/null +++ b/backend/locales/es/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Bot language", + "numberFormat": "Format of numbers in chat", + "gracefulExitEachXHours": { + "title": "Graceful exit each X hours", + "help": "0 - disabled" + }, + "shouldGracefulExitHelp": "Enabling of graceful exit is recommended if your bot is running endlessly on server. You should have bot running on pm2 (or similar service) or have it dockerized to ensure automatic bot restart. Bot won't gracefully exit when stream is online." + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/core/oauth.json b/backend/locales/es/ui/core/oauth.json new file mode 100644 index 000000000..76b48ccc9 --- /dev/null +++ b/backend/locales/es/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Owners", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "Username", + "channelUsername": "Username", + "botExpectedScopes": "Scopes", + "channelExpectedScopes": "Scopes" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/core/permissions.json b/backend/locales/es/ui/core/permissions.json new file mode 100644 index 000000000..07aec34c0 --- /dev/null +++ b/backend/locales/es/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Add new permission group", + "higherPermissionHaveAccessToLowerPermissions": "Higher Permission have access to lower permissions.", + "typeUsernameOrIdToSearch": "Type username or ID to search", + "typeUsernameOrIdToTest": "Type username or ID to test", + "noUsersWereFound": "No users were found.", + "noUsersManuallyAddedToPermissionYet": "No users were manually added to permission yet.", + "done": "Done", + "previous": "Previous", + "next": "Next", + "loading": "loading", + "permissionNotFoundInDatabase": "Permission not found in database, please save before testing user.", + "userHaveNoAccessToThisPermissionGroup": "User $username DOESN'T have access to this permission group.", + "userHaveAccessToThisPermissionGroup": "User $username HAVE access to this permission group.", + "accessDirectlyThrough": "Direct access through", + "accessThroughHigherPermission": "Access through higher permission", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Something went wrong, user $username was not found in bot database.", + "permissionsGroups": "Permissions Groups", + "allowHigherPermissions": "Allow access through higher permission", + "type": "Type", + "value": "Value", + "watched": "Watched time in hours", + "followtime": "Follow time in months", + "points": "Points", + "tips": "Tips", + "bits": "Bits", + "messages": "Messages", + "subtier": "Sub Tier (1, 2, or 3)", + "subcumulativemonths": "Sub cumulative months", + "substreakmonths": "Current sub streak", + "ranks": "Current rank", + "level": "Current level", + "isLowerThan": "is lower than", + "isLowerThanOrEquals": "is lower than or equals", + "equals": "equals", + "isHigherThanOrEquals": "is higher than or equals", + "isHigherThan": "is higher than", + "addFilter": "Add filter", + "selectPermissionGroup": "Select permission group", + "settings": "Settings", + "name": "Name", + "baseUsersSet": "Base set of users", + "manuallyAddedUsers": "Manually added users", + "manuallyExcludedUsers": "Manually excluded users", + "filters": "Filters", + "testUser": "Test user", + "none": "- none -", + "casters": "Casters", + "moderators": "Moderators", + "subscribers": "Subscribers", + "vip": "VIP", + "viewers": "Viewers", + "followers": "Followers" +} \ No newline at end of file diff --git a/backend/locales/es/ui/core/socket.json b/backend/locales/es/ui/core/socket.json new file mode 100644 index 000000000..38f1582f4 --- /dev/null +++ b/backend/locales/es/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Purge All Authenticated Connection (yours as well)", + "accessTokenExpirationTime": "Access Token Expiration Time (seconds)", + "refreshTokenExpirationTime": "Refresh Token Expiration Time (seconds)", + "socketToken": { + "title": "Socket token", + "help": "This token will give you full admin access through sockets. Don't share!" + } + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/core/tmi.json b/backend/locales/es/ui/core/tmi.json new file mode 100644 index 000000000..49e6fd64d --- /dev/null +++ b/backend/locales/es/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Ignore list (ID or username)", + "showWithAt": "Show users with @", + "sendWithMe": "Send messages with /me", + "sendAsReply": "Send bot messages as replies", + "mute": "Bot is muted", + "whisperListener": "Listen on commands on whispers" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/core/tts.json b/backend/locales/es/ui/core/tts.json new file mode 100644 index 000000000..f4b8119bc --- /dev/null +++ b/backend/locales/es/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Service" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/core/twitch.json b/backend/locales/es/ui/core/twitch.json new file mode 100644 index 000000000..e056c6a1e --- /dev/null +++ b/backend/locales/es/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Create stream marker on event" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/core/ui.json b/backend/locales/es/ui/core/ui.json new file mode 100644 index 000000000..1c4778f3d --- /dev/null +++ b/backend/locales/es/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Default theme", + "domain": { + "title": "Domain", + "help": "Format without http/https: yourdomain.com or your.domain.com" + }, + "percentage": "Percentage difference for stats", + "shortennumbers": "Short format of numbers", + "showdiff": "Show difference", + "enablePublicPage": "Enable public page" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/core/updater.json b/backend/locales/es/ui/core/updater.json new file mode 100644 index 000000000..b93fa3738 --- /dev/null +++ b/backend/locales/es/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Automatically update if newer version available" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/errors.json b/backend/locales/es/ui/errors.json new file mode 100644 index 000000000..8b3e4bef8 --- /dev/null +++ b/backend/locales/es/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Unexpected errors during validation", + "isNotEmpty": "$property is required.", + "minLength": "$property must be longer than or equal to $constraint1 characters.", + "isPositive": "$property must be greater then 0", + "isCommand": "$property must start with !", + "isCommandOrCustomVariable": "$property must start with ! or $_", + "isCustomVariable": "$property must start with $_", + "min": "$property must be at least $constraint1", + "max": "$property must be lower or equal to $constraint1", + "isInt": "$property must be an integer", + "this_value_must_be_a_positive_number_and_greater_then_0": "This value must be a positive number or greater then 0", + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty", + "minLength_of_value_is": "Minimal length is $value.", + "this_currency_is_not_supported": "This currency is not supported", + "something_went_wrong": "Something went wrong", + "permission_must_exist": "Permission must exist", + "minValue_of_value_is": "Minimal value is $value", + "value_cannot_be": "Value cannot be $value.", + "invalid_format": "Invalid value format.", + "invalid_regexp_format": "This is not valid regex.", + "owner_and_broadcaster_oauth_is_not_set": "Owner and channel oauth is not set", + "channel_is_not_set": "Channel is not set", + "please_set_your_broadcaster_oauth_or_owners": "Please set your channel oauth or owners, or all users will have access to this dashboard and will be considered as casters.", + "new_update_available": "New update available", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "One of inputs must be set" +} \ No newline at end of file diff --git a/backend/locales/es/ui/games/duel.json b/backend/locales/es/ui/games/duel.json new file mode 100644 index 000000000..84789a717 --- /dev/null +++ b/backend/locales/es/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Status", + "cooldown": "Cooldown", + "duration": { + "title": "Duration", + "help": "Minutes" + }, + "minimalBet": "Minimal bet", + "bypassCooldownByOwnerAndMods": "Bypass cooldown by owner and mods" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/games/gamble.json b/backend/locales/es/ui/games/gamble.json new file mode 100644 index 000000000..6a78309aa --- /dev/null +++ b/backend/locales/es/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "minimalBet": "Minimal bet", + "chanceToWin": { + "title": "Chance to win", + "help": "Percent" + }, + "enableJackpot": "Enable jackpot", + "chanceToTriggerJackpot": "Chance to trigger jackpot in %", + "maxJackpotValue": "Maximum value of jackpot", + "lostPointsAddedToJackpot": "How many lost points should be added to jackpot in %" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/games/heist.json b/backend/locales/es/ui/games/heist.json new file mode 100644 index 000000000..e0ffc9feb --- /dev/null +++ b/backend/locales/es/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Heist", + "settings": { + "enabled": "Status", + "showMaxUsers": "Max users to show in payout", + "copsCooldownInMinutes": { + "title": "Cooldown between heists", + "help": "Minutes" + }, + "entryCooldownInSeconds": { + "title": "Time to entry heist", + "help": "Seconds" + }, + "started": "Heist start message", + "nextLevelMessage": "Message when next level is reached", + "maxLevelMessage": "Message when max level is reached", + "copsOnPatrol": "Response of bot when heist is still on cooldown", + "copsCooldown": "Bot announcement when heist can be started", + "singleUserSuccess": "Success message for one user", + "singleUserFailed": "Fail message for one user", + "noUser": "Message if no user participated" + }, + "message": "Message", + "winPercentage": "Win percentage", + "payoutMultiplier": "Payout multiplier", + "maxUsers": "Max users for level", + "percentage": "Percentage", + "noResultsFound": "No results found. Click button below to add new result.", + "noLevelsFound": "No levels found. Click button below to add new level." +} \ No newline at end of file diff --git a/backend/locales/es/ui/games/roulette.json b/backend/locales/es/ui/games/roulette.json new file mode 100644 index 000000000..65696d4e3 --- /dev/null +++ b/backend/locales/es/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout duration", + "help": "Seconds" + }, + "winnerWillGet": "How many points will be added on win", + "loserWillLose": "How many points will be lost on lose" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/games/seppuku.json b/backend/locales/es/ui/games/seppuku.json new file mode 100644 index 000000000..4d628e202 --- /dev/null +++ b/backend/locales/es/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout duration", + "help": "Seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/integrations/discord.json b/backend/locales/es/ui/integrations/discord.json new file mode 100644 index 000000000..baa645ab9 --- /dev/null +++ b/backend/locales/es/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Status", + "guild": "Guild", + "listenAtChannels": "Listen for commands on this channel", + "sendOnlineAnnounceToChannel": "Send online announcement to this channel", + "onlineAnnounceMessage": "Message in online announcement (can include mentions)", + "sendAnnouncesToChannel": "Setup sending of announcements to channels", + "deleteMessagesAfterWhile": "Delete message after while", + "clientId": "ClientId", + "token": "Token", + "joinToServerBtn": "Click to join bot to your server", + "joinToServerBtnDisabled": "Please save changes to enable bot join to your server", + "cannotJoinToServerBtn": "Set token and clientId to be able to join bot to your server", + "noChannelSelected": "no channel selected", + "noRoleSelected": "no role selected", + "noGuildSelected": "no guild selected", + "noGuildSelectedBox": "Select guild where bot should work and you'll see more settings", + "onlinePresenceStatusDefault": "Default Status", + "onlinePresenceStatusDefaultName": "Default Status Message", + "onlinePresenceStatusOnStream": "Status when Streaming", + "onlinePresenceStatusOnStreamName": "Status Message when Streaming", + "ignorelist": { + "title": "Ignore list", + "help": "username, username#0000 or userID" + } + } +} diff --git a/backend/locales/es/ui/integrations/donatello.json b/backend/locales/es/ui/integrations/donatello.json new file mode 100644 index 000000000..75bd1598d --- /dev/null +++ b/backend/locales/es/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Get your token at https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/integrations/donationalerts.json b/backend/locales/es/ui/integrations/donationalerts.json new file mode 100644 index 000000000..e37a63aee --- /dev/null +++ b/backend/locales/es/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Status", + "access_token": { + "title": "Access token", + "help": "Get your access token at https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Refresh token" + }, + "accessTokenBtn": "DonationAlerts access and refresh token generator" + } +} diff --git a/backend/locales/es/ui/integrations/kofi.json b/backend/locales/es/ui/integrations/kofi.json new file mode 100644 index 000000000..a8179bf1e --- /dev/null +++ b/backend/locales/es/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Verification token", + "help": "Get your verification token at https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "Webhook URL", + "help": "Set Webhook URL at https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL must have HTTPS", + "origin": "You cannot use localhost for webhooks" + } + } + } +} diff --git a/backend/locales/es/ui/integrations/lastfm.json b/backend/locales/es/ui/integrations/lastfm.json new file mode 100644 index 000000000..3acc84d8a --- /dev/null +++ b/backend/locales/es/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": "API key", + "username": "Username" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/integrations/obswebsocket.json b/backend/locales/es/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..e681b04e4 --- /dev/null +++ b/backend/locales/es/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Status", + "accessBy": { + "title": "Access by", + "help": "Direct - connect directly from a bot | Overlay - connect via overlay browser source" + }, + "address": "Address", + "password": "Password" + }, + "noSourceSelected": "No source selected", + "noSceneSelected": "No scene selected", + "empty": "No action sets were created yet.", + "emptyAfterSearch": "No action sets were found by your search for \"$search\".", + "command": "Command", + "new": "Create new OBS Websocket action set", + "actions": "Actions", + "name": { + "name": "Name" + }, + "mute": "Mute", + "unmute": "Unmute", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Wait X miliseconds" + }, + "Log": { + "name": "Log message" + }, + "StartRecording": { + "name": "StartRecording" + }, + "StopRecording": { + "name": "StopRecording" + }, + "PauseRecording": { + "name": "PauseRecording" + }, + "ResumeRecording": { + "name": "ResumeRecording" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/integrations/pubg.json b/backend/locales/es/ui/integrations/pubg.json new file mode 100644 index 000000000..166aef5d9 --- /dev/null +++ b/backend/locales/es/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "API Key", + "help": "Get your API Key at https://developer.pubg.com/" + }, + "platform": "Platform", + "playerName": "Player Name", + "playerId": "Player ID", + "seasonId": { + "title": "Season ID", + "help": "Current season ID is being fetch every hour." + }, + "rankedGameModeStatsCustomization": "Customized message for ranked stats", + "gameModeStatsCustomization": "Customized message for normal stats" + }, + "click_to_fetch": "Click to fetch", + "something_went_wrong": "Something went wrong!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Stats are automatically refreshed every 10 minutes.", + "player_stats_ranked": "Player stats (ranked)", + "player_stats": "Player stats" +} diff --git a/backend/locales/es/ui/integrations/qiwi.json b/backend/locales/es/ui/integrations/qiwi.json new file mode 100644 index 000000000..e5f7cb336 --- /dev/null +++ b/backend/locales/es/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "secretToken": { + "title": "Secret token", + "help": "Get secret token at Qiwi Donate dashboard settings->click show secret token" + } + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/integrations/responsivevoice.json b/backend/locales/es/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..fc98112a2 --- /dev/null +++ b/backend/locales/es/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Key", + "help": "Get your key at http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/integrations/spotify.json b/backend/locales/es/ui/integrations/spotify.json new file mode 100644 index 000000000..e3f885ae2 --- /dev/null +++ b/backend/locales/es/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Artists", + "settings": { + "enabled": "Status", + "songRequests": "Song Requests", + "fetchCurrentSongWhenOffline": { + "title": "Fetch current song when stream is offline", + "help": "It's advised to have this disabled to avoid reach API limits" + }, + "allowApprovedArtistsOnly": "Allow approved artists only", + "approvedArtists": { + "title": "Approved artists", + "help": "Name or SpotifyURI of artist, one item per line" + }, + "queueWhenOffline": { + "title": "Queue songs when stream is offline", + "help": "It's advised to have this disabled to avoid queueing when you are just listening music" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "Forced Device ID", + "help": "Empty = disabled, force spotify device ID to be used to queue songs. Check logs for current active device or use button when playing song for at least 10 seconds." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Format", + "help": "Available variables: $song, $artist, $artists" + }, + "username": "Authorized user", + "revokeBtn": "Revoke user authorization", + "authorizeBtn": "Authorize user", + "scopes": "Scopes", + "playlistToPlay": { + "title": "Spotify URI of main playlist", + "help": "If set, after request finished this playlist will continue" + }, + "continueOnPlaylistAfterRequest": "Continue on playing of playlist after song request", + "notify": "Send message on song change" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/integrations/streamelements.json b/backend/locales/es/ui/integrations/streamelements.json new file mode 100644 index 000000000..b983c17ff --- /dev/null +++ b/backend/locales/es/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "jwtToken": { + "title": "JWT token", + "help": "Get JWT token at StreamElements Channels setting and toggle Show secrets" + } + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/integrations/streamlabs.json b/backend/locales/es/ui/integrations/streamlabs.json new file mode 100644 index 000000000..a2c359f1b --- /dev/null +++ b/backend/locales/es/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "socketToken": { + "title": "Socket token", + "help": "Get socket token from streamlabs dashboard API settings->API tokens->Your Socket API Token" + }, + "accessToken": { + "title": "Access token", + "help": "Get your access token at https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "StreamLabs access token generator" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/integrations/tipeeestream.json b/backend/locales/es/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..880b7bcfe --- /dev/null +++ b/backend/locales/es/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "Api key", + "help": "Get socket token from tipeeestream dashboard -> API -> Your API Key" + } + } +} diff --git a/backend/locales/es/ui/integrations/twitter.json b/backend/locales/es/ui/integrations/twitter.json new file mode 100644 index 000000000..940fd5589 --- /dev/null +++ b/backend/locales/es/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "consumerKey": "Consumer Key (API Key)", + "consumerSecret": "Consumer Secret (API Secret)", + "accessToken": "Access Token", + "secretToken": "Access Token Secret" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/managers.json b/backend/locales/es/ui/managers.json new file mode 100644 index 000000000..21055ff3e --- /dev/null +++ b/backend/locales/es/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "User event history", + "hostAndRaidViewersCount": "Viewers: $value", + "receivedSubscribeFrom": "Received subscribe from $value", + "giftedSubscribeTo": "Gifted subscribe to $value" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/overlays/alerts.json b/backend/locales/es/ui/overlays/alerts.json new file mode 100644 index 000000000..2c72859cb --- /dev/null +++ b/backend/locales/es/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Cache gallery items", + "galleryCacheLimitInMb": "Max size of gallery item (in MB) to cache" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/overlays/clips.json b/backend/locales/es/ui/overlays/clips.json new file mode 100644 index 000000000..aa6555159 --- /dev/null +++ b/backend/locales/es/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Volume", + "cClipsFilter": "Clip filter", + "cClipsLabel": "Show 'clip' label" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/overlays/clipscarousel.json b/backend/locales/es/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..b50a0a71d --- /dev/null +++ b/backend/locales/es/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsTimeToNextClip": "Time to next clip (s)" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/overlays/credits.json b/backend/locales/es/ui/overlays/credits.json new file mode 100644 index 000000000..6b2f72805 --- /dev/null +++ b/backend/locales/es/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Speed", + "cCreditsAggregated": "Aggregated credits", + "cShowGameThumbnail": "Show game thumbnail", + "cShowFollowers": "Show followers", + "cShowRaids": "Show raids", + "cShowSubscribers": "Show subscribers", + "cShowSubgifts": "Show gifted subs", + "cShowSubcommunitygifts": "Show subs gifted to community", + "cShowResubs": "Show resubs", + "cShowCheers": "Show cheers", + "cShowClips": "Show clips", + "cShowTips": "Show tips", + "cTextLastMessage": "Last message", + "cTextLastSubMessage": "Last submessge", + "cTextStreamBy": "Streamed by", + "cTextFollow": "Follow by", + "cTextRaid": "Raided by", + "cTextCheer": "Cheer by", + "cTextSub": "Subscribe by", + "cTextResub": "Resub by", + "cTextSubgift": "Gifted subs", + "cTextSubcommunitygift": "Subs gifted to community", + "cTextTip": "Tips by", + "cClipsPeriod": "Time interval", + "cClipsCustomPeriodInDays": "Custom time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsShouldPlay": "Clips should be played", + "cClipsVolume": "Volume" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/overlays/emotes.json b/backend/locales/es/ui/overlays/emotes.json new file mode 100644 index 000000000..8a961ad91 --- /dev/null +++ b/backend/locales/es/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Delete cache", + "hypeMessagesEnabled": "Show hype messages in chat", + "btnTestExplosion": "Test emote explosion", + "btnTestEmote": "Test emote", + "btnTestFirework": "Test emote firework", + "cEmotesSize": "Emotes size", + "cEmotesMaxEmotesPerMessage": "Maximum of emotes per message", + "cEmotesMaxRotation": "Maximal rotation of emote", + "cEmotesOffsetX": "Maximal offset on X-axis", + "cEmotesAnimation": "Animation", + "cEmotesAnimationTime": "Animation duration", + "cExplosionNumOfEmotes": "No. of emotes", + "cExplosionNumOfEmotesPerExplosion": "No. of emotes per explosion", + "cExplosionNumOfExplosions": "No. of explosions", + "enableEmotesCombo": "Enable emotes combo", + "comboBreakMessages": "Combo break messages", + "threshold": "Threshold", + "noMessagesFound": "No messages found.", + "message": "Message", + "showEmoteInOverlayThreshold": "Minimal message threshold to show emote in overlay", + "hideEmoteInOverlayAfter": { + "title": "Hide emote in overlay after inactivity", + "help": "Will hide emote in overlay after certain time in seconds" + }, + "comboCooldown": { + "title": "Combo cooldown", + "help": "Cooldown of combo in seconds" + }, + "comboMessageMinThreshold": { + "title": "Minimal message threshold", + "help": "Minimal message threshold to count emotes as combo (until then won't trigger cooldown)" + }, + "comboMessages": "Combo messages" + }, + "hype": { + "5": "Let's go! We got $amountx $emote combo so far! SeemsGood", + "15": "Keep it going! Can we get more than $amountx $emote? TriHard" + }, + "message": { + "3": "$amountx $emote combo", + "5": "$amountx $emote combo SeemsGood", + "10": "$amountx $emote combo PogChamp", + "15": "$amountx $emote combo TriHard", + "20": "$sender ruined $amountx $emote combo! NotLikeThis" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/overlays/polls.json b/backend/locales/es/ui/overlays/polls.json new file mode 100644 index 000000000..da094ce9b --- /dev/null +++ b/backend/locales/es/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Theme", + "cDisplayHideAfterInactivity": "Hide on inactivity", + "cDisplayAlign": "Align", + "cDisplayInactivityTime": { + "title": "Inactivity after", + "help": "in miliseconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/overlays/texttospeech.json b/backend/locales/es/ui/overlays/texttospeech.json new file mode 100644 index 000000000..c61ee3567 --- /dev/null +++ b/backend/locales/es/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "You haven't properly set ResponsiveVoice key", + "voice": { + "title": "Voice", + "help": "If voices are not properly loading after ResponsiveVoice key update, try to refresh browser" + }, + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "triggerTTSByHighlightedMessage": "Text to Speech will be triggered by highlighted message" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/properties.json b/backend/locales/es/ui/properties.json new file mode 100644 index 000000000..e6243cf72 --- /dev/null +++ b/backend/locales/es/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Command", + "variableName": "Variable name", + "price": "Price (points)", + "priceBits": "Price (bits)", + "thisvalue": "This value", + "promo": { + "shoutoutMessage": "Shoutout message", + "enableShoutoutMessage": "Send shoutout message in chat" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/registry/alerts.json b/backend/locales/es/ui/registry/alerts.json new file mode 100644 index 000000000..d12319c25 --- /dev/null +++ b/backend/locales/es/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Enabled", + "testDlg": { + "alertTester": "Alert tester", + "command": "Command", + "username": "Username", + "recipient": "Recipient", + "message": "Message", + "tier": "Tier", + "amountOfViewers": "Amount of viewers", + "amountOfBits": "Amount of bits", + "amountOfGifts": "Amount of gifts", + "amountOfMonths": "Amount of months", + "amountOfTips": "Tip", + "event": "Event", + "service": "Service" + }, + "empty": "Alerts registry is empty, create new alerts.", + "emptyAfterSearch": "Alerts registry is empty in searching for \"$search\"", + "revertcode": "Revert code to defaults", + "name": { + "name": "Name", + "placeholder": "Set name of your alerts" + }, + "alertDelayInMs": { + "name": "Alert delay" + }, + "parryEnabled": { + "name": "Alert parries" + }, + "parryDelay": { + "name": "Alert parry delay" + }, + "profanityFilterType": { + "name": "Profanity filter", + "disabled": "Disabled", + "replace-with-asterisk": "Replace with asterisk", + "replace-with-happy-words": "Replace with happy words", + "hide-messages": "Hide messages", + "disable-alerts": "Disable alerts" + }, + "loadStandardProfanityList": "Load standard profanity list", + "customProfanityList": { + "name": "Custom profanity list", + "help": "Words should be separated with comma." + }, + "event": { + "follow": "Follow", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Subgift to community", + "tip": "Tip", + "raid": "Raid", + "custom": "Custom", + "promo": "Promo", + "rewardredeem": "Reward Redeem" + }, + "title": { + "name": "Variant name", + "placeholder": "Set your variant name" + }, + "variant": { + "name": "Variant occurence" + }, + "filter": { + "name": "Filter", + "operator": "Operator", + "rule": "Rule", + "addRule": "Add rule", + "addGroup": "Add group", + "comparator": "Comparator", + "value": "Value", + "valueSplitByComma": "Values split by comma (e.g. val1, val2)", + "isEven": "is even", + "isOdd": "is odd", + "lessThan": "less than", + "lessThanOrEqual": "less than or equal", + "contain": "contains", + "contains": "contains", + "equal": "equal", + "notEqual": "not equal", + "present": "is present", + "includes": "includes", + "greaterThan": "greater than", + "greaterThanOrEqual": "greater than or equal", + "noFilter": "no filter" + }, + "speed": { + "name": "Speed" + }, + "maxTimeToDecrypt": { + "name": "Max time to decrypt" + }, + "characters": { + "name": "Characters" + }, + "random": "Random", + "exact-amount": "Exact amount", + "greater-than-or-equal-to-amount": "Greater than or equal to amount", + "tier-exact-amount": "Tier is exactly", + "tier-greater-than-or-equal-to-amount": "Tier is higher or equal to", + "months-exact-amount": "Months amount is exactly", + "months-greater-than-or-equal-to-amount": "Months amount is higher or equal to", + "gifts-exact-amount": "Gifts amount is exactly", + "gifts-greater-than-or-equal-to-amount": "Gifts amount is higher or equal to", + "very-rarely": "Very rarely", + "rarely": "Rarely", + "default": "Default", + "frequently": "Frequently", + "very-frequently": "Very frequently", + "exclusive": "Exclusive", + "messageTemplate": { + "name": "Message template", + "placeholder": "Set your message template", + "help": "Available variables: {name}, {amount} (cheers, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). If | is added (see promo) then it will show those values in sequence." + }, + "ttsTemplate": { + "name": "TTS template", + "placeholder": "Set your TTS template", + "help": "Available variables: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Animation text" + }, + "animationType": { + "name": "Type of animation" + }, + "animationIn": { + "name": "Animation in" + }, + "animationOut": { + "name": "Animation out" + }, + "alertDurationInMs": { + "name": "Alert duration" + }, + "alertTextDelayInMs": { + "name": "Alert text delay" + }, + "layoutPicker": { + "name": "Layout" + }, + "loop": { + "name": "Play on loop" + }, + "scale": { + "name": "Scale" + }, + "translateY": { + "name": "Move -Up / +Down" + }, + "translateX": { + "name": "Move -Left / +Right" + }, + "image": { + "name": "Image / Video(.webm)", + "setting": "Image / Video(.webm) settings" + }, + "sound": { + "name": "Sound", + "setting": "Sound settings" + }, + "soundVolume": { + "name": "Alert volume" + }, + "enableAdvancedMode": "Enable advanced mode", + "font": { + "setting": "Font settings", + "name": "Font family", + "overrideGlobal": "Override global font settings", + "align": { + "name": "Alignment", + "left": "Left", + "center": "Center", + "right": "Right" + }, + "size": { + "name": "Font size" + }, + "weight": { + "name": "Font weight" + }, + "borderPx": { + "name": "Font border" + }, + "borderColor": { + "name": "Font border color" + }, + "color": { + "name": "Font color" + }, + "highlightcolor": { + "name": "Font highlight color" + } + }, + "minAmountToShow": { + "name": "Minimal amount to show" + }, + "minAmountToPlay": { + "name": "Minimal amount to play" + }, + "allowEmotes": { + "name": "Allow emotes" + }, + "message": { + "setting": "Message settings" + }, + "voice": "Voice", + "keepAlertShown": "Alert keeps visible during TTS", + "skipUrls": "Skip URLs during TTS", + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "test": "Test", + "tts": { + "setting": "TTS settings" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/registry/goals.json b/backend/locales/es/ui/registry/goals.json new file mode 100644 index 000000000..8c486828d --- /dev/null +++ b/backend/locales/es/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Add Goal Group", + "addGoal": "Add Goal", + "newGoal": "new Goal", + "newGoalGroup": "new Goal Group", + "goals": "Goals", + "general": "General", + "display": "Display", + "fontSettings": "Font Settings", + "barSettings": "Bar Settings", + "selectGoalOnLeftSide": "Select or add goal on left side", + "input": { + "description": { + "title": "Description" + }, + "goalAmount": { + "title": "Goal Amount" + }, + "countBitsAsTips": { + "title": "Count Bits as Tips" + }, + "currentAmount": { + "title": "Current Amount" + }, + "endAfter": { + "title": "End After" + }, + "endAfterIgnore": { + "title": "Goal will not expire" + }, + "borderPx": { + "title": "Border", + "help": "Border size is in pixels" + }, + "barHeight": { + "title": "Bar Height", + "help": "Bar height is in pixels" + }, + "color": { + "title": "Color" + }, + "borderColor": { + "title": "Border Color" + }, + "backgroundColor": { + "title": "Background Color" + }, + "type": { + "title": "Type" + }, + "nameGroup": { + "title": "Name of this goal group" + }, + "name": { + "title": "Name of this goal" + }, + "displayAs": { + "title": "Display as", + "help": "Sets how goal group will be shown" + }, + "durationMs": { + "title": "Duration", + "help": "This value is in milliseconds", + "placeholder": "How long goal should be shown" + }, + "animationInMs": { + "title": "Animation In duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation In duration" + }, + "animationOutMs": { + "title": "Animation Out duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation Out duration" + }, + "interval": { + "title": "What interval to count" + }, + "spaceBetweenGoalsInPx": { + "title": "Space between goals", + "help": "This value is in pixels", + "placeholder": "Set your space between goals" + } + }, + "groupSettings": "Group Settings" +} \ No newline at end of file diff --git a/backend/locales/es/ui/registry/overlays.json b/backend/locales/es/ui/registry/overlays.json new file mode 100644 index 000000000..f56199cb8 --- /dev/null +++ b/backend/locales/es/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Create new overlay link mapping", + "emptyMapping": "No overlay link mapping were created yet.", + "allowedIPs": { + "name": "Allowed IPs", + "help": "Allow access from set IPs separated by new line" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/registry/plugins.json b/backend/locales/es/ui/registry/plugins.json new file mode 100644 index 000000000..00eb1444f --- /dev/null +++ b/backend/locales/es/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "This node needs to be linked with listeners with sender attributes" + }, + "filter": { + "permission": { + "name": "Permission filter" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Event listener", + "type": { + "twitchChatMessage": "Twitch chat message", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat cleared", + "twitchCommand": "Twitch command", + "twitchFollow": "New Twitch follower", + "twitchSubscription": "New Twitch subscription", + "twitchSubgift": "New Twitch subscription gift", + "twitchSubcommunitygift": "New Twitch subscription community gift", + "twitchResub": "New Twitch recurring subscription", + "twitchGameChanged": "Twitch category changed", + "twitchStreamStarted": "Twitch stream started", + "twitchStreamStopped": "Twitch stream stopped", + "twitchRewardRedeem": "Twitch reward redeemed", + "twitchRaid": "Twitch raid incoming", + "tip": "Tipped by user", + "botStarted": "Bot started" + }, + "command": { + "add-parameter": "Add parameter", + "parameters": "Parameters", + "order-is-important": "order is important" + } + }, + "others": { + "idle": { + "name": "Idle" + } + }, + "output": { + "log": { + "name": "Log message" + }, + "timeout-user": { + "name": "Timeout user" + }, + "ban-user": { + "name": "Ban user" + }, + "send-twitch-message": { + "name": "Send Twitch Message" + } + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/registry/randomizer.json b/backend/locales/es/ui/registry/randomizer.json new file mode 100644 index 000000000..5f728918b --- /dev/null +++ b/backend/locales/es/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Add Randomizer", + "form": { + "name": "Name", + "command": "Command", + "permission": "Command permission", + "simple": "Simple", + "tape": "Tape", + "wheelOfFortune": "Wheel of Fortune", + "type": "Type", + "options": "Options", + "optionsAreEmpty": "Options are empty.", + "color": "Color", + "numOfDuplicates": "No. of duplicates", + "minimalSpacing": "Minimal spacing", + "groupUp": "Group Up", + "ungroup": "Ungroup", + "groupedWithOptionAbove": "Grouped with option above", + "generatedOptionsPreview": "Preview of generated options", + "probability": "Probability", + "tick": "Tick sound during spin" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/registry/textoverlay.json b/backend/locales/es/ui/registry/textoverlay.json new file mode 100644 index 000000000..03e974b69 --- /dev/null +++ b/backend/locales/es/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Create new text overlay", + "title": "text overlay", + "name": { + "placeholder": "Set your text overlay name" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/stats/commandcount.json b/backend/locales/es/ui/stats/commandcount.json new file mode 100644 index 000000000..e6fd27f4c --- /dev/null +++ b/backend/locales/es/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Command", + "hour": "Hour", + "day": "Day", + "week": "Week", + "month": "Month", + "year": "Year", + "total": "Total" +} \ No newline at end of file diff --git a/backend/locales/es/ui/systems/checklist.json b/backend/locales/es/ui/systems/checklist.json new file mode 100644 index 000000000..eac70e101 --- /dev/null +++ b/backend/locales/es/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "itemsArray": "List" + }, + "check": "Checklist" +} \ No newline at end of file diff --git a/backend/locales/es/ui/systems/howlongtobeat.json b/backend/locales/es/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..a9dcc7f7a --- /dev/null +++ b/backend/locales/es/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Status" + }, + "empty": "No games were tracked yet.", + "emptyAfterSearch": "No tracked games were found by your search for \"$search\".", + "when": "When streamed", + "time": "Tracked time", + "overallTime": "Overall time", + "offset": "Offset of tracked time", + "main": "Main", + "extra": "Main+Extra", + "completionist": "Completionist", + "game": "Tracked game", + "startedAt": "Tracking started at", + "updatedAt": "Last update", + "showHistory": "Show history ($count)", + "hideHistory": "Hide history ($count)", + "searchToAddNewGame": "Search to add new game to track" +} \ No newline at end of file diff --git a/backend/locales/es/ui/systems/keywords.json b/backend/locales/es/ui/systems/keywords.json new file mode 100644 index 000000000..9e725400f --- /dev/null +++ b/backend/locales/es/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "New Keyword", + "empty": "No keywords were created yet.", + "emptyAfterSearch": "No keywords were found by your search for \"$search\".", + "keyword": { + "name": "Keyword / Regular Expression", + "placeholder": "Set your keyword or regular expression to trigger keyword.", + "help": "You can use regexp (case insensitive) to use keywords, e.g. hello.*|hi" + }, + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "no-responses-set": "No responses", + "addResponse": "Add response", + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/systems/levels.json b/backend/locales/es/ui/systems/levels.json new file mode 100644 index 000000000..91bcaa02b --- /dev/null +++ b/backend/locales/es/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Status", + "conversionRate": "Conversion rate 1 XP for x Points", + "firstLevelStartsAt": "First level starts at XP", + "nextLevelFormula": { + "title": "Next level calculation formula", + "help": "Available variables: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "Levels example will be refreshed on save", + "xpName": "Name", + "interval": "Minutes interval to add xp to online users when stream online", + "offlineInterval": "Minutes interval to add xp to online users when stream offline", + "messageInterval": "How many messages to add xp", + "messageOfflineInterval": "How many messages to add xp when stream offline", + "perInterval": "How many xp to add per online interval", + "perOfflineInterval": "How many xp to add per offline interval", + "perMessageInterval": "How many xp to add per message interval", + "perMessageOfflineInterval": "How many xp to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/systems/polls.json b/backend/locales/es/ui/systems/polls.json new file mode 100644 index 000000000..f31a08052 --- /dev/null +++ b/backend/locales/es/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Total votes", + "totalPoints": "Total points", + "closedAt": "Closed at", + "activeFor": "Active for" +} \ No newline at end of file diff --git a/backend/locales/es/ui/systems/scrim.json b/backend/locales/es/ui/systems/scrim.json new file mode 100644 index 000000000..6b719fc3b --- /dev/null +++ b/backend/locales/es/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "waitForMatchIdsInSeconds": { + "title": "Interval for putting match ID into chat", + "help": "Set in seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/systems/top.json b/backend/locales/es/ui/systems/top.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/es/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/es/ui/systems/userinfo.json b/backend/locales/es/ui/systems/userinfo.json new file mode 100644 index 000000000..c8bba8b80 --- /dev/null +++ b/backend/locales/es/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "formatSeparator": "Format separator", + "order": "Format", + "lastSeenFormat": { + "title": "Time format", + "help": "Possible formats at https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr.json b/backend/locales/fr.json new file mode 100644 index 000000000..b627d939d --- /dev/null +++ b/backend/locales/fr.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "est chargé et", + "enabled": "activé", + "disabled": "désactivé", + "usage": "Utilisation", + "lang-selected": "La langue du bot est actuellement définie en français", + "refresh-panel": "Vous devrez actualiser l'interface utilisateur pour voir les changements.", + "command-parse": "Désolé, $sender, mais cette commande n'est pas correcte, utilisez", + "error": "Désolé, $sender, mais une erreur s'est produite !", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, l'API ne répond pas correctement !", + "not-available": "indisponible" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "année|années", + "months": "mois|mois", + "days": "jour|jours", + "hours": "heure|heures", + "minutes": "minute|minutes", + "seconds": "seconde|secondes", + "messages": "message|messages", + "bits": "bit|bits", + "links": "lien|liens", + "entries": "entrée|entrées", + "empty": "vide", + "isRegistered": "$sender, vous ne pouvez pas utiliser !$keyword, car il est déjà utilisé pour une autre action !" + }, + "clip": { + "notCreated": "Une erreur s'est produite et le clip n'a pas été créé.", + "offline": "Le stream est actuellement hors ligne et le clip ne peut pas être créé." + }, + "uptime": { + "online": "Le stream est en ligne pour (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)", + "offline": "Le stream est actuellement hors ligne pour (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)" + }, + "webpanel": { + "this-system-is-disabled": "Ce système est désactivé", + "or": "or", + "loading": "Chargement", + "this-may-take-a-while": "Ceci peut prendre un certain temps", + "display-as": "Afficher en tant que", + "go-to-admin": "Accéder à l'administration", + "go-to-public": "Accéder à la page public", + "logout": "Se déconnecter", + "popout": "Popout", + "not-logged-in": "Non connecté", + "remove-widget": "Retirer le widget $name", + "join-channel": "Faire rejoindre le bot sur la chaîne", + "leave-channel": "Faire quitter le bot de la chaîne", + "set-default": "Définir par défaut", + "add": "Ajouter", + "placeholders": { + "text-url-generator": "Collez votre texte ou html pour générer base64 ci-dessous et URL au-dessus", + "text-decode-base64": "Collez votre base64 pour générer l'URL et le texte ci-dessus", + "creditsSpeed": "Définir la vitesse de roulement des crédits, inférieur = plus rapide" + }, + "timers": { + "title": "Timers", + "timer": "Timer", + "messages": "messages", + "seconds": "secondes", + "badges": { + "enabled": "Activé", + "disabled": "Désactivé" + }, + "errors": { + "timer_name_must_be_compliant": "Cette valeur ne peut contenir que a-zA-Z09_", + "this_value_must_be_a_positive_number_or_0": "Cette valeur doit être un nombre positif ou 0", + "value_cannot_be_empty": "La valeur ne peut pas être vide" + }, + "dialog": { + "timer": "Timer", + "name": "Nom", + "tickOffline": "Effectué si le stream est arrêté", + "interval": "Intervalle", + "responses": "Réponses", + "messages": "Déclencher tous les X Messages", + "seconds": "Déclencher toutes les X Secondes", + "title": { + "new": "Nouveau minuteur", + "edit": "Editer le minuteurs" + }, + "placeholders": { + "name": "Nom de votre minuteur, ne peut contenir que ces caractères a-zA-Z0-9_", + "messages": "Déclencher la minuterie tous les X messages", + "seconds": "Déclencher la minuterie toutes les X secondes" + }, + "alerts": { + "success": "Le minuteur a été enregistré avec succès.", + "fail": "Une erreur s'est produite." + } + }, + "buttons": { + "close": "Fermer", + "save-changes": "Enregistrer les modifications", + "disable": "Désactiver", + "enable": "Activer", + "edit": "Éditer", + "delete": "Supprimer", + "yes": "Oui", + "no": "Non" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Êtes-vous sûr de vouloir supprimer le minuteur" + } + }, + "events": { + "event": "Événement", + "noEvents": "Aucun événement trouvé dans la base de données.", + "whatsthis": "qu'est-ce que c'est ?", + "myRewardIsNotListed": "Ma récompense n'est pas répertoriée !", + "redeemAndClickRefreshToSeeReward": "Si votre récompense créée est manquante dans une liste, actualisez-la en cliquant sur l’icône de rafraîchissement.", + "badges": { + "enabled": "Activé", + "disabled": "Désactivé" + }, + "buttons": { + "test": "Test", + "enable": "Activer", + "disable": "Désactiver", + "edit": "Editer", + "delete": "Supprimer", + "yes": "Oui", + "no": "Non" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Êtes-vous sûr de vouloir supprimer le minuteur", + "example_of_user_object_data": "Exemple de données objet utilisateur" + }, + "errors": { + "command_must_start_with_!": "La commande doit commencer par !", + "this_value_must_be_a_positive_number_or_0": "Cette valeur doit être un nombre positif ou égal à 0", + "value_cannot_be_empty": "La valeur ne peut pas être vide" + }, + "dialog": { + "title": { + "new": "Nouveau event listener", + "edit": "Modifier l'event listener" + }, + "placeholders": { + "name": "Définir le nom de votre event listener (si vide, le nom sera généré)" + }, + "alerts": { + "success": "L'événement a été enregistré avec succès.", + "fail": "Une erreur s'est produite." + }, + "close": "Fermer", + "save-changes": "Enregistrer les modifications", + "event": "Event", + "name": "Nom", + "usable-events-variables": "Variables d'événements utilisables", + "settings": "Paramètres", + "filters": "Filtres", + "operations": "Opérations" + }, + "definitions": { + "taskId": { + "label": "ID de tâche" + }, + "filter": { + "label": "Filtrer" + }, + "linkFilter": { + "label": "Lier le Filtre d'overlay", + "placeholder": "Si vous utilisez l'overlay, ajoutez un lien ou un id de votre overlay" + }, + "hashtag": { + "label": "Hashtag ou mot-clé", + "placeholder": "#VotreHashtagIci ou mot-clé" + }, + "fadeOutXCommands": { + "label": "Fondu X commandes", + "placeholder": "Number of commands subtracted every fade out interval" + }, + "fadeOutXKeywords": { + "label": "Fade Out X Keywords", + "placeholder": "Number of keywords subtracted every fade out interval" + }, + "fadeOutInterval": { + "label": "Fade Out Interval (seconds)", + "placeholder": "Fade out interval subtracting" + }, + "runEveryXCommands": { + "label": "Run Every X Commands", + "placeholder": "Number of commands before event is triggered" + }, + "runEveryXKeywords": { + "label": "Run Every X Keywords", + "placeholder": "Number of keywords before event is triggered" + }, + "commandToWatch": { + "label": "Command To Watch", + "placeholder": "Set your !commandToWatch" + }, + "keywordToWatch": { + "label": "Keyword To Watch", + "placeholder": "Set your keywordToWatch" + }, + "resetCountEachMessage": { + "label": "Reset count each message", + "true": "Reset count", + "false": "Keep count" + }, + "viewersAtLeast": { + "label": "Viewers At Least", + "placeholder": "How many viewers at least to trigger event" + }, + "runInterval": { + "label": "Run Interval (0 = run once per stream)", + "placeholder": "Trigger event every x seconds" + }, + "runAfterXMinutes": { + "label": "Run After X Minutes", + "placeholder": "Trigger event after x minutes" + }, + "runEveryXMinutes": { + "label": "Run Every X Minutes", + "placeholder": "Trigger event every x minutes" + }, + "messageToSend": { + "label": "Message To Send", + "placeholder": "Set your message" + }, + "channel": { + "label": "Channel", + "placeholder": "Channelname or ID" + }, + "timeout": { + "label": "Timeout", + "placeholder": "Set timeout in milliseconds" + }, + "timeoutType": { + "label": "Type of timeout", + "placeholder": "Set type of timeout" + }, + "command": { + "label": "Command", + "placeholder": "Set your !command" + }, + "commandToRun": { + "label": "Command To Run", + "placeholder": "Set your !commandToRun" + }, + "isCommandQuiet": { + "label": "Mute command output" + }, + "urlOfSoundFile": { + "label": "Url Of Your Sound File", + "placeholder": "http://www.pathToYour.url/where/is/file.mp3" + }, + "emotesToExplode": { + "label": "Emotes To Explode", + "placeholder": "List of emotes to explode, e.g. Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Emotes To Firework", + "placeholder": "List of emotes to firework, e.g. Kappa PurpleHeart" + }, + "replay": { + "label": "Replay clip in overlay", + "true": "Will play in as replay in overlay/alerts", + "false": "Replay won't be played" + }, + "announce": { + "label": "Announce in chat", + "true": "Will be announced", + "false": "Will not be announced" + }, + "hasDelay": { + "label": "Clip should have slight delay (to be closer what viewer see)", + "true": "Will have delay", + "false": "Will not have delay" + }, + "durationOfCommercial": { + "label": "Duration Of Commercial", + "placeholder": "Available durations - 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Custom variable to update" + }, + "numberToIncrement": { + "label": "Number to increment", + "placeholder": "" + }, + "value": { + "label": "Valeur", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Number to decrement", + "placeholder": "" + }, + "": "", + "reward": { + "label": "Reward", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Followed you", + "raid": "Raided you with $viewers raiders.", + "sub": "Subscribed to you with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "has been gifted subscription from $username", + "subcommunitygift": "Gifted subscriptions for community", + "resub": "Resubscribed with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Cheered you", + "tip": "Tipped you", + "tipToCharity": "donated to $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Twitch Prediction - Title", + "outcomes": "Twitch Prediction - Outcomes", + "locksAt": "Twitch Prediction - Locks At Date", + "winningOutcomeTitle": "Twitch Prediction - Winning outcome title", + "winningOutcomeTotalPoints": "Twitch Prediction - Winning outcome total points", + "winningOutcomePercentage": "Twitch Prediction - Winning outcome percentage", + "titleOfPoll": "Twitch Poll - Title", + "bitAmountPerVote": "Twitch Poll - Amount of bits to count as 1 vote", + "bitVotingEnabled": "Twitch Poll - Is bit voting enabled (boolean)", + "channelPointsAmountPerVote": "Twitch Poll - Amount of channel points to count as 1 vote", + "channelPointsVotingEnabled": "Twitch Poll - Is channel points voting enabled (boolean)", + "votes": "Twitch Poll - votes count", + "winnerChoice": "Twitch Poll - Winner choice", + "winnerPercentage": "Twitch Poll - Winner choice percentage", + "winnerVotes": "Twitch Poll - Winner choice votes", + "goal": "Goal", + "total": "Total", + "lastContributionTotal": "Last Contribution - Total", + "lastContributionType": "Last Contribution - Type", + "lastContributionUserId": "Last Contribution - User ID", + "lastContributionUsername": "Last Contribution - Username", + "level": "Level", + "topContributionsBitsTotal": "Top Bits Contribution - Total", + "topContributionsBitsUserId": "Top Bits Contribution - User ID", + "topContributionsBitsUsername": "Top Bits Contribution - Username", + "topContributionsSubsTotal": "Top Subs Contribution - Total", + "topContributionsSubsUserId": "Top Subs Contribution - User ID", + "topContributionsSubsUsername": "Top Subs Contribution - Username", + "sender": "User who initiated", + "title": "Current title", + "game": "Current category", + "language": "Current stream language", + "viewers": "Current viewers count", + "hostViewers": "Raid viewers count", + "followers": "Current followers count", + "subscribers": "Current subscribers count", + "arg": "Argument", + "param": "Parameter (required)", + "touser": "Username parameter", + "!param": "Parameter (not required)", + "alias": "Alias", + "command": "Command", + "keyword": "Keyword", + "response": "Response", + "list": "Populated list", + "type": "Type", + "days": "Jours", + "hours": "Heures", + "minutes": "Minutes", + "seconds": "Secondes", + "description": "Description", + "quiet": "Quiet (bool)", + "id": "ID", + "name": "Name", + "messages": "Messages", + "amount": "Amount", + "amountInBotCurrency": "Amount in bot currency", + "currency": "Currency", + "currencyInBot": "Currency in bot", + "pointsName": "Points name", + "points": "Points", + "rank": "Rank", + "nextrank": "Next rank", + "username": "Username", + "value": "Value", + "variable": "Variable", + "count": "Count", + "link": "Link (translated)", + "winner": "Winner", + "loser": "Loser", + "challenger": "Challenger", + "min": "Minimum", + "max": "Maximum", + "eligibility": "Eligibility", + "probability": "Probability", + "time": "Time", + "options": "Options", + "option": "Option", + "when": "When", + "diff": "Difference", + "users": "Users", + "user": "User", + "bank": "Bank", + "nextBank": "Next bank", + "cooldown": "Cooldown", + "tickets": "Tickets", + "ticketsName": "Tickets name", + "fromUsername": "From username", + "toUsername": "To username", + "items": "Items", + "bits": "Bits", + "subgifts": "Subgifts", + "subStreakShareEnabled": "Is substreak share enabled (true/false)", + "subStreak": "Current sub streak", + "subStreakName": "localized name of month (1 month, 2 months) for current sub strek", + "subCumulativeMonths": "Cumulative subscribe months", + "subCumulativeMonthsName": "localized name of month (1 month, 2 months) for cumulative subscribe months", + "message": "Message ", + "reason": "Reason", + "target": "Target", + "duration": "Duration", + "method": "Method", + "tier": "Tier", + "months": "Mois", + "monthsName": "localized name of month (1 month, 2 months)", + "oldGame": "Category before change", + "recipientObject": "Full recipient object", + "recipient": "Recipient", + "ytSong": "Current song on YouTube", + "spotifySong": "Current song on Spotify", + "latestFollower": "Latest Follower", + "latestSubscriber": "Latest Subscriber", + "latestSubscriberMonths": "Latest Subscriber cumulative months", + "latestSubscriberStreak": "Latest Subscriber months streak", + "latestTipAmount": "Latest Tip (amount)", + "latestTipCurrency": "Latest Tip (currency)", + "latestTipMessage": "Latest Tip (message)", + "latestTip": "Latest Tip (username)", + "toptip": { + "overall": { + "username": "Top Tip - overall (username)", + "amount": "Top Tip - overall (amount)", + "currency": "Top Tip - overall (currency)", + "message": "Top Tip - overall (message)" + }, + "stream": { + "username": "Top Tip - during stream (username)", + "amount": "Top Tip - during stream (amount)", + "currency": "Top Tip - during stream (currency)", + "message": "Top Tip - during stream (message)" + } + }, + "latestCheerAmount": "Latest Bits (amount)", + "latestCheerMessage": "Latest Bits (message)", + "latestCheer": "Latest Bits (username)", + "version": "Bot version", + "haveParam": "Have command parameter? (bool)", + "source": "Current source (twitch or discord)", + "userInput": "User input during reward redeem", + "isBotSubscriber": "Is bot subscriber (bool)", + "isStreamOnline": "Is stream online (bool)", + "uptime": "Uptime of stream", + "is": { + "moderator": "Is user mod? (bool)", + "subscriber": "Is user sub? (bool)", + "vip": "Is user vip? (bool)", + "newchatter": "Is user's first message? (bool)", + "follower": "Is user follower? (bool)", + "broadcaster": "Is user broadcaster? (bool)", + "bot": "Is user bot? (bool)", + "owner": "Is user bot owner? (bool)" + }, + "recipientis": { + "moderator": "Is recipient mod? (bool)", + "subscriber": "Is recipient sub? (bool)", + "vip": "Is recipient vip? (bool)", + "follower": "Is recipient follower? (bool)", + "broadcaster": "Is recipient broadcaster? (bool)", + "bot": "Is recipient bot? (bool)", + "owner": "Is recipient bot owner? (bool)" + }, + "sceneName": "Name of scene", + "inputName": "Name of input", + "inputMuted": "Mute state (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Others", + "currency": "Currency" + }, + "whispers": { + "title": "Whispers", + "toggle": { + "listener": "Listen commands on whisper", + "settings": "Whispers on settings change", + "raffle": "Whispers on raffle join", + "permissions": "Whispers on insufficient permissions", + "cooldowns": "Whispers on cooldown (if set as notify)" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Messages", + "follows": "Follows", + "subs": "Subs & Resubs", + "cheers": "Bits", + "responses": "Bot responses", + "whispers": "Whispers", + "bans": "Bans", + "timeouts": "Timeouts" + }, + "range": { + "day": "un jour", + "week": "a week", + "month": "un mois", + "year": "an year", + "all": "All time" + }, + "order": { + "asc": "Ascending", + "desc": "Descending" + }, + "labels": { + "order": "ORDER", + "range": "RANGE", + "filters": "FILTERS" + } + }, + "stats-panel": { + "show": "Show stats", + "hide": "Hide stats" + }, + "translations": "Custom translations", + "bot-responses": "Bot responses", + "duration": "Duration", + "viewers-reset-attributes": "Reset attributes", + "viewers-points-of-all-users": "Points of all users", + "viewers-watchtime-of-all-users": "Watch time of all users", + "viewers-messages-of-all-users": "Messages of all users", + "events-game-after-change": "category after change", + "events-game-before-change": "category before change", + "events-user-triggered-event": "user triggered event", + "events-method-used-to-subscribe": "method used to subscribe", + "events-months-of-subscription": "months of subscription", + "events-monthsName-of-subscription": "word 'month' by number (1 month, 2 months)", + "events-user-message": "user message", + "events-bits-user-sent": "bits user sent", + "events-reason-for-ban-timeout": "reason for ban/timeout", + "events-duration-of-timeout": "duration of timeout", + "events-duration-of-commercial": "duration of commercial", + "overlays-eventlist-resub": "resub", + "overlays-eventlist-subgift": "subgift", + "overlays-eventlist-subcommunitygift": "subcommunitygift", + "overlays-eventlist-sub": "sub", + "overlays-eventlist-follow": "follow", + "overlays-eventlist-cheer": "bits", + "overlays-eventlist-tip": "tip", + "overlays-eventlist-raid": "raid", + "requested-by": "Requested by", + "description": "Description", + "raffle-type": "Raffle type", + "raffle-type-keywords": "Only keyword", + "raffle-type-tickets": "With tickets", + "raffle-tickets-range": "Tickets range", + "video_id": "Video ID", + "highlights": "Highlights", + "cooldown-quiet-header": "Show cooldown message", + "cooldown-quiet-toggle-no": "Notify", + "cooldown-quiet-toggle-yes": "Won't notify", + "cooldown-moderators": "Modérateurs", + "cooldown-owners": "Owners", + "cooldown-subscribers": "Subscribers", + "cooldown-followers": "Followers", + "in-seconds": "in seconds", + "songs": "Songs", + "show-usernames-with-at": "Show usernames with @", + "send-message-as-a-bot": "Send message as a bot", + "chat-as-bot": "Chat (as bot)", + "product": "Product", + "optional": "optional", + "placeholder-search": "Search", + "placeholder-enter-product": "Enter product", + "placeholder-enter-keyword": "Enter keyword", + "credits": "Credits", + "fade-out-top": "fade up", + "fade-out-zoom": "fade zoom", + "global": "Global", + "user": "User", + "alerts": "Alerts", + "eventlist": "EventList", + "dashboard": "Dashboard", + "carousel": "Image Carousel", + "text": "Text", + "filter": "Filter", + "filters": "Filters", + "isUsed": "Is used", + "permissions": "Permissions", + "permission": "Permission", + "viewers": "Viewers", + "systems": "Systems", + "overlays": "Overlays", + "gallery": "Media gallery", + "aliases": "Aliases", + "alias": "Alias", + "command": "Command", + "cooldowns": "Cooldowns", + "title-template": "Title template", + "keyword": "Keyword", + "moderation": "Moderation", + "timer": "Timer", + "price": "Price", + "rank": "Rank", + "previous": "Previous", + "next": "Suivant", + "close": "Close", + "save-changes": "Save changes", + "saving": "Saving...", + "deleting": "Deleting...", + "done": "Done", + "error": "Error", + "title": "Title", + "change-title": "Change title", + "game": "category", + "tags": "Tags", + "change-game": "Change category", + "click-to-change": "click to change", + "uptime": "uptime", + "not-affiliate-or-partner": "Not affiliate/partner", + "not-available": "Not Available", + "max-viewers": "Max viewers", + "new-chatters": "New Chatters", + "chat-messages": "Chat messages", + "followers": "Followers", + "subscribers": "Subscribers", + "bits": "Bits", + "subgifts": "Subgifts", + "subStreak": "Current sub streak", + "subCumulativeMonths": "Cumulative subscribe months", + "tips": "Tips", + "tier": "Tier", + "status": "Status", + "add-widget": "Add widget", + "remove-dashboard": "Remove dashboard", + "close-bet-after": "Close bet after", + "refund": "refund", + "roll-again": "Roll again", + "no-eligible-participants": "No eligible participants", + "follower": "Follower", + "subscriber": "Subscriber", + "minutes": "minutes", + "seconds": "secondes", + "hours": "heures", + "months": "mois", + "eligible-to-enter": "Eligible to enter", + "everyone": "Everyone", + "roll-a-winner": "Roll a winner", + "send-message": "Send Message", + "messages": "Messages", + "level": "Level", + "create": "Create", + "cooldown": "Cooldown", + "confirm": "Confirm", + "delete": "Delete", + "enabled": "Enabled", + "disabled": "Disabled", + "enable": "Enable", + "disable": "Disable", + "slug": "Slug", + "posted-by": "Posted by", + "time": "Time", + "type": "Type", + "response": "Response", + "cost": "Cost", + "name": "Name", + "playlist": "Playlist", + "length": "Length", + "volume": "Volume", + "start-time": "Start Time", + "end-time": "End Time", + "watched-time": "Watched time", + "currentsong": "Current song", + "group": "Groupe", + "followed-since": "Followed since", + "subscribed-since": "Subscribed since", + "username": "Username", + "hashtag": "Hashtag", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "Scopes", + "last-seen": "Last Seen", + "date": "Date", + "points": "Points ", + "calendar": "Calendrier", + "string": "string", + "interval": "Interval", + "number": "number", + "minimal-messages-required": "Minimal Messages Required", + "max-duration": "Max duration", + "shuffle": "Shuffle", + "song-request": "Song Request", + "format": "Format", + "available": "Available", + "one-record-per-line": "one record per line", + "on": "on", + "off": "off", + "search-by-username": "Search by username", + "widget-title-custom": "CUSTOM", + "widget-title-eventlist": "EVENTLIST", + "widget-title-chat": "CHAT", + "widget-title-queue": "QUEUE", + "widget-title-raffles": "RAFFLES", + "widget-title-social": "SOCIAL", + "widget-title-ytplayer": "MUSIC PLAYER", + "widget-title-monitor": "MONITOR", + "event": "event", + "operation": "operation", + "tweet-post-with-hashtag": "Tweet posted with hashtag", + "user-joined-channel": "user joined a channel", + "user-parted-channel": "user parted a channel", + "follow": "new follow", + "tip": "new tip", + "obs-scene-changed": "OBS scene changed", + "obs-input-mute-state-changed": "OBS input source mute state changed", + "unfollow": "unfollow", + "hypetrain-started": "Hype Train started", + "hypetrain-ended": "Hype Train ended", + "prediction-started": "Twitch Prediction started", + "prediction-locked": "Twitch Prediction locked", + "prediction-ended": "Twitch Prediction ended", + "poll-started": "Twitch Poll started", + "poll-ended": "Twitch Poll ended", + "hypetrain-level-reached": "Hype Train new level reached", + "subscription": "new subscription", + "subgift": "new subgift", + "subcommunitygift": "new sub given to community", + "resub": "user resubscribed", + "command-send-x-times": "command was send x times", + "keyword-send-x-times": "keyword was send x times", + "number-of-viewers-is-at-least-x": "number of viewers is at least x", + "stream-started": "stream started", + "reward-redeemed": "reward redeemed", + "stream-stopped": "stream stopped", + "stream-is-running-x-minutes": "stream is running x minutes", + "chatter-first-message": "first message of chatter", + "every-x-minutes-of-stream": "every x minutes of stream", + "game-changed": "category changed", + "cheer": "received bits", + "clearchat": "chat was cleared", + "action": "user sent /me", + "ban": "user was banned", + "raid": "your channel is raided", + "mod": "user is a new mod", + "timeout": "user was timeouted", + "create-a-new-event-listener": "Create a new event listener", + "send-discord-message": "send a discord message", + "send-chat-message": "send a twitch chat message", + "send-whisper": "send a whisper", + "run-command": "run a command", + "run-obswebsocket-command": "run an OBS Websocket command", + "do-nothing": "--- do nothing ---", + "count": "count", + "timestamp": "timestamp", + "message": "message", + "sound": "sound", + "emote-explosion": "emote explosion", + "emote-firework": "emote firework", + "quiet": "quiet", + "noisy": "noisy", + "true": "true", + "false": "false", + "light": "light theme", + "dark": "dark theme", + "gambling": "Gambling", + "seppukuTimeout": "Timeout for !seppuku", + "rouletteTimeout": "Timeout for !roulette", + "fightmeTimeout": "Timeout for !fightme", + "duelCooldown": "Cooldown for !duel", + "fightmeCooldown": "Cooldown for !fightme", + "gamblingCooldownBypass": "Bypass gambling cooldowns for mods/caster", + "click-to-highlight": "highlight", + "click-to-toggle-display": "toggle display", + "commercial": "commercial started", + "start-commercial": "run a commercial", + "bot-will-join-channel": "bot will join channel", + "bot-will-leave-channel": "bot will leave channel", + "create-a-clip": "create a clip", + "increment-custom-variable": "increment a custom variable", + "set-custom-variable": "set a custom variable", + "decrement-custom-variable": "decrement a custom variable", + "omit": "omit", + "comply": "comply", + "visible": "visible", + "hidden": "hidden", + "gamblingChanceToWin": "Chance to win !gamble", + "gamblingMinimalBet": "Minimal bet for !gamble", + "duelDuration": "Duration of !duel", + "duelMinimalBet": "Minimal bet for !duel" + }, + "raffles": { + "announceInterval": "Opened raffles will be announced every $value minute", + "eligibility-followers-item": "followers", + "eligibility-subscribers-item": "subscribers", + "eligibility-everyone-item": "everyone", + "raffle-is-running": "Raffle is running ($count $l10n_entries).", + "to-enter-raffle": "To enter type \"$keyword\". Raffle is opened for $eligibility.", + "to-enter-ticket-raffle": "To enter type \"$keyword <$min-$max>\". Raffle is opened for $eligibility.", + "added-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-raffle}", + "added-ticket-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "Your raffle messages will be deleted on join.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "Sorry, $sender, but you cannot create raffle without keyword", + "raffle-is-already-running": "Sorry, $sender, raffle is already running with keyword $keyword", + "no-raffle-is-currently-running": "$sender, no raffles without winners are currently running", + "no-participants-to-pick-winner": "$sender, nobody joined a raffle", + "raffle-winner-is": "Winner of raffle $keyword is $username! Win probability was $probability%!" + }, + "bets": { + "running": "$sender, bet is already opened! Bet options: $options. Use $command close 1-$maxIndex", + "notRunning": "No bet is currently opened, ask mods to open it!", + "opened": "New bet '$title' is opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "closeNotEnoughOptions": "$sender, you need to select winning option for bet close.", + "notEnoughOptions": "$sender, new bets needs at least 2 options!", + "info": "Bet '$title' is still opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "diffBet": "$sender, you already made a bet on $option and you cannot bet to different option!", + "undefinedBet": "Sorry, $sender, but this bet option doesn't exist, use $command to check usage", + "betPercentGain": "Bet percent gain per option was set to $value%", + "betCloseTimer": "Bets will be automatically closed after $valuemin", + "refund": "Bets were closed without a winning. All users are refunded!", + "notOption": "$sender, this option doesn't exist! Bet is not closed, check $command", + "closed": "Bets was closed and winning option was $option! $amount users won in total $points $pointsName!", + "timeUpBet": "I guess you are too late, $sender, your time for betting is up!", + "locked": "Betting time is up! No more bets.", + "zeroBet": "Oh boy, $sender, you cannot bet 0 $pointsName", + "lockedInfo": "Bet '$title' is still opened, but time for betting is up!", + "removed": "Betting time is up! No bets were sent -> automatically closing", + "error": "Sorry, $sender, this command is not correct! Use $command 1-$maxIndex . E.g. $command 0 100 will bet 100 points to item 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, alias $alias was not found in database", + "alias-was-edited": "$sender, alias $alias is changed to $command", + "alias-was-added": "$sender, alias $alias for $command was added", + "list-is-not-empty": "$sender, list of aliases: $list", + "list-is-empty": "$sender, list of aliases is empty", + "alias-was-enabled": "$sender, alias $alias was enabled", + "alias-was-disabled": "$sender, alias $alias was disabled", + "alias-was-concealed": "$sender, alias $alias was concealed", + "alias-was-exposed": "$sender, alias $alias was exposed", + "alias-was-removed": "$sender, alias $alias was removed", + "alias-group-set": "$sender, alias $alias was set to group $group", + "alias-group-unset": "$sender, alias $alias group was unset", + "alias-group-list": "$sender, list of aliases groups: $list", + "alias-group-list-aliases": "$sender, list of aliases in $group: $list", + "alias-group-list-enabled": "$sender, aliases in $group are enabled.", + "alias-group-list-disabled": "$sender, aliases in $group are disabled." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, command $command was not found in database", + "response-was-not-found": "$sender, response #$response of command $command was not found in database", + "command-was-edited": "$sender, command $command is changed to '$response'", + "command-was-added": "$sender, command $command was added", + "list-is-not-empty": "$sender, list of commands: $list", + "list-is-empty": "$sender, list of commands is empty", + "command-was-enabled": "$sender, command $command was enabled", + "command-was-disabled": "$sender, command $command was disabled", + "command-was-concealed": "$sender, command $command was concealed", + "command-was-exposed": "$sender, command $command was exposed", + "command-was-removed": "$sender, command $command was removed", + "response-was-removed": "$sender, response #$response of $command was removed", + "list-of-responses-is-empty": "$sender, $command have no responses or doesn't exists", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, keyword $keyword is ambiguous, use ID of keyword", + "keyword-was-not-found": "$sender, keyword $keyword was not found in database", + "response-was-not-found": "$sender, response #$response of keyword $keyword was not found in database", + "keyword-was-edited": "$sender, keyword $keyword is changed to '$response'", + "keyword-was-added": "$sender, keyword $keyword ($id) was added", + "list-is-not-empty": "$sender, list of keywords: $list", + "list-is-empty": "$sender, list of keywords is empty", + "keyword-was-enabled": "$sender, keyword $keyword was enabled", + "keyword-was-disabled": "$sender, keyword $keyword was disabled", + "keyword-was-removed": "$sender, keyword $keyword was removed", + "list-of-responses-is-empty": "$sender, $keyword have no responses or doesn't exists", + "response": "$keyword#$index ($permission) $after| $response" + }, + "points": { + "success": { + "undo": "$sender, points '$command' for $username was reverted ($updatedValue $updatedValuePointsLocale to $originalValue $originalValuePointsLocale).", + "set": "$username was set to $amount $pointsName", + "give": "$sender just gave his $amount $pointsName to $username", + "online": { + "positive": "All online users just received $amount $pointsName!", + "negative": "All online users just lost $amount $pointsName!" + }, + "all": { + "positive": "All users just received $amount $pointsName!", + "negative": "All users just lost $amount $pointsName!" + }, + "rain": "Make it rain! All online users just received up to $amount $pointsName!", + "add": "$username just received $amount $pointsName!", + "remove": "Ouch, $amount $pointsName was removed from $username!" + }, + "failed": { + "undo": "$sender, username wasn't found in database or user have no undo operations", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Sorry, $sender, you don't have $amount $pointsName to give it to $username", + "cannotGiveZeroPoints": "Sorry, $sender, you cannot give $amount $pointsName to $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username has currently $amount $pointsName. Your position is $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, playlist to import is empty", + "playlist-imported": "$sender, imported $imported and skipped $skipped to playlist", + "not-playing": "Not Playing", + "song-was-banned": "Song $name was banned and will never play again!", + "song-was-banned-timeout-message": "You've got timeout for posting banned song", + "song-was-unbanned": "Song was succesfully unbanned", + "song-was-not-banned": "This song was not banned", + "no-song-is-currently-playing": "No song is currently playing", + "current-song-from-playlist": "Current song is $name from playlist", + "current-song-from-songrequest": "Current song is $name requested by $username", + "songrequest-disabled": "Sorry, $sender, song requests are disabled", + "song-is-banned": "Sorry, $sender, but this song is banned", + "youtube-is-not-responding-correctly": "Sorry, $sender, but YouTube is sending unexpected responses, please try again later.", + "song-was-not-found": "Sorry, $sender, but this song was not found", + "song-is-too-long": "Sorry, $sender, but this song is too long", + "this-song-is-not-in-playlist": "Sorry, $sender, but this song is not in current playlist", + "incorrect-category": "Sorry, $sender, but this song must be music category", + "song-was-added-to-queue": "$sender, song $name was added to queue", + "song-was-added-to-playlist": "$sender, song $name was added to playlist", + "song-is-already-in-playlist": "$sender, song $name is already in playlist", + "song-was-removed-from-playlist": "$sender, song $name was removed from playlist", + "song-was-removed-from-queue": "$sender, your song $name was removed from queue", + "playlist-current": "$sender, current playlist is $playlist.", + "playlist-list": "$sender, available playlists: $list.", + "playlist-not-exist": "$sender, your requested playlist $playlist doesn't exist.", + "playlist-set": "$sender, you changed playlist to $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, price for $command was set to $amount $pointsName", + "price-was-unset": "$sender, price for $command was unset", + "price-was-not-found": "$sender, price for $command was not found", + "price-was-enabled": "$sender, price for $command was enabled", + "price-was-disabled": "$sender, price for $command was disabled", + "user-have-not-enough-points": "Sorry, $sender, but you don't have $amount $pointsName to use $command", + "user-have-not-enough-points-or-bits": "Sorry, $sender, but you don't have $amount $pointsName or redeem command by $bitsAmount bits to use $command", + "user-have-not-enough-bits": "Sorry, $sender, but you need to redeem command by $bitsAmount bits to use $command", + "list-is-empty": "$sender, list of prices is empty", + "list-is-not-empty": "$sender, list of prices: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, new rank $type $rank($hours$hlocale) was added", + "rank-was-edited": "$sender, rank for $type $hours$hlocale was changed to $rank", + "rank-was-removed": "$sender, rank for $type $hours$hlocale was removed", + "rank-already-exist": "$sender, there is already a rank for $type $hours$hlocale", + "rank-was-not-found": "$sender, rank for $type $hours$hlocale was not found", + "custom-rank-was-set-to-user": "$sender, you set $rank to $username", + "custom-rank-was-unset-for-user": "$sender, custom rank for $username was unset", + "list-is-empty": "$sender, no ranks was found", + "list-is-not-empty": "$sender, ranks list: $list", + "show-rank-without-next-rank": "$sender, you have $rank rank", + "show-rank-with-next-rank": "$sender, you have $rank rank. Next rank - $nextrank", + "user-dont-have-rank": "$sender, you don't have a rank yet" + }, + "followage": { + "success": { + "never": "$sender, $username is not a channel follower", + "time": "$sender, $username is following channel $diff" + }, + "successSameUsername": { + "never": "$sender, you are not follower of this channel", + "time": "$sender, you are following this channel for $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username is not a channel subscriber.", + "notNow": "$sender, $username is currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username is subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username is subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, you are not a channel subscriber.", + "notNow": "$sender, you are currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, you are subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, you are subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, I don't have data for $username account age", + "success": { + "withUsername": "$sender, account age for $username is $diff", + "withoutUsername": "$sender, your account age is $diff" + } + }, + "lastseen": { + "success": { + "never": "$username was never in this channel!", + "time": "$username was last seen at $when in this channel" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username watched this channel for $time hours" + }, + "failed": { + "parse": "{core.command-parse} !watched or !watched [username]" + } + }, + "permissions": { + "without-permission": "You don't have enough permissions for '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, user $username have $type immunity for $time seconds", + "user-have-immunity-parameterError": "$sender, parameter error. $command ", + "user-have-link-permit": "User $username can post a $count $link to chat", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "No links allowed, ask for !permit [$count warnings left]", + "user-is-warned-about-symbols": "No excessive symbols usage [$count warnings left]", + "user-is-warned-about-long-message": "Long messages are not allowed [$count warnings left]", + "user-is-warned-about-caps": "No excessive caps usage [$count warnings left]", + "user-is-warned-about-spam": "Spamming is not allowed [$count warnings left]", + "user-is-warned-about-color": "Italic and /me is not allowed [$count warnings left]", + "user-is-warned-about-emotes": "No emotes spamming [$count warnings left]", + "user-is-warned-about-forbidden-words": "No forbidden words [$count warnings left]", + "user-have-timeout-for-links": "No links allowed, ask for !permit", + "user-have-timeout-for-symbols": "No excessive symbols usage", + "user-have-timeout-for-long-message": "Long message are not allowed", + "user-have-timeout-for-caps": "No excessive caps usage", + "user-have-timeout-for-spam": "Spamming is not allowed", + "user-have-timeout-for-color": "Italic and /me is not allowed", + "user-have-timeout-for-emotes": "No emotes spamming", + "user-have-timeout-for-forbidden-words": "No forbidden words" + }, + "queue": { + "list": "$sender, current queue pool: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Sorry $sender, queue is currently closed", + "opened": "$sender were added into queue" + }, + "open": "Queue is currently OPENED! Join to queue with !queue join", + "close": "Queue is currently closed!", + "clear": "Queue were completely cleared", + "picked": { + "single": "This user was picked from queue: $users", + "multi": "These users were picked from queue: $users", + "none": "No users were found in queue" + } + }, + "marker": "Stream marker has been created at $time.", + "title": { + "current": "$sender, title of stream is '$title'.", + "change": { + "success": "$sender, title was set to: $title" + } + }, + "game": { + "current": "$sender, streamer is currently playing $game.", + "change": { + "success": "$sender, category was set to: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type cooldown for $command was set to $secondss", + "cooldown-was-unset": "$sender, cooldown for $command was unset", + "cooldown-triggered": "$sender, '$command' is on cooldown, remaining $secondss", + "cooldown-not-found": "$sender, cooldown for $command was not found", + "cooldown-was-enabled": "$sender, cooldown for $command was enabled", + "cooldown-was-disabled": "$sender, cooldown for $command was disabled", + "cooldown-was-enabled-for-moderators": "$sender, cooldown for $command was enabled for moderators", + "cooldown-was-disabled-for-moderators": "$sender, cooldown for $command was disabled for moderators", + "cooldown-was-enabled-for-owners": "$sender, cooldown for $command was enabled for owners", + "cooldown-was-disabled-for-owners": "$sender, cooldown for $command was disabled for owners", + "cooldown-was-enabled-for-subscribers": "$sender, cooldown for $command was enabled for subscribers", + "cooldown-was-disabled-for-subscribers": "$sender, cooldown for $command was disabled for subscribers", + "cooldown-was-enabled-for-followers": "$sender, cooldown for $command was enabled for followers", + "cooldown-was-disabled-for-followers": "$sender, cooldown for $command was disabled for followers" + }, + "timers": { + "id-must-be-defined": "$sender, response id must be defined.", + "id-or-name-must-be-defined": "$sender, response id or timer name must be defined.", + "name-must-be-defined": "$sender, timer name must be defined.", + "response-must-be-defined": "$sender, timer response must be defined.", + "cannot-set-messages-and-seconds-0": "$sender, you cannot set both messages and seconds to 0.", + "timer-was-set": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger", + "timer-was-set-with-offline-flag": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger even when stream is offline", + "timer-not-found": "$sender, timer (name: $name) was not found in database. Check timers with !timers list", + "timer-deleted": "$sender, timer $name and its responses was deleted.", + "timer-enabled": "$sender, timer (name: $name) was enabled", + "timer-disabled": "$sender, timer (name: $name) was disabled", + "timers-list": "$sender, timers list: $list", + "responses-list": "$sender, timer (name: $name) list", + "response-deleted": "$sender, response (id: $id) was deleted.", + "response-was-added": "$sender, response (id: $id) for timer (name: $name) was added - '$response'", + "response-not-found": "$sender, response (id: $id) was not found in database", + "response-enabled": "$sender, response (id: $id) was enabled", + "response-disabled": "$sender, response (id: $id) was disabled" + }, + "gambling": { + "duel": { + "bank": "$sender, current bank for $command is $points $pointsName", + "lowerThanMinimalBet": "$sender, minimal bet for $command is $points $pointsName", + "cooldown": "$sender, you cannot use $command for $cooldown $minutesName.", + "joined": "$sender, good luck with your dueling skills. You bet on yourself $points $pointsName!", + "added": "$sender really thinks he is better than others raising his bet to $points $pointsName!", + "new": "$sender is your new duel challenger! To participate use $command [points], you have $minutes $minutesName left to join.", + "zeroBet": "$sender, you cannot duel 0 $pointsName", + "notEnoughOptions": "$sender, you need to specify points to dueling", + "notEnoughPoints": "$sender, you don't have $points $pointsName to duel!", + "noContestant": "Only $winner have courage to join duel! Your bet of $points $pointsName are returned to you.", + "winner": "Congratulations to $winner! He is last man standing and he won $points $pointsName ($probability% with bet of $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender is trying his luck and pulled a trigger", + "alive": "$sender is alive! Nothing happened.", + "dead": "$sender's brain was splashed on the wall!", + "mod": "$sender is incompetent and completely missed his head!", + "broadcaster": "$sender is using blanks, boo!", + "timeout": "Roulette timeout set to $values" + }, + "gamble": { + "chanceToWin": "$sender, chance to win !gamble set to $value%", + "zeroBet": "$sender, you cannot gamble 0 $pointsName", + "minimalBet": "$sender, minimal bet for !gamble is set to $value", + "lowerThanMinimalBet": "$sender, minimal bet for !gamble is $points $pointsName", + "notEnoughOptions": "$sender, you need to specify points to gamble", + "notEnoughPoints": "$sender, you don't have $points $pointsName to gamble", + "win": "$sender, you WON! You now have $points $pointsName", + "winJackpot": "$sender, you hit JACKPOT! You won $jackpot $jackpotName in addition to your bet. You now have $points $pointsName", + "loseWithJackpot": "$sender, you LOST! You now have $points $pointsName. Jackpot increased to $jackpot $jackpotName", + "lose": "$sender, you LOST! You now have $points $pointsName", + "currentJackpot": "$sender, current jackpot for $command is $points $pointsName", + "winJackpotCount": "$sender, you won $count jackpots", + "jackpotIsDisabled": "$sender, jackpot is disabled for $command." + } + }, + "highlights": { + "saved": "$sender, highlight was saved for $hoursh$minutesm$secondss", + "list": { + "items": "$sender, list of saved highlights for latest stream: $items", + "empty": "$sender, no highlights were saved" + }, + "offline": "$sender, cannot save highlight, stream is offline" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "Bot won't send errors on insufficient permissions", + "false": "Bot won't send errors on insufficient permissions through whispers" + }, + "disableCooldownWhispers": { + "true": "Bot won't send cooldown notifications", + "false": "Bot will send cooldown notifications through whispers" + } + } + }, + "time": "Current time in streamer's timezone is $time", + "subs": "$sender, there is currently $onlineSubCount online subscribers. Last sub/resub was $lastSubUsername $lastSubAgo", + "followers": "$sender, last follow was $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, user $username is not ignored by bot" + }, + "added": "$sender, user $username is added to bot ignorelist", + "removed": "$sender, user $username is removed from bot ignorelist", + "ignored": "$sender, user $username is ignored by bot" + } + } + }, + "filters": { + "setVariable": "$sender, $variable was set to $value." + } +} diff --git a/backend/locales/fr/api.clips.json b/backend/locales/fr/api.clips.json new file mode 100644 index 000000000..21895e7a3 --- /dev/null +++ b/backend/locales/fr/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "Clip was created and is available at $link" +} \ No newline at end of file diff --git a/backend/locales/fr/core/permissions.json b/backend/locales/fr/core/permissions.json new file mode 100644 index 000000000..5b7f05ac6 --- /dev/null +++ b/backend/locales/fr/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "Liste de vos permissions :", + "excludeAddSuccessful": "$sender, vous avez ajouté $username à la liste d'exclusion pour la permission $permissionName", + "excludeRmSuccessful": "$sender, vous avez retiré $username de la liste d'exclusion pour la permission $permissionName", + "userNotFound": "$sender, $username n'a pas été trouvé dans la base de données.", + "permissionNotFound": "$sender, la permission $userlevel n'a pas été trouvée dans la base de données.", + "cannotIgnoreForCorePermission": "$sender, vous ne pouvez pas exclure manuellement cet utilisateur pour la permission de base $userlevel" +} \ No newline at end of file diff --git a/backend/locales/fr/games.heist.json b/backend/locales/fr/games.heist.json new file mode 100644 index 000000000..bb322fdb0 --- /dev/null +++ b/backend/locales/fr/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, cops are still searching for last heist team. Try again after $cooldown.", + "copsCooldownMessage": "Alright guys, looks like police forces are eating donuts and we can get that sweet money!", + "entryMessage": "$sender has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type $command to enter.", + "lateEntryMessage": "$sender, heist is currently in progress!", + "entryInstruction": "$sender, type $command to enter.", + "levelMessage": "With this crew, we can heist $bank! Let's see if we can get enough crew to heist $nextBank", + "maxLevelMessage": "With this crew, we can heist $bank! It cannot be any better!", + "started": "Alright guys, check your equipment, this is what we trained for. This is not a game, this is real life. We will get money from $bank!", + "noUser": "Nobody joins a crew to heist.", + "singleUserSuccess": "$user was like a ninja. Nobody noticed missing money.", + "singleUserFailed": "$user failed to get rid of police and will be spending his time in jail.", + "result": { + "0": "Everyone was mercilessly obliterated. This is slaughter.", + "33": "Only 1/3rd of team get its money from heist.", + "50": "Half of heist team was killed or catched by police.", + "99": "Some loses of heist team is nothing of what remaining crew have in theirs pockets.", + "100": "God divinity, nobody is dead, everyone won!" + }, + "levels": { + "bankVan": "Bank van", + "cityBank": "City bank", + "stateBank": "State bank", + "nationalReserve": "Réserve nationale", + "federalReserve": "Réserve fédérale" + }, + "results": "The heist payouts are: $users", + "andXMore": "and $count more..." +} \ No newline at end of file diff --git a/backend/locales/fr/integrations/discord.json b/backend/locales/fr/integrations/discord.json new file mode 100644 index 000000000..8e3bb91b8 --- /dev/null +++ b/backend/locales/fr/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "votre compte n'est pas lié, utilisez `$command`", + "all-your-links-were-deleted": "tous vos liens ont été supprimés", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender, ce compte a été associé à $discordTag.", + "invalid-or-expired-token": "$sender, jeton invalide ou expiré.", + "help-message": "$sender, pour lier votre compte sur Discord: 1. Allez sur le serveur discord et envoyez $command dans le salon bot. | 2. Attendez le MP du bot | 3. Envoyez la commande que vous avez reçue dans vos MPs ici dans le chat twitch.", + "started-at": "Démarré à", + "announced-by": "Announced by sogeBot", + "streamed-at": "Diffusé à", + "link-whisper": "Bonjour $tag, pour lier ce compte Discord à votre compte Twitch sur la chaîne de $broadcaster , allez sur , connectez-vous à votre compte et envoyez cette commande dans le chat \n\n\t\t`$command $id`\n\nNOTE: Ceci expire dans 10 minutes.", + "check-your-dm": "vérifiez vos DMs pour les étapes à suivre afin de lier votre compte." +} \ No newline at end of file diff --git a/backend/locales/fr/integrations/lastfm.json b/backend/locales/fr/integrations/lastfm.json new file mode 100644 index 000000000..79a075396 --- /dev/null +++ b/backend/locales/fr/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "Current song is $name" +} \ No newline at end of file diff --git a/backend/locales/fr/integrations/obswebsocket.json b/backend/locales/fr/integrations/obswebsocket.json new file mode 100644 index 000000000..0e46efdaa --- /dev/null +++ b/backend/locales/fr/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, aucune action n'est définie pour l'id : $id !", + "ParameterError": "$sender, vous devez spécifier l'id!", + "UnknownError": "$sender, un problème est survenu. Consultez les journaux du bot pour obtenir des informations supplémentaires." + } +} \ No newline at end of file diff --git a/backend/locales/fr/integrations/protondb.json b/backend/locales/fr/integrations/protondb.json new file mode 100644 index 000000000..0e7df5a0f --- /dev/null +++ b/backend/locales/fr/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | $rating rated | Native on $native | Details: $url", + "responseNg": "Rating for game $game was not found on ProtonDB.", + "responseNotFound": "Game $game was not found on ProtonDB." +} \ No newline at end of file diff --git a/backend/locales/fr/integrations/pubg.json b/backend/locales/fr/integrations/pubg.json new file mode 100644 index 000000000..74ada97b0 --- /dev/null +++ b/backend/locales/fr/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, attendait l'un de ces paramètres : $list" +} \ No newline at end of file diff --git a/backend/locales/fr/integrations/spotify.json b/backend/locales/fr/integrations/spotify.json new file mode 100644 index 000000000..1ab0c5418 --- /dev/null +++ b/backend/locales/fr/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "Désolé, $sender, la musique n'a pas été trouvée sur spotify", + "song-requested": "$sender, vous avez demandé la musique $name de $artist", + "not-banned-song-not-playing": "$sender, aucune chanson n'est actuellement en cours de bannissement.", + "song-banned": "$sender, la chanson $name de $artist est bannie.", + "song-unbanned": "$sender, la chanson $name de $artist n'est plus bannie.", + "song-not-found-in-banlist": "$sender, chanson $uri n'a pas été trouvée dans la liste des bannissements.", + "cannot-request-song-is-banned": "$sender, impossible de demander le bannissement de la chanson $name de $artist.", + "cannot-request-song-from-unapproved-artist": "$sender, ne peut pas demander une chanson d'un artiste non approuvé.", + "no-songs-found-in-history": "$sender, il n'y a actuellement aucune musique dans l'historique .", + "return-one-song-from-history": "$sender, la chanson précédente était $name de $artist.", + "return-multiple-song-from-history": "$sender, $count chansons précédentes ont été :", + "return-multiple-song-from-history-item": "$index - $name de $artist", + "song-notify": "La chanson actuellement jouée est $name par $artist." +} \ No newline at end of file diff --git a/backend/locales/fr/integrations/tiltify.json b/backend/locales/fr/integrations/tiltify.json new file mode 100644 index 000000000..aa574fb09 --- /dev/null +++ b/backend/locales/fr/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, there are currently no active campaigns.", + "active_campaigns": "$sender, list of currently active campaigns:" +} \ No newline at end of file diff --git a/backend/locales/fr/systems.quotes.json b/backend/locales/fr/systems.quotes.json new file mode 100644 index 000000000..92025a17c --- /dev/null +++ b/backend/locales/fr/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, quote $id '$quote' was added. (tags: $tags)", + "error": "$sender, $command is not correct or missing -quote parameter" + }, + "remove": { + "ok": "$sender, quote $id was successfully deleted.", + "error": "$sender, quote ID is missing.", + "not-found": "$sender, quote $id was not found." + }, + "show": { + "ok": "Quote $id by $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found.", + "not-found-by-tag": "$sender, no quotes with tag $tag was not found." + } + }, + "set": { + "ok": "$sender, quote $id tags were set. (tags: $tags)", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found." + } + }, + "list": { + "ok": "$sender, You can find quote list at http://$urlBase/public/#/quotes", + "is-localhost": "$sender, quote list url is not properly specified." + } +} \ No newline at end of file diff --git a/backend/locales/fr/systems/antihateraid.json b/backend/locales/fr/systems/antihateraid.json new file mode 100644 index 000000000..ad9760e7c --- /dev/null +++ b/backend/locales/fr/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "Ce chat a été réglé sur $mode par $username pour se débarrasser du raid haineux. Désolé pour le dérangement !", + "mode": { + "0": "subs-only", + "1": "follow-only", + "2": "emotes-only" + } +} \ No newline at end of file diff --git a/backend/locales/fr/systems/howlongtobeat.json b/backend/locales/fr/systems/howlongtobeat.json new file mode 100644 index 000000000..0b122594d --- /dev/null +++ b/backend/locales/fr/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game introuvable dans la base de données.", + "game": "$sender, $game | Main: $currentMain/$hltbMainh - $percentMain% | Main+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Completionist: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Main: $currentMainh | Main+Extra: $currentMainExtrah | Completionist: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/fr/systems/levels.json b/backend/locales/fr/systems/levels.json new file mode 100644 index 000000000..1821ad3b7 --- /dev/null +++ b/backend/locales/fr/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, niveau: $currentLevel ($currentXP $xpName), $nextXP $xpName jusqu'au niveau suivant.", + "changeXP": "$sender, vous avez changé $xpName de $amount $xpName en $username.", + "notEnoughPointsToBuy": "Désolé $sender, mais vous n'avez pas $points $pointsName pour acheter $amount $xpName pour le niveau $level.", + "XPBoughtByPoints": "$sender, vous avez acheté $amount $xpName avec $points $pointsName et avez atteint le niveau $level.", + "somethingGetWrong": "$sender, un problème est survenu avec votre demande." +} \ No newline at end of file diff --git a/backend/locales/fr/systems/scrim.json b/backend/locales/fr/systems/scrim.json new file mode 100644 index 000000000..6d3de1e60 --- /dev/null +++ b/backend/locales/fr/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Correspondance de snipe ($type) commençant dans $time $unit", + "go": "Commence maintenant ! Go !", + "putMatchIdInChat": "Veuillez mettre l'ID de recherche dans le chat => $command xxx", + "currentMatches": "Correspondances actuelles: $matches", + "stopped": "La recherche a été annulée." +} \ No newline at end of file diff --git a/backend/locales/fr/systems/top.json b/backend/locales/fr/systems/top.json new file mode 100644 index 000000000..8d5f788b7 --- /dev/null +++ b/backend/locales/fr/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Top $amount (temps de visionnage) : ", + "tips": "Top $amount (dons): ", + "level": "Top $amount (niveau): ", + "points": "Top $amount (points): ", + "messages": "Top $amount (messages): ", + "followage": "Top $amount (ancienneté de follow) : ", + "subage": "Top $amount (ancienneté de sub): ", + "submonths": "Top $amount (mois de sub): ", + "bits": "Top $amount (bits): ", + "gifts": "Top $amount (cadeaux de sub): " +} \ No newline at end of file diff --git a/backend/locales/fr/ui.commons.json b/backend/locales/fr/ui.commons.json new file mode 100644 index 000000000..339ea2373 --- /dev/null +++ b/backend/locales/fr/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Paramètres supplémentaires", + "never": "jamais", + "reset": "réinitialiser", + "moveUp": "monter", + "moveDown": "descendre", + "stop-if-executed": "arrêter, si exécuté", + "continue-if-executed": "continuer, si exécuté", + "generate": "Générer", + "thumbnail": "Miniature", + "yes": "Oui", + "no": "Non", + "show-more": "Voir plus", + "show-less": "Voir moins", + "allowed": "Autorisé", + "disallowed": "Non autorisé", + "back": "Précédent" +} diff --git a/backend/locales/fr/ui.dialog.json b/backend/locales/fr/ui.dialog.json new file mode 100644 index 000000000..3fa411fde --- /dev/null +++ b/backend/locales/fr/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Éditer", + "add": "Ajouter" + }, + "position": { + "settings": "Paramètres de positionnement", + "anchorX": "Ancrer la position X", + "anchorY": "Ancrer la position Y", + "left": "Gauche", + "right": "Droite", + "middle": "Milieu", + "top": "Haut", + "bottom": "Bas", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Déplacer à droite", + "shadowShiftDown": "Déplacer vers le bas", + "shadowBlur": "Flouter", + "shadowOpacity": "Opacité", + "color": "Couleur" + }, + "errors": { + "required": "Cette valeur ne peut pas être vide.", + "minValue": "La valeur la plus basse de cette entrée est $value." + }, + "buttons": { + "reorder": "Réordonner", + "upload": { + "idle": "Télécharger", + "progress": "En cours de téléchargement", + "done": "Téléchargé" + }, + "cancel": "Annuler", + "close": "Fermer", + "test": { + "idle": "Test", + "progress": "Test en cours", + "done": "Test effectué" + }, + "saveChanges": { + "idle": "Enregistrer les modifications", + "invalid": "Impossible d'enregistrer les changements", + "progress": "Enregistrement des modifications", + "done": "Modifications enregistrées" + }, + "something-went-wrong": "Une erreur s'est produite", + "mark-to-delete": "Sélectionner pour suppression", + "disable": "Désactiver", + "enable": "Activer", + "disabled": "Désactivé", + "enabled": "Activé", + "edit": "Éditer", + "delete": "Supprimer", + "play": "Play", + "stop": "Arrêt", + "hold-to-delete": "Maintenir pour supprimer", + "yes": "Oui", + "no": "Non", + "permission": "Permission", + "group": "Groupe", + "visibility": "Visibilité", + "reset": "Réinitialiser " + }, + "changesPending": "Vos modifications n'ont pas été enregistrées.", + "formNotValid": "Formulaire non valide.", + "nothingToShow": "Il n'y a rien à voir ici !" +} \ No newline at end of file diff --git a/backend/locales/fr/ui.menu.json b/backend/locales/fr/ui.menu.json new file mode 100644 index 000000000..c2c18c9c7 --- /dev/null +++ b/backend/locales/fr/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Services", + "updater": "Updater", + "index": "Tableau de bord", + "core": "Bot", + "users": "Utilisateurs", + "tmi": "TMI", + "ui": "UI", + "eventsub": "EventSub", + "twitch": "Twich", + "general": "Général", + "timers": "Minuteurs", + "new": "Nouvel élément", + "keywords": "Mots-clés", + "customcommands": "Commandes personnalisées", + "botcommands": "Commandes du bot", + "commands": "Commandes", + "events": "Événements", + "ranks": "Rangs", + "songs": "Chansons", + "modules": "Modules", + "viewers": "Spectateurs", + "alias": "Alias", + "cooldowns": "Cooldowns", + "cooldown": "Cooldown", + "highlights": "Temps forts", + "price": "Prix", + "logs": "Logs", + "systems": "Systems", + "permissions": "Permissions", + "translations": "Traduction personnalisée", + "moderation": "Modération", + "overlays": "Overlays", + "gallery": "Galerie Média", + "games": "Jeux", + "spotify": "Spotify", + "integrations": "Intégrations", + "customvariables": "Variables personnalisées", + "registry": "Registre", + "quotes": "Citations", + "settings": "Paramètres", + "commercial": "Commercial", + "bets": "Paris", + "points": "Points ", + "raffles": "Loteries", + "queue": "File d'attente", + "playlist": "Playlist", + "bannedsongs": "Chansons interdites", + "spotifybannedsongs": "Musiques interdites Spotify", + "duel": "Duel", + "fightme": "FightMe", + "seppuku": "Seppuku", + "gamble": "Pari", + "roulette": "Roulette", + "heist": "Braquage", + "oauth": "OAuth", + "socket": "Socket", + "carouseloverlay": "Carousel overlay", + "alerts": "Alertes", + "carousel": "Image carousel", + "clips": "Clips", + "credits": "Credits", + "emotes": "Emotes", + "stats": "Stats", + "text": "Text", + "currency": "Devise", + "eventlist": "Eventlist", + "clipscarousel": "Clips carousel", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Checklist", + "bot": "Bot", + "api": "API", + "manage": "Gérer", + "top": "Top", + "goals": "Goals", + "userinfo": "User info", + "scrim": "Scrim", + "commandcount": "Command count", + "profiler": "Profiler", + "howlongtobeat": "How long to beat", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Randomizer", + "tips": "Dons", + "bits": "Bits", + "discord": "Discord", + "texttospeech": "Text To Speech", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "Niveaux", + "obswebsocket": "OBS Websocket", + "api-explorer": "API Explorer", + "emotescombo": "Emotes Combo", + "notifications": "Notifications", + "plugins": "Plugins", + "tts": "TTS" +} diff --git a/backend/locales/fr/ui.page.settings.overlays.carousel.json b/backend/locales/fr/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..7ca51081f --- /dev/null +++ b/backend/locales/fr/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "options", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Are you sure to delete this image?" + }, + "button": { + "update": "Update", + "fix_your_errors_first": "Fix errors before save" + }, + "errors": { + "number_greater_or_equal_than_0": "Value must be a number >= 0", + "value_must_not_be_empty": "Value must not be empty" + }, + "titles": { + "waitBefore": "Wait before image show (in ms)", + "waitAfter": "Wait after image disappear (in ms)", + "duration": "How long image should be shown (in ms)", + "animationIn": "Animation In", + "animationOut": "Animation Out", + "animationInDuration": "Animation In duration (in ms)", + "animationOutDuration": "Animation Out duration (in ms)", + "showOnlyOncePerStream": "Show only once per stream" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.registry.customvariables.json b/backend/locales/fr/ui.registry.customvariables.json new file mode 100644 index 000000000..fc949bb5f --- /dev/null +++ b/backend/locales/fr/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "URLs", + "generateurl": "Generate new URL", + "show-examples": "show CURL examples", + "response": { + "show": "Show response after POST", + "name": "Response after variable set", + "default": "Default", + "default-placeholder": "Set your bot response", + "default-help": "Use $value to get new variable value", + "custom": "Custom", + "command": "Command" + }, + "useIfInCommand": "Use if you use variable in command. Will return only updated variable without response.", + "permissionToChange": "Permission to change", + "isReadOnly": "read-only in chat", + "isNotReadOnly": "can be changed through chat", + "no-variables-found": "No variables found", + "additional-info": "Additional info", + "run-script": "Run script", + "last-run": "Last run at", + "variable": { + "name": "Variable name", + "help": "Variable name must be unique, e.g. $_wins, $_loses, $_top3", + "placeholder": "Enter your unique variable name", + "error": { + "isNotUnique": "Variable must have unique name.", + "isEmpty": "Variable name must not be empty." + } + }, + "description": { + "name": "Description", + "help": "Optional description", + "placeholder": "Enter your optional description" + }, + "type": { + "name": "Type", + "error": { + "isNotSelected": "Please choose a variable type." + } + }, + "currentValue": { + "name": "Current value", + "help": "If type is set to Evaluated script, value cannot be manually changed" + }, + "usableOptions": { + "name": "Usable options", + "placeholder": "Enter, your, options, here", + "help": "Options, which can be used with this variable, example: SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "You need to set at least 1 value." + } + }, + "scriptToEvaluate": "Script to evaluate", + "runScript": { + "name": "Run script", + "error": { + "isNotSelected": "Please choose an option." + } + }, + "testCurrentScript": { + "name": "Test current script", + "help": "Click Test current script to see value in Current value input" + }, + "history": "Historique", + "historyIsEmpty": "History for this variable is empty!", + "warning": "Warning: All data of this variable will be discarded!", + "choose": "Choose...", + "types": { + "number": "Number", + "text": "Text", + "options": "Options", + "eval": "Script" + }, + "runEvery": { + "isUsed": "When variable is used" + } +} + diff --git a/backend/locales/fr/ui.systems.antihateraid.json b/backend/locales/fr/ui.systems.antihateraid.json new file mode 100644 index 000000000..d821c979d --- /dev/null +++ b/backend/locales/fr/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Clear Chat", + "mode": "Mode", + "minFollowTime": "Minimum follow time", + "customAnnounce": "Customize announcement on anti hate raid enable" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.bets.json b/backend/locales/fr/ui.systems.bets.json new file mode 100644 index 000000000..51b9de149 --- /dev/null +++ b/backend/locales/fr/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "betPercentGain": "Add x% to bet payout each option" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.commercial.json b/backend/locales/fr/ui.systems.commercial.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/fr/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.cooldown.json b/backend/locales/fr/ui.systems.cooldown.json new file mode 100644 index 000000000..064403519 --- /dev/null +++ b/backend/locales/fr/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Notify as whisper", + "settings": { + "enabled": "Status", + "cooldownNotifyAsWhisper": "Whisper cooldown informations", + "cooldownNotifyAsChat": "Chat message cooldown informations", + "defaultCooldownOfCommandsInSeconds": "Default cooldown for commands (in seconds)", + "defaultCooldownOfKeywordsInSeconds": "Default cooldown for keywords (in seconds)" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.customcommands.json b/backend/locales/fr/ui.systems.customcommands.json new file mode 100644 index 000000000..5c93eb931 --- /dev/null +++ b/backend/locales/fr/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "No responses", + "addResponse": "Add response", + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.highlights.json b/backend/locales/fr/ui.systems.highlights.json new file mode 100644 index 000000000..63ed31e83 --- /dev/null +++ b/backend/locales/fr/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "urls": "Generated URLs" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.moderation.json b/backend/locales/fr/ui.systems.moderation.json new file mode 100644 index 000000000..7c9c7f3e5 --- /dev/null +++ b/backend/locales/fr/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Status", + "cListsEnabled": "Enforce the rule", + "cLinksEnabled": "Enforce the rule", + "cSymbolsEnabled": "Enforce the rule", + "cLongMessageEnabled": "Enforce the rule", + "cCapsEnabled": "Enforce the rule", + "cSpamEnabled": "Enforce the rule", + "cColorEnabled": "Enforce the rule", + "cEmotesEnabled": "Enforce the rule", + "cListsWhitelist": { + "title": "Allowed words", + "help": "To allow domains use \"domain:prtzl.io\"" + }, + "autobanMessages": "Autoban Messages", + "cListsBlacklist": "Forbidden words", + "cListsTimeout": "Timeout duration", + "cLinksTimeout": "Timeout duration", + "cSymbolsTimeout": "Timeout duration", + "cLongMessageTimeout": "Timeout duration", + "cCapsTimeout": "Timeout duration", + "cSpamTimeout": "Timeout duration", + "cColorTimeout": "Timeout duration", + "cEmotesTimeout": "Timeout duration", + "cWarningsShouldClearChat": "Should clear chat (will timeout for 1s)", + "cLinksIncludeSpaces": "Include spaces", + "cLinksIncludeClips": "Include clips", + "cSymbolsTriggerLength": "Trigger length of message", + "cLongMessageTriggerLength": "Trigger length of message", + "cCapsTriggerLength": "Trigger length of message", + "cSpamTriggerLength": "Trigger length of message", + "cSymbolsMaxSymbolsConsecutively": "Max symbols consecutively", + "cSymbolsMaxSymbolsPercent": "Max symbols %", + "cCapsMaxCapsPercent": "Max caps %", + "cSpamMaxLength": "Max length", + "cEmotesMaxCount": "Max count", + "cWarningsAnnounceTimeouts": "Announce timeouts in chat for everyone", + "cWarningsAllowedCount": "Warning count", + "cEmotesEmojisAreEmotes": "Treat Emojis as Emotes" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.points.json b/backend/locales/fr/ui.systems.points.json new file mode 100644 index 000000000..b0b011374 --- /dev/null +++ b/backend/locales/fr/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Status", + "name": { + "title": "Name", + "help": "Possible formats:
point|points
bod|4:body|bodu" + }, + "isPointResetIntervalEnabled": "Interval of points reset", + "resetIntervalCron": { + "name": "Cron interval", + "help": "CronTab generator" + }, + "interval": "Minutes interval to add points to online users when stream online", + "offlineInterval": "Minutes interval to add points to online users when stream offline", + "messageInterval": "How many messages to add points", + "messageOfflineInterval": "How many messages to add points when stream offline", + "perInterval": "How many points to add per online interval", + "perOfflineInterval": "How many points to add per offline interval", + "perMessageInterval": "How many points to add per message interval", + "perMessageOfflineInterval": "How many points to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.price.json b/backend/locales/fr/ui.systems.price.json new file mode 100644 index 000000000..5ae443f32 --- /dev/null +++ b/backend/locales/fr/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Trigger custom alerts on bit redeem", + "price": { + "name": "prix", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "Cette action ne peut pas être annulée !", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.queue.json b/backend/locales/fr/ui.systems.queue.json new file mode 100644 index 000000000..944a9444d --- /dev/null +++ b/backend/locales/fr/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Status", + "eligibilityAll": "All", + "eligibilityFollowers": "Followers", + "eligibilitySubscribers": "Abonnés" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.quotes.json b/backend/locales/fr/ui.systems.quotes.json new file mode 100644 index 000000000..c8acc6fcf --- /dev/null +++ b/backend/locales/fr/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "Nous sommes désolés, aucune citation n'a été trouvée dans la base de données.", + "new": "Add new quote", + "empty": "List of quotes is empty, create new quote.", + "emptyAfterSearch": "List of quotes is empty in searching for \"$search\"", + "quote": { + "name": "Quote", + "placeholder": "Set your quote here" + }, + "by": { + "name": "Quoted by" + }, + "tags": { + "name": "Tags", + "placeholder": "Set your tags here", + "help": "Comma-separated tags. Example: tag 1, tag 2, tag 3" + }, + "date": { + "name": "Date" + }, + "error": { + "isEmpty": "This value cannot be empty", + "atLeastOneTag": "You need to set at least one tag" + }, + "tag-filter": "Filtering by tag", + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status", + "urlBase": { + "title": "URL base", + "help": "You should use public endpoint for quotes, to be accessible by everyone" + } + } +} diff --git a/backend/locales/fr/ui.systems.raffles.json b/backend/locales/fr/ui.systems.raffles.json new file mode 100644 index 000000000..e8ce8703e --- /dev/null +++ b/backend/locales/fr/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Subscribers luck" + }, + "settings": { + "enabled": "Status", + "announceNewEntries": { + "title": "Announce new entries", + "help": "If users joins raffle, announce message will be send to chat after while." + }, + "announceNewEntriesBatchTime": { + "title": "How long to wait before announce new entries (in seconds)", + "help": "Longer time will keep chat cleaner, entries will be aggregated together." + }, + "deleteRaffleJoinCommands": { + "title": "Delete user raffle join command", + "help": "This will delete user message if they use !yourraffle command. Should keep chat cleaner." + }, + "allowOverTicketing": { + "title": "Allow over ticketing", + "help": "Allow user join raffle with over ticket of his points. E.g. user have 10 points but can join with !raffle 100 which will use all of his points." + }, + "raffleAnnounceInterval": { + "title": "Announce interval", + "help": "Minutes" + }, + "raffleAnnounceMessageInterval": { + "title": "Announce message interval", + "help": "How many messages must be sent to chat until announce can be posted." + }, + "subscribersPercent": { + "title": "Additional subscribers luck", + "help": "en pourcents" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.ranks.json b/backend/locales/fr/ui.systems.ranks.json new file mode 100644 index 000000000..accc91fe2 --- /dev/null +++ b/backend/locales/fr/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "New Rank", + "empty": "No ranks were created yet.", + "emptyAfterSearch": "No ranks were found by your search for \"$search\".", + "rank": { + "name": "rank", + "placeholder": "" + }, + "value": { + "name": "heures", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.songs.json b/backend/locales/fr/ui.systems.songs.json new file mode 100644 index 000000000..8197b1e8a --- /dev/null +++ b/backend/locales/fr/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Status", + "volume": "Volume", + "calculateVolumeByLoudness": "Dynamic volume by loudness", + "duration": { + "title": "Max song duration", + "help": "In minutes" + }, + "shuffle": "Shuffle", + "songrequest": "Play from song request", + "playlist": "Play from playlist", + "onlyMusicCategory": "Allow only category music", + "allowRequestsOnlyFromPlaylist": "Allow song requests only from current playlist", + "notify": "Send message on song change" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "startTime": "Start song at", + "endTime": "End song at", + "add_song": "Add song", + "add_or_import": "Add song or import from playlist", + "importing": "Importing", + "importing_done": "Importing Done", + "seconds": "Secondes", + "calculated": "Calculated", + "set_manually": "Set manually", + "bannedSongsEmptyAfterSearch": "No banned songs were found by your search for \"$search\".", + "emptyAfterSearch": "No songs were found by your search for \"$search\".", + "empty": "No songs were added yet.", + "bannedSongsEmpty": "No songs were added to banlist yet." +} \ No newline at end of file diff --git a/backend/locales/fr/ui.systems.timers.json b/backend/locales/fr/ui.systems.timers.json new file mode 100644 index 000000000..3b16c2302 --- /dev/null +++ b/backend/locales/fr/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "New Timer", + "empty": "No timers were created yet.", + "emptyAfterSearch": "Aucune minuterie n'a été trouvée lors de votre recherche de \"$search\".", + "add_response": "Ajouter une réponse", + "settings": { + "enabled": "Status" + }, + "warning": "Cette action ne peut pas être annulée !" +} \ No newline at end of file diff --git a/backend/locales/fr/ui.widgets.customvariables.json b/backend/locales/fr/ui.widgets.customvariables.json new file mode 100644 index 000000000..9fc16ba18 --- /dev/null +++ b/backend/locales/fr/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "Aucune variable personnalisée trouvée, ajoutez au registre de variables personnalisées", + "add-variable-into-watchlist": "Add variable to watchlist", + "watchlist": "Watchlist" +} \ No newline at end of file diff --git a/backend/locales/fr/ui.widgets.randomizer.json b/backend/locales/fr/ui.widgets.randomizer.json new file mode 100644 index 000000000..14a8de970 --- /dev/null +++ b/backend/locales/fr/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "No randomizer found, add at randomizer registry", + "add-randomizer-to-widget": "Ajouter un randomiseur au widget" +} \ No newline at end of file diff --git a/backend/locales/fr/ui/categories.json b/backend/locales/fr/ui/categories.json new file mode 100644 index 000000000..3bf42c732 --- /dev/null +++ b/backend/locales/fr/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Annonces", + "keys": "Keys", + "currency": "Devise", + "general": "Général", + "settings": "Paramètres", + "commands": "Commandes", + "bot": "Bot", + "channel": "Chaîne", + "connection": "Connexion", + "chat": "Chat", + "graceful_exit": "Graceful exit", + "rewards": "Récompenses", + "levels": "Niveaux", + "notifications": "Notifications", + "options": "Options", + "comboBreakMessages": "Combo Break Messages", + "hypeMessages": "Hype Messages", + "messages": "Messages", + "results": "Résultats", + "customization": "Personnalisation", + "status": "Status", + "mapping": "Mappage", + "player": "Joueur", + "stats": "Statistiques", + "api": "API", + "token": "Token", + "text": "Texte", + "custom_texts": "Textes personnalisés", + "credits": "Crédits", + "show": "Afficher", + "social": "Social", + "explosion": "Explosion", + "fireworks": "Feux d'artifice", + "test": "Test", + "emotes": "Emotes", + "default": "Défaut", + "urls": "URLs", + "conversion": "Conversion", + "xp": "XP", + "caps_filter": "Filtre des Majuscules", + "color_filter": "Filtre Italique (/me)", + "links_filter": "Filtre de liens", + "symbols_filter": "Filtre des symboles", + "longMessage_filter": "Filtre de longueur du message", + "spam_filter": "Filtre anti-spam", + "emotes_filter": "Filtres d'Emotes", + "warnings": "Avertissements", + "reset": "Réinitialiser", + "reminder": "Rappel", + "eligibility": "Éligibilité", + "join": "Rejoindre", + "luck": "Luck", + "lists": "Listes", + "me": "Moi", + "emotes_combo": "Combo d'emotes", + "tmi": "tmi", + "oauth": "oAuth", + "eventsub": "eventsub", + "rules": "règles" +} \ No newline at end of file diff --git a/backend/locales/fr/ui/core/currency.json b/backend/locales/fr/ui/core/currency.json new file mode 100644 index 000000000..5431fdea2 --- /dev/null +++ b/backend/locales/fr/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Devise principale" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/core/general.json b/backend/locales/fr/ui/core/general.json new file mode 100644 index 000000000..5a124f817 --- /dev/null +++ b/backend/locales/fr/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Langue du bot", + "numberFormat": "Format des chiffres dans le chat", + "gracefulExitEachXHours": { + "title": "Sortie gracieuse toutes les X heures", + "help": "0 - Désactivé" + }, + "shouldGracefulExitHelp": "Activer la sortie gracieuse est recommandé si votre bot fonctionne tout le temps sur le serveur. Vous devriez avoir un bot fonctionnant sur pm2 (ou un service similaire) ou le faire dockerizer pour assurer un redémarrage automatique du bot. Le bot ne quittera pas gracieusement lorsque le flux est en ligne." + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/core/oauth.json b/backend/locales/fr/ui/core/oauth.json new file mode 100644 index 000000000..24fc1586f --- /dev/null +++ b/backend/locales/fr/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Propriétaires", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "Nom d'utilisateur", + "channelUsername": "Username", + "botExpectedScopes": "Scopes", + "channelExpectedScopes": "Scopes" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/core/permissions.json b/backend/locales/fr/ui/core/permissions.json new file mode 100644 index 000000000..e8c1e7d8c --- /dev/null +++ b/backend/locales/fr/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Ajouter un nouveau groupe de permissions", + "higherPermissionHaveAccessToLowerPermissions": "Les Permissions les plus élevées ont accès a des autorisations plus faibles.", + "typeUsernameOrIdToSearch": "Tapez le nom d'utilisateur ou l'ID à rechercher", + "typeUsernameOrIdToTest": "Tapez le nom d'utilisateur ou l'ID à tester", + "noUsersWereFound": "Aucun utilisateur n'a été trouvé.", + "noUsersManuallyAddedToPermissionYet": "Aucun utilisateur n'a encore été ajouté manuellement à la permission.", + "done": "Terminé", + "previous": "Précédent", + "next": "Suivant", + "loading": "chargement", + "permissionNotFoundInDatabase": "Autorisation introuvable dans la base de données, veuillez l'enregistrer avant de tester l'utilisateur.", + "userHaveNoAccessToThisPermissionGroup": "L'utilisateur $username n'a PAS accès à ce groupe d'autorisations.", + "userHaveAccessToThisPermissionGroup": "L'utilisateur $username a accès à ce groupe de permissions.", + "accessDirectlyThrough": "Accès direct via", + "accessThroughHigherPermission": "Accès via une autorisation plus élevée", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Quelque chose s'est mal passé, l'utilisateur $username n'a pas été trouvé dans la base de données du bot.", + "permissionsGroups": "Groupes de permissions", + "allowHigherPermissions": "Autoriser l'accès via une autorisation plus élevée", + "type": "Type", + "value": "Valeur", + "watched": "Temps de visionnage en heures", + "followtime": "Ancienneté de follow en mois", + "points": "Points ", + "tips": "Dons", + "bits": "Bits", + "messages": "Messages", + "subtier": "Sub Tier (1, 2, or 3)", + "subcumulativemonths": "Mois de sub consécutifs", + "substreakmonths": "Série de sub actuels", + "ranks": "Rang actuel", + "level": "Niveau Actuel", + "isLowerThan": "est inférieur à", + "isLowerThanOrEquals": "est inférieur ou égal à", + "equals": "égal à", + "isHigherThanOrEquals": "est supérieur ou égal à", + "isHigherThan": "est supérieur à", + "addFilter": "Ajouter un filtre", + "selectPermissionGroup": "Sélectionnez le groupe de permissions", + "settings": "Paramètres", + "name": "Nom", + "baseUsersSet": "Ensemble de base des utilisateurs", + "manuallyAddedUsers": "Utilisateurs ajoutés manuellement", + "manuallyExcludedUsers": "Utilisateurs exclus manuellement", + "filters": "Filtres", + "testUser": "Utilisateur de test", + "none": "- aucun -", + "casters": "Streamers", + "moderators": "Modérateurs", + "subscribers": "Abonnés", + "vip": "VIP", + "viewers": "Viewers", + "followers": "Followers" +} \ No newline at end of file diff --git a/backend/locales/fr/ui/core/socket.json b/backend/locales/fr/ui/core/socket.json new file mode 100644 index 000000000..1b109c650 --- /dev/null +++ b/backend/locales/fr/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Purger toutes les connexions authentifiées (la vôtre aussi)", + "accessTokenExpirationTime": "Temps d'expiration du Token d'Accès (secondes)", + "refreshTokenExpirationTime": "Temps d'expiration du Token Refresh (secondes)", + "socketToken": { + "title": "Socket token", + "help": "Ce token vous donnera un accès administrateur complet via les sockets. Ne partagez pas!" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/core/tmi.json b/backend/locales/fr/ui/core/tmi.json new file mode 100644 index 000000000..913f7ce93 --- /dev/null +++ b/backend/locales/fr/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Ignorer la liste (ID ou nom d'utilisateur)", + "showWithAt": "Afficher les utilisateurs avec @", + "sendWithMe": "Envoyer des messages avec /me", + "sendAsReply": "Envoyer les messages du bot en réponse", + "mute": "Le bot est mute", + "whisperListener": "Accepter les commandes en whispers" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/core/tts.json b/backend/locales/fr/ui/core/tts.json new file mode 100644 index 000000000..f4b8119bc --- /dev/null +++ b/backend/locales/fr/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Service" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/core/twitch.json b/backend/locales/fr/ui/core/twitch.json new file mode 100644 index 000000000..e056c6a1e --- /dev/null +++ b/backend/locales/fr/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Create stream marker on event" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/core/ui.json b/backend/locales/fr/ui/core/ui.json new file mode 100644 index 000000000..a17836347 --- /dev/null +++ b/backend/locales/fr/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Thème par défaut", + "domain": { + "title": "Domaine", + "help": "Format sans http/https: yourdomain.com ou votre.domain.com" + }, + "percentage": "Différence de pourcentage pour les statistiques", + "shortennumbers": "Format court des nombres", + "showdiff": "Afficher la différence", + "enablePublicPage": "Enable public page" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/core/updater.json b/backend/locales/fr/ui/core/updater.json new file mode 100644 index 000000000..8b4a7aa16 --- /dev/null +++ b/backend/locales/fr/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Mettre à jour automatiquement si une version plus récente est disponible" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/errors.json b/backend/locales/fr/ui/errors.json new file mode 100644 index 000000000..8b3e4bef8 --- /dev/null +++ b/backend/locales/fr/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Unexpected errors during validation", + "isNotEmpty": "$property is required.", + "minLength": "$property must be longer than or equal to $constraint1 characters.", + "isPositive": "$property must be greater then 0", + "isCommand": "$property must start with !", + "isCommandOrCustomVariable": "$property must start with ! or $_", + "isCustomVariable": "$property must start with $_", + "min": "$property must be at least $constraint1", + "max": "$property must be lower or equal to $constraint1", + "isInt": "$property must be an integer", + "this_value_must_be_a_positive_number_and_greater_then_0": "This value must be a positive number or greater then 0", + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty", + "minLength_of_value_is": "Minimal length is $value.", + "this_currency_is_not_supported": "This currency is not supported", + "something_went_wrong": "Something went wrong", + "permission_must_exist": "Permission must exist", + "minValue_of_value_is": "Minimal value is $value", + "value_cannot_be": "Value cannot be $value.", + "invalid_format": "Invalid value format.", + "invalid_regexp_format": "This is not valid regex.", + "owner_and_broadcaster_oauth_is_not_set": "Owner and channel oauth is not set", + "channel_is_not_set": "Channel is not set", + "please_set_your_broadcaster_oauth_or_owners": "Please set your channel oauth or owners, or all users will have access to this dashboard and will be considered as casters.", + "new_update_available": "New update available", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "One of inputs must be set" +} \ No newline at end of file diff --git a/backend/locales/fr/ui/games/duel.json b/backend/locales/fr/ui/games/duel.json new file mode 100644 index 000000000..f9828d90e --- /dev/null +++ b/backend/locales/fr/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Statut", + "cooldown": "Cooldown", + "duration": { + "title": "Durée", + "help": "Minutes" + }, + "minimalBet": "Mise minimale", + "bypassCooldownByOwnerAndMods": "Contourner le cooldown pour le propriétaire et les mods" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/games/gamble.json b/backend/locales/fr/ui/games/gamble.json new file mode 100644 index 000000000..8187b9094 --- /dev/null +++ b/backend/locales/fr/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Statut", + "minimalBet": "Mise minimale", + "chanceToWin": { + "title": "Chances de gagner", + "help": "Pourcentage" + }, + "enableJackpot": "Activer le jackpot", + "chanceToTriggerJackpot": "Chance de déclencher le jackpot en %", + "maxJackpotValue": "Valeur maximale du jackpot", + "lostPointsAddedToJackpot": "Combien de points perdus doivent être ajoutés au jackpot en %" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/games/heist.json b/backend/locales/fr/ui/games/heist.json new file mode 100644 index 000000000..0d688d9c3 --- /dev/null +++ b/backend/locales/fr/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Braquage", + "settings": { + "enabled": "Statut", + "showMaxUsers": "Nombre maximum d'utilisateurs à afficher dans le paiement", + "copsCooldownInMinutes": { + "title": "Cooldown entre les braquages", + "help": "Minutes" + }, + "entryCooldownInSeconds": { + "title": "Temps pour participer au braquage", + "help": "Secondes" + }, + "started": "Message de début de braquage", + "nextLevelMessage": "Message lorsque le niveau suivant est atteint", + "maxLevelMessage": "Message lorsque le niveau max est atteint", + "copsOnPatrol": "Réponse du bot quand le braquage est toujours en cooldown", + "copsCooldown": "Annonce du bot quand le braquage peut être démarré", + "singleUserSuccess": "Message de succès pour un utilisateur", + "singleUserFailed": "Message d'échec pour un utilisateur", + "noUser": "Message si aucun utilisateur n'a participé" + }, + "message": "Message ", + "winPercentage": "Pourcentage de victoire", + "payoutMultiplier": "Multiplicateur de gains", + "maxUsers": "Nombre maximum d'utilisateurs pour le niveau", + "percentage": "Pourcentage", + "noResultsFound": "Aucun résultat trouvé. Cliquez sur le bouton ci-dessous pour ajouter un nouveau résultat.", + "noLevelsFound": "Aucun niveau trouvé. Cliquez sur le bouton ci-dessous pour ajouter un nouveau niveau." +} \ No newline at end of file diff --git a/backend/locales/fr/ui/games/roulette.json b/backend/locales/fr/ui/games/roulette.json new file mode 100644 index 000000000..f5c238b58 --- /dev/null +++ b/backend/locales/fr/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Statut", + "timeout": { + "title": "Durée d'expiration", + "help": "Secondes" + }, + "winnerWillGet": "Combien de points seront ajoutés à la victoire", + "loserWillLose": "Combien de points seront perdus en cas de perte" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/games/seppuku.json b/backend/locales/fr/ui/games/seppuku.json new file mode 100644 index 000000000..e15d11eba --- /dev/null +++ b/backend/locales/fr/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Statut", + "timeout": { + "title": "Durée d'expiration", + "help": "Secondes" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/integrations/discord.json b/backend/locales/fr/ui/integrations/discord.json new file mode 100644 index 000000000..01a59de6a --- /dev/null +++ b/backend/locales/fr/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Status", + "guild": "Serveur", + "listenAtChannels": "Activer les commandes sur ce salon", + "sendOnlineAnnounceToChannel": "Envoyer une annonce en ligne pour ce salon", + "onlineAnnounceMessage": "Message in online announcement (can include mentions)", + "sendAnnouncesToChannel": "Configurer l'envoi d'annonces aux salons", + "deleteMessagesAfterWhile": "Supprimer le message après un moment", + "clientId": "ClientId", + "token": "Token", + "joinToServerBtn": "Cliquez pour ajouter le bot sur votre serveur", + "joinToServerBtnDisabled": "Please save changes to enable bot join to your server", + "cannotJoinToServerBtn": "Définir le token et le clientId client pour pouvoir ajouter le bot sur votre serveur", + "noChannelSelected": "aucun salon sélectionné", + "noRoleSelected": "aucun rôle sélectionné", + "noGuildSelected": "aucun serveur sélectionné", + "noGuildSelectedBox": "Sélectionnez le serveur où le bot devrait fonctionner et vous verrez plus de paramètres", + "onlinePresenceStatusDefault": "Statut par défaut", + "onlinePresenceStatusDefaultName": "Message de statut par défaut", + "onlinePresenceStatusOnStream": "Statut lors du Streaming", + "onlinePresenceStatusOnStreamName": "Message de statut lors du streaming", + "ignorelist": { + "title": "Liste des ignorés", + "help": "username, username#0000 ou userID" + } + } +} diff --git a/backend/locales/fr/ui/integrations/donatello.json b/backend/locales/fr/ui/integrations/donatello.json new file mode 100644 index 000000000..75bd1598d --- /dev/null +++ b/backend/locales/fr/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Get your token at https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/integrations/donationalerts.json b/backend/locales/fr/ui/integrations/donationalerts.json new file mode 100644 index 000000000..f40d9fa3a --- /dev/null +++ b/backend/locales/fr/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Statut", + "access_token": { + "title": "Token d'accès", + "help": "Obtenez votre token d'accès sur https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Token de rafraîchissement" + }, + "accessTokenBtn": "Accès aux alertes de donation et au générateur de token de rafraichissement" + } +} diff --git a/backend/locales/fr/ui/integrations/kofi.json b/backend/locales/fr/ui/integrations/kofi.json new file mode 100644 index 000000000..a8179bf1e --- /dev/null +++ b/backend/locales/fr/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Verification token", + "help": "Get your verification token at https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "Webhook URL", + "help": "Set Webhook URL at https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL must have HTTPS", + "origin": "You cannot use localhost for webhooks" + } + } + } +} diff --git a/backend/locales/fr/ui/integrations/lastfm.json b/backend/locales/fr/ui/integrations/lastfm.json new file mode 100644 index 000000000..e64efefa7 --- /dev/null +++ b/backend/locales/fr/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Statut", + "apiKey": "Clé d'API", + "username": "Nom d'utilisateur" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/integrations/obswebsocket.json b/backend/locales/fr/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..ca900d794 --- /dev/null +++ b/backend/locales/fr/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Statut", + "accessBy": { + "title": "Accès par", + "help": "Direct - se connecter directement à partir d'un bot | Overlay - se connecter via la source du navigateur d'overlay" + }, + "address": "Adresse", + "password": "Mot de passe" + }, + "noSourceSelected": "Aucune source sélectionnée", + "noSceneSelected": "Aucune scène sélectionnée", + "empty": "Aucun ensemble d'action n'a encore été créé.", + "emptyAfterSearch": "Aucun ensemble d'actions n'a été trouvé par votre recherche pour \"$search\".", + "command": "Commande", + "new": "Créer un nouvel ensemble d'action OBS Websocket", + "actions": "Actions", + "name": { + "name": "Nom" + }, + "mute": "Mute", + "unmute": "Unmute", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Attendez X millisecondes" + }, + "Log": { + "name": "Message de log" + }, + "StartRecording": { + "name": "StartRecording" + }, + "StopRecording": { + "name": "StopRecording" + }, + "PauseRecording": { + "name": "PauseRecording" + }, + "ResumeRecording": { + "name": "ResumeRecording" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/integrations/pubg.json b/backend/locales/fr/ui/integrations/pubg.json new file mode 100644 index 000000000..4442415b3 --- /dev/null +++ b/backend/locales/fr/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Statut", + "apiKey": { + "title": "Clé d'API", + "help": "Obtenez votre clé d'API sur https://developer.pubg.com/" + }, + "platform": "Plateforme", + "playerName": "Nom de joueur", + "playerId": "ID du joueur", + "seasonId": { + "title": "ID de la saison", + "help": "L'ID de la saison actuelle se vérifiera toutes les heures." + }, + "rankedGameModeStatsCustomization": "Message personnalisé pour les statistiques classées", + "gameModeStatsCustomization": "Message personnalisé pour les statistiques normales" + }, + "click_to_fetch": "Cliquer pour récupérer", + "something_went_wrong": "Une erreur s'est produite!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Les statistiques sont automatiquement actualisées toutes les 10 minutes.", + "player_stats_ranked": "Statistiques du joueur (classé)", + "player_stats": "Statistiques du joueur" +} diff --git a/backend/locales/fr/ui/integrations/qiwi.json b/backend/locales/fr/ui/integrations/qiwi.json new file mode 100644 index 000000000..e3978d855 --- /dev/null +++ b/backend/locales/fr/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Statut", + "secretToken": { + "title": "Secret token", + "help": "Obtenez un secret token dans les paramètres du tableau de bord de Qiwi Donate -> cliquez sur afficher le secret token" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/integrations/responsivevoice.json b/backend/locales/fr/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..f8476ac9a --- /dev/null +++ b/backend/locales/fr/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Clé", + "help": "Obtenez votre clé sur http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/integrations/spotify.json b/backend/locales/fr/ui/integrations/spotify.json new file mode 100644 index 000000000..7f08fa233 --- /dev/null +++ b/backend/locales/fr/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Artists", + "settings": { + "enabled": "Statut", + "songRequests": "Demandes de musiques", + "fetchCurrentSongWhenOffline": { + "title": "Récupérer la chanson en cours lorsque le flux est hors ligne", + "help": "Il est conseillé de désactiver ceci pour éviter d'atteindre les limites de l'API" + }, + "allowApprovedArtistsOnly": "Autoriser uniquement les artistes approuvés", + "approvedArtists": { + "title": "Artistes approuvés", + "help": "Nom ou SpotifyURI de l'artiste, un élément par ligne" + }, + "queueWhenOffline": { + "title": "Mettre en file d'attente lorsque le stream est hors ligne", + "help": "Il est conseillé de désactiver cette option pour éviter d'avoir des surprises en écoutant juste votre musique" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "ID de l'appareil forcé", + "help": "Vide = désactivé, force spotify device ID à être utilisé pour mettre en file d'attente. Vérifiez les logs pour le périphérique actif actuel ou utilisez le bouton lors de la lecture de la chanson pendant au moins 10 secondes." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Format", + "help": "Variables disponibles : $song, $artist, $artists" + }, + "username": "Utilisateur autorisé", + "revokeBtn": "Révoquer l'autorisation de l'utilisateur", + "authorizeBtn": "Autoriser l'utilisateur", + "scopes": "Scopes", + "playlistToPlay": { + "title": "URI Spotify de la playlist principale", + "help": "Si défini, après la fin de la requête, cette playlist continuera" + }, + "continueOnPlaylistAfterRequest": "Continuer à jouer de la playlist après demande de chanson", + "notify": "Envoyer un message en cas de changement de chanson" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/integrations/streamelements.json b/backend/locales/fr/ui/integrations/streamelements.json new file mode 100644 index 000000000..7c3669414 --- /dev/null +++ b/backend/locales/fr/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Statut", + "jwtToken": { + "title": "Token JWT", + "help": "Get JWT token at StreamElements Channels setting and toggle Show secrets" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/integrations/streamlabs.json b/backend/locales/fr/ui/integrations/streamlabs.json new file mode 100644 index 000000000..1f800c4ab --- /dev/null +++ b/backend/locales/fr/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Statut", + "socketToken": { + "title": "Socket token", + "help": "Obtenir le socket token depuis streamlabs dashboard API settings->API tokens->Votre Socket API Token" + }, + "accessToken": { + "title": "Access token", + "help": "Obtenez votre token d'accès sur https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "Générateur de token d'accès StreamLabs" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/integrations/tipeeestream.json b/backend/locales/fr/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..90b291a1e --- /dev/null +++ b/backend/locales/fr/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Statut", + "apiKey": { + "title": "Clé d'Api", + "help": "Récupérer le socket token depuis le tableau de bord de tipeeestream -> API -> Votre clé d'API" + } + } +} diff --git a/backend/locales/fr/ui/integrations/twitter.json b/backend/locales/fr/ui/integrations/twitter.json new file mode 100644 index 000000000..614c5a9bc --- /dev/null +++ b/backend/locales/fr/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Statut", + "consumerKey": "Clé Consommateur (clé d'API)", + "consumerSecret": "Secret du consommateur (API Secret)", + "accessToken": "Token d'Accès", + "secretToken": "Access Token Secret" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/managers.json b/backend/locales/fr/ui/managers.json new file mode 100644 index 000000000..21055ff3e --- /dev/null +++ b/backend/locales/fr/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "User event history", + "hostAndRaidViewersCount": "Viewers: $value", + "receivedSubscribeFrom": "Received subscribe from $value", + "giftedSubscribeTo": "Gifted subscribe to $value" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/overlays/alerts.json b/backend/locales/fr/ui/overlays/alerts.json new file mode 100644 index 000000000..e98640a0a --- /dev/null +++ b/backend/locales/fr/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Mettre en cache les éléments de la galerie", + "galleryCacheLimitInMb": "Taille maximale de l'élément de la galerie (en Mo) au cache" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/overlays/clips.json b/backend/locales/fr/ui/overlays/clips.json new file mode 100644 index 000000000..c1a7e4228 --- /dev/null +++ b/backend/locales/fr/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Volume", + "cClipsFilter": "Filtre de Clip", + "cClipsLabel": "Afficher le label 'clip'" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/overlays/clipscarousel.json b/backend/locales/fr/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..b911cbc7e --- /dev/null +++ b/backend/locales/fr/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Intervalle de temps (jours)", + "cClipsNumOfClips": "Nombre de clips", + "cClipsTimeToNextClip": "Temps avant le prochain clip (s)" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/overlays/credits.json b/backend/locales/fr/ui/overlays/credits.json new file mode 100644 index 000000000..8c294d2e4 --- /dev/null +++ b/backend/locales/fr/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Vitesse", + "cCreditsAggregated": "Crédits agrégés", + "cShowGameThumbnail": "Show game thumbnail", + "cShowFollowers": "Afficher les followers", + "cShowRaids": "Afficher les raids", + "cShowSubscribers": "Afficher les abonnés", + "cShowSubgifts": "Afficher les subs offerts", + "cShowSubcommunitygifts": "Afficher les subs offerts à la communauté", + "cShowResubs": "Afficher les resubs", + "cShowCheers": "Show cheers", + "cShowClips": "Afficher les clips", + "cShowTips": "Afficher les dons", + "cTextLastMessage": "Dernier message", + "cTextLastSubMessage": "Dernier message de sub", + "cTextStreamBy": "Streamé par", + "cTextFollow": "Suivi par", + "cTextRaid": "Raid par", + "cTextCheer": "Cheer by", + "cTextSub": "Abonné par", + "cTextResub": "Resub par", + "cTextSubgift": "Subs offerts", + "cTextSubcommunitygift": "Subs offerts à la communauté", + "cTextTip": "Dons par", + "cClipsPeriod": "Intervalle de temps", + "cClipsCustomPeriodInDays": "Intervalle de temps personnalisé (jours)", + "cClipsNumOfClips": "Nombre de clips", + "cClipsShouldPlay": "Les clips doivent être joués", + "cClipsVolume": "Volume" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/overlays/emotes.json b/backend/locales/fr/ui/overlays/emotes.json new file mode 100644 index 000000000..d4f7c07e8 --- /dev/null +++ b/backend/locales/fr/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Vider le cache", + "hypeMessagesEnabled": "Afficher les messages hype dans le chat", + "btnTestExplosion": "Tester l'explosion des emotes", + "btnTestEmote": "Émote de test", + "btnTestFirework": "Tester le feu d'artifice d'emotes", + "cEmotesSize": "Taille des emotes", + "cEmotesMaxEmotesPerMessage": "Maximum of emotes per message", + "cEmotesMaxRotation": "Maximal rotation of emote", + "cEmotesOffsetX": "Maximal offset on X-axis", + "cEmotesAnimation": "Animation", + "cEmotesAnimationTime": "Durée de l'animation", + "cExplosionNumOfEmotes": "Nbre d'emotes", + "cExplosionNumOfEmotesPerExplosion": "Nbre d'emotes par explosion", + "cExplosionNumOfExplosions": "Nbre d'explosions", + "enableEmotesCombo": "Activer le combo d'emotes", + "comboBreakMessages": "Messages de rupture de combo", + "threshold": "Seuil", + "noMessagesFound": "Aucun message trouvé.", + "message": "Message ", + "showEmoteInOverlayThreshold": "Seuil minimal de message pour afficher l'emote sur l'overlay", + "hideEmoteInOverlayAfter": { + "title": "Masquer l'emote sur l'overlay après inactivité", + "help": "Masquera l'emote sur l'overlay après un certain temps en secondes" + }, + "comboCooldown": { + "title": "Cooldown de combo", + "help": "Cooldown du combo en secondes" + }, + "comboMessageMinThreshold": { + "title": "Seuil minimal du message", + "help": "Seuil minimal de message pour compter les emotes comme un combo (jusqu'à ce que cela ne déclenche le cooldown)" + }, + "comboMessages": "Messages de combo" + }, + "hype": { + "5": "Let's go! Nous avons un combo de $amountx pour l'emote $emote jusqu'à présent ! SeemsGood", + "15": "Continuez comme ça ! Est-ce qu'on peut obtenir plus que $amountx $emote? Trihard" + }, + "message": { + "3": "Combo $amountx $emote", + "5": "$amountx $emote combo SeemsGood", + "10": "$amountx $emote combo PogChamp", + "15": "$amountx $emote combo TriHard", + "20": "$sender a ruiné le combo $emote qui était de $amountx ! NotLikeThis" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/overlays/polls.json b/backend/locales/fr/ui/overlays/polls.json new file mode 100644 index 000000000..a86caba60 --- /dev/null +++ b/backend/locales/fr/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Thème", + "cDisplayHideAfterInactivity": "Masquer en cas d'inactivité", + "cDisplayAlign": "Aligner", + "cDisplayInactivityTime": { + "title": "Inactif après", + "help": "en millisecondes" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/overlays/texttospeech.json b/backend/locales/fr/ui/overlays/texttospeech.json new file mode 100644 index 000000000..286587b4a --- /dev/null +++ b/backend/locales/fr/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "Vous n'avez pas correctement défini ResponsiveVoice key", + "voice": { + "title": "Voix", + "help": "Si les voix ne se chargent pas correctement après la mise à jour de la clé ResponsiveVoice, essayez de rafraîchir le navigateur" + }, + "volume": "Volume", + "rate": "Taux", + "pitch": "Pitch", + "triggerTTSByHighlightedMessage": "La synthèse vocale sera déclenchée par un message surligné" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/properties.json b/backend/locales/fr/ui/properties.json new file mode 100644 index 000000000..e6243cf72 --- /dev/null +++ b/backend/locales/fr/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Command", + "variableName": "Variable name", + "price": "Price (points)", + "priceBits": "Price (bits)", + "thisvalue": "This value", + "promo": { + "shoutoutMessage": "Shoutout message", + "enableShoutoutMessage": "Send shoutout message in chat" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/registry/alerts.json b/backend/locales/fr/ui/registry/alerts.json new file mode 100644 index 000000000..f85c27579 --- /dev/null +++ b/backend/locales/fr/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Activé", + "testDlg": { + "alertTester": "Testeur d'alerte", + "command": "Commande", + "username": "Nom d'utilisateur", + "recipient": "Destinataire", + "message": "Message ", + "tier": "Niveau", + "amountOfViewers": "Nombre de spectateurs", + "amountOfBits": "Nombre de bits", + "amountOfGifts": "Nombre de gifts", + "amountOfMonths": "Nombre de mois", + "amountOfTips": "Don", + "event": "Événement", + "service": "Service" + }, + "empty": "Le registre des alertes est vide, créez de nouvelles alertes.", + "emptyAfterSearch": "Le registre des alertes est vide dans la recherche de \"$search\"", + "revertcode": "Revenir au code par défaut", + "name": { + "name": "Nom", + "placeholder": "Définir le nom de vos alertes" + }, + "alertDelayInMs": { + "name": "Délai d'alerte" + }, + "parryEnabled": { + "name": "Esquives d'alertes" + }, + "parryDelay": { + "name": "Délai d'esquive d'alerte" + }, + "profanityFilterType": { + "name": "Filtre de profanité", + "disabled": "Désactivé", + "replace-with-asterisk": "Remplacer par un astérisque", + "replace-with-happy-words": "Remplacer par des mots heureux", + "hide-messages": "Masquer les messages", + "disable-alerts": "Désactiver les alertes" + }, + "loadStandardProfanityList": "Charger la liste de profanité standard", + "customProfanityList": { + "name": "Liste de profanités personnalisée", + "help": "Les mots doivent être séparés par des virgules." + }, + "event": { + "follow": "Follow", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Subgift to community", + "tip": "Tip", + "raid": "Raid", + "custom": "Custom", + "promo": "Promo", + "rewardredeem": "Reward Redeem" + }, + "title": { + "name": "Nom de la variante", + "placeholder": "Définissez le nom de votre variante" + }, + "variant": { + "name": "Occurence de variante" + }, + "filter": { + "name": "Filter", + "operator": "Opérateur", + "rule": "Règle", + "addRule": "Ajouter une règle", + "addGroup": "Ajouter un groupe", + "comparator": "Comparateur", + "value": "Valeur", + "valueSplitByComma": "Valeurs séparées par virgules (ex : val1, val2)", + "isEven": "est même", + "isOdd": "est impair", + "lessThan": "plus petit que", + "lessThanOrEqual": "inférieur ou égal à", + "contain": "contient", + "contains": "contains", + "equal": "égal", + "notEqual": "n'est pas égal à", + "present": "est présent", + "includes": "inclut", + "greaterThan": "est supérieur à", + "greaterThanOrEqual": "supérieur ou égal à", + "noFilter": "sans filtre" + }, + "speed": { + "name": "Vitesse" + }, + "maxTimeToDecrypt": { + "name": "Temps maximum pour décrypter" + }, + "characters": { + "name": "Caractères" + }, + "random": "Aléatoire", + "exact-amount": "Montant exact", + "greater-than-or-equal-to-amount": "Supérieur ou égal au montant", + "tier-exact-amount": "Le niveau est exactement", + "tier-greater-than-or-equal-to-amount": "Le niveau est supérieur ou égal à", + "months-exact-amount": "Le montant des mois est exactement", + "months-greater-than-or-equal-to-amount": "Le montant des mois est supérieur ou égal à", + "gifts-exact-amount": "Le montant des gifts est exactement", + "gifts-greater-than-or-equal-to-amount": "Le montant des gifts est supérieur ou égal à", + "very-rarely": "Très rarement", + "rarely": "Rarement", + "default": "Par défaut", + "frequently": "Fréquemment", + "very-frequently": "Très fréquemment", + "exclusive": "Exclusif", + "messageTemplate": { + "name": "Modèle de message", + "placeholder": "Mettez votre modèle de message", + "help": "Available variables: {name}, {amount} (cheers, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). If | is added (see promo) then it will show those values in sequence." + }, + "ttsTemplate": { + "name": "TTS template", + "placeholder": "Set your TTS template", + "help": "Available variables: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Animation du texte" + }, + "animationType": { + "name": "Type of animation" + }, + "animationIn": { + "name": "Arrivée de l'animation" + }, + "animationOut": { + "name": "Sortie de l'animation" + }, + "alertDurationInMs": { + "name": "Durée de l'alerte" + }, + "alertTextDelayInMs": { + "name": "Délai du texte d'alerte" + }, + "layoutPicker": { + "name": "Mise en page" + }, + "loop": { + "name": "Play on loop" + }, + "scale": { + "name": "Echelle" + }, + "translateY": { + "name": "Déplacer -Haut / +Bas" + }, + "translateX": { + "name": "Déplacer -Gauche / +Droite" + }, + "image": { + "name": "Image / Vidéo (.webm)", + "setting": "Paramètres de l'image / Vidéo(.webm)" + }, + "sound": { + "name": "Son", + "setting": "Paramètres audio" + }, + "soundVolume": { + "name": "Volume d'alerte" + }, + "enableAdvancedMode": "Activer le mode avancé", + "font": { + "setting": "Paramètres de la police", + "name": "Famille de polices", + "overrideGlobal": "Remplacer les paramètres globaux de police", + "align": { + "name": "Alignement", + "left": "Gauche", + "center": "Centre", + "right": "Droite" + }, + "size": { + "name": "Taille de la police" + }, + "weight": { + "name": "Épaisseur de la police" + }, + "borderPx": { + "name": "Contour de la police" + }, + "borderColor": { + "name": "Couleur de la bordure de la police" + }, + "color": { + "name": "Couleur de la police" + }, + "highlightcolor": { + "name": "Couleur de surlignage du texte" + } + }, + "minAmountToShow": { + "name": "Montant minimum à afficher" + }, + "minAmountToPlay": { + "name": "Montant minimum pour jouer" + }, + "allowEmotes": { + "name": "Autoriser les emotes" + }, + "message": { + "setting": "Paramètres des messages" + }, + "voice": "Voix", + "keepAlertShown": "L'alerte reste visible pendant le TTS", + "skipUrls": "Ignorer les URL pendant les TTS", + "volume": "Volume", + "rate": "Taux", + "pitch": "Pitch", + "test": "Test", + "tts": { + "setting": "Paramètres TTS" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/registry/goals.json b/backend/locales/fr/ui/registry/goals.json new file mode 100644 index 000000000..149ae5b25 --- /dev/null +++ b/backend/locales/fr/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Add Goal Group", + "addGoal": "Add Goal", + "newGoal": "new Goal", + "newGoalGroup": "new Goal Group", + "goals": "Goals", + "general": "Général", + "display": "Display", + "fontSettings": "Font Settings", + "barSettings": "Bar Settings", + "selectGoalOnLeftSide": "Select or add goal on left side", + "input": { + "description": { + "title": "Description" + }, + "goalAmount": { + "title": "Goal Amount" + }, + "countBitsAsTips": { + "title": "Count Bits as Tips" + }, + "currentAmount": { + "title": "Current Amount" + }, + "endAfter": { + "title": "End After" + }, + "endAfterIgnore": { + "title": "Goal will not expire" + }, + "borderPx": { + "title": "Border", + "help": "Border size is in pixels" + }, + "barHeight": { + "title": "Bar Height", + "help": "Bar height is in pixels" + }, + "color": { + "title": "Color" + }, + "borderColor": { + "title": "Border Color" + }, + "backgroundColor": { + "title": "Background Color" + }, + "type": { + "title": "Type" + }, + "nameGroup": { + "title": "Name of this goal group" + }, + "name": { + "title": "Name of this goal" + }, + "displayAs": { + "title": "Display as", + "help": "Sets how goal group will be shown" + }, + "durationMs": { + "title": "Duration", + "help": "This value is in milliseconds", + "placeholder": "How long goal should be shown" + }, + "animationInMs": { + "title": "Animation In duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation In duration" + }, + "animationOutMs": { + "title": "Animation Out duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation Out duration" + }, + "interval": { + "title": "What interval to count" + }, + "spaceBetweenGoalsInPx": { + "title": "Space between goals", + "help": "This value is in pixels", + "placeholder": "Set your space between goals" + } + }, + "groupSettings": "Group Settings" +} \ No newline at end of file diff --git a/backend/locales/fr/ui/registry/overlays.json b/backend/locales/fr/ui/registry/overlays.json new file mode 100644 index 000000000..f56199cb8 --- /dev/null +++ b/backend/locales/fr/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Create new overlay link mapping", + "emptyMapping": "No overlay link mapping were created yet.", + "allowedIPs": { + "name": "Allowed IPs", + "help": "Allow access from set IPs separated by new line" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/registry/plugins.json b/backend/locales/fr/ui/registry/plugins.json new file mode 100644 index 000000000..00eb1444f --- /dev/null +++ b/backend/locales/fr/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "This node needs to be linked with listeners with sender attributes" + }, + "filter": { + "permission": { + "name": "Permission filter" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Event listener", + "type": { + "twitchChatMessage": "Twitch chat message", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat cleared", + "twitchCommand": "Twitch command", + "twitchFollow": "New Twitch follower", + "twitchSubscription": "New Twitch subscription", + "twitchSubgift": "New Twitch subscription gift", + "twitchSubcommunitygift": "New Twitch subscription community gift", + "twitchResub": "New Twitch recurring subscription", + "twitchGameChanged": "Twitch category changed", + "twitchStreamStarted": "Twitch stream started", + "twitchStreamStopped": "Twitch stream stopped", + "twitchRewardRedeem": "Twitch reward redeemed", + "twitchRaid": "Twitch raid incoming", + "tip": "Tipped by user", + "botStarted": "Bot started" + }, + "command": { + "add-parameter": "Add parameter", + "parameters": "Parameters", + "order-is-important": "order is important" + } + }, + "others": { + "idle": { + "name": "Idle" + } + }, + "output": { + "log": { + "name": "Log message" + }, + "timeout-user": { + "name": "Timeout user" + }, + "ban-user": { + "name": "Ban user" + }, + "send-twitch-message": { + "name": "Send Twitch Message" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/registry/randomizer.json b/backend/locales/fr/ui/registry/randomizer.json new file mode 100644 index 000000000..5f728918b --- /dev/null +++ b/backend/locales/fr/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Add Randomizer", + "form": { + "name": "Name", + "command": "Command", + "permission": "Command permission", + "simple": "Simple", + "tape": "Tape", + "wheelOfFortune": "Wheel of Fortune", + "type": "Type", + "options": "Options", + "optionsAreEmpty": "Options are empty.", + "color": "Color", + "numOfDuplicates": "No. of duplicates", + "minimalSpacing": "Minimal spacing", + "groupUp": "Group Up", + "ungroup": "Ungroup", + "groupedWithOptionAbove": "Grouped with option above", + "generatedOptionsPreview": "Preview of generated options", + "probability": "Probability", + "tick": "Tick sound during spin" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/registry/textoverlay.json b/backend/locales/fr/ui/registry/textoverlay.json new file mode 100644 index 000000000..03e974b69 --- /dev/null +++ b/backend/locales/fr/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Create new text overlay", + "title": "text overlay", + "name": { + "placeholder": "Set your text overlay name" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/stats/commandcount.json b/backend/locales/fr/ui/stats/commandcount.json new file mode 100644 index 000000000..ffc633ee7 --- /dev/null +++ b/backend/locales/fr/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Command", + "hour": "Heure", + "day": "Jour", + "week": "Semaine", + "month": "Mois", + "year": "Year", + "total": "Total" +} \ No newline at end of file diff --git a/backend/locales/fr/ui/systems/checklist.json b/backend/locales/fr/ui/systems/checklist.json new file mode 100644 index 000000000..1742f9c5c --- /dev/null +++ b/backend/locales/fr/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "itemsArray": "Liste" + }, + "check": "Checklist" +} \ No newline at end of file diff --git a/backend/locales/fr/ui/systems/howlongtobeat.json b/backend/locales/fr/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..a9dcc7f7a --- /dev/null +++ b/backend/locales/fr/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Status" + }, + "empty": "No games were tracked yet.", + "emptyAfterSearch": "No tracked games were found by your search for \"$search\".", + "when": "When streamed", + "time": "Tracked time", + "overallTime": "Overall time", + "offset": "Offset of tracked time", + "main": "Main", + "extra": "Main+Extra", + "completionist": "Completionist", + "game": "Tracked game", + "startedAt": "Tracking started at", + "updatedAt": "Last update", + "showHistory": "Show history ($count)", + "hideHistory": "Hide history ($count)", + "searchToAddNewGame": "Search to add new game to track" +} \ No newline at end of file diff --git a/backend/locales/fr/ui/systems/keywords.json b/backend/locales/fr/ui/systems/keywords.json new file mode 100644 index 000000000..9e725400f --- /dev/null +++ b/backend/locales/fr/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "New Keyword", + "empty": "No keywords were created yet.", + "emptyAfterSearch": "No keywords were found by your search for \"$search\".", + "keyword": { + "name": "Keyword / Regular Expression", + "placeholder": "Set your keyword or regular expression to trigger keyword.", + "help": "You can use regexp (case insensitive) to use keywords, e.g. hello.*|hi" + }, + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "no-responses-set": "No responses", + "addResponse": "Add response", + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/systems/levels.json b/backend/locales/fr/ui/systems/levels.json new file mode 100644 index 000000000..91bcaa02b --- /dev/null +++ b/backend/locales/fr/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Status", + "conversionRate": "Conversion rate 1 XP for x Points", + "firstLevelStartsAt": "First level starts at XP", + "nextLevelFormula": { + "title": "Next level calculation formula", + "help": "Available variables: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "Levels example will be refreshed on save", + "xpName": "Name", + "interval": "Minutes interval to add xp to online users when stream online", + "offlineInterval": "Minutes interval to add xp to online users when stream offline", + "messageInterval": "How many messages to add xp", + "messageOfflineInterval": "How many messages to add xp when stream offline", + "perInterval": "How many xp to add per online interval", + "perOfflineInterval": "How many xp to add per offline interval", + "perMessageInterval": "How many xp to add per message interval", + "perMessageOfflineInterval": "How many xp to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/systems/polls.json b/backend/locales/fr/ui/systems/polls.json new file mode 100644 index 000000000..f31a08052 --- /dev/null +++ b/backend/locales/fr/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Total votes", + "totalPoints": "Total points", + "closedAt": "Closed at", + "activeFor": "Active for" +} \ No newline at end of file diff --git a/backend/locales/fr/ui/systems/scrim.json b/backend/locales/fr/ui/systems/scrim.json new file mode 100644 index 000000000..6b719fc3b --- /dev/null +++ b/backend/locales/fr/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "waitForMatchIdsInSeconds": { + "title": "Interval for putting match ID into chat", + "help": "Set in seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/systems/top.json b/backend/locales/fr/ui/systems/top.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/fr/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/fr/ui/systems/userinfo.json b/backend/locales/fr/ui/systems/userinfo.json new file mode 100644 index 000000000..c8bba8b80 --- /dev/null +++ b/backend/locales/fr/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "formatSeparator": "Format separator", + "order": "Format", + "lastSeenFormat": { + "title": "Time format", + "help": "Possible formats at https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/it.json b/backend/locales/it.json new file mode 100644 index 000000000..38c1c5635 --- /dev/null +++ b/backend/locales/it.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "è caricato e", + "enabled": "abilitato", + "disabled": "disattivato", + "usage": "Utilizzo", + "lang-selected": "La lingua del bot è attualmente impostata in italiano", + "refresh-panel": "Sarà necessario aggiornare l'interfaccia utente per vedere le modifiche.", + "command-parse": "Siamo spiacenti, $sender, ma questo comando non è corretto, usa", + "error": "Siamo spiacenti, $sender, ma qualcosa è andato storto!", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, l'API non risponde correttamente!", + "not-available": "non disponibile" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "anno|anni", + "months": "mese|mesi", + "days": "giorno|giorni", + "hours": "ora|ore", + "minutes": "minuto|minuti", + "seconds": "secondo|secondi", + "messages": "messaggio|messaggi", + "bits": "bit|bits", + "links": "link|links", + "entries": "voce|voci", + "empty": "vuoto", + "isRegistered": "$sender, non puoi usare!$keyword, perché è già in uso per un'altra azione!" + }, + "clip": { + "notCreated": "Something went wrong and clip was not created.", + "offline": "Stream is currently offline and clip cannot be created." + }, + "uptime": { + "online": "Lo stream è online da (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)", + "offline": "Lo stream è offline da (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)" + }, + "webpanel": { + "this-system-is-disabled": "Questo sitema è disattivato", + "or": "or", + "loading": "Caricamento", + "this-may-take-a-while": "Questo potrebbe richiedere un po' di tempo", + "display-as": "Visualizza come", + "go-to-admin": "Vai ad Admin", + "go-to-public": "Vai a Pubblico", + "logout": "Logout", + "popout": "Popout", + "not-logged-in": "Login non effettuato", + "remove-widget": "Rimuovi il widget $name", + "join-channel": "Fai entrare il bot nel canale", + "leave-channel": "Fai uscire il bot dal canale", + "set-default": "Imposta come predefinito", + "add": "Aggiungi", + "placeholders": { + "text-url-generator": "Incolla il tuo testo o html per generare il base64 qui sotto e l'URL qui sopra", + "text-decode-base64": "Incolla il tuo base64 per generare URL ed il testo qui sopra", + "creditsSpeed": "Imposta la velocità di scorrimento dei titoli di coda, lentissimo ---> velocissimo" + }, + "timers": { + "title": "Timers", + "timer": "Timer", + "messages": "messaggi", + "seconds": "secondi", + "badges": { + "enabled": "Abilitato", + "disabled": "Disabilitato" + }, + "errors": { + "timer_name_must_be_compliant": "Questo valore può contenere solo a-zA-Z09_", + "this_value_must_be_a_positive_number_or_0": "Questo valore deve essere un numero positivo o 0", + "value_cannot_be_empty": "Il valore non può essere vuoto" + }, + "dialog": { + "timer": "Timer", + "name": "Nome", + "tickOffline": "Fallo funzionare se lo streaming è offline", + "interval": "Intervallo", + "responses": "Risposte", + "messages": "Attiva ogni X Messaggi", + "seconds": "Attiva ogni X Secondi", + "title": { + "new": "Nuovo timer", + "edit": "Modifica timer" + }, + "placeholders": { + "name": "Imposta il nome del tuo timer, può contenere solo questi caratteri a-zA-Z0-9_", + "messages": "Esegui timer ogni X messaggi", + "seconds": "Esegui timer ogni X secondi" + }, + "alerts": { + "success": "Il timer è stato salvato correttamente.", + "fail": "Qualcosa è andato storto." + } + }, + "buttons": { + "close": "Chiudi", + "save-changes": "Salva modifiche", + "disable": "Disattiva", + "enable": "Attiva", + "edit": "Modifica", + "delete": "Elimina", + "yes": "Si", + "no": "No" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Sei sicuro di voler eliminare il timer" + } + }, + "events": { + "event": "Evento", + "noEvents": "Nessun evento trovato nel database.", + "whatsthis": "cos'è questo?", + "myRewardIsNotListed": "La mia ricompensa non è elencata!", + "redeemAndClickRefreshToSeeReward": "Se manca la ricompensa creata in una lista, aggiorna facendo clic sull'icona di aggiornamento.", + "badges": { + "enabled": "Abilitato", + "disabled": "Disabilitato" + }, + "buttons": { + "test": "Test", + "enable": "Attiva", + "disable": "Disattiva", + "edit": "Modifica", + "delete": "Elimina", + "yes": "Si", + "no": "No" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Sei sicuro di voler eliminare l'evento", + "example_of_user_object_data": "Esempio di dati di oggetto utente" + }, + "errors": { + "command_must_start_with_!": "Il comando deve iniziare con !", + "this_value_must_be_a_positive_number_or_0": "Questo valore deve essere un numero positivo o 0", + "value_cannot_be_empty": "Il valore non può essere vuoto" + }, + "dialog": { + "title": { + "new": "Nuovo registratore eventi", + "edit": "Modifica registratore di eventi" + }, + "placeholders": { + "name": "Imposta il nome del tuo registratore di eventi (se vuoto, verrà generato un nome casuale)" + }, + "alerts": { + "success": "L'evento è stato correttamente salvato.", + "fail": "Qualcosa è andato storto." + }, + "close": "Chiudi", + "save-changes": "Salvare le modifiche", + "event": "Evento", + "name": "Nome", + "usable-events-variables": "Variabili utilizzabili dagli eventi", + "settings": "Impostazioni", + "filters": "Filtri", + "operations": "Operazioni" + }, + "definitions": { + "taskId": { + "label": "ID Attività" + }, + "filter": { + "label": "Filtro" + }, + "linkFilter": { + "label": "Link filtro Overlay", + "placeholder": "Se si utilizzi un overlay, aggiungi il link o l'id del tuo overlay" + }, + "hashtag": { + "label": "Hashtag o Parola chiave", + "placeholder": "#tuoHashtagQui o Parola chiave" + }, + "fadeOutXCommands": { + "label": "Dissolvenza X Comandi", + "placeholder": "Numero di comandi sottratti ogni intervallo di dissolvenza" + }, + "fadeOutXKeywords": { + "label": "Dissolvenza X Parole Chiave", + "placeholder": "Numero di parole chiave sottratte ad ogni intervallo di dissolvenza" + }, + "fadeOutInterval": { + "label": "Intervallo di Dissolvenza (secondi)", + "placeholder": "Intervallo di dissolvenza sottratto" + }, + "runEveryXCommands": { + "label": "Esegui Ogni X Comandi", + "placeholder": "Numero di comandi prima che l'evento venga attivato" + }, + "runEveryXKeywords": { + "label": "Esegui Ogni X Keywords", + "placeholder": "Numero di parole chiave prima che l'evento venga attivato" + }, + "commandToWatch": { + "label": "Comando Da Osservare", + "placeholder": "Imposta il tuo !comandoDaOsservare" + }, + "keywordToWatch": { + "label": "Parola Chiave Da Osservare", + "placeholder": "Imposta la parolaChiaveDaOsservare" + }, + "resetCountEachMessage": { + "label": "Resetta contatore ad ogni messaggio", + "true": "Resetta contatore", + "false": "Mantieni il conteggio" + }, + "viewersAtLeast": { + "label": "Numero minimo di spettatori", + "placeholder": "Numero minimo spettatori per attivare l'evento" + }, + "runInterval": { + "label": "Intervallo d'esecuzione (0 = esegui una volta per stream)", + "placeholder": "Attiva l'evento ogni x secondi" + }, + "runAfterXMinutes": { + "label": "Esegui Dopo X Minuti", + "placeholder": "Attiva evento dopo x minuti" + }, + "runEveryXMinutes": { + "label": "Esegui Ogni X Minuti", + "placeholder": "Attiva l'evento ogni x minuti" + }, + "messageToSend": { + "label": "Messaggio Da Inviare", + "placeholder": "Imposta il tuo messaggio" + }, + "channel": { + "label": "Canale", + "placeholder": "NomeCanale o ID" + }, + "timeout": { + "label": "Timeout", + "placeholder": "Set timeout in milliseconds" + }, + "timeoutType": { + "label": "Type of timeout", + "placeholder": "Set type of timeout" + }, + "command": { + "label": "Command", + "placeholder": "Set your !command" + }, + "commandToRun": { + "label": "Comando Da Eseguire", + "placeholder": "Imposta il tuo !comandoDaEseguire" + }, + "isCommandQuiet": { + "label": "Mute command output" + }, + "urlOfSoundFile": { + "label": "Url Del Tuo File Audio", + "placeholder": "http://www.pathToYour.url/where/is/file.mp3" + }, + "emotesToExplode": { + "label": "Emote Da Esplodere", + "placeholder": "Elenco di emotes da far esplodere, ad esempio Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Emotes per Fuochi D'Artificio", + "placeholder": "Elenco delle emotes per fuoco d'artificio, ad esempio Kappa PurpleHeart" + }, + "replay": { + "label": "Replay clip in overlay", + "true": "Will play in as replay in overlay/alerts", + "false": "Replay won't be played" + }, + "announce": { + "label": "Annuncio in chat", + "true": "Sarà annunciato", + "false": "Non sarà annunciato" + }, + "hasDelay": { + "label": "Clip dovrebbe avere un leggero ritardo (per essere più vicino a ciò che lo spettatore vede)", + "true": "Avrà dei ritardi", + "false": "Non avrà ritardi" + }, + "durationOfCommercial": { + "label": "Durata della pubblicità", + "placeholder": "Durate disponibili - 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Variabile personalizzata da aggiornare" + }, + "numberToIncrement": { + "label": "Numero da aumentare", + "placeholder": "" + }, + "value": { + "label": "Valore", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Numero da diminuire", + "placeholder": "" + }, + "": "", + "reward": { + "label": "Reward", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Followed you", + "raid": "Raided you with $viewers raiders.", + "sub": "Subscribed to you with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "has been gifted subscription from $username", + "subcommunitygift": "Gifted subscriptions for community", + "resub": "Resubscribed with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Cheered you", + "tip": "Tipped you", + "tipToCharity": "donated to $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Twitch Prediction - Title", + "outcomes": "Twitch Prediction - Outcomes", + "locksAt": "Twitch Prediction - Locks At Date", + "winningOutcomeTitle": "Twitch Prediction - Winning outcome title", + "winningOutcomeTotalPoints": "Twitch Prediction - Winning outcome total points", + "winningOutcomePercentage": "Twitch Prediction - Winning outcome percentage", + "titleOfPoll": "Twitch Poll - Title", + "bitAmountPerVote": "Twitch Poll - Amount of bits to count as 1 vote", + "bitVotingEnabled": "Twitch Poll - Is bit voting enabled (boolean)", + "channelPointsAmountPerVote": "Twitch Poll - Amount of channel points to count as 1 vote", + "channelPointsVotingEnabled": "Twitch Poll - Is channel points voting enabled (boolean)", + "votes": "Twitch Poll - votes count", + "winnerChoice": "Twitch Poll - Winner choice", + "winnerPercentage": "Twitch Poll - Winner choice percentage", + "winnerVotes": "Twitch Poll - Winner choice votes", + "goal": "Goal", + "total": "Totale", + "lastContributionTotal": "Ultimo Contributo - Totale", + "lastContributionType": "Ultimo Contributo - Tipo", + "lastContributionUserId": "Ultimo Contributo - ID Utente", + "lastContributionUsername": "Ultimo Contributo - Nome Utente", + "level": "Livello", + "topContributionsBitsTotal": "Contributo Top Bits - Totale", + "topContributionsBitsUserId": "Contributo Top Bits - ID Utente", + "topContributionsBitsUsername": "Contributo Top Bits - Nome Utente", + "topContributionsSubsTotal": "Contributo Top Subs - Totale", + "topContributionsSubsUserId": "Contributo Top Subs - ID Utente", + "topContributionsSubsUsername": "Contributo Top Subs - Nome Utente", + "sender": "Utente che ha iniziato", + "title": "Titolo attuale", + "game": "Current category", + "language": "Lingua della diretta attuale", + "viewers": "Conteggio spettatori attuali", + "hostViewers": "Raid viewers count", + "followers": "Numero di follower attuali", + "subscribers": "Conteggio abbonati attuali", + "arg": "Argomento", + "param": "Parametro (obbligatorio)", + "touser": "Parametro nome utente", + "!param": "Parametro (non obbligatorio)", + "alias": "Alias", + "command": "Comando", + "keyword": "Parola chiave", + "response": "Risposta", + "list": "Lista popolata", + "type": "Tipo", + "days": "Giorni", + "hours": "Ore", + "minutes": "Minuti", + "seconds": "Secondi", + "description": "Descrizione", + "quiet": "Silenzioso (bool)", + "id": "ID", + "name": "Nome", + "messages": "Messaggi", + "amount": "Importo", + "amountInBotCurrency": "Importo nella valuta del bot", + "currency": "Valuta", + "currencyInBot": "Valuta del bot", + "pointsName": "Nome dei punti", + "points": "Punti", + "rank": "Rango", + "nextrank": "Rango successivo", + "username": "Nome Utente", + "value": "Valore", + "variable": "Variabile", + "count": "Conteggio", + "link": "Link (tradotto)", + "winner": "Vincitore", + "loser": "Perdente", + "challenger": "Sfidante", + "min": "Minimo", + "max": "Massimo", + "eligibility": "Ammissibilità", + "probability": "Probabilità", + "time": "Tempo", + "options": "Opzioni", + "option": "Opzione", + "when": "Quando", + "diff": "Differenza", + "users": "Utenti", + "user": "Utente", + "bank": "Banca", + "nextBank": "Prossima banca", + "cooldown": "Tempo di attesa", + "tickets": "Tickets", + "ticketsName": "Nome ticket", + "fromUsername": "Dal nome utente", + "toUsername": "Al nome utente", + "items": "Oggetti", + "bits": "Bits", + "subgifts": "Abbonamenti regalo", + "subStreakShareEnabled": "È abilitata la condivisione di substreak (true/false)", + "subStreak": "Substreak attuale", + "subStreakName": "nome localizzato del mese (1 mese, 2 mesi) per substrek corrente", + "subCumulativeMonths": "Mesi cumulativi di abbonamento", + "subCumulativeMonthsName": "nome localizzato del mese (1 mese, 2 mesi) per mesi cumulativi abbonamenti", + "message": "Messaggio", + "reason": "Motivazione", + "target": "Obiettivo", + "duration": "Durata", + "method": "Metodo", + "tier": "Livello", + "months": "Mesi", + "monthsName": "nome localizzato del mese (1 mese, 2 mesi)", + "oldGame": "Category before change", + "recipientObject": "Oggetto completo del destinatario", + "recipient": "Destinatario", + "ytSong": "Canzone corrente su YouTube", + "spotifySong": "Canzone corrente su Spotify", + "latestFollower": "Ultimo Follower", + "latestSubscriber": "Ultimo Abbonato", + "latestSubscriberMonths": "Mesi cumulativi dell'Ultimo Abbonato", + "latestSubscriberStreak": "Serie dei mesi Ultimo Abbonato", + "latestTipAmount": "Ultima Donazione (importo)", + "latestTipCurrency": "Ultima Donazione (valuta)", + "latestTipMessage": "Ultima Donazione (messaggio)", + "latestTip": "Ultima Donazione (username)", + "toptip": { + "overall": { + "username": "Top Donazione - di sempre (username)", + "amount": "Top Donazione - di sempre (importo)", + "currency": "Top Donazione - di sempre (valuta)", + "message": "Top Donazione - di sempre (messaggio)" + }, + "stream": { + "username": "Top Donazione - durante lo streaming (username)", + "amount": "Top Donazione - durante lo streaming (importo)", + "currency": "Top Donazione - durante lo streaming (valuta)", + "message": "Top Donazione - durante lo streaming (messaggio)" + } + }, + "latestCheerAmount": "Ultimi Bits (quantità)", + "latestCheerMessage": "Ultimi Bits (messaggio)", + "latestCheer": "Ultimi Bits (username)", + "version": "Versione Bot", + "haveParam": "Ha il parametro di comando? (bool)", + "source": "Sorgente corrente (twitch o discord)", + "userInput": "Inserimento utente durante il riscatto dei premi", + "isBotSubscriber": "È abbonato al bot (bool)", + "isStreamOnline": "Lo stream è online (bool)", + "uptime": "Uptime of stream", + "is": { + "moderator": "L'utente è mod? (bool)", + "subscriber": "L'utente è abbonato? (bool)", + "vip": "L'utente è vip? (bool)", + "newchatter": "Is user's first message? (bool)", + "follower": "L'utente è follower? (bool)", + "broadcaster": "L'utente è emittente? (bool)", + "bot": "L'utente è bot? (bool)", + "owner": "L'utente è il proprietario del bot (bool)" + }, + "recipientis": { + "moderator": "Il destinatario è moderatore? (bool)", + "subscriber": "Il destinatario è abbonato? (bool)", + "vip": "Il destinatario è vip? (bool)", + "follower": "Il destinatario è follower? (bool)", + "broadcaster": "Il destinatario è streamer? (bool)", + "bot": "Il destinatario è bot? (bool)", + "owner": "Il destinatario è proprietario del bot? (bool)" + }, + "sceneName": "Nome della scena", + "inputName": "Name of input", + "inputMuted": "Mute state (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Altri", + "currency": "Valuta" + }, + "whispers": { + "title": "Messaggi privati", + "toggle": { + "listener": "Ascolta i comandi nei messaggi privati", + "settings": "Messaggi privati sulla modifica delle impostazioni", + "raffle": "Messaggi privati per iscriversi alla riffa", + "permissions": "Messaggi privati sui permessi insufficienti", + "cooldowns": "Sussurri sui tempi di ricarica (se impostati come notifica)" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Messaggi", + "follows": "Segue", + "subs": "Abbonamenti & Abbonamenti Rinnovati", + "cheers": "Bits", + "responses": "Risposte Bot", + "whispers": "Messaggi privati", + "bans": "Ban", + "timeouts": "Timeouts" + }, + "range": { + "day": "un giorno", + "week": "una settimana", + "month": "un mese", + "year": "un anno", + "all": "Per sempre" + }, + "order": { + "asc": "Crescente", + "desc": "Decrescente" + }, + "labels": { + "order": "ORDINE", + "range": "INTERVALLO", + "filters": "FILTRI" + } + }, + "stats-panel": { + "show": "Mostra statistiche", + "hide": "Nascondi statistiche" + }, + "translations": "Traduzioni personalizzate", + "bot-responses": "Risposte Bot", + "duration": "Durata", + "viewers-reset-attributes": "Reimposta gli attributi", + "viewers-points-of-all-users": "Punti di tutti gli utenti", + "viewers-watchtime-of-all-users": "Tempo da spettatore per tutti gli utenti", + "viewers-messages-of-all-users": "Messaggi di tutti gli utenti", + "events-game-after-change": "category after change", + "events-game-before-change": "category before change", + "events-user-triggered-event": "evento attivato dall'utente", + "events-method-used-to-subscribe": "metodo usato per iscriversi", + "events-months-of-subscription": "mesi di abbonamento", + "events-monthsName-of-subscription": "parola 'mese' per numero (1 mese, 2 mesi)", + "events-user-message": "messaggio dell'utente", + "events-bits-user-sent": "bit inviati all'utente", + "events-reason-for-ban-timeout": "motivo del divieto/sospensione", + "events-duration-of-timeout": "durata della sospensione", + "events-duration-of-commercial": "durata della pubblicità", + "overlays-eventlist-resub": "riabbonati", + "overlays-eventlist-subgift": "abbonamenti regalo", + "overlays-eventlist-subcommunitygift": "abbonamenti-comunità-in-regalo", + "overlays-eventlist-sub": "abbonati", + "overlays-eventlist-follow": "follow", + "overlays-eventlist-cheer": "bits", + "overlays-eventlist-tip": "donazione", + "overlays-eventlist-raid": "raid", + "requested-by": "Richiesto da", + "description": "Descrizione", + "raffle-type": "Tipo di lotteria", + "raffle-type-keywords": "Solo parola chiave", + "raffle-type-tickets": "Con i biglietti", + "raffle-tickets-range": "Intervallo di biglietti", + "video_id": "ID video", + "highlights": "Punti salienti", + "cooldown-quiet-header": "Mostra messaggio di attesa", + "cooldown-quiet-toggle-no": "Notificare", + "cooldown-quiet-toggle-yes": "Non notificare", + "cooldown-moderators": "Moderatori", + "cooldown-owners": "Proprietari", + "cooldown-subscribers": "Abbonati", + "cooldown-followers": "Followers", + "in-seconds": "in secondi", + "songs": "Brani", + "show-usernames-with-at": "Mostra nomi utente con @", + "send-message-as-a-bot": "Invia messaggio come bot", + "chat-as-bot": "Chat (come bot)", + "product": "Prodotto", + "optional": "facoltativo", + "placeholder-search": "Cerca", + "placeholder-enter-product": "Inserisci prodotto", + "placeholder-enter-keyword": "Inserisci parola chiave", + "credits": "Riconoscimenti", + "fade-out-top": "dissolvenza verso l'alto", + "fade-out-zoom": "dissolvenza di zoom", + "global": "Globale", + "user": "Utente", + "alerts": "Avvisi", + "eventlist": "Lista Eventi", + "dashboard": "Pannello di controllo", + "carousel": "Carosello Immagini", + "text": "Testo", + "filter": "Filter", + "filters": "Filters", + "isUsed": "Is used", + "permissions": "Permessi", + "permission": "Permesso", + "viewers": "Spettatori", + "systems": "Sistemi", + "overlays": "Overlays", + "gallery": "Galleria multimediale", + "aliases": "Alias", + "alias": "Alias", + "command": "Comando", + "cooldowns": "Tempo di ricarica", + "title-template": "Titolo modello", + "keyword": "Parola chiave", + "moderation": "Moderazione", + "timer": "Timer", + "price": "Prezzo", + "rank": "Rango", + "previous": "Precedente", + "next": "Prossimo", + "close": "Chiudi", + "save-changes": "Salva le modifiche", + "saving": "Salvataggio in corso...", + "deleting": "Cancellazione in corso...", + "done": "Fatto", + "error": "Errore", + "title": "Titolo", + "change-title": "Cambia il titolo", + "game": "category", + "tags": "Tags", + "change-game": "Change category", + "click-to-change": "clicca per cambiare", + "uptime": "uptime", + "not-affiliate-or-partner": "Non affiliato/partner", + "not-available": "Non Disponibile", + "max-viewers": "Spettatori massimi", + "new-chatters": "Nuovi Chatters", + "chat-messages": "Messaggi chat", + "followers": "Followers", + "subscribers": "Iscritti", + "bits": "Bits", + "subgifts": "Abbonamenti regalo", + "subStreak": "Substreak attuale", + "subCumulativeMonths": "Mesi cumulativi di abbonamento", + "tips": "Donazioni", + "tier": "Livello", + "status": "Stato", + "add-widget": "Aggiungi widget", + "remove-dashboard": "Rimuovi pannello", + "close-bet-after": "Chiudi la scommessa dopo", + "refund": "rimborso", + "roll-again": "Rilancia", + "no-eligible-participants": "Nessun partecipante idoneo", + "follower": "Follower", + "subscriber": "Abbonato", + "minutes": "minuti", + "seconds": "secondi", + "hours": "ore", + "months": "mesi", + "eligible-to-enter": "Ha facoltà di entrare", + "everyone": "Tutti", + "roll-a-winner": "Sorteggia un vincitore", + "send-message": "Invia un messaggio", + "messages": "Messaggi", + "level": "Livello", + "create": "Crea", + "cooldown": "Ricarica", + "confirm": "Conferma", + "delete": "Elimina", + "enabled": "Attivato", + "disabled": "Disattivato", + "enable": "Attiva", + "disable": "Disattiva", + "slug": "Slug", + "posted-by": "Pubblicato da", + "time": "Tempo", + "type": "Tipo", + "response": "Risposta", + "cost": "Costo", + "name": "Nome", + "playlist": "Scaletta", + "length": "Durata", + "volume": "Volume", + "start-time": "Ora Di Inizio", + "end-time": "Orario Fine", + "watched-time": "Tempo di osservazione", + "currentsong": "Brano attuale", + "group": "Gruppo", + "followed-since": "Seguito dal", + "subscribed-since": "Abbonato dal", + "username": "Nome Utente", + "hashtag": "Hashtag", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "Scopes", + "last-seen": "Visto l'ultima volta", + "date": "Data", + "points": "Punti", + "calendar": "Calendario", + "string": "stringa", + "interval": "Intervallo", + "number": "numero", + "minimal-messages-required": "Messaggi Minimi Richiesti", + "max-duration": "Durata massima", + "shuffle": "Casuale", + "song-request": "Richiesta Brano", + "format": "Formato", + "available": "Disponibile", + "one-record-per-line": "un elemento per riga", + "on": "acceso", + "off": "spento", + "search-by-username": "Cerca per nome utente", + "widget-title-custom": "PERSONALIZZATO", + "widget-title-eventlist": "LISTA-EVENTI", + "widget-title-chat": "CHAT", + "widget-title-queue": "CODA", + "widget-title-raffles": "TOMBOLE", + "widget-title-social": "SOCIAL", + "widget-title-ytplayer": "LETTORE MUSICALE", + "widget-title-monitor": "MONITOR", + "event": "evento", + "operation": "operazione", + "tweet-post-with-hashtag": "Tweet pubblicato con hashtag", + "user-joined-channel": "l'utente si è unito a un canale", + "user-parted-channel": "utente ha lasciato un canale", + "follow": "nuovo follow", + "tip": "nuova mancia", + "obs-scene-changed": "Scena OBS cambiata", + "obs-input-mute-state-changed": "OBS input source mute state changed", + "unfollow": "unfollow", + "hypetrain-started": "Hype Train avviato", + "hypetrain-ended": "Hype Train terminato", + "prediction-started": "Twitch Prediction started", + "prediction-locked": "Twitch Prediction locked", + "prediction-ended": "Twitch Prediction ended", + "poll-started": "Twitch Poll started", + "poll-ended": "Twitch Poll ended", + "hypetrain-level-reached": "Nuovo livello di Hype Train raggiunto", + "subscription": "nuovo abbonamento", + "subgift": "nuovo abbonamento regalato", + "subcommunitygift": "nuovo abbonamento dato alla comunità", + "resub": "utente riabbonato", + "command-send-x-times": "il comando è stato inviato x volte", + "keyword-send-x-times": "parola chiave è stata inviata x volte", + "number-of-viewers-is-at-least-x": "il numero di spettatori è almeno x", + "stream-started": "stream avviato", + "reward-redeemed": "ricompensa riscattata", + "stream-stopped": "stream terminato", + "stream-is-running-x-minutes": "lo stream è in esecuzione da x minuti", + "chatter-first-message": "first message of chatter", + "every-x-minutes-of-stream": "ogni x minuti di streaming", + "game-changed": "category changed", + "cheer": "bits ricevuti", + "clearchat": "la chat è stata cancellata", + "action": "l'utente ha inviato /me", + "ban": "l'utente è stato bannato", + "raid": "il tuo canale è stato raidato", + "mod": "l'utente è un nuovo mod", + "timeout": "l'utente è stato timeouttato", + "create-a-new-event-listener": "Crea un nuovo listener di eventi", + "send-discord-message": "invia un messaggio discord", + "send-chat-message": "invia un messaggio di chat twitch", + "send-whisper": "invia un messaggio privato", + "run-command": "esegui un comando", + "run-obswebsocket-command": "esegui un comando OBS Websocket", + "do-nothing": "--- non fare nulla ---", + "count": "conteggio", + "timestamp": "marcatura temporale", + "message": "messaggio", + "sound": "suono", + "emote-explosion": "esplosione emote", + "emote-firework": "fuochi d'artificio emote", + "quiet": "silenzioso", + "noisy": "rumoroso", + "true": "vero", + "false": "falso", + "light": "tema chiaro", + "dark": "tema scuro", + "gambling": "Gioco d’azzardo", + "seppukuTimeout": "Timeout per !seppuku", + "rouletteTimeout": "Timeout per !roulette", + "fightmeTimeout": "Timeout per !fightme", + "duelCooldown": "Tempo di ricarica per !duel", + "fightmeCooldown": "Tempo di ricarica per !fightme", + "gamblingCooldownBypass": "Bypassa il tempo di recupero per gioco d'azzardo per i mods/streamer", + "click-to-highlight": "evidenzia", + "click-to-toggle-display": "attiva/disattiva visualizzazione", + "commercial": "la pubblicità è cominciata", + "start-commercial": "fai partire una pubblicità", + "bot-will-join-channel": "il bot entrerà nel canale", + "bot-will-leave-channel": "il bot uscirà dal canale", + "create-a-clip": "crea una clip", + "increment-custom-variable": "incrementa una variabile personalizzata", + "set-custom-variable": "imposta una variabile personalizzata", + "decrement-custom-variable": "diminuisci una variabile personalizzata", + "omit": "omettere", + "comply": "conforma", + "visible": "visibile", + "hidden": "nascosto", + "gamblingChanceToWin": "Possibilità di vincere !gamble", + "gamblingMinimalBet": "Scommessa minima per !gamble", + "duelDuration": "Durata di !duello", + "duelMinimalBet": "Scommessa minima per !duel" + }, + "raffles": { + "announceInterval": "Le lotterie aperte saranno annunciate ogni $value minuti", + "eligibility-followers-item": "followers", + "eligibility-subscribers-item": "abbonati", + "eligibility-everyone-item": "tutti", + "raffle-is-running": "La lotteria è in esecuzione ($count $l10n_entries).", + "to-enter-raffle": "Per inserire il tipo \"$keyword\". La lotteria è aperta per $eligibility.", + "to-enter-ticket-raffle": "Per inserire il tipo \"$keyword <$min-$max>\". La lotteria è aperta per $eligibility.", + "added-entries": "Aggiunto $count $l10n_entries alla lotteria ($countTotal totale). {raffles.to-enter-raffle}", + "added-ticket-entries": "Aggiunto $count $l10n_entries alla lotteria ($countTotal totale). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "I tuoi messaggi lotteria verranno eliminati al momento dell'adesione.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "Siamo spiacenti, $sender, ma non puoi creare la lotteria senza parola chiave", + "raffle-is-already-running": "Spiacenti, $sender, la lotteria è già in esecuzione con la parola chiave $keyword", + "no-raffle-is-currently-running": "$sender, nessuna lotteria senza vincitori è attualmente in corso", + "no-participants-to-pick-winner": "$sender, nessuno si è unito a una lotteria", + "raffle-winner-is": "Vincitore della lotteria $keyword è $username! La probabilità di vittoria è stata $probability%!" + }, + "bets": { + "running": "$sender, la scommessa è già aperta! Opzioni scommessa: $options. Usa $command close 1-$maxIndex", + "notRunning": "Nessuna scommessa è attualmente aperta, chiedi alle mod per aprirla!", + "opened": "Nuova scommessa '$title' è aperta! Opzioni scommessa: $options. Usa $command 1-$maxIndex per vincere! Hai solo $minutesmin per scommettere!", + "closeNotEnoughOptions": "$sender, è necessario selezionare l'opzione vincente per la puntata vicina.", + "notEnoughOptions": "$sender, le nuove scommesse hanno bisogno di almeno 2 opzioni!", + "info": "Scommessa '$title' è ancora aperta! Scommessa opzioni: $options. Usa $command 1-$maxIndex per vincere! Hai solo $minutesmin per scommettere!", + "diffBet": "$sender, hai già fatto una scommessa su $option e non puoi scommettere su opzioni diverse!", + "undefinedBet": "Siamo spiacenti, $sender, ma questa opzione di puntata non esiste, usa $command per controllare l'utilizzo", + "betPercentGain": "Puntata percentuale di guadagno per opzione è stata impostata a $value%", + "betCloseTimer": "Le scommesse saranno automaticamente chiuse dopo $valuemin", + "refund": "Le scommesse sono state chiuse senza una vincita. Tutti gli utenti sono rimborsati!", + "notOption": "$sender, questa opzione non esiste! La scommessa non è chiusa, controlla $command", + "closed": "Le scommesse sono state chiuse e l'opzione di vincita è stata $option! $amount utenti hanno vinto in totale $points $pointsName!", + "timeUpBet": "Immagino che tu sia troppo tardi, $sender, il tuo tempo per scommettere è finito!", + "locked": "Il tempo delle scommesse è finito! Niente più scommesse.", + "zeroBet": "Cavolo, $sender, non puoi scommettere 0 $pointsName", + "lockedInfo": "Scommessa '$title' è ancora aperta, ma il tempo per scommettere è finito!", + "removed": "Il tempo delle scommesse è finito! Non sono state inviate scommesse -> si chiudono automaticamente", + "error": "Siamo spiacenti, $sender, questo comando non è corretto! Usa $command 1-$maxIndex . Ad esempio $command 0 100 scommetterà 100 punti all'oggetto 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, alias $alias non è stato trovato nel database", + "alias-was-edited": "$sender, alias $alias è stato cambiato in $command", + "alias-was-added": "$sender, alias $alias per $command è stato aggiunto", + "list-is-not-empty": "$sender, lista degli alias: $list", + "list-is-empty": "$sender, la lista degli alias è vuota", + "alias-was-enabled": "$sender, l'alias $alias è stato attivato", + "alias-was-disabled": "$sender, l'alias $alias è stato disattivato", + "alias-was-concealed": "$sender, l'alias $alias è stato nascosto", + "alias-was-exposed": "$sender, l'alias $alias è stato esposto", + "alias-was-removed": "$sender, l'alias $alias è stato rimosso", + "alias-group-set": "$sender, l'alias $alias è stato impostato sul gruppo $group", + "alias-group-unset": "$sender, il gruppo di alias $alias è stato disattivato", + "alias-group-list": "$sender, elenco dei gruppi di alias: $list", + "alias-group-list-aliases": "$sender, elenco di alias in $group: $list", + "alias-group-list-enabled": "$sender, gli alias tra $group sono abilitati.", + "alias-group-list-disabled": "$sender, gli alias in $group sono disabilitati." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, il comando $command non è stato trovato nel database", + "response-was-not-found": "$sender, la risposta #$response del comando $command non è stata trovata nel database", + "command-was-edited": "$sender, il comando $command è stato cambiato in '$response'", + "command-was-added": "$sender, il comando $command è stato aggiunto", + "list-is-not-empty": "$sender, elenco dei comandi: $list", + "list-is-empty": "$sender, l'elenco dei comandi è vuoto", + "command-was-enabled": "$sender, il comando $command è stato abilitato", + "command-was-disabled": "$sender, il comando $command è stato disabilitato", + "command-was-concealed": "$sender, il comando $command è stato nascosto", + "command-was-exposed": "$sender, il comando $command è stato esposto", + "command-was-removed": "$sender, il comando $command è stato rimosso", + "response-was-removed": "$sender, la risposta #$response di $command è stata rimossa", + "list-of-responses-is-empty": "$sender, $command non ha alcuna risposta o non esiste", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, la parola chiave $keyword è ambigua, usa l'ID della parola chiave", + "keyword-was-not-found": "$sender, la parola chiave $keyword non è stata trovata nel database", + "response-was-not-found": "$sender, risposta #$response della parola chiave $keyword non è stata trovata nel database", + "keyword-was-edited": "$sender, la parola chiave $keyword è cambiata in '$response'", + "keyword-was-added": "$sender, è stata aggiunta la parola chiave $keyword ($id)", + "list-is-not-empty": "$sender, elenco delle parole chiave: $list", + "list-is-empty": "$sender, l'elenco delle parole chiave è vuoto", + "keyword-was-enabled": "$sender, parola chiave $keyword è stata abilitata", + "keyword-was-disabled": "$sender, la parola chiave $keyword è stata disattivata", + "keyword-was-removed": "$sender, la parola chiave $keyword è stata rimossa", + "list-of-responses-is-empty": "$sender, $keyword non ha risposte o non esiste", + "response": "$keyword#$index ($permission) $after $response" + }, + "points": { + "success": { + "undo": "$sender, i punti '$command' per $username sono stati ripristinati ($updatedValue $updatedValuePointsLocale a $originalValue $originalValuePointsLocale).", + "set": "$username è stato impostato su $amount $pointsName", + "give": "$sender ha appena dato il suo $amount $pointsName a $username", + "online": { + "positive": "Tutti gli utenti online hanno appena ricevuto $amount $pointsName!", + "negative": "Tutti gli utenti online hanno appena perso $amount $pointsName!" + }, + "all": { + "positive": "Tutti gli utenti hanno appena ricevuto $amount $pointsName!", + "negative": "Tutti gli utenti hanno appena perso $amount $pointsName!" + }, + "rain": "Fai piovere! Tutti gli utenti online hanno appena ricevuto fino a $amount $pointsName!", + "add": "$username ha appena ricevuto $amount $pointsName!", + "remove": "Ahia, $amount $pointsName è stato rimosso da $username!" + }, + "failed": { + "undo": "$sender, il nome utente non è stato trovato nel database o l'utente non ha operazioni di annullamento", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Siamo spiacenti, $sender, non hai $amount $pointsName da dare a $username", + "cannotGiveZeroPoints": "Siamo spiacenti, $sender, non puoi dare $amount $pointsName a $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username ha al momento $amount $pointsName. La tua posizione è $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, la scaletta da importare è vuota", + "playlist-imported": "$sender, importato $imported ed è saltato $skipped alla scaletta", + "not-playing": "Non In Riproduzione", + "song-was-banned": "La canzone $name è stata bandita e non sarà mai più riprodotta!", + "song-was-banned-timeout-message": "Sei stato sospeso per aver pubblicato un brano bandito", + "song-was-unbanned": "Il brano è stato sbloccato con successo", + "song-was-not-banned": "Questa canzone non è stata bandita", + "no-song-is-currently-playing": "Nessun brano è attualmente in riproduzione", + "current-song-from-playlist": "Il brano corrente è $name dalla playlist", + "current-song-from-songrequest": "Il brano corrente è $name richiesto da $username", + "songrequest-disabled": "Spiacenti, $sender, le richieste dei brani sono disabilitate", + "song-is-banned": "Spiacenti, $sender, ma questa canzone è bandita", + "youtube-is-not-responding-correctly": "Spiacenti, $sender, ma YouTube sta inviando risposte impreviste, riprova più tardi.", + "song-was-not-found": "Siamo spiacenti, $sender, ma questo brano non è stato trovato", + "song-is-too-long": "Spiacenti, $sender, ma questo brano è troppo lungo", + "this-song-is-not-in-playlist": "Siamo spiacenti, $sender, ma questa canzone non è nella playlist corrente", + "incorrect-category": "Siamo spiacenti, $sender, ma questo brano deve essere una categoria musicale", + "song-was-added-to-queue": "$sender, il brano $name è stato aggiunto alla coda", + "song-was-added-to-playlist": "$sender, il brano $name è stato aggiunto alla playlist", + "song-is-already-in-playlist": "$sender, il brano $name è già nella playlist", + "song-was-removed-from-playlist": "$sender, il brano $name è stato rimosso dalla playlist", + "song-was-removed-from-queue": "$sender, il brano $name è stato rimosso dalla coda", + "playlist-current": "$sender, la playlist attuale è $playlist.", + "playlist-list": "$sender, playlist disponibili: $list.", + "playlist-not-exist": "$sender, la tua playlist richiesta $playlist non esiste.", + "playlist-set": "$sender, hai cambiato la playlist in $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, il prezzo per $command è stato impostato a $amount $pointsName", + "price-was-unset": "$sender, il prezzo per $command non è stato impostato", + "price-was-not-found": "$sender, il prezzo per $command non è stato trovato", + "price-was-enabled": "$sender, il prezzo per $command è stato abilitato", + "price-was-disabled": "$sender, il prezzo per $command è stato disabilitato", + "user-have-not-enough-points": "Siamo spiacenti, $sender, ma non hai $amount $pointsName per usare $command", + "user-have-not-enough-points-or-bits": "Siamo spiacenti, $sender, ma non hai $amount $pointsName o riscattare il comando di $bitsAmount bit per usare $command", + "user-have-not-enough-bits": "Siamo spiacenti, $sender, ma devi riscattare il comando di $bitsAmount bit per usare $command", + "list-is-empty": "$sender, l'elenco dei prezzi è vuoto", + "list-is-not-empty": "$sender, elenco dei prezzi: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, nuovo rango $type $rank($hours$hlocale) è stato aggiunto", + "rank-was-edited": "$sender, il rango per $type $hours$hlocale è stato cambiato in $rank", + "rank-was-removed": "$sender, il rango per $type $hours$hlocale è stato rimosso", + "rank-already-exist": "$sender, c'è già un rango per $type $hours$hlocale", + "rank-was-not-found": "$sender, il rango per $type $hours$hlocale non è stato trovato", + "custom-rank-was-set-to-user": "$sender, hai impostato $rank a $username", + "custom-rank-was-unset-for-user": "$sender, il rango personalizzato per $username è stato azzerato", + "list-is-empty": "$sender, non sono stati trovati ranghi", + "list-is-not-empty": "$sender, elenco ranghi: $list", + "show-rank-without-next-rank": "$sender, hai $rank rango", + "show-rank-with-next-rank": "$sender, hai $rank rango. Prossimo rango - $nextrank", + "user-dont-have-rank": "$sender, non hai ancora un rango" + }, + "followage": { + "success": { + "never": "$sender, $username non è un follower del canale", + "time": "$sender, $username sta seguendo il canale $diff" + }, + "successSameUsername": { + "never": "$sender, non sei follower di questo canale", + "time": "$sender, stai seguendo questo canale per $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username non è un abbonato al canale.", + "notNow": "$sender, $username non è attualmente un abbonato al canale. In totale $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username è iscritto al canale. Sub streak attuale per $diff ($subStreak $subStreakMonthsName) e in totale di $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username è iscritto al canale. In totale $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, non sei un abbonato al canale.", + "notNow": "$sender, al momento non sei un abbonato al canale. In totale $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, sei iscritto al canale. Sub streak attuale per $diff ($subStreak $subStreakMonthsName) e in totale di $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, sei iscritto al canale. In totale $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, Non ho dati per l'età dell'account $username", + "success": { + "withUsername": "$sender, l'età del conto per $username è $diff", + "withoutUsername": "$sender, l'età del tuo account è $diff" + } + }, + "lastseen": { + "success": { + "never": "$username non è mai stato in questo canale!", + "time": "$username è stato visto l'ultima volta alle $when in questo canale" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username ha guardato questo canale per $time ore" + }, + "failed": { + "parse": "{core.command-parse} !visto o !visto [username]" + } + }, + "permissions": { + "without-permission": "Non hai abbastanza permessi per '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, l'utente $username ha $type immunità per $time secondi", + "user-have-immunity-parameterError": "$sender, parametro error. $command ", + "user-have-link-permit": "L'utente $username può pubblicare un $count $link per chattare", + "permit-parse-failed": "{core.command-parse} !allow [username]", + "user-is-warned-about-links": "Nessun link permesso, chiedi !allow [$count avvisi rimasti]", + "user-is-warned-about-symbols": "Nessun uso eccessivo di simboli [$count avvisi rimasti]", + "user-is-warned-about-long-message": "I messaggi lunghi non sono ammessi [$count avvisi rimasti]", + "user-is-warned-about-caps": "Nessun utilizzo eccessivo dei limiti [$count avvisi rimasti]", + "user-is-warned-about-spam": "Lo spamming non è consentito [$count avvisi rimasti]", + "user-is-warned-about-color": "Corsivo e /me non sono ammessi [$count avvisi rimasti]", + "user-is-warned-about-emotes": "No emotes spamming [$count avvisi rimasti]", + "user-is-warned-about-forbidden-words": "Nessuna parola proibita [$count avvisi rimasti]", + "user-have-timeout-for-links": "Nessun link permesso, chiedi !allow", + "user-have-timeout-for-symbols": "Nessun uso eccessivo di simboli", + "user-have-timeout-for-long-message": "Il messaggio lungo non è consentito", + "user-have-timeout-for-caps": "Nessun uso eccessivo di caps", + "user-have-timeout-for-spam": "Lo spamming non è consentito", + "user-have-timeout-for-color": "Corsivo ed /me non è consentito", + "user-have-timeout-for-emotes": "Nessuno spamming delle emotes", + "user-have-timeout-for-forbidden-words": "Nessuna parola proibita" + }, + "queue": { + "list": "$sender, attuale gruppo di coda: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Spiacenti $sender, la coda è attualmente chiusa", + "opened": "$sender sono stati aggiunti nella coda" + }, + "open": "La coda è attualmente APERTA! Unisciti alla coda con !queue join", + "close": "La coda è attualmente chiusa!", + "clear": "La coda è stata completamente cancellata", + "picked": { + "single": "Questo utente è stato scelto dalla coda: $users", + "multi": "Questi utenti sono stati selezionati dalla coda: $users", + "none": "Nessun utente trovato in coda" + } + }, + "marker": "Stream marker has been created at $time.", + "title": { + "current": "$sender, il titolo del flusso è '$title'.", + "change": { + "success": "$sender, il titolo è stato impostato a: $title" + } + }, + "game": { + "current": "$sender, lo streamer sta attualmente giocando $game.", + "change": { + "success": "$sender, category was set to: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type ricarica per $command è stato impostato a $secondss", + "cooldown-was-unset": "$sender, il tempo di ricarica per $command è stato disattivato", + "cooldown-triggered": "$sender, il '$command' è in cooldown, remimangono $secondss di attesa", + "cooldown-not-found": "$sender, il tempo di ricarica per $command non è stato trovato", + "cooldown-was-enabled": "$sender, è stato attivato il tempo di ricarica per $command", + "cooldown-was-disabled": "$sender, ricarica per $command è stato disabilitato", + "cooldown-was-enabled-for-moderators": "$sender, è stato attivato il tempo di ricarica per $command per i moderatori", + "cooldown-was-disabled-for-moderators": "$sender, il tempo di ricarica per $command è stato disabilitato per moderatori", + "cooldown-was-enabled-for-owners": "$sender, è stato attivato il tempo di ricarica per $command per i proprietari", + "cooldown-was-disabled-for-owners": "$sender, il tempo di ricarica per $command è stato disabilitato per i proprietari", + "cooldown-was-enabled-for-subscribers": "$sender, è stato attivato il tempo di ricarica per $command per gli abbonati", + "cooldown-was-disabled-for-subscribers": "$sender, ricarica per $command è stato disabilitato per gli abbonati", + "cooldown-was-enabled-for-followers": "$sender, è stato attivato il tempo di ricarica per $command per i seguaci", + "cooldown-was-disabled-for-followers": "$sender, il tempo di ricarica per $command è stato disabilitato per i seguaci" + }, + "timers": { + "id-must-be-defined": "$sender, l'id della risposta deve essere definito.", + "id-or-name-must-be-defined": "$sender, deve essere definito l'id della risposta o il nome del timer.", + "name-must-be-defined": "$sender, il nome del timer deve essere definito.", + "response-must-be-defined": "$sender, la risposta del timer deve essere definita.", + "cannot-set-messages-and-seconds-0": "$sender, non puoi impostare sia i messaggi che i secondi a 0.", + "timer-was-set": "$sender, il timer $name è stato impostato con $messages messaggi e $seconds secondi per attivare", + "timer-was-set-with-offline-flag": "$sender, il timer $name è stato impostato con $messages messaggi e $seconds secondi per attivare anche quando lo stream è fuori rete", + "timer-not-found": "$sender, timer (nome: $name) non è stato trovato nel database. Controlla i timer con la lista !timers", + "timer-deleted": "$sender, timer $name e le sue risposte sono state eliminate.", + "timer-enabled": "$sender, timer (nome: $name) è stato abilitato", + "timer-disabled": "$sender, timer (nome: $name) è stato disabilitato", + "timers-list": "$sender, lista timer: $list", + "responses-list": "$sender, timer (nome: $name) list", + "response-deleted": "$sender, risposta (id: $id) è stata eliminata.", + "response-was-added": "$sender, risposta (id: $id) per timer (nome: $name) è stato aggiunto - '$response'", + "response-not-found": "$sender, risposta (id: $id) non è stato trovato nel database", + "response-enabled": "$sender, risposta (id: $id) è stata abilitata", + "response-disabled": "$sender, risposta (id: $id) è stata disabilitata" + }, + "gambling": { + "duel": { + "bank": "$sender, la banca corrente per $command è $points $pointsName", + "lowerThanMinimalBet": "$sender, la scommessa minima per $command è $points $pointsName", + "cooldown": "$sender, non puoi usare $command per $cooldown $minutesName.", + "joined": "$sender, buona fortuna con le tue abilità di duellamento. Scommetti su di te $points $pointsName!", + "added": "$sender pensa davvero di essere meglio di altri che alzare la sua scommessa a $points $pointsName!", + "new": "$sender è il tuo nuovo duello sfidante! Per partecipare usa $command [points], hai ancora $minutes $minutesName per partecipare.", + "zeroBet": "$sender, non puoi duellare 0 $pointsName", + "notEnoughOptions": "$sender, è necessario specificare i punti in dueling", + "notEnoughPoints": "$sender, non hai $points $pointsName da duellare!", + "noContestant": "Solo $winner ha il coraggio di unirsi al duello! La tua scommessa di $points $pointsName ti viene restituita.", + "winner": "Congratulazioni a $winner! È l'ultimo uomo in piedi e ha vinto $points $pointsName ($probability% con scommessa di $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender sta provando la sua fortuna e ha premuto il grilletto", + "alive": "$sender è vivo! Non è successo nulla.", + "dead": "Il cervello di $senderè stato schizzato sul muro!", + "mod": "$sender is incompetent and completely missed his head!", + "broadcaster": "$sender sta usando degli spazi vuoti, boo!", + "timeout": "Sospensione della roulette impostato a $values" + }, + "gamble": { + "chanceToWin": "$sender, possibilità di vincere!gioca impostata al $value%", + "zeroBet": "$sender, non puoi giocare 0 $pointsName", + "minimalBet": "$sender, la scommessa minima per !gamble è impostata su $value", + "lowerThanMinimalBet": "$sender, scommessa minima per !gamble è $points $pointsName", + "notEnoughOptions": "$sender, è necessario specificare i punti per giocare", + "notEnoughPoints": "$sender, non hai $points $pointsName da giocare", + "win": "$sender, hai vinto! Ora hai $points $pointsName", + "winJackpot": "$sender, hai colpito JACKPOT! Hai vinto $jackpot $jackpotName in aggiunta alla tua scommessa. Ora hai $points $pointsName", + "loseWithJackpot": "$sender, LOST! Ora hai $points $pointsName. Il Jackpot è aumentato a $jackpot $jackpotName", + "lose": "$sender, LOST! Ora hai $points $pointsName", + "currentJackpot": "$sender, il jackpot attuale per $command è $points $pointsName", + "winJackpotCount": "$sender, hai vinto $count di jackpot", + "jackpotIsDisabled": "$sender, il jackpot è disabilitato per $command." + } + }, + "highlights": { + "saved": "$sender, l'evidenziazione è stata salvata per $hoursh$minutesm$secondss", + "list": { + "items": "$sender, elenco dei punti salienti salvati per l'ultimo stream: $items", + "empty": "$sender, nessuna evidenziazione è stata salvata" + }, + "offline": "$sender, impossibile salvare l'evidenziazione, lo stream è fuori rete" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "Bot won't send errors on insufficient permissions", + "false": "Bot won't send errors on insufficient permissions through whispers" + }, + "disableCooldownWhispers": { + "true": "Il bot non invierà notifiche di ricarica", + "false": "Il bot invierà notifiche di ricarica tramite sussurri" + } + } + }, + "time": "Current time in streamer's timezone is $time", + "subs": "$sender, ci sono attualmente $onlineSubCount abbonati online. L'ultimo sub/resub è stato $lastSubUsername $lastSubAgo", + "followers": "$sender, last follow was $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, l'utente $username non è ignorato dal bot" + }, + "added": "$sender, l'utente $username è stato aggiunto al bot ignorelist", + "removed": "$sender, l'utente $username è stato rimosso dal bot ignorelist", + "ignored": "$sender, l'utente $username è ignorato dal bot" + } + } + }, + "filters": { + "setVariable": "$sender, $variable è stato impostato a $value." + } +} diff --git a/backend/locales/it/api.clips.json b/backend/locales/it/api.clips.json new file mode 100644 index 000000000..ab013987a --- /dev/null +++ b/backend/locales/it/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "Clip creato ed è disponibile su $link" +} \ No newline at end of file diff --git a/backend/locales/it/core/permissions.json b/backend/locales/it/core/permissions.json new file mode 100644 index 000000000..4d00d3483 --- /dev/null +++ b/backend/locales/it/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "Lista dei tuoi permessi:", + "excludeAddSuccessful": "$sender, hai aggiunto $username alla lista delle esclusioni per il permesso $permissionName", + "excludeRmSuccessful": "$sender, hai rimosso $username dalla lista delle esclusioni per il permesso $permissionName", + "userNotFound": "$sender, l'utente $username non è stato trovato nel database.", + "permissionNotFound": "$sender, il permesso $userlevel non è stato trovato nel database.", + "cannotIgnoreForCorePermission": "$sender, non puoi escludere manualmente l'utente per l'autorizzazione principale $userlevel" +} \ No newline at end of file diff --git a/backend/locales/it/games.heist.json b/backend/locales/it/games.heist.json new file mode 100644 index 000000000..e50122e1b --- /dev/null +++ b/backend/locales/it/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, i poliziotti sono ancora alla ricerca dell'ultima squadra di rapina. Riprova dopo $cooldown.", + "copsCooldownMessage": "Bene ragazzi, sembra che le forze di polizia stiano mangiando ciambelle e possiamo ottenere quel dolce denaro!", + "entryMessage": "$sender ha iniziato a pianificare una rapina in banca! Alla ricerca di una squadra più grande per un punteggio più grande. Unisciti! Digita $command per entrare.", + "lateEntryMessage": "$sender, rapina è attualmente in corso!", + "entryInstruction": "$sender, digita $command per entrare.", + "levelMessage": "Con questa squadra, possiamo rapinare la $bank! Vediamo se riusciamo a ottenere abbastanza equipaggio per rapinare $nextBank", + "maxLevelMessage": "Con questa squadra, possiamo rapinare $bank! Non può andare meglio di cosi!", + "started": "Bene ragazzi, controllate la vostra attrezzatura, è quello per cui ci siamo allenati. Questo non è un gioco, questa è la vita reale. Prenderemo il denaro da $bank!", + "noUser": "Nessuno si unisce a una squadra da rapire.", + "singleUserSuccess": "$user era come un ninja. Nessuno ha notato i soldi mancanti.", + "singleUserFailed": "$user non è riuscito a sbarazzarsi della polizia e passerà il suo tempo in prigione.", + "result": { + "0": "Tutti sono stati annientati senza pietà. Questo è un massacro.", + "33": "Solo 1/3 di squadra ottiene i suoi soldi dalla rapina.", + "50": "La metà della squadra di rapina è stata uccisa o catturata dalla polizia.", + "99": "Alcune perdire della squadra di rapina non è nulla di ciò che i rimanenti membri hanno nelle loro tasche.", + "100": "Dio divino, nessuno è morto, tutti hanno vinto!" + }, + "levels": { + "bankVan": "Trasportatore della banca", + "cityBank": "Banca della città", + "stateBank": "Banca di Stato", + "nationalReserve": "Riserva nazionale", + "federalReserve": "Riserva federale" + }, + "results": "Le vincite della rapina sono: $users", + "andXMore": "e $count in più..." +} \ No newline at end of file diff --git a/backend/locales/it/integrations/discord.json b/backend/locales/it/integrations/discord.json new file mode 100644 index 000000000..0275c2689 --- /dev/null +++ b/backend/locales/it/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "il tuo account non è collegato, usa `$command`", + "all-your-links-were-deleted": "tutti i tuoi link sono stati eliminati", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender, questo account è stato collegato a $discordTag.", + "invalid-or-expired-token": "$sender, token non valido o scaduto.", + "help-message": "$sender, per collegare il tuo account su Discord: 1. Vai sul server Discord e invia $command nel canale del bot. | 2. Aspetta il messaggio privato dal bot | 3. Invia il comando dal vostro Discord PM qui in chat twitch.", + "started-at": "Iniziato alle", + "announced-by": "Announced by sogeBot", + "streamed-at": "Streammato alle", + "link-whisper": "Ciao $tag, per collegare questo account Discord al tuo account Twitch sul canale $broadcaster , vai su , accedi al tuo account e invia questo comando in chat \n\n\t\t `$command $id`\n\nNOTA: Questo comando scadrà tra 10 minuti.", + "check-your-dm": "verifica i tuoi messaggi privati per continuare la procedura per collegare il tuo account." +} \ No newline at end of file diff --git a/backend/locales/it/integrations/lastfm.json b/backend/locales/it/integrations/lastfm.json new file mode 100644 index 000000000..79a075396 --- /dev/null +++ b/backend/locales/it/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "Current song is $name" +} \ No newline at end of file diff --git a/backend/locales/it/integrations/obswebsocket.json b/backend/locales/it/integrations/obswebsocket.json new file mode 100644 index 000000000..98782b63a --- /dev/null +++ b/backend/locales/it/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, non c'è nessuna azione impostata per l'id:$id!", + "ParameterError": "$sender, è necessario specificare l'id!", + "UnknownError": "$sender, qualcosa è andato storto. Controlla i log del bot per ulteriori informazioni." + } +} \ No newline at end of file diff --git a/backend/locales/it/integrations/protondb.json b/backend/locales/it/integrations/protondb.json new file mode 100644 index 000000000..0e7df5a0f --- /dev/null +++ b/backend/locales/it/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | $rating rated | Native on $native | Details: $url", + "responseNg": "Rating for game $game was not found on ProtonDB.", + "responseNotFound": "Game $game was not found on ProtonDB." +} \ No newline at end of file diff --git a/backend/locales/it/integrations/pubg.json b/backend/locales/it/integrations/pubg.json new file mode 100644 index 000000000..bbbbe451d --- /dev/null +++ b/backend/locales/it/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, atteso uno di questi parametri: $list" +} \ No newline at end of file diff --git a/backend/locales/it/integrations/spotify.json b/backend/locales/it/integrations/spotify.json new file mode 100644 index 000000000..1b5b07469 --- /dev/null +++ b/backend/locales/it/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "Mi spiace, $sender, la traccia non è stata trovata su spotify", + "song-requested": "$sender, hai richiesto il brano $name di $artist", + "not-banned-song-not-playing": "$sender, nessuna canzone da bannare è attualmente in riproduzione.", + "song-banned": "$sender, la canzone $name di $artist è bannata.", + "song-unbanned": "$sender, la canzone $name di $artist è stata sbannata.", + "song-not-found-in-banlist": "$sender, canzone di spotifyURI $uri non è stata trovata nella lista ban.", + "cannot-request-song-is-banned": "$sender, non puoi richiedere il brano $name di $artist perchè è bannato.", + "cannot-request-song-from-unapproved-artist": "$sender, non è possibile richiedere un brano di un artista non approvato.", + "no-songs-found-in-history": "$sender, al momento non c'è nessuna canzone nella cronologia.", + "return-one-song-from-history": "$sender, la canzone precedente era $name di $artist.", + "return-multiple-song-from-history": "$sender, le $count canzoni precedenti erano:", + "return-multiple-song-from-history-item": "$index - $name di $artist", + "song-notify": "Il brano attualmente in riproduzione è $name di $artist." +} \ No newline at end of file diff --git a/backend/locales/it/integrations/tiltify.json b/backend/locales/it/integrations/tiltify.json new file mode 100644 index 000000000..aa574fb09 --- /dev/null +++ b/backend/locales/it/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, there are currently no active campaigns.", + "active_campaigns": "$sender, list of currently active campaigns:" +} \ No newline at end of file diff --git a/backend/locales/it/systems.quotes.json b/backend/locales/it/systems.quotes.json new file mode 100644 index 000000000..0b471a8f9 --- /dev/null +++ b/backend/locales/it/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, preventivo $id '$quote' è stato aggiunto. (tag: $tags)", + "error": "$sender, $command non è corretto o manca il parametro -quote" + }, + "remove": { + "ok": "$sender, il preventivo $id è stato eliminato con successo.", + "error": "$sender, l'ID del preventivo è mancante.", + "not-found": "$sender, preventivo $id non trovato." + }, + "show": { + "ok": "Preventivo $id di $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command è mancante -id o -tag.", + "not-found-by-id": "$sender, preventivo $id non trovato.", + "not-found-by-tag": "$sender, nessun preventivo con tag $tag non è stato trovato." + } + }, + "set": { + "ok": "$sender, sono stati impostati i tag del preventivo $id. (tag: $tags)", + "error": { + "no-parameters": "$sender, $command è mancante -id o -tag.", + "not-found-by-id": "$sender, preventivo $id non trovato." + } + }, + "list": { + "ok": "$sender, Puoi trovare la lista delle quotazioni su http://$urlBase/public/#/quotes", + "is-localhost": "$sender, l'url della lista delle citazioni non è specificato correttamente." + } +} \ No newline at end of file diff --git a/backend/locales/it/systems/antihateraid.json b/backend/locales/it/systems/antihateraid.json new file mode 100644 index 000000000..7ad602a98 --- /dev/null +++ b/backend/locales/it/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "This chat was set to $mode by $username to get rid of hate raid. Sorry for inconvenience!", + "mode": { + "0": "subs-only", + "1": "follow-only", + "2": "emotes-only" + } +} \ No newline at end of file diff --git a/backend/locales/it/systems/howlongtobeat.json b/backend/locales/it/systems/howlongtobeat.json new file mode 100644 index 000000000..759b94fdb --- /dev/null +++ b/backend/locales/it/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game non è stato trovato nel db.", + "game": "$sender, $game Principale: $currentMain/$hltbMainh - $percentMain% Principale+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% Completista: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game Principale: $currentMainh Principale+Extra: $currentMainExtrah Completista: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/it/systems/levels.json b/backend/locales/it/systems/levels.json new file mode 100644 index 000000000..7b3db6d0d --- /dev/null +++ b/backend/locales/it/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, livello: $currentLevel ($currentXP $xpName), $nextXP $xpName al livello successivo.", + "changeXP": "$sender, hai cambiato $xpName da $amount $xpName a $username.", + "notEnoughPointsToBuy": "Ci dispiace $sender, ma non hai $points $pointsName per acquistare $amount $xpName per il livello $level.", + "XPBoughtByPoints": "$sender, hai comprato $amount $xpName con $points $pointsName e raggiunto il livello $level.", + "somethingGetWrong": "$sender, qualcosa non va nella tua richiesta." +} \ No newline at end of file diff --git a/backend/locales/it/systems/scrim.json b/backend/locales/it/systems/scrim.json new file mode 100644 index 000000000..cedf0817f --- /dev/null +++ b/backend/locales/it/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Corrispondenza Snipe ($type) a partire da $time $unit", + "go": "Inizia ora! Vai!", + "putMatchIdInChat": "Inserisci il tuo ID di corrispondenza nella chat => $command xxx", + "currentMatches": "Corrispondenze attuali: $matches", + "stopped": "Corrispondenza Snipe annullata." +} \ No newline at end of file diff --git a/backend/locales/it/systems/top.json b/backend/locales/it/systems/top.json new file mode 100644 index 000000000..6f2598956 --- /dev/null +++ b/backend/locales/it/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Top $amount (ora dell'orologio): ", + "tips": "Top $amount (consigli): ", + "level": "Migliore $amount (livello): ", + "points": "Top $amount (punti): ", + "messages": "Top $amount (messaggi): ", + "followage": "Top $amount (seguimento): ", + "subage": "Top $amount (abbonati): ", + "submonths": "Top $amount (mesi): ", + "bits": "Top $amount (bit): ", + "gifts": "Top $amount (regali di abbonati): " +} \ No newline at end of file diff --git a/backend/locales/it/ui.commons.json b/backend/locales/it/ui.commons.json new file mode 100644 index 000000000..a82e4371d --- /dev/null +++ b/backend/locales/it/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Impostazioni aggiuntive", + "never": "mai", + "reset": "reset", + "moveUp": "sposta su", + "moveDown": "sposta giù", + "stop-if-executed": "interrompere, se eseguito", + "continue-if-executed": "continua, se eseguito", + "generate": "Generare", + "thumbnail": "Miniatura", + "yes": "Si", + "no": "No", + "show-more": "Mostra di più", + "show-less": "Mostra di meno", + "allowed": "Permesso", + "disallowed": "Non Consentito", + "back": "Indietro" +} diff --git a/backend/locales/it/ui.dialog.json b/backend/locales/it/ui.dialog.json new file mode 100644 index 000000000..41cf89d2e --- /dev/null +++ b/backend/locales/it/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Modifica", + "add": "Aggiungi" + }, + "position": { + "settings": "Impostazioni posizione", + "anchorX": "Posizione ancoraggio X", + "anchorY": "Posizione ancoraggio Y", + "left": "Sinistra", + "right": "Destra", + "middle": "Medio", + "top": "Alto", + "bottom": "Basso", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Sposta A Destra", + "shadowShiftDown": "Scorri in basso", + "shadowBlur": "Sfocatura", + "shadowOpacity": "Opacità", + "color": "Colore" + }, + "errors": { + "required": "Questo campo non può essere vuoto.", + "minValue": "Il valore più basso di questo input è $value." + }, + "buttons": { + "reorder": "Riordina", + "upload": { + "idle": "Carica", + "progress": "Caricamento", + "done": "Caricato" + }, + "cancel": "Annulla", + "close": "Chiudi", + "test": { + "idle": "Prova", + "progress": "Prove in corso", + "done": "Test effettuato" + }, + "saveChanges": { + "idle": "Salva modifiche", + "invalid": "Impossibile salvare le modifiche", + "progress": "Salvataggio delle modifiche", + "done": "Modifiche salvate" + }, + "something-went-wrong": "Qualcosa è andato storto", + "mark-to-delete": "Segna per eliminare", + "disable": "Disattiva", + "enable": "Attiva", + "disabled": "Disabilitato", + "enabled": "Attivato", + "edit": "Modifica", + "delete": "Elimina", + "play": "Esegui", + "stop": "Arresta", + "hold-to-delete": "Tieni premuto per eliminare", + "yes": "Si", + "no": "No", + "permission": "Permesso", + "group": "Gruppo", + "visibility": "Visibilità", + "reset": "Reset " + }, + "changesPending": "Le modifiche non sono state salvate.", + "formNotValid": "Modulo non valido.", + "nothingToShow": "Niente da mostrare qui." +} \ No newline at end of file diff --git a/backend/locales/it/ui.menu.json b/backend/locales/it/ui.menu.json new file mode 100644 index 000000000..d4edcde65 --- /dev/null +++ b/backend/locales/it/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Services", + "updater": "Updater", + "index": "Pannello di controllo", + "core": "Bot", + "users": "Utenti", + "tmi": "TMI", + "ui": "UI", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "Generale", + "timers": "Timers", + "new": "Nuovo Elemento", + "keywords": "Parole Chiave", + "customcommands": "Comandi personalizzati", + "botcommands": "Comandi Bot", + "commands": "Comandi", + "events": "Eventi", + "ranks": "Ranghi", + "songs": "Brani", + "modules": "Moduli", + "viewers": "Spettatori", + "alias": "Alias", + "cooldowns": "Tempi di attesa", + "cooldown": "Tempo di ricarica", + "highlights": "Evidenziamenti", + "price": "Prezzo", + "logs": "Registri", + "systems": "Sistemi", + "permissions": "Permessi", + "translations": "Traduzioni personalizzate", + "moderation": "Moderazione", + "overlays": "Sovrapposizioni", + "gallery": "Galleria multimediale", + "games": "Giochi", + "spotify": "Spotify", + "integrations": "Integrazioni", + "customvariables": "Variabili personalizzate", + "registry": "Registro", + "quotes": "Citazioni", + "settings": "Impostazioni", + "commercial": "Commerciale", + "bets": "Puntate", + "points": "Punti", + "raffles": "Lotterie", + "queue": "Coda", + "playlist": "Scaletta", + "bannedsongs": "Brani banditi", + "spotifybannedsongs": "Brani banditi da Spotify", + "duel": "Duello", + "fightme": "Lotta", + "seppuku": "Seppuku", + "gamble": "Gioco d'azzardo", + "roulette": "Roulette", + "heist": "Rapina", + "oauth": "OAuth", + "socket": "Socket", + "carouseloverlay": "Carousel overlay", + "alerts": "Allerte", + "carousel": "Giostra immagine", + "clips": "Clip", + "credits": "Riconoscimenti", + "emotes": "Emotes", + "stats": "Statistiche", + "text": "Testo", + "currency": "Valuta", + "eventlist": "Lista Eventi", + "clipscarousel": "Carosello di clip", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Checklist", + "bot": "Bot", + "api": "Api", + "manage": "Gestisci", + "top": "Top", + "goals": "Obiettivi", + "userinfo": "Informazioni utente", + "scrim": "Scrim", + "commandcount": "Conteggio comandi", + "profiler": "Profiler", + "howlongtobeat": "How Long to Beat", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Casualizzatore", + "tips": "Donazioni", + "bits": "Bits", + "discord": "Discord", + "texttospeech": "Sintesi Vocale", + "lastfm": "Last.fm", + "pubg": "BATTLEGROUNDI PLAYERUNKNOWN", + "levels": "Livelli", + "obswebsocket": "OBS Websocket", + "api-explorer": "Esploratore API", + "emotescombo": "Emotes Combo", + "notifications": "Notifications", + "plugins": "Plugins", + "tts": "TTS" +} diff --git a/backend/locales/it/ui.page.settings.overlays.carousel.json b/backend/locales/it/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..7c6f57c86 --- /dev/null +++ b/backend/locales/it/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "opzioni", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Sei sicuro di eliminare questa immagine?" + }, + "button": { + "update": "Aggiornamento", + "fix_your_errors_first": "Correggi errori prima di salvare" + }, + "errors": { + "number_greater_or_equal_than_0": "Il valore deve essere un numero >= 0", + "value_must_not_be_empty": "Il valore non deve essere vuoto" + }, + "titles": { + "waitBefore": "Attendere prima di mostrare l'immagine (in m)", + "waitAfter": "Attendere dopo che l'immagine sparisce (in ms)", + "duration": "Quanto tempo deve essere mostrato (in ms)", + "animationIn": "Animazione In Entrata", + "animationOut": "Animazione Uscita", + "animationInDuration": "Durata animazione (in ms)", + "animationOutDuration": "Durata uscita animazione (in ms)", + "showOnlyOncePerStream": "Mostra solo una volta per stream" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.registry.customvariables.json b/backend/locales/it/ui.registry.customvariables.json new file mode 100644 index 000000000..f4cced2e7 --- /dev/null +++ b/backend/locales/it/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "URLs", + "generateurl": "Genera un nuovo URL", + "show-examples": "mostra esempi CURL", + "response": { + "show": "Mostra risposta dopo POST", + "name": "Risposta dopo impostazione variabile", + "default": "Predefinito", + "default-placeholder": "Imposta la tua risposta del bot", + "default-help": "Usa $value per ottenere un nuovo valore variabile", + "custom": "Personalizzato", + "command": "Comando" + }, + "useIfInCommand": "Usa se usi la variabile nel comando. Restituirà solo la variabile aggiornata senza risposta.", + "permissionToChange": "Permesso di modifica", + "isReadOnly": "sola lettura in chat", + "isNotReadOnly": "può essere modificato attraverso la chat", + "no-variables-found": "Nessuna variabile trovata", + "additional-info": "Informazioni supplementari", + "run-script": "Esegui script", + "last-run": "Ultima esecuzione il", + "variable": { + "name": "Nome della variabile", + "help": "Il nome della variabile deve essere unico, ad esempio $_wins, $_loses, $_top3", + "placeholder": "Inserisci il nome della variabile univoco", + "error": { + "isNotUnique": "La variabile deve avere un nome univoco.", + "isEmpty": "Il nome della variabile non deve essere vuoto." + } + }, + "description": { + "name": "Descrizione", + "help": "Descrizione facoltativa", + "placeholder": "Inserisci la tua descrizione opzionale" + }, + "type": { + "name": "Tipo", + "error": { + "isNotSelected": "Scegliere un tipo di variabile." + } + }, + "currentValue": { + "name": "Valore attuale", + "help": "Se il tipo è impostato su Script valutato, il valore non può essere modificato manualmente" + }, + "usableOptions": { + "name": "Opzioni utilizzabili", + "placeholder": "Invia, le tue, opzioni, qui", + "help": "Opzioni, che possono essere utilizzate con questa variabile, esempio: SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "È necessario impostare almeno 1 valore." + } + }, + "scriptToEvaluate": "Script da valutare", + "runScript": { + "name": "Esegui script", + "error": { + "isNotSelected": "Scegliere un'opzione." + } + }, + "testCurrentScript": { + "name": "Prova script attuale", + "help": "Clicca Prova lo script corrente per vedere il valore in Valore corrente input" + }, + "history": "Storico", + "historyIsEmpty": "La cronologia di questa variabile è vuota!", + "warning": "Attenzione: Tutti i dati di questa variabile saranno scartati!", + "choose": "Scegli...", + "types": { + "number": "Numero", + "text": "Testo", + "options": "Opzioni", + "eval": "Script" + }, + "runEvery": { + "isUsed": "Quando la variabile è usata" + } +} + diff --git a/backend/locales/it/ui.systems.antihateraid.json b/backend/locales/it/ui.systems.antihateraid.json new file mode 100644 index 000000000..d821c979d --- /dev/null +++ b/backend/locales/it/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Clear Chat", + "mode": "Mode", + "minFollowTime": "Minimum follow time", + "customAnnounce": "Customize announcement on anti hate raid enable" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.bets.json b/backend/locales/it/ui.systems.bets.json new file mode 100644 index 000000000..961b8ca81 --- /dev/null +++ b/backend/locales/it/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Stato", + "betPercentGain": "Aggiungi x% per scommettere il pagamento di ogni opzione" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.commercial.json b/backend/locales/it/ui.systems.commercial.json new file mode 100644 index 000000000..f2b1d432b --- /dev/null +++ b/backend/locales/it/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Stato" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.cooldown.json b/backend/locales/it/ui.systems.cooldown.json new file mode 100644 index 000000000..794e41c71 --- /dev/null +++ b/backend/locales/it/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Notifica come sussurro", + "settings": { + "enabled": "Stato", + "cooldownNotifyAsWhisper": "Sussurra informazioni della ricarica", + "cooldownNotifyAsChat": "Messaggio chat su informazioni della ricarica", + "defaultCooldownOfCommandsInSeconds": "Ricarica predefinita per i comandi (in secondi)", + "defaultCooldownOfKeywordsInSeconds": "Ricarica predefinita per le parole chiave (in secondi)" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.customcommands.json b/backend/locales/it/ui.systems.customcommands.json new file mode 100644 index 000000000..3c1523a16 --- /dev/null +++ b/backend/locales/it/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "Nessuna risposta", + "addResponse": "Aggiungi risposta", + "response": { + "name": "Risposta", + "placeholder": "Imposta qui la tua risposta." + }, + "filter": { + "name": "filtro", + "placeholder": "Aggiungi filtro per questa risposta" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.highlights.json b/backend/locales/it/ui.systems.highlights.json new file mode 100644 index 000000000..d979a724a --- /dev/null +++ b/backend/locales/it/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Stato", + "urls": "Url Generati" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.moderation.json b/backend/locales/it/ui.systems.moderation.json new file mode 100644 index 000000000..ba282170a --- /dev/null +++ b/backend/locales/it/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Stato", + "cListsEnabled": "Forza la regola", + "cLinksEnabled": "Forza la regola", + "cSymbolsEnabled": "Forza la regola", + "cLongMessageEnabled": "Forza la regola", + "cCapsEnabled": "Forza la regola", + "cSpamEnabled": "Forza la regola", + "cColorEnabled": "Forza la regola", + "cEmotesEnabled": "Forza la regola", + "cListsWhitelist": { + "title": "Parole consentite", + "help": "Per consentire l'uso dei domini \"dominio:prtzl.io\"" + }, + "autobanMessages": "Autoban Messages", + "cListsBlacklist": "Parole vietate", + "cListsTimeout": "Durata del timeout", + "cLinksTimeout": "Durata del timeout", + "cSymbolsTimeout": "Durata del timeout", + "cLongMessageTimeout": "Durata del timeout", + "cCapsTimeout": "Durata del timeout", + "cSpamTimeout": "Durata del timeout", + "cColorTimeout": "Durata del timeout", + "cEmotesTimeout": "Durata del timeout", + "cWarningsShouldClearChat": "Indica se cancellare la chat (timeout per 1s)", + "cLinksIncludeSpaces": "Includi spazi", + "cLinksIncludeClips": "Includi clip", + "cSymbolsTriggerLength": "Lunghezza limite del messaggio", + "cLongMessageTriggerLength": "Lunghezza limite del messaggio", + "cCapsTriggerLength": "Lunghezza limite del messaggio", + "cSpamTriggerLength": "Lunghezza limite del messaggio", + "cSymbolsMaxSymbolsConsecutively": "Massimo simboli consecutivi", + "cSymbolsMaxSymbolsPercent": "Massimo simboli %", + "cCapsMaxCapsPercent": "Limite massimo di lettere maiuscole %", + "cSpamMaxLength": "Lunghezza massima", + "cEmotesMaxCount": "Numero massimo", + "cWarningsAnnounceTimeouts": "Timeout dell'annuncio in chat per tutti", + "cWarningsAllowedCount": "Conteggio avvisi", + "cEmotesEmojisAreEmotes": "Tratta Emojis come Emotes" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.points.json b/backend/locales/it/ui.systems.points.json new file mode 100644 index 000000000..836a998c2 --- /dev/null +++ b/backend/locales/it/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Stato", + "name": { + "title": "Nome", + "help": "Formati possibili:
punto punti
bod 4:body bodu" + }, + "isPointResetIntervalEnabled": "Intervallo di reset punti", + "resetIntervalCron": { + "name": "Intervallo cron", + "help": "Generatore CronTab" + }, + "interval": "Intervallo di minuti per aggiungere punti agli utenti online quando lo stream online", + "offlineInterval": "Intervallo di minuti per aggiungere punti agli utenti online quando lo stream è offline", + "messageInterval": "Quanti messaggi per aggiungere punti", + "messageOfflineInterval": "Quanti messaggi da aggiungere punti quando lo stream è fuori rete", + "perInterval": "Quanti punti aggiungere per intervallo online", + "perOfflineInterval": "Quanti punti aggiungere per intervallo offline", + "perMessageInterval": "Quanti punti aggiungere per intervallo di messaggio", + "perMessageOfflineInterval": "Quanti punti aggiungere per ogni intervallo di messaggio offline" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.price.json b/backend/locales/it/ui.systems.price.json new file mode 100644 index 000000000..db51f837f --- /dev/null +++ b/backend/locales/it/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Trigger custom alerts on bit redeem", + "price": { + "name": "prezzo", + "placeholder": "" + }, + "error": { + "isEmpty": "Questo valore non può essere vuoto" + }, + "warning": "Questa azione non può essere annullata!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.queue.json b/backend/locales/it/ui.systems.queue.json new file mode 100644 index 000000000..2f889b078 --- /dev/null +++ b/backend/locales/it/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Status", + "eligibilityAll": "Tutti", + "eligibilityFollowers": "Follower", + "eligibilitySubscribers": "Abbonati" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.quotes.json b/backend/locales/it/ui.systems.quotes.json new file mode 100644 index 000000000..19fb20fee --- /dev/null +++ b/backend/locales/it/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "Siamo spiacenti, nessuna citazione è stata trovata nel database.", + "new": "Aggiungi una nuova citazione", + "empty": "La lista delle citazioni è vuota, crea una nuova citazione.", + "emptyAfterSearch": "La lista delle citazioni è vuota nella ricerca di \"$search\"", + "quote": { + "name": "Citazione", + "placeholder": "Imposta qui la tua citazione" + }, + "by": { + "name": "Citato da" + }, + "tags": { + "name": "Etichette", + "placeholder": "Imposta qui le tue etichette", + "help": "Etichette separate da virgole. Esempio: tag 1, tag 2, tag 3" + }, + "date": { + "name": "Data" + }, + "error": { + "isEmpty": "Questo campo non può essere vuoto", + "atLeastOneTag": "È necessario impostare almeno un tag" + }, + "tag-filter": "Filtraggio per tag", + "warning": "Questa azione non può essere annullata!", + "settings": { + "enabled": "Stato", + "urlBase": { + "title": "Base URL", + "help": "Dovresti usare endpoint pubblico per le quotazioni, per essere accessibile da tutti" + } + } +} diff --git a/backend/locales/it/ui.systems.raffles.json b/backend/locales/it/ui.systems.raffles.json new file mode 100644 index 000000000..1c2c366a9 --- /dev/null +++ b/backend/locales/it/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Abbonati fortunati" + }, + "settings": { + "enabled": "Status", + "announceNewEntries": { + "title": "Annunciare nuove voci", + "help": "Se gli utenti si uniscono alla lotteria, il messaggio di annuncio verrà inviato alla chat dopo tempo." + }, + "announceNewEntriesBatchTime": { + "title": "Per quanto tempo attendere prima di annunciare nuove voci (in secondi)", + "help": "Più tempo manterrà la chat più pulita, le voci saranno aggregate insieme." + }, + "deleteRaffleJoinCommands": { + "title": "Elimina il comando join della lotteria utente", + "help": "Questo eliminerà il messaggio dell'utente se userà il comando !yourraffle. Dovrebbe mantenere la chat più pulita." + }, + "allowOverTicketing": { + "title": "Consenti la sovra-emissione di biglietti", + "help": "Consenti all'utente di unirsi alla lotteria con sovraemissione del biglietto dei suoi punti. l'utente ha 10 punti ma può unirsi con !lotteria 100 che userà tutti i suoi punti." + }, + "raffleAnnounceInterval": { + "title": "Intervallo annunci", + "help": "Minuti" + }, + "raffleAnnounceMessageInterval": { + "title": "Intervallo annunci messaggi", + "help": "Quanti messaggi devono essere inviati in chat fino a quando annunciare può essere pubblicato." + }, + "subscribersPercent": { + "title": "Fortuna abbonati aggiuntiva", + "help": "in percentuale" + } + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.ranks.json b/backend/locales/it/ui.systems.ranks.json new file mode 100644 index 000000000..2c7c11768 --- /dev/null +++ b/backend/locales/it/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "Nuovo Rango", + "empty": "Nessun rango è stato ancora creato.", + "emptyAfterSearch": "Non sono stati trovati ranghi dalla tua ricerca per \"$search\".", + "rank": { + "name": "rango", + "placeholder": "" + }, + "value": { + "name": "ore", + "placeholder": "" + }, + "error": { + "isEmpty": "Questo valore non può essere vuoto" + }, + "warning": "Questa azione non può essere annullata!", + "settings": { + "enabled": "Stato" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.songs.json b/backend/locales/it/ui.systems.songs.json new file mode 100644 index 000000000..a02acb5bd --- /dev/null +++ b/backend/locales/it/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Stato", + "volume": "Volume", + "calculateVolumeByLoudness": "Volume dinamico per intensità", + "duration": { + "title": "Durata massima del brano", + "help": "In minuti" + }, + "shuffle": "Casuale", + "songrequest": "Riproduci dalla richiesta del brano", + "playlist": "Riproduci dalla scaletta", + "onlyMusicCategory": "Permetti solo musica di categoria", + "allowRequestsOnlyFromPlaylist": "Consenti richieste di brani solo dalla scaletta corrente", + "notify": "Invia messaggio al cambiamento del brano" + }, + "error": { + "isEmpty": "Questo valore non può essere vuoto" + }, + "startTime": "Avvia il brano alle", + "endTime": "Termina brano alle", + "add_song": "Aggiungi brano", + "add_or_import": "Aggiungi brano o importa dalla scaletta", + "importing": "Importazione", + "importing_done": "Importazione Completata", + "seconds": "Secondi", + "calculated": "Calcolato", + "set_manually": "Imposta manualmente", + "bannedSongsEmptyAfterSearch": "Non sono stati trovati brani banditi dalla tua ricerca per \"$search\".", + "emptyAfterSearch": "Non sono stati trovati brani dalla tua ricerca per \"$search\".", + "empty": "Non sono stati ancora aggiunti brani.", + "bannedSongsEmpty": "Nessun brano è stato ancora aggiunto alla lista brani banditi." +} \ No newline at end of file diff --git a/backend/locales/it/ui.systems.timers.json b/backend/locales/it/ui.systems.timers.json new file mode 100644 index 000000000..125de54dd --- /dev/null +++ b/backend/locales/it/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "Nuovo Timer", + "empty": "Nessun timer è stato ancora creato.", + "emptyAfterSearch": "Nessun timer è stato trovato dalla tua ricerca per \"$search\".", + "add_response": "Aggiungi Risposta", + "settings": { + "enabled": "Stato" + }, + "warning": "Questa azione non può essere annullata!" +} \ No newline at end of file diff --git a/backend/locales/it/ui.widgets.customvariables.json b/backend/locales/it/ui.widgets.customvariables.json new file mode 100644 index 000000000..a5a31315e --- /dev/null +++ b/backend/locales/it/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "Nessuna variabile personalizzata trovata, aggiungi al registro delle variabili personalizzate", + "add-variable-into-watchlist": "Aggiungi variabile alla lista di controllo", + "watchlist": "Lista di controllo" +} \ No newline at end of file diff --git a/backend/locales/it/ui.widgets.randomizer.json b/backend/locales/it/ui.widgets.randomizer.json new file mode 100644 index 000000000..f6df11f33 --- /dev/null +++ b/backend/locales/it/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "Nessun randomizzatore trovato, aggiungi al registro un randomizzatore", + "add-randomizer-to-widget": "Aggiungi randomizzatore al widget" +} \ No newline at end of file diff --git a/backend/locales/it/ui/categories.json b/backend/locales/it/ui/categories.json new file mode 100644 index 000000000..a07946b81 --- /dev/null +++ b/backend/locales/it/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Annunci", + "keys": "Keys", + "currency": "Valuta", + "general": "Generale", + "settings": "Impostazioni", + "commands": "Comandi", + "bot": "Bot", + "channel": "Channel", + "connection": "Connessione", + "chat": "Chat", + "graceful_exit": "Uscita graziosa", + "rewards": "Ricompense", + "levels": "Livelli", + "notifications": "Notifiche", + "options": "Opzioni", + "comboBreakMessages": "Messaggi Combo Break", + "hypeMessages": "Messaggi Di Hype", + "messages": "Messaggi", + "results": "Risultati", + "customization": "Personalizzazione", + "status": "Stato", + "mapping": "Mappatura", + "player": "Giocatore", + "stats": "Statistiche", + "api": "API", + "token": "Token", + "text": "Testo", + "custom_texts": "Testo personalizzato", + "credits": "Riconoscimenti", + "show": "Visualizza", + "social": "Social", + "explosion": "Esplosione", + "fireworks": "Fuochi D'Artificio", + "test": "Prova", + "emotes": "Emotes", + "default": "Predefinito", + "urls": "URLs", + "conversion": "Conversione", + "xp": "XP", + "caps_filter": "Filtro maiuscole", + "color_filter": "Filtro Italic (/me)", + "links_filter": "Filtro collegamenti", + "symbols_filter": "Filtro simboli", + "longMessage_filter": "Filtro lunghezza messaggio", + "spam_filter": "Filtro spam", + "emotes_filter": "Filtro Emotes", + "warnings": "Avvisi", + "reset": "Reimposta", + "reminder": "Promemoria", + "eligibility": "Ammissibilità", + "join": "Entra", + "luck": "Fortuna", + "lists": "Liste", + "me": "Me", + "emotes_combo": "Combinazione di Emotes", + "tmi": "tmi", + "oauth": "oauth", + "eventsub": "eventsub", + "rules": "rules" +} \ No newline at end of file diff --git a/backend/locales/it/ui/core/currency.json b/backend/locales/it/ui/core/currency.json new file mode 100644 index 000000000..20db08547 --- /dev/null +++ b/backend/locales/it/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Valuta principale" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/core/general.json b/backend/locales/it/ui/core/general.json new file mode 100644 index 000000000..2b93995e3 --- /dev/null +++ b/backend/locales/it/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Lingua del bot", + "numberFormat": "Formato dei numeri in chat", + "gracefulExitEachXHours": { + "title": "Uscita graziosa ogni X ore", + "help": "0 - disattivato" + }, + "shouldGracefulExitHelp": "Abilitare l'uscita graziosa è raccomandato se il tuo bot è in esecuzione senza fine sul server. Dovresti avere bot in esecuzione su pm2 (o servizio simile) o averlo dockerizzato per garantire il riavvio automatico del bot. Il bot non uscirà con grazia quando lo stream è online." + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/core/oauth.json b/backend/locales/it/ui/core/oauth.json new file mode 100644 index 000000000..910379f90 --- /dev/null +++ b/backend/locales/it/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Proprietari", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "Nome Utente", + "channelUsername": "Username", + "botExpectedScopes": "Ambiti", + "channelExpectedScopes": "Scopes" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/core/permissions.json b/backend/locales/it/ui/core/permissions.json new file mode 100644 index 000000000..b527ddf00 --- /dev/null +++ b/backend/locales/it/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Aggiungi nuovo gruppo di permessi", + "higherPermissionHaveAccessToLowerPermissions": "Permessi più alti hanno accesso a permessi più bassi.", + "typeUsernameOrIdToSearch": "Digita nome utente o ID da cercare", + "typeUsernameOrIdToTest": "Digita nome utente o ID da testare", + "noUsersWereFound": "Nessun utente trovato.", + "noUsersManuallyAddedToPermissionYet": "Nessun utente è stato ancora aggiunto manualmente al permesso.", + "done": "Fatto", + "previous": "Precedente", + "next": "Successivo", + "loading": "caricamento", + "permissionNotFoundInDatabase": "Permesso non trovato nel database, si prega di salvare prima di testare utente.", + "userHaveNoAccessToThisPermissionGroup": "L'utente $username NON ha accesso a questo gruppo di autorizzazioni.", + "userHaveAccessToThisPermissionGroup": "L'utente $username ha accesso a questo gruppo di autorizzazioni.", + "accessDirectlyThrough": "Accesso diretto tramite", + "accessThroughHigherPermission": "Accesso tramite autorizzazioni più elevate", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Qualcosa è andato storto, l'utente $username non è stato trovato nel database del bot.", + "permissionsGroups": "Gruppi di autorizzazioni", + "allowHigherPermissions": "Consenti l'accesso tramite permessi più elevati", + "type": "Tipo", + "value": "Valore", + "watched": "Tempo guardato in ore", + "followtime": "Tempo di follow in mesi", + "points": "Punti", + "tips": "Donazioni", + "bits": "Bits", + "messages": "Messaggi", + "subtier": "Livello abbonati (1, 2 o 3)", + "subcumulativemonths": "Mesi cumulativi abbonamento", + "substreakmonths": "Substreak attuale", + "ranks": "Rango corrente", + "level": "Livello corrente", + "isLowerThan": "è minore di", + "isLowerThanOrEquals": "è inferiore o uguale a", + "equals": "uguali", + "isHigherThanOrEquals": "è superiore o uguale", + "isHigherThan": "è maggiore di", + "addFilter": "Aggiungi filtro", + "selectPermissionGroup": "Seleziona gruppo di autorizzazioni", + "settings": "Impostazioni", + "name": "Nome", + "baseUsersSet": "Set di utenti di base", + "manuallyAddedUsers": "Utenti inseriti manualmente", + "manuallyExcludedUsers": "Utenti esclusi manualmente", + "filters": "Filtri", + "testUser": "Test utente", + "none": "- nessuno -", + "casters": "Caster", + "moderators": "Moderatori", + "subscribers": "Abbonati", + "vip": "VIP", + "viewers": "Spettatori", + "followers": "Follower" +} \ No newline at end of file diff --git a/backend/locales/it/ui/core/socket.json b/backend/locales/it/ui/core/socket.json new file mode 100644 index 000000000..084a60d18 --- /dev/null +++ b/backend/locales/it/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Elimina tutta la connessione autenticata (anche tua)", + "accessTokenExpirationTime": "Tempo Di Scadenza Token D'Accesso (Secondi)", + "refreshTokenExpirationTime": "Aggiorna Il Tempo Di Scadenza Token (Secondi)", + "socketToken": { + "title": "Token di socket", + "help": "Questo token ti darà pieno accesso admin attraverso i socket. Non condividere!" + } + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/core/tmi.json b/backend/locales/it/ui/core/tmi.json new file mode 100644 index 000000000..1008eacb9 --- /dev/null +++ b/backend/locales/it/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Ignora lista (ID o nome utente)", + "showWithAt": "Mostra utenti con @", + "sendWithMe": "Invia messaggi con /me", + "sendAsReply": "Send bot messages as replies", + "mute": "Il bot è silenziato", + "whisperListener": "Ascolta sui comandi dei sussurri" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/core/tts.json b/backend/locales/it/ui/core/tts.json new file mode 100644 index 000000000..f4b8119bc --- /dev/null +++ b/backend/locales/it/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Service" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/core/twitch.json b/backend/locales/it/ui/core/twitch.json new file mode 100644 index 000000000..e056c6a1e --- /dev/null +++ b/backend/locales/it/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Create stream marker on event" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/core/ui.json b/backend/locales/it/ui/core/ui.json new file mode 100644 index 000000000..0bf7be2ad --- /dev/null +++ b/backend/locales/it/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Tema predefinito", + "domain": { + "title": "Dominio", + "help": "Formato senza http/https: yourdomain.com o your.domain.com" + }, + "percentage": "Differenza percentuale per le statistiche", + "shortennumbers": "Breve formato dei numeri", + "showdiff": "Mostra differenza", + "enablePublicPage": "Enable public page" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/core/updater.json b/backend/locales/it/ui/core/updater.json new file mode 100644 index 000000000..7774bdc4f --- /dev/null +++ b/backend/locales/it/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Aggiorna automaticamente se una versione più recente è disponibile" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/errors.json b/backend/locales/it/ui/errors.json new file mode 100644 index 000000000..8b3e4bef8 --- /dev/null +++ b/backend/locales/it/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Unexpected errors during validation", + "isNotEmpty": "$property is required.", + "minLength": "$property must be longer than or equal to $constraint1 characters.", + "isPositive": "$property must be greater then 0", + "isCommand": "$property must start with !", + "isCommandOrCustomVariable": "$property must start with ! or $_", + "isCustomVariable": "$property must start with $_", + "min": "$property must be at least $constraint1", + "max": "$property must be lower or equal to $constraint1", + "isInt": "$property must be an integer", + "this_value_must_be_a_positive_number_and_greater_then_0": "This value must be a positive number or greater then 0", + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty", + "minLength_of_value_is": "Minimal length is $value.", + "this_currency_is_not_supported": "This currency is not supported", + "something_went_wrong": "Something went wrong", + "permission_must_exist": "Permission must exist", + "minValue_of_value_is": "Minimal value is $value", + "value_cannot_be": "Value cannot be $value.", + "invalid_format": "Invalid value format.", + "invalid_regexp_format": "This is not valid regex.", + "owner_and_broadcaster_oauth_is_not_set": "Owner and channel oauth is not set", + "channel_is_not_set": "Channel is not set", + "please_set_your_broadcaster_oauth_or_owners": "Please set your channel oauth or owners, or all users will have access to this dashboard and will be considered as casters.", + "new_update_available": "New update available", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "One of inputs must be set" +} \ No newline at end of file diff --git a/backend/locales/it/ui/games/duel.json b/backend/locales/it/ui/games/duel.json new file mode 100644 index 000000000..5fcec14bc --- /dev/null +++ b/backend/locales/it/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Stato", + "cooldown": "Cooldown", + "duration": { + "title": "Durata", + "help": "Minuti" + }, + "minimalBet": "Puntata minima", + "bypassCooldownByOwnerAndMods": "Ignora cooldown dal proprietario e mod" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/games/gamble.json b/backend/locales/it/ui/games/gamble.json new file mode 100644 index 000000000..8f2f8c3a2 --- /dev/null +++ b/backend/locales/it/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Stato", + "minimalBet": "Puntata minima", + "chanceToWin": { + "title": "Probabilità di vincere", + "help": "Percentuale" + }, + "enableJackpot": "Abilita jackpot", + "chanceToTriggerJackpot": "Possibilità di attivare il jackpot in %", + "maxJackpotValue": "Valore massimo del jackpot", + "lostPointsAddedToJackpot": "Quanti punti persi dovrebbero essere aggiunti al jackpot in %" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/games/heist.json b/backend/locales/it/ui/games/heist.json new file mode 100644 index 000000000..9fbaa6c31 --- /dev/null +++ b/backend/locales/it/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Rapina", + "settings": { + "enabled": "Stato", + "showMaxUsers": "Numero massimo di utenti da mostrare nel pagamento", + "copsCooldownInMinutes": { + "title": "Attesa tra rapine", + "help": "Minuti" + }, + "entryCooldownInSeconds": { + "title": "Tempo per entrare nella rapina", + "help": "Secondi" + }, + "started": "Messaggio iniziale della rapina", + "nextLevelMessage": "Messaggio quando viene raggiunto il livello successivo", + "maxLevelMessage": "Messaggio quando viene raggiunto il livello massimo", + "copsOnPatrol": "Risposta del bot quando la rapina è ancora in fase di attesa", + "copsCooldown": "Annuncio del bot quando la rapina può essere avviata", + "singleUserSuccess": "Messaggio di successo per un utente", + "singleUserFailed": "Messaggio di errore per un utente", + "noUser": "Messaggio se nessun utente ha partecipato" + }, + "message": "Messaggio", + "winPercentage": "Percentuale di vincita", + "payoutMultiplier": "Moltiplicatore di pagamento", + "maxUsers": "Numero massimo di utenti per livello", + "percentage": "Percentuale", + "noResultsFound": "Nessun risultato trovato. Clicca il pulsante qui sotto per aggiungere un nuovo risultato.", + "noLevelsFound": "Nessun livello trovato. Fare clic sul pulsante qui sotto per aggiungere un nuovo livello." +} \ No newline at end of file diff --git a/backend/locales/it/ui/games/roulette.json b/backend/locales/it/ui/games/roulette.json new file mode 100644 index 000000000..5670f076a --- /dev/null +++ b/backend/locales/it/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Stato", + "timeout": { + "title": "Durata dell'intervallo", + "help": "Secondi" + }, + "winnerWillGet": "Quanti punti verranno aggiunti alla vittoria", + "loserWillLose": "Quanti punti andranno persi alla perdita" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/games/seppuku.json b/backend/locales/it/ui/games/seppuku.json new file mode 100644 index 000000000..65b0a6702 --- /dev/null +++ b/backend/locales/it/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Stato", + "timeout": { + "title": "Durata dell'intervallo", + "help": "Secondi" + } + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/integrations/discord.json b/backend/locales/it/ui/integrations/discord.json new file mode 100644 index 000000000..d85cff681 --- /dev/null +++ b/backend/locales/it/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Stato", + "guild": "Gilda", + "listenAtChannels": "Ascolta i comandi su questo canale", + "sendOnlineAnnounceToChannel": "Invia un annuncio online a questo canale", + "onlineAnnounceMessage": "Message in online announcement (can include mentions)", + "sendAnnouncesToChannel": "Imposta l'invio di annunci ai canali", + "deleteMessagesAfterWhile": "Elimina il messaggio dopo un po di tempo", + "clientId": "ClientId", + "token": "Token", + "joinToServerBtn": "Clicca per unirti al bot sul tuo server", + "joinToServerBtnDisabled": "Please save changes to enable bot join to your server", + "cannotJoinToServerBtn": "Imposta token e clientId per essere in grado di unirsi al bot sul tuo server", + "noChannelSelected": "nessun canale selezionato", + "noRoleSelected": "nessun ruolo selezionato", + "noGuildSelected": "nessuna gilda selezionata", + "noGuildSelectedBox": "Seleziona la gilda dove il bot dovrebbe funzionare e vedrai più impostazioni", + "onlinePresenceStatusDefault": "Stato Predefinito", + "onlinePresenceStatusDefaultName": "Messaggio Di Stato Predefinito", + "onlinePresenceStatusOnStream": "Stato durante la trasmissione", + "onlinePresenceStatusOnStreamName": "Messaggio di stato durante la trasmissione", + "ignorelist": { + "title": "Lista degli utenti ignorati", + "help": "username, username#0000 o userID" + } + } +} diff --git a/backend/locales/it/ui/integrations/donatello.json b/backend/locales/it/ui/integrations/donatello.json new file mode 100644 index 000000000..75bd1598d --- /dev/null +++ b/backend/locales/it/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Get your token at https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/integrations/donationalerts.json b/backend/locales/it/ui/integrations/donationalerts.json new file mode 100644 index 000000000..ae3a0b42b --- /dev/null +++ b/backend/locales/it/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Stato", + "access_token": { + "title": "Token di accesso", + "help": "Ottieni il tuo token di accesso su https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Refresh token" + }, + "accessTokenBtn": "Generatore di access e refresh token di DonationAlerts" + } +} diff --git a/backend/locales/it/ui/integrations/kofi.json b/backend/locales/it/ui/integrations/kofi.json new file mode 100644 index 000000000..a8179bf1e --- /dev/null +++ b/backend/locales/it/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Verification token", + "help": "Get your verification token at https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "Webhook URL", + "help": "Set Webhook URL at https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL must have HTTPS", + "origin": "You cannot use localhost for webhooks" + } + } + } +} diff --git a/backend/locales/it/ui/integrations/lastfm.json b/backend/locales/it/ui/integrations/lastfm.json new file mode 100644 index 000000000..77237d293 --- /dev/null +++ b/backend/locales/it/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Stato", + "apiKey": "Chiave API", + "username": "Nome Utente" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/integrations/obswebsocket.json b/backend/locales/it/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..1f63a1eeb --- /dev/null +++ b/backend/locales/it/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Stato", + "accessBy": { + "title": "Accesso da", + "help": "Diretto - connettersi direttamente da un bot Overlay - connettersi tramite la sorgente del browser sovrapposto" + }, + "address": "Indirizzi", + "password": "Password" + }, + "noSourceSelected": "Nessuna fonte selezionata", + "noSceneSelected": "Nessuna scena selezionata", + "empty": "Nessun set di azioni è stato ancora creato.", + "emptyAfterSearch": "Nessun set di azioni trovato dalla tua ricerca per \"$search\".", + "command": "Comando", + "new": "Crea un nuovo set di azioni OBS Websocket", + "actions": "Azioni", + "name": { + "name": "Nome" + }, + "mute": "Silenzia", + "unmute": "Riattiva", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Attendere X milisecondi" + }, + "Log": { + "name": "Messaggio di log" + }, + "StartRecording": { + "name": "StartRecording" + }, + "StopRecording": { + "name": "StopRecording" + }, + "PauseRecording": { + "name": "PauseRecording" + }, + "ResumeRecording": { + "name": "ResumeRecording" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/integrations/pubg.json b/backend/locales/it/ui/integrations/pubg.json new file mode 100644 index 000000000..efcce18bd --- /dev/null +++ b/backend/locales/it/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Stato", + "apiKey": { + "title": "Chiave API", + "help": "Ottieni la tua chiave API su https://developer.pubg.com/" + }, + "platform": "Piattaforma", + "playerName": "Nome del giocatore", + "playerId": "ID Giocatore", + "seasonId": { + "title": "ID Stagione", + "help": "L'ID della stagione corrente viene recuperato ogni ora." + }, + "rankedGameModeStatsCustomization": "Messaggio personalizzato per statistiche classificate", + "gameModeStatsCustomization": "Messaggio personalizzato per le statistiche normali" + }, + "click_to_fetch": "Clicca per recuperare", + "something_went_wrong": "Qualcosa è andato storto!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Le statistiche vengono aggiornate automaticamente ogni 10 minuti.", + "player_stats_ranked": "Statistiche giocatore (classificato)", + "player_stats": "Statistiche giocatore" +} diff --git a/backend/locales/it/ui/integrations/qiwi.json b/backend/locales/it/ui/integrations/qiwi.json new file mode 100644 index 000000000..970b77b5a --- /dev/null +++ b/backend/locales/it/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Stato", + "secretToken": { + "title": "Token segreto", + "help": "Ottieni un token segreto su Qiwi Dona impostazioni dashboard->clicca mostra token segreto" + } + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/integrations/responsivevoice.json b/backend/locales/it/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..bc30ac15d --- /dev/null +++ b/backend/locales/it/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Chiave", + "help": "Ottieni la tua chiave Api su http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/integrations/spotify.json b/backend/locales/it/ui/integrations/spotify.json new file mode 100644 index 000000000..69d0a7d8d --- /dev/null +++ b/backend/locales/it/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Artists", + "settings": { + "enabled": "Stato", + "songRequests": "Richieste Brano", + "fetchCurrentSongWhenOffline": { + "title": "Recupera il brano corrente quando la diretta è offline", + "help": "Si consiglia di avere questo disabilitato per evitare di raggiungere i limiti delle API" + }, + "allowApprovedArtistsOnly": "Consenti solo artisti approvati", + "approvedArtists": { + "title": "Artisti approvati", + "help": "Nome o SpotifyURI dell'artista, un elemento per riga" + }, + "queueWhenOffline": { + "title": "Accoda i brani quando la diretta è offline", + "help": "Si consiglia di disabilitare questo per evitare la coda quando si sta solo ascoltando musica" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "Id Dispositivo Forzato", + "help": "Vuoto = disabilitato, forza l'ID del dispositivo di spotify per essere usato per mettere in coda le canzoni. Controlla i registri per il dispositivo attivo corrente o usa il pulsante quando riproduci il brano per almeno 10 secondi." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Formato", + "help": "Variabili disponibili: $song, $artist, $artists" + }, + "username": "Utente autorizzato", + "revokeBtn": "Revoca autorizzazione utente", + "authorizeBtn": "Autorizza utente", + "scopes": "Campi", + "playlistToPlay": { + "title": "Spotify URI della scaletta principale", + "help": "Se impostata, una volta completata la richiesta, questa scaletta continuerà" + }, + "continueOnPlaylistAfterRequest": "Continua a riprodurre la scaletta dopo la richiesta del brano", + "notify": "Invia messaggio al cambiamento del brano" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/integrations/streamelements.json b/backend/locales/it/ui/integrations/streamelements.json new file mode 100644 index 000000000..63aa1fe3d --- /dev/null +++ b/backend/locales/it/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Stato", + "jwtToken": { + "title": "Token JWT", + "help": "Get JWT token at StreamElements Channels setting and toggle Show secrets" + } + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/integrations/streamlabs.json b/backend/locales/it/ui/integrations/streamlabs.json new file mode 100644 index 000000000..b8d6a9acd --- /dev/null +++ b/backend/locales/it/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Stato", + "socketToken": { + "title": "Token del socket", + "help": "Ottieni il token del socket dalle impostazioni delle API dela dashboard streamlabs->tokens->Il tuo token API Socket" + }, + "accessToken": { + "title": "Token di accesso", + "help": "Ottieni il tuo token di accesso su https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "Generatore di token di accesso StreamLabs" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/integrations/tipeeestream.json b/backend/locales/it/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..4c655bd81 --- /dev/null +++ b/backend/locales/it/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Stato", + "apiKey": { + "title": "Chiave Api", + "help": "Ottieni il token del socket dalla dashboard tipeeestream -> API -> La tua chiave API" + } + } +} diff --git a/backend/locales/it/ui/integrations/twitter.json b/backend/locales/it/ui/integrations/twitter.json new file mode 100644 index 000000000..e0f5f413f --- /dev/null +++ b/backend/locales/it/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Stato", + "consumerKey": "Chiave Consumatore (Chiave Api)", + "consumerSecret": "Consumer Secret (API Segreto)", + "accessToken": "Token di accesso", + "secretToken": "Token di accesso segreto" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/managers.json b/backend/locales/it/ui/managers.json new file mode 100644 index 000000000..f5c180fe4 --- /dev/null +++ b/backend/locales/it/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "Cronologia eventi dell'utente", + "hostAndRaidViewersCount": "Spettatori: $value", + "receivedSubscribeFrom": "Ricevuto abbonamento da $value", + "giftedSubscribeTo": "Abbonamento donato a $value" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/overlays/alerts.json b/backend/locales/it/ui/overlays/alerts.json new file mode 100644 index 000000000..3cae87a02 --- /dev/null +++ b/backend/locales/it/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Oggetti galleria cache", + "galleryCacheLimitInMb": "Dimensione massima dell'elemento galleria (in MB) per la cache" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/overlays/clips.json b/backend/locales/it/ui/overlays/clips.json new file mode 100644 index 000000000..1ae4fad12 --- /dev/null +++ b/backend/locales/it/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Volume", + "cClipsFilter": "Filtro clip", + "cClipsLabel": "Mostra etichetta 'clip'" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/overlays/clipscarousel.json b/backend/locales/it/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..7513a4595 --- /dev/null +++ b/backend/locales/it/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Intervallo di tempo (giorni)", + "cClipsNumOfClips": "Numero di clip", + "cClipsTimeToNextClip": "Tempo alla prossima clip (s)" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/overlays/credits.json b/backend/locales/it/ui/overlays/credits.json new file mode 100644 index 000000000..84db4b6fa --- /dev/null +++ b/backend/locales/it/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Velocità", + "cCreditsAggregated": "Crediti aggregati", + "cShowGameThumbnail": "Show game thumbnail", + "cShowFollowers": "Mostra follower", + "cShowRaids": "Mostra raid", + "cShowSubscribers": "Mostra abbonati", + "cShowSubgifts": "Mostra abbonamenti donati", + "cShowSubcommunitygifts": "Mostra gli abbonamenti donati alla comunità", + "cShowResubs": "Mostra i riabbonati", + "cShowCheers": "Show cheers", + "cShowClips": "Mostra le clips", + "cShowTips": "Mostra donazioni", + "cTextLastMessage": "Ultimo messaggio", + "cTextLastSubMessage": "Ultimo submessge", + "cTextStreamBy": "Diretta da", + "cTextFollow": "Seguiti da", + "cTextRaid": "Raidato da", + "cTextCheer": "Cheer by", + "cTextSub": "Abbonato da", + "cTextResub": "Riabbonato da", + "cTextSubgift": "Abbonamenti regalati", + "cTextSubcommunitygift": "Abbonamenti donati alla comunità", + "cTextTip": "Donazioni da", + "cClipsPeriod": "Intervallo di tempo", + "cClipsCustomPeriodInDays": "Intervallo di tempo personalizzato (giorni)", + "cClipsNumOfClips": "Numero di clip", + "cClipsShouldPlay": "Le clip dovrebbero essere avviate", + "cClipsVolume": "Volume" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/overlays/emotes.json b/backend/locales/it/ui/overlays/emotes.json new file mode 100644 index 000000000..f78ac0574 --- /dev/null +++ b/backend/locales/it/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Cancella cache", + "hypeMessagesEnabled": "Mostra i messaggi di hype nella chat", + "btnTestExplosion": "Test Esplosione di Emote", + "btnTestEmote": "Test emote", + "btnTestFirework": "Test fuochi d'artificio emote", + "cEmotesSize": "Dimensione degli Emotes", + "cEmotesMaxEmotesPerMessage": "Maximum of emotes per message", + "cEmotesMaxRotation": "Maximal rotation of emote", + "cEmotesOffsetX": "Maximal offset on X-axis", + "cEmotesAnimation": "Animazione", + "cEmotesAnimationTime": "Durata dell'animazione", + "cExplosionNumOfEmotes": "N. di emotes", + "cExplosionNumOfEmotesPerExplosion": "N. di emotes per esplosione", + "cExplosionNumOfExplosions": "N. di esplosioni", + "enableEmotesCombo": "Abilita combinazione di emotes", + "comboBreakMessages": "Messaggi di interruzione combinati", + "threshold": "Soglia", + "noMessagesFound": "Nessun messaggio trovato.", + "message": "Messaggio", + "showEmoteInOverlayThreshold": "Soglia minima messaggio da mostrare emote in sovrapposizione", + "hideEmoteInOverlayAfter": { + "title": "Nascondi emote in sovrapposizione dopo inattività", + "help": "Si nasconderà l'emote in sovrapposizione dopo un certo tempo in secondi" + }, + "comboCooldown": { + "title": "Tempo d'attesa combo", + "help": "Tempo d'attesa della combinazione in secondi" + }, + "comboMessageMinThreshold": { + "title": "Soglia minima messaggio", + "help": "Soglia minima del messaggio per contare le emozioni come combo (fino a quel momento non si attiverà il tempo di attesa)" + }, + "comboMessages": "Messaggi combinati" + }, + "hype": { + "5": "Evvai! Abbiamo ottenuto $amountx $emote combo finora! SeemsGood", + "15": "Teniamo duro! Possiamo ottenere più di $amountx $emote? TriHard" + }, + "message": { + "3": "$amountx $emote combo", + "5": "$amountx $emote combo sembraBuono", + "10": "$amountx $emote combo PogChamp", + "15": "$amountx $emote combo TriHard", + "20": "$sender ha rovinato $amountx $emote combo! NonCosi" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/overlays/polls.json b/backend/locales/it/ui/overlays/polls.json new file mode 100644 index 000000000..3e9688f71 --- /dev/null +++ b/backend/locales/it/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Tema", + "cDisplayHideAfterInactivity": "Nascondi in inattività", + "cDisplayAlign": "Allinea", + "cDisplayInactivityTime": { + "title": "Inattività dopo", + "help": "in millisecondi" + } + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/overlays/texttospeech.json b/backend/locales/it/ui/overlays/texttospeech.json new file mode 100644 index 000000000..1d896a2d6 --- /dev/null +++ b/backend/locales/it/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "Non hai impostato correttamente la chiave ResponsiveVoice", + "voice": { + "title": "Voce", + "help": "Se le voci non vengono caricate correttamente dopo l'aggiornamento della chiave ResponsiveVoice, prova ad aggiornare il browser" + }, + "volume": "Volume", + "rate": "Ritmo", + "pitch": "Tono", + "triggerTTSByHighlightedMessage": "Il testo alla voce verrà attivato dal messaggio evidenziato" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/properties.json b/backend/locales/it/ui/properties.json new file mode 100644 index 000000000..e6243cf72 --- /dev/null +++ b/backend/locales/it/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Command", + "variableName": "Variable name", + "price": "Price (points)", + "priceBits": "Price (bits)", + "thisvalue": "This value", + "promo": { + "shoutoutMessage": "Shoutout message", + "enableShoutoutMessage": "Send shoutout message in chat" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/registry/alerts.json b/backend/locales/it/ui/registry/alerts.json new file mode 100644 index 000000000..3591ac43a --- /dev/null +++ b/backend/locales/it/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Abilitato", + "testDlg": { + "alertTester": "Tester allerta", + "command": "Comando", + "username": "Nome Utente", + "recipient": "Destinatario", + "message": "Messaggio", + "tier": "Livello", + "amountOfViewers": "Quantità di spettatori", + "amountOfBits": "Quantità di bit", + "amountOfGifts": "Quantità di doni", + "amountOfMonths": "Ammontare mesi", + "amountOfTips": "Tip", + "event": "Evento", + "service": "Service" + }, + "empty": "Il registro degli avvisi è vuoto, crea nuovi avvisi.", + "emptyAfterSearch": "Il registro degli avvisi è vuoto nella ricerca di \"$search\"", + "revertcode": "Ripristina il codice predefinito", + "name": { + "name": "Nome", + "placeholder": "Imposta il nome dei tuoi avvisi" + }, + "alertDelayInMs": { + "name": "Ritardo di allarme" + }, + "parryEnabled": { + "name": "Interrompi con nuovo alert" + }, + "parryDelay": { + "name": "Ritardo interruzione nuovo alert" + }, + "profanityFilterType": { + "name": "Filtro profanità", + "disabled": "Disattivato", + "replace-with-asterisk": "Sostituisci con l'asterisco", + "replace-with-happy-words": "Sostituisci con parole felici", + "hide-messages": "Nascondi messaggi", + "disable-alerts": "Disattiva avvisi" + }, + "loadStandardProfanityList": "Carica lista di profanità standard", + "customProfanityList": { + "name": "Lista di profanità personalizzata", + "help": "Le parole dovrebbero essere separate da virgole." + }, + "event": { + "follow": "Follow", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Subgift to community", + "tip": "Tip", + "raid": "Raid", + "custom": "Custom", + "promo": "Promo", + "rewardredeem": "Reward Redeem" + }, + "title": { + "name": "Nome variante", + "placeholder": "Imposta il nome della tua variante" + }, + "variant": { + "name": "Variante di occorrenza" + }, + "filter": { + "name": "Filter", + "operator": "Operatore", + "rule": "Regola", + "addRule": "Aggiungi regola", + "addGroup": "Aggiungi gruppo", + "comparator": "Comparatore", + "value": "Valore", + "valueSplitByComma": "Valori divisi per virgola (ad es. val1, val2)", + "isEven": "è pari", + "isOdd": "è dispari", + "lessThan": "meno di", + "lessThanOrEqual": "minore o uguale a", + "contain": "contiene", + "contains": "contains", + "equal": "uguale", + "notEqual": "non uguale", + "present": "è presente", + "includes": "include", + "greaterThan": "piú grande di", + "greaterThanOrEqual": "maggiore o uguale a", + "noFilter": "nessun filtro" + }, + "speed": { + "name": "Velocità" + }, + "maxTimeToDecrypt": { + "name": "Tempo massimo per decifrare" + }, + "characters": { + "name": "Caratteri" + }, + "random": "Casuale", + "exact-amount": "Importo esatto", + "greater-than-or-equal-to-amount": "Maggiore o uguale all’importo", + "tier-exact-amount": "Il livello è esattamente", + "tier-greater-than-or-equal-to-amount": "Il livello è superiore o uguale a", + "months-exact-amount": "La quantità di mesi è esattamente", + "months-greater-than-or-equal-to-amount": "L'importo mensile è superiore o uguale a", + "gifts-exact-amount": "La quantità delle donazioni è esattamente", + "gifts-greater-than-or-equal-to-amount": "L'importo delle donazioni è superiore o uguale a", + "very-rarely": "Molto raramente", + "rarely": "Raramente", + "default": "Predefinito", + "frequently": "Frequentemente", + "very-frequently": "Molto frequentemente", + "exclusive": "Esclusivo", + "messageTemplate": { + "name": "Modello messaggio", + "placeholder": "Imposta il tuo modello di messaggi", + "help": "Available variables: {name}, {amount} (cheers, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). If | is added (see promo) then it will show those values in sequence." + }, + "ttsTemplate": { + "name": "TTS template", + "placeholder": "Set your TTS template", + "help": "Available variables: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Testo animazione" + }, + "animationType": { + "name": "Type of animation" + }, + "animationIn": { + "name": "Animazione in entrata" + }, + "animationOut": { + "name": "Animazione in uscita" + }, + "alertDurationInMs": { + "name": "Durata dell'avviso" + }, + "alertTextDelayInMs": { + "name": "Ritardo testo dell'avviso" + }, + "layoutPicker": { + "name": "Disposizione" + }, + "loop": { + "name": "Play on loop" + }, + "scale": { + "name": "Scala" + }, + "translateY": { + "name": "Sposta -Su / +Giù" + }, + "translateX": { + "name": "Sposta -Sinistra / +Destra" + }, + "image": { + "name": "Immagine / Video(.webm)", + "setting": "Impostazioni immagine / video (.webm)" + }, + "sound": { + "name": "Suono", + "setting": "Impostazioni suono" + }, + "soundVolume": { + "name": "Volume dell'avviso" + }, + "enableAdvancedMode": "Abilita modalità avanzata", + "font": { + "setting": "Impostazioni carattere", + "name": "Famiglia di caratteri", + "overrideGlobal": "Ignora le impostazioni globali dei caratteri", + "align": { + "name": "Allinemento", + "left": "Sinistra", + "center": "Centro", + "right": "Destra" + }, + "size": { + "name": "Dimensione carattere" + }, + "weight": { + "name": "Spessore carattere" + }, + "borderPx": { + "name": "Bordi del carattere" + }, + "borderColor": { + "name": "Colore bordi del carattere" + }, + "color": { + "name": "Colore del carattere" + }, + "highlightcolor": { + "name": "Colore evidenziazione carattere" + } + }, + "minAmountToShow": { + "name": "Quantità minima da mostrare" + }, + "minAmountToPlay": { + "name": "Quantità minima da riprodurre" + }, + "allowEmotes": { + "name": "Consenti emotes" + }, + "message": { + "setting": "Impostazioni dei messaggi" + }, + "voice": "Voce", + "keepAlertShown": "Avviso mantenuto visibile durante il TTS", + "skipUrls": "Salta URL durante TTS", + "volume": "Volume", + "rate": "Ritmo", + "pitch": "Tono", + "test": "Test", + "tts": { + "setting": "Impostazioni TTS" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/registry/goals.json b/backend/locales/it/ui/registry/goals.json new file mode 100644 index 000000000..65b8efcd4 --- /dev/null +++ b/backend/locales/it/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Aggiungi Gruppo Obiettivi", + "addGoal": "Aggiungi Obiettivo", + "newGoal": "nuovo Obiettivo", + "newGoalGroup": "nuovo Gruppo Obiettivo", + "goals": "Obiettivi", + "general": "Generale", + "display": "Visualizza", + "fontSettings": "Impostazioni Carattere", + "barSettings": "Impostazioni Della Barra", + "selectGoalOnLeftSide": "Selezionare o aggiungere un obiettivo sul lato sinistro", + "input": { + "description": { + "title": "Descrizione" + }, + "goalAmount": { + "title": "Importo Obiettivo" + }, + "countBitsAsTips": { + "title": "Conta bit come suggerimenti" + }, + "currentAmount": { + "title": "Importo Corrente" + }, + "endAfter": { + "title": "Termina dopo" + }, + "endAfterIgnore": { + "title": "L'obiettivo non scadrà" + }, + "borderPx": { + "title": "Bordo", + "help": "La dimensione del bordo è in pixel" + }, + "barHeight": { + "title": "Altezza Barra", + "help": "L'altezza della barra è in pixel" + }, + "color": { + "title": "Colore" + }, + "borderColor": { + "title": "Colore dei margini" + }, + "backgroundColor": { + "title": "Colore sfondo" + }, + "type": { + "title": "Tipo" + }, + "nameGroup": { + "title": "Nome di questo gruppo di obiettivi" + }, + "name": { + "title": "Nome di questo gruppo di obiettivi" + }, + "displayAs": { + "title": "Visualizza come", + "help": "Imposta come verrà mostrato il gruppo di obiettivi" + }, + "durationMs": { + "title": "Durata", + "help": "Questo valore è in millisecondi", + "placeholder": "Per quanto tempo dovrebbe essere mostrato l'obiettivo" + }, + "animationInMs": { + "title": "Durata animazione", + "help": "Questo valore è in millisecondi", + "placeholder": "Imposta la tua animazione In durata" + }, + "animationOutMs": { + "title": "Durata animazione in uscita", + "help": "Questo valore è in millisecondi", + "placeholder": "Imposta durata dell'animazione in uscita" + }, + "interval": { + "title": "Quale intervallo contare" + }, + "spaceBetweenGoalsInPx": { + "title": "Spazio tra gli obiettivi", + "help": "Questo valore è in pixel", + "placeholder": "Imposta il tuo spazio tra gli obiettivi" + } + }, + "groupSettings": "Impostazioni di gruppo" +} \ No newline at end of file diff --git a/backend/locales/it/ui/registry/overlays.json b/backend/locales/it/ui/registry/overlays.json new file mode 100644 index 000000000..2240fa56e --- /dev/null +++ b/backend/locales/it/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Crea una nuova mappatura dei collegamenti sovrapposti", + "emptyMapping": "Nessuna mappatura di collegamento sovrapposizione è stata ancora creata.", + "allowedIPs": { + "name": "IP consentiti", + "help": "Consenti l'accesso da IP impostati separati da una nuova riga" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/registry/plugins.json b/backend/locales/it/ui/registry/plugins.json new file mode 100644 index 000000000..00eb1444f --- /dev/null +++ b/backend/locales/it/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "This node needs to be linked with listeners with sender attributes" + }, + "filter": { + "permission": { + "name": "Permission filter" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Event listener", + "type": { + "twitchChatMessage": "Twitch chat message", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat cleared", + "twitchCommand": "Twitch command", + "twitchFollow": "New Twitch follower", + "twitchSubscription": "New Twitch subscription", + "twitchSubgift": "New Twitch subscription gift", + "twitchSubcommunitygift": "New Twitch subscription community gift", + "twitchResub": "New Twitch recurring subscription", + "twitchGameChanged": "Twitch category changed", + "twitchStreamStarted": "Twitch stream started", + "twitchStreamStopped": "Twitch stream stopped", + "twitchRewardRedeem": "Twitch reward redeemed", + "twitchRaid": "Twitch raid incoming", + "tip": "Tipped by user", + "botStarted": "Bot started" + }, + "command": { + "add-parameter": "Add parameter", + "parameters": "Parameters", + "order-is-important": "order is important" + } + }, + "others": { + "idle": { + "name": "Idle" + } + }, + "output": { + "log": { + "name": "Log message" + }, + "timeout-user": { + "name": "Timeout user" + }, + "ban-user": { + "name": "Ban user" + }, + "send-twitch-message": { + "name": "Send Twitch Message" + } + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/registry/randomizer.json b/backend/locales/it/ui/registry/randomizer.json new file mode 100644 index 000000000..e74f3db3e --- /dev/null +++ b/backend/locales/it/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Aggiungi Randomizer", + "form": { + "name": "Nome", + "command": "Comando", + "permission": "Autorizzazione comando", + "simple": "Semplice", + "tape": "Nastro", + "wheelOfFortune": "Ruota della Fortuna", + "type": "Tipo", + "options": "Opzioni", + "optionsAreEmpty": "Le opzioni sono vuote.", + "color": "Colore", + "numOfDuplicates": "N. di duplicati", + "minimalSpacing": "Spaziatura minima", + "groupUp": "Raggruppa Su", + "ungroup": "Separa", + "groupedWithOptionAbove": "Raggruppato con l'opzione sopra", + "generatedOptionsPreview": "Anteprima delle opzioni generate", + "probability": "Probabilità", + "tick": "Tick suono durante il giro" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/registry/textoverlay.json b/backend/locales/it/ui/registry/textoverlay.json new file mode 100644 index 000000000..a60e33341 --- /dev/null +++ b/backend/locales/it/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Crea una nuova sovrapposizione di testo", + "title": "testo Sovrapposto", + "name": { + "placeholder": "Imposta il tuo nome di sovrapposizione testo" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/stats/commandcount.json b/backend/locales/it/ui/stats/commandcount.json new file mode 100644 index 000000000..c9affe9dc --- /dev/null +++ b/backend/locales/it/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Comando", + "hour": "Ora", + "day": "Giorno", + "week": "Settimana", + "month": "Mese", + "year": "Anno", + "total": "Totale" +} \ No newline at end of file diff --git a/backend/locales/it/ui/systems/checklist.json b/backend/locales/it/ui/systems/checklist.json new file mode 100644 index 000000000..c4f7eab4d --- /dev/null +++ b/backend/locales/it/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Stato", + "itemsArray": "Lista" + }, + "check": "Lista di controllo" +} \ No newline at end of file diff --git a/backend/locales/it/ui/systems/howlongtobeat.json b/backend/locales/it/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..d8e7a249c --- /dev/null +++ b/backend/locales/it/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Stato" + }, + "empty": "Nessun gioco è stato ancora tracciato.", + "emptyAfterSearch": "Nessun gioco tracciato è stato trovato dalla tua ricerca per \"$search\".", + "when": "Quando viene trasmesso", + "time": "Tempo tracciato", + "overallTime": "Overall time", + "offset": "Scostamento del tempo tracciato", + "main": "Principale", + "extra": "Principale+Extra", + "completionist": "Completista", + "game": "Gioco tracciato", + "startedAt": "Tracciamento iniziato il", + "updatedAt": "Last update", + "showHistory": "Mostra cronologia ($count)", + "hideHistory": "Nascondi cronologia ($count)", + "searchToAddNewGame": "Cerca per aggiungere una nuova partita da traccia" +} \ No newline at end of file diff --git a/backend/locales/it/ui/systems/keywords.json b/backend/locales/it/ui/systems/keywords.json new file mode 100644 index 000000000..9577b1e6c --- /dev/null +++ b/backend/locales/it/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "Nuova Parola Chiave", + "empty": "Nessuna parola chiave è stata ancora creata.", + "emptyAfterSearch": "Nessuna parola chiave trovata dalla tua ricerca per \"$search\".", + "keyword": { + "name": "Parola Chiave / Espressione Regolare", + "placeholder": "Imposta la parola chiave o l'espressione regolare per attivare la parola chiave.", + "help": "Puoi usare regexp (case insensitive) per usare parole chiave, ad esempio hello.* hi" + }, + "response": { + "name": "Risposta", + "placeholder": "Imposta qui la tua risposta." + }, + "error": { + "isEmpty": "Questo valore non può essere vuoto" + }, + "no-responses-set": "Nessuna risposta", + "addResponse": "Aggiungi risposta", + "filter": { + "name": "filtro", + "placeholder": "Aggiungi filtro per questa risposta" + }, + "warning": "Questa azione non può essere annullata!", + "settings": { + "enabled": "Stato" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/systems/levels.json b/backend/locales/it/ui/systems/levels.json new file mode 100644 index 000000000..2af0ef4b9 --- /dev/null +++ b/backend/locales/it/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Stato", + "conversionRate": "Tasso di conversione 1 XP per x Punti", + "firstLevelStartsAt": "Il primo livello inizia da XP", + "nextLevelFormula": { + "title": "Formula di calcolo del livello successivo", + "help": "Variabili disponibili: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "L'esempio dei livelli verrà aggiornato al salvataggio", + "xpName": "Nome", + "interval": "Intervallo di minuti per aggiungere xp agli utenti online quando lo stream è online", + "offlineInterval": "Intervallo di minuti per aggiungere xp agli utenti online quando lo stream è offline", + "messageInterval": "Quanti messaggi aggiungere xp", + "messageOfflineInterval": "Quanti messaggi aggiungere xp quando la diretta è offline", + "perInterval": "Quanti exp aggiungere per intervallo online", + "perOfflineInterval": "Quanti xp aggiungere per intervallo offline", + "perMessageInterval": "Quanti xp aggiungere per intervallo di messaggio", + "perMessageOfflineInterval": "Quanti xp aggiungere per ogni messaggio offline" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/systems/polls.json b/backend/locales/it/ui/systems/polls.json new file mode 100644 index 000000000..404aed40c --- /dev/null +++ b/backend/locales/it/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Voti totali", + "totalPoints": "Total points", + "closedAt": "Chiuso a", + "activeFor": "Attivo per" +} \ No newline at end of file diff --git a/backend/locales/it/ui/systems/scrim.json b/backend/locales/it/ui/systems/scrim.json new file mode 100644 index 000000000..e7d38dd38 --- /dev/null +++ b/backend/locales/it/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Stato", + "waitForMatchIdsInSeconds": { + "title": "Intervallo per inserire l'ID della partita in chat", + "help": "Imposta in secondi" + } + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/systems/top.json b/backend/locales/it/ui/systems/top.json new file mode 100644 index 000000000..f2b1d432b --- /dev/null +++ b/backend/locales/it/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Stato" + } +} \ No newline at end of file diff --git a/backend/locales/it/ui/systems/userinfo.json b/backend/locales/it/ui/systems/userinfo.json new file mode 100644 index 000000000..892b2734c --- /dev/null +++ b/backend/locales/it/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Stato", + "formatSeparator": "Separatore formato", + "order": "Formato", + "lastSeenFormat": { + "title": "Formato orario", + "help": "Possibili formati su https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja.json b/backend/locales/ja.json new file mode 100644 index 000000000..642c0d9a5 --- /dev/null +++ b/backend/locales/ja.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "が読み込まれ、", + "enabled": "有効です", + "disabled": "無効です", + "usage": "使い方", + "lang-selected": "Bot言語は現在日本語に設定されています", + "refresh-panel": "変更を確認するには UI を更新する必要があります。", + "command-parse": "申し訳ありませんが、$sender このコマンドは正しくありません。", + "error": "申し訳ありませんが、$senderで何か問題が発生しました", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender、API が正しく応答していません!", + "not-available": "利用できません" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "年|年", + "months": "ヶ月|ヶ月", + "days": "日|日", + "hours": "時間|時間", + "minutes": "分|分", + "seconds": "秒|秒", + "messages": "メッセージ|メッセージ", + "bits": "ビッツ|ビッツ", + "links": "リンク|リンク", + "entries": "エントリー|エントリー", + "empty": "empty", + "isRegistered": "$sender, あなたは !$keywordを使用することはできません。なぜなら、既に他のアクションに使用されているからです!" + }, + "clip": { + "notCreated": "問題が発生し、クリップが作成されませんでした。", + "offline": "ストリームは現在オフラインで、クリップを作成できません。" + }, + "uptime": { + "online": "ストリームは (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss) オンラインです", + "offline": "ストリームは 現在(if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss) オンラインです" + }, + "webpanel": { + "this-system-is-disabled": "このシステムは無効になっています", + "or": "または", + "loading": "読み込み中", + "this-may-take-a-while": "しばらく時間がかかるかもしれません", + "display-as": "として表示", + "go-to-admin": "管理画面へ移動", + "go-to-public": "公開ページへ移動", + "logout": "ログアウト", + "popout": "ポップアウト", + "not-logged-in": "ログインしていません", + "remove-widget": "$name ウィジェットを削除する", + "join-channel": "Botをチャンネルに追加", + "leave-channel": "Botをチャンネルから退出する", + "set-default": "デフォルトに設定", + "add": "追加", + "placeholders": { + "text-url-generator": "テキストまたはhtmlを貼り付けて、下にBase64と上にURLを生成します。", + "text-decode-base64": "URLとテキストを生成するためにbase64を貼り付けます", + "creditsSpeed": "クレジットの回転速度を設定します。より低い=より速い" + }, + "timers": { + "title": "タイマー", + "timer": "タイマー", + "messages": "メッセージ", + "seconds": "秒", + "badges": { + "enabled": "有効", + "disabled": "無効" + }, + "errors": { + "timer_name_must_be_compliant": "この値は a-zA-Z09_ のみを含めることができます", + "this_value_must_be_a_positive_number_or_0": "この値は正の数または0でなければなりません", + "value_cannot_be_empty": "値を空にすることはできません。" + }, + "dialog": { + "timer": "タイマー", + "name": "名前", + "tickOffline": "ストリームがオフラインの場合チェック", + "interval": "間隔", + "responses": "応答", + "messages": "Xメッセージごとにトリガーする", + "seconds": "X秒ごとにトリガーする", + "title": { + "new": "新規タイマー", + "edit": "タイマーを編集" + }, + "placeholders": { + "name": "タイマーの名前を設定します。 a-zA-Z0-9_のみを含めることができます", + "messages": "Xメッセージごとにタイマーをトリガーする", + "seconds": "X秒ごとにタイマーをトリガーする" + }, + "alerts": { + "success": "タイマーを 正常に 保存しました。", + "fail": "何かがうまくいかなかった" + } + }, + "buttons": { + "close": "終了", + "save-changes": "変更を保存", + "disable": "無効", + "enable": "有効", + "edit": "編集", + "delete": "削除", + "yes": "はい", + "no": "いいえ" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "タイマーを削除しますか?" + } + }, + "events": { + "event": "イベント", + "noEvents": "データベースにイベントが見つかりません。", + "whatsthis": "これは何ですか?", + "myRewardIsNotListed": "私の報酬はリストされていません!", + "redeemAndClickRefreshToSeeReward": "リストに作成した報酬がない場合は、更新アイコンをクリックして更新してください。", + "badges": { + "enabled": "有効", + "disabled": "無効" + }, + "buttons": { + "test": "テスト", + "enable": "有効", + "disable": "無効", + "edit": "編集", + "delete": "削除", + "yes": "はい", + "no": "いいえ" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "イベントを削除しますか?", + "example_of_user_object_data": "ユーザーオブジェクトデータの例" + }, + "errors": { + "command_must_start_with_!": "コマンドは ! で開始する必要があります。", + "this_value_must_be_a_positive_number_or_0": "この値は正の数または0でなければなりません", + "value_cannot_be_empty": "値を空にすることはできません。" + }, + "dialog": { + "title": { + "new": "新しいイベントリスナー", + "edit": "イベントリスナーを編集" + }, + "placeholders": { + "name": "イベントリスナーの名前を設定します(空の場合は名前が生成されます)" + }, + "alerts": { + "success": "イベントを 正常に 保存しました。", + "fail": "何かがうまくいかなかった" + }, + "close": "終了", + "save-changes": "変更を保存", + "event": "イベント", + "name": "名前", + "usable-events-variables": "使用可能なイベント変数", + "settings": "設定", + "filters": "フィルター", + "operations": "オペレーション" + }, + "definitions": { + "taskId": { + "label": "タスクID" + }, + "filter": { + "label": "フィルター" + }, + "linkFilter": { + "label": "Link Overlay Filter", + "placeholder": "オーバーレイを使用する場合は、オーバーレイのリンクまたは id を追加してください" + }, + "hashtag": { + "label": "ハッシュタグまたはキーワード", + "placeholder": "#あなたのハッシュタグ か キーワード" + }, + "fadeOutXCommands": { + "label": "X コマンドをフェードアウト", + "placeholder": "フェードアウト間隔ごとに減算されるコマンドの数" + }, + "fadeOutXKeywords": { + "label": "Xキーワードをフェードアウト", + "placeholder": "フェードアウト間隔ごとに減算されるキーワードの数" + }, + "fadeOutInterval": { + "label": "フェードアウトの間隔 (秒)", + "placeholder": "減算間隔をフェードアウトする" + }, + "runEveryXCommands": { + "label": "すべての X コマンドを実行", + "placeholder": "イベントが発生する前のコマンドの数" + }, + "runEveryXKeywords": { + "label": "すべてのXキーワードを実行", + "placeholder": "イベントが発生する前のキーワードの数" + }, + "commandToWatch": { + "label": "監視するコマンド", + "placeholder": "あなたの !監視するコマンド を設定" + }, + "keywordToWatch": { + "label": "ウォッチするキーワード", + "placeholder": "あなたの 監視するキーワード を設定" + }, + "resetCountEachMessage": { + "label": "各メッセージ数をリセット", + "true": "カウントをリセット", + "false": "カウントを維持" + }, + "viewersAtLeast": { + "label": "視聴者は最低でも", + "placeholder": "イベントを発生させるのに必要な最低限の視聴者数" + }, + "runInterval": { + "label": "実行間隔 (0 = ストリームごとに1回実行)", + "placeholder": "x秒ごとにイベントをトリガーする" + }, + "runAfterXMinutes": { + "label": "X分後に実行", + "placeholder": "x分後にイベントをトリガーする" + }, + "runEveryXMinutes": { + "label": "X分ごとに実行", + "placeholder": "x分ごとにイベントをトリガーする" + }, + "messageToSend": { + "label": "送信するメッセージ", + "placeholder": "メッセージを設定" + }, + "channel": { + "label": "チャンネル", + "placeholder": "チャンネル名または ID" + }, + "timeout": { + "label": "タイムアウト", + "placeholder": "ミリ秒単位でタイムアウトを設定します" + }, + "timeoutType": { + "label": "タイムアウトのタイプ", + "placeholder": "タイムアウトの種類を設定" + }, + "command": { + "label": "コマンド", + "placeholder": "!commandを設定" + }, + "commandToRun": { + "label": "実行するコマンド", + "placeholder": "!実行するコマンドを設定する" + }, + "isCommandQuiet": { + "label": "コマンドの出力をミュート" + }, + "urlOfSoundFile": { + "label": "サウンドファイルの Url", + "placeholder": "http://www.pathToYour.url/where/is/file.mp3" + }, + "emotesToExplode": { + "label": "爆発するエモート", + "placeholder": "爆発するエモートのリスト、例:Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "打ち上げるエモート", + "placeholder": "打ち上げるのエモートのリスト、例えばKappa PurpleHeart" + }, + "replay": { + "label": "オーバーレイでクリップを再生", + "true": "オーバーレイ/アラートでリプレイとして再生する", + "false": "リプレイは再生されません" + }, + "announce": { + "label": "チャットでアナウンスする", + "true": "アナウンスされます", + "false": "アナウンスされません" + }, + "hasDelay": { + "label": "クリップは少し遅延する必要があります(視聴者が見てるものに近づけるために)", + "true": "遅延が発生します", + "false": "遅延はありません" + }, + "durationOfCommercial": { + "label": "CMの放映時間", + "placeholder": "利用可能な時間:30、60、90、120、150、180" + }, + "customVariable": { + "label": "$_", + "placeholder": "更新するカスタム変数" + }, + "numberToIncrement": { + "label": "増加する数値", + "placeholder": "" + }, + "value": { + "label": "値", + "placeholder": "" + }, + "numberToDecrement": { + "label": "減少する数", + "placeholder": "" + }, + "": "", + "reward": { + "label": "リワード", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Followed you", + "raid": "Raided you with $viewers raiders.", + "sub": "Subscribed to you with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "has been gifted subscription from $username", + "subcommunitygift": "Gifted subscriptions for community", + "resub": "Resubscribed with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Cheered you", + "tip": "Tipped you", + "tipToCharity": "donated to $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Twitch チャンネルポイント予測 - タイトル", + "outcomes": "Twitch チャンネルポイント予測 - 結果", + "locksAt": "Twitch チャンネルポイント予測 - 日付でロック", + "winningOutcomeTitle": "Twitch チャンネルポイント予測 - 勝利結果タイトル", + "winningOutcomeTotalPoints": "Twitch チャンネルポイント予測 - 勝利結果総ポイント数", + "winningOutcomePercentage": "Twitch チャンネルポイント予測 - 勝利結果割合", + "titleOfPoll": "Twitch アンケート- タイトル", + "bitAmountPerVote": "Twitch アンケート- 1票としてカウントするビッツの量", + "bitVotingEnabled": "Twitch アンケート- ビッツ投票を有効にする (ブール値)", + "channelPointsAmountPerVote": "Twitch アンケート- 1票としてカウントするチャンネルポイントの量", + "channelPointsVotingEnabled": "Twitch アンケート- チャンネルポイント投票を有効にする (ブール値)", + "votes": "Twitch アンケート- 投票数", + "winnerChoice": "Twitch アンケート- 投票結果", + "winnerPercentage": "Twitch アンケート - 投票結果の選択率", + "winnerVotes": "Twitch アンケート - 投票結果の投票数", + "goal": "目標", + "total": "合計", + "lastContributionTotal": "最後の貢献 - 合計", + "lastContributionType": "最後の貢献 - タイプ", + "lastContributionUserId": "最後の貢献 - ユーザーID", + "lastContributionUsername": "最後の貢献 - ユーザー名", + "level": "レベル", + "topContributionsBitsTotal": "上位ビッツ貢献 - 合計", + "topContributionsBitsUserId": "上位ビッツ貢献 - ユーザーID", + "topContributionsBitsUsername": "上位ビッツ貢献 - ユーザー名", + "topContributionsSubsTotal": "上位サブスクライブ貢献 - 合計", + "topContributionsSubsUserId": "上位サブスクライブ貢献 - ユーザーID", + "topContributionsSubsUsername": "上位サブスクライブ貢献 - ユーザー名", + "sender": "開始したユーザー", + "title": "現在のタイトル", + "game": "Current category", + "language": "現在のストリームの言語", + "viewers": "現在の視聴者数", + "hostViewers": "Raid viewers count", + "followers": "現在のフォロワー数", + "subscribers": "現在のサブスクライバー数", + "arg": "引数", + "param": "パラメーター (必須)", + "touser": "ユーザー名のパラメーター", + "!param": "パラメータ (必須ではありません)", + "alias": "エイリアス", + "command": "コマンド", + "keyword": "キーワード", + "response": "応答", + "list": "入力済みリスト", + "type": "タイプ", + "days": "日", + "hours": "時間", + "minutes": "分", + "seconds": "秒", + "description": "説明", + "quiet": "サイレント(ブール)", + "id": "ID", + "name": "Name", + "messages": "メッセージ", + "amount": "金額", + "amountInBotCurrency": "金額と通貨", + "currency": "通貨", + "currencyInBot": "ボット内の通貨", + "pointsName": "ポイント名", + "points": "ポイント", + "rank": "ランク", + "nextrank": "次のランク", + "username": "ユーザー名", + "value": "値", + "variable": "変数", + "count": "カウント", + "link": "リンク (翻訳済み)", + "winner": "勝者", + "loser": "敗者", + "challenger": "チャレンジャー", + "min": "最小", + "max": "最大", + "eligibility": "参加資格", + "probability": "確率", + "time": "時間", + "options": "オプション", + "option": "オプション", + "when": "いつ", + "diff": "違い", + "users": "ユーザー", + "user": "ユーザー", + "bank": "銀行", + "nextBank": "次の銀行", + "cooldown": "クールダウン", + "tickets": "チケット", + "ticketsName": "チケット名", + "fromUsername": "ユーザー名から", + "toUsername": "ユーザー名に", + "items": "アイテム", + "bits": "ビッツ", + "subgifts": "サブギフト", + "subStreakShareEnabled": "サブストリーク共有が有効になっています (true/false)", + "subStreak": "現在のサブ連続数", + "subStreakName": "現在のサブ継続の月の名(1ヶ月、2ヶ月)", + "subCumulativeMonths": "累積サブスクリプション月数", + "subCumulativeMonthsName": "現在の累計サブスクライブの名(1ヶ月、2ヶ月)", + "message": "メッセージ", + "reason": "理由", + "target": "対象", + "duration": "期間", + "method": "方法", + "tier": "ティア", + "months": "月", + "monthsName": "ローカライズされた月の名前 (1ヶ月、2ヶ月)", + "oldGame": "Category before change", + "recipientObject": "受信者(オブジェクト全体)", + "recipient": "宛先", + "ytSong": "YouTubeの現在の曲", + "spotifySong": "Spotifyで現在の曲", + "latestFollower": "最新のフォロワー", + "latestSubscriber": "最新のサブスクライバー", + "latestSubscriberMonths": "最新のサブスクライバーの累積月", + "latestSubscriberStreak": "最新のサブスクライバーの連続月数", + "latestTipAmount": "最新のTip(金額)", + "latestTipCurrency": "最新のTip(通貨)", + "latestTipMessage": "最新のTip (メッセージ)", + "latestTip": "最新のTip(ユーザー名)", + "toptip": { + "overall": { + "username": "トップ Tip - 全体 (ユーザー名)", + "amount": "トップ Tip - 全体(金額)", + "currency": "トップ Tip - 全体(通貨)", + "message": "トップ Tip - 全体(メッセージ)" + }, + "stream": { + "username": "トップ Tip - ストリーム中(ユーザー名)", + "amount": "トップ Tip - ストリーム中(金額)", + "currency": "トップ Tip - ストリーム中(通貨)", + "message": "トップ Tip - ストリーム中(メッセージ)" + } + }, + "latestCheerAmount": "最新のビッツ(金額)", + "latestCheerMessage": "最新ビッツ(メッセージ)", + "latestCheer": "最新のビッツ(ユーザー名)", + "version": "Botバージョン", + "haveParam": "コマンドパラメータを持っていますか?(bool)", + "source": "現在のソース (twitchまたはdiscord)", + "userInput": "報酬引き換え時にユーザーが入力する", + "isBotSubscriber": "Botはサブスクライバーですか?(bool)", + "isStreamOnline": "ストリームはオンラインです (bool)", + "uptime": "ストリームの稼働時間", + "is": { + "moderator": "ユーザーはモデレーターですか? (bool)", + "subscriber": "ユーザーはサブスクライバーですか? (bool)", + "vip": "ユーザーはVIPですか? (bool)", + "newchatter": "ユーザーの最初のメッセージはありますか?(bool)", + "follower": "ユーザーはフォロワーですか? (bool)", + "broadcaster": "ユーザーは配信者ですか? (bool)", + "bot": "ユーザーボットとは? (bool)", + "owner": "ユーザーボットの所有者ですか? (bool)" + }, + "recipientis": { + "moderator": "受信者はモデレーターですか? (bool)", + "subscriber": "受信者はサブスクライバーですか? (bool)", + "vip": "受信者はVIPですか? (bool)", + "follower": "受信者はフォロワーですか?(bool)", + "broadcaster": "受信者は配信者ですか? (bool)", + "bot": "受信者のボットとは? (bool)", + "owner": "受信者のボットの所有者ですか? (bool)" + }, + "sceneName": "シーン名", + "inputName": "Name of input", + "inputMuted": "Mute state (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "その他", + "currency": "通貨" + }, + "whispers": { + "title": "ウィスパー", + "toggle": { + "listener": "ウィスパーに関するコマンドを聞く", + "settings": "設定の変更時のウィスパー", + "raffle": "抽選参加時のウィスパー", + "permissions": "権限が不足している場合のウィスパー", + "cooldowns": "クールダウン時のウィスパー(通知として設定されている場合)" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "メッセージ", + "follows": "フォロー", + "subs": "サブ & 再サブ", + "cheers": "ビッツ", + "responses": "ボット応答", + "whispers": "ウィスパー", + "bans": "禁止", + "timeouts": "タイムアウト" + }, + "range": { + "day": "1日", + "week": "1週間", + "month": "1ヶ月", + "year": "1 年", + "all": "全期間" + }, + "order": { + "asc": "昇順", + "desc": "降順" + }, + "labels": { + "order": "オーダー", + "range": "範囲", + "filters": "フィルター" + } + }, + "stats-panel": { + "show": "スタッツを表示", + "hide": "スタッツを非表示" + }, + "translations": "カスタム翻訳", + "bot-responses": "Bot応答", + "duration": "期間", + "viewers-reset-attributes": "属性のリセット", + "viewers-points-of-all-users": "全てのユーザーのポイント", + "viewers-watchtime-of-all-users": "全てのユーザーの視聴時間", + "viewers-messages-of-all-users": "すべてのユーザのメッセージ", + "events-game-after-change": "category after change", + "events-game-before-change": "category before change", + "events-user-triggered-event": "ユーザーがトリガーしたイベント", + "events-method-used-to-subscribe": "サブスクライブに使用される方法", + "events-months-of-subscription": "サブスクリプションの月", + "events-monthsName-of-subscription": "数字で「月」(1ヶ月、2ヶ月)", + "events-user-message": "ユーザーメッセージ", + "events-bits-user-sent": "ユーザーが送信したビッツ", + "events-reason-for-ban-timeout": "BAN/タイムアウトの理由", + "events-duration-of-timeout": "タイムアウトの長さ", + "events-duration-of-commercial": "CMの放映時間", + "overlays-eventlist-resub": "再サブスクライブ", + "overlays-eventlist-subgift": "サブスクギフト", + "overlays-eventlist-subcommunitygift": "コミュニティギフト", + "overlays-eventlist-sub": "サブ", + "overlays-eventlist-follow": "フォロー", + "overlays-eventlist-cheer": "ビッツ", + "overlays-eventlist-tip": "Tip", + "overlays-eventlist-raid": "レイド", + "requested-by": "リクエスト者", + "description": "説明", + "raffle-type": "抽選タイプ", + "raffle-type-keywords": "キーワードのみ", + "raffle-type-tickets": "チケットあり", + "raffle-tickets-range": "チケットの範囲", + "video_id": "ビデオ ID", + "highlights": "ハイライト", + "cooldown-quiet-header": "クールダウンメッセージを表示", + "cooldown-quiet-toggle-no": "通知する", + "cooldown-quiet-toggle-yes": "通知しない", + "cooldown-moderators": "モデレーター", + "cooldown-owners": "所有者", + "cooldown-subscribers": "サブスクライバー", + "cooldown-followers": "フォロワー", + "in-seconds": "秒で", + "songs": "曲", + "show-usernames-with-at": "@ でユーザー名を表示", + "send-message-as-a-bot": "ボットとしてメッセージを送信する", + "chat-as-bot": "チャット (ボットとして)", + "product": "商品", + "optional": "任意", + "placeholder-search": "検索", + "placeholder-enter-product": "商品を入力", + "placeholder-enter-keyword": "キーワードを入力", + "credits": "クレジット", + "fade-out-top": "フェードアップ", + "fade-out-zoom": "フェードズーム", + "global": "グローバル", + "user": "ユーザー", + "alerts": "通知", + "eventlist": "イベントリスト", + "dashboard": "ダッシュボード", + "carousel": "イメージカルーセル", + "text": "テキスト", + "filter": "Filter", + "filters": "Filters", + "isUsed": "Is used", + "permissions": "権限", + "permission": "権限", + "viewers": "視聴者数", + "systems": "システム", + "overlays": "オーバーレイ", + "gallery": "メディアギャラリー", + "aliases": "エイリアス", + "alias": "エイリアス", + "command": "コマンド", + "cooldowns": "クールダウン", + "title-template": "タイトルテンプレート", + "keyword": "キーワード", + "moderation": "モデレーション", + "timer": "タイマー", + "price": "価格", + "rank": "ランク", + "previous": "戻る", + "next": "次へ", + "close": "終了", + "save-changes": "変更を保存", + "saving": "保存中...", + "deleting": "削除中...", + "done": "完了", + "error": "エラー", + "title": "タイトル", + "change-title": "タイトルを変更", + "game": "category", + "tags": "タグ", + "change-game": "Change category", + "click-to-change": "クリックして変更", + "uptime": "稼働時間", + "not-affiliate-or-partner": "アフィリエイト/パートナーではありません", + "not-available": "利用できません", + "max-viewers": "最大視聴者数", + "new-chatters": "新規チャット", + "chat-messages": "チャットメッセージ", + "followers": "フォロワー", + "subscribers": "サブスクライバー", + "bits": "ビッツ", + "subgifts": "サブスクギフト", + "subStreak": "現在の連続サブスク数", + "subCumulativeMonths": "累積サブスク月数", + "tips": "Tip", + "tier": "ティア", + "status": "状態", + "add-widget": "ウィジェットを追加", + "remove-dashboard": "ダッシュボードを削除", + "close-bet-after": "ベット終了までの時間", + "refund": "払い戻し", + "roll-again": "もう一度回転", + "no-eligible-participants": "対象となる参加者がいません", + "follower": "フォロワー", + "subscriber": "サブスクライバー", + "minutes": "分", + "seconds": "秒", + "hours": "時間", + "months": "月", + "eligible-to-enter": "参加可能", + "everyone": "全員", + "roll-a-winner": "勝者を回転", + "send-message": "メッセージを送信", + "messages": "メッセージ", + "level": "レベル", + "create": "作成", + "cooldown": "クールダウン", + "confirm": "確認", + "delete": "削除", + "enabled": "有効", + "disabled": "無効", + "enable": "有効", + "disable": "無効", + "slug": "Slug", + "posted-by": "投稿者", + "time": "時間", + "type": "タイプ", + "response": "応答", + "cost": "コスト", + "name": "名前", + "playlist": "プレイリスト", + "length": "長さ", + "volume": "音量", + "start-time": "開始時刻", + "end-time": "終了時刻", + "watched-time": "視聴時間", + "currentsong": "現在の曲", + "group": "グループ", + "followed-since": "フォローしてから", + "subscribed-since": "サブスクライブしてから", + "username": "ユーザー名", + "hashtag": "ハッシュタグ", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "範囲", + "last-seen": "最終閲覧", + "date": "日付", + "points": "ポイント", + "calendar": "カレンダー", + "string": "文字列", + "interval": "間隔", + "number": "数値", + "minimal-messages-required": "最低限のメッセージが必要", + "max-duration": "最大期間", + "shuffle": "シャッフル", + "song-request": "ソングリクエスト", + "format": "フォーマット", + "available": "有効にする", + "one-record-per-line": "1行に1つのレコード", + "on": "オン", + "off": "オフ", + "search-by-username": "ユーザー名で検索", + "widget-title-custom": "カスタム", + "widget-title-eventlist": "イベントリスト", + "widget-title-chat": "チャット", + "widget-title-queue": "キュー", + "widget-title-raffles": "抽選", + "widget-title-social": "ソーシャル", + "widget-title-ytplayer": "音楽プレーヤー", + "widget-title-monitor": "モニター", + "event": "イベント", + "operation": "操作", + "tweet-post-with-hashtag": "ハッシュタグ付きのツイートを投稿", + "user-joined-channel": "ユーザーがチャンネルに参加しました", + "user-parted-channel": "ユーザーがチャンネルを離脱しました", + "follow": "新しいフォロー", + "tip": "新しいチップ", + "obs-scene-changed": "OBSのシーンが変更されました", + "obs-input-mute-state-changed": "OBS input source mute state changed", + "unfollow": "フォロー解除", + "hypetrain-started": "ハイプトレイン開始", + "hypetrain-ended": "ハイプトレイン終了", + "prediction-started": "Twitch予測開始", + "prediction-locked": "Twitch予測はロックされました", + "prediction-ended": "Twitch 予測が終了しました", + "poll-started": "Twitchアンケートを開始", + "poll-ended": "Twitchアンケート終了", + "hypetrain-level-reached": "ハイプトレインの次のレベルに達しました", + "subscription": "新規サブスク", + "subgift": "新しいサブギフト", + "subcommunitygift": "新しいコミュニティサブスクギフト", + "resub": "ユーザーが再購読しました", + "command-send-x-times": "コマンドはx回送信されました", + "keyword-send-x-times": "キーワードはx回送信されました", + "number-of-viewers-is-at-least-x": "視聴者数は少なくともxです", + "stream-started": "ストリーム開始", + "reward-redeemed": "報酬引き替え", + "stream-stopped": "ストリームが停止しました", + "stream-is-running-x-minutes": "ストリームが x 分実行中です", + "chatter-first-message": "チャットの最初のメッセージ", + "every-x-minutes-of-stream": "ストリームのx分ごとに", + "game-changed": "category changed", + "cheer": "受け取ったビッツ", + "clearchat": "チャットをクリアしました", + "action": "ユーザーが/me を送信しました", + "ban": "ユーザーはBANされました", + "raid": "あなたのチャンネルはレイドされました", + "mod": "ユーザーは新しいモデレーターです", + "timeout": "ユーザーはタイムアウトされました", + "create-a-new-event-listener": "新しいイベントリスナーを作成する", + "send-discord-message": "discordメッセージを送る", + "send-chat-message": "twitchチャットメッセージを送信", + "send-whisper": "ウィスパーを送る", + "run-command": "コマンドの実行", + "run-obswebsocket-command": "OBS Websocket コマンドの実行", + "do-nothing": "--- 何もしない ---", + "count": "カウント", + "timestamp": "タイムスタンプ", + "message": "メッセージ", + "sound": "サウンド", + "emote-explosion": "エモート爆発", + "emote-firework": "エモート花火", + "quiet": "静かな", + "noisy": "うるさい", + "true": "true", + "false": "false", + "light": "ライトテーマ", + "dark": "ダークテーマ", + "gambling": "ギャンブル", + "seppukuTimeout": "!sepuku のタイムアウト", + "rouletteTimeout": "!roulette のタイムアウト", + "fightmeTimeout": "!fightme のタイムアウト", + "duelCooldown": "!duelのクールダウン", + "fightmeCooldown": "!fightmeのクールダウン", + "gamblingCooldownBypass": "Mods/casterのギャンブルのクールダウンを回避する", + "click-to-highlight": "ハイライト", + "click-to-toggle-display": "表示の切り替え", + "commercial": "コマーシャル開始", + "start-commercial": "コマーシャルを流す", + "bot-will-join-channel": "ボットがチャンネルに参加します", + "bot-will-leave-channel": "ボットがチャンネルを離れます", + "create-a-clip": "クリップを作成", + "increment-custom-variable": "カスタム変数を増加", + "set-custom-variable": "変数の設定", + "decrement-custom-variable": "カスタム変数を減少", + "omit": "省略", + "comply": "準拠", + "visible": "表示", + "hidden": "非表示", + "gamblingChanceToWin": "当選のチャンス !gamble", + "gamblingMinimalBet": "!gamble の最小ベット数", + "duelDuration": "!duel の間隔", + "duelMinimalBet": "!duel の最小ベット数" + }, + "raffles": { + "announceInterval": "抽選は $value 分ごとに発表されます", + "eligibility-followers-item": "フォロワー", + "eligibility-subscribers-item": "サブスクライバー", + "eligibility-everyone-item": "全員", + "raffle-is-running": "抽選が実行されています ($count $l10n_entries)", + "to-enter-raffle": "”$keyword”と入力して、抽選は$eligibilityで開きます", + "to-enter-ticket-raffle": "”$keyword <$min-$max>”と入力して、抽選は$eligibilityで開きます", + "added-entries": "$count $l10n_entries を抽選に追加しました(合計 $countTotal){raffles.to-enter-raffle}", + "added-ticket-entries": "$count $l10n_entries を抽選に追加しました(合計 $countTotal){raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "抽選メッセージは参加時に削除されます。", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "申し訳ありません、 $sender 、キーワードなしで抽選を作成することはできません", + "raffle-is-already-running": "申し訳ありません、 $sender、抽選はすでにキーワード $keyword で実行されています", + "no-raffle-is-currently-running": "$sender, 当選者のいない抽選は現在行われていません", + "no-participants-to-pick-winner": "$sender, 誰も抽選に参加しませんでした", + "raffle-winner-is": "抽選 $keyword の商社は $username !勝率は$probability%!" + }, + "bets": { + "running": "$sender, ベットは既に開いています!ベットオプション: $options. $command close 1-$maxIndex を使用", + "notRunning": "現在開かれているベットはありません。モデレーターに開いてもらってください!", + "opened": "新しいベット'$title' が開かれました!ベットオプション: $options. $command 1-$maxIndex を使用して勝とう! ベットできる時間は$minutesmin だけです!", + "closeNotEnoughOptions": "$sender、ベット終了のために勝利オプションを選択する必要があります。", + "notEnoughOptions": "$sender、新しいベットには少なくとも2つのオプションが必要です!", + "info": "ベット'$title' はまだ開かれています!ベットオプション: $options. $command 1-$maxIndex を使用して勝とう! ベットできる時間は$minutesmin だけです!", + "diffBet": "$sender、あなたはすでに $option にベットしました。他のオプションに賭けることはできません!", + "undefinedBet": "申し訳ありません、 $senderですが、このベットオプションは存在しません。 $command を使用して利用方法を確認してください", + "betPercentGain": "Bet percent gain per option was set to $value%", + "betCloseTimer": "$valuemin 後にベットは自動的に終了します", + "refund": "ベットは勝利せずに終了しました。全てのユーザーは返金されます!", + "notOption": "$sender、このオプションは存在しません!ベットはクローズされていません。 $command を確認してください", + "closed": "Bets was closed and winning option was $option! $amount users won in total $points $pointsName!", + "timeUpBet": "I guess you are too late, $sender, your time for betting is up!", + "locked": "Betting time is up! No more bets.", + "zeroBet": "Oh boy, $sender, you cannot bet 0 $pointsName", + "lockedInfo": "Bet '$title' is still opened, but time for betting is up!", + "removed": "Betting time is up! No bets were sent -> automatically closing", + "error": "Sorry, $sender, this command is not correct! Use $command 1-$maxIndex . E.g. $command 0 100 will bet 100 points to item 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, エイリアス $alias はデータベースに見つかりませんでした。", + "alias-was-edited": "$sender, エイリアス $alias は $command に変更されます", + "alias-was-added": "$sender, alias $alias for $command was added", + "list-is-not-empty": "$sender, エイリアスのリスト: $list", + "list-is-empty": "$sender、エイリアスのリストは空です", + "alias-was-enabled": "$sender, エイリアス $alias は有効になりました", + "alias-was-disabled": "$sender, エイリアス $alias は無効になりました", + "alias-was-concealed": "$sender, エイリアス $alias が隠されました", + "alias-was-exposed": "$sender, エイリアス $alias が公開されました", + "alias-was-removed": "$sender, エイリアス $alias が削除されました", + "alias-group-set": "$sender, alias $alias was set to group $group", + "alias-group-unset": "$sender, alias $alias group was unset", + "alias-group-list": "$sender, list of aliases groups: $list", + "alias-group-list-aliases": "$sender, list of aliases in $group: $list", + "alias-group-list-enabled": "$sender, aliases in $group are enabled.", + "alias-group-list-disabled": "$sender, aliases in $group are disabled." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, command $command was not found in database", + "response-was-not-found": "$sender, response #$response of command $command was not found in database", + "command-was-edited": "$sender, command $command is changed to '$response'", + "command-was-added": "$sender, command $command was added", + "list-is-not-empty": "$sender, list of commands: $list", + "list-is-empty": "$sender, list of commands is empty", + "command-was-enabled": "$sender, command $command was enabled", + "command-was-disabled": "$sender, command $command was disabled", + "command-was-concealed": "$sender, command $command was concealed", + "command-was-exposed": "$sender, command $command was exposed", + "command-was-removed": "$sender, command $command was removed", + "response-was-removed": "$sender, response #$response of $command was removed", + "list-of-responses-is-empty": "$sender, $command have no responses or doesn't exists", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, keyword $keyword is ambiguous, use ID of keyword", + "keyword-was-not-found": "$sender, keyword $keyword was not found in database", + "response-was-not-found": "$sender, response #$response of keyword $keyword was not found in database", + "keyword-was-edited": "$sender, keyword $keyword is changed to '$response'", + "keyword-was-added": "$sender, keyword $keyword ($id) was added", + "list-is-not-empty": "$sender, list of keywords: $list", + "list-is-empty": "$sender, list of keywords is empty", + "keyword-was-enabled": "$sender, keyword $keyword was enabled", + "keyword-was-disabled": "$sender, keyword $keyword was disabled", + "keyword-was-removed": "$sender, keyword $keyword was removed", + "list-of-responses-is-empty": "$sender, $keyword have no responses or doesn't exists", + "response": "$keyword#$index ($permission) $after| $response" + }, + "points": { + "success": { + "undo": "$sender, points '$command' for $username was reverted ($updatedValue $updatedValuePointsLocale to $originalValue $originalValuePointsLocale).", + "set": "$username was set to $amount $pointsName", + "give": "$sender は $username に $amount $pointsName を与えました", + "online": { + "positive": "All online users just received $amount $pointsName!", + "negative": "All online users just lost $amount $pointsName!" + }, + "all": { + "positive": "All users just received $amount $pointsName!", + "negative": "All users just lost $amount $pointsName!" + }, + "rain": "Make it rain! All online users just received up to $amount $pointsName!", + "add": "$username just received $amount $pointsName!", + "remove": "Ouch, $amount $pointsName was removed from $username!" + }, + "failed": { + "undo": "$sender, username wasn't found in database or user have no undo operations", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Sorry, $sender, you don't have $amount $pointsName to give it to $username", + "cannotGiveZeroPoints": "Sorry, $sender, you cannot give $amount $pointsName to $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username は現在 $amount $pointsName を持っています、あなたのポジションは $order/$count です" + } + }, + "songs": { + "playlist-is-empty": "$sender, playlist to import is empty", + "playlist-imported": "$sender, imported $imported and skipped $skipped to playlist", + "not-playing": "Not Playing", + "song-was-banned": "Song $name was banned and will never play again!", + "song-was-banned-timeout-message": "You've got timeout for posting banned song", + "song-was-unbanned": "Song was succesfully unbanned", + "song-was-not-banned": "This song was not banned", + "no-song-is-currently-playing": "No song is currently playing", + "current-song-from-playlist": "Current song is $name from playlist", + "current-song-from-songrequest": "現在の曲は $username がリクエストした $name です", + "songrequest-disabled": "Sorry, $sender, song requests are disabled", + "song-is-banned": "Sorry, $sender, but this song is banned", + "youtube-is-not-responding-correctly": "Sorry, $sender, but YouTube is sending unexpected responses, please try again later.", + "song-was-not-found": "Sorry, $sender, but this song was not found", + "song-is-too-long": "Sorry, $sender, but this song is too long", + "this-song-is-not-in-playlist": "Sorry, $sender, but this song is not in current playlist", + "incorrect-category": "Sorry, $sender, but this song must be music category", + "song-was-added-to-queue": "$sender, song $name was added to queue", + "song-was-added-to-playlist": "$sender, song $name was added to playlist", + "song-is-already-in-playlist": "$sender, song $name is already in playlist", + "song-was-removed-from-playlist": "$sender, song $name was removed from playlist", + "song-was-removed-from-queue": "$sender, your song $name was removed from queue", + "playlist-current": "$sender, current playlist is $playlist.", + "playlist-list": "$sender, available playlists: $list.", + "playlist-not-exist": "$sender, your requested playlist $playlist doesn't exist.", + "playlist-set": "$sender, you changed playlist to $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, price for $command was set to $amount $pointsName", + "price-was-unset": "$sender, price for $command was unset", + "price-was-not-found": "$sender, price for $command was not found", + "price-was-enabled": "$sender, price for $command was enabled", + "price-was-disabled": "$sender, price for $command was disabled", + "user-have-not-enough-points": "Sorry, $sender, but you don't have $amount $pointsName to use $command", + "user-have-not-enough-points-or-bits": "Sorry, $sender, but you don't have $amount $pointsName or redeem command by $bitsAmount bits to use $command", + "user-have-not-enough-bits": "Sorry, $sender, but you need to redeem command by $bitsAmount bits to use $command", + "list-is-empty": "$sender, list of prices is empty", + "list-is-not-empty": "$sender, list of prices: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, new rank $type $rank($hours$hlocale) was added", + "rank-was-edited": "$sender, rank for $type $hours$hlocale was changed to $rank", + "rank-was-removed": "$sender, rank for $type $hours$hlocale was removed", + "rank-already-exist": "$sender, there is already a rank for $type $hours$hlocale", + "rank-was-not-found": "$sender, rank for $type $hours$hlocale was not found", + "custom-rank-was-set-to-user": "$sender, you set $rank to $username", + "custom-rank-was-unset-for-user": "$sender, custom rank for $username was unset", + "list-is-empty": "$sender, no ranks was found", + "list-is-not-empty": "$sender, ranks list: $list", + "show-rank-without-next-rank": "$sender, you have $rank rank", + "show-rank-with-next-rank": "$sender, you have $rank rank. Next rank - $nextrank", + "user-dont-have-rank": "$sender、あなたはまだランクを持っていません" + }, + "followage": { + "success": { + "never": "$sender, $username はチャンネルフォロワーではありません", + "time": "$sender, $username は $diff をフォローしています" + }, + "successSameUsername": { + "never": "$sender、あなたはこのチャンネルをフォローしていません", + "time": "$sender, あなたはこのチャンネルを $diff でフォローしています" + } + }, + "subage": { + "success": { + "never": "$sender, $username はチャンネルサブスクライバーではありません.", + "notNow": "$sender, $username は現在チャンネルサブスクライバーではありません。合計で $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username はチャネルのサブスクライバーです。現在のサブストリークは $diff ($subStreak $subStreakMonthsName) で、合計で $subCumulativeMonths $subCumulativeMonthsName です。", + "time": "$sender, $username はチャンネルサブスクライバーです。合計で $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, you are not a channel subscriber.", + "notNow": "$sender, you are currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, you are subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, you are subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, I don't have data for $username account age", + "success": { + "withUsername": "$sender, $username のアカウントの年齢は $diff です", + "withoutUsername": "$sender、あなたのアカウントの年齢は $diff です。" + } + }, + "lastseen": { + "success": { + "never": "$username はこのチャンネルに参加しませんでした!", + "time": "$username は $when でこのチャンネルで最後に見られました" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username がこのチャンネルを $time 時間視聴しました" + }, + "failed": { + "parse": "{core.command-parse} !watched or !watched [username]" + } + }, + "permissions": { + "without-permission": "You don't have enough permissions for '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, user $username have $type immunity for $time seconds", + "user-have-immunity-parameterError": "$sender, parameter error. $command ", + "user-have-link-permit": "User $username can post a $count $link to chat", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "リンクは許可されていません。許可を求めてください [$count 警告残り]", + "user-is-warned-about-symbols": "過剰なシンボル使用しない [$count 警告残り]", + "user-is-warned-about-long-message": "長いメッセージは許可されていません [$count 警告残り]", + "user-is-warned-about-caps": "過剰なキャップ使用なし [$count 警告残り]", + "user-is-warned-about-spam": "スパムは許可されていません [$count 警告残り]", + "user-is-warned-about-color": "斜体と/meは許可されていません [$count 警告残り]", + "user-is-warned-about-emotes": "エモートスパムの禁止[ $count 警告残り]", + "user-is-warned-about-forbidden-words": "禁止された単語はありません [$count 警告残り]", + "user-have-timeout-for-links": "No links allowed, ask for !permit", + "user-have-timeout-for-symbols": "No excessive symbols usage", + "user-have-timeout-for-long-message": "Long message are not allowed", + "user-have-timeout-for-caps": "No excessive caps usage", + "user-have-timeout-for-spam": "スパムは許可されていません", + "user-have-timeout-for-color": "斜体と/meは許可されていません", + "user-have-timeout-for-emotes": "No emotes spamming", + "user-have-timeout-for-forbidden-words": "No forbidden words" + }, + "queue": { + "list": "$sender, 現在のキュープール: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Sorry $sender, queue is currently closed", + "opened": "$sender were added into queue" + }, + "open": "Queue is currently OPENED! Join to queue with !queue join", + "close": "Queue is currently closed!", + "clear": "Queue were completely cleared", + "picked": { + "single": "このユーザーは、キューから選ばれました: $users", + "multi": "These users were picked from queue: $users", + "none": "No users were found in queue" + } + }, + "marker": "Stream marker has been created at $time.", + "title": { + "current": "$sender, ストリームのタイトルは '$title ' です", + "change": { + "success": "$sender, title was set to: $title" + } + }, + "game": { + "current": "$sender, ストリーマーは $game をプレイしています", + "change": { + "success": "$sender, category was set to: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type cooldown for $command was set to $secondss", + "cooldown-was-unset": "$sender, cooldown for $command was unset", + "cooldown-triggered": "$sender, '$command' is on cooldown, remaining $secondss", + "cooldown-not-found": "$sender, cooldown for $command was not found", + "cooldown-was-enabled": "$sender, cooldown for $command was enabled", + "cooldown-was-disabled": "$sender, cooldown for $command was disabled", + "cooldown-was-enabled-for-moderators": "$sender, cooldown for $command was enabled for moderators", + "cooldown-was-disabled-for-moderators": "$sender, cooldown for $command was disabled for moderators", + "cooldown-was-enabled-for-owners": "$sender, cooldown for $command was enabled for owners", + "cooldown-was-disabled-for-owners": "$sender, cooldown for $command was disabled for owners", + "cooldown-was-enabled-for-subscribers": "$sender, cooldown for $command was enabled for subscribers", + "cooldown-was-disabled-for-subscribers": "$sender, cooldown for $command was disabled for subscribers", + "cooldown-was-enabled-for-followers": "$sender, cooldown for $command was enabled for followers", + "cooldown-was-disabled-for-followers": "$sender, cooldown for $command was disabled for followers" + }, + "timers": { + "id-must-be-defined": "$sender, response id must be defined.", + "id-or-name-must-be-defined": "$sender, response id or timer name must be defined.", + "name-must-be-defined": "$sender, timer name must be defined.", + "response-must-be-defined": "$sender, timer response must be defined.", + "cannot-set-messages-and-seconds-0": "$sender, メッセージと秒の両方を 0 に設定することはできません。", + "timer-was-set": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger", + "timer-was-set-with-offline-flag": "$sender, タイマー$name は$messages 個のメッセージと$seconds 秒で設定されていてストリームがオフラインでもトリガーされます", + "timer-not-found": "$sender, timer (name: $name) was not found in database. Check timers with !timers list", + "timer-deleted": "$sender, timer $name and its responses was deleted.", + "timer-enabled": "$sender, timer (name: $name) was enabled", + "timer-disabled": "$sender, timer (name: $name) was disabled", + "timers-list": "$sender, timers list: $list", + "responses-list": "$sender, timer (name: $name) list", + "response-deleted": "$sender, response (id: $id) was deleted.", + "response-was-added": "$sender, response (id: $id) for timer (name: $name) was added - '$response'", + "response-not-found": "$sender, response (id: $id) was not found in database", + "response-enabled": "$sender, response (id: $id) was enabled", + "response-disabled": "$sender, response (id: $id) was disabled" + }, + "gambling": { + "duel": { + "bank": "$sender, current bank for $command is $points $pointsName", + "lowerThanMinimalBet": "$sender, minimal bet for $command is $points $pointsName", + "cooldown": "$sender, you cannot use $command for $cooldown $minutesName.", + "joined": "$sender, good luck with your dueling skills. You bet on yourself $points $pointsName!", + "added": "$sender really thinks he is better than others raising his bet to $points $pointsName!", + "new": "$sender is your new duel challenger! To participate use $command [points], you have $minutes $minutesName left to join.", + "zeroBet": "$sender, you cannot duel 0 $pointsName", + "notEnoughOptions": "$sender, you need to specify points to dueling", + "notEnoughPoints": "$sender, you don't have $points $pointsName to duel!", + "noContestant": "Only $winner have courage to join duel! Your bet of $points $pointsName are returned to you.", + "winner": "Congratulations to $winner! He is last man standing and he won $points $pointsName ($probability% with bet of $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender is trying his luck and pulled a trigger", + "alive": "$sender is alive! Nothing happened.", + "dead": "$sender's brain was splashed on the wall!", + "mod": "$sender is incompetent and completely missed his head!", + "broadcaster": "$sender is using blanks, boo!", + "timeout": "Roulette timeout set to $values" + }, + "gamble": { + "chanceToWin": "$sender, chance to win !gamble set to $value%", + "zeroBet": "$sender, you cannot gamble 0 $pointsName", + "minimalBet": "$sender, minimal bet for !gamble is set to $value", + "lowerThanMinimalBet": "$sender, minimal bet for !gamble is $points $pointsName", + "notEnoughOptions": "$sender, you need to specify points to gamble", + "notEnoughPoints": "$sender, you don't have $points $pointsName to gamble", + "win": "$sender, you WON! You now have $points $pointsName", + "winJackpot": "$sender, you hit JACKPOT! You won $jackpot $jackpotName in addition to your bet. You now have $points $pointsName", + "loseWithJackpot": "$sender, you LOST! You now have $points $pointsName. Jackpot increased to $jackpot $jackpotName", + "lose": "$sender, you LOST! You now have $points $pointsName", + "currentJackpot": "$sender, current jackpot for $command is $points $pointsName", + "winJackpotCount": "$sender, you won $count jackpots", + "jackpotIsDisabled": "$sender, jackpot is disabled for $command." + } + }, + "highlights": { + "saved": "$sender, highlight was saved for $hoursh$minutesm$secondss", + "list": { + "items": "$sender, 最新ストリームの保存されたハイライトリスト: $items", + "empty": "$sender, no highlights were saved" + }, + "offline": "$sender、ハイライトを保存できません。ストリームはオフラインです。" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "ボットは権限不足のエラーを送信しない", + "false": "ボットがウィスパーで権限不足のエラーを送らない" + }, + "disableCooldownWhispers": { + "true": "ボットはクールダウン通知を送信しない", + "false": "ボットはウィスパーでクールダウン通知を送信します" + } + } + }, + "time": "ストリーマーのタイムゾーンの現在の時刻は $time です", + "subs": "$sender, 現在 $onlineSubCount 人のオンラインサブスクライバーがいます. 最後のサブ/再サブは $lastSubUsername $lastSubAgo でした", + "followers": "$sender, last follow was $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, ユーザー $username はボットによって無視されていません" + }, + "added": "$sender, ユーザー $username がボットの無視リストに追加されました", + "removed": "$sender, ユーザー $username がボットの無視リストから削除されました", + "ignored": "$sender, ユーザー $username はボットによって無視されます" + } + } + }, + "filters": { + "setVariable": "$sender, $variable was set to $value." + } +} diff --git a/backend/locales/ja/api.clips.json b/backend/locales/ja/api.clips.json new file mode 100644 index 000000000..f7d47b52e --- /dev/null +++ b/backend/locales/ja/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "クリップが作成され、 $link で利用可能です" +} \ No newline at end of file diff --git a/backend/locales/ja/core/permissions.json b/backend/locales/ja/core/permissions.json new file mode 100644 index 000000000..22f0ff8de --- /dev/null +++ b/backend/locales/ja/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "権限の一覧:", + "excludeAddSuccessful": "$sender 、 $username を権限 $permissionName の除外リストに追加しました", + "excludeRmSuccessful": "$sender 、 $username を権限 $permissionName の除外リストから削除しました", + "userNotFound": "$sender, ユーザー $username はデータベースに見つかりませんでした。", + "permissionNotFound": "$sender, 権限 $userlevel がデータベースに見つかりませんでした。", + "cannotIgnoreForCorePermission": "$sender, コア権限 $userlevel のユーザーを手動で除外することはできません" +} \ No newline at end of file diff --git a/backend/locales/ja/games.heist.json b/backend/locales/ja/games.heist.json new file mode 100644 index 000000000..144fd6aa2 --- /dev/null +++ b/backend/locales/ja/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, cops are still searching for last heist team. Try again after $cooldown.", + "copsCooldownMessage": "Alright guys, looks like police forces are eating donuts and we can get that sweet money!", + "entryMessage": "$sender has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type $command to enter.", + "lateEntryMessage": "$sender, heist is currently in progress!", + "entryInstruction": "$sender, type $command to enter.", + "levelMessage": "With this crew, we can heist $bank! Let's see if we can get enough crew to heist $nextBank", + "maxLevelMessage": "With this crew, we can heist $bank! It cannot be any better!", + "started": "Alright guys, check your equipment, this is what we trained for. This is not a game, this is real life. We will get money from $bank!", + "noUser": "強盗するために仲間になる人はいない。", + "singleUserSuccess": "$user は忍者のようだった。誰もお金の紛失に気づきませんでした", + "singleUserFailed": "$user は警察の排除に失敗し、刑務所で過ごすことになる。", + "result": { + "0": "Everyone was mercilessly obliterated. This is slaughter.", + "33": "Only 1/3rd of team get its money from heist.", + "50": "Half of heist team was killed or catched by police.", + "99": "Some loses of heist team is nothing of what remaining crew have in theirs pockets.", + "100": "God divinity, nobody is dead, everyone won!" + }, + "levels": { + "bankVan": "Bank van", + "cityBank": "City bank", + "stateBank": "State bank", + "nationalReserve": "National reserve", + "federalReserve": "Federal reserve" + }, + "results": "強盗の支払い: $users", + "andXMore": "and $count more..." +} \ No newline at end of file diff --git a/backend/locales/ja/integrations/discord.json b/backend/locales/ja/integrations/discord.json new file mode 100644 index 000000000..b21a5f94b --- /dev/null +++ b/backend/locales/ja/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "あなたのアカウントはリンクされていません。`$command ` を使用してください", + "all-your-links-were-deleted": "すべてのリンクが削除されました", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender、このアカウントは $discordTag とリンクされました。", + "invalid-or-expired-token": "$sender, 無効または期限切れのトークン.", + "help-message": "$sender、Discordであなたのアカウントをリンクするには: 1. Discordサーバーに行き、ボットチャンネルで $command を送信してください。 | 2. ボットからDMを待つ | 3. DiscordのDMから、twitchチャットでコマンドを送信してください。", + "started-at": "開始日時", + "announced-by": "sogeBotによる通知", + "streamed-at": "ストリーム日時", + "link-whisper": "こんにちは $tag, Discordアカウントと $broadcaster チャンネルのTwichアカウントをリンクさせるには、に行って、自分のアカウントにログインしてこのコマンドをチャットに送信してください。 \n\n\t\t`$command $id`\n\nNOTE: 有効期限は10分です。", + "check-your-dm": "アカウントをリンクする手順についてはDMを確認してください。" +} \ No newline at end of file diff --git a/backend/locales/ja/integrations/lastfm.json b/backend/locales/ja/integrations/lastfm.json new file mode 100644 index 000000000..64b8750bf --- /dev/null +++ b/backend/locales/ja/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "現在の曲は $name です" +} \ No newline at end of file diff --git a/backend/locales/ja/integrations/obswebsocket.json b/backend/locales/ja/integrations/obswebsocket.json new file mode 100644 index 000000000..98a4e7af4 --- /dev/null +++ b/backend/locales/ja/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, idのアクションが設定されていません:$id!", + "ParameterError": "$sender, idを指定する必要があります!", + "UnknownError": "$sender、何か問題が発生しました。追加情報がないかボットのログを確認してください。" + } +} \ No newline at end of file diff --git a/backend/locales/ja/integrations/protondb.json b/backend/locales/ja/integrations/protondb.json new file mode 100644 index 000000000..9d607e97a --- /dev/null +++ b/backend/locales/ja/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | $rating 評価 | ネイティブon $native | 詳細: $url", + "responseNg": "ProtonDB上で $game の評価が見つかりませんでした。", + "responseNotFound": "$game はProtonDB上に見つかりませんでした。" +} \ No newline at end of file diff --git a/backend/locales/ja/integrations/pubg.json b/backend/locales/ja/integrations/pubg.json new file mode 100644 index 000000000..a99c102ec --- /dev/null +++ b/backend/locales/ja/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender、これらのパラメータのいずれかを期待しました: $list" +} \ No newline at end of file diff --git a/backend/locales/ja/integrations/spotify.json b/backend/locales/ja/integrations/spotify.json new file mode 100644 index 000000000..7ee251f9e --- /dev/null +++ b/backend/locales/ja/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "申し訳ありませんが、 $sender、トラックがSpotifyで見つかりませんでした", + "song-requested": "$sender、 $name から $artist の曲をリクエストしました", + "not-banned-song-not-playing": "$sender, 禁止されている曲はありません。", + "song-banned": "$sender, $artist の曲 $name は禁止されています。", + "song-unbanned": "$sender, $artist の曲 $name は禁止されていません。", + "song-not-found-in-banlist": "$sender, sotifyURI $uri の曲がBANリストに見つかりませんでした。", + "cannot-request-song-is-banned": "$sender, $artistの曲$nameは禁止されているのでリクエストできません。", + "cannot-request-song-from-unapproved-artist": "$sender、未承認のアーティストから曲をリクエストできません。", + "no-songs-found-in-history": "$sender, 現在履歴リストに曲がありません.", + "return-one-song-from-history": "$sender, 以前の曲は $artist の $name でした.", + "return-multiple-song-from-history": "$sender, 以前の $count 曲は以下の通りです:", + "return-multiple-song-from-history-item": "$index - $artist の $name", + "song-notify": "現在再生中の曲は$artistの$nameです。" +} \ No newline at end of file diff --git a/backend/locales/ja/integrations/tiltify.json b/backend/locales/ja/integrations/tiltify.json new file mode 100644 index 000000000..c2533ee40 --- /dev/null +++ b/backend/locales/ja/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender、現在有効なキャンペーンはありません", + "active_campaigns": "$sender、現在アクティブなキャンペーンのリスト:" +} \ No newline at end of file diff --git a/backend/locales/ja/systems.quotes.json b/backend/locales/ja/systems.quotes.json new file mode 100644 index 000000000..67fc7ef0f --- /dev/null +++ b/backend/locales/ja/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, 引用 $id '$quote' が追加されました. (タグ: $tags)", + "error": "$sender, $command は正しくないか、-quoteパラメーターがありません" + }, + "remove": { + "ok": "$sender, 引用 $id は正常に削除されました。", + "error": "$sender, 引用IDがありません。", + "not-found": "$sender、引用 $id が見つかりませんでした。" + }, + "show": { + "ok": "$quotedBy '$quote' の引用 $id", + "error": { + "no-parameters": "$sender, $command には -id または -tag がありません。", + "not-found-by-id": "$sender、引用 $id が見つかりませんでした。", + "not-found-by-tag": "$sender、タグ $tag の引用が見つかりませんでした。" + } + }, + "set": { + "ok": "$sender, 引用 $id タグが設定されました. (tags: $tags)", + "error": { + "no-parameters": "$sender, $command には -id または -tag がありません。", + "not-found-by-id": "$sender、引用 $id が見つかりませんでした。" + } + }, + "list": { + "ok": "$sender, http://$urlBase/public/#/quotes で引用リストを見つけることができます", + "is-localhost": "$sender, 引用一覧URLが正しく指定されていません。" + } +} \ No newline at end of file diff --git a/backend/locales/ja/systems/antihateraid.json b/backend/locales/ja/systems/antihateraid.json new file mode 100644 index 000000000..518091535 --- /dev/null +++ b/backend/locales/ja/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "このチャットは$usernameによって$modeに設定され、ヘイトレイドの解消を図っています。ご迷惑をおかけして申し訳ありません!", + "mode": { + "0": "サブ限", + "1": "フォロワー限定", + "2": "エモート限定" + } +} \ No newline at end of file diff --git a/backend/locales/ja/systems/howlongtobeat.json b/backend/locales/ja/systems/howlongtobeat.json new file mode 100644 index 000000000..8941742c1 --- /dev/null +++ b/backend/locales/ja/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game が db に見つかりません。", + "game": "$sender, $game | Main: $currentMain/$hltbMainh - $percentMain% | Main+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Completionist: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Main: $currentMainh | Main+Extra: $currentMainExtrah | Completionist: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/ja/systems/levels.json b/backend/locales/ja/systems/levels.json new file mode 100644 index 000000000..f1e3ae7f9 --- /dev/null +++ b/backend/locales/ja/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, レベル: $currentLevel ($currentXP $xpName), 次のレベルまで $nextXP $xpName", + "changeXP": "$sender, you changed $xpName by $amount $xpName to $username.", + "notEnoughPointsToBuy": "Sorry $sender, but you don't have $points $pointsName to buy $amount $xpName for level $level.", + "XPBoughtByPoints": "$sender, you bought $amount $xpName with $points $pointsName and reached level $level.", + "somethingGetWrong": "$sender, something get wrong with your request." +} \ No newline at end of file diff --git a/backend/locales/ja/systems/scrim.json b/backend/locales/ja/systems/scrim.json new file mode 100644 index 000000000..aba9abe61 --- /dev/null +++ b/backend/locales/ja/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Snipe match ($type) starting in $time $unit", + "go": "今すぐ始めましょう!", + "putMatchIdInChat": "Please put your match ID in the chat => $command xxx", + "currentMatches": "Current Matches: $matches", + "stopped": "Snipe match was cancelled." +} \ No newline at end of file diff --git a/backend/locales/ja/systems/top.json b/backend/locales/ja/systems/top.json new file mode 100644 index 000000000..e3ac323df --- /dev/null +++ b/backend/locales/ja/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Top $amount (watch time): ", + "tips": "Top $amount (Tip): ", + "level": "Top $amount (レベル): ", + "points": "Top $amount (ポイント): ", + "messages": "Top $amount (メッセージ): ", + "followage": "Top $amount (followage): ", + "subage": "Top $amount (サブ期間): ", + "submonths": "Top $amount (サブ月数): ", + "bits": "Top $amount (ビッツ): ", + "gifts": "Top $amount (サブギフ): " +} \ No newline at end of file diff --git a/backend/locales/ja/ui.commons.json b/backend/locales/ja/ui.commons.json new file mode 100644 index 000000000..6befbebb8 --- /dev/null +++ b/backend/locales/ja/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Additional settings", + "never": "never", + "reset": "reset", + "moveUp": "move up", + "moveDown": "move down", + "stop-if-executed": "stop, if executed", + "continue-if-executed": "continue, if executed", + "generate": "生成", + "thumbnail": "サムネイル", + "yes": "Yes", + "no": "No", + "show-more": "詳細を表示", + "show-less": "詳細を閉じる", + "allowed": "許可", + "disallowed": "許可しない", + "back": "戻る" +} diff --git a/backend/locales/ja/ui.dialog.json b/backend/locales/ja/ui.dialog.json new file mode 100644 index 000000000..f4e2cfb18 --- /dev/null +++ b/backend/locales/ja/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "編集", + "add": "追加" + }, + "position": { + "settings": "位置の設定", + "anchorX": "アンカーのX座標", + "anchorY": "アンカーのY座標", + "left": "左", + "right": "右", + "middle": "中央", + "top": "上部", + "bottom": "下部", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "右移動", + "shadowShiftDown": "下移動", + "shadowBlur": "ぼかし", + "shadowOpacity": "透明度", + "color": "カラー" + }, + "errors": { + "required": "この入力は空にできません。", + "minValue": "この入力の最小値は $value です。" + }, + "buttons": { + "reorder": "並べ替え", + "upload": { + "idle": "アップロード", + "progress": "アップロード中", + "done": "アップロード済" + }, + "cancel": "キャンセル", + "close": "閉じる", + "test": { + "idle": "テスト", + "progress": "テスト中", + "done": "テスト完了" + }, + "saveChanges": { + "idle": "変更を保存", + "invalid": "変更を保存できません", + "progress": "変更を保存します", + "done": "変更を保存しました" + }, + "something-went-wrong": "エラーが発生しました", + "mark-to-delete": "Mark to delete", + "disable": "無効", + "enable": "有効", + "disabled": "無効", + "enabled": "有効", + "edit": "編集", + "delete": "削除", + "play": "再生", + "stop": "停止", + "hold-to-delete": "長押しで削除", + "yes": "Yes", + "no": "No", + "permission": "Permission", + "group": "Group", + "visibility": "Visibility", + "reset": "Reset " + }, + "changesPending": "Your changes was not saved.", + "formNotValid": "Form is invalid.", + "nothingToShow": "Nothing to show here." +} \ No newline at end of file diff --git a/backend/locales/ja/ui.menu.json b/backend/locales/ja/ui.menu.json new file mode 100644 index 000000000..80fb32b5f --- /dev/null +++ b/backend/locales/ja/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Services", + "updater": "アップデーター", + "index": "ダッシュボード", + "core": "ボット", + "users": "ユーザー", + "tmi": "TMI", + "ui": "UI", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "General", + "timers": "タイマー", + "new": "新しい項目", + "keywords": "キーワード", + "customcommands": "カスタムコマンド", + "botcommands": "ボットコマンド", + "commands": "コマンド", + "events": "イベント", + "ranks": "ランク", + "songs": "曲", + "modules": "モジュール", + "viewers": "視聴者数", + "alias": "エイリアス", + "cooldowns": "クールダウン", + "cooldown": "クールダウン", + "highlights": "ハイライト", + "price": "価格", + "logs": "ログ", + "systems": "システム", + "permissions": "権限", + "translations": "カスタム翻訳", + "moderation": "モデレーション", + "overlays": "オーバーレイ", + "gallery": "メディアギャラリー", + "games": "ゲーム", + "spotify": "Spotify", + "integrations": "統合", + "customvariables": "カスタム変数", + "registry": "レジストリ", + "quotes": "引用", + "settings": "設定", + "commercial": "コマーシャル", + "bets": "ベット", + "points": "ポイント", + "raffles": "抽選", + "queue": "キュー", + "playlist": "プレイリスト", + "bannedsongs": "禁止された曲", + "spotifybannedsongs": "SpotifyでBANされた曲", + "duel": "決闘", + "fightme": "ファイトミー", + "seppuku": "切腹", + "gamble": "ギャンブル", + "roulette": "ルーレット", + "heist": "強盗", + "oauth": "OAuth", + "socket": "ソケット", + "carouseloverlay": "カルーセルオーバーレイ", + "alerts": "アラート", + "carousel": "イメージカルーセル", + "clips": "クリップ", + "credits": "クレジット", + "emotes": "エモート", + "stats": "統計", + "text": "テキスト", + "currency": "通貨", + "eventlist": "イベントリスト", + "clipscarousel": "クリップカルーセル", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "チェックリスト", + "bot": "ボット", + "api": "API", + "manage": "管理", + "top": "上部", + "goals": "ゴール", + "userinfo": "ユーザー情報", + "scrim": "Scrim", + "commandcount": "コマンドカウント", + "profiler": "Profiler", + "howlongtobeat": "How long to beat", + "responsivevoice": "ResponsiveVoice", + "randomizer": "ランダマイザー", + "tips": "Tip", + "bits": "ビッツ", + "discord": "Discord", + "texttospeech": "テキスト読み上げ", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "レベル", + "obswebsocket": "OBS Websocket", + "api-explorer": "API エクスプローラー", + "emotescombo": "エモートコンボ", + "notifications": "通知", + "plugins": "Plugins", + "tts": "TTS" +} diff --git a/backend/locales/ja/ui.page.settings.overlays.carousel.json b/backend/locales/ja/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..8c4dea86c --- /dev/null +++ b/backend/locales/ja/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "オプション", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "この画像を削除しますか?" + }, + "button": { + "update": "アップデート", + "fix_your_errors_first": "保存する前にエラーを修正します" + }, + "errors": { + "number_greater_or_equal_than_0": "値は0以上の数値でなければなりません", + "value_must_not_be_empty": "値は空白にできません" + }, + "titles": { + "waitBefore": "画像が表示されるまで待機(ミリ秒)", + "waitAfter": "画像が消えるまで待機(ミリ秒)", + "duration": "画像を表示する時間(ミリ秒)", + "animationIn": "アニメーションイン", + "animationOut": "アニメーションアウト", + "animationInDuration": "アニメーションイン時間 (ミリ秒)", + "animationOutDuration": "アニメーションアウト時間(ミリ秒)", + "showOnlyOncePerStream": "ストリームごとに1回だけ表示" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.registry.customvariables.json b/backend/locales/ja/ui.registry.customvariables.json new file mode 100644 index 000000000..b8b88a11a --- /dev/null +++ b/backend/locales/ja/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "URLs", + "generateurl": "新しいURLを生成する", + "show-examples": "CURLの例を表示", + "response": { + "show": "POST の後に応答を表示", + "name": "変数が設定された後の応答", + "default": "デフォルト", + "default-placeholder": "ボットの応答を設定", + "default-help": "$value を使用して新しい変数値を取得する", + "custom": "カスタム", + "command": "コマンド" + }, + "useIfInCommand": "コマンドで変数を使用する場合に使用します。応答はなく、更新された変数のみが返されます。", + "permissionToChange": "変更の権限", + "isReadOnly": "チャットで読み取り専用", + "isNotReadOnly": "チャットで変更できます", + "no-variables-found": "変数が見つかりません", + "additional-info": "追加情報", + "run-script": "スクリプトを実行", + "last-run": "最後の実行:", + "variable": { + "name": "変数名", + "help": "変数名は一意でなければなりません。例: $_wins, $_loses, $_top3", + "placeholder": "一意の変数名を入力してください", + "error": { + "isNotUnique": "変数には一意の名前が必要です。", + "isEmpty": "変数名は空にできません。" + } + }, + "description": { + "name": "説明", + "help": "説明 (任意)", + "placeholder": "任意の説明を入力してください" + }, + "type": { + "name": "型", + "error": { + "isNotSelected": "変数型を選択してください。" + } + }, + "currentValue": { + "name": "現在の値", + "help": "型が 評価されたスクリプトに設定されている場合、値は手動で変更できません" + }, + "usableOptions": { + "name": "利用可能なオプション", + "placeholder": "ここに、あなたの、オプションを、入力してください", + "help": "この変数で使用できるオプションの例: SOLO、DUO、3-SQ、SQUAD", + "error": { + "atLeastOneValue": "You need to set at least 1 value." + } + }, + "scriptToEvaluate": "Script to evaluate", + "runScript": { + "name": "Run script", + "error": { + "isNotSelected": "Please choose an option." + } + }, + "testCurrentScript": { + "name": "Test current script", + "help": "Click Test current script to see value in Current value input" + }, + "history": "History", + "historyIsEmpty": "この変数の履歴は空です!", + "warning": "警告: この変数のすべてのデータは破棄されます!", + "choose": "選択...", + "types": { + "number": "数値", + "text": "テキスト", + "options": "オプション", + "eval": "スクリプト" + }, + "runEvery": { + "isUsed": "変数が使用されたとき" + } +} + diff --git a/backend/locales/ja/ui.systems.antihateraid.json b/backend/locales/ja/ui.systems.antihateraid.json new file mode 100644 index 000000000..d821c979d --- /dev/null +++ b/backend/locales/ja/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Clear Chat", + "mode": "Mode", + "minFollowTime": "Minimum follow time", + "customAnnounce": "Customize announcement on anti hate raid enable" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.bets.json b/backend/locales/ja/ui.systems.bets.json new file mode 100644 index 000000000..c93ad12fb --- /dev/null +++ b/backend/locales/ja/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "状態", + "betPercentGain": "Add x% to bet payout each option" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.commercial.json b/backend/locales/ja/ui.systems.commercial.json new file mode 100644 index 000000000..90676ec12 --- /dev/null +++ b/backend/locales/ja/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "状態" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.cooldown.json b/backend/locales/ja/ui.systems.cooldown.json new file mode 100644 index 000000000..fa076ccf9 --- /dev/null +++ b/backend/locales/ja/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "ウィスパーとして通知する", + "settings": { + "enabled": "状態", + "cooldownNotifyAsWhisper": "Whisper cooldown informations", + "cooldownNotifyAsChat": "Chat message cooldown informations", + "defaultCooldownOfCommandsInSeconds": "Default cooldown for commands (in seconds)", + "defaultCooldownOfKeywordsInSeconds": "Default cooldown for keywords (in seconds)" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.customcommands.json b/backend/locales/ja/ui.systems.customcommands.json new file mode 100644 index 000000000..5c93eb931 --- /dev/null +++ b/backend/locales/ja/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "No responses", + "addResponse": "Add response", + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.highlights.json b/backend/locales/ja/ui.systems.highlights.json new file mode 100644 index 000000000..63ed31e83 --- /dev/null +++ b/backend/locales/ja/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "urls": "Generated URLs" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.moderation.json b/backend/locales/ja/ui.systems.moderation.json new file mode 100644 index 000000000..d30701b46 --- /dev/null +++ b/backend/locales/ja/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Status", + "cListsEnabled": "Enforce the rule", + "cLinksEnabled": "Enforce the rule", + "cSymbolsEnabled": "Enforce the rule", + "cLongMessageEnabled": "Enforce the rule", + "cCapsEnabled": "Enforce the rule", + "cSpamEnabled": "Enforce the rule", + "cColorEnabled": "Enforce the rule", + "cEmotesEnabled": "Enforce the rule", + "cListsWhitelist": { + "title": "Allowed words", + "help": "To allow domains use \"domain:prtzl.io\"" + }, + "autobanMessages": "Autoban Messages", + "cListsBlacklist": "Forbidden words", + "cListsTimeout": "Timeout duration", + "cLinksTimeout": "Timeout duration", + "cSymbolsTimeout": "Timeout duration", + "cLongMessageTimeout": "Timeout duration", + "cCapsTimeout": "Timeout duration", + "cSpamTimeout": "Timeout duration", + "cColorTimeout": "Timeout duration", + "cEmotesTimeout": "Timeout duration", + "cWarningsShouldClearChat": "Should clear chat (will timeout for 1s)", + "cLinksIncludeSpaces": "スペースを含める", + "cLinksIncludeClips": "Include clips", + "cSymbolsTriggerLength": "Trigger length of message", + "cLongMessageTriggerLength": "Trigger length of message", + "cCapsTriggerLength": "Trigger length of message", + "cSpamTriggerLength": "Trigger length of message", + "cSymbolsMaxSymbolsConsecutively": "Max symbols consecutively", + "cSymbolsMaxSymbolsPercent": "Max symbols %", + "cCapsMaxCapsPercent": "Max caps %", + "cSpamMaxLength": "Max length", + "cEmotesMaxCount": "Max count", + "cWarningsAnnounceTimeouts": "Announce timeouts in chat for everyone", + "cWarningsAllowedCount": "Warning count", + "cEmotesEmojisAreEmotes": "Treat Emojis as Emotes" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.points.json b/backend/locales/ja/ui.systems.points.json new file mode 100644 index 000000000..830031471 --- /dev/null +++ b/backend/locales/ja/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Status", + "name": { + "title": "名前", + "help": "Possible formats:
point|points
bod|4:body|bodu" + }, + "isPointResetIntervalEnabled": "Interval of points reset", + "resetIntervalCron": { + "name": "Cron interval", + "help": "CronTab generator" + }, + "interval": "ストリームがオンラインの時に、オンラインユーザーへのポイント付与を分単位で行う。", + "offlineInterval": "ストリームがオフラインの時に、オンラインユーザーへのポイント付与を分単位で行う。", + "messageInterval": "How many messages to add points", + "messageOfflineInterval": "ストリームがオフラインのときにポイントを追加するメッセージ数", + "perInterval": "How many points to add per online interval", + "perOfflineInterval": "How many points to add per offline interval", + "perMessageInterval": "How many points to add per message interval", + "perMessageOfflineInterval": "How many points to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.price.json b/backend/locales/ja/ui.systems.price.json new file mode 100644 index 000000000..c0ede4950 --- /dev/null +++ b/backend/locales/ja/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Trigger custom alerts on bit redeem", + "price": { + "name": "価格", + "placeholder": "" + }, + "error": { + "isEmpty": "この値は空にできません" + }, + "warning": "この操作は取り消せません!", + "settings": { + "enabled": "状態" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.queue.json b/backend/locales/ja/ui.systems.queue.json new file mode 100644 index 000000000..106d8c22f --- /dev/null +++ b/backend/locales/ja/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "状態", + "eligibilityAll": "すべて", + "eligibilityFollowers": "フォロワー", + "eligibilitySubscribers": "サブスクライバー" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.quotes.json b/backend/locales/ja/ui.systems.quotes.json new file mode 100644 index 000000000..c961f48a8 --- /dev/null +++ b/backend/locales/ja/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "申し訳ありませんが、データベースに引用が見つかりませんでした。", + "new": "新しい引用の追加", + "empty": "引用の一覧が空です。新しい引用を作成します。", + "emptyAfterSearch": "\"$search\"を検索すると、引用の一覧が空です。", + "quote": { + "name": "引用", + "placeholder": "ここに引用を設定してください" + }, + "by": { + "name": "引用者:" + }, + "tags": { + "name": "タグ", + "placeholder": "ここにタグを設定してください", + "help": "カンマ区切りのタグ。例: tag 1, tag 2, tag 3" + }, + "date": { + "name": "Date" + }, + "error": { + "isEmpty": "This value cannot be empty", + "atLeastOneTag": "少なくとも 1 つのタグを設定する必要があります。" + }, + "tag-filter": "タグでフィルタリング", + "warning": "この操作は取り消せません!", + "settings": { + "enabled": "Status", + "urlBase": { + "title": "URL base", + "help": "誰でもアクセスできるように、引用にはパブリックエンドポイントを使用する必要があります。 " + } + } +} diff --git a/backend/locales/ja/ui.systems.raffles.json b/backend/locales/ja/ui.systems.raffles.json new file mode 100644 index 000000000..046756c26 --- /dev/null +++ b/backend/locales/ja/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Subscribers luck" + }, + "settings": { + "enabled": "Status", + "announceNewEntries": { + "title": "Announce new entries", + "help": "If users joins raffle, announce message will be send to chat after while." + }, + "announceNewEntriesBatchTime": { + "title": "How long to wait before announce new entries (in seconds)", + "help": "Longer time will keep chat cleaner, entries will be aggregated together." + }, + "deleteRaffleJoinCommands": { + "title": "Delete user raffle join command", + "help": "これにより、ユーザが !yourraffle コマンドを使用した場合、ユーザメッセージが削除されます。チャットをすっきりさせることができます。" + }, + "allowOverTicketing": { + "title": "Allow over ticketing", + "help": "ユーザーが自分のポイントのチケット以上のくじに参加できるようにする。例えば、ユーザーは10ポイントを持っていますが、彼のすべてのポイントを使用する100の !raffle に参加することができます。" + }, + "raffleAnnounceInterval": { + "title": "Announce interval", + "help": "Minutes" + }, + "raffleAnnounceMessageInterval": { + "title": "Announce message interval", + "help": "How many messages must be sent to chat until announce can be posted." + }, + "subscribersPercent": { + "title": "Additional subscribers luck", + "help": "in percents" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.ranks.json b/backend/locales/ja/ui.systems.ranks.json new file mode 100644 index 000000000..9e2329673 --- /dev/null +++ b/backend/locales/ja/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "New Rank", + "empty": "No ranks were created yet.", + "emptyAfterSearch": "No ranks were found by your search for \"$search\".", + "rank": { + "name": "rank", + "placeholder": "" + }, + "value": { + "name": "hours", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "この操作は取り消せません!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.songs.json b/backend/locales/ja/ui.systems.songs.json new file mode 100644 index 000000000..c1df88a05 --- /dev/null +++ b/backend/locales/ja/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Status", + "volume": "Volume", + "calculateVolumeByLoudness": "Dynamic volume by loudness", + "duration": { + "title": "Max song duration", + "help": "In minutes" + }, + "shuffle": "Shuffle", + "songrequest": "Play from song request", + "playlist": "Play from playlist", + "onlyMusicCategory": "Allow only category music", + "allowRequestsOnlyFromPlaylist": "Allow song requests only from current playlist", + "notify": "Send message on song change" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "startTime": "Start song at", + "endTime": "End song at", + "add_song": "Add song", + "add_or_import": "Add song or import from playlist", + "importing": "Importing", + "importing_done": "Importing Done", + "seconds": "Seconds", + "calculated": "Calculated", + "set_manually": "Set manually", + "bannedSongsEmptyAfterSearch": "No banned songs were found by your search for \"$search\".", + "emptyAfterSearch": "No songs were found by your search for \"$search\".", + "empty": "No songs were added yet.", + "bannedSongsEmpty": "No songs were added to banlist yet." +} \ No newline at end of file diff --git a/backend/locales/ja/ui.systems.timers.json b/backend/locales/ja/ui.systems.timers.json new file mode 100644 index 000000000..38598c10d --- /dev/null +++ b/backend/locales/ja/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "New Timer", + "empty": "No timers were created yet.", + "emptyAfterSearch": "No timers were found by your search for \"$search\".", + "add_response": "Add Response", + "settings": { + "enabled": "Status" + }, + "warning": "この操作は取り消せません!" +} \ No newline at end of file diff --git a/backend/locales/ja/ui.widgets.customvariables.json b/backend/locales/ja/ui.widgets.customvariables.json new file mode 100644 index 000000000..761875e3b --- /dev/null +++ b/backend/locales/ja/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "No custom variables found, add at custom variables registry", + "add-variable-into-watchlist": "Add variable to watchlist", + "watchlist": "Watchlist" +} \ No newline at end of file diff --git a/backend/locales/ja/ui.widgets.randomizer.json b/backend/locales/ja/ui.widgets.randomizer.json new file mode 100644 index 000000000..17a70ebb9 --- /dev/null +++ b/backend/locales/ja/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "No randomizer found, add at randomizer registry", + "add-randomizer-to-widget": "Add randomizer to widget" +} \ No newline at end of file diff --git a/backend/locales/ja/ui/categories.json b/backend/locales/ja/ui/categories.json new file mode 100644 index 000000000..f61f0deca --- /dev/null +++ b/backend/locales/ja/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Announcements", + "keys": "Keys", + "currency": "Currency", + "general": "General", + "settings": "Settings", + "commands": "コマンド", + "bot": "ボット", + "channel": "Channel", + "connection": "Connection", + "chat": "Chat", + "graceful_exit": "Graceful exit", + "rewards": "Rewards", + "levels": "Levels", + "notifications": "Notifications", + "options": "Options", + "comboBreakMessages": "Combo Break Messages", + "hypeMessages": "Hype Messages", + "messages": "Messages", + "results": "Results", + "customization": "Customization", + "status": "Status", + "mapping": "Mapping", + "player": "Player", + "stats": "Stats", + "api": "API", + "token": "Token", + "text": "Text", + "custom_texts": "Custom texts", + "credits": "Credits", + "show": "Show", + "social": "Social", + "explosion": "Explosion", + "fireworks": "Fireworks", + "test": "Test", + "emotes": "Emotes", + "default": "Default", + "urls": "URLs", + "conversion": "Conversion", + "xp": "XP", + "caps_filter": "Caps filter", + "color_filter": "Italic (/me) filter", + "links_filter": "Links filter", + "symbols_filter": "Symbols filter", + "longMessage_filter": "Message length filter", + "spam_filter": "Spam filter", + "emotes_filter": "Emotes filter", + "warnings": "Warnings", + "reset": "Reset", + "reminder": "Reminder", + "eligibility": "Eligibility", + "join": "Join", + "luck": "Luck", + "lists": "Lists", + "me": "Me", + "emotes_combo": "Emotes combo", + "tmi": "tmi", + "oauth": "oauth", + "eventsub": "eventsub", + "rules": "rules" +} \ No newline at end of file diff --git a/backend/locales/ja/ui/core/currency.json b/backend/locales/ja/ui/core/currency.json new file mode 100644 index 000000000..f745eed1a --- /dev/null +++ b/backend/locales/ja/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "メイン通貨" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/core/general.json b/backend/locales/ja/ui/core/general.json new file mode 100644 index 000000000..3b05fd3fa --- /dev/null +++ b/backend/locales/ja/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Botの言語", + "numberFormat": "チャットでの数値のフォーマット", + "gracefulExitEachXHours": { + "title": "X時間ごとに正常に終了する", + "help": "0 - 無効" + }, + "shouldGracefulExitHelp": "サーバー上でボットが延々と動作している場合は、正常な終了を有効にすることを推奨します。 botをpm2(または同様のサービス)上で動作させるか、docker化することで、botの自動再起動を保証する必要があります。ストリームがオンラインになると、botが正常な終了をしない。" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/core/oauth.json b/backend/locales/ja/ui/core/oauth.json new file mode 100644 index 000000000..a22239c2e --- /dev/null +++ b/backend/locales/ja/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "所有者", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "ユーザー名", + "channelUsername": "ユーザー名", + "botExpectedScopes": "範囲", + "channelExpectedScopes": "範囲" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/core/permissions.json b/backend/locales/ja/ui/core/permissions.json new file mode 100644 index 000000000..887e70cea --- /dev/null +++ b/backend/locales/ja/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "新しいパーミッショングループの追加", + "higherPermissionHaveAccessToLowerPermissions": "より高い権限は低い権限にアクセスできます。", + "typeUsernameOrIdToSearch": "検索するユーザー名または ID を入力してください", + "typeUsernameOrIdToTest": "テストするユーザー名またはIDを入力してください", + "noUsersWereFound": "ユーザーが見つかりません。", + "noUsersManuallyAddedToPermissionYet": "権限に手動で追加されたユーザーはまだいません。", + "done": "完了", + "previous": "前へ", + "next": "次へ", + "loading": "読み込み中", + "permissionNotFoundInDatabase": "権限がデータベースに見つかりません。ユーザをテストする前に保存してください。", + "userHaveNoAccessToThisPermissionGroup": "ユーザー $username はこの権限グループへのアクセス権を持っていません。", + "userHaveAccessToThisPermissionGroup": "ユーザー $username はこの権限グループにアクセスできます。", + "accessDirectlyThrough": "Direct access through", + "accessThroughHigherPermission": "Access through higher permission", + "somethingWentWrongUserWasNotFoundInBotDatabase": "問題が発生しました。ユーザ $username がボットデータベースに見つかりませんでした。", + "permissionsGroups": "Permissions Groups", + "allowHigherPermissions": "Allow access through higher permission", + "type": "Type", + "value": "Value", + "watched": "Watched time in hours", + "followtime": "Follow time in months", + "points": "Points", + "tips": "Tip", + "bits": "Bits", + "messages": "Messages", + "subtier": "Sub Tier (1, 2, or 3)", + "subcumulativemonths": "Sub cumulative months", + "substreakmonths": "Current sub streak", + "ranks": "Current rank", + "level": "Current level", + "isLowerThan": "is lower than", + "isLowerThanOrEquals": "is lower than or equals", + "equals": "equals", + "isHigherThanOrEquals": "is higher than or equals", + "isHigherThan": "is higher than", + "addFilter": "Add filter", + "selectPermissionGroup": "Select permission group", + "settings": "Settings", + "name": "Name", + "baseUsersSet": "Base set of users", + "manuallyAddedUsers": "Manually added users", + "manuallyExcludedUsers": "Manually excluded users", + "filters": "Filters", + "testUser": "Test user", + "none": "- none -", + "casters": "Casters", + "moderators": "Moderators", + "subscribers": "サブスクライバー", + "vip": "VIP", + "viewers": "Viewers", + "followers": "Followers" +} \ No newline at end of file diff --git a/backend/locales/ja/ui/core/socket.json b/backend/locales/ja/ui/core/socket.json new file mode 100644 index 000000000..a9abe531b --- /dev/null +++ b/backend/locales/ja/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Purge All Authenticated Connection (yours as well)", + "accessTokenExpirationTime": "Access Token Expiration Time (seconds)", + "refreshTokenExpirationTime": "Refresh Token Expiration Time (seconds)", + "socketToken": { + "title": "Socket token", + "help": "このトークンはソケットを通じて完全な管理者アクセスを提供します。共有しないでください!" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/core/tmi.json b/backend/locales/ja/ui/core/tmi.json new file mode 100644 index 000000000..e5fa6407c --- /dev/null +++ b/backend/locales/ja/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "無視リスト(IDまたはユーザ名)", + "showWithAt": "Show users with @", + "sendWithMe": "Send messages with /me", + "sendAsReply": "ボットメッセージを返信として送信する", + "mute": "ボットはミュートされています", + "whisperListener": "Listen on commands on whispers" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/core/tts.json b/backend/locales/ja/ui/core/tts.json new file mode 100644 index 000000000..f4b8119bc --- /dev/null +++ b/backend/locales/ja/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Service" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/core/twitch.json b/backend/locales/ja/ui/core/twitch.json new file mode 100644 index 000000000..bd18197e1 --- /dev/null +++ b/backend/locales/ja/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "イベントにストリームマーカーを作成" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/core/ui.json b/backend/locales/ja/ui/core/ui.json new file mode 100644 index 000000000..ae77c0756 --- /dev/null +++ b/backend/locales/ja/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Default theme", + "domain": { + "title": "Domain", + "help": "Format without http/https: yourdomain.com or your.domain.com" + }, + "percentage": "統計の割合の差", + "shortennumbers": "Short format of numbers", + "showdiff": "Show difference", + "enablePublicPage": "Enable public page" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/core/updater.json b/backend/locales/ja/ui/core/updater.json new file mode 100644 index 000000000..b93fa3738 --- /dev/null +++ b/backend/locales/ja/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Automatically update if newer version available" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/errors.json b/backend/locales/ja/ui/errors.json new file mode 100644 index 000000000..8b3e4bef8 --- /dev/null +++ b/backend/locales/ja/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Unexpected errors during validation", + "isNotEmpty": "$property is required.", + "minLength": "$property must be longer than or equal to $constraint1 characters.", + "isPositive": "$property must be greater then 0", + "isCommand": "$property must start with !", + "isCommandOrCustomVariable": "$property must start with ! or $_", + "isCustomVariable": "$property must start with $_", + "min": "$property must be at least $constraint1", + "max": "$property must be lower or equal to $constraint1", + "isInt": "$property must be an integer", + "this_value_must_be_a_positive_number_and_greater_then_0": "This value must be a positive number or greater then 0", + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty", + "minLength_of_value_is": "Minimal length is $value.", + "this_currency_is_not_supported": "This currency is not supported", + "something_went_wrong": "Something went wrong", + "permission_must_exist": "Permission must exist", + "minValue_of_value_is": "Minimal value is $value", + "value_cannot_be": "Value cannot be $value.", + "invalid_format": "Invalid value format.", + "invalid_regexp_format": "This is not valid regex.", + "owner_and_broadcaster_oauth_is_not_set": "Owner and channel oauth is not set", + "channel_is_not_set": "Channel is not set", + "please_set_your_broadcaster_oauth_or_owners": "Please set your channel oauth or owners, or all users will have access to this dashboard and will be considered as casters.", + "new_update_available": "New update available", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "One of inputs must be set" +} \ No newline at end of file diff --git a/backend/locales/ja/ui/games/duel.json b/backend/locales/ja/ui/games/duel.json new file mode 100644 index 000000000..84789a717 --- /dev/null +++ b/backend/locales/ja/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Status", + "cooldown": "Cooldown", + "duration": { + "title": "Duration", + "help": "Minutes" + }, + "minimalBet": "Minimal bet", + "bypassCooldownByOwnerAndMods": "Bypass cooldown by owner and mods" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/games/gamble.json b/backend/locales/ja/ui/games/gamble.json new file mode 100644 index 000000000..6a78309aa --- /dev/null +++ b/backend/locales/ja/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "minimalBet": "Minimal bet", + "chanceToWin": { + "title": "Chance to win", + "help": "Percent" + }, + "enableJackpot": "Enable jackpot", + "chanceToTriggerJackpot": "Chance to trigger jackpot in %", + "maxJackpotValue": "Maximum value of jackpot", + "lostPointsAddedToJackpot": "How many lost points should be added to jackpot in %" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/games/heist.json b/backend/locales/ja/ui/games/heist.json new file mode 100644 index 000000000..c58f43810 --- /dev/null +++ b/backend/locales/ja/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Heist", + "settings": { + "enabled": "Status", + "showMaxUsers": "Max users to show in payout", + "copsCooldownInMinutes": { + "title": "Cooldown between heists", + "help": "Minutes" + }, + "entryCooldownInSeconds": { + "title": "Time to entry heist", + "help": "Seconds" + }, + "started": "Heist start message", + "nextLevelMessage": "Message when next level is reached", + "maxLevelMessage": "Message when max level is reached", + "copsOnPatrol": "Response of bot when heist is still on cooldown", + "copsCooldown": "Bot announcement when heist can be started", + "singleUserSuccess": "Success message for one user", + "singleUserFailed": "Fail message for one user", + "noUser": "Message if no user participated" + }, + "message": "Message", + "winPercentage": "勝率", + "payoutMultiplier": "Payout multiplier", + "maxUsers": "Max users for level", + "percentage": "パーセンテージ", + "noResultsFound": "No results found. Click button below to add new result.", + "noLevelsFound": "No levels found. Click button below to add new level." +} \ No newline at end of file diff --git a/backend/locales/ja/ui/games/roulette.json b/backend/locales/ja/ui/games/roulette.json new file mode 100644 index 000000000..65696d4e3 --- /dev/null +++ b/backend/locales/ja/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout duration", + "help": "Seconds" + }, + "winnerWillGet": "How many points will be added on win", + "loserWillLose": "How many points will be lost on lose" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/games/seppuku.json b/backend/locales/ja/ui/games/seppuku.json new file mode 100644 index 000000000..4d628e202 --- /dev/null +++ b/backend/locales/ja/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout duration", + "help": "Seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/integrations/discord.json b/backend/locales/ja/ui/integrations/discord.json new file mode 100644 index 000000000..ea313b391 --- /dev/null +++ b/backend/locales/ja/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Status", + "guild": "Guild", + "listenAtChannels": "Listen for commands on this channel", + "sendOnlineAnnounceToChannel": "Send online announcement to this channel", + "onlineAnnounceMessage": "Message in online announcement (can include mentions)", + "sendAnnouncesToChannel": "Setup sending of announcements to channels", + "deleteMessagesAfterWhile": "Delete message after while", + "clientId": "ClientId", + "token": "Token", + "joinToServerBtn": "クリックしてあなたのサーバーにボットを参加する", + "joinToServerBtnDisabled": "Please save changes to enable bot join to your server", + "cannotJoinToServerBtn": "Set token and clientId to be able to join bot to your server", + "noChannelSelected": "no channel selected", + "noRoleSelected": "no role selected", + "noGuildSelected": "no guild selected", + "noGuildSelectedBox": "Select guild where bot should work and you'll see more settings", + "onlinePresenceStatusDefault": "Default Status", + "onlinePresenceStatusDefaultName": "Default Status Message", + "onlinePresenceStatusOnStream": "ストリーミング時の状態", + "onlinePresenceStatusOnStreamName": "ストリーミング時のステータスメッセージ", + "ignorelist": { + "title": "Ignore list", + "help": "ユーザー名、ユーザー名#0000またはユーザーID" + } + } +} diff --git a/backend/locales/ja/ui/integrations/donatello.json b/backend/locales/ja/ui/integrations/donatello.json new file mode 100644 index 000000000..75bd1598d --- /dev/null +++ b/backend/locales/ja/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Get your token at https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/integrations/donationalerts.json b/backend/locales/ja/ui/integrations/donationalerts.json new file mode 100644 index 000000000..e37a63aee --- /dev/null +++ b/backend/locales/ja/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Status", + "access_token": { + "title": "Access token", + "help": "Get your access token at https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Refresh token" + }, + "accessTokenBtn": "DonationAlerts access and refresh token generator" + } +} diff --git a/backend/locales/ja/ui/integrations/kofi.json b/backend/locales/ja/ui/integrations/kofi.json new file mode 100644 index 000000000..a8179bf1e --- /dev/null +++ b/backend/locales/ja/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Verification token", + "help": "Get your verification token at https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "Webhook URL", + "help": "Set Webhook URL at https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL must have HTTPS", + "origin": "You cannot use localhost for webhooks" + } + } + } +} diff --git a/backend/locales/ja/ui/integrations/lastfm.json b/backend/locales/ja/ui/integrations/lastfm.json new file mode 100644 index 000000000..29c833743 --- /dev/null +++ b/backend/locales/ja/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": "API key", + "username": "ユーザー名" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/integrations/obswebsocket.json b/backend/locales/ja/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..9064e4f0d --- /dev/null +++ b/backend/locales/ja/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Status", + "accessBy": { + "title": "Access by", + "help": "Direct - ボットから直接接続 | Overlay - オーバーレイブラウザのソース経由で接続" + }, + "address": "Address", + "password": "Password" + }, + "noSourceSelected": "No source selected", + "noSceneSelected": "No scene selected", + "empty": "No action sets were created yet.", + "emptyAfterSearch": "No action sets were found by your search for \"$search\".", + "command": "コマンド", + "new": "Create new OBS Websocket action set", + "actions": "Actions", + "name": { + "name": "Name" + }, + "mute": "Mute", + "unmute": "Unmute", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Wait X miliseconds" + }, + "Log": { + "name": "Log message" + }, + "StartRecording": { + "name": "StartRecording" + }, + "StopRecording": { + "name": "StopRecording" + }, + "PauseRecording": { + "name": "PauseRecording" + }, + "ResumeRecording": { + "name": "ResumeRecording" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/integrations/pubg.json b/backend/locales/ja/ui/integrations/pubg.json new file mode 100644 index 000000000..a63d92e30 --- /dev/null +++ b/backend/locales/ja/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "API Key", + "help": "Get your API Key at https://developer.pubg.com/" + }, + "platform": "Platform", + "playerName": "Player Name", + "playerId": "Player ID", + "seasonId": { + "title": "Season ID", + "help": "Current season ID is being fetch every hour." + }, + "rankedGameModeStatsCustomization": "Customized message for ranked stats", + "gameModeStatsCustomization": "Customized message for normal stats" + }, + "click_to_fetch": "Click to fetch", + "something_went_wrong": "エラーが発生しました!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Stats are automatically refreshed every 10 minutes.", + "player_stats_ranked": "Player stats (ranked)", + "player_stats": "Player stats" +} diff --git a/backend/locales/ja/ui/integrations/qiwi.json b/backend/locales/ja/ui/integrations/qiwi.json new file mode 100644 index 000000000..e5f7cb336 --- /dev/null +++ b/backend/locales/ja/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "secretToken": { + "title": "Secret token", + "help": "Get secret token at Qiwi Donate dashboard settings->click show secret token" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/integrations/responsivevoice.json b/backend/locales/ja/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..3a59d48a5 --- /dev/null +++ b/backend/locales/ja/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Key", + "help": "http://responsvevoice.org でキーを取得してください" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/integrations/spotify.json b/backend/locales/ja/ui/integrations/spotify.json new file mode 100644 index 000000000..391df6fcd --- /dev/null +++ b/backend/locales/ja/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Artists", + "settings": { + "enabled": "Status", + "songRequests": "Song Requests", + "fetchCurrentSongWhenOffline": { + "title": "ストリームがオフラインの時に現在の曲を取得", + "help": "It's advised to have this disabled to avoid reach API limits" + }, + "allowApprovedArtistsOnly": "Allow approved artists only", + "approvedArtists": { + "title": "Approved artists", + "help": "Name or SpotifyURI of artist, one item per line" + }, + "queueWhenOffline": { + "title": "ストリームがオフラインのときに曲をキューに入れる", + "help": "It's advised to have this disabled to avoid queueing when you are just listening music" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "Forced Device ID", + "help": "Empty = disabled, force spotify device ID to be used to queue songs. Check logs for current active device or use button when playing song for at least 10 seconds." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Format", + "help": "Available variables: $song, $artist, $artists" + }, + "username": "認証済みのユーザー", + "revokeBtn": "Revoke user authorization", + "authorizeBtn": "Authorize user", + "scopes": "Scopes", + "playlistToPlay": { + "title": "Spotify URI of main playlist", + "help": "If set, after request finished this playlist will continue" + }, + "continueOnPlaylistAfterRequest": "Continue on playing of playlist after song request", + "notify": "Send message on song change" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/integrations/streamelements.json b/backend/locales/ja/ui/integrations/streamelements.json new file mode 100644 index 000000000..ae287e3b8 --- /dev/null +++ b/backend/locales/ja/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "jwtToken": { + "title": "JWT token", + "help": "StreamElements Channels 設定 で JWT トークンを取得し、シークレットを表示する" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/integrations/streamlabs.json b/backend/locales/ja/ui/integrations/streamlabs.json new file mode 100644 index 000000000..b3c3ff87f --- /dev/null +++ b/backend/locales/ja/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "socketToken": { + "title": "Socket token", + "help": "streamlabsダッシュボードのAPI設定からソケットトークンを取得->APIトークン->あなたのソケットAPIトークン" + }, + "accessToken": { + "title": "Access token", + "help": "アクセストークンは https://www.sogebot.xyz/integrations/#StreamLabs で入手してください" + }, + "accessTokenBtn": "StreamLabs アクセストークンジェネレーター" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/integrations/tipeeestream.json b/backend/locales/ja/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..e1ad3b924 --- /dev/null +++ b/backend/locales/ja/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": { + "title": "Api key", + "help": "tipeeestream ダッシュボードからソケットトークンを取得-> API -> API キー" + } + } +} diff --git a/backend/locales/ja/ui/integrations/twitter.json b/backend/locales/ja/ui/integrations/twitter.json new file mode 100644 index 000000000..940fd5589 --- /dev/null +++ b/backend/locales/ja/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "consumerKey": "Consumer Key (API Key)", + "consumerSecret": "Consumer Secret (API Secret)", + "accessToken": "Access Token", + "secretToken": "Access Token Secret" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/managers.json b/backend/locales/ja/ui/managers.json new file mode 100644 index 000000000..0591fae44 --- /dev/null +++ b/backend/locales/ja/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "ユーザーイベント履歴", + "hostAndRaidViewersCount": "Viewers: $value", + "receivedSubscribeFrom": "Received subscribe from $value", + "giftedSubscribeTo": "Gifted subscribe to $value" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/overlays/alerts.json b/backend/locales/ja/ui/overlays/alerts.json new file mode 100644 index 000000000..2c72859cb --- /dev/null +++ b/backend/locales/ja/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Cache gallery items", + "galleryCacheLimitInMb": "Max size of gallery item (in MB) to cache" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/overlays/clips.json b/backend/locales/ja/ui/overlays/clips.json new file mode 100644 index 000000000..aa6555159 --- /dev/null +++ b/backend/locales/ja/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Volume", + "cClipsFilter": "Clip filter", + "cClipsLabel": "Show 'clip' label" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/overlays/clipscarousel.json b/backend/locales/ja/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..b50a0a71d --- /dev/null +++ b/backend/locales/ja/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsTimeToNextClip": "Time to next clip (s)" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/overlays/credits.json b/backend/locales/ja/ui/overlays/credits.json new file mode 100644 index 000000000..ce8f1d817 --- /dev/null +++ b/backend/locales/ja/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Speed", + "cCreditsAggregated": "Aggregated credits", + "cShowGameThumbnail": "Show game thumbnail", + "cShowFollowers": "Show followers", + "cShowRaids": "Show raids", + "cShowSubscribers": "Show subscribers", + "cShowSubgifts": "Show gifted subs", + "cShowSubcommunitygifts": "Show subs gifted to community", + "cShowResubs": "Show resubs", + "cShowCheers": "Show cheers", + "cShowClips": "Show clips", + "cShowTips": "Tipを表示", + "cTextLastMessage": "Last message", + "cTextLastSubMessage": "Last submessge", + "cTextStreamBy": "次の人によってストリームされました:", + "cTextFollow": "Follow by", + "cTextRaid": "Raided by", + "cTextCheer": "Cheer by", + "cTextSub": "Subscribe by", + "cTextResub": "Resub by", + "cTextSubgift": "Gifted subs", + "cTextSubcommunitygift": "Subs gifted to community", + "cTextTip": "Tip by", + "cClipsPeriod": "Time interval", + "cClipsCustomPeriodInDays": "Custom time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsShouldPlay": "Clips should be played", + "cClipsVolume": "Volume" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/overlays/emotes.json b/backend/locales/ja/ui/overlays/emotes.json new file mode 100644 index 000000000..441ab762b --- /dev/null +++ b/backend/locales/ja/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Delete cache", + "hypeMessagesEnabled": "Show hype messages in chat", + "btnTestExplosion": "Test emote explosion", + "btnTestEmote": "Test emote", + "btnTestFirework": "Test emote firework", + "cEmotesSize": "Emotes size", + "cEmotesMaxEmotesPerMessage": "Maximum of emotes per message", + "cEmotesMaxRotation": "Maximal rotation of emote", + "cEmotesOffsetX": "Maximal offset on X-axis", + "cEmotesAnimation": "Animation", + "cEmotesAnimationTime": "Animation duration", + "cExplosionNumOfEmotes": "No. of emotes", + "cExplosionNumOfEmotesPerExplosion": "No. of emotes per explosion", + "cExplosionNumOfExplosions": "No. of explosions", + "enableEmotesCombo": "Enable emotes combo", + "comboBreakMessages": "Combo break messages", + "threshold": "Threshold", + "noMessagesFound": "No messages found.", + "message": "Message", + "showEmoteInOverlayThreshold": "オーバーレイでエモートを表示する最小メッセージ閾値です。", + "hideEmoteInOverlayAfter": { + "title": "非アクティブの後にオーバーレイでエモートを非表示", + "help": "一定時間(秒) 経過後にオーバーレイでエモートを非表示にする" + }, + "comboCooldown": { + "title": "Combo cooldown", + "help": "Cooldown of combo in seconds" + }, + "comboMessageMinThreshold": { + "title": "Minimal message threshold", + "help": "Minimal message threshold to count emotes as combo (until then won't trigger cooldown)" + }, + "comboMessages": "Combo messages" + }, + "hype": { + "5": "Let's go! $amountx $emote コンボを獲得しました!SeemsGood", + "15": "そのまま続けてください! $amountx $emoteを超えることはできますか? TriHard" + }, + "message": { + "3": "$amountx $emote combo", + "5": "$amountx $emote combo SeemsGood", + "10": "$amountx $emote combo PogChamp", + "15": "$amountx $emote combo TriHard", + "20": "$sender が $amountx $emote コンボを台無しにしました! NotLikeThis" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/overlays/polls.json b/backend/locales/ja/ui/overlays/polls.json new file mode 100644 index 000000000..da094ce9b --- /dev/null +++ b/backend/locales/ja/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Theme", + "cDisplayHideAfterInactivity": "Hide on inactivity", + "cDisplayAlign": "Align", + "cDisplayInactivityTime": { + "title": "Inactivity after", + "help": "in miliseconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/overlays/texttospeech.json b/backend/locales/ja/ui/overlays/texttospeech.json new file mode 100644 index 000000000..dd76c1741 --- /dev/null +++ b/backend/locales/ja/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "ResponsiveVoice キーが正しく設定されていません", + "voice": { + "title": "Voice", + "help": "ResponsiveVoiceキーの更新後にボイスが正しく読み込まれない場合は、ブラウザを更新してみてください" + }, + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "triggerTTSByHighlightedMessage": "Text to Speech will be triggered by highlighted message" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/properties.json b/backend/locales/ja/ui/properties.json new file mode 100644 index 000000000..e6243cf72 --- /dev/null +++ b/backend/locales/ja/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Command", + "variableName": "Variable name", + "price": "Price (points)", + "priceBits": "Price (bits)", + "thisvalue": "This value", + "promo": { + "shoutoutMessage": "Shoutout message", + "enableShoutoutMessage": "Send shoutout message in chat" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/registry/alerts.json b/backend/locales/ja/ui/registry/alerts.json new file mode 100644 index 000000000..73d937085 --- /dev/null +++ b/backend/locales/ja/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Enabled", + "testDlg": { + "alertTester": "Alert tester", + "command": "コマンド", + "username": "ユーザー名", + "recipient": "Recipient", + "message": "Message", + "tier": "Tier", + "amountOfViewers": "Amount of viewers", + "amountOfBits": "Amount of bits", + "amountOfGifts": "Amount of gifts", + "amountOfMonths": "Amount of months", + "amountOfTips": "Tip", + "event": "Event", + "service": "Service" + }, + "empty": "Alerts registry is empty, create new alerts.", + "emptyAfterSearch": "Alerts registry is empty in searching for \"$search\"", + "revertcode": "Revert code to defaults", + "name": { + "name": "Name", + "placeholder": "Set name of your alerts" + }, + "alertDelayInMs": { + "name": "Alert delay" + }, + "parryEnabled": { + "name": "Alert parries" + }, + "parryDelay": { + "name": "Alert parry delay" + }, + "profanityFilterType": { + "name": "Profanity filter", + "disabled": "Disabled", + "replace-with-asterisk": "Replace with asterisk", + "replace-with-happy-words": "Replace with happy words", + "hide-messages": "Hide messages", + "disable-alerts": "Disable alerts" + }, + "loadStandardProfanityList": "Load standard profanity list", + "customProfanityList": { + "name": "Custom profanity list", + "help": "Words should be separated with comma." + }, + "event": { + "follow": "Follow", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Subgift to community", + "tip": "Tip", + "raid": "Raid", + "custom": "Custom", + "promo": "Promo", + "rewardredeem": "Reward Redeem" + }, + "title": { + "name": "Variant name", + "placeholder": "Set your variant name" + }, + "variant": { + "name": "Variant occurence" + }, + "filter": { + "name": "Filter", + "operator": "Operator", + "rule": "Rule", + "addRule": "Add rule", + "addGroup": "Add group", + "comparator": "Comparator", + "value": "Value", + "valueSplitByComma": "Values split by comma (e.g. val1, val2)", + "isEven": "is even", + "isOdd": "is odd", + "lessThan": "less than", + "lessThanOrEqual": "less than or equal", + "contain": "contains", + "contains": "contains", + "equal": "equal", + "notEqual": "not equal", + "present": "is present", + "includes": "含む", + "greaterThan": "greater than", + "greaterThanOrEqual": "greater than or equal", + "noFilter": "no filter" + }, + "speed": { + "name": "Speed" + }, + "maxTimeToDecrypt": { + "name": "Max time to decrypt" + }, + "characters": { + "name": "Characters" + }, + "random": "Random", + "exact-amount": "Exact amount", + "greater-than-or-equal-to-amount": "Greater than or equal to amount", + "tier-exact-amount": "Tier is exactly", + "tier-greater-than-or-equal-to-amount": "Tier is higher or equal to", + "months-exact-amount": "Months amount is exactly", + "months-greater-than-or-equal-to-amount": "Months amount is higher or equal to", + "gifts-exact-amount": "Gifts amount is exactly", + "gifts-greater-than-or-equal-to-amount": "Gifts amount is higher or equal to", + "very-rarely": "Very rarely", + "rarely": "Rarely", + "default": "Default", + "frequently": "Frequently", + "very-frequently": "Very frequently", + "exclusive": "Exclusive", + "messageTemplate": { + "name": "Message template", + "placeholder": "Set your message template", + "help": "Available variables: {name}, {amount} (cheers, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). If | is added (see promo) then it will show those values in sequence." + }, + "ttsTemplate": { + "name": "TTS template", + "placeholder": "Set your TTS template", + "help": "Available variables: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Animation text" + }, + "animationType": { + "name": "Type of animation" + }, + "animationIn": { + "name": "Animation in" + }, + "animationOut": { + "name": "Animation out" + }, + "alertDurationInMs": { + "name": "Alert duration" + }, + "alertTextDelayInMs": { + "name": "Alert text delay" + }, + "layoutPicker": { + "name": "Layout" + }, + "loop": { + "name": "Play on loop" + }, + "scale": { + "name": "Scale" + }, + "translateY": { + "name": "Move -Up / +Down" + }, + "translateX": { + "name": "Move -Left / +Right" + }, + "image": { + "name": "Image / Video(.webm)", + "setting": "Image / Video(.webm) settings" + }, + "sound": { + "name": "Sound", + "setting": "Sound settings" + }, + "soundVolume": { + "name": "Alert volume" + }, + "enableAdvancedMode": "Enable advanced mode", + "font": { + "setting": "Font settings", + "name": "Font family", + "overrideGlobal": "Override global font settings", + "align": { + "name": "Alignment", + "left": "Left", + "center": "Center", + "right": "Right" + }, + "size": { + "name": "Font size" + }, + "weight": { + "name": "Font weight" + }, + "borderPx": { + "name": "Font border" + }, + "borderColor": { + "name": "Font border color" + }, + "color": { + "name": "Font color" + }, + "highlightcolor": { + "name": "Font highlight color" + } + }, + "minAmountToShow": { + "name": "Minimal amount to show" + }, + "minAmountToPlay": { + "name": "Minimal amount to play" + }, + "allowEmotes": { + "name": "Allow emotes" + }, + "message": { + "setting": "Message settings" + }, + "voice": "Voice", + "keepAlertShown": "Alert keeps visible during TTS", + "skipUrls": "Skip URLs during TTS", + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "test": "Test", + "tts": { + "setting": "TTS settings" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/registry/goals.json b/backend/locales/ja/ui/registry/goals.json new file mode 100644 index 000000000..79952717a --- /dev/null +++ b/backend/locales/ja/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Add Goal Group", + "addGoal": "Add Goal", + "newGoal": "new Goal", + "newGoalGroup": "new Goal Group", + "goals": "Goals", + "general": "General", + "display": "Display", + "fontSettings": "Font Settings", + "barSettings": "Bar Settings", + "selectGoalOnLeftSide": "Select or add goal on left side", + "input": { + "description": { + "title": "説明" + }, + "goalAmount": { + "title": "Goal Amount" + }, + "countBitsAsTips": { + "title": "Tipとしてビッツをカウント" + }, + "currentAmount": { + "title": "Current Amount" + }, + "endAfter": { + "title": "End After" + }, + "endAfterIgnore": { + "title": "Goal will not expire" + }, + "borderPx": { + "title": "Border", + "help": "Border size is in pixels" + }, + "barHeight": { + "title": "Bar Height", + "help": "Bar height is in pixels" + }, + "color": { + "title": "Color" + }, + "borderColor": { + "title": "Border Color" + }, + "backgroundColor": { + "title": "Background Color" + }, + "type": { + "title": "Type" + }, + "nameGroup": { + "title": "Name of this goal group" + }, + "name": { + "title": "Name of this goal" + }, + "displayAs": { + "title": "Display as", + "help": "Sets how goal group will be shown" + }, + "durationMs": { + "title": "Duration", + "help": "This value is in milliseconds", + "placeholder": "How long goal should be shown" + }, + "animationInMs": { + "title": "Animation In duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation In duration" + }, + "animationOutMs": { + "title": "Animation Out duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation Out duration" + }, + "interval": { + "title": "What interval to count" + }, + "spaceBetweenGoalsInPx": { + "title": "Space between goals", + "help": "This value is in pixels", + "placeholder": "Set your space between goals" + } + }, + "groupSettings": "Group Settings" +} \ No newline at end of file diff --git a/backend/locales/ja/ui/registry/overlays.json b/backend/locales/ja/ui/registry/overlays.json new file mode 100644 index 000000000..165bec13f --- /dev/null +++ b/backend/locales/ja/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "新しいオーバーレイリンクのマッピングを作成", + "emptyMapping": "オーバーレイリンクのマッピングはまだ作成されていません。", + "allowedIPs": { + "name": "Allowed IPs", + "help": "Allow access from set IPs separated by new line" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/registry/plugins.json b/backend/locales/ja/ui/registry/plugins.json new file mode 100644 index 000000000..00eb1444f --- /dev/null +++ b/backend/locales/ja/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "This node needs to be linked with listeners with sender attributes" + }, + "filter": { + "permission": { + "name": "Permission filter" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Event listener", + "type": { + "twitchChatMessage": "Twitch chat message", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat cleared", + "twitchCommand": "Twitch command", + "twitchFollow": "New Twitch follower", + "twitchSubscription": "New Twitch subscription", + "twitchSubgift": "New Twitch subscription gift", + "twitchSubcommunitygift": "New Twitch subscription community gift", + "twitchResub": "New Twitch recurring subscription", + "twitchGameChanged": "Twitch category changed", + "twitchStreamStarted": "Twitch stream started", + "twitchStreamStopped": "Twitch stream stopped", + "twitchRewardRedeem": "Twitch reward redeemed", + "twitchRaid": "Twitch raid incoming", + "tip": "Tipped by user", + "botStarted": "Bot started" + }, + "command": { + "add-parameter": "Add parameter", + "parameters": "Parameters", + "order-is-important": "order is important" + } + }, + "others": { + "idle": { + "name": "Idle" + } + }, + "output": { + "log": { + "name": "Log message" + }, + "timeout-user": { + "name": "Timeout user" + }, + "ban-user": { + "name": "Ban user" + }, + "send-twitch-message": { + "name": "Send Twitch Message" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/registry/randomizer.json b/backend/locales/ja/ui/registry/randomizer.json new file mode 100644 index 000000000..617ddc4cf --- /dev/null +++ b/backend/locales/ja/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Add Randomizer", + "form": { + "name": "Name", + "command": "コマンド", + "permission": "コマンドの権限", + "simple": "Simple", + "tape": "Tape", + "wheelOfFortune": "Wheel of Fortune", + "type": "Type", + "options": "Options", + "optionsAreEmpty": "Options are empty.", + "color": "Color", + "numOfDuplicates": "No. of duplicates", + "minimalSpacing": "Minimal spacing", + "groupUp": "Group Up", + "ungroup": "Ungroup", + "groupedWithOptionAbove": "Grouped with option above", + "generatedOptionsPreview": "Preview of generated options", + "probability": "Probability", + "tick": "Tick sound during spin" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/registry/textoverlay.json b/backend/locales/ja/ui/registry/textoverlay.json new file mode 100644 index 000000000..5728ad1b4 --- /dev/null +++ b/backend/locales/ja/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "新しいテキストオーバーレイを作成", + "title": "テキストオーバーレイ", + "name": { + "placeholder": "テキストオーバーレイ名を設定" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/stats/commandcount.json b/backend/locales/ja/ui/stats/commandcount.json new file mode 100644 index 000000000..332cb3086 --- /dev/null +++ b/backend/locales/ja/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "コマンド", + "hour": "時", + "day": "日", + "week": "週", + "month": "月", + "year": "年", + "total": "合計" +} \ No newline at end of file diff --git a/backend/locales/ja/ui/systems/checklist.json b/backend/locales/ja/ui/systems/checklist.json new file mode 100644 index 000000000..aca70f203 --- /dev/null +++ b/backend/locales/ja/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "状態", + "itemsArray": "リスト" + }, + "check": "チェックリスト" +} \ No newline at end of file diff --git a/backend/locales/ja/ui/systems/howlongtobeat.json b/backend/locales/ja/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..e1453b147 --- /dev/null +++ b/backend/locales/ja/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "状態" + }, + "empty": "No games were tracked yet.", + "emptyAfterSearch": "No tracked games were found by your search for \"$search\".", + "when": "ストリーミングされた時", + "time": "Tracked time", + "overallTime": "Overall time", + "offset": "Offset of tracked time", + "main": "Main", + "extra": "Main+Extra", + "completionist": "Completionist", + "game": "Tracked game", + "startedAt": "Tracking started at", + "updatedAt": "Last update", + "showHistory": "Show history ($count)", + "hideHistory": "Hide history ($count)", + "searchToAddNewGame": "Search to add new game to track" +} \ No newline at end of file diff --git a/backend/locales/ja/ui/systems/keywords.json b/backend/locales/ja/ui/systems/keywords.json new file mode 100644 index 000000000..30842fbc2 --- /dev/null +++ b/backend/locales/ja/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "新しいキーワード", + "empty": "No keywords were created yet.", + "emptyAfterSearch": "No keywords were found by your search for \"$search\".", + "keyword": { + "name": "Keyword / Regular Expression", + "placeholder": "Set your keyword or regular expression to trigger keyword.", + "help": "You can use regexp (case insensitive) to use keywords, e.g. hello.*|hi" + }, + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "no-responses-set": "No responses", + "addResponse": "Add response", + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + }, + "warning": "この操作は取り消せません!", + "settings": { + "enabled": "状態" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/systems/levels.json b/backend/locales/ja/ui/systems/levels.json new file mode 100644 index 000000000..6fefee1ea --- /dev/null +++ b/backend/locales/ja/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Status", + "conversionRate": "Conversion rate 1 XP for x Points", + "firstLevelStartsAt": "First level starts at XP", + "nextLevelFormula": { + "title": "Next level calculation formula", + "help": "Available variables: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "Levels example will be refreshed on save", + "xpName": "Name", + "interval": "ストリームがオンラインの時に、オンラインユーザーへのXP付与を分単位で行う。", + "offlineInterval": "ストリームがオフラインの時に、オンラインユーザーへのXP付与を分単位で行う。", + "messageInterval": "How many messages to add xp", + "messageOfflineInterval": "ストリームがオフラインのときにXPを追加するメッセージ数", + "perInterval": "How many xp to add per online interval", + "perOfflineInterval": "How many xp to add per offline interval", + "perMessageInterval": "How many xp to add per message interval", + "perMessageOfflineInterval": "How many xp to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/systems/polls.json b/backend/locales/ja/ui/systems/polls.json new file mode 100644 index 000000000..f31a08052 --- /dev/null +++ b/backend/locales/ja/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Total votes", + "totalPoints": "Total points", + "closedAt": "Closed at", + "activeFor": "Active for" +} \ No newline at end of file diff --git a/backend/locales/ja/ui/systems/scrim.json b/backend/locales/ja/ui/systems/scrim.json new file mode 100644 index 000000000..6b719fc3b --- /dev/null +++ b/backend/locales/ja/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "waitForMatchIdsInSeconds": { + "title": "Interval for putting match ID into chat", + "help": "Set in seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/systems/top.json b/backend/locales/ja/ui/systems/top.json new file mode 100644 index 000000000..b0cbbf0cc --- /dev/null +++ b/backend/locales/ja/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/ja/ui/systems/userinfo.json b/backend/locales/ja/ui/systems/userinfo.json new file mode 100644 index 000000000..c8bba8b80 --- /dev/null +++ b/backend/locales/ja/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "formatSeparator": "Format separator", + "order": "Format", + "lastSeenFormat": { + "title": "Time format", + "help": "Possible formats at https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt.json b/backend/locales/pt.json new file mode 100644 index 000000000..0982984a7 --- /dev/null +++ b/backend/locales/pt.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "carregado, e", + "enabled": "ativado", + "disabled": "desabilitado", + "usage": "Utilização", + "lang-selected": "O idioma do bot está atualmente definido como Inglês", + "refresh-panel": "Você precisará atualizar a UI para ver as alterações.", + "command-parse": "Desculpe, $sender, mas esse comando não está correto, use", + "error": "Desculpe, $sender, mas algo deu errado!", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, API não está respondendo corretamente!", + "not-available": "indisponível" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "ano|anos", + "months": "mês|meses", + "days": "dia|dias", + "hours": "hora|horas", + "minutes": "minuto|minutos", + "seconds": "segundo|segundos", + "messages": "mensagem|mensagens", + "bits": "bit|bits", + "links": "link|links", + "entries": "entrada|entradas", + "empty": "vazio", + "isRegistered": "$sender, você não pode usar !$keyword, porque já está em uso para outra ação!" + }, + "clip": { + "notCreated": "Algo deu errado e o clipe não foi criado.", + "offline": "A transmissão está offline no momento e não é possível criar um clipe." + }, + "uptime": { + "online": "Transmitindo a (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)", + "offline": "Transmitindo a (if $days>0|$daysd )(if $hours>0|$hoursh )(if $minutes>0|$minutesm )(if $seconds>0|$secondss)" + }, + "webpanel": { + "this-system-is-disabled": "Este sistema está desativado", + "or": "ou", + "loading": "Carregando", + "this-may-take-a-while": "Isto pode demorar um pouco", + "display-as": "Exibir como", + "go-to-admin": "Painel do administrador", + "go-to-public": "Painel do usuario", + "logout": "Finalizar sessão", + "popout": "Desanexar", + "not-logged-in": "Não está logado", + "remove-widget": "Remover o widget $name", + "join-channel": "Associar ‘bot’ ao canal", + "leave-channel": "Desassociar ‘bot’ do canal", + "set-default": "Definir como padrão", + "add": "Adicionar", + "placeholders": { + "text-url-generator": "Cole seu texto ou HTML para gerar base64 abaixo e URL acima", + "text-decode-base64": "Cole seu base64 para gerar o URL e o texto acima", + "creditsSpeed": "Definir velocidade de rolagem de créditos, mais baixo = mais rápido" + }, + "timers": { + "title": "Temporizadores", + "timer": "Temporizador", + "messages": "mensagens", + "seconds": "segundos", + "badges": { + "enabled": "Ativado", + "disabled": "Desativado" + }, + "errors": { + "timer_name_must_be_compliant": "Este valor pode conter apenas a-zA-Z09_", + "this_value_must_be_a_positive_number_or_0": "Este valor deve ser um número positivo ou 0", + "value_cannot_be_empty": "O valor não pode ficar vazio" + }, + "dialog": { + "timer": "Temporizador", + "name": "Name", + "tickOffline": "Deve marcar se a transmissão estiver offline", + "interval": "Intervalo", + "responses": "Respostas", + "messages": "Acionar a cada X mensagens", + "seconds": "Acionar a cada X segundos", + "title": { + "new": "Novo temporizador", + "edit": "Editar Temporizador" + }, + "placeholders": { + "name": "Defina o nome do seu temporizador, pode conter apenas estes caracteres a-zA-Z0-9_", + "messages": "Acionar o temporizador a cada X mensagens", + "seconds": "Acionar o temporizador a cada X mensagens" + }, + "alerts": { + "success": "Timer foi salvo com sucesso.", + "fail": "Algo está errado." + } + }, + "buttons": { + "close": "Fechar", + "save-changes": "Salvar alterações", + "disable": "Desativar", + "enable": "Ativar", + "edit": "Editar", + "delete": "Deletar", + "yes": "Sim", + "no": "Não" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Tem certeza que deseja excluir timer" + } + }, + "events": { + "event": "Evento", + "noEvents": "Nenhum evento encontrado na base de dados.", + "whatsthis": "o que é isto?", + "myRewardIsNotListed": "Minha recompensa não está listada!", + "redeemAndClickRefreshToSeeReward": "Se sua recompensa criada estiver faltando em uma lista, atualize clicando no ícone de atualização.", + "badges": { + "enabled": "Ativado", + "disabled": "Desativado" + }, + "buttons": { + "test": "Teste", + "enable": "Ativar", + "disable": "Desativar", + "edit": "Editar", + "delete": "Deletar", + "yes": "Sim", + "no": "Não" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Tem certeza que deseja excluir evento", + "example_of_user_object_data": "Exemplo de dados do objeto de usuário" + }, + "errors": { + "command_must_start_with_!": "O comando deve começar com !", + "this_value_must_be_a_positive_number_or_0": "Este valor deve ser um número positivo ou 0", + "value_cannot_be_empty": "O valor não pode ficar vazio" + }, + "dialog": { + "title": { + "new": "Novo ouvinte de evento", + "edit": "Editar ouvinte de evento" + }, + "placeholders": { + "name": "Defina o nome do ouvinte do seu evento (se vazio, o nome será gerado)" + }, + "alerts": { + "success": "Evento foi salvo com sucesso.", + "fail": "Algo deu errado." + }, + "close": "Fechar", + "save-changes": "Salvar alterações", + "event": "Evento", + "name": "Nome", + "usable-events-variables": "Variáveis de eventos utilizáveis", + "settings": "Configurações", + "filters": "Filtros", + "operations": "Operações" + }, + "definitions": { + "taskId": { + "label": "ID de Tarefa" + }, + "filter": { + "label": "Filtro" + }, + "linkFilter": { + "label": "Filtro de Overlay de Link", + "placeholder": "Se estiver usando overlay, adicione o link ou id da sua overlay" + }, + "hashtag": { + "label": "Hashtag ou Palavra-chave", + "placeholder": "#suaHashtagAqui ou Palavra-chave" + }, + "fadeOutXCommands": { + "label": "Esmaecer X Comandos", + "placeholder": "Número de comandos subtraídos a cada intervalo de esmaecimento" + }, + "fadeOutXKeywords": { + "label": "Esmaecer X Palavras-chave", + "placeholder": "Número de palavras-chave subtraídos a cada intervalo de esmaecimento" + }, + "fadeOutInterval": { + "label": "Intervalo de Esmaecimento (segundos)", + "placeholder": "Subtração de Intervalo de esmaecimento" + }, + "runEveryXCommands": { + "label": "Executar a Cada X Comandos", + "placeholder": "Número de comandos antes do evento ser acionado" + }, + "runEveryXKeywords": { + "label": "Executar a cada X palavras-chave", + "placeholder": "Número de palavras-chave antes do evento ser acionado" + }, + "commandToWatch": { + "label": "Comando para Observar", + "placeholder": "Defina seu !commandToWatch" + }, + "keywordToWatch": { + "label": "Palavra-chave para Observar", + "placeholder": "Defina sua keywordToWatch para Observar" + }, + "resetCountEachMessage": { + "label": "Redefinir a contagem a cada mensagem", + "true": "Redefinir contagem", + "false": "Manter contagem" + }, + "viewersAtLeast": { + "label": "'Viewers' pelo menos", + "placeholder": "Quantos espectadores pelo menos para acionar o evento" + }, + "runInterval": { + "label": "Executar Intervalo (0 = executar uma vez por stream)", + "placeholder": "Acionar a cada x segundos" + }, + "runAfterXMinutes": { + "label": "Executar Após X Minutos", + "placeholder": "Disparar evento após x minutos" + }, + "runEveryXMinutes": { + "label": "Executar A Cada X Minutos", + "placeholder": "Acionar a cada x minutos" + }, + "messageToSend": { + "label": "Mensagem a enviar", + "placeholder": "Defina sua mensagem" + }, + "channel": { + "label": "Canal", + "placeholder": "Nome ou ID do canal" + }, + "timeout": { + "label": "Tempo limite", + "placeholder": "Definir tempo limite em milissegundos" + }, + "timeoutType": { + "label": "Tipo de tempo limite", + "placeholder": "Definir tipo de tempo limite" + }, + "command": { + "label": "Comando", + "placeholder": "Definir seu !comando" + }, + "commandToRun": { + "label": "Comando para Rodar", + "placeholder": "Defina seu !commandToRun" + }, + "isCommandQuiet": { + "label": "Silenciar saída de comando" + }, + "urlOfSoundFile": { + "label": "URL de seu arquivo de som", + "placeholder": "http://www.caminhoParaSua.url/onde/seu/arquivo/está.mp3" + }, + "emotesToExplode": { + "label": "Emotes Para Explodir", + "placeholder": "Lista de emotes para explodir, p. ex.: Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Emotes apra Fogos de Artifício", + "placeholder": "Lista de emotes para Fogos de Artifício, p. ex. Kappa PurpleHeart" + }, + "replay": { + "label": "Passar clipe no overlay", + "true": "Passará como 'replay' em overlay/alerta", + "false": "'Replay' não passará" + }, + "announce": { + "label": "Anunciar no bate-papo", + "true": "Será anunciado", + "false": "Não será anunciado" + }, + "hasDelay": { + "label": "O clipe deverá ter um pequeno atraso (para ficar mais próximo do que o 'viewer' vê)", + "true": "Terá atraso", + "false": "Não terá atraso" + }, + "durationOfCommercial": { + "label": "Duração do Comercial", + "placeholder": "Durações disponíveis - 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Variável personalizada para atualizar" + }, + "numberToIncrement": { + "label": "Número para incrementar", + "placeholder": "" + }, + "value": { + "label": "Valor", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Número para decrementar", + "placeholder": "" + }, + "": "", + "reward": { + "label": "Recompensa", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Seguiu você", + "raid": "Invadiu você com $viewers invasores.", + "sub": "Inscreveu-se com $subType. Inscrição pelo já pelo período de $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "presenteada(o) com uma inscrição por $username", + "subcommunitygift": "Presenteou a comunidade com inscrições", + "resub": "Re-inscreveu-se com $subType. Inscrição pelo período $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Deu um Cheered em você", + "tip": "Doou para você", + "tipToCharity": "doou para $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Palpites Twitch - Título", + "outcomes": "Palpites Twitch - Resultado", + "locksAt": "Palpites Twitch - Bloqueia na Data", + "winningOutcomeTitle": "Palpites Twitch - Título do resultado vencedor", + "winningOutcomeTotalPoints": "Palpites Twitch - Pontos totais do resultado vencedor", + "winningOutcomePercentage": "Palpites Twitch - Resultado vencedor porcentagem", + "titleOfPoll": "Enquete Twitch - Título", + "bitAmountPerVote": "Enquete Twitch - Quantidade de bits conta com 1 voto", + "bitVotingEnabled": "Enquete Twitch - Votação em bit ativada (booleano)", + "channelPointsAmountPerVote": "Enquete Twitch - Quantidade de pontos do canal para contar como 1 voto", + "channelPointsVotingEnabled": "Enquete Twitch - Votação de pontos de canal ativada (booleano)", + "votes": "Enquete Twitch - contagem de votos", + "winnerChoice": "Enquete Twitch - Escolha vencedora", + "winnerPercentage": "Enquete Twitch - Porcentagem da escolha vencedora", + "winnerVotes": "Enquete Twitch - Votos da escolha vencedora", + "goal": "Objetivo", + "total": "Total", + "lastContributionTotal": "Última Contribuição - Total", + "lastContributionType": "Última contribuição - Tipo", + "lastContributionUserId": "Última contribuição - ID de usuário", + "lastContributionUsername": "Última Contribuição - Nome de Usuário", + "level": "Nível", + "topContributionsBitsTotal": "Contribuição dos Top Bits - Total", + "topContributionsBitsUserId": "Contribuição dos Top Bits - ID de Usuário", + "topContributionsBitsUsername": "Contribuição do Top Bits - Nome de usuário", + "topContributionsSubsTotal": "Contribuição Top Subs - Total", + "topContributionsSubsUserId": "Contribuição Top Subs - ID de Usuário", + "topContributionsSubsUsername": "Contribuição Top Subs - Nome de usuário", + "sender": "Usuário que iniciou", + "title": "Título atual", + "game": "Categoria atual", + "language": "Idioma atual da transmissão", + "viewers": "Contagem atual de 'viewers'", + "hostViewers": "Contagem de viewers de Raid", + "followers": "Contagem autal de seguidores", + "subscribers": "Contagem atual de assinantes", + "arg": "Argumento", + "param": "Parâmetro (requerido)", + "touser": "Parâmetro do usuário", + "!param": "Parâmetro (não requerido)", + "alias": "Apelido", + "command": "Comando", + "keyword": "Palavra-chave", + "response": "Resposta", + "list": "Lista preenchida", + "type": "Tipo", + "days": "Dias", + "hours": "Horas", + "minutes": "Minutos", + "seconds": "Segundos", + "description": "Descrição", + "quiet": "Silêncioso (booleano)", + "id": "ID", + "name": "Nome", + "messages": "Mensagens", + "amount": "Quantia", + "amountInBotCurrency": "Quantia em moeda do bot", + "currency": "Moeda", + "currencyInBot": "Moeda do bot", + "pointsName": "Nome dos Pontos", + "points": "Pontos", + "rank": "Colocação", + "nextrank": "Próxima colocação", + "username": "Nome de usuário", + "value": "Valor", + "variable": "Variável", + "count": "Contagem", + "link": "Link (traduzido)", + "winner": "Vencedor", + "loser": "Perdedor", + "challenger": "Desafiante", + "min": "Mínimo", + "max": "Máximo", + "eligibility": "Elegibilidade", + "probability": "Probabilidade", + "time": "Tempo", + "options": "Opções", + "option": "Opção", + "when": "Quando", + "diff": "Diferença", + "users": "Usuários", + "user": "Usuário", + "bank": "Banco", + "nextBank": "Próximo banco", + "cooldown": "Intervalo", + "tickets": "Ingressos", + "ticketsName": "Nome do Tickets", + "fromUsername": "Do usuário", + "toUsername": "Para o usuário", + "items": "Ítens", + "bits": "Bits", + "subgifts": "'Subgifts'", + "subStreakShareEnabled": "Compartilhamento de sequência ativado (verdadeiro/falso)", + "subStreak": "Sequencia de 'sub' atual", + "subStreakName": "nome de mês localizado (1 mês, 2 meses) para sequência de sub atual", + "subCumulativeMonths": "Cumulativo de inscrições mensais", + "subCumulativeMonthsName": "nome de mês localizado (1 mês, 2 meses) para sequência de meses de 'sub'", + "message": "Mensagem", + "reason": "Razão", + "target": "Alvo", + "duration": "Duração", + "method": "Método", + "tier": "Nível", + "months": "Meses", + "monthsName": "nome do mês localizado (1 mês, 2 meses)", + "oldGame": "Categoria antes da alteração", + "recipientObject": "Objeto destinatário completo", + "recipient": "Destinatário", + "ytSong": "Música atual no YouTube", + "spotifySong": "Música atual no Spotify", + "latestFollower": "Último seguidor", + "latestSubscriber": "Último inscrito", + "latestSubscriberMonths": "Último inscrito meses acumulados", + "latestSubscriberStreak": "Último inscrito sequência de meses", + "latestTipAmount": "Última Doação (quantia)", + "latestTipCurrency": "Última Doação (moeda)", + "latestTipMessage": "Última Doação (mensagem)", + "latestTip": "Última Doação (nome de usuário)", + "toptip": { + "overall": { + "username": "Melhor Doação - geral (nome de usuário)", + "amount": "Melhor Doação - geral (quantia)", + "currency": "Melhor Doação - geral (moeda)", + "message": "Melhor Doação - geral (mensagem)" + }, + "stream": { + "username": "Melhor Doação - durante a 'stream' (nome de usuário)", + "amount": "Melhor Doação - durante a 'stream' (quantia)", + "currency": "Melhor Doação - durante a 'stream' (moeda)", + "message": "Melhor Doação - durante a 'stream' (mensagem)" + } + }, + "latestCheerAmount": "Bits mais recentes (quantia)", + "latestCheerMessage": "Bits mais recentes (mensagem)", + "latestCheer": "Bits mais recentes (nome de usuário)", + "version": "Versão do bot", + "haveParam": "Há algum parâmetro para o bot? (booleano)", + "source": "Fonte atual (twitch ou discord)", + "userInput": "Entrada do usuário durante o 'redeem' de recompensa", + "isBotSubscriber": "O bot é inscrito (booleano)", + "isStreamOnline": "A 'stream' está online (boolean)", + "uptime": "Tempo transmitindo", + "is": { + "moderator": "O usuário é mod? (booleano)", + "subscriber": "O usuário é inscrito? (booleano)", + "vip": "O usuário é VIP? (booleano)", + "newchatter": "É a 1.ª mensagem do usuário? (booleano)", + "follower": "O usuário é seguidor? (booleano)", + "broadcaster": "O usuário é 'streamer'? (booleano)", + "bot": "O usuário é bot? (booleano)", + "owner": "Usuário é proprietário do bot? (booleano)" + }, + "recipientis": { + "moderator": "O destinatário é mod? (booleano)", + "subscriber": "O destinatário é inscrito? (booleano)", + "vip": "O destinatário é VIP? (booleano)", + "follower": "O destinatário é seguidor? (booleano)", + "broadcaster": "O destinatário é 'streamer'? (booleano)", + "bot": "O destinatário é bot? (booleano)", + "owner": "O destinatário é proprietário do bot? (booleano)" + }, + "sceneName": "Nome de cena", + "inputName": "Nome do campo", + "inputMuted": "Estado do Mudo (boleano)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Outros", + "currency": "Moeda" + }, + "whispers": { + "title": "Sussurros", + "toggle": { + "listener": "Ouça comandos no sussurro", + "settings": "Sussurros ao mudar configurações", + "raffle": "Sussurros ao entrar num sorteio", + "permissions": "Sussurros por permissões insuficientes", + "cooldowns": "Sussurros ao dar 'cooldown' (se definido para notificar)" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Mensagens", + "follows": "Seguidas", + "subs": "Inscrições & Reinscrições", + "cheers": "Bits", + "responses": "Respostas de bot", + "whispers": "Sussurros", + "bans": "'Bans'", + "timeouts": "Suspensões" + }, + "range": { + "day": "um dia", + "week": "uma semana", + "month": "um mês", + "year": "um ano", + "all": "Para sempre" + }, + "order": { + "asc": "Ascendente", + "desc": "Descendente" + }, + "labels": { + "order": "ORDEM", + "range": "FAIXA", + "filters": "FILTROS" + } + }, + "stats-panel": { + "show": "Mostra statísticas", + "hide": "Oculta statísticas" + }, + "translations": "Traduções personalizadas", + "bot-responses": "Respostas de bot", + "duration": "Duração", + "viewers-reset-attributes": "Redefinir atributos", + "viewers-points-of-all-users": "Pontos para todos usuários", + "viewers-watchtime-of-all-users": "Tempo de espectador de todos usuários", + "viewers-messages-of-all-users": "Mensagens de todos usuários", + "events-game-after-change": "categoria após alteração", + "events-game-before-change": "categoria antes da alteração", + "events-user-triggered-event": "evento acionado por usuário", + "events-method-used-to-subscribe": "método utilizado para inscrição", + "events-months-of-subscription": "meses de assinatura", + "events-monthsName-of-subscription": "palavra 'mês' por número (1 mês, 2 meses)", + "events-user-message": "mensagens de usuário", + "events-bits-user-sent": "bits enviados por usuário", + "events-reason-for-ban-timeout": "razão para ban/suspensão", + "events-duration-of-timeout": "duração da suspensão", + "events-duration-of-commercial": "duração do comercial", + "overlays-eventlist-resub": "resub", + "overlays-eventlist-subgift": "subgift", + "overlays-eventlist-subcommunitygift": "subcommunitygift", + "overlays-eventlist-sub": "inscrição", + "overlays-eventlist-follow": "seguiu", + "overlays-eventlist-cheer": "bits", + "overlays-eventlist-tip": "doação", + "overlays-eventlist-raid": "raid", + "requested-by": "Requisitado por", + "description": "Descrição", + "raffle-type": "Tipo de rifa", + "raffle-type-keywords": "Somente palavra-chave", + "raffle-type-tickets": "Com 'tickets'", + "raffle-tickets-range": "Faixa de tickets", + "video_id": "ID de Vídeo", + "highlights": "Destaques", + "cooldown-quiet-header": "Mostrar mensagem de 'cooldown'", + "cooldown-quiet-toggle-no": "Notificar", + "cooldown-quiet-toggle-yes": "Não notificará", + "cooldown-moderators": "Moderadores", + "cooldown-owners": "Proprietários", + "cooldown-subscribers": "Inscritos", + "cooldown-followers": "Seguidores", + "in-seconds": "em segundos", + "songs": "Músicas", + "show-usernames-with-at": "Mostrar nomes de usuários com @", + "send-message-as-a-bot": "Enviar mensagem como um bot", + "chat-as-bot": "Bate-papo (como bot)", + "product": "Produto", + "optional": "opcional", + "placeholder-search": "Buscar", + "placeholder-enter-product": "Informe o produto", + "placeholder-enter-keyword": "Digitar palavra-chave", + "credits": "Créditos", + "fade-out-top": "desaparecer para cima", + "fade-out-zoom": "zoom de desvanecedor", + "global": "Global", + "user": "Usuário", + "alerts": "Alertas", + "eventlist": "Lista de eventos", + "dashboard": "Painel", + "carousel": "Carrossel de Imagens", + "text": "Texto", + "filter": "Filtro", + "filters": "Filtros", + "isUsed": "É usado", + "permissions": "Permissões", + "permission": "Permissão", + "viewers": "Espectadores", + "systems": "Sistemas", + "overlays": "Overlays", + "gallery": "Galeria de mídia", + "aliases": "Apelidos", + "alias": "Apelido", + "command": "Comando", + "cooldowns": "Intervalo", + "title-template": "Título modelo", + "keyword": "Palavra-chave", + "moderation": "Moderação", + "timer": "Temporizador", + "price": "Preço", + "rank": "Colocação", + "previous": "Anterior", + "next": "Próximo", + "close": "Fechar", + "save-changes": "Salvar alterações", + "saving": "Salvando...", + "deleting": "Deletando...", + "done": "Concluído", + "error": "Erro", + "title": "Título", + "change-title": "Alterar o título", + "game": "categoria", + "tags": "Tags", + "change-game": "Alterar categoria", + "click-to-change": "clique para mudar", + "uptime": "uptime", + "not-affiliate-or-partner": "Não afiliado/parceiro", + "not-available": "Não Disponível", + "max-viewers": "Máx. espectadores", + "new-chatters": "Novos no bate-papo", + "chat-messages": "Mensagens de chat", + "followers": "Seguidores", + "subscribers": "Inscritos", + "bits": "Bits", + "subgifts": "Inscrições de presente", + "subStreak": "Sequencia de 'sub' atual", + "subCumulativeMonths": "Cumulativo de inscrições mensais", + "tips": "Doações", + "tier": "Nível", + "status": "Situação", + "add-widget": "Adicionar o widget", + "remove-dashboard": "Remover painel", + "close-bet-after": "Fechar aposta depois", + "refund": "reembolso", + "roll-again": "Rolar de novo", + "no-eligible-participants": "Não há participantes elegíveis", + "follower": "Seguidor", + "subscriber": "Inscrito", + "minutes": "minutos", + "seconds": "segundos", + "hours": "horas", + "months": "meses", + "eligible-to-enter": "Elegível a entrar", + "everyone": "Todos", + "roll-a-winner": "Rolar um vencedor", + "send-message": "Enviar mensagem", + "messages": "Mensagens", + "level": "Nível", + "create": "Criar", + "cooldown": "Intervalo", + "confirm": "Confirmar", + "delete": "Deletar", + "enabled": "Ativado", + "disabled": "Desativado", + "enable": "Ativar", + "disable": "Desativar", + "slug": "Slug", + "posted-by": "Publicado por", + "time": "Tempo", + "type": "Tipo", + "response": "Resposta", + "cost": "Custo", + "name": "Nome", + "playlist": "Playlist", + "length": "Duração", + "volume": "Volume", + "start-time": "Hora de Início", + "end-time": "Hora de término", + "watched-time": "Tempo assistindo", + "currentsong": "Música atual", + "group": "Grupo", + "followed-since": "Seguiu desde", + "subscribed-since": "Assinante desde", + "username": "Nome de usuário", + "hashtag": "Hashtag", + "accessToken": "Token de Acesso", + "refreshToken": "Token de Atualização", + "scopes": "Escopos", + "last-seen": "Última vez visto", + "date": "Data", + "points": "Pontos", + "calendar": "Calendário", + "string": "texto", + "interval": "Intervalo", + "number": "número", + "minimal-messages-required": "Mínimo de Mensagens Necessário", + "max-duration": "Duração máx", + "shuffle": "Embaralhar", + "song-request": "Pedido de música", + "format": "Formato", + "available": "Disponível", + "one-record-per-line": "um registro por linha", + "on": "ligado", + "off": "desligado", + "search-by-username": "Pesquisar por nome de usuário", + "widget-title-custom": "PERSONALIZADO", + "widget-title-eventlist": "LISTADEEVENTO", + "widget-title-chat": "BATE-PAPO", + "widget-title-queue": "FILA", + "widget-title-raffles": "RIFAS", + "widget-title-social": "SOCIAL", + "widget-title-ytplayer": "MUSIC", + "widget-title-monitor": "MONITOR", + "event": "evento", + "operation": "operação", + "tweet-post-with-hashtag": "Tweet postado com hashtag", + "user-joined-channel": "usuário juntou-se a um canal", + "user-parted-channel": "usuário partiu de um canal", + "follow": "novo seguidor", + "tip": "nova doação", + "obs-scene-changed": "Cena do OBS mudou", + "obs-input-mute-state-changed": "Estado mutado da fonte de entrada OBS mudou", + "unfollow": "deixar de seguir", + "hypetrain-started": "Trem do Hype iniciado", + "hypetrain-ended": "Trem do Hype acabou", + "prediction-started": "Palpites Twitch iniciados", + "prediction-locked": "Palpites Twitch fechados", + "prediction-ended": "Palpites Twitch finalizou", + "poll-started": "Enquete da Twitch iniciada", + "poll-ended": "Enquete da Twitch finalizada", + "hypetrain-level-reached": "Novo nível do Trem de Hype atingido", + "subscription": "nova inscrição", + "subgift": "novo sub presente", + "subcommunitygift": "novo sub dado à comunidade", + "resub": "usuário reinscreveu-se", + "command-send-x-times": "comando foi enviado x vezes", + "keyword-send-x-times": "palavra-chave foi enviar x vezes", + "number-of-viewers-is-at-least-x": "número de espectadores é de pelo menos x", + "stream-started": "transmissão iniciada", + "reward-redeemed": "recompensa resgatada", + "stream-stopped": "transmissão parada", + "stream-is-running-x-minutes": "transmissão está rodando a x minutos", + "chatter-first-message": "primeira mensagem no bate-papo", + "every-x-minutes-of-stream": "a cada x minutos de transmissão", + "game-changed": "categoria alterada", + "cheer": "bits recebidos", + "clearchat": "bate-papo foi limpo", + "action": "usuário enviou /me", + "ban": "usuário foi banido", + "raid": "seu canal está recebendo uma raid", + "mod": "usuário é um novo mod", + "timeout": "usuário foi temporizado", + "create-a-new-event-listener": "Criar um novo ouvinte de evento", + "send-discord-message": "enviar uma mensagem no discord", + "send-chat-message": "enviar uma mensagem de chat da twitch", + "send-whisper": "envie um sussurro", + "run-command": "executar o comando", + "run-obswebsocket-command": "executar um comando de Websocket OBS", + "do-nothing": "--- fazer nada ---", + "count": "contar", + "timestamp": "carimbo de data/hora", + "message": "mensagem", + "sound": "som", + "emote-explosion": "explosão de emotes", + "emote-firework": "fogos de artificio de emotes", + "quiet": "silencioso", + "noisy": "barulhento", + "true": "verdadeiro", + "false": "false", + "light": "tema claro", + "dark": "tema escuro", + "gambling": "Apostas", + "seppukuTimeout": "Tempo limite de !seppuku", + "rouletteTimeout": "Tempo limite para !roulette", + "fightmeTimeout": "Tempo limite para !fightme", + "duelCooldown": "Intervalo para !duel", + "fightmeCooldown": "Intervalo para !fightme", + "gamblingCooldownBypass": "Ignorar resfriamentos de apostas para mods/'steamer'", + "click-to-highlight": "destaques", + "click-to-toggle-display": "alternar tela", + "commercial": "comercial iniciado", + "start-commercial": "rodar um comercial", + "bot-will-join-channel": "bot entrará no canal", + "bot-will-leave-channel": "bot sairá do canal", + "create-a-clip": "criar um clipe", + "increment-custom-variable": "incrementar uma variável personalizada", + "set-custom-variable": "definir uma variável personalizada", + "decrement-custom-variable": "diminuir uma variável personalizada", + "omit": "omitir", + "comply": "obedecer", + "visible": "visível", + "hidden": "oculto", + "gamblingChanceToWin": "Chance de vencer !gamble", + "gamblingMinimalBet": "Aposta mínima para !gamble", + "duelDuration": "Duração de !duel", + "duelMinimalBet": "Aposta mínima para !duel" + }, + "raffles": { + "announceInterval": "Os sorteios abertos serão anunciados a cada $value minutos", + "eligibility-followers-item": "seguidores", + "eligibility-subscribers-item": "inscritos", + "eligibility-everyone-item": "todos", + "raffle-is-running": "Sorteio está rodando ($count $l10n_entries).", + "to-enter-raffle": "Para entrar digite \"$keyword\". Sorteio aberto para $eligibility.", + "to-enter-ticket-raffle": "Para entrar digite \"$keyword <$min-$max>\". Sorteio está aberto para $eligibility.", + "added-entries": "Adicionada(s) $count $l10n_entries para o sorteio ($countTotal total). {raffles.to-enter-raffle}", + "added-ticket-entries": "$count $l10n_entries adicionado ao sorteio ($countTotal total). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "Suas mensagens de sorteio serão excluídas ao entrar.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "Desculpe, $sender, mas você não pode criar um sorteio sem palavra-chave", + "raffle-is-already-running": "Desculpe, $sender, o sorteio já está sendo executado com palavra-chave $keyword", + "no-raffle-is-currently-running": "$sender, nenhum sorteio sem vencedores está em execução", + "no-participants-to-pick-winner": "$sender, ninguém entrou em um sorteio", + "raffle-winner-is": "O vencedor do sorteio $keyword é $username! A probabilidade de vitória foi de $probability%!" + }, + "bets": { + "running": "$sender, a aposta já está aberta! Opções de aposta: $options. Use $command fechar 1-$maxIndex", + "notRunning": "Nenhuma aposta está aberta, peça aos mods para uma!", + "opened": "Nova aposta '$title' está aberta! Opções de aposta: $options. Use $command 1 -$maxIndex para ganhar! Você tem apenas $minutesmin para apostar!", + "closeNotEnoughOptions": "$sender, você precisa selecionar a opção vencedora para fechar a aposta.", + "notEnoughOptions": "$sender, novas apostas precisam de pelo menos 2 opções!", + "info": "Aposta '$title' ainda está aberta! Opções de aposta: $options. Use $command 1 -$maxIndex para ganhar! Você tem apenas $minutesmin para apostar!", + "diffBet": "$sender, você já fez uma aposta em $option e não pode apostar em uma opção diferente!", + "undefinedBet": "Desculpe, $sender, mas esta opção de aposta não existe, use $command para verificar o uso", + "betPercentGain": "Porcentagem de Aposta obtida por opção foi definida como $value%", + "betCloseTimer": "Apostas serão fechadas automaticamente após $valuemin", + "refund": "As apostas foram fechadas sem uma vitória. Todos os usuários são reembolsados!", + "notOption": "$sender, esta opção não existe! Aposta não está fechada, verifique $command", + "closed": "As apostas foram fechadas e a opção vencedora foi $option! $amount usuários ganharam no total de $points $pointsName!", + "timeUpBet": "Acho que você é muito tarde, $sender, seu tempo para apostar acabou!", + "locked": "O tempo de apostas acabou! Não há mais apostas.", + "zeroBet": "Ah, garoto, $sender, você não pode apostar 0 $pointsName", + "lockedInfo": "Aposta '$title' ainda está aberta, mas o tempo para apostar acabou!", + "removed": "Tempo de apostas acabou! Nenhuma aposta foi enviada -> fechando automaticamente", + "error": "Desculpe, $sender, esse comando não está correto! Use $command 1-$maxIndex . Por exemplo, $command 0 100 apostará 100 pontos para o item 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, o apelido $alias não foi encontrado na base de dados", + "alias-was-edited": "$sender, o apelido $alias é alterado para $command", + "alias-was-added": "$sender, o apelido $alias para $command foi adicionado", + "list-is-not-empty": "$sender, lista de apelidos: $list", + "list-is-empty": "$sender, lista de apelidos está vazia", + "alias-was-enabled": "$sender, apelido $alias foi ativado", + "alias-was-disabled": "$sender, alias $alias foi desativado", + "alias-was-concealed": "$sender, apelido $alias foi ocultado", + "alias-was-exposed": "$sender, apelido $alias foi exposto", + "alias-was-removed": "$sender, o alias $alias foi removido", + "alias-group-set": "$sender, o apelido $alias foi definido para o grupo $group", + "alias-group-unset": "$sender, apelido $alias grupo não foi definido", + "alias-group-list": "$sender, lista de grupos de apelidos: $list", + "alias-group-list-aliases": "$sender, lista de apelidos em $group: $list", + "alias-group-list-enabled": "$sender, os apelidos em $group estão ativados.", + "alias-group-list-disabled": "$sender, os apelidos em $group estão desativados." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, o comando $command não foi encontrado no banco de dados", + "response-was-not-found": "$sender, resposta #$response do comando $command não foi encontrada no banco de dados", + "command-was-edited": "$sender, o comando $command foi alterado para '$response'", + "command-was-added": "$sender, comando $command foi adicionado", + "list-is-not-empty": "$sender, lista de comandos: $list", + "list-is-empty": "$sender, lista de comandos está vazia", + "command-was-enabled": "$sender, o comando $command foi ativado", + "command-was-disabled": "$sender, comando $command foi desativado", + "command-was-concealed": "$sender, o comando $command foi ocultado", + "command-was-exposed": "$sender, o comando $command foi exposto", + "command-was-removed": "$sender, o comando $command foi removido", + "response-was-removed": "$sender, a resposta #$response de $command foi removida", + "list-of-responses-is-empty": "$sender, $command não tem resposta ou não existe", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, a palavra-chave $keyword é ambígua, use a ID da palavra-chave", + "keyword-was-not-found": "$sender, palavra-chave $keyword não foi encontrado no banco de dados", + "response-was-not-found": "$sender, resposta #$response da palavra-chave $keyword não foi encontrada no banco de dados", + "keyword-was-edited": "$sender, palavra-chave $keyword foi alterado para '$response'", + "keyword-was-added": "$sender, palavra-chave $keyword ($id) foi adicionada", + "list-is-not-empty": "$sender, lista de palavras-chave: $list", + "list-is-empty": "$sender, lista de palavras-chave está vazia", + "keyword-was-enabled": "$sender, a palavra-chave $keyword foi ativada", + "keyword-was-disabled": "$sender, a palavra-chave $keyword foi desativada", + "keyword-was-removed": "$sender, palavra-chave $keyword foi removida", + "list-of-responses-is-empty": "$sender, $keyword não tem respostas ou não existe", + "response": "$keyword#$index ($permission) $after| $response" + }, + "points": { + "success": { + "undo": "$sender, pontos '$command' para $username foi revertido ($updatedValue $updatedValuePointsLocale a $originalValue $originalValuePointsLocale).", + "set": "$username foi definido para $amount $pointsName", + "give": "$sender acabou de presentear $amount $pointsName para $username", + "online": { + "positive": "Todos os usuários online receberam $amount $pointsName!", + "negative": "Todos os usuários online perderam $amount $pointsName!" + }, + "all": { + "positive": "Todos os usuários acabam de receber $amount $pointsName!", + "negative": "Todos os usuários acabam de perder $amount $pointsName!" + }, + "rain": "Fazendo chover! Todos os usuários online receberam até $amount $pointsName!", + "add": "$username acabou de receber $amount $pointsName!", + "remove": "Ai, $amount $pointsName foi removido de $username!" + }, + "failed": { + "undo": "$sender, nome de usuário não foi encontrado na base de dados ou usuário não pode desfazer operações", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Desculpe, $sender, você não tem $amount $pointsName para dar para $username", + "cannotGiveZeroPoints": "Desculpe, $sender, você não pode dar $amount $pointsName para $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username tem atualmente $amount $pointsName. Sua posição é $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, playlist para importar está vazia", + "playlist-imported": "$sender, importou $imported e pulou $skipped para playlist", + "not-playing": "Não está tocando", + "song-was-banned": "A música $name foi banida e nunca mais será reproduzida!", + "song-was-banned-timeout-message": "Você levou 'time out' por postar música banida", + "song-was-unbanned": "Música desbanida com sucesso", + "song-was-not-banned": "Esta música não foi banida", + "no-song-is-currently-playing": "Não há música tocando atualmente", + "current-song-from-playlist": "Música atual é $name da playlist", + "current-song-from-songrequest": "Música atual é $name solicitada por $username", + "songrequest-disabled": "Desculpe, $sender, solicitações de música estão desativados", + "song-is-banned": "Desculpe, $sender, mas esta música está banida", + "youtube-is-not-responding-correctly": "Desculpe, $sender, mas o YouTube está enviando respostas inesperadas, por favor, tente novamente mais tarde.", + "song-was-not-found": "Desculpe, $sender, mas esta música está banida", + "song-is-too-long": "Desculpe, $sender, mas esta música é muito longa", + "this-song-is-not-in-playlist": "Desculpe, $sender, mas esta música não está na playlist atual", + "incorrect-category": "Desculpe, $sender, mas esta 'faixa' deve ser uma categoria de música", + "song-was-added-to-queue": "$sender, música $name foi adicionada à fila", + "song-was-added-to-playlist": "$sender, música $name foi adicionada à playlist", + "song-is-already-in-playlist": "$sender, música $name já está na playlist", + "song-was-removed-from-playlist": "$sender, música $name removida da playlist", + "song-was-removed-from-queue": "$sender, a sua música $name foi removida da fila", + "playlist-current": "$sender, playlist atual é $playlist.", + "playlist-list": "$sender, playlists disponíveis: $list.", + "playlist-not-exist": "$sender, a playlist solicitada $playlist não existe.", + "playlist-set": "$sender, você mudou a playlist para $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, o preço para $command foi definido para $amount $pointsName", + "price-was-unset": "$sender, o preço para $command não foi removido", + "price-was-not-found": "$sender, preço para $command não foi encontrado", + "price-was-enabled": "$sender, o preço para $command foi ativado", + "price-was-disabled": "$sender, o preço para $command foi desativado", + "user-have-not-enough-points": "Desculpe, $sender, mas você não tem $amount $pointsName para usar $command", + "user-have-not-enough-points-or-bits": "Desculpe, $sender, mas você não tem $amount $pointsName ou usar um comando de resgatar $bitsAmount bits para usar $command", + "user-have-not-enough-bits": "Desculpe, $sender, mas você precisa resgatar o comando de $bitsAmount bits para usar $command", + "list-is-empty": "$sender, lista de preços está vazia", + "list-is-not-empty": "$sender, lista de preços: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank ajuda", + "rank-was-added": "$sender, nova colocação $type $rank($hours$hlocale) foi adicionada", + "rank-was-edited": "$sender, colocação para $type $hours$hlocale foi alterada para $rank", + "rank-was-removed": "$sender, colocação para $type $hours$hlocale foi removida", + "rank-already-exist": "$sender, já existe uma colocação para $type $hours$hlocale", + "rank-was-not-found": "$sender, a colocação para $type $hours$hlocale não foi encontrada", + "custom-rank-was-set-to-user": "$sender, você definiu $rank para $username", + "custom-rank-was-unset-for-user": "$sender, colocação personalizada para $username foi desconfigurada", + "list-is-empty": "$sender, nenhuma colocação foi encontrada", + "list-is-not-empty": "$sender, lista de colocações: $list", + "show-rank-without-next-rank": "$sender, você tem a colocação $rank", + "show-rank-with-next-rank": "$sender, você tem $rank de colocação. Próxima colocação - $nextrank", + "user-dont-have-rank": "$sender, você ainda não tem uma colocação" + }, + "followage": { + "success": { + "never": "$sender, $username não é um seguidor do canal", + "time": "$sender, $username está seguindo o canal $diff" + }, + "successSameUsername": { + "never": "$sender, você não é um seguidor deste canal", + "time": "$sender, você está seguindo este canal por $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username não é um assinante do canal.", + "notNow": "$sender, $username não é atualmente um assinante do canal. No total de $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username é um assinante do canal. Sua atual sequência é de $diff ($subStreak $subStreakMonthsName) e no total de $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username é um assinante do canal. Por um total de $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, você não é um assinante do canal.", + "notNow": "$sender, você não é atualmente um assinante do canal. No total de $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, você é um assinante do canal. Sequência atual de $diff ($subStreak $subStreakMonthsName) e num total de $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, você é assinante do canal. Num total de $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, Não tenho dados para idade da conta $username", + "success": { + "withUsername": "$sender, a idade da conta para $username é $diff", + "withoutUsername": "$sender, a idade de sua conta é $diff" + } + }, + "lastseen": { + "success": { + "never": "$username nunca esteve neste canal!", + "time": "$username foi visto pela última vez em $when neste canal" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username assistiu este canal por $time horas" + }, + "failed": { + "parse": "{core.command-parse} !watched or !watched [username]" + } + }, + "permissions": { + "without-permission": "Você não tem permissão para executar '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, usuário $username tem $type de imunidade por $time segundos", + "user-have-immunity-parameterError": "$sender, erro de parâmetro. $command ", + "user-have-link-permit": "O usuário $username pode postar $count $link no bate-papo", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "Links não são permitidos, peça !permit [$count avisos restantes]", + "user-is-warned-about-symbols": "Não use símbolos excessivamente [$count warnings left]", + "user-is-warned-about-long-message": "Mensagens longas não são permitidas [$count warnings left]", + "user-is-warned-about-caps": "Não use caps excessivos [$count advertências restantes]", + "user-is-warned-about-spam": "Spamming não é permitido [$count advertências restantes]", + "user-is-warned-about-color": "Itálico e /me não é permitido [$count advertências restantes]", + "user-is-warned-about-emotes": "Sem spam de emote [$count advertências restantes]", + "user-is-warned-about-forbidden-words": "Sem palavras proibidas [$count advertências restantes]", + "user-have-timeout-for-links": "Links não são permitidos, peça antes !permit", + "user-have-timeout-for-symbols": "Sem uso excessivo de símbolos", + "user-have-timeout-for-long-message": "Mensagens longas não são permitidas", + "user-have-timeout-for-caps": "Sem uso excessivo de caps", + "user-have-timeout-for-spam": "Não é permitido spam", + "user-have-timeout-for-color": "Itálico e /me não são permitidos", + "user-have-timeout-for-emotes": "Sem spamming de emotes", + "user-have-timeout-for-forbidden-words": "Sem palavras proibidas" + }, + "queue": { + "list": "$sender, banco de fila atual: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Desculpe $sender, a fila está fechada no momento", + "opened": "$sender foi adicionado na fila" + }, + "open": "A fila está liberada! Junte à fila com !queue join", + "close": "A fila está atualmente fechada!", + "clear": "Filas foram completamente limpa", + "picked": { + "single": "Este usuário foi escolhido da fila: $users", + "multi": "Estes usuários foram escolhidos da fila: $users", + "none": "Nenhum usuário foi encontrado na fila" + } + }, + "marker": "Marcador de transmissão foi criado em $time.", + "title": { + "current": "$sender, o título da transmissão é '$title'.", + "change": { + "success": "$sender, título foi definido para: $title" + } + }, + "game": { + "current": "$sender, streamer está atualemtne jogando $game.", + "change": { + "success": "$sender, categoria foi alterada para: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type cooldown para $command foi definido para $secondss", + "cooldown-was-unset": "$sender, 'cooldown' para $command foi removido", + "cooldown-triggered": "$sender, '$command' está em 'coodown', restando $secondss", + "cooldown-not-found": "$sender, 'cooldown' para $command não foi encontrado", + "cooldown-was-enabled": "$sender, cooldown para $command foi ativado", + "cooldown-was-disabled": "$sender, cooldown para $command foi desativado", + "cooldown-was-enabled-for-moderators": "$sender, cooldown para $command foi ativado para moderadores", + "cooldown-was-disabled-for-moderators": "$sender, cooldown para $command foi desativado para moderadores", + "cooldown-was-enabled-for-owners": "$sender, cooldown para $command foi ativado para proprietários", + "cooldown-was-disabled-for-owners": "$sender, coodown para $command foi desativado para proprietários", + "cooldown-was-enabled-for-subscribers": "$sender, cooldown para $command foi ativado para assinantes", + "cooldown-was-disabled-for-subscribers": "$sender, cooldown para $command foi desativado para inscritos", + "cooldown-was-enabled-for-followers": "$sender, cooldown para $command foi ativado para seguidores", + "cooldown-was-disabled-for-followers": "$sender, cooldown para $command foi desativado para seguidores" + }, + "timers": { + "id-must-be-defined": "$sender, Id da resposta deve ser definida.", + "id-or-name-must-be-defined": "$sender, Id da resposta ou nome do temporizador devem ser definidos.", + "name-must-be-defined": "$sender, o nome do temporizador deve ser definido.", + "response-must-be-defined": "$sender, a resposta do temporizador deve ser definida.", + "cannot-set-messages-and-seconds-0": "$sender, você não pode definir ambas as mensagens e segundos para 0.", + "timer-was-set": "$sender, o temporizador $name foi definido com $messages mensagens e $seconds segundos para acionar", + "timer-was-set-with-offline-flag": "$sender, o cronômetro $name foi definido com $messages mensagens e $seconds segundos para acionar mesmo quando a transmissão está offline", + "timer-not-found": "$sender, temporizador (nome: $name) não foi encontrado no banco de dados. Verificar os temporizadores na lista !timers", + "timer-deleted": "$sender, temporizador $name e suas respostas foram excluídas.", + "timer-enabled": "$sender, temporizador (nome: $name) foi ativado", + "timer-disabled": "$sender, temporizador (nome: $name) foi desativado", + "timers-list": "$sender, lista de temporizadores: $list", + "responses-list": "$sender, lista do temporizador (nome: $name)", + "response-deleted": "$sender, a resposta (id: $id) foi excluída.", + "response-was-added": "$sender, resposta (id: $id) para o temporizador (nome: $name) foi adicionado - '$response'", + "response-not-found": "$sender, resposta (id: $id) não foi encontrada na base de dados", + "response-enabled": "$sender, resposta (id: $id) foi ativada", + "response-disabled": "$sender, resposta (id: $id) foi desativada" + }, + "gambling": { + "duel": { + "bank": "$sender, banco atual para $command é $points $pointsName", + "lowerThanMinimalBet": "$sender, a aposta mínima para $command é $points $pointsName", + "cooldown": "$sender, você não pode usar $command por $cooldown $minutesName.", + "joined": "$sender, boa sorte com suas habilidades de duelo. Você apostou em si mesmo $points $pointsName!", + "added": "$sender realmente acha que é melhor que outros, aumentando a aposta para $points $pointsName!", + "new": "$sender é o seu novo desafiante no duelo! Para participar use $command [points], você tem $minutes $minutesName restante para entrar.", + "zeroBet": "$sender, você não pode duelar 0 $pointsName", + "notEnoughOptions": "$sender, você precisa especificar os pontos para o duelo", + "notEnoughPoints": "$sender, você não tem $points $pointsName para duelo!", + "noContestant": "Só $winner têm coragem de entrar no duelo! Sua aposta de $points $pointsName foi devolvida para você.", + "winner": "Parabéns a $winner! Ele é o último homem em pé e ganhou $points $pointsName ($probability% com a aposta de $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender está tentando sua sorte e puxou um gatilho", + "alive": "$sender está vivo! Nada aconteceu.", + "dead": "O cérebro de $sender foi espirrado na parede!", + "mod": "$sender é incompetente e perdeu completamente sua cabeça!", + "broadcaster": "$sender está usando festim, boo!", + "timeout": "Tempo limite da roleta definido para $values" + }, + "gamble": { + "chanceToWin": "$sender, a chance de ganhar !gamble definido para $value%", + "zeroBet": "$sender, você não pode apostar 0 $pointsName", + "minimalBet": "$sender, aposta mínima para !gamble está definida como $value", + "lowerThanMinimalBet": "$sender, aposta mínima para !gamble é $points $pointsName", + "notEnoughOptions": "$sender, você precisa especificar os pontos de aposta", + "notEnoughPoints": "$sender, você não tem $points $pointsName para apostar", + "win": "$sender, você VENCEU! Você agora tem $points $pointsName", + "winJackpot": "$sender, você atingiu o JACKPOT! Você ganhou $jackpot $jackpotName além da sua aposta. Agora você tem $points $pointsName", + "loseWithJackpot": "$sender, você PERDEU! Agora você tem $points $pointsName. Jackpot aumentou para $jackpot $jackpotName", + "lose": "$sender, você PERDEU! Você agora tem $points $pointsName", + "currentJackpot": "$sender, jackpot atual para $command é $points $pointsName", + "winJackpotCount": "$sender, você ganhou $count jackpots", + "jackpotIsDisabled": "$sender, jackpot está desativado para $command." + } + }, + "highlights": { + "saved": "$sender, o destaque foi salvo em $hoursh$minutos$secondss", + "list": { + "items": "$sender, lista de destaques salvos para a streaming mais recente: $items", + "empty": "$sender, nenhum destaque foi salvo" + }, + "offline": "$sender, não pode salvar destaque, transmissão está offline" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "Bot não enviará erros de permissões insuficientes", + "false": "Bot não enviará erros de permissões insuficientes via sussurros" + }, + "disableCooldownWhispers": { + "true": "O Bot não enviará notificações de 'cooldown'", + "false": "O bot irá enviar notificações de 'cooldown' via sussurros" + } + } + }, + "time": "Hora atual no fuso horário da/do streamer é $time", + "subs": "$sender, atualmente há $onlineSubCount assinantes online. Último sub/resub foi $lastSubUsername $lastSubAgo", + "followers": "$sender, a última pessoa que seguiu foi $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, o usuário $username não é ignorado pelo bot" + }, + "added": "$sender, usuário $username foi adicionado à lista-para-ignorar do bot", + "removed": "$sender, o usuário $username foi removido da lista-para-ignorar do bo", + "ignored": "$sender, o usuário $username é ignorado pelo bot" + } + } + }, + "filters": { + "setVariable": "$sender, $variable foi definido para $value." + } +} diff --git a/backend/locales/pt/api.clips.json b/backend/locales/pt/api.clips.json new file mode 100644 index 000000000..3308d1202 --- /dev/null +++ b/backend/locales/pt/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "O clipe foi criado e está disponível em $link" +} \ No newline at end of file diff --git a/backend/locales/pt/core/permissions.json b/backend/locales/pt/core/permissions.json new file mode 100644 index 000000000..b8f53bd2d --- /dev/null +++ b/backend/locales/pt/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "Lista de suas permissões:", + "excludeAddSuccessful": "$sender, você adicionou $username na lista de exclusão para permissão $permissionName", + "excludeRmSuccessful": "$sender, você removeu $username da lista de exclusão para permissão $permissionName", + "userNotFound": "$sender, o usuário $username não foi encontrado na base de dados.", + "permissionNotFound": "$sender, permissão $userlevel não foi encontrada na base de dados.", + "cannotIgnoreForCorePermission": "$sender, você não pode excluir manualmente o usuário para permissão core $userlevel" +} \ No newline at end of file diff --git a/backend/locales/pt/games.heist.json b/backend/locales/pt/games.heist.json new file mode 100644 index 000000000..0595be7f7 --- /dev/null +++ b/backend/locales/pt/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, policiais ainda estão procurando pela última equipe de assalto. Tente novamente após $cooldown.", + "copsCooldownMessage": "Muito bem galera, parece que as forças policiais estão comendo rosquinhas e nós podemos obter esse dinheiro!", + "entryMessage": "$sender começou a planejar um assalto ao banco! Procurando uma equipe maior para obter uma pontuação maior. Junte-se a nós! Digite $command para entrar.", + "lateEntryMessage": "$sender, o assalto está em progresso!", + "entryInstruction": "$sender, digite $command para entrar.", + "levelMessage": "Com esta equipe, podemos tomar $bank! Vamos ver se conseguimos equipe suficiente para assaltar $nextBank", + "maxLevelMessage": "Com esta equipe, podemos tomar $bank! Não poderia ser melhor!", + "started": "Muito bem galera, verifiquem seu equipamentos, é pra isso que treinamos. Este não é um jogo, isso é vida real. Vamos pegar a grana de $bank!", + "noUser": "Ninguém se juntou a uma equipe para assaltar.", + "singleUserSuccess": "$user foi como um ninja. Ninguém percebeu que falta dinheiro.", + "singleUserFailed": "$user falhou em se livrar da polícia e vai passar um tempo na cadeia.", + "result": { + "0": "Todos foram aniquilados sem piedade. Isso foi chacina.", + "33": "Apenas 1/3 da equipe obtém seu dinheiro do assalto.", + "50": "Metade da equipe de assalto foi morta ou capturada pela polícia.", + "99": "Algumas perdas da equipe em nada se comparam com o montante que os outros agora têm em seus bolsos.", + "100": "Deus divindade, ninguém morreu, todos ganharam!" + }, + "levels": { + "bankVan": "Van do banco", + "cityBank": "Banco Municipal", + "stateBank": "Banco estadual", + "nationalReserve": "Reserva nacional", + "federalReserve": "Reserva Federal" + }, + "results": "Aqueles recebendo sua parte no assalto são: $users", + "andXMore": "e $count mais..." +} \ No newline at end of file diff --git a/backend/locales/pt/integrations/discord.json b/backend/locales/pt/integrations/discord.json new file mode 100644 index 000000000..773f8d829 --- /dev/null +++ b/backend/locales/pt/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "sua conta não está vinculada, use `$command`", + "all-your-links-were-deleted": "todas as suas contas foram desvinculadas", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender, sua conta foi vinculada com $discordTag.", + "invalid-or-expired-token": "$sender, token inválido ou expirado.", + "help-message": "$sender, para vincular sua conta no Discord: 1. Vá ao servidor Discord server e envie $command no canal de bot. | 2. Aguarde a MP do bot | 3. Envie o comando de sua MP do Discord aqui no chat da Twitch.", + "started-at": "Iniciou em", + "announced-by": "Anunciado por sogeBot", + "streamed-at": "Transmitido em", + "link-whisper": "Olá $tag, para vincular sua conta no Discord com sua conta da Twitch no canal $broadcaster, acesse , faça o login na sua conta e envie este comando no chat \n\n\t\t`$command $id`\n\nOBS: expira em 10 minutos.", + "check-your-dm": "verifique seu privado para vincular sua conta." +} \ No newline at end of file diff --git a/backend/locales/pt/integrations/lastfm.json b/backend/locales/pt/integrations/lastfm.json new file mode 100644 index 000000000..8f0e72cfb --- /dev/null +++ b/backend/locales/pt/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "A música atual é $name" +} \ No newline at end of file diff --git a/backend/locales/pt/integrations/obswebsocket.json b/backend/locales/pt/integrations/obswebsocket.json new file mode 100644 index 000000000..6b8cdcdf1 --- /dev/null +++ b/backend/locales/pt/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, não há nenhuma ação definida para id:$id!", + "ParameterError": "$sender, você precisa especificar o id!", + "UnknownError": "$sender, algo deu errado. Verifique os logs do bot para informações adicionais." + } +} \ No newline at end of file diff --git a/backend/locales/pt/integrations/protondb.json b/backend/locales/pt/integrations/protondb.json new file mode 100644 index 000000000..4636c2480 --- /dev/null +++ b/backend/locales/pt/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | $rating de avaliação | Nativo no $native | Detalhes: $url", + "responseNg": "A avaliação para o jogo $game não foi encontrada no ProtonDB.", + "responseNotFound": "O jogo $game não foi encontrado no ProtonDB." +} \ No newline at end of file diff --git a/backend/locales/pt/integrations/pubg.json b/backend/locales/pt/integrations/pubg.json new file mode 100644 index 000000000..70547a5b6 --- /dev/null +++ b/backend/locales/pt/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, esperava um destes parâmetros: $list" +} \ No newline at end of file diff --git a/backend/locales/pt/integrations/spotify.json b/backend/locales/pt/integrations/spotify.json new file mode 100644 index 000000000..3e1e4c425 --- /dev/null +++ b/backend/locales/pt/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "Desculpe, $sender, não consigo encontrar esta música no Spotify", + "song-requested": "$sender, você solicitou a música $name de $artist", + "not-banned-song-not-playing": "$sender, nenhuma música está tocando para banir agora.", + "song-banned": "$sender, música $name de $artist está banida.", + "song-unbanned": "$sender, música $name de $artist foi desbanida.", + "song-not-found-in-banlist": "$sender, música de spotifyURI $uri não foi encontrada na lista de banimento.", + "cannot-request-song-is-banned": "$sender, não é possível solicitar a música $name banida de $artist.", + "cannot-request-song-from-unapproved-artist": "$sender, não é possível solicitar música de artista não aprovado.", + "no-songs-found-in-history": "$sender, atualmente não há música na lista de histórico.", + "return-one-song-from-history": "$sender, a música anterior era $name de $artist.", + "return-multiple-song-from-history": "$sender, $count músicas anteriores foram:", + "return-multiple-song-from-history-item": "$index - $name de $artist", + "song-notify": "A música que está tocando é $name por $artist." +} \ No newline at end of file diff --git a/backend/locales/pt/integrations/tiltify.json b/backend/locales/pt/integrations/tiltify.json new file mode 100644 index 000000000..6a0d094e7 --- /dev/null +++ b/backend/locales/pt/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, não há campanhas ativas no momento.", + "active_campaigns": "$sender, lista das campanhas ativas no momento:" +} \ No newline at end of file diff --git a/backend/locales/pt/systems.quotes.json b/backend/locales/pt/systems.quotes.json new file mode 100644 index 000000000..50bca10c5 --- /dev/null +++ b/backend/locales/pt/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, citação $id '$quote' foi adicionada. (tags: $tags)", + "error": "$sender, $command não é correto ou falta o parâmetro -quote" + }, + "remove": { + "ok": "$sender, citação $id foi excluída com sucesso.", + "error": "$sender, ID da citação está faltando.", + "not-found": "$sender, citação $id não foi encontrada." + }, + "show": { + "ok": "Cotação $id por $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command está faltando -id ou -tag.", + "not-found-by-id": "$sender, citação $id não foi encontrada.", + "not-found-by-tag": "$sender, não foram encontradas citações com a tag $tag." + } + }, + "set": { + "ok": "$sender, citação $id teve suas tags definidas. (tags: $tags)", + "error": { + "no-parameters": "$sender, $command está faltando -id ou -tag.", + "not-found-by-id": "$sender, citação $id não foi encontrada." + } + }, + "list": { + "ok": "$sender, Você pode encontrar a lista de citações em http://$urlBase/public/#/quotes", + "is-localhost": "$sender, a URL da lista de citação não foi especificada corretamente." + } +} \ No newline at end of file diff --git a/backend/locales/pt/systems/antihateraid.json b/backend/locales/pt/systems/antihateraid.json new file mode 100644 index 000000000..2f906e266 --- /dev/null +++ b/backend/locales/pt/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "Este chat foi definido para $mode por $username para se livrar do ataque do ódio. Desculpe a inconveniência!", + "mode": { + "0": "somente-inscritos", + "1": "somente-seguidores", + "2": "somente-emotes" + } +} \ No newline at end of file diff --git a/backend/locales/pt/systems/howlongtobeat.json b/backend/locales/pt/systems/howlongtobeat.json new file mode 100644 index 000000000..04f4c1554 --- /dev/null +++ b/backend/locales/pt/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game não encontrado no db.", + "game": "$sender, $game | Principal: $currentMain/$hltbMainh - $percentMain% | Principal+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Detalhista: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Principal: $currentMainh | Principal+Extra: $currentMainExtrah | Detalhista: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/pt/systems/levels.json b/backend/locales/pt/systems/levels.json new file mode 100644 index 000000000..392bbae5a --- /dev/null +++ b/backend/locales/pt/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, nível: $currentLevel ($currentXP $xpName), $nextXP $xpName para o próximo nível.", + "changeXP": "$sender, você mudou $xpName por $amount $xpName para $username.", + "notEnoughPointsToBuy": "Desculpe $sender, mas você não tem $points $pointsName para comprar $amount $xpName para o nível $level.", + "XPBoughtByPoints": "$sender, você comprou $amount $xpName com $points $pointsName e atingiu o nível $level.", + "somethingGetWrong": "$sender, algo deu errado com sua requisição." +} \ No newline at end of file diff --git a/backend/locales/pt/systems/scrim.json b/backend/locales/pt/systems/scrim.json new file mode 100644 index 000000000..5b5138337 --- /dev/null +++ b/backend/locales/pt/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Jogo de snipe ($type) começando em $time $unit", + "go": "Começando agora! Vai!", + "putMatchIdInChat": "Por favor coloque o ID de sua partida no chat => $command xxx", + "currentMatches": "Partidas Atuais: $matches", + "stopped": "A partida de snipe foi cancelada." +} \ No newline at end of file diff --git a/backend/locales/pt/systems/top.json b/backend/locales/pt/systems/top.json new file mode 100644 index 000000000..4b607a29a --- /dev/null +++ b/backend/locales/pt/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Top $amount (tempo assistido): ", + "tips": "Top $amount (doações): ", + "level": "Top $amount (nível): ", + "points": "Top $amount (pontos): ", + "messages": "Top $amount (mensagens): ", + "followage": "Top $amount (seguidores antigos): ", + "subage": "Top $amount (subscribers antigos): ", + "submonths": "Top $amount (tempo de sub): ", + "bits": "Top $amount (bits): ", + "gifts": "Top $amount (doadores de sub): " +} \ No newline at end of file diff --git a/backend/locales/pt/ui.commons.json b/backend/locales/pt/ui.commons.json new file mode 100644 index 000000000..8d18e8d38 --- /dev/null +++ b/backend/locales/pt/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Configurações adicionais", + "never": "nunca", + "reset": "redefinir", + "moveUp": "mova para cima", + "moveDown": "mova para baixo", + "stop-if-executed": "parar, se executado", + "continue-if-executed": "continuar, se executado", + "generate": "Gerado", + "thumbnail": "Miniatura", + "yes": "Sim", + "no": "Não", + "show-more": "Mostrar mais", + "show-less": "Mostrar menos", + "allowed": "Permitido", + "disallowed": "Não permitido", + "back": "Voltar" +} diff --git a/backend/locales/pt/ui.dialog.json b/backend/locales/pt/ui.dialog.json new file mode 100644 index 000000000..db854a871 --- /dev/null +++ b/backend/locales/pt/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Editar", + "add": "Adicionar" + }, + "position": { + "settings": "Configurações de posição", + "anchorX": "Posição X da Âncora", + "anchorY": "Posição Y da âncora", + "left": "Esquerda", + "right": "Direita", + "middle": "Meio", + "top": "Superior", + "bottom": "Inferior", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Deslocar à direita", + "shadowShiftDown": "Mover para Baixo", + "shadowBlur": "Desfoque", + "shadowOpacity": "Opacidade", + "color": "Cor" + }, + "errors": { + "required": "Este campo não pode ficar vazio.", + "minValue": "O menor valor desta entrada é $value." + }, + "buttons": { + "reorder": "Reordenar", + "upload": { + "idle": "Enviar", + "progress": "Enviando", + "done": "Enviado" + }, + "cancel": "Cancelar", + "close": "Fechar", + "test": { + "idle": "Teste", + "progress": "Teste em andamento", + "done": "Testando concluído" + }, + "saveChanges": { + "idle": "Salvar alterações", + "invalid": "Não é possível salvar alterações", + "progress": "Salvando alterações", + "done": "Alterações salvas" + }, + "something-went-wrong": "Alguma coisa deu errado", + "mark-to-delete": "Marcar para apagar", + "disable": "Desativar", + "enable": "Ativar", + "disabled": "Desativado", + "enabled": "Ativado", + "edit": "Editar", + "delete": "Deletar", + "play": "Tocar", + "stop": "Parar", + "hold-to-delete": "Segure para excluir", + "yes": "Sim", + "no": "Não", + "permission": "Permissão", + "group": "Grupo", + "visibility": "Visibilidade", + "reset": "Redefinir " + }, + "changesPending": "Suas alterações não foram salvas.", + "formNotValid": "Formulário inválido.", + "nothingToShow": "Nada para mostrar aqui." +} \ No newline at end of file diff --git a/backend/locales/pt/ui.menu.json b/backend/locales/pt/ui.menu.json new file mode 100644 index 000000000..25873c8e8 --- /dev/null +++ b/backend/locales/pt/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Serviços", + "updater": "Atualizador", + "index": "Painel", + "core": "Bot", + "users": "Usuários", + "tmi": "TMI", + "ui": "UI", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "Geral", + "timers": "Temporizadores", + "new": "Novo Item", + "keywords": "Palavras-chave", + "customcommands": "Comandos personalizados", + "botcommands": "Comandos do bot", + "commands": "Comandos", + "events": "Eventos", + "ranks": "Colocações", + "songs": "Músicas", + "modules": "Módulos", + "viewers": "Espectadores", + "alias": "Apelidos", + "cooldowns": "'Cooldowns'", + "cooldown": "'Cooldown'", + "highlights": "Destaques", + "price": "Preço", + "logs": "Registros", + "systems": "Sistemas", + "permissions": "Permissões", + "translations": "Traduções personalizadas", + "moderation": "Moderação", + "overlays": "Overlays", + "gallery": "Galeria de mídia", + "games": "Jogos", + "spotify": "Spotify", + "integrations": "Integrações", + "customvariables": "Variáveis personalizadas", + "registry": "Registro", + "quotes": "Citações", + "settings": "Configurações", + "commercial": "Comercial", + "bets": "Apostas", + "points": "Pontos", + "raffles": "Sorteios", + "queue": "Fila", + "playlist": "Playlist", + "bannedsongs": "Músicas banidas", + "spotifybannedsongs": "Músicas banidas do Spotify", + "duel": "Duelar", + "fightme": "LuteComigo", + "seppuku": "Seppuku", + "gamble": "Aposta", + "roulette": "Roleta", + "heist": "Roubo", + "oauth": "OAuth", + "socket": "Socket", + "carouseloverlay": "Overlay de Carrossel", + "alerts": "Alertas", + "carousel": "Carrossel de Imagens", + "clips": "Clipes", + "credits": "Créditos", + "emotes": "Emotes", + "stats": "Estatísticas", + "text": "Texto", + "currency": "Unidade monetária", + "eventlist": "Lista de eventos", + "clipscarousel": "Carrossel de clipes", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Checklist", + "bot": "Bot", + "api": "API", + "manage": "Gerenciar", + "top": "Superior", + "goals": "Objetivos", + "userinfo": "Informações do usuário", + "scrim": "Scrim", + "commandcount": "Contagem de comando", + "profiler": "Perfilador", + "howlongtobeat": "How long to beat", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Aleatorizador", + "tips": "Doações", + "bits": "Bits", + "discord": "Discord", + "texttospeech": "Texto para fala", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "Níveis", + "obswebsocket": "OBS Websocket", + "api-explorer": "Explorador de API", + "emotescombo": "Combo de Emotes", + "notifications": "Notificações", + "plugins": "Plugins", + "tts": "TTS" +} diff --git a/backend/locales/pt/ui.page.settings.overlays.carousel.json b/backend/locales/pt/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..fe0f28547 --- /dev/null +++ b/backend/locales/pt/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "opções", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Tem certeza que deseja excluir esta imagem?" + }, + "button": { + "update": "Atualize", + "fix_your_errors_first": "Corrigir erros antes de salvar" + }, + "errors": { + "number_greater_or_equal_than_0": "Valor deve ser um número >= 0", + "value_must_not_be_empty": "O valor não pode estar vazio" + }, + "titles": { + "waitBefore": "Aguarde antes da imagem ser exibida (em ms)", + "waitAfter": "Esperar após a imagem desaparecer (em ms)", + "duration": "Quanto tempo a imagem deve ser exibida (em ms)", + "animationIn": "Animação de entrada", + "animationOut": "Animação de saída", + "animationInDuration": "Duração da animação de entrada (em ms)", + "animationOutDuration": "Duração da Animação de saída (em ms)", + "showOnlyOncePerStream": "Mostrar apenas uma vez por stream" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.registry.customvariables.json b/backend/locales/pt/ui.registry.customvariables.json new file mode 100644 index 000000000..8b8819bca --- /dev/null +++ b/backend/locales/pt/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "URLs", + "generateurl": "Gerar uma nova URL", + "show-examples": "mostra exemplos CURL", + "response": { + "show": "Exibir resposta após POST", + "name": "Resposta após definir variável", + "default": "Padrão", + "default-placeholder": "Definir resposta do seu bot", + "default-help": "Use $value para obter novo valor da variável", + "custom": "Personalizado", + "command": "Comando" + }, + "useIfInCommand": "Use se você usar a variável no comando. Retornará somente a variável atualizada sem resposta.", + "permissionToChange": "Permissão para modicar", + "isReadOnly": "somente leitura no chat", + "isNotReadOnly": "pode ser alterado através do bate-papo", + "no-variables-found": "Nenhuma variável encontrada", + "additional-info": "Informação adicional", + "run-script": "Executar o script", + "last-run": "Última data de execução", + "variable": { + "name": "Nome da variável", + "help": "O nome da variável deve ser único, por exemplo, $_wins, $_loses, $_top3", + "placeholder": "Digite o nome único da variável", + "error": { + "isNotUnique": "A variável precisa ter um nome único.", + "isEmpty": "O nome da variável não pode estar vazio." + } + }, + "description": { + "name": "Descrição", + "help": "Descrição opcional", + "placeholder": "Digite sua descrição opcional" + }, + "type": { + "name": "Tipo", + "error": { + "isNotSelected": "Por favor, escolha um tipo variável." + } + }, + "currentValue": { + "name": "Valor atual", + "help": "Se o tipo é definido como script Avaliado, o valor não pode ser alterado manualmente" + }, + "usableOptions": { + "name": "Opções utilizáveis", + "placeholder": "Entrada, sua, opções, aqui", + "help": "Opções que podem ser usadas com esta variável, exemplo: SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "Você precisa definir ao menos 1 valor." + } + }, + "scriptToEvaluate": "Script para avaliar", + "runScript": { + "name": "Executar script", + "error": { + "isNotSelected": "Por favor, selecione uma opção." + } + }, + "testCurrentScript": { + "name": "Testar script atual", + "help": "Clique em Testar o script atual para ver o valor na entrada Atual" + }, + "history": "Histórico", + "historyIsEmpty": "Histórico para esta variável está vazio!", + "warning": "Aviso: Todos os dados desta variável serão descartados!", + "choose": "Escolha...", + "types": { + "number": "Número", + "text": "Texto", + "options": "Opções", + "eval": "Script" + }, + "runEvery": { + "isUsed": "Quando a variável é usada" + } +} + diff --git a/backend/locales/pt/ui.systems.antihateraid.json b/backend/locales/pt/ui.systems.antihateraid.json new file mode 100644 index 000000000..cf511c7ef --- /dev/null +++ b/backend/locales/pt/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Limpar Chat", + "mode": "Modo", + "minFollowTime": "Tempo mínimo de follow", + "customAnnounce": "Personalizar anúncios anti hate-raid ativo" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.bets.json b/backend/locales/pt/ui.systems.bets.json new file mode 100644 index 000000000..8af5ca88c --- /dev/null +++ b/backend/locales/pt/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Situação", + "betPercentGain": "Adicione x% para apostar pagamento a cada opção" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.commercial.json b/backend/locales/pt/ui.systems.commercial.json new file mode 100644 index 000000000..eb2c28502 --- /dev/null +++ b/backend/locales/pt/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Situação" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.cooldown.json b/backend/locales/pt/ui.systems.cooldown.json new file mode 100644 index 000000000..f62512629 --- /dev/null +++ b/backend/locales/pt/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Notificar como sussurro", + "settings": { + "enabled": "Situação", + "cooldownNotifyAsWhisper": "Informações de intervalo do sussurro", + "cooldownNotifyAsChat": "Informações de interfalo de mensagens", + "defaultCooldownOfCommandsInSeconds": "Tempo de espera padrão para comandos (em segundos)", + "defaultCooldownOfKeywordsInSeconds": "Tempo de espera padrão para palavras-chave (em segundos)" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.customcommands.json b/backend/locales/pt/ui.systems.customcommands.json new file mode 100644 index 000000000..c4aeac9a4 --- /dev/null +++ b/backend/locales/pt/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "Sem resposta", + "addResponse": "Adicionar Resposta", + "response": { + "name": "Resposta", + "placeholder": "Defina sua resposta aqui." + }, + "filter": { + "name": "filtrar", + "placeholder": "Adicionar filtro para esta resposta" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.highlights.json b/backend/locales/pt/ui.systems.highlights.json new file mode 100644 index 000000000..2aef403d1 --- /dev/null +++ b/backend/locales/pt/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "urls": "URLs geradas" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.moderation.json b/backend/locales/pt/ui.systems.moderation.json new file mode 100644 index 000000000..d84b0559c --- /dev/null +++ b/backend/locales/pt/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Situação", + "cListsEnabled": "Impor a regra", + "cLinksEnabled": "Impor a regra", + "cSymbolsEnabled": "Impor a regra", + "cLongMessageEnabled": "Impor a regra", + "cCapsEnabled": "Impor a regra", + "cSpamEnabled": "Impor a regra", + "cColorEnabled": "Impor a regra", + "cEmotesEnabled": "Impor a regra", + "cListsWhitelist": { + "title": "Palavras permitidas", + "help": "Para permitir domínios, use \"domain:prtzl.io\"" + }, + "autobanMessages": "Mensagens de Autoban", + "cListsBlacklist": "Palavras proibidas", + "cListsTimeout": "Duração do tempo limite", + "cLinksTimeout": "Duração do tempo limite", + "cSymbolsTimeout": "Duração do tempo limite", + "cLongMessageTimeout": "Duração do tempo limite", + "cCapsTimeout": "Duração do tempo limite", + "cSpamTimeout": "Duração do tempo limite", + "cColorTimeout": "Duração do tempo limite", + "cEmotesTimeout": "Duração do tempo limite", + "cWarningsShouldClearChat": "Deve limpar o chat (expirará em 1s)", + "cLinksIncludeSpaces": "Incluir espaços", + "cLinksIncludeClips": "Incluir clipes", + "cSymbolsTriggerLength": "Gatilho de comprimento da mensagem", + "cLongMessageTriggerLength": "Gatilho de comprimento da mensagem", + "cCapsTriggerLength": "Gatilho de comprimento da mensagem", + "cSpamTriggerLength": "Gatilho de comprimento da mensagem", + "cSymbolsMaxSymbolsConsecutively": "Max de símbolos consecutivamente", + "cSymbolsMaxSymbolsPercent": "Símbolos máximos %", + "cCapsMaxCapsPercent": "Max de caps %", + "cSpamMaxLength": "Comprimento máximo", + "cEmotesMaxCount": "Contagem máxima", + "cWarningsAnnounceTimeouts": "Anunciar timeouts no chat para todos", + "cWarningsAllowedCount": "Contagem de avisos", + "cEmotesEmojisAreEmotes": "Tratar Emojis como Emotes" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.points.json b/backend/locales/pt/ui.systems.points.json new file mode 100644 index 000000000..3c827a118 --- /dev/null +++ b/backend/locales/pt/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Situação", + "name": { + "title": "Nome", + "help": "Possíveis formatos:
ponto|pontos
bod|4:body|bodu" + }, + "isPointResetIntervalEnabled": "Intervalo de pontos redefinidos", + "resetIntervalCron": { + "name": "Intervalo do Cron", + "help": "CronTab generator" + }, + "interval": "Intervalo de minutos para adicionar pontos aos usuários online quando a stream estiver online", + "offlineInterval": "Intervalo de minutos para adicionar pontos aos usuários online quando a stream estiver offline", + "messageInterval": "Quantas mensagens adicionarão pontos", + "messageOfflineInterval": "Quantas mensagens para adicionar pontos quando o streaming está offline", + "perInterval": "Quantos pontos adicionar por intervalo online", + "perOfflineInterval": "Quantos pontos adicionar por intervalo offline", + "perMessageInterval": "Quantos pontos adicionar por intervalo de mensagem", + "perMessageOfflineInterval": "Quantos pontos adicionar por intervalo offline de mensagens" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.price.json b/backend/locales/pt/ui.systems.price.json new file mode 100644 index 000000000..0c12b7ed6 --- /dev/null +++ b/backend/locales/pt/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Disparar alertas personalizados ao resgatar bits", + "price": { + "name": "preço", + "placeholder": "" + }, + "error": { + "isEmpty": "Este valor não pode ficar vazio" + }, + "warning": "Esta ação não pode ser revertida!", + "settings": { + "enabled": "Situação" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.queue.json b/backend/locales/pt/ui.systems.queue.json new file mode 100644 index 000000000..14a0db3f5 --- /dev/null +++ b/backend/locales/pt/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Situação", + "eligibilityAll": "Todos", + "eligibilityFollowers": "Seguidores", + "eligibilitySubscribers": "Inscritos" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.quotes.json b/backend/locales/pt/ui.systems.quotes.json new file mode 100644 index 000000000..5a40dc732 --- /dev/null +++ b/backend/locales/pt/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "Desculpe, nenhuma citação foi encontrada no banco de dados.", + "new": "Adicionar nova citação", + "empty": "A lista de citações está vazia, crie uma nova citação.", + "emptyAfterSearch": "Lista de citações está vazia na busca por \"$search\"", + "quote": { + "name": "Citação", + "placeholder": "Defina sua citação aqui" + }, + "by": { + "name": "Citação por" + }, + "tags": { + "name": "Tags", + "placeholder": "Defina suas tags aqui", + "help": "Tags separadas por vírgula. Exemplo: tag 1, tag 2, tag 3" + }, + "date": { + "name": "Data" + }, + "error": { + "isEmpty": "Este valor não pode ficar vazio", + "atLeastOneTag": "É preciso definir pelo menos uma tag" + }, + "tag-filter": "Filtrando por tag", + "warning": "Não é possível reverter esta ação!", + "settings": { + "enabled": "Situação", + "urlBase": { + "title": "Base de URL", + "help": "Você deve usar um endpoint público para citações, para ser acessível por todos" + } + } +} diff --git a/backend/locales/pt/ui.systems.raffles.json b/backend/locales/pt/ui.systems.raffles.json new file mode 100644 index 000000000..e2535b475 --- /dev/null +++ b/backend/locales/pt/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Sorte dos assinantes" + }, + "settings": { + "enabled": "Situação", + "announceNewEntries": { + "title": "Anunciar novas entradas", + "help": "Se usuários se juntarem ao sorteio, a mensagem de anúncio será enviada para o chat após um tempo." + }, + "announceNewEntriesBatchTime": { + "title": "Quanto tempo esperar para anunciar novas entradas (em segundos)", + "help": "O tempo mais longo manterá o chat limpo, as entradas serão agregadas." + }, + "deleteRaffleJoinCommands": { + "title": "Excluir comando de juntar para o usuário juntar-se ao sorteio", + "help": "Isto excluirá a mensagem do usuário se eles usarem o comando !yourraffle. Deve manter o chat limpo." + }, + "allowOverTicketing": { + "title": "Permitir tickets a mais", + "help": "Permitir que o usuário se junte ao sorteio com ticket de excesso de seus pontos. Por exemplo, o usuário tem 10 pontos, mas pode juntar-se ao !raffle 100, que usará todos os seus pontos." + }, + "raffleAnnounceInterval": { + "title": "Intervalo do anúncio", + "help": "Minutos" + }, + "raffleAnnounceMessageInterval": { + "title": "Anunciar intervalo de mensagem", + "help": "Quantas mensagens devem ser enviadas no chat até que o anúncio possa ser publicado." + }, + "subscribersPercent": { + "title": "Sorte de assinantes adicionais", + "help": "em percentagens" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.ranks.json b/backend/locales/pt/ui.systems.ranks.json new file mode 100644 index 000000000..d53464316 --- /dev/null +++ b/backend/locales/pt/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "Nova Colocação", + "empty": "Nenhuma colocação foi criada ainda.", + "emptyAfterSearch": "Nenhuma colocação foi encontrada na sua pesquisa por \"$search\".", + "rank": { + "name": "colocação", + "placeholder": "" + }, + "value": { + "name": "horas", + "placeholder": "" + }, + "error": { + "isEmpty": "Este valor não pode ficar vazio" + }, + "warning": "Esta ação não pode ser desfeita!", + "settings": { + "enabled": "Situação" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.songs.json b/backend/locales/pt/ui.systems.songs.json new file mode 100644 index 000000000..910cf71eb --- /dev/null +++ b/backend/locales/pt/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Situação", + "volume": "Volume", + "calculateVolumeByLoudness": "Volume dinâmico por ruído", + "duration": { + "title": "Duração da música", + "help": "Em minutos" + }, + "shuffle": "Embaralhar", + "songrequest": "Reproduzir de solicitação de música", + "playlist": "Reproduzir da playlist", + "onlyMusicCategory": "Permitir apenas música de categoria", + "allowRequestsOnlyFromPlaylist": "Permitir solicitações de música somente da lista atual", + "notify": "Enviar mensagem ao mudar a música" + }, + "error": { + "isEmpty": "Este valor não pode ficar vazio" + }, + "startTime": "Comece a música em", + "endTime": "Finalizar música em", + "add_song": "Adicionar música", + "add_or_import": "Adicionar música ou importar da playlist", + "importing": "Importando", + "importing_done": "Importação Concluída", + "seconds": "Segundos", + "calculated": "Calculado", + "set_manually": "Definir manualmente", + "bannedSongsEmptyAfterSearch": "Nenhuma música banida foi encontrada para sua pesquisa por \"$search\".", + "emptyAfterSearch": "Nenhuma música foi encontrada para sua pesquisa por \"$search\".", + "empty": "Nenhuma música foi adicionada ainda.", + "bannedSongsEmpty": "Nenhuma música foi adicionada a lista de banimento ainda." +} \ No newline at end of file diff --git a/backend/locales/pt/ui.systems.timers.json b/backend/locales/pt/ui.systems.timers.json new file mode 100644 index 000000000..eaa29dec4 --- /dev/null +++ b/backend/locales/pt/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "Novo Temporizador", + "empty": "Nenhum temporizador foi criado ainda.", + "emptyAfterSearch": "Nenhum temporizador foi encontrado na sua pesquisa por \"$search\".", + "add_response": "Adicionar Resposta", + "settings": { + "enabled": "Situação" + }, + "warning": "Esta ação não pode ser revertida!" +} \ No newline at end of file diff --git a/backend/locales/pt/ui.widgets.customvariables.json b/backend/locales/pt/ui.widgets.customvariables.json new file mode 100644 index 000000000..04d21f878 --- /dev/null +++ b/backend/locales/pt/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "Nenhuma variável personalizada encontrada, adicione no registro de variáveis personalizadas", + "add-variable-into-watchlist": "Adicionar variável à lista de observação", + "watchlist": "Lista de observação" +} \ No newline at end of file diff --git a/backend/locales/pt/ui.widgets.randomizer.json b/backend/locales/pt/ui.widgets.randomizer.json new file mode 100644 index 000000000..f1d284a44 --- /dev/null +++ b/backend/locales/pt/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "Nenhum aleatorizador encontrado, adicione em Aleatorizador", + "add-randomizer-to-widget": "Adicionar aleatorizador ao widget" +} \ No newline at end of file diff --git a/backend/locales/pt/ui/categories.json b/backend/locales/pt/ui/categories.json new file mode 100644 index 000000000..d4a350a8c --- /dev/null +++ b/backend/locales/pt/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Anúncios", + "keys": "Chaves", + "currency": "Moeda", + "general": "Geral", + "settings": "Configurações", + "commands": "Comandos", + "bot": "Bot", + "channel": "Canal", + "connection": "Conexão", + "chat": "Chat", + "graceful_exit": "Saída Graciosa", + "rewards": "Recompensas", + "levels": "Níveis", + "notifications": "Notificações", + "options": "Opções", + "comboBreakMessages": "Mensagens de Quebra Combo", + "hypeMessages": "Mensagens Hype", + "messages": "Mensagens", + "results": "Resultados", + "customization": "Customização", + "status": "Situação", + "mapping": "Mapeamento", + "player": "Jogador", + "stats": "Estatísticas", + "api": "API", + "token": "Token", + "text": "Text", + "custom_texts": "Textos personalizados", + "credits": "Créditos", + "show": "Mostar", + "social": "Social", + "explosion": "Explosão", + "fireworks": "Fogo de artifício", + "test": "Teste", + "emotes": "Emotes", + "default": "Padrão", + "urls": "URLs", + "conversion": "Conversão", + "xp": "XP", + "caps_filter": "Filtro de caps", + "color_filter": "Italic (/me) filter", + "links_filter": "Filtro de links", + "symbols_filter": "Filtro de símbolos", + "longMessage_filter": "Filtro de tamanho da mensagem", + "spam_filter": "Filtros de spam", + "emotes_filter": "Filtro de Emotes", + "warnings": "Avisos", + "reset": "Redefinir", + "reminder": "Lembrete", + "eligibility": "Elegibilidade", + "join": "Aderir", + "luck": "Sorte", + "lists": "Listas", + "me": "Eu", + "emotes_combo": "Combo de Emotes", + "tmi": "tmi", + "oauth": "oauth", + "eventsub": "eventsub", + "rules": "regras" +} \ No newline at end of file diff --git a/backend/locales/pt/ui/core/currency.json b/backend/locales/pt/ui/core/currency.json new file mode 100644 index 000000000..d4ff6fbe9 --- /dev/null +++ b/backend/locales/pt/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Moeda Principal" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/core/general.json b/backend/locales/pt/ui/core/general.json new file mode 100644 index 000000000..16a70abdd --- /dev/null +++ b/backend/locales/pt/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Idioma do bot", + "numberFormat": "Formato de números no chat", + "gracefulExitEachXHours": { + "title": "Saída inteligente a cada X horas", + "help": "0 - desativado" + }, + "shouldGracefulExitHelp": "Ativar a saída inteligente é recomendado se o seu bot estiver rodando sem parar no servidor. Você deve ter o bot rodando em 'pm2' (ou serviço similar), ou garantir que o bot seja reiniciado automaticamente quando fechado. o Bot não irá fechar quando a stream estiver online." + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/core/oauth.json b/backend/locales/pt/ui/core/oauth.json new file mode 100644 index 000000000..ade844cfd --- /dev/null +++ b/backend/locales/pt/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Proprietários", + "botAccessToken": "Token de Acesso", + "channelAccessToken": "Token de Acesso", + "botRefreshToken": "Token de Atualização", + "channelRefreshToken": "Token de Atualização", + "botUsername": "Nome de usuário", + "channelUsername": "Nome de usuário", + "botExpectedScopes": "Escopos", + "channelExpectedScopes": "Escopos" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/core/permissions.json b/backend/locales/pt/ui/core/permissions.json new file mode 100644 index 000000000..de245ad68 --- /dev/null +++ b/backend/locales/pt/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Adicionar novo grupo de permissão", + "higherPermissionHaveAccessToLowerPermissions": "Permissões Altas tem acesso a permissões inferiores.", + "typeUsernameOrIdToSearch": "Digite o nome de usuário ou ID para pesquisar", + "typeUsernameOrIdToTest": "Digite o nome de usuário ou ID para testar", + "noUsersWereFound": "Nenhum usuário foi encontrado.", + "noUsersManuallyAddedToPermissionYet": "Nenhum usuário foi adicionado manualmente à permissão ainda.", + "done": "Concluído", + "previous": "Anterior", + "next": "Próximo", + "loading": "carregando", + "permissionNotFoundInDatabase": "Permissão não foi encontrada no banco de dados. Por favor, salve antes de testar o usuário.", + "userHaveNoAccessToThisPermissionGroup": "Usuário $username NÃO tem acesso a este grupo de permissões.", + "userHaveAccessToThisPermissionGroup": "Usuário $username TEM acesso a este grupo de permissões.", + "accessDirectlyThrough": "Acesso direto através de", + "accessThroughHigherPermission": "Acesso através de permissão superior", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Algo deu errado, o usuário $username não foi encontrado no banco de dados do bot.", + "permissionsGroups": "Grupos de Permissão", + "allowHigherPermissions": "Permitir acesso através de permissão superior", + "type": "Tipo", + "value": "Valor", + "watched": "Tempo assistido em horas", + "followtime": "Tempo de seguidor em meses", + "points": "Pontos", + "tips": "Doações", + "bits": "Bits", + "messages": "Mensagens", + "subtier": "Nível de Sub(1, 2, ou 3)", + "subcumulativemonths": "Sub-meses cumulativos", + "substreakmonths": "Sequência atual de incrição", + "ranks": "Colocação atual", + "level": "Nível atual", + "isLowerThan": "é menor que", + "isLowerThanOrEquals": "é menor que ou igual à", + "equals": "igual a", + "isHigherThanOrEquals": "é maior que ou igual à", + "isHigherThan": "é maior que", + "addFilter": "Adicionar filtro", + "selectPermissionGroup": "Selecionar grupo de permissão", + "settings": "Configurações", + "name": "Nome", + "baseUsersSet": "Conjunto base de usuários", + "manuallyAddedUsers": "Usuários adicionados manualmente", + "manuallyExcludedUsers": "Usuários excluídos manualmente", + "filters": "Filtros", + "testUser": "Usuário de teste", + "none": "- nenhum -", + "casters": "Casters", + "moderators": "Moderadores", + "subscribers": "Subscribers", + "vip": "VIP", + "viewers": "Viewers", + "followers": "Seguidores" +} \ No newline at end of file diff --git a/backend/locales/pt/ui/core/socket.json b/backend/locales/pt/ui/core/socket.json new file mode 100644 index 000000000..db69df681 --- /dev/null +++ b/backend/locales/pt/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Limpar todas as conexões autenticadas (a sua também)", + "accessTokenExpirationTime": "Duração do Token de Acesso (segundos)", + "refreshTokenExpirationTime": "Duração do Token de Atualização (segundos)", + "socketToken": { + "title": "Token do Socket", + "help": "Este token lhe dará acesso de administrador completo através de sockets. Não compartilhe!" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/core/tmi.json b/backend/locales/pt/ui/core/tmi.json new file mode 100644 index 000000000..de2f1a00e --- /dev/null +++ b/backend/locales/pt/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Lista de ignorados (ID ou usuário)", + "showWithAt": "Mostrar usuários com @", + "sendWithMe": "Enviar mensagens com /me", + "sendAsReply": "Enviar mensagens do bot como respostas", + "mute": "Bot mutado", + "whisperListener": "Aceitar comandos no privado" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/core/tts.json b/backend/locales/pt/ui/core/tts.json new file mode 100644 index 000000000..cfe35631c --- /dev/null +++ b/backend/locales/pt/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Serviço" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/core/twitch.json b/backend/locales/pt/ui/core/twitch.json new file mode 100644 index 000000000..3db0e1c40 --- /dev/null +++ b/backend/locales/pt/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Criar marcador de stream no evento" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/core/ui.json b/backend/locales/pt/ui/core/ui.json new file mode 100644 index 000000000..968accd65 --- /dev/null +++ b/backend/locales/pt/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Tema padrão", + "domain": { + "title": "Domínio", + "help": "Formato sem http/https: seudominio.com ou seu.dominio.com" + }, + "percentage": "Diferença de porcentagem para estatísticas", + "shortennumbers": "Formato curto dos números", + "showdiff": "Mostrar diferenças", + "enablePublicPage": "Ativar página pública" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/core/updater.json b/backend/locales/pt/ui/core/updater.json new file mode 100644 index 000000000..7bcc27a49 --- /dev/null +++ b/backend/locales/pt/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Atualizar automaticamente se uma nova versão estiver disponível" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/errors.json b/backend/locales/pt/ui/errors.json new file mode 100644 index 000000000..a1ce1fb32 --- /dev/null +++ b/backend/locales/pt/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Erros inesperados durante a validação", + "isNotEmpty": "$property é necessária.", + "minLength": "$property deve ser maior ou igual a $constraint1 caracteres.", + "isPositive": "$property deve ser maior que 0", + "isCommand": "$property deve iniciar com !", + "isCommandOrCustomVariable": "$property deve iniciar com ! ou $_", + "isCustomVariable": "$property deve começar com $_", + "min": "$property deve ser ao menos $constraint1", + "max": "$property deve ser menor ou igual a $constraint1", + "isInt": "$property precisa ser um número inteiro", + "this_value_must_be_a_positive_number_and_greater_then_0": "Este valor deve ser um número positivo ou maior que 0", + "command_must_start_with_!": "Comandos devem iniciar com !", + "this_value_must_be_a_positive_number_or_0": "Este valor deve ser um número positivo ou 0", + "value_cannot_be_empty": "Valor não pode ser vazio", + "minLength_of_value_is": "O comprimento mínimo é $value.", + "this_currency_is_not_supported": "Esta moeda não é suportada", + "something_went_wrong": "Ocorreu um problema", + "permission_must_exist": "Permissão deve existir", + "minValue_of_value_is": "O valor mínimo é $value", + "value_cannot_be": "Valor não pode ser $value.", + "invalid_format": "Formato de valor inválido.", + "invalid_regexp_format": "Isto não é uma regex válida.", + "owner_and_broadcaster_oauth_is_not_set": "Proprietário e OAUTH do canal não estão definidos", + "channel_is_not_set": "Canal não está definido", + "please_set_your_broadcaster_oauth_or_owners": "Por favor, defina seu auth de canal ou donos, ou todos os usuários terão acesso a este painel e serão considerados como Casters.", + "new_update_available": "Nova atualização disponível", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "Uma das entradas deve ser definida" +} \ No newline at end of file diff --git a/backend/locales/pt/ui/games/duel.json b/backend/locales/pt/ui/games/duel.json new file mode 100644 index 000000000..87b541544 --- /dev/null +++ b/backend/locales/pt/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Estado", + "cooldown": "Intervalo", + "duration": { + "title": "Duração", + "help": "Minutos" + }, + "minimalBet": "Aposta mínima", + "bypassCooldownByOwnerAndMods": "Ignorar intervalo dos proprietários e moderadores" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/games/gamble.json b/backend/locales/pt/ui/games/gamble.json new file mode 100644 index 000000000..d81d60172 --- /dev/null +++ b/backend/locales/pt/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Estado", + "minimalBet": "Aposta mínima", + "chanceToWin": { + "title": "Chance de vitória", + "help": "Porcentagem" + }, + "enableJackpot": "Ativar jackpot", + "chanceToTriggerJackpot": "Chance de acionar jackpot em %", + "maxJackpotValue": "Valor máximo do jackpot em %", + "lostPointsAddedToJackpot": "Quantos pontos perdidos devem ser adicionados ao jackpot em %" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/games/heist.json b/backend/locales/pt/ui/games/heist.json new file mode 100644 index 000000000..8eb39202e --- /dev/null +++ b/backend/locales/pt/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Assalto", + "settings": { + "enabled": "Estado", + "showMaxUsers": "Máximo de usuários a exibir no pagamento", + "copsCooldownInMinutes": { + "title": "Intervalo entre assaltos", + "help": "Minutos" + }, + "entryCooldownInSeconds": { + "title": "Tempo para entrar em assalto", + "help": "Segundos" + }, + "started": "Mensagem de inicio do assalto", + "nextLevelMessage": "Mensagem quando o próximo nível for atingido", + "maxLevelMessage": "Mensagem quando o nível máximo for atingido", + "copsOnPatrol": "Resposta do bot quando o assalto está no intervalo", + "copsCooldown": "Anúncio do bot quando o assalto pode ser iniciado", + "singleUserSuccess": "Mensagem de sucesso para um usuário", + "singleUserFailed": "Mensagem de falha para um usuário", + "noUser": "Mensagem se nenhum usuário participou" + }, + "message": "Mensagem", + "winPercentage": "Porcentagem de Vitória", + "payoutMultiplier": "Multiplicador de pagamento", + "maxUsers": "Máximo de usuários por level", + "percentage": "Porcentagem", + "noResultsFound": "Nenhum resultado foi encontrado. Clique no botão abaixo para adicionar um novo resultado.", + "noLevelsFound": "Nenhum level encontrado. Clique no botão abaixo para adicionar um novo level." +} \ No newline at end of file diff --git a/backend/locales/pt/ui/games/roulette.json b/backend/locales/pt/ui/games/roulette.json new file mode 100644 index 000000000..670f032a3 --- /dev/null +++ b/backend/locales/pt/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Estado", + "timeout": { + "title": "Duração de intervalo", + "help": "Segundos" + }, + "winnerWillGet": "Quantos pontos serão adicionados na vitória", + "loserWillLose": "Quantos pontos serão removidos na derrota" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/games/seppuku.json b/backend/locales/pt/ui/games/seppuku.json new file mode 100644 index 000000000..ed1f3393e --- /dev/null +++ b/backend/locales/pt/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Estado", + "timeout": { + "title": "Duração de intervalo", + "help": "Segundos" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/integrations/discord.json b/backend/locales/pt/ui/integrations/discord.json new file mode 100644 index 000000000..214f66eae --- /dev/null +++ b/backend/locales/pt/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Estado", + "guild": "Servidor", + "listenAtChannels": "Escutar comandos neste canal", + "sendOnlineAnnounceToChannel": "Enviar anúncio online para este canal", + "onlineAnnounceMessage": "Mensagem em anúncio de online (pode incluir menções)", + "sendAnnouncesToChannel": "Configuração de envio de avisos para canais", + "deleteMessagesAfterWhile": "Excluir mensagem após um tempo", + "clientId": "ClientId", + "token": "Token", + "joinToServerBtn": "Clique para adicionar o bot no seu servidor", + "joinToServerBtnDisabled": "Por favor salve suas alterações para habilitar o bot a juntar-se a seu servidor", + "cannotJoinToServerBtn": "Defina o token, e o clientId para poder adicionar o bot no seu servidor", + "noChannelSelected": "nenhum canal selecionado", + "noRoleSelected": "nenhuma função selecionada", + "noGuildSelected": "nenhum servidor selecionado", + "noGuildSelectedBox": "Selecione o servidor que o bot irá funcionar, e você conseguirá ver mais opções", + "onlinePresenceStatusDefault": "Situação Padrão", + "onlinePresenceStatusDefaultName": "Mensagem de status padrão", + "onlinePresenceStatusOnStream": "Status ao transmitir", + "onlinePresenceStatusOnStreamName": "Mensagem de status ao transmitir", + "ignorelist": { + "title": "Lista de ignorados", + "help": "nome-de-usuario#0000 ou userID" + } + } +} diff --git a/backend/locales/pt/ui/integrations/donatello.json b/backend/locales/pt/ui/integrations/donatello.json new file mode 100644 index 000000000..c48cedfde --- /dev/null +++ b/backend/locales/pt/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Obtenha seu token de acesso em https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/integrations/donationalerts.json b/backend/locales/pt/ui/integrations/donationalerts.json new file mode 100644 index 000000000..e48f16380 --- /dev/null +++ b/backend/locales/pt/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Estado", + "access_token": { + "title": "Token de acesso", + "help": "Obtenha seu token de acesso em https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Atualizar token" + }, + "accessTokenBtn": "Gerador de token de acesso de DonationAlerts" + } +} diff --git a/backend/locales/pt/ui/integrations/kofi.json b/backend/locales/pt/ui/integrations/kofi.json new file mode 100644 index 000000000..8bd43f206 --- /dev/null +++ b/backend/locales/pt/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Token de verifição", + "help": "Obtenha o seu token de verificação em https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "URL do Webhook", + "help": "Defina a URL de Webhook em https://ko-fi.com/manage/webhooks", + "errors": { + "https": "A URL precisa ter HTTPS", + "origin": "Você não pode usar localhost para webhooks" + } + } + } +} diff --git a/backend/locales/pt/ui/integrations/lastfm.json b/backend/locales/pt/ui/integrations/lastfm.json new file mode 100644 index 000000000..f7600588a --- /dev/null +++ b/backend/locales/pt/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Situação", + "apiKey": "API key", + "username": "Nome de usuário" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/integrations/obswebsocket.json b/backend/locales/pt/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..b1f63ea64 --- /dev/null +++ b/backend/locales/pt/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Situação", + "accessBy": { + "title": "Acesso por", + "help": "Direto - conectar diretamente de um bot | Overlay - conectar através da overlay vi fonte do navegador" + }, + "address": "Endereço", + "password": "Senha" + }, + "noSourceSelected": "Sem fonte selecionada", + "noSceneSelected": "Nenhuma cena selecionada", + "empty": "Nenhum grupo de ações foi criado ainda.", + "emptyAfterSearch": "Nenhum conjunto de ações foram encontradas para sua pesquisa por \"$search\".", + "command": "Comando", + "new": "Criar nova ação do OBS Websocket", + "actions": "Ações", + "name": { + "name": "Nome" + }, + "mute": "Mutar", + "unmute": "Desmutar", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Aguardar X milissegundos" + }, + "Log": { + "name": "Mensagens de log" + }, + "StartRecording": { + "name": "StartRecording" + }, + "StopRecording": { + "name": "StopRecording" + }, + "PauseRecording": { + "name": "PauseRecording" + }, + "ResumeRecording": { + "name": "ResumeRecording" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/integrations/pubg.json b/backend/locales/pt/ui/integrations/pubg.json new file mode 100644 index 000000000..5aa0d311a --- /dev/null +++ b/backend/locales/pt/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Situação", + "apiKey": { + "title": "API Key", + "help": "Obtenha a sua chave de API em https://developer.pubg.com/" + }, + "platform": "Plataforma", + "playerName": "Nome do Jogador", + "playerId": "ID do jogador", + "seasonId": { + "title": "ID da Temporada", + "help": "O ID da temporada atual sendo obtido toda hora." + }, + "rankedGameModeStatsCustomization": "Mensagem personalizada para estatísticas de colocação", + "gameModeStatsCustomization": "Mensagem personalizada para estatísticas normais" + }, + "click_to_fetch": "Clique para obter", + "something_went_wrong": "Alguma coisa deu errado!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Estatísticas são atualizadas automaticamente a cada 10 minutos.", + "player_stats_ranked": "Estatísticas do jogador (classificado)", + "player_stats": "Estatísticas do Jogador" +} diff --git a/backend/locales/pt/ui/integrations/qiwi.json b/backend/locales/pt/ui/integrations/qiwi.json new file mode 100644 index 000000000..b12942bfa --- /dev/null +++ b/backend/locales/pt/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Estado", + "secretToken": { + "title": "Token Secreto", + "help": "Obtenha seu token secreto em: Qiwi Donate dashboard settings->click show secret token" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/integrations/responsivevoice.json b/backend/locales/pt/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..79d8a226d --- /dev/null +++ b/backend/locales/pt/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Chave", + "help": "Obtenha sua chave em http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/integrations/spotify.json b/backend/locales/pt/ui/integrations/spotify.json new file mode 100644 index 000000000..1731d7c0d --- /dev/null +++ b/backend/locales/pt/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Artistas", + "settings": { + "enabled": "Estado", + "songRequests": "Pedidos de músicas", + "fetchCurrentSongWhenOffline": { + "title": "Buscar a música atual quando a stream estiver offline", + "help": "Recomenda-se que isso seja desativado para evitar que atinja os limites da API" + }, + "allowApprovedArtistsOnly": "Permitir somente artistas aprovados", + "approvedArtists": { + "title": "Artistas aprovados", + "help": "Nome ou SpotifyURI do artista, um item por linha" + }, + "queueWhenOffline": { + "title": "Efileirar músicas quando a stream estiver off-line", + "help": "É aconselhável que isso seja desativado para evitar fazer fila quando você está apenas ouvindo música" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "ID do Dispositivo Forçado", + "help": "Vazio = desabilitado, forçar o ID do dispositivo Spotify para ser usado na fila de músicas. Verifique os logs para o dispositivo ativo atual ou use o botão ao reproduzir música por pelo menos 10 segundos." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Formato", + "help": "Variáveis disponíveis: $song, $artist, $artists" + }, + "username": "Usuário autorizado", + "revokeBtn": "Revogar autorização", + "authorizeBtn": "Autorizar usuário", + "scopes": "Escopos", + "playlistToPlay": { + "title": "Endereço da playlist principal do Spotify", + "help": "Se definido, após a solicitação concluída, esta playlist continuará" + }, + "continueOnPlaylistAfterRequest": "Continuar tocando na playlist após solicitação de música", + "notify": "Enviar mensagem ao mudar a música" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/integrations/streamelements.json b/backend/locales/pt/ui/integrations/streamelements.json new file mode 100644 index 000000000..c96346c9a --- /dev/null +++ b/backend/locales/pt/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Estado", + "jwtToken": { + "title": "Token JWT", + "help": "Obtenha o token JWT na configuração de Canais de StreamElements e ative o Show Secrets" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/integrations/streamlabs.json b/backend/locales/pt/ui/integrations/streamlabs.json new file mode 100644 index 000000000..c7f4310f9 --- /dev/null +++ b/backend/locales/pt/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Estado", + "socketToken": { + "title": "Token Secreto", + "help": "Obtenha seu token secreto em: streamlabs dashboard API settings->API tokens->Your Socket API Token" + }, + "accessToken": { + "title": "Token de acesso", + "help": "Obtenha seu token de acesso em https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "Gerador de token de acesso StreamLabs" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/integrations/tipeeestream.json b/backend/locales/pt/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..eff0ed95d --- /dev/null +++ b/backend/locales/pt/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Estado", + "apiKey": { + "title": "Chave API", + "help": "Obtenha o token de acesso em: tipeeestream dashboard -> API -> Your API Key" + } + } +} diff --git a/backend/locales/pt/ui/integrations/twitter.json b/backend/locales/pt/ui/integrations/twitter.json new file mode 100644 index 000000000..761d165c8 --- /dev/null +++ b/backend/locales/pt/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Estado", + "consumerKey": "Consumer Key (Chave API)", + "consumerSecret": "Consumer Secret (API Secret)", + "accessToken": "Token de Acesso", + "secretToken": "Token de Acesso Secreto" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/managers.json b/backend/locales/pt/ui/managers.json new file mode 100644 index 000000000..4f3083fbf --- /dev/null +++ b/backend/locales/pt/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "Histórico de eventos de usuário", + "hostAndRaidViewersCount": "Viewers: $value", + "receivedSubscribeFrom": "Inscrição recebida de $value", + "giftedSubscribeTo": "Inscrição presente para $value" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/overlays/alerts.json b/backend/locales/pt/ui/overlays/alerts.json new file mode 100644 index 000000000..0333223d8 --- /dev/null +++ b/backend/locales/pt/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Armazenar items da galeria no cache", + "galleryCacheLimitInMb": "Tamanho máximo do item da galeria (em MB) para armazenar em cache" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/overlays/clips.json b/backend/locales/pt/ui/overlays/clips.json new file mode 100644 index 000000000..029663996 --- /dev/null +++ b/backend/locales/pt/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Volume", + "cClipsFilter": "Filtro de clipes", + "cClipsLabel": "Mostrar a etiqueta 'clip'" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/overlays/clipscarousel.json b/backend/locales/pt/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..939e9c5c4 --- /dev/null +++ b/backend/locales/pt/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Intervalo de tempo (dias)", + "cClipsNumOfClips": "Número de clipes", + "cClipsTimeToNextClip": "Tempo para o próximo clipe (s)" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/overlays/credits.json b/backend/locales/pt/ui/overlays/credits.json new file mode 100644 index 000000000..efe21e297 --- /dev/null +++ b/backend/locales/pt/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Velocidade", + "cCreditsAggregated": "Créditos agregados", + "cShowGameThumbnail": "Mostrar miniatura do jogo", + "cShowFollowers": "Mostrar seguidores", + "cShowRaids": "Mostrar raids", + "cShowSubscribers": "Mostrar subscribers", + "cShowSubgifts": "Mostrar subs presenteados", + "cShowSubcommunitygifts": "Mostrar subs presenteados à comunidade", + "cShowResubs": "Mostrar resubs", + "cShowCheers": "Mostrar torcidas", + "cShowClips": "Mostrar clipes", + "cShowTips": "Mostrar doações", + "cTextLastMessage": "Última mensagem", + "cTextLastSubMessage": "Última submensagem", + "cTextStreamBy": "Streamado por", + "cTextFollow": "Seguido por", + "cTextRaid": "Raidado por", + "cTextCheer": "Torça por", + "cTextSub": "Subscriber por", + "cTextResub": "Resub por", + "cTextSubgift": "Subs presenteados", + "cTextSubcommunitygift": "Subs presenteados à comunidade", + "cTextTip": "Doações por", + "cClipsPeriod": "Intervalo de Tempo", + "cClipsCustomPeriodInDays": "Intervalo de tempo customizado (dias)", + "cClipsNumOfClips": "Número de clipes", + "cClipsShouldPlay": "Os clipes devem ser reproduzidos", + "cClipsVolume": "Volume" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/overlays/emotes.json b/backend/locales/pt/ui/overlays/emotes.json new file mode 100644 index 000000000..12e1ef2a3 --- /dev/null +++ b/backend/locales/pt/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Limpar cache", + "hypeMessagesEnabled": "Mostrar mensagens de hype no chat", + "btnTestExplosion": "Teste explosão de emotes", + "btnTestEmote": "Testar emote", + "btnTestFirework": "Testar fogo de artifício de emotes", + "cEmotesSize": "Tamanho dos emotes", + "cEmotesMaxEmotesPerMessage": "Máximo de emotes por mensagem", + "cEmotesMaxRotation": "Rotação máxima dos emotes", + "cEmotesOffsetX": "Deslocamento máximo no eixo x", + "cEmotesAnimation": "Animação", + "cEmotesAnimationTime": "Duração da animação", + "cExplosionNumOfEmotes": "Nº de emotes", + "cExplosionNumOfEmotesPerExplosion": "Nº de emotes por explosão", + "cExplosionNumOfExplosions": "Nº de explosões", + "enableEmotesCombo": "Ativar combo de emotes", + "comboBreakMessages": "Mensagem de quebra do Combo", + "threshold": "Limiar", + "noMessagesFound": "Nenhuma mensagem encontrada.", + "message": "Mensagem", + "showEmoteInOverlayThreshold": "Limiar mínimo de mensagem para mostrar o emote na overlay", + "hideEmoteInOverlayAfter": { + "title": "Ocultar emote na overlay após inatividade", + "help": "Ocultará o emote na overlay após certo tempo em segundos" + }, + "comboCooldown": { + "title": "Recarga do Combo", + "help": "Tempo de recarga de combo em segundos" + }, + "comboMessageMinThreshold": { + "title": "Limiar mínimo de mensagem", + "help": "Limiar mínimo de mensagens para contar emotes como combos (até então não vai ativar o tempo de espera)" + }, + "comboMessages": "Mensagens de Combo" + }, + "hype": { + "5": "Vamos lá! Temos o combo $amountx $emote até agora! Boa", + "15": "Continue assim! Podemos obter mais do que $amountx $emote? TriHard" + }, + "message": { + "3": "Combo de $amountx $emote", + "5": "$amountx combo $emote Parece Bom", + "10": "$amountx $emote combo PogChamp", + "15": "$amountx combo TriHard de $emote", + "20": "$sender arruinou $amountx $emote combo! NotLikeThis" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/overlays/polls.json b/backend/locales/pt/ui/overlays/polls.json new file mode 100644 index 000000000..c948eae99 --- /dev/null +++ b/backend/locales/pt/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Tema", + "cDisplayHideAfterInactivity": "Esconder quando inativo", + "cDisplayAlign": "Alinhamento", + "cDisplayInactivityTime": { + "title": "Inatividade após", + "help": "em milissegundos" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/overlays/texttospeech.json b/backend/locales/pt/ui/overlays/texttospeech.json new file mode 100644 index 000000000..2f4e85238 --- /dev/null +++ b/backend/locales/pt/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "Você não configurou corretamente a chave ResponsiveVoice", + "voice": { + "title": "Voz", + "help": "Se as vozes não estiverem carregando corretamente após a atualização da chave ResponsiveVoice, tente atualizar o navegador" + }, + "volume": "Volume", + "rate": "Rate", + "pitch": "Tom", + "triggerTTSByHighlightedMessage": "Texto para fala será acionado pela mensagem destacada" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/properties.json b/backend/locales/pt/ui/properties.json new file mode 100644 index 000000000..f9b22f915 --- /dev/null +++ b/backend/locales/pt/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Apelido", + "command": "Comando", + "variableName": "Nome da variável", + "price": "Preço (pontos)", + "priceBits": "Preço (bits)", + "thisvalue": "Este valor", + "promo": { + "shoutoutMessage": "Mensagem de promo (shout-out)", + "enableShoutoutMessage": "Enviar mensagem de shout-out no chat" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/registry/alerts.json b/backend/locales/pt/ui/registry/alerts.json new file mode 100644 index 000000000..66772a0ca --- /dev/null +++ b/backend/locales/pt/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Ativado", + "testDlg": { + "alertTester": "Teste de alerta", + "command": "Comando", + "username": "Nome de Usuário", + "recipient": "Destinatário", + "message": "Mensagem", + "tier": "Nível", + "amountOfViewers": "Quantidade de espectadores", + "amountOfBits": "Quantidade de bits", + "amountOfGifts": "Quantidade de presentes", + "amountOfMonths": "Quantidade de meses", + "amountOfTips": "Doações", + "event": "Evento", + "service": "Serviço" + }, + "empty": "Registro de alertas está vazio, crie novos alertas.", + "emptyAfterSearch": "Registro de alertas está vazio em busca de \"$search\"", + "revertcode": "Reverter o código para os padrões", + "name": { + "name": "Nome", + "placeholder": "Definir nome dos seus alertas" + }, + "alertDelayInMs": { + "name": "Atraso do alerta" + }, + "parryEnabled": { + "name": "\"Esquivas\" de alerta" + }, + "parryDelay": { + "name": "Alerta de atraso do bloqueio" + }, + "profanityFilterType": { + "name": "Filtro de Palavrões", + "disabled": "Desativado", + "replace-with-asterisk": "Substituir por asterisco", + "replace-with-happy-words": "Substituir com palavras felizes", + "hide-messages": "Ocultar mensagens", + "disable-alerts": "Desativar alertas" + }, + "loadStandardProfanityList": "Carregar lista de palavrãos padrão", + "customProfanityList": { + "name": "Lista de palavrãos personalizada", + "help": "As palavras devem ser separadas por vírgulas." + }, + "event": { + "follow": "Seguir", + "cheer": "Cheer", + "sub": "Inscrição", + "resub": "Resub", + "subgift": "Sub de presente", + "subcommunitygift": "Sub de presente para comunidade", + "tip": "Gorjeta", + "raid": "Raid", + "custom": "Personalizado", + "promo": "Promo (shout-out)", + "rewardredeem": "Resgate de Recompensa" + }, + "title": { + "name": "Nome da variante", + "placeholder": "Defina o nome da variante" + }, + "variant": { + "name": "Ocorrência de variante" + }, + "filter": { + "name": "Filtro", + "operator": "Operador", + "rule": "Regra", + "addRule": "Adicionar regra", + "addGroup": "Adicionar Grupo", + "comparator": "Comparador", + "value": "Valor", + "valueSplitByComma": "Valores divididos por vírgula (por exemplo val1, val2)", + "isEven": "é par", + "isOdd": "é ímpar", + "lessThan": "menor que", + "lessThanOrEqual": "menor ou igual a", + "contain": "contém", + "contains": "contém", + "equal": "igual", + "notEqual": "diferente de", + "present": "está presente", + "includes": "inclui", + "greaterThan": "maior que", + "greaterThanOrEqual": "maior que ou igual", + "noFilter": "sem filtro" + }, + "speed": { + "name": "Velocidade" + }, + "maxTimeToDecrypt": { + "name": "Tempo máximo de descriptografar" + }, + "characters": { + "name": "Caracteres" + }, + "random": "Aleatório", + "exact-amount": "Quantidade exata", + "greater-than-or-equal-to-amount": "Maior que ou igual a quantidade", + "tier-exact-amount": "Nível é exatamente", + "tier-greater-than-or-equal-to-amount": "O nível é superior ou igual a", + "months-exact-amount": "Quantidade de meses está exatamente", + "months-greater-than-or-equal-to-amount": "A quantidade dos meses é maior ou igual a", + "gifts-exact-amount": "Quantidade de presentes é exatamente", + "gifts-greater-than-or-equal-to-amount": "Quantidade de presentes é maior ou igual a", + "very-rarely": "Muito raramente", + "rarely": "Raramente", + "default": "Padrão", + "frequently": "Frequentemente", + "very-frequently": "Muito Frequentemente", + "exclusive": "Exclusivo", + "messageTemplate": { + "name": "Modelo de mensagem", + "placeholder": "Defina o seu modelo de mensagem", + "help": "Variáveis disponíveis: {name}, {amount} (cheers, subs, dicas, sub-presetnes, subs-presente da comunidade, resgate de comandos), {recipient} (subs-presente, resgate de comandos), {monthsName} (subs, subs-presente), {currency} (doações), {game} (promo). Se | adicionado (veja a promo) então mostrará esses valores em sequência." + }, + "ttsTemplate": { + "name": "Modelos TTS", + "placeholder": "Defina seu modelo de TTS", + "help": "Variáveis disponíveis: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Texto de animação" + }, + "animationType": { + "name": "Tipo de animação" + }, + "animationIn": { + "name": "Animação de entrada" + }, + "animationOut": { + "name": "Animação de saída" + }, + "alertDurationInMs": { + "name": "Duração do alerta" + }, + "alertTextDelayInMs": { + "name": "Alerta de atraso texto" + }, + "layoutPicker": { + "name": "Layout" + }, + "loop": { + "name": "Reproduzir em loop" + }, + "scale": { + "name": "Escala" + }, + "translateY": { + "name": "Mover para cima / +Baixo" + }, + "translateX": { + "name": "Mover -Esquerda / +Direita" + }, + "image": { + "name": "Imagem / Vídeo(.webm)", + "setting": "Configurações de imagem / Vídeo(.webm)" + }, + "sound": { + "name": "Som", + "setting": "Configurações de som" + }, + "soundVolume": { + "name": "Volume dos alertas" + }, + "enableAdvancedMode": "Ativar o modo avançado", + "font": { + "setting": "Configurações de fonte", + "name": "Família da fonte", + "overrideGlobal": "Substituir as configurações de fonte globais", + "align": { + "name": "Alinhamento", + "left": "Esquerdo", + "center": "Centro", + "right": "Direita" + }, + "size": { + "name": "Tamanho da fonte" + }, + "weight": { + "name": "Peso da fonte" + }, + "borderPx": { + "name": "Borda da fonte" + }, + "borderColor": { + "name": "Cor da borda da fonte" + }, + "color": { + "name": "Cor da fonte" + }, + "highlightcolor": { + "name": "Cor de realce da fonte" + } + }, + "minAmountToShow": { + "name": "Montante mínimo para mostrar" + }, + "minAmountToPlay": { + "name": "Valor mínimo para jogar" + }, + "allowEmotes": { + "name": "Permitir emotes" + }, + "message": { + "setting": "Configurações de mensagem" + }, + "voice": "Voz", + "keepAlertShown": "O alerta mantém visível durante o TTS", + "skipUrls": "Pular URLs durante o TTS", + "volume": "Volume", + "rate": "Taxa", + "pitch": "Tom", + "test": "Teste", + "tts": { + "setting": "Configurações de TTS" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/registry/goals.json b/backend/locales/pt/ui/registry/goals.json new file mode 100644 index 000000000..51d6238db --- /dev/null +++ b/backend/locales/pt/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Adicionar Grupo de Metas", + "addGoal": "Adicionar meta", + "newGoal": "nova Meta", + "newGoalGroup": "novo Grupo de Meta", + "goals": "Objetivos", + "general": "Geral", + "display": "Visualização", + "fontSettings": "Configurações de Fonte", + "barSettings": "Configurações da Barra", + "selectGoalOnLeftSide": "Selecione ou adicione meta ao lado esquerdo", + "input": { + "description": { + "title": "Descrição" + }, + "goalAmount": { + "title": "Valor da Meta" + }, + "countBitsAsTips": { + "title": "Contar bits como Doações" + }, + "currentAmount": { + "title": "Montante Atual" + }, + "endAfter": { + "title": "Finalizar após" + }, + "endAfterIgnore": { + "title": "O objetivo não expirará" + }, + "borderPx": { + "title": "Borda", + "help": "Tamanho da borda está em pixels" + }, + "barHeight": { + "title": "Altura da barra", + "help": "Altura da barra em pixels" + }, + "color": { + "title": "Cor" + }, + "borderColor": { + "title": "Cor da Borda" + }, + "backgroundColor": { + "title": "Cor do Fundo" + }, + "type": { + "title": "Tipo" + }, + "nameGroup": { + "title": "Nome deste grupo de objetivos" + }, + "name": { + "title": "Nome deste objetivo" + }, + "displayAs": { + "title": "Exibir como", + "help": "Define como o grupo de metas será mostrado" + }, + "durationMs": { + "title": "Duração", + "help": "This value is in milliseconds", + "placeholder": "Quanto tempo o objetivo deve ser mostrado" + }, + "animationInMs": { + "title": "Animação em duração", + "help": "Este valor está em milissegundos", + "placeholder": "Definir sua animação em duração" + }, + "animationOutMs": { + "title": "Duração da saída da animação", + "help": "Este valor está em milissegundos", + "placeholder": "Definir a duração da animação de saída" + }, + "interval": { + "title": "Que intervalo contar" + }, + "spaceBetweenGoalsInPx": { + "title": "Espaço entre metas", + "help": "Este valor está em pixels", + "placeholder": "Defina seu espaço entre as metas" + } + }, + "groupSettings": "Configurações do Grupo" +} \ No newline at end of file diff --git a/backend/locales/pt/ui/registry/overlays.json b/backend/locales/pt/ui/registry/overlays.json new file mode 100644 index 000000000..c1e3ccbe6 --- /dev/null +++ b/backend/locales/pt/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Criar nova overlay de mapeamento de links", + "emptyMapping": "Nenhum mapeamento de link de overlay foi criado ainda.", + "allowedIPs": { + "name": "IPs Permitidos", + "help": "Permitir acesso dos IPs definidos separados por nova linha" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/registry/plugins.json b/backend/locales/pt/ui/registry/plugins.json new file mode 100644 index 000000000..41d41339d --- /dev/null +++ b/backend/locales/pt/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "Este nó precisa ser ligado aos 'listeners' com atributos do remetente" + }, + "filter": { + "permission": { + "name": "Filtro de permissão" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Ouvinte de evento", + "type": { + "twitchChatMessage": "Mensagem de chat Twitch", + "twitchCheer": "Twitch cheer recebido", + "twitchClearChat": "Chat Twitch foi limpo", + "twitchCommand": "Comando Twitch", + "twitchFollow": "Novo seguidor Twitch", + "twitchSubscription": "Nova assinatura Twitch", + "twitchSubgift": "Nova inscrição de presente Twitch", + "twitchSubcommunitygift": "Nova inscrição de presente da comunidade Twitch", + "twitchResub": "Nova assinatura recorrente Twitch", + "twitchGameChanged": "Categoria Twitch alterada", + "twitchStreamStarted": "Transmissão da Twitch iniciada", + "twitchStreamStopped": "Transmissão da Twitch parada", + "twitchRewardRedeem": "Recompensa Twitch resgatada", + "twitchRaid": "'Raid' da Twitch chegando", + "tip": "Doado pelo usuário", + "botStarted": "Bot iniciado" + }, + "command": { + "add-parameter": "Adicionar parâmetro", + "parameters": "Parâmetros", + "order-is-important": "ordem é importante" + } + }, + "others": { + "idle": { + "name": "Ocioso" + } + }, + "output": { + "log": { + "name": "Mensagens de log" + }, + "timeout-user": { + "name": "Suspender usuário" + }, + "ban-user": { + "name": "Banir usuário" + }, + "send-twitch-message": { + "name": "Enviar Mensagem da Twitch" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/registry/randomizer.json b/backend/locales/pt/ui/registry/randomizer.json new file mode 100644 index 000000000..06766d674 --- /dev/null +++ b/backend/locales/pt/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Adicionar Aleatorizador", + "form": { + "name": "Nome", + "command": "Comando", + "permission": "Permissão do comando", + "simple": "Simples", + "tape": "Fita", + "wheelOfFortune": "Roda da Fortuna", + "type": "Tipo", + "options": "Opções", + "optionsAreEmpty": "Opções estão vazias.", + "color": "Cor", + "numOfDuplicates": "No. de duplicados", + "minimalSpacing": "Espaçamento mínimo", + "groupUp": "Agrupar", + "ungroup": "Desagrupar", + "groupedWithOptionAbove": "Agrupados com a opção acima", + "generatedOptionsPreview": "Pré-visualização das opções geradas", + "probability": "Probabilidade", + "tick": "Som de toque durante o giro" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/registry/textoverlay.json b/backend/locales/pt/ui/registry/textoverlay.json new file mode 100644 index 000000000..638110f81 --- /dev/null +++ b/backend/locales/pt/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Criar nova overlay de texto", + "title": "overlay de texto", + "name": { + "placeholder": "Defina o nome desta overlay de texto" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/stats/commandcount.json b/backend/locales/pt/ui/stats/commandcount.json new file mode 100644 index 000000000..df1eab33c --- /dev/null +++ b/backend/locales/pt/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Comando", + "hour": "Hora", + "day": "Dia", + "week": "Semana", + "month": "Mês", + "year": "Ano", + "total": "Total" +} \ No newline at end of file diff --git a/backend/locales/pt/ui/systems/checklist.json b/backend/locales/pt/ui/systems/checklist.json new file mode 100644 index 000000000..238546be7 --- /dev/null +++ b/backend/locales/pt/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Situação", + "itemsArray": "Lista" + }, + "check": "Checklist" +} \ No newline at end of file diff --git a/backend/locales/pt/ui/systems/howlongtobeat.json b/backend/locales/pt/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..2930ee93b --- /dev/null +++ b/backend/locales/pt/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Situação" + }, + "empty": "Nenhum jogo foi rastreado ainda.", + "emptyAfterSearch": "Nenhum jogo monitorado foi encontrado na sua pesquisa por \"$search\".", + "when": "Quando transmitido", + "time": "Tempo registrado", + "overallTime": "Tempo geral", + "offset": "Deslocamento de tempo rastreado", + "main": "Principal", + "extra": "Principal + extra", + "completionist": "Complecionista", + "game": "Jogo rastreado", + "startedAt": "Rastreamento começou em", + "updatedAt": "Última atualização", + "showHistory": "Mostrar histórico ($count)", + "hideHistory": "Ocultar histórico ($count)", + "searchToAddNewGame": "Pesquise para adicionar um novo jogo para gravar" +} \ No newline at end of file diff --git a/backend/locales/pt/ui/systems/keywords.json b/backend/locales/pt/ui/systems/keywords.json new file mode 100644 index 000000000..1bef1c88f --- /dev/null +++ b/backend/locales/pt/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "Nova Palavra-chave", + "empty": "Nenhuma palavra-chave foi criada ainda.", + "emptyAfterSearch": "Nenhuma palavra-chave foi encontrada em sua busca por \"$search\".", + "keyword": { + "name": "Palavra-chave / Expressão regular", + "placeholder": "Defina sua palavra-chave ou expressão regular para ativar a palavra-chave.", + "help": "Você pode usar regexp (diferencia maiúsculas de minúsculas) para usar palavras-chave, por exemplo, hello.*➲ hi" + }, + "response": { + "name": "Resposta", + "placeholder": "Defina sua resposta aqui." + }, + "error": { + "isEmpty": "Este valor não pode ficar vazio" + }, + "no-responses-set": "Não há respostas", + "addResponse": "Adicione resposta", + "filter": { + "name": "filtrar", + "placeholder": "Adicionar filtro para esta resposta" + }, + "warning": "Esta ação não pode ser revertida!", + "settings": { + "enabled": "Situação" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/systems/levels.json b/backend/locales/pt/ui/systems/levels.json new file mode 100644 index 000000000..12e661f60 --- /dev/null +++ b/backend/locales/pt/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Situação", + "conversionRate": "Conversão taxa 1 XP para x Pontos", + "firstLevelStartsAt": "O primeiro nível começa na XP", + "nextLevelFormula": { + "title": "Fórmula do cálculo do próximo nível", + "help": "Variáveis disponíveis: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "O exemplo de níveis será atualizado ao salvar", + "xpName": "Nome", + "interval": "Intervalo de minutos para adicionar Xp para os usuários online quando a stream estiver online", + "offlineInterval": "Intervalo de minutos para adicionar Xp aos usuários online quando a stream estiver offline", + "messageInterval": "Quantas mensagens adicionarão pontos", + "messageOfflineInterval": "Quantas mensagens para adicionar xp quando o stream está offline", + "perInterval": "Quantos Xp adicionar por intervalo online", + "perOfflineInterval": "Quantos Xp adicionar por intervalo offline", + "perMessageInterval": "Quantos xp adicionar por intervalo de mensagem", + "perMessageOfflineInterval": "Quantos Xp adicionar por intervalo de mensagens offline" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/systems/polls.json b/backend/locales/pt/ui/systems/polls.json new file mode 100644 index 000000000..38c75e4ea --- /dev/null +++ b/backend/locales/pt/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Votos totais", + "totalPoints": "Pontos totais", + "closedAt": "Fechado em", + "activeFor": "Ativo por" +} \ No newline at end of file diff --git a/backend/locales/pt/ui/systems/scrim.json b/backend/locales/pt/ui/systems/scrim.json new file mode 100644 index 000000000..3fa31decd --- /dev/null +++ b/backend/locales/pt/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Situação", + "waitForMatchIdsInSeconds": { + "title": "Intervalo para colocar o ID da partida no chat", + "help": "Definir em segundos" + } + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/systems/top.json b/backend/locales/pt/ui/systems/top.json new file mode 100644 index 000000000..eb2c28502 --- /dev/null +++ b/backend/locales/pt/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Situação" + } +} \ No newline at end of file diff --git a/backend/locales/pt/ui/systems/userinfo.json b/backend/locales/pt/ui/systems/userinfo.json new file mode 100644 index 000000000..2edf03260 --- /dev/null +++ b/backend/locales/pt/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Situação", + "formatSeparator": "Separador de Formato", + "order": "Formato", + "lastSeenFormat": { + "title": "Formato de hora", + "help": "Formatos possíveis em https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru.json b/backend/locales/ru.json new file mode 100644 index 000000000..68369504a --- /dev/null +++ b/backend/locales/ru.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "загружено и", + "enabled": "включено", + "disabled": "выключено", + "usage": "Использование", + "lang-selected": "Язык бота в настоящее время установлен на русский", + "refresh-panel": "Вам нужно обновить пользовательский интерфейс, чтобы увидеть изменения.", + "command-parse": "Извините, $sender, но эта команда не верна, используйте", + "error": "Извините, $sender, но что-то пошло не так!", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, API ответил неправильно!", + "not-available": "недоступно" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "год|4:года|лет", + "months": "месяц|4:месяца|месяцев", + "days": "день|4:дня|дней", + "hours": "час|4:часа|часов", + "minutes": "минута|4:минуты|минут", + "seconds": "секунда|4:секунды|секунд", + "messages": "сообщение|4:сообщения|сообщений", + "bits": "бит|битсов", + "links": "ссылка|ссылки", + "entries": "запись|записи", + "empty": "пусто", + "isRegistered": "$sender, вы не можете использовать !$keyword, потому что уже используется для другого действия!" + }, + "clip": { + "notCreated": "Что-то пошло не так и клип не был создан.", + "offline": "Поток в настоящее время оффлайн и клип не может быть создан." + }, + "uptime": { + "online": "Стрим идёт (if $days>0|$daysд )(if $hours>0|$hoursч )(if $minutes>0|$minutesм )(if $seconds>0|$secondsс)", + "offline": "Стрим оффлайн (if $days>0|$daysд )(if $hours>0|$hoursч )(if $minutes>0|$minutesм )(if $seconds>0|$secondsс)" + }, + "webpanel": { + "this-system-is-disabled": "Эта система отключена", + "or": "or", + "loading": "Загрузка", + "this-may-take-a-while": "Это может занять некоторое время", + "display-as": "Отображать как", + "go-to-admin": "Перейти к администратору", + "go-to-public": "Перейти к публичному", + "logout": "Выйти", + "popout": "Открыть в новом окне", + "not-logged-in": "Вход не выполнен", + "remove-widget": "Удалить $name виджет", + "join-channel": "Присоединиться к боту на канал", + "leave-channel": "Покинуть бота из канала", + "set-default": "Установить по умолчанию", + "add": "Добавить", + "placeholders": { + "text-url-generator": "Вставьте свой текст или html для создания base64 ниже и URL выше", + "text-decode-base64": "Вставьте свой base64 для создания URL и текста выше", + "creditsSpeed": "Установите скорость прокрутки, меньше = быстрее" + }, + "timers": { + "title": "Таймеры", + "timer": "Таймер", + "messages": "сообщения", + "seconds": "секунды", + "badges": { + "enabled": "Включено", + "disabled": "Выключено" + }, + "errors": { + "timer_name_must_be_compliant": "Значение может содержать только a-zA-Z09_", + "this_value_must_be_a_positive_number_or_0": "Значение должно быть положительным числом", + "value_cannot_be_empty": "Значение не может быть пустым" + }, + "dialog": { + "timer": "Таймер", + "name": "Название", + "tickOffline": "Отметить если трансляция оффлайн", + "interval": "Интервал", + "responses": "Ответы", + "messages": "Срабатывать каждое X сообщение", + "seconds": "Срабатывать каждую X секунду", + "title": { + "new": "Новый таймер", + "edit": "Изменить таймер" + }, + "placeholders": { + "name": "Установка имени вашего таймера. Может содержать только a-zA-Z0-9_ символы", + "messages": "Срабатывать каждое X сообщение", + "seconds": "Срабатывать каждую X секунду" + }, + "alerts": { + "success": "Таймер был успешно сохранен.", + "fail": "Что-то пошло не так." + } + }, + "buttons": { + "close": "Закрыть", + "save-changes": "Сохранить изменения", + "disable": "Выключить", + "enable": "Включить", + "edit": "Редактировать", + "delete": "Удалить", + "yes": "Да", + "no": "Нет" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Вы уверены, что хотите удалить таймер" + } + }, + "events": { + "event": "Событие", + "noEvents": "События не найдены в базе данных.", + "whatsthis": "что это?", + "myRewardIsNotListed": "Моя награда не указанна!", + "redeemAndClickRefreshToSeeReward": "Если в списке отсутствует созданная награда, обновите, нажав на значок обновления.", + "badges": { + "enabled": "Включено", + "disabled": "Выключено" + }, + "buttons": { + "test": "Тест", + "enable": "Включить", + "disable": "Выключить", + "edit": "Редактировать", + "delete": "Удалить", + "yes": "Да", + "no": "Нет" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Вы уверены, что хотите удалить событие", + "example_of_user_object_data": "Пример данных объекта пользователя" + }, + "errors": { + "command_must_start_with_!": "Команда должна начинаться с !", + "this_value_must_be_a_positive_number_or_0": "Значение должно быть положительным числом или 0", + "value_cannot_be_empty": "Значение не может быть пустым" + }, + "dialog": { + "title": { + "new": "Новый обработчик событий", + "edit": "Редактировать обработчик событий" + }, + "placeholders": { + "name": "Установка имени обработчика событий (если пусто, имя будет сгенерировано)" + }, + "alerts": { + "success": "Таймер был успешно сохранен.", + "fail": "Что-то пошло не так." + }, + "close": "Закрыть", + "save-changes": "Сохранить изменения", + "event": "Событие", + "name": "Название", + "usable-events-variables": "Доступные переменные событий", + "settings": "Параметры", + "filters": "Фильтры", + "operations": "Операции" + }, + "definitions": { + "taskId": { + "label": "ID задачи" + }, + "filter": { + "label": "Фильтр" + }, + "linkFilter": { + "label": "Link Overlay Filter", + "placeholder": "При использовании наложения, добавить ссылку или идентификатор наложения" + }, + "hashtag": { + "label": "Хэштег или Ключевое слово", + "placeholder": "#yourHashtagHere или Ключевое слово" + }, + "fadeOutXCommands": { + "label": "Затухание X команд", + "placeholder": "Количество команд, отнимающих каждый интервал затухания" + }, + "fadeOutXKeywords": { + "label": "Затухание X ключевых слов", + "placeholder": "Количество ключевых слов, отнимающих каждый интервал затухания" + }, + "fadeOutInterval": { + "label": "Интервал затухания (секунд)", + "placeholder": "Вычитание от интервала затухания" + }, + "runEveryXCommands": { + "label": "Запускать каждые X команду", + "placeholder": "Количество команд перед выполнением события" + }, + "runEveryXKeywords": { + "label": "Запускать каждые X ключевых слов", + "placeholder": "Количество ключевых слов перед срабатыванием события" + }, + "commandToWatch": { + "label": "Команда для наблюдения", + "placeholder": "Установите вашу !commandToWatch" + }, + "keywordToWatch": { + "label": "Ключевое слово для наблюдения", + "placeholder": "Установите ваше ключевое слово для отслеживания" + }, + "resetCountEachMessage": { + "label": "Сбросить счетчик каждое Х сообщение", + "true": "Сбросить счетчик", + "false": "Продолжить подсчет" + }, + "viewersAtLeast": { + "label": "Зрители минимум", + "placeholder": "Сколько зрителей минимально для запуска события" + }, + "runInterval": { + "label": "Интервал запуска (0 = запуск один раз за стрим)", + "placeholder": "Срабатывание ивента каждую X секунду" + }, + "runAfterXMinutes": { + "label": "Запустить через X минут", + "placeholder": "Срабатывание ивента через Х минут" + }, + "runEveryXMinutes": { + "label": "Запускать каждые X минут", + "placeholder": "Срабатывание ивента каждую X минуту" + }, + "messageToSend": { + "label": "Сообщение для отправки", + "placeholder": "Установите ваше сообщение" + }, + "channel": { + "label": "Канал", + "placeholder": "Название или ID канала" + }, + "timeout": { + "label": "Timeout", + "placeholder": "Set timeout in milliseconds" + }, + "timeoutType": { + "label": "Type of timeout", + "placeholder": "Set type of timeout" + }, + "command": { + "label": "Command", + "placeholder": "Set your !command" + }, + "commandToRun": { + "label": "Команда для запуска", + "placeholder": "Установите вашу !commandToRun" + }, + "isCommandQuiet": { + "label": "Отключить вывод команды" + }, + "urlOfSoundFile": { + "label": "Ссылка на звуковой файл", + "placeholder": "http://www.pathToYour.url/where/is/file.mp3" + }, + "emotesToExplode": { + "label": "Смайлики для взрыва", + "placeholder": "Список смайлов для взрыва, например, Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Смайлы для фейерверка", + "placeholder": "Список смайлов для фейерверка, например, Kappa PurpleHeart" + }, + "replay": { + "label": "Повторить клип в наслоении", + "true": "Воспроизвести повтор в наслоениях/оповещениях", + "false": "Повтор не будет воспроизводиться" + }, + "announce": { + "label": "Объявить в чате", + "true": "Будет объявлено", + "false": "Не будет объявлено" + }, + "hasDelay": { + "label": "Клип должен иметь небольшую задержку (чтобы быть ближе к тому, что видит зритель)", + "true": "Будет задержка", + "false": "Не будет задержки" + }, + "durationOfCommercial": { + "label": "Продолжительность рекламы", + "placeholder": "Доступные длительности - 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Переменная для обновления" + }, + "numberToIncrement": { + "label": "Число для увеличения", + "placeholder": "" + }, + "value": { + "label": "Значение", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Число для уменьшения", + "placeholder": "" + }, + "": "", + "reward": { + "label": "Награда", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Followed you", + "raid": "Raided you with $viewers raiders.", + "sub": "Subscribed to you with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "has been gifted subscription from $username", + "subcommunitygift": "Gifted subscriptions for community", + "resub": "Resubscribed with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Cheered you", + "tip": "Tipped you", + "tipToCharity": "donated to $campaignName" + }, + "responses": { + "variable": { + "tags": "Метки", + "titleOfPrediction": "Twitch Prediction - Title", + "outcomes": "Twitch Prediction - Outcomes", + "locksAt": "Twitch Prediction - Locks At Date", + "winningOutcomeTitle": "Twitch Prediction - Winning outcome title", + "winningOutcomeTotalPoints": "Twitch Prediction - Winning outcome total points", + "winningOutcomePercentage": "Twitch Prediction - Winning outcome percentage", + "titleOfPoll": "Twitch опрос - Заголовок", + "bitAmountPerVote": "Twitch опрос - Количество битов для подсчета как 1 голос", + "bitVotingEnabled": "Twitch Poll - Is bit voting enabled (boolean)", + "channelPointsAmountPerVote": "Twitch Poll - Amount of channel points to count as 1 vote", + "channelPointsVotingEnabled": "Twitch Poll - Is channel points voting enabled (boolean)", + "votes": "Twitch опрос - количество голосов", + "winnerChoice": "Twitch Poll - Winner choice", + "winnerPercentage": "Twitch Poll - Winner choice percentage", + "winnerVotes": "Twitch Poll - Winner choice votes", + "goal": "Цель", + "total": "Всего", + "lastContributionTotal": "Последний взнос - Всего", + "lastContributionType": "Last Contribution - Type", + "lastContributionUserId": "Последний вклад - ID пользователя", + "lastContributionUsername": "Последний вклад - ID пользователя", + "level": "Уровень", + "topContributionsBitsTotal": "Top Bits Contribution - Total", + "topContributionsBitsUserId": "Top Bits Contribution - User ID", + "topContributionsBitsUsername": "Top Bits Contribution - Username", + "topContributionsSubsTotal": "Top Subs Contribution - Total", + "topContributionsSubsUserId": "Топ подписчик - ID пользователя", + "topContributionsSubsUsername": "Top Subs Contribution - Username", + "sender": "Инициатор", + "title": "Текущее звание", + "game": "Текущая категория", + "language": "Текущий язык стрима", + "viewers": "Текущее количество зрителей", + "hostViewers": "Raid viewers count", + "followers": "Текущее количество подписчиков", + "subscribers": "Текущее количество сабскрайберов", + "arg": "Параметр", + "param": "Параметр (обязательный)", + "touser": "Параметр как именя пользователя", + "!param": "Параметр (не обязательный)", + "alias": "Псевдоним", + "command": "Команда", + "keyword": "Ключевое слово", + "response": "Ответ", + "list": "Выбраный список", + "type": "Тип", + "days": "Дни", + "hours": "Часы", + "minutes": "Минуты", + "seconds": "Секунды", + "description": "Описание", + "quiet": "Тихий (bool)", + "id": "ID", + "name": "Название", + "messages": "Сообщения", + "amount": "Сумма", + "amountInBotCurrency": "Сумма в валюте бота", + "currency": "Валюта", + "currencyInBot": "Валюта в боте", + "pointsName": "Название поинтов", + "points": "Поинты", + "rank": "Ранг", + "nextrank": "Следующий ранг", + "username": "Имя пользователя", + "value": "Значение", + "variable": "Переменная", + "count": "Количество", + "link": "Ссылка (переведено)", + "winner": "Победитель", + "loser": "Проигравший", + "challenger": "Претендент", + "min": "Минимум", + "max": "Максимум", + "eligibility": "Право на участие", + "probability": "Вероятность", + "time": "Время", + "options": "Параметры", + "option": "Опция", + "when": "Когда", + "diff": "Различия", + "users": "Пользователи", + "user": "Пользователь", + "bank": "Банк", + "nextBank": "Следующий банк", + "cooldown": "Перезарядка", + "tickets": "Билеты", + "ticketsName": "Название билетов", + "fromUsername": "От имени", + "toUsername": "Для пользователя", + "items": "Предметы", + "bits": "Bits", + "subgifts": "Сабгифты", + "subStreakShareEnabled": "Делиться ли стриком саба (true/false)", + "subStreak": "Текущий стрик саба", + "subStreakName": "локализованное имя месяца (1 месяц, 2 месяца) для текущего стрика саба", + "subCumulativeMonths": "Общее время подписки", + "subCumulativeMonthsName": "локализованное имя месяца (1 месяц, 2 месяца) для текущего общего стрика саба", + "message": "Сообщение", + "reason": "Причина", + "target": "Цель", + "duration": "Длительность", + "method": "Способ", + "tier": "Тир", + "months": "Месяцы", + "monthsName": "локализованное название месяца (1 месяц, 2 месяца)", + "oldGame": "Категория перед изменением", + "recipientObject": "Полный объект получателя", + "recipient": "Получатель", + "ytSong": "Текущая песня с YouTube", + "spotifySong": "Текущая песня в Spotify", + "latestFollower": "Последний подписчик", + "latestSubscriber": "Последний сабскрайбер", + "latestSubscriberMonths": "Latest Subscriber cumulative months", + "latestSubscriberStreak": "Latest Subscriber months streak", + "latestTipAmount": "Последний донат (сумма)", + "latestTipCurrency": "Последний донат (валюта)", + "latestTipMessage": "Последний донат (сообщение)", + "latestTip": "Последний донат (имя пользователя)", + "toptip": { + "overall": { + "username": "Топ донат - общее (имя пользователя)", + "amount": "Топ донат - общее (сумма)", + "currency": "Топ донат - общее (валюта)", + "message": "Топ донат - общее (сообщение)" + }, + "stream": { + "username": "Топ донат - за стрим (имя пользователя)", + "amount": "Топ донат - за стрим (сумма)", + "currency": "Топ донат - за стрим (валюта)", + "message": "Топ донат - за стрим (сообщение)" + } + }, + "latestCheerAmount": "Последние биты (сумма)", + "latestCheerMessage": "Последние биты (сообщение)", + "latestCheer": "Последние биты (имя пользователя)", + "version": "Версия бота", + "haveParam": "Есть параметр команды? (bool)", + "source": "Текущий источник (twitch или discord)", + "userInput": "User input during reward redeem", + "isBotSubscriber": "Является подписчиком бота (bool)", + "isStreamOnline": "Трансляция онлайн (бул)", + "uptime": "Время работы трансляций", + "is": { + "moderator": "Пользователь модератор? (bool)", + "subscriber": "Пользователь сабскрайбер? (bool)", + "vip": "Пользователь вип? (bool)", + "newchatter": "Is user's first message? (bool)", + "follower": "Пользователь подписчик? (bool)", + "broadcaster": "Пользователь стример? (bool)", + "bot": "Пользователь бот? (bool)", + "owner": "Пользователь владелец бота? (bool)" + }, + "recipientis": { + "moderator": "Является ли получатель модератором? (bool)", + "subscriber": "Является ли получатель сабскрайбером? (bool)", + "vip": "Является ли получатель випом? (bool)", + "follower": "Является ли получатель фоловером? (bool)", + "broadcaster": "Является ли получатель броадкастером? (bool)", + "bot": "Является ли получатель ботом? (bool)", + "owner": "Является ли получатель владельцем бота? (bool)" + }, + "sceneName": "Имя сцены", + "inputName": "Name of input", + "inputMuted": "Mute state (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Другое", + "currency": "Валюта" + }, + "whispers": { + "title": "Висперсы", + "toggle": { + "listener": "Слушать команды в висперсах", + "settings": "Уведомлять в висперсе при изменении настроек", + "raffle": "Висперсы при присоединении к розыгрышу", + "permissions": "Висперсы при нехватки прав", + "cooldowns": "Висперсы при кулдауне" + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Сообщения", + "follows": "Подписки", + "subs": "Сабы & Ресабы", + "cheers": "Битсы", + "responses": "Ответы бота", + "whispers": "Висперсы", + "bans": "Баны", + "timeouts": "Таймауты" + }, + "range": { + "day": "день", + "week": "неделя", + "month": "месяц", + "year": "год", + "all": "Все время" + }, + "order": { + "asc": "По возрастанию", + "desc": "По убыванию" + }, + "labels": { + "order": "ЗАКАЗ", + "range": "ОБЛАСТЬ", + "filters": "ФИЛЬТРЫ" + } + }, + "stats-panel": { + "show": "Отображать статистику", + "hide": "Скрыть статистику" + }, + "translations": "Пользовательские переводы", + "bot-responses": "Ответы бота", + "duration": "Длительность", + "viewers-reset-attributes": "Сбросить все атрибуты", + "viewers-points-of-all-users": "Поинты всех пользователей", + "viewers-watchtime-of-all-users": "Время просмотра всех пользователей", + "viewers-messages-of-all-users": "Сообщения всех пользователей", + "events-game-after-change": "Категория после изменения", + "events-game-before-change": "category before change", + "events-user-triggered-event": "пользователь вызвавший событие", + "events-method-used-to-subscribe": "способ сабскрайба", + "events-months-of-subscription": "продолжительность сабскрайба", + "events-monthsName-of-subscription": "слово \"месяц\" по номеру (1 месяц, 2 месяца)", + "events-user-message": "сообщение пользователя", + "events-bits-user-sent": "битсов отправлено пользователем", + "events-reason-for-ban-timeout": "причина для бана/таймаута", + "events-duration-of-timeout": "продолжительность таймаута", + "events-duration-of-commercial": "продолжительность рекламы", + "overlays-eventlist-resub": "ресаб", + "overlays-eventlist-subgift": "сабгифт", + "overlays-eventlist-subcommunitygift": "саб подарок сообщества", + "overlays-eventlist-sub": "саб", + "overlays-eventlist-follow": "подписка", + "overlays-eventlist-cheer": "битсы", + "overlays-eventlist-tip": "донат", + "overlays-eventlist-raid": "рейд", + "requested-by": "Автор —", + "description": "Описание", + "raffle-type": "Тип розыгрыша", + "raffle-type-keywords": "Только ключевое слово", + "raffle-type-tickets": "С билетами", + "raffle-tickets-range": "Диапазон билетов", + "video_id": "ID видео", + "highlights": "Яркие моменты", + "cooldown-quiet-header": "Показать сообщение о кулдауне", + "cooldown-quiet-toggle-no": "Оповещать", + "cooldown-quiet-toggle-yes": "Не уведомлять", + "cooldown-moderators": "Модераторы", + "cooldown-owners": "Владельцы", + "cooldown-subscribers": "Сабскрайберы", + "cooldown-followers": "Подписчики", + "in-seconds": "в секундах", + "songs": "Песни", + "show-usernames-with-at": "Показать имена пользователей с @", + "send-message-as-a-bot": "Отправить сообщение как бот", + "chat-as-bot": "Чат (как бот)", + "product": "Товар", + "optional": "опционально", + "placeholder-search": "Поиск", + "placeholder-enter-product": "Введите имя товара", + "placeholder-enter-keyword": "Введите ключевое слово", + "credits": "Благодарность", + "fade-out-top": "затухание", + "fade-out-zoom": "масштаб затухания", + "global": "Все", + "user": "Пользователь", + "alerts": "Алерты", + "eventlist": "Список событий", + "dashboard": "Главная", + "carousel": "Карусель из изображений", + "text": "Текст", + "filter": "Filter", + "filters": "Filters", + "isUsed": "Is used", + "permissions": "Права доступа", + "permission": "Разрешение", + "viewers": "Зрителей", + "systems": "Системы", + "overlays": "Оверлеи", + "gallery": "Медиагалерея", + "aliases": "Псевдонимы", + "alias": "Псевдоним", + "command": "Команда", + "cooldowns": "Кулдауны", + "title-template": "Шаблон названия", + "keyword": "Ключевое слово", + "moderation": "Модерация", + "timer": "Таймер", + "price": "Цена", + "rank": "Ранг", + "previous": "Предыдущий", + "next": "Следующий", + "close": "Закрыть", + "save-changes": "Сохранить изменения", + "saving": "Сохранение...", + "deleting": "Удаление...", + "done": "Выполнено", + "error": "Ошибка", + "title": "Название", + "change-title": "Изменить заголовок", + "game": "category", + "tags": "Теги", + "change-game": "Изменить категорию", + "click-to-change": "нажмите, чтобы изменить", + "uptime": "uptime", + "not-affiliate-or-partner": "Не компаньон/партнер", + "not-available": "Недоступно", + "max-viewers": "Максимум зрителей", + "new-chatters": "Новые в чате", + "chat-messages": "Сообщения в чате", + "followers": "Фоловеров", + "subscribers": "Сабскрайберов", + "bits": "Битсов", + "subgifts": "Сабгифтов", + "subStreak": "Текущий стрик саба", + "subCumulativeMonths": "Общий стрик саба", + "tips": "Донаты", + "tier": "Тир", + "status": "Статус", + "add-widget": "Добавить виджет", + "remove-dashboard": "Удалить панель", + "close-bet-after": "Закрыть ставки через", + "refund": "возврат", + "roll-again": "Сделать еще раз", + "no-eligible-participants": "Нет подходящих участников", + "follower": "Подписчик", + "subscriber": "Сабкрайбер", + "minutes": "минуты", + "seconds": "секунды", + "hours": "часы", + "months": "месяцы", + "eligible-to-enter": "Eligible to enter", + "everyone": "Всем", + "roll-a-winner": "Выбрать победителя", + "send-message": "Отправить сообщение", + "messages": "Сообщения", + "level": "Уровень", + "create": "Создать", + "cooldown": "Кулдаун", + "confirm": "Подтвердить", + "delete": "Удалить", + "enabled": "Включено", + "disabled": "Выключено", + "enable": "Включить", + "disable": "Выключить", + "slug": "Slug", + "posted-by": "Автор", + "time": "Время", + "type": "Тип", + "response": "Ответ", + "cost": "Стоимость", + "name": "Название", + "playlist": "Плейлист", + "length": "Длина", + "volume": "Громкость", + "start-time": "Время начала", + "end-time": "Время окончания", + "watched-time": "Просмотренное время", + "currentsong": "Текущая песня", + "group": "Группа", + "followed-since": "Отслеживается с", + "subscribed-since": "Сабскрайб с", + "username": "Имя пользователя", + "hashtag": "Хэштег", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "Разрешения", + "last-seen": "Последняя активность", + "date": "Дата", + "points": "Поинты", + "calendar": "Календарь", + "string": "строка", + "interval": "Интервал", + "number": "число", + "minimal-messages-required": "Требуется минимально сообщений", + "max-duration": "Максимальная длительность", + "shuffle": "Перемешать", + "song-request": "Запрос песни", + "format": "Формат", + "available": "Доступно", + "one-record-per-line": "одна запись в строке", + "on": "вкл.", + "off": "выкл.", + "search-by-username": "Поиск по имени пользователя", + "widget-title-custom": "Пользовательский", + "widget-title-eventlist": "СПИСОК СОБЫТИЙ", + "widget-title-chat": "ЧАТ", + "widget-title-queue": "ОЧЕРЕДЬ", + "widget-title-raffles": "РОЗЫГРЫШИ", + "widget-title-social": "СОЦИАЛЬНЫЕ ССЫЛКИ", + "widget-title-ytplayer": "Музыкальный плеер", + "widget-title-monitor": "Монитор", + "event": "событие", + "operation": "операция", + "tweet-post-with-hashtag": "Тви запосчен с хэштегом", + "user-joined-channel": "пользователь присоединился к каналу", + "user-parted-channel": "пользователь вышел с канала", + "follow": "новый подписчик", + "tip": "новый донат", + "obs-scene-changed": "Сцена OBS изменена", + "obs-input-mute-state-changed": "OBS input source mute state changed", + "unfollow": "отписка", + "hypetrain-started": "Hype Train started", + "hypetrain-ended": "Hype Train ended", + "prediction-started": "Twitch Prediction started", + "prediction-locked": "Twitch Prediction locked", + "prediction-ended": "Twitch Prediction ended", + "poll-started": "Twitch опрос начался", + "poll-ended": "Голосование Twitch завершено", + "hypetrain-level-reached": "Hype Train new level reached", + "subscription": "новый сарскрайбер", + "subgift": "новый подаренный саб", + "subcommunitygift": "новый саб подарен комьюнити", + "resub": "пользователь ресабнулся", + "command-send-x-times": "команда была отправлена X раз", + "keyword-send-x-times": "ключевое слово было отправлено X раз", + "number-of-viewers-is-at-least-x": "количество зрителей минимум х", + "stream-started": "стрим запущен", + "reward-redeemed": "reward redeemed", + "stream-stopped": "стрим выключен", + "stream-is-running-x-minutes": "стрим идёт Х минут", + "chatter-first-message": "первое сообщение в чате", + "every-x-minutes-of-stream": "каждую Х минуту стрима", + "game-changed": "category changed", + "cheer": "received bits", + "clearchat": "чат был очищен", + "action": "пользователь использовал /me", + "ban": "пользователь заблокирован", + "raid": "ваш канал зарейдили", + "mod": "пользователь новый модератор", + "timeout": "пользователь получил таймаут", + "create-a-new-event-listener": "Создать новый обработчик событий", + "send-discord-message": "Отправить сообщение в discord", + "send-chat-message": "Отправить сообщение в чат (twitch)", + "send-whisper": "отправить сообщение в висперсы", + "run-command": "запустить команду", + "run-obswebsocket-command": "Выполнить команду OBS Websocket", + "do-nothing": "--- ничего не делать ----", + "count": "количество", + "timestamp": "отметка времени", + "message": "сообщение", + "sound": "звук", + "emote-explosion": "взрыв смайликов", + "emote-firework": "фейерверк смайликов", + "quiet": "тихий", + "noisy": "шумный", + "true": "true", + "false": "false", + "light": "светлая тема", + "dark": "темная тема", + "gambling": "Азартные игры", + "seppukuTimeout": "Таймаут для !seppuku", + "rouletteTimeout": "Таймаут для !roulette", + "fightmeTimeout": "Таймаут для !fightme", + "duelCooldown": "Кулдаун для !duel", + "fightmeCooldown": "Кулдаун для !fightme", + "gamblingCooldownBypass": "Игнорировать кулдаун азартных игр для модеров/стримера", + "click-to-highlight": "яркие моменты", + "click-to-toggle-display": "переключить отображение", + "commercial": "реклама запущена", + "start-commercial": "запустить рекламу", + "bot-will-join-channel": "бот присоединится к каналу", + "bot-will-leave-channel": "бот покинет канал", + "create-a-clip": "создать клип", + "increment-custom-variable": "увеличенить переменную", + "set-custom-variable": "задать пользовательскую переменную", + "decrement-custom-variable": "уменьшить переменную", + "omit": "пропустить", + "comply": "соблюдать", + "visible": "видимость", + "hidden": "скрытые", + "gamblingChanceToWin": "Шанс выиграть !gamble", + "gamblingMinimalBet": "Минимальная ставка для !gamble", + "duelDuration": "Длительность !duel", + "duelMinimalBet": "Минимальная ставка для !duel" + }, + "raffles": { + "announceInterval": "Открытые розыгрыши будет анонсированы каждые $value минут", + "eligibility-followers-item": "подписчики", + "eligibility-subscribers-item": "сабскрайберов", + "eligibility-everyone-item": "все", + "raffle-is-running": "Raffle запущен ($count $l10n_entries).", + "to-enter-raffle": "Розыгрыш запущен. Для входа введите $keyword. Розыгрыш открыт для $eligibility.", + "to-enter-ticket-raffle": "To enter type \"$keyword <$min-$max>\". Raffle is opened for $eligibility.", + "added-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-raffle}", + "added-ticket-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "Сообщения с розыгрышем будут удалены при присоединении.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "Извините, $sender, но вы не можете создавать розыгрыш без ключевого слова", + "raffle-is-already-running": "Извините, $sender, розыгрыш уже запущен с ключевым словом $keyword", + "no-raffle-is-currently-running": "$sender, в настоящее время никакие розыгрыши без победителей не запущены", + "no-participants-to-pick-winner": "$sender, никто не присоединился к розыгрышу", + "raffle-winner-is": "Победитель розыгрыша $keyword - $username! Шанс победить был $probability%!" + }, + "bets": { + "running": "$sender, ставки уже открыты! Варианты: $options. Используйте $command close 1-$maxIndex", + "notRunning": "На данный момент ставки не открыты, попросите модераторов открыть их!", + "opened": "Новая ставка '$title'' открыта! Опции: $options. Используйте $command 1-$maxIndex , чтобы победить! У вас есть $minutesмин для ставки!", + "closeNotEnoughOptions": "$sender, вы должны выбрать победный вариант для закрытия ставки.", + "notEnoughOptions": "$sender, новые ставки требуют не менее 2 вариантов!", + "info": "Ставка '$title' ' все еще открыта! Ставки на ставку: $options. Используйте $command 1-$maxIndex , чтобы победить! У вас есть $minutesmin для ставки!", + "diffBet": "$sender, вы уже сделали ставку на $option и вы не можете делать ставку на другой вариант!", + "undefinedBet": "Извините, $sender, но этот вариант не существует, используйте $command для проверки использования", + "betPercentGain": "Вариант $value% повышен по ставке", + "betCloseTimer": "Ставки будут автоматически закрыты после $valuemin", + "refund": "Ставки были закрыты без выигрыша. Всем пользователи получили возврат!", + "notOption": "$sender, этот вариант не существует! Ставка не закрыта, проверьте $command", + "closed": "Ставки были закрыты и победа была $option! Всего $amount пользователей выиграли $points $pointsName!", + "timeUpBet": "Думаю, ты опоздал, $sender, время делать ставки истекло!", + "locked": "Время ставок истекло! Больше ставок нет.", + "zeroBet": "$sender, вы не можете ставить 0 $pointsName", + "lockedInfo": "Ставка '$title' все еще открыта, время делать ставки вышло!", + "removed": "Время ставок истекло! Ставок не было -> автоматическое закрытие", + "error": "Извините, $sender, эта команда неправильная! Используйте $command 1-$maxIndex . Например, $command 0 100 поставит 100 очков за предмет 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, псевдоним $alias не найден в базе данных", + "alias-was-edited": "$sender, псевдоним $alias изменен на $command", + "alias-was-added": "$sender, псевдоним $alias для $command был добавлен", + "list-is-not-empty": "$sender, список псевдонимов: $list", + "list-is-empty": "$sender, список псевдонимов пуст", + "alias-was-enabled": "$sender, псевдоним $alias был включен", + "alias-was-disabled": "$sender, псевдоним $alias отключен", + "alias-was-concealed": "$sender, псевдоним $alias был скрыт", + "alias-was-exposed": "$sender, псевдоним $alias был открыт", + "alias-was-removed": "$sender, псевдоним $alias был удален", + "alias-group-set": "$sender, псевдоним $alias был установлен в группу $group", + "alias-group-unset": "$sender, псевдоним $alias удалён из группы", + "alias-group-list": "$sender, список групп псевдонимов: $list", + "alias-group-list-aliases": "$sender, список псевдонимов в $group: $list", + "alias-group-list-enabled": "$sender, алиасы в $group включены.", + "alias-group-list-disabled": "$sender, алиасы в $group отключены." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, команда $command не найдена в базе данных", + "response-was-not-found": "$sender, ответ #$response команды $command не найден в базе данных", + "command-was-edited": "$sender, команда $command изменена на '$response'", + "command-was-added": "$sender, команда $command добавлена", + "list-is-not-empty": "$sender, список команд: $list", + "list-is-empty": "$sender, список команд пуст", + "command-was-enabled": "$sender, команда $command включена", + "command-was-disabled": "$sender, команда $command отключена", + "command-was-concealed": "$sender, команда $command скрыта", + "command-was-exposed": "$sender, команда $command была открыта", + "command-was-removed": "$sender, команда $command удалена", + "response-was-removed": "$sender, ответ #$response команды $command был удален", + "list-of-responses-is-empty": "$sender, $command не имеет ответов или не существует", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, ключевое слово $keyword неоднозначно, используйте ID ключевого слова", + "keyword-was-not-found": "$sender, ключевое слово $keyword не найдено в базе данных", + "response-was-not-found": "$sender, ответ #$response ключевого слова $keyword не найден в базе данных", + "keyword-was-edited": "$sender, ключевое слово $keyword изменено на '$response'", + "keyword-was-added": "$sender, ключевое слово $keyword ($id) было добавлено", + "list-is-not-empty": "$sender, список ключевых слов: $list", + "list-is-empty": "$sender, список ключевых слов пуст", + "keyword-was-enabled": "$sender, ключевое слово $keyword было включено", + "keyword-was-disabled": "$sender, ключевое слово $keyword отключено", + "keyword-was-removed": "$sender, ключевое слово $keyword удалено", + "list-of-responses-is-empty": "$sender, $keyword не содержат ответов или не существует", + "response": "$keyword#$index ($permission) $after| $response" + }, + "points": { + "success": { + "undo": "$sender, очки '$command' для $username были отменены (с $updatedValue $updatedValuePointsLocale на $originalValue $originalValuePointsLocale).", + "set": "$username было установлено $amount $pointsName", + "give": "$sender только что дал $amount $pointsName $username", + "online": { + "positive": "Все онлайн-пользователи получили $amount $pointsName!", + "negative": "Все пользователи сети потеряли $amount $pointsName!" + }, + "all": { + "positive": "Все пользователи получили $amount $pointsName!", + "negative": "Все пользователи потеряли $amount $pointsName!" + }, + "rain": "Дождь! Все онлайн-пользователи могут получить до $amount $pointsName!", + "add": "$username получил $amount $pointsName!", + "remove": "Ой, $amount $pointsName было удалено у $username!" + }, + "failed": { + "undo": "$sender, имя пользователя не найдено в базе данных или у пользователя нет операций отмены", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Извините, $sender, у вас нет $amount $pointsName, чтобы дать $username", + "cannotGiveZeroPoints": "Извините, $sender, у вас нет $amount $pointsName, чтобы дать $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username у вас $amount $pointsName. Ваша позиция $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, список воспроизведения для импорта пуст", + "playlist-imported": "$sender, импортировано $imported и пропущено $skipped в плейлист", + "not-playing": "Не играет", + "song-was-banned": "Песня $name была забанена и никогда не будет играть снова!", + "song-was-banned-timeout-message": "Вы получили тайм-аут за размещение забаненной песни", + "song-was-unbanned": "Пользователь успешно разблокирован", + "song-was-not-banned": "Эта песня не была заблокирована", + "no-song-is-currently-playing": "Сейчас ничего не проигрывается", + "current-song-from-playlist": "Текущая песня $name из плейлиста", + "current-song-from-songrequest": "Текущая песня $name заказана $username", + "songrequest-disabled": "Извините, $sender , запросы на песню отключены", + "song-is-banned": "Извините, $sender, но эта песня забанена", + "youtube-is-not-responding-correctly": "К сожалению, $sender, но YouTube отправляет неожиданные ответы, пожалуйста, повторите попытку позже.", + "song-was-not-found": "Извините, $sender, но эта песня не найдена", + "song-is-too-long": "Извините, $sender, но эта песня слишком длинная", + "this-song-is-not-in-playlist": "Извините, $sender, но эта песня нет в текущем плейлисте", + "incorrect-category": "Извините, $sender, но эта песня должна быть в категории музыка", + "song-was-added-to-queue": "$sender, песня $name была добавлена в очередь", + "song-was-added-to-playlist": "$sender, песня $name добавлена в плейлист", + "song-is-already-in-playlist": "$sender, песня $name уже в плейлисте", + "song-was-removed-from-playlist": "$sender, песня $name удалена из плейлиста", + "song-was-removed-from-queue": "$sender, ваша песня $name была удалена из очереди", + "playlist-current": "$sender, текущий плейлист $playlist.", + "playlist-list": "$sender, доступные плейлисты: $list.", + "playlist-not-exist": "$sender, ваш запрошенный список воспроизведения $playlist не существует.", + "playlist-set": "$sender, вы изменили плейлист на $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, цена $command установлена в $amount $pointsName", + "price-was-unset": "$sender, цена за $command была убрана", + "price-was-not-found": "$sender, цена для $command не найдена", + "price-was-enabled": "$sender, цена для $command включена", + "price-was-disabled": "$sender, цена для $command отключена", + "user-have-not-enough-points": "Извините, $sender, но у вас нет $amount $pointsName для использования $command", + "user-have-not-enough-points-or-bits": "Извините, $sender, но у вас нет $amount $pointsName или вы можете использовать команду $bitsAmount бит, чтобы использовать $command", + "user-have-not-enough-bits": "Извините, $sender, но вы должны активировать команду $bitsAmount бит, чтобы использовать $command", + "list-is-empty": "$sender, список цен пуст", + "list-is-not-empty": "$sender, список цен: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, добавлен новый ранг $type $rank($hours$hlocale)", + "rank-was-edited": "$sender, ранг $type $hours$hlocale был изменён на $rank", + "rank-was-removed": "$sender, ранг $type $hours$hlocale был удалён", + "rank-already-exist": "$sender, уже есть ранг для $type $hours$hlocale", + "rank-was-not-found": "$sender, ранг $type $hours$hlocale не был найден", + "custom-rank-was-set-to-user": "$sender, вы установили $rank для $username", + "custom-rank-was-unset-for-user": "$sender, пользовательский ранг для $username был убран", + "list-is-empty": "$sender, ранги не были найдены", + "list-is-not-empty": "$sender, список рангов: $list", + "show-rank-without-next-rank": "$sender, у вас $rank ранг", + "show-rank-with-next-rank": "$sender, у вас $rank ранг. Следующий ранг - $nextrank", + "user-dont-have-rank": "$sender, у вас пока нет ранга" + }, + "followage": { + "success": { + "never": "$sender, $username не является подписчиком", + "time": "$sender, $username подписан $diff" + }, + "successSameUsername": { + "never": "$sender, вы не подписаны на канал", + "time": "$sender, вы подписаны $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username не сабскрайбер.", + "notNow": "$sender, $username в настоящее время не является сабскрайбером. Общий стрик $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username сабскрайбер $diff ($subStreak $subStreakMonthsName). Общий стрик $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username сабскрайбер. Общий стрик $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, вы не сабскрайбер.", + "notNow": "$sender, в настоящее время не является сабскрайбером. Общий стрик $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, сабскрайбер $diff ($subStreak $subStreakMonthsName). Общий стрик $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, сабскрайбер. Общий стрик $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, у меня нет данных по $username", + "success": { + "withUsername": "$sender, возраст учетной записи $username - $diff", + "withoutUsername": "$sender, ваш возраст аккаунта $diff" + } + }, + "lastseen": { + "success": { + "never": "$username никогда не был на этом канале!", + "time": "$username последний раз был $when" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username смотрел стрим $time часов" + }, + "failed": { + "parse": "{core.command-parse} !watched или !watched [username]" + } + }, + "permissions": { + "without-permission": "У вас недостаточно прав для '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, пользователь $username имеет иммунитет $type на $time секунд", + "user-have-immunity-parameterError": "$sender, ошибка параметра. $command ", + "user-have-link-permit": "Пользователь $username может отправить $count $link в чат", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "Ссылки запрещены, запросите !permit [$count предупреждений осталось]", + "user-is-warned-about-symbols": "Спам символами запрещён [$count предупреждений осталось]", + "user-is-warned-about-long-message": "Длинные сообщения недопустимы [$count предупреждений]", + "user-is-warned-about-caps": "Капс запрещён [$count предупреждений]", + "user-is-warned-about-spam": "Спам запрещён [ осталось предупреждений$count]", + "user-is-warned-about-color": "Курсив и /me недопустимы [$count предупреждений осталось]", + "user-is-warned-about-emotes": "Спам смайликами запрещён [$count предупреждений осталось]", + "user-is-warned-about-forbidden-words": "Слово в чёрном списке [$count предупреждений осталось]", + "user-have-timeout-for-links": "Ссылки запрещены, попросите !permit", + "user-have-timeout-for-symbols": "Чрезмерное кол-во символов", + "user-have-timeout-for-long-message": "Длинные сообщения запрещены", + "user-have-timeout-for-caps": "Чрезмерное использование капса", + "user-have-timeout-for-spam": "Спам запрещён", + "user-have-timeout-for-color": "Курсив и /me не допускаются", + "user-have-timeout-for-emotes": "Спам смайликами запрещён", + "user-have-timeout-for-forbidden-words": "Запрещенные слова" + }, + "queue": { + "list": "$sender, текущий пул очереди: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Извините $sender, очередь в данный момент закрыта", + "opened": "$sender был добавлен в очередь" + }, + "open": "Очередь в настоящее время открыта! Присоединиться к очереди !очередью", + "close": "Извините, очередь в данный момент закрыта!", + "clear": "Очередь полностью очищена", + "picked": { + "single": "Этот пользователь был выбран из очереди: $users", + "multi": "Эти пользователи были выбраны из очереди: $users", + "none": "Пользователи не найдены в очереди" + } + }, + "marker": "Stream marker has been created at $time.", + "title": { + "current": "$sender, название стрима '$title'.", + "change": { + "success": "$sender, название установлено на: $title" + } + }, + "game": { + "current": "$sender, стример сейчас играет в $game.", + "change": { + "success": "$sender, категория изменена на: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type кулдаун $command установлен на $secondss", + "cooldown-was-unset": "$sender, $type перезарядка $command была убрана", + "cooldown-triggered": "$sender, '$command' перезаряжается, осталось $secondss", + "cooldown-not-found": "$sender, кулдаун $command не найден", + "cooldown-was-enabled": "$sender, кулдаун $command включён", + "cooldown-was-disabled": "$sender, кулдаун $command выключен", + "cooldown-was-enabled-for-moderators": "$sender, кулдаун $command включён для модераторов", + "cooldown-was-disabled-for-moderators": "$sender, кулдаун $command выключен для модераторов", + "cooldown-was-enabled-for-owners": "$sender, кулдаун $command включён для владельцев бота", + "cooldown-was-disabled-for-owners": "$sender, кулдаун $command выключен для владельцев бота", + "cooldown-was-enabled-for-subscribers": "$sender, кулдаун $command включён для сабскрайберов", + "cooldown-was-disabled-for-subscribers": "$sender, кулдаун $command выключен для сабскрайберов", + "cooldown-was-enabled-for-followers": "$sender, кулдаун $command включён для фоловеров", + "cooldown-was-disabled-for-followers": "$sender, кулдаун $command выключён для фоловеров" + }, + "timers": { + "id-must-be-defined": "$sender, должен быть определен id ответа.", + "id-or-name-must-be-defined": "$sender, идентификатор ответа или таймера должны быть определены.", + "name-must-be-defined": "$sender, имя таймера должно быть определено.", + "response-must-be-defined": "$sender, ответ таймера должен быть определен.", + "cannot-set-messages-and-seconds-0": "$sender, вы не можете установить сообщения и секунды в 0.", + "timer-was-set": "$sender, таймер $name был установлен с $messages сообщениями и $seconds секунд для срабатывания", + "timer-was-set-with-offline-flag": "$sender, таймер $name был установлен с $messages сообщениями и $seconds секунд, чтобы срабатывать даже когда поток не в сети", + "timer-not-found": "$sender, таймер (имя: $name) не найден в базе данных. Проверьте таймеры с помощью списка !timers list", + "timer-deleted": "$sender, таймер $name и его ответы были удалены.", + "timer-enabled": "$sender, таймер (имя: $name) включен", + "timer-disabled": "$sender, таймер (имя: $name) отключен", + "timers-list": "$sender, список таймеров: $list", + "responses-list": "$sender, таймер (имя: $name) список", + "response-deleted": "$sender, ответ (id: $id) был удален.", + "response-was-added": "$sender, ответ (id: $id) для таймера (имя: $name) был добавлен - '$response'", + "response-not-found": "$sender, ответ (id: $id) не найден в базе данных", + "response-enabled": "$sender, ответ (id: $id) был включен", + "response-disabled": "$sender, ответ (id: $id) отключен" + }, + "gambling": { + "duel": { + "bank": "$sender, текущий банк для $command - $points $pointsName", + "lowerThanMinimalBet": "$sender, минимальная ставка для $command - $points $pointsName", + "cooldown": "$sender, вы не можете использовать $command ещё $cooldown $minutesName.", + "joined": "$sender, удачи с вашими навыками дуэлянта. Вы поставили $points $pointsName!", + "added": "$sender действительно думает, что он лучше, чем другие, подняв свою ставку до $points $pointsName!", + "new": "$sender является вашим новым испытанием! Чтобы принять участие пишите $command [points], у вас осталось $minutes $minutesName для вступления.", + "zeroBet": "$sender, вы не можете ставить 0 $pointsName", + "notEnoughOptions": "$sender, вам нужно указать кол-во поинтовдля дуэли", + "notEnoughPoints": "$sender, у вас нет $points $pointsName для дуэли!", + "noContestant": "Только $winner имеет мужество вступить в поединок! Ваша ставка $points $pointsName возвращена вам.", + "winner": "Поздравляем игрока $winner! Он последний выживший и выигрывает $points $pointsName ($probability% от ставки $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender играет с удачей и ему не везёт", + "alive": "$sender жив! Ничего не произошло.", + "dead": "Мозги $sender был размазан по стене!", + "mod": "$sender просто напросто не может попасть в свой голову!", + "broadcaster": "$sender стреляет себе в ногу!", + "timeout": "Таймаут рулетки установлен на $values" + }, + "gamble": { + "chanceToWin": "$sender, шанс выиграть в !gamble установлен на $value%", + "zeroBet": "$sender, вы не можете ставить 0 $pointsName", + "minimalBet": "$sender, минимальная ставка для !gamble установлена на $value", + "lowerThanMinimalBet": "$sender, минимальная ставка для !gamble равна $points $pointsName", + "notEnoughOptions": "$sender, вам нужно указать кол-во поинтов для игры", + "notEnoughPoints": "$sender, у вас нет $points $pointsName для игры", + "win": "$sender, вы выиграли! Теперь у вас $points $pointsName", + "winJackpot": "$sender, вы получили JACKPOT! Вы выиграли $jackpot $jackpotName помимо вашей ставки. Теперь у вас $points $pointsName", + "loseWithJackpot": "$sender, вы ПРОИГРАЛИ! Теперь у вас $points $pointsName. Джекпот увеличился до $jackpot $jackpotName", + "lose": "$sender, вы проиграли! Теперь у вас $points $pointsName", + "currentJackpot": "$sender, текущий джекпот для $command равен $points $pointsName", + "winJackpotCount": "$sender, вы выиграли $count джекпотов", + "jackpotIsDisabled": "$sender, джекпот отключен для $command." + } + }, + "highlights": { + "saved": "$sender, яркий момент был сохранён на отметке $hoursч$minutesм$secondsc", + "list": { + "items": "$sender, список сохраненных ярких моментов для последнего стрима: $items", + "empty": "$sender, никаких ярких событий не было сохранено" + }, + "offline": "$sender, невозможно сохранить яркий момент, стрим оффлайн" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "Bot won't send errors on insufficient permissions", + "false": "Bot won't send errors on insufficient permissions through whispers" + }, + "disableCooldownWhispers": { + "true": "Бот не будет отправлять уведомления о кулдауне", + "false": "Бот будет отправлять уведомления о кулдауне в висперсы" + } + } + }, + "time": "Текущее время в часовом поясе стримера: $time", + "subs": "$sender, сейчас $onlineSubCount онлайн сабскрайберов. Последний sub/resub был $lastSubUsername $lastSubAgo", + "followers": "$sender, last follow was $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, пользователь $username не игнорируется ботом" + }, + "added": "$sender, пользователь $username добавлен в игнорируемый ботом список", + "removed": "$sender, пользователь $username убран из из списка игнорируемых", + "ignored": "$sender, пользователь $username игнорируется ботом" + } + } + }, + "filters": { + "setVariable": "$sender, $variable изменено на $value." + } +} diff --git a/backend/locales/ru/api.clips.json b/backend/locales/ru/api.clips.json new file mode 100644 index 000000000..1ad46de95 --- /dev/null +++ b/backend/locales/ru/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "Клип создан и доступен по ссылке $link" +} \ No newline at end of file diff --git a/backend/locales/ru/core/permissions.json b/backend/locales/ru/core/permissions.json new file mode 100644 index 000000000..8bf598a38 --- /dev/null +++ b/backend/locales/ru/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "Список ваших прав:", + "excludeAddSuccessful": "$sender, вы добавили $username для исключения в группе $permissionName", + "excludeRmSuccessful": "$sender, вы добавили $username для исключения в группе $permissionName", + "userNotFound": "$sender, пользователь $username не найден в базе данных.", + "permissionNotFound": "$sender, право $userlevel не найдено в базе данных.", + "cannotIgnoreForCorePermission": "$sender, вы не можете вручную исключить пользователя для права $userlevel" +} \ No newline at end of file diff --git a/backend/locales/ru/games.heist.json b/backend/locales/ru/games.heist.json new file mode 100644 index 000000000..b40332d19 --- /dev/null +++ b/backend/locales/ru/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, копы все еще ищут последнюю команду ограбления. Попробуйте еще раз через $cooldown.", + "copsCooldownMessage": "Окей ребят, похоже на то, что полицейские едят свои жирные пончики, и мы можем получить эти сладкие деньги!", + "entryMessage": "$sender начал планировать рейд на банк! Ищите банду что бы утащить с собой побольше. Присоединяйтесь! Введите $command , чтобы войти.", + "lateEntryMessage": "$sender, в настоящее время уже проводиться ограбление!", + "entryInstruction": "$sender, наберите $command для входа.", + "levelMessage": "С такой крутой командой мы можем ограбить $bank, давай посмотрим, сможем ли мы напасть на $nextBank", + "maxLevelMessage": "С такой командой мы можем ограбить аж $bank! Лучше и быть не может!", + "started": "Внимательно, ребят. Проверьте вашу экипировку, это то, к чему мы готовилисб! Это не игра, а реальная жизнь. Давайте уже ограбим $bank!", + "noUser": "Никто не присоединился к команде для ограбления.", + "singleUserSuccess": "$user был похож на ниндзю. Никто не заметил пропавших денег.", + "singleUserFailed": "$user не смог избавиться от полиции и попадает в тюрму.", + "result": { + "0": "Все были безжалостно уничтожены. Это бойня.", + "33": "Только 1/3 от команды получат деньги.", + "50": "Половина команды была убита полицийскими.", + "99": "Такие небольшие потери команды ничто, по сравнению сколько денег мы срубили.", + "100": "Никто не умер! Все выжили!" + }, + "levels": { + "bankVan": "Маленький банк", + "cityBank": "Городской банк", + "stateBank": "Государственный банк", + "nationalReserve": "Национальный резерв", + "federalReserve": "Федеральный резерв" + }, + "results": "Выплаты за ограбление: $users", + "andXMore": "и еще $count..." +} \ No newline at end of file diff --git a/backend/locales/ru/integrations/discord.json b/backend/locales/ru/integrations/discord.json new file mode 100644 index 000000000..da7096011 --- /dev/null +++ b/backend/locales/ru/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "ваш аккаунт не привязан, используйте `$command`", + "all-your-links-were-deleted": "привязанные аккаунты были удалены", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender, к вашему аккаунту привязан $discordTag.", + "invalid-or-expired-token": "$sender, неверный или устаревший токен.", + "help-message": "$sender, для привязки вашей учётной записи Discord'а: 1. Перейдите на сервер Discord и отправьте $command в канале бота | 2. Дождитесь личного сообщения от бота | 3. Отправьте сгенерированную ботом команду в twitch чат.", + "started-at": "Запущен в", + "announced-by": "Автоматическое оповещение | sogeBot", + "streamed-at": "Стрим шёл", + "link-whisper": "Здравствуйте, $tag, чтобы связать эту учётную запись Discord с Вашей учётной записью в Twitch на канале $broadcaster, перейдите на , отправьте эту команду в чат \n\n\t\t`$command $id`\n\nПРИМЕЧАНИЕ: Срок действия токена истечёт через 10 минут.", + "check-your-dm": "проверьте свои ЛС для того, чтобы привязать ваш аккаунт." +} \ No newline at end of file diff --git a/backend/locales/ru/integrations/lastfm.json b/backend/locales/ru/integrations/lastfm.json new file mode 100644 index 000000000..87bb88b59 --- /dev/null +++ b/backend/locales/ru/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "Текущая песня $name" +} \ No newline at end of file diff --git a/backend/locales/ru/integrations/obswebsocket.json b/backend/locales/ru/integrations/obswebsocket.json new file mode 100644 index 000000000..c4e76ba3b --- /dev/null +++ b/backend/locales/ru/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, не установлено действие для id:$id!", + "ParameterError": "$sender, вы должны указать id!", + "UnknownError": "$sender, что-то пошло не так. Проверьте логи ботов для дополнительной информации." + } +} \ No newline at end of file diff --git a/backend/locales/ru/integrations/protondb.json b/backend/locales/ru/integrations/protondb.json new file mode 100644 index 000000000..027daad8f --- /dev/null +++ b/backend/locales/ru/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | Рейтинг $rating | Родитель на $native | Подробности: $url", + "responseNg": "Рейтинг для игры $game не найден в ProtonDB.", + "responseNotFound": "Игра $game не найдена в ProtonDB." +} \ No newline at end of file diff --git a/backend/locales/ru/integrations/pubg.json b/backend/locales/ru/integrations/pubg.json new file mode 100644 index 000000000..d4cfd2fc7 --- /dev/null +++ b/backend/locales/ru/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, ожидался один из этих параметров: $list" +} \ No newline at end of file diff --git a/backend/locales/ru/integrations/spotify.json b/backend/locales/ru/integrations/spotify.json new file mode 100644 index 000000000..1dcf5d11b --- /dev/null +++ b/backend/locales/ru/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "$sender к сожалению, трек не найден в spotify", + "song-requested": "$sender, вы заказали песню $artist - $name", + "not-banned-song-not-playing": "$sender, в настоящее время песня не проигрывается.", + "song-banned": "$sender, песня $name от $artist забанена.", + "song-unbanned": "$sender, песня $name от $artist разбанена.", + "song-not-found-in-banlist": "$sender, песня $uri не найдена в списке запретов.", + "cannot-request-song-is-banned": "$sender, не может запросить забаненную песню $name от $artist.", + "cannot-request-song-from-unapproved-artist": "$sender, невозможно запустить песню от неподтвержденного артиста.", + "no-songs-found-in-history": "$sender, в настоящее время нет песен в истории.", + "return-one-song-from-history": "$sender, предыдущая песня $name от $artist.", + "return-multiple-song-from-history": "$sender, $count предыдущих песен:", + "return-multiple-song-from-history-item": "$index - $name от $artist", + "song-notify": "Текущая песня $name - $artist." +} \ No newline at end of file diff --git a/backend/locales/ru/integrations/tiltify.json b/backend/locales/ru/integrations/tiltify.json new file mode 100644 index 000000000..aa574fb09 --- /dev/null +++ b/backend/locales/ru/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, there are currently no active campaigns.", + "active_campaigns": "$sender, list of currently active campaigns:" +} \ No newline at end of file diff --git a/backend/locales/ru/systems.quotes.json b/backend/locales/ru/systems.quotes.json new file mode 100644 index 000000000..bda8b9347 --- /dev/null +++ b/backend/locales/ru/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, цитата $id '$quote' (теги: $tags)", + "error": "$sender, $command неправильно или отсутствует -quote параметр" + }, + "remove": { + "ok": "$sender, цитата $id была успешно удалена.", + "error": "$sender, ID цитаты отсутствует.", + "not-found": "$sender, цитата $id не найдена." + }, + "show": { + "ok": "Цитата $id от $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command отсутствует -id или -tag.", + "not-found-by-id": "$sender, цитата $id не найдена.", + "not-found-by-tag": "$sender, цитаты с тегом $tag не найдены." + } + }, + "set": { + "ok": "$sender, тэги $id были установлены. (тэги: $tags)", + "error": { + "no-parameters": "$sender, $command отсутствует -id или -tag.", + "not-found-by-id": "$sender, цитата $id не найдена." + } + }, + "list": { + "ok": "$sender, Вы можете найти список цитат по адресу http://$urlBase/public/#/quotes", + "is-localhost": "$sender, URL списка цитат указан неправильно." + } +} \ No newline at end of file diff --git a/backend/locales/ru/systems/antihateraid.json b/backend/locales/ru/systems/antihateraid.json new file mode 100644 index 000000000..98ae5e8b2 --- /dev/null +++ b/backend/locales/ru/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "This chat was set to $mode by $username to get rid of hate raid. Sorry for inconvenience!", + "mode": { + "0": "только подписчики ", + "1": "follow-only", + "2": "emotes-only" + } +} \ No newline at end of file diff --git a/backend/locales/ru/systems/howlongtobeat.json b/backend/locales/ru/systems/howlongtobeat.json new file mode 100644 index 000000000..1207a56f7 --- /dev/null +++ b/backend/locales/ru/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game не найдена в db.", + "game": "$sender, $game | Главное: $currentMain/$hltbMainh - $percentMain% | Главное+Экстра: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Completionist: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Главное: $currentMainh | Главное+Экстра: $currentMainExtrah | Completionist: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/ru/systems/levels.json b/backend/locales/ru/systems/levels.json new file mode 100644 index 000000000..9b352aa3d --- /dev/null +++ b/backend/locales/ru/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, уровень: $currentLevel ($currentXP $xpName), $nextXP $xpName на следующий уровень.", + "changeXP": "$sender, вы изменили $xpName от $amount $xpName на $username.", + "notEnoughPointsToBuy": "Извините $sender, но у вас нет $points $pointsName, чтобы купить $amount $xpName за $level уровень.", + "XPBoughtByPoints": "$sender, вы купили $amount $xpName за $points $pointsName и достигли уровня $level.", + "somethingGetWrong": "$sender, что-то пошло не так с вашим запросом." +} \ No newline at end of file diff --git a/backend/locales/ru/systems/scrim.json b/backend/locales/ru/systems/scrim.json new file mode 100644 index 000000000..7fcc4a1ef --- /dev/null +++ b/backend/locales/ru/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Снайперский матч ($type), начинается через $time $unit", + "go": "Начинаем прямо сейчас!", + "putMatchIdInChat": "Пожалуйста, введите ваш ID матча в чат => $command xxx", + "currentMatches": "Текущие матчи: $matches", + "stopped": "Матч со снайпами отменены." +} \ No newline at end of file diff --git a/backend/locales/ru/systems/top.json b/backend/locales/ru/systems/top.json new file mode 100644 index 000000000..80ae02205 --- /dev/null +++ b/backend/locales/ru/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Топ $amount (время просмотра): ", + "tips": "Топ $amount (донат): ", + "level": "Топ $amount (уровень): ", + "points": "Топ $amount (поинты): ", + "messages": "Топ $amount (сообщения): ", + "followage": "Топ $amount (время подписки): ", + "subage": "Топ $amount (время сабскрайба): ", + "submonths": "Топ $amount (общее время сабскрайба): ", + "bits": "Топ $amount (битсы): ", + "gifts": "Топ $amount (сабгифты): " +} \ No newline at end of file diff --git a/backend/locales/ru/ui.commons.json b/backend/locales/ru/ui.commons.json new file mode 100644 index 000000000..47f8492b9 --- /dev/null +++ b/backend/locales/ru/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Дополнительные параметры", + "never": "никогда", + "reset": "сброс", + "moveUp": "поднять", + "moveDown": "опустить", + "stop-if-executed": "остановить, если выполнено", + "continue-if-executed": "продолжить, если будет выполнено", + "generate": "Генерировать", + "thumbnail": "Превью", + "yes": "Да", + "no": "Нет", + "show-more": "Показать больше", + "show-less": "Показывать меньше", + "allowed": "Разрешено", + "disallowed": "Запрещено", + "back": "Назад" +} diff --git a/backend/locales/ru/ui.dialog.json b/backend/locales/ru/ui.dialog.json new file mode 100644 index 000000000..dde9f301d --- /dev/null +++ b/backend/locales/ru/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Редактировать", + "add": "Добавить" + }, + "position": { + "settings": "Дополнительные параметры", + "anchorX": "Позиция X курсора", + "anchorY": "Позиция Y курсора", + "left": "Слева", + "right": "Справа", + "middle": "Средний", + "top": "Вверх", + "bottom": "Внизу", + "x": "Х", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Сдвиг вправо", + "shadowShiftDown": "Сдвиг вниз", + "shadowBlur": "Размытие", + "shadowOpacity": "Прозрачность", + "color": "Цвет" + }, + "errors": { + "required": "Это поле не может быть пустым.", + "minValue": "Наименьшее значение этого поля равно $value." + }, + "buttons": { + "reorder": "Изменить порядок", + "upload": { + "idle": "Загрузить", + "progress": "Загружается", + "done": "Загружено" + }, + "cancel": "Отменить", + "close": "Закрыть", + "test": { + "idle": "Тест", + "progress": "Тестирование в процессе", + "done": "Тестирование завершено" + }, + "saveChanges": { + "idle": "Сохранить изменения", + "invalid": "Не удается сохранить изменения", + "progress": "Сохранение изменений", + "done": "Изменения сохранены" + }, + "something-went-wrong": "Что-то пошло не так", + "mark-to-delete": "Отметить для удаления", + "disable": "Выключить", + "enable": "Включить", + "disabled": "Выключено", + "enabled": "Включено", + "edit": "Редактировать", + "delete": "Удалить", + "play": "Прослушать", + "stop": "Остановить", + "hold-to-delete": "Удерживайте, чтобы удалить", + "yes": "Да", + "no": "Нет", + "permission": "Разрешение", + "group": "Группа", + "visibility": "Видимость", + "reset": "Сброс " + }, + "changesPending": "Ваши изменения не были сохранены.", + "formNotValid": "Форма неверна.", + "nothingToShow": "Нечего показывать" +} \ No newline at end of file diff --git a/backend/locales/ru/ui.menu.json b/backend/locales/ru/ui.menu.json new file mode 100644 index 000000000..1ea50c7d8 --- /dev/null +++ b/backend/locales/ru/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Сервисы", + "updater": "Обновить", + "index": "Панель управления", + "core": "Бот", + "users": "Пользователи", + "tmi": "TMI", + "ui": "Интерфейс", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "Общее", + "timers": "Таймеры", + "new": "Новый объект", + "keywords": "Ключевые слова", + "customcommands": "Пользовательские команды", + "botcommands": "Команды бота", + "commands": "Команды", + "events": "События", + "ranks": "Ранги", + "songs": "Песни", + "modules": "Модули", + "viewers": "Зрители", + "alias": "Псевдонимы", + "cooldowns": "Перезарядка", + "cooldown": "Перезарядка", + "highlights": "Яркие моменты", + "price": "Цены", + "logs": "Логи", + "systems": "Системы", + "permissions": "Права доступа", + "translations": "Пользовательские переводы", + "moderation": "Модерация", + "overlays": "Оверлеи", + "gallery": "Медиа галлерея", + "games": "Игры", + "spotify": "Spotify", + "integrations": "Интеграции", + "customvariables": "Пользовательские переменные", + "registry": "Реестр", + "quotes": "Цитаты", + "settings": "Настройки", + "commercial": "Реклама", + "bets": "Ставки", + "points": "Поинты", + "raffles": "Розыгрыши", + "queue": "Очередь", + "playlist": "Плейлист", + "bannedsongs": "Заблокированные песни", + "spotifybannedsongs": "Spotify забаненные песни", + "duel": "Дуэль", + "fightme": "FightMe", + "seppuku": "Seppuku", + "gamble": "Gamble", + "roulette": "Рулетка", + "heist": "Ограбление", + "oauth": "OAuth", + "socket": "Ядро", + "carouseloverlay": "Carousel overlay", + "alerts": "Алерты", + "carousel": "Карусель изображений", + "clips": "Клипы", + "credits": "Благодарность", + "emotes": "Смайлики", + "stats": "Статистика", + "text": "Текст", + "currency": "Валюта", + "eventlist": "Список событий", + "clipscarousel": "Карусель клипов", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Checklist", + "bot": "Бот", + "api": "API", + "manage": "Управлeние", + "top": "Топ", + "goals": "Цели", + "userinfo": "Информация о пользователе", + "scrim": "Scrim", + "commandcount": "Кол-во команд", + "profiler": "Профиль", + "howlongtobeat": "Время прохождения", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Рандомайзер", + "tips": "Донаты", + "bits": "Битсы", + "discord": "Discord", + "texttospeech": "Текст для озвучивания", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "Уровень", + "obswebsocket": "OBS Websocket", + "api-explorer": "Проводник API", + "emotescombo": "Комбо эмоций", + "notifications": "Уведомления", + "plugins": "Модули", + "tts": "TTS" +} diff --git a/backend/locales/ru/ui.page.settings.overlays.carousel.json b/backend/locales/ru/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..b44ed8420 --- /dev/null +++ b/backend/locales/ru/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "параметры", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Вы уверены, что хотите удалить изображение?" + }, + "button": { + "update": "Обновить", + "fix_your_errors_first": "Исправьте ошибки перед сохранением" + }, + "errors": { + "number_greater_or_equal_than_0": "Значение должно быть числом >= 0", + "value_must_not_be_empty": "Значение не должно быть пустым" + }, + "titles": { + "waitBefore": "Ожидание перед показом изображения (миллисекунды)", + "waitAfter": "Ожидание перед исчезанием изображения (миллисекунды)", + "duration": "Как долго должно быть показано изображение (в мс)", + "animationIn": "Анимация в", + "animationOut": "Анимация из", + "animationInDuration": "Длительность анимации В (в миллисекундах)", + "animationOutDuration": "Длительность анимации ИЗ (в миллисекундах)", + "showOnlyOncePerStream": "Показывать только один раз за стрим" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.registry.customvariables.json b/backend/locales/ru/ui.registry.customvariables.json new file mode 100644 index 000000000..816536575 --- /dev/null +++ b/backend/locales/ru/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "Ссылки", + "generateurl": "Генерировать URL", + "show-examples": "Показать CURL примеры", + "response": { + "show": "Показать ответ после POST", + "name": "Ответ после установки переменной", + "default": "По умолчанию", + "default-placeholder": "Установите ответ бота", + "default-help": "Используйте $value, чтобы получить новое значение переменной", + "custom": "Индивидуальный", + "command": "Команда" + }, + "useIfInCommand": "Используйте, если вы используете переменную в команде. Возвращает только обновленную переменную без ответа.", + "permissionToChange": "Разрешение на изменение", + "isReadOnly": "только для чтения в чате", + "isNotReadOnly": "можно изменить в чате", + "no-variables-found": "Переменных не найдено", + "additional-info": "Дополнительная информация", + "run-script": "Запустить скрипт", + "last-run": "Последний запуск", + "variable": { + "name": "Имя переменной", + "help": "Имя переменной должно быть уникальным, например $_wins, $_loses, $_top3", + "placeholder": "Введите уникальное имя переменной", + "error": { + "isNotUnique": "Variable must have unique name.", + "isEmpty": "Имя переменной не должно быть пустым." + } + }, + "description": { + "name": "Описание", + "help": "Дополнительное описание", + "placeholder": "Введите необязательное описание" + }, + "type": { + "name": "Тип", + "error": { + "isNotSelected": "Пожалуйста, выберите тип переменной." + } + }, + "currentValue": { + "name": "Текущее значение", + "help": "Если тип установлен скрипт, значение не может быть изменено вручную" + }, + "usableOptions": { + "name": "Доступные варианты", + "placeholder": "Введите, ваши, варинты, здесь", + "help": "Варианты, которые могут использоваться с этой переменной, например: SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "Необходимо задать не менее 1 значения." + } + }, + "scriptToEvaluate": "Скрипт для запуска", + "runScript": { + "name": "Запустить скрипт", + "error": { + "isNotSelected": "Пожалуйста, выберите опцию." + } + }, + "testCurrentScript": { + "name": "Проверить текущий скрипт", + "help": "Нажмите Проверить текущий скрипт, чтобы увидеть значение в текущем значении" + }, + "history": "История", + "historyIsEmpty": "История для этой переменной пуста!", + "warning": "Предупреждение: Все данные этой переменной будут удалены!", + "choose": "Выбрать...", + "types": { + "number": "Число", + "text": "Текст", + "options": "Варианты", + "eval": "Скрипт" + }, + "runEvery": { + "isUsed": "Когда используется переменная" + } +} + diff --git a/backend/locales/ru/ui.systems.antihateraid.json b/backend/locales/ru/ui.systems.antihateraid.json new file mode 100644 index 000000000..fdb586012 --- /dev/null +++ b/backend/locales/ru/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Очистить чат", + "mode": "Режим", + "minFollowTime": "Minimum follow time", + "customAnnounce": "Customize announcement on anti hate raid enable" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.bets.json b/backend/locales/ru/ui.systems.bets.json new file mode 100644 index 000000000..f4ee839d1 --- /dev/null +++ b/backend/locales/ru/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Статус", + "betPercentGain": "Добавить х% к ставке на каждый вариант" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.commercial.json b/backend/locales/ru/ui.systems.commercial.json new file mode 100644 index 000000000..47ba1abdb --- /dev/null +++ b/backend/locales/ru/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Статус" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.cooldown.json b/backend/locales/ru/ui.systems.cooldown.json new file mode 100644 index 000000000..5cd199236 --- /dev/null +++ b/backend/locales/ru/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Уведомлять в висперсы", + "settings": { + "enabled": "Статус", + "cooldownNotifyAsWhisper": "Информация о кулдауне в висперсы", + "cooldownNotifyAsChat": "Информация о кулдауне в чат", + "defaultCooldownOfCommandsInSeconds": "Default cooldown for commands (in seconds)", + "defaultCooldownOfKeywordsInSeconds": "Default cooldown for keywords (in seconds)" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.customcommands.json b/backend/locales/ru/ui.systems.customcommands.json new file mode 100644 index 000000000..bd87b5240 --- /dev/null +++ b/backend/locales/ru/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "Нет ответов", + "addResponse": "Добавить ответ", + "response": { + "name": "Ответ", + "placeholder": "Установите здесь свой ответ." + }, + "filter": { + "name": "фильтр", + "placeholder": "Добавить фильтр для этого ответа" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.highlights.json b/backend/locales/ru/ui.systems.highlights.json new file mode 100644 index 000000000..bdec934d3 --- /dev/null +++ b/backend/locales/ru/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Статус", + "urls": "Сгенерированные ссылки" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.moderation.json b/backend/locales/ru/ui.systems.moderation.json new file mode 100644 index 000000000..eaa464388 --- /dev/null +++ b/backend/locales/ru/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Статус", + "cListsEnabled": "Enforce the rule", + "cLinksEnabled": "Enforce the rule", + "cSymbolsEnabled": "Enforce the rule", + "cLongMessageEnabled": "Enforce the rule", + "cCapsEnabled": "Enforce the rule", + "cSpamEnabled": "Enforce the rule", + "cColorEnabled": "Enforce the rule", + "cEmotesEnabled": "Enforce the rule", + "cListsWhitelist": { + "title": "Allowed words", + "help": "To allow domains use \"domain:prtzl.io\"" + }, + "autobanMessages": "Autoban Messages", + "cListsBlacklist": "Forbidden words", + "cListsTimeout": "Время таймаута", + "cLinksTimeout": "Время таймаута", + "cSymbolsTimeout": "Время таймаута", + "cLongMessageTimeout": "Время таймаута", + "cCapsTimeout": "Время таймаута", + "cSpamTimeout": "Время таймаута", + "cColorTimeout": "Время таймаута", + "cEmotesTimeout": "Время таймаута", + "cWarningsShouldClearChat": "Очищать чат (таймаут на 1с)", + "cLinksIncludeSpaces": "Включая пробелы", + "cLinksIncludeClips": "Включая клипы", + "cSymbolsTriggerLength": "Длина сообщения для срабатывания", + "cLongMessageTriggerLength": "Длина сообщения для срабатывания", + "cCapsTriggerLength": "Длина сообщения для срабатывания", + "cSpamTriggerLength": "Длина сообщения для срабатывания", + "cSymbolsMaxSymbolsConsecutively": "Максимальное количество символов последовательно", + "cSymbolsMaxSymbolsPercent": "Максимальное % символов", + "cCapsMaxCapsPercent": "Максимально капса в %", + "cSpamMaxLength": "Максимальная длина", + "cEmotesMaxCount": "Максимальное число", + "cWarningsAnnounceTimeouts": "Объявить таймауты в чате для всех", + "cWarningsAllowedCount": "Количество предупреждений", + "cEmotesEmojisAreEmotes": "Считать эмодзи как смайлики" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.points.json b/backend/locales/ru/ui.systems.points.json new file mode 100644 index 000000000..7e831953a --- /dev/null +++ b/backend/locales/ru/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Статус", + "name": { + "title": "Название", + "help": "Возможные форматы:
поинт|поинтов
поинт|4:поинта|поинтов" + }, + "isPointResetIntervalEnabled": "Интервал сброса поинтов", + "resetIntervalCron": { + "name": "Cron interval", + "help": "CronTab generator" + }, + "interval": "Интервал в минутах для добавления поинтов онлайн пользователям когда стрим ОНЛАЙН", + "offlineInterval": "Интервал в минутах для добавления поинтов онлайн пользователям когда стрим ОФФЛАЙН", + "messageInterval": "За сколько сообщений добавлять поинтов", + "messageOfflineInterval": "За сколько сообщений добавлять поинтов когда стрим оффлайн", + "perInterval": "Сколько добавлять поинтов за просмотр если стрим онлайн", + "perOfflineInterval": "Сколько добавлять поинтов за просмотр если стрим оффлайн", + "perMessageInterval": "Сколько поинтов добавить в интервал сообщений", + "perMessageOfflineInterval": "Сколько поинтов добавить в интервал сообщений когда стрим оффлайн" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.price.json b/backend/locales/ru/ui.systems.price.json new file mode 100644 index 000000000..f98747828 --- /dev/null +++ b/backend/locales/ru/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Trigger custom alerts on bit redeem", + "price": { + "name": "цена", + "placeholder": "" + }, + "error": { + "isEmpty": "Это поле не может быть пустым" + }, + "warning": "Это действие нельзя будет отменить!", + "settings": { + "enabled": "Статус" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.queue.json b/backend/locales/ru/ui.systems.queue.json new file mode 100644 index 000000000..285e07ce2 --- /dev/null +++ b/backend/locales/ru/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Статус", + "eligibilityAll": "Все", + "eligibilityFollowers": "Фолловеров", + "eligibilitySubscribers": "Сабскрайберов" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.quotes.json b/backend/locales/ru/ui.systems.quotes.json new file mode 100644 index 000000000..509312b39 --- /dev/null +++ b/backend/locales/ru/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "К сожалению, цитаты не найдены в базе данных.", + "new": "Добавить новую цитату", + "empty": "Список цитат пуст, создайте новую цитату.", + "emptyAfterSearch": "Список цитат пуст при поиске \"$search\"", + "quote": { + "name": "Цитата", + "placeholder": "Установить цитату здесь" + }, + "by": { + "name": "Цитата от" + }, + "tags": { + "name": "Теги", + "placeholder": "Установите тэги здесь", + "help": "Теги, разделенные запятыми. Пример: тег 1, тег 2, тег 3" + }, + "date": { + "name": "Дата" + }, + "error": { + "isEmpty": "Это поле не может быть пустым", + "atLeastOneTag": "Вы должны добавить по крайней мере одно значение" + }, + "tag-filter": "Фильтрация по тегам", + "warning": "Это действие нельзя будет отменить!", + "settings": { + "enabled": "Статус", + "urlBase": { + "title": "Базовый URL", + "help": "Вы должны использовать публичную ссылку для цитат, чтобы они были доступны каждому" + } + } +} diff --git a/backend/locales/ru/ui.systems.raffles.json b/backend/locales/ru/ui.systems.raffles.json new file mode 100644 index 000000000..00ad2de8f --- /dev/null +++ b/backend/locales/ru/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Удача сабскрайберам" + }, + "settings": { + "enabled": "Статус", + "announceNewEntries": { + "title": "Announce new entries", + "help": "If users joins raffle, announce message will be send to chat after while." + }, + "announceNewEntriesBatchTime": { + "title": "How long to wait before announce new entries (in seconds)", + "help": "Longer time will keep chat cleaner, entries will be aggregated together." + }, + "deleteRaffleJoinCommands": { + "title": "Delete user raffle join command", + "help": "This will delete user message if they use !yourraffle command. Should keep chat cleaner." + }, + "allowOverTicketing": { + "title": "Разрешить чрезмерный взнос", + "help": "Разрешите пользователю присоединиться к розыгрышу с большим кол-вом поинтов, чем у него есть. Например пользователь имеет 10 очков, но может присоединиться используя !raffle 100, но внесётся всё равно 10." + }, + "raffleAnnounceInterval": { + "title": "Интервал объявления", + "help": "Минуты" + }, + "raffleAnnounceMessageInterval": { + "title": "Announce message interval", + "help": "How many messages must be sent to chat until announce can be posted." + }, + "subscribersPercent": { + "title": "Дополнительная удача для сабскрайберов", + "help": "в процентах" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.ranks.json b/backend/locales/ru/ui.systems.ranks.json new file mode 100644 index 000000000..5bc152dff --- /dev/null +++ b/backend/locales/ru/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "Новый ранг", + "empty": "Ранги еще не созданы.", + "emptyAfterSearch": "Не найдено рангов по вашему запросу \"$search\".", + "rank": { + "name": "ранг", + "placeholder": "" + }, + "value": { + "name": "часы", + "placeholder": "" + }, + "error": { + "isEmpty": "Это поле не может быть пустым" + }, + "warning": "Это действие нельзя будет отменить!", + "settings": { + "enabled": "Статус" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.songs.json b/backend/locales/ru/ui.systems.songs.json new file mode 100644 index 000000000..f64629af4 --- /dev/null +++ b/backend/locales/ru/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Статус", + "volume": "Громкость", + "calculateVolumeByLoudness": "Динамический уровень громкости", + "duration": { + "title": "Максимальная длительность", + "help": "В минутах" + }, + "shuffle": "Перемешать", + "songrequest": "Воспроизвести из запросов песен", + "playlist": "Воспроизвести из плейлиста", + "onlyMusicCategory": "Разрешить только категории музыки", + "allowRequestsOnlyFromPlaylist": "Разрешить запросы треков только из текущего плейлиста", + "notify": "Отправить сообщение при смене песни" + }, + "error": { + "isEmpty": "Это поле не может быть пустым" + }, + "startTime": "Начать трек с", + "endTime": "Конец песни в", + "add_song": "Добавить песню", + "add_or_import": "Добавить песню или импортировать из плейлиста", + "importing": "Импортируется", + "importing_done": "Импорт выполнен", + "seconds": "Секунды", + "calculated": "Рассчитано", + "set_manually": "Настроить вручную", + "bannedSongsEmptyAfterSearch": "Забаненные песни не были найдены по вашему запросу \"$search\".", + "emptyAfterSearch": "Не найдено песен по вашему запросу \"$search\".", + "empty": "Треки не были добавлены.", + "bannedSongsEmpty": "Треки еще не были добавлены в банлист." +} \ No newline at end of file diff --git a/backend/locales/ru/ui.systems.timers.json b/backend/locales/ru/ui.systems.timers.json new file mode 100644 index 000000000..c553d91ba --- /dev/null +++ b/backend/locales/ru/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "Новый таймер", + "empty": "Не было создано ни одного таймера.", + "emptyAfterSearch": "Не найдено таймеров по вашему запросу \"$search\".", + "add_response": "Добавить ответ", + "settings": { + "enabled": "Статус" + }, + "warning": "Это действие нельзя будет отменить!" +} \ No newline at end of file diff --git a/backend/locales/ru/ui.widgets.customvariables.json b/backend/locales/ru/ui.widgets.customvariables.json new file mode 100644 index 000000000..b52ecc289 --- /dev/null +++ b/backend/locales/ru/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "Пользовательские переменные не найдены, добавьте в реестре пользовательских переменных", + "add-variable-into-watchlist": "Добавить переменную в список наблюдения", + "watchlist": "Список Отслеживаемых" +} \ No newline at end of file diff --git a/backend/locales/ru/ui.widgets.randomizer.json b/backend/locales/ru/ui.widgets.randomizer.json new file mode 100644 index 000000000..081e8036f --- /dev/null +++ b/backend/locales/ru/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "No randomizer found, add at randomizer registry", + "add-randomizer-to-widget": "Добавить рандомизатор в виджет" +} \ No newline at end of file diff --git a/backend/locales/ru/ui/categories.json b/backend/locales/ru/ui/categories.json new file mode 100644 index 000000000..aa504c1bd --- /dev/null +++ b/backend/locales/ru/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Объявления", + "keys": "Ключ", + "currency": "Валюта", + "general": "Общее", + "settings": "Настройки", + "commands": "Команды", + "bot": "Бот", + "channel": "Канал", + "connection": "Соединения", + "chat": "Чат", + "graceful_exit": "Плавный выход", + "rewards": "Награда канала", + "levels": "Уровни", + "notifications": "Уведомления", + "options": "Параметры", + "comboBreakMessages": "Combo Break Messages", + "hypeMessages": "Hype Messages", + "messages": "Сообщения", + "results": "Результаты", + "customization": "Кастомизация", + "status": "Статус", + "mapping": "Mapping", + "player": "Проигрыватель", + "stats": "Статистика", + "api": "API", + "token": "Token", + "text": "Текст", + "custom_texts": "Пользовательский текст", + "credits": "Credits", + "show": "Показать", + "social": "Social", + "explosion": "Взрыв", + "fireworks": "Фейерверки", + "test": "Проверка", + "emotes": "Эмодзи", + "default": "По умолчанию", + "urls": "URLs", + "conversion": "Conversion", + "xp": "XP", + "caps_filter": "Фильтр Caps", + "color_filter": "Italic (/me) filter", + "links_filter": "Фильтр Ссылок", + "symbols_filter": "Symbols filter", + "longMessage_filter": "Message length filter", + "spam_filter": "Фильтр спама", + "emotes_filter": "Фильтр эмоций", + "warnings": "Предупреждение", + "reset": "Сброс", + "reminder": "Reminder", + "eligibility": "Eligibility", + "join": "Присоединиться", + "luck": "Удача", + "lists": "Списки", + "me": "Me", + "emotes_combo": "Комбо эмоций", + "tmi": "tmi", + "oauth": "oAuth", + "eventsub": "eventSub", + "rules": "rules" +} \ No newline at end of file diff --git a/backend/locales/ru/ui/core/currency.json b/backend/locales/ru/ui/core/currency.json new file mode 100644 index 000000000..b4caabc79 --- /dev/null +++ b/backend/locales/ru/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Основная валюта" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/core/general.json b/backend/locales/ru/ui/core/general.json new file mode 100644 index 000000000..8896eb0ef --- /dev/null +++ b/backend/locales/ru/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Язык бота", + "numberFormat": "Формат чисел в чате", + "gracefulExitEachXHours": { + "title": "Плавный рестарт каждые X часов", + "help": "0 - отключено" + }, + "shouldGracefulExitHelp": "Включение изящного рестарта рекомендуетсяне будет перезапускаться, когда стрим онлайн." + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/core/oauth.json b/backend/locales/ru/ui/core/oauth.json new file mode 100644 index 000000000..a6bd8756c --- /dev/null +++ b/backend/locales/ru/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Владельцы", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "Имя пользователя", + "channelUsername": "Имя пользователя", + "botExpectedScopes": "Разрешения", + "channelExpectedScopes": "Разрешения" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/core/permissions.json b/backend/locales/ru/ui/core/permissions.json new file mode 100644 index 000000000..71556e48f --- /dev/null +++ b/backend/locales/ru/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Добавить новую группу прав доступа", + "higherPermissionHaveAccessToLowerPermissions": "Более высокое разрешение имеет доступ к более низким разрешениям.", + "typeUsernameOrIdToSearch": "Введите имя пользователя или ID для поиска", + "typeUsernameOrIdToTest": "Введите имя пользователя или ID для тестирования", + "noUsersWereFound": "Пользователи не найдены.", + "noUsersManuallyAddedToPermissionYet": "Пользователи еще не были добавлены в разрешение.", + "done": "Выполнено", + "previous": "Предыдущий", + "next": "Следующий", + "loading": "загрузка", + "permissionNotFoundInDatabase": "Разрешение не найдено в базе данных, пожалуйста, сохраните перед тестированием пользователя.", + "userHaveNoAccessToThisPermissionGroup": "Пользователь $username НЕ ИМЕЕТ доступ к этому разрешению.", + "userHaveAccessToThisPermissionGroup": "Пользователь $username ИМЕЕТ доступ к этой группе разрешений.", + "accessDirectlyThrough": "Прямой доступ через", + "accessThroughHigherPermission": "Доступ через более высокое разрешение", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Что-то пошло не так, пользователь $username не найден в базе данных бота.", + "permissionsGroups": "Разрешения Группы", + "allowHigherPermissions": "Разрешить доступ через более высокие разрешения", + "type": "Тип", + "value": "Значение", + "watched": "Просмотренное время в часах", + "followtime": "Время подписки в месяцах", + "points": "Поинты", + "tips": "Донаты", + "bits": "Битсы", + "messages": "Сообщения", + "subtier": "Саб уровень (1, 2 или 3)", + "subcumulativemonths": "Всего месяцев саба", + "substreakmonths": "Текущий стрик саба", + "ranks": "Текущий ранг", + "level": "Текущий уровень", + "isLowerThan": "ниже, чем", + "isLowerThanOrEquals": "меньше или равно чем", + "equals": "равно", + "isHigherThanOrEquals": "меньше или равно", + "isHigherThan": "выше, чем", + "addFilter": "Добавить фильтр", + "selectPermissionGroup": "Выберите группу прав доступа", + "settings": "Параметры", + "name": "Название", + "baseUsersSet": "Базовый набор пользователей", + "manuallyAddedUsers": "Вручную добавленные пользователи", + "manuallyExcludedUsers": "Вручную исключаемые пользователи", + "filters": "Фильтры", + "testUser": "Тестовый пользователь", + "none": "- нет -", + "casters": "Кастеры", + "moderators": "Модераторы", + "subscribers": "Сабскрайберы", + "vip": "VIP", + "viewers": "Зрители", + "followers": "Подписчики" +} \ No newline at end of file diff --git a/backend/locales/ru/ui/core/socket.json b/backend/locales/ru/ui/core/socket.json new file mode 100644 index 000000000..0fe129d1d --- /dev/null +++ b/backend/locales/ru/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Очистить все аутентифицированные соединения (включая ваши)", + "accessTokenExpirationTime": "Время жизни AccessToken (секунды)", + "refreshTokenExpirationTime": "Время жизни RefreshToken (секунды)", + "socketToken": { + "title": "Токен сокета", + "help": "Этот токен даст вам полный доступ администратора через сокеты. Не распространяйте его!" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/core/tmi.json b/backend/locales/ru/ui/core/tmi.json new file mode 100644 index 000000000..81249c98f --- /dev/null +++ b/backend/locales/ru/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Список игнорируемых (ID или имя пользователя)", + "showWithAt": "Показать имена пользователей с @", + "sendWithMe": "Отправлять сообщения с /me", + "sendAsReply": "Отправить сообщения бота в качестве ответов", + "mute": "Бот заглушен", + "whisperListener": "Слушать команды в висперсах" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/core/tts.json b/backend/locales/ru/ui/core/tts.json new file mode 100644 index 000000000..2ddd01d1d --- /dev/null +++ b/backend/locales/ru/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Служба" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/core/twitch.json b/backend/locales/ru/ui/core/twitch.json new file mode 100644 index 000000000..3a340e338 --- /dev/null +++ b/backend/locales/ru/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Создать маркер потока на событии" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/core/ui.json b/backend/locales/ru/ui/core/ui.json new file mode 100644 index 000000000..ef93cb6b6 --- /dev/null +++ b/backend/locales/ru/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Тема по умолчанию", + "domain": { + "title": "Домен", + "help": "Формат без http/https: yourdomain.com или ваш.домена" + }, + "percentage": "Разница в процентах для статистики", + "shortennumbers": "Короткий формат чисел", + "showdiff": "Показать различия", + "enablePublicPage": "Включить публичную страницу" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/core/updater.json b/backend/locales/ru/ui/core/updater.json new file mode 100644 index 000000000..a4a952949 --- /dev/null +++ b/backend/locales/ru/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Автоматически обновлять при наличии более новой версии" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/errors.json b/backend/locales/ru/ui/errors.json new file mode 100644 index 000000000..8b3e4bef8 --- /dev/null +++ b/backend/locales/ru/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Unexpected errors during validation", + "isNotEmpty": "$property is required.", + "minLength": "$property must be longer than or equal to $constraint1 characters.", + "isPositive": "$property must be greater then 0", + "isCommand": "$property must start with !", + "isCommandOrCustomVariable": "$property must start with ! or $_", + "isCustomVariable": "$property must start with $_", + "min": "$property must be at least $constraint1", + "max": "$property must be lower or equal to $constraint1", + "isInt": "$property must be an integer", + "this_value_must_be_a_positive_number_and_greater_then_0": "This value must be a positive number or greater then 0", + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty", + "minLength_of_value_is": "Minimal length is $value.", + "this_currency_is_not_supported": "This currency is not supported", + "something_went_wrong": "Something went wrong", + "permission_must_exist": "Permission must exist", + "minValue_of_value_is": "Minimal value is $value", + "value_cannot_be": "Value cannot be $value.", + "invalid_format": "Invalid value format.", + "invalid_regexp_format": "This is not valid regex.", + "owner_and_broadcaster_oauth_is_not_set": "Owner and channel oauth is not set", + "channel_is_not_set": "Channel is not set", + "please_set_your_broadcaster_oauth_or_owners": "Please set your channel oauth or owners, or all users will have access to this dashboard and will be considered as casters.", + "new_update_available": "New update available", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "One of inputs must be set" +} \ No newline at end of file diff --git a/backend/locales/ru/ui/games/duel.json b/backend/locales/ru/ui/games/duel.json new file mode 100644 index 000000000..89c19d612 --- /dev/null +++ b/backend/locales/ru/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Статус", + "cooldown": "Кулдаун", + "duration": { + "title": "Длительность", + "help": "Минуты" + }, + "minimalBet": "Минимальная ставка", + "bypassCooldownByOwnerAndMods": "Игнорировать кулдаун владельцами и модераторами" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/games/gamble.json b/backend/locales/ru/ui/games/gamble.json new file mode 100644 index 000000000..95d6b8995 --- /dev/null +++ b/backend/locales/ru/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Статус", + "minimalBet": "Минимальная ставка", + "chanceToWin": { + "title": "Шанс на победу", + "help": "Процент" + }, + "enableJackpot": "Включить джекпот", + "chanceToTriggerJackpot": "Шанс получить джекпот в %", + "maxJackpotValue": "Максимальный выигрыш в джекпоте", + "lostPointsAddedToJackpot": "Сколько потерянных очков должно быть добавлено в % джекпот" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/games/heist.json b/backend/locales/ru/ui/games/heist.json new file mode 100644 index 000000000..22a4e3027 --- /dev/null +++ b/backend/locales/ru/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Ограбление", + "settings": { + "enabled": "Статус", + "showMaxUsers": "Максимум пользователей для отображения в выплате", + "copsCooldownInMinutes": { + "title": "Кулдаун между ограблениями", + "help": "Минуты" + }, + "entryCooldownInSeconds": { + "title": "Время для входа", + "help": "Секунды" + }, + "started": "Стартовое сообщение ограбления", + "nextLevelMessage": "Сообщение по достижении следующего уровня", + "maxLevelMessage": "Сообщение по достижении максимального уровня", + "copsOnPatrol": "Ответ бота при кулдауне", + "copsCooldown": "Объявление бота при запуске ограбления", + "singleUserSuccess": "Успешное сообщение от одного пользователя", + "singleUserFailed": "Сообщение об ошибке для одного пользователя", + "noUser": "Сообщение, если пользователи не участвовал" + }, + "message": "Сообщение", + "winPercentage": "Процент победы", + "payoutMultiplier": "Множитель выплаты", + "maxUsers": "Максимальное количество пользователей на уровне", + "percentage": "Процентаж", + "noResultsFound": "Ничего не найдено. Нажмите кнопку ниже, чтобы добавить новый результат.", + "noLevelsFound": "Уровни не найдены. Нажмите кнопку ниже, чтобы добавить новый уровень." +} \ No newline at end of file diff --git a/backend/locales/ru/ui/games/roulette.json b/backend/locales/ru/ui/games/roulette.json new file mode 100644 index 000000000..a43996e94 --- /dev/null +++ b/backend/locales/ru/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Статус", + "timeout": { + "title": "Время таймаута", + "help": "Секунды" + }, + "winnerWillGet": "Сколько поинтов будет добавлено при победе", + "loserWillLose": "Сколько поинтов будет отнято при поражении" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/games/seppuku.json b/backend/locales/ru/ui/games/seppuku.json new file mode 100644 index 000000000..4b03a756b --- /dev/null +++ b/backend/locales/ru/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Статус", + "timeout": { + "title": "Время таймаута", + "help": "Секунды" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/integrations/discord.json b/backend/locales/ru/ui/integrations/discord.json new file mode 100644 index 000000000..0dd344e6c --- /dev/null +++ b/backend/locales/ru/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Статус", + "guild": "Сервер", + "listenAtChannels": "Слушать на этих каналах", + "sendOnlineAnnounceToChannel": "Отправить онлайн объявление в этот канал", + "onlineAnnounceMessage": "Message in online announcement (can include mentions)", + "sendAnnouncesToChannel": "Настройка отправки объявлений в каналы", + "deleteMessagesAfterWhile": "Удалить сообщение через некоторое время", + "clientId": "ClientId", + "token": "Токен", + "joinToServerBtn": "Нажмите, чтобы пригласить бота на ваш сервер", + "joinToServerBtnDisabled": "Please save changes to enable bot join to your server", + "cannotJoinToServerBtn": "Установите токен и clientId, чтобы иметь возможность пригласить бота на ваш сервер", + "noChannelSelected": "канал не выбран", + "noRoleSelected": "роль не выбрана", + "noGuildSelected": "сервер не выбран", + "noGuildSelectedBox": "Выберете сервер на котором должен работать бот, и вы увидите больше настроек", + "onlinePresenceStatusDefault": "Статус по умолчанию", + "onlinePresenceStatusDefaultName": "Сообщение о статусе по умолчанию", + "onlinePresenceStatusOnStream": "Статус при трансляции", + "onlinePresenceStatusOnStreamName": "Status Message when Streaming", + "ignorelist": { + "title": "Черный список", + "help": "username, username#0000 or userID" + } + } +} diff --git a/backend/locales/ru/ui/integrations/donatello.json b/backend/locales/ru/ui/integrations/donatello.json new file mode 100644 index 000000000..f41a667b1 --- /dev/null +++ b/backend/locales/ru/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Ваш Токен:", + "help": "Получите свой API ключ на https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/integrations/donationalerts.json b/backend/locales/ru/ui/integrations/donationalerts.json new file mode 100644 index 000000000..f61ac3a80 --- /dev/null +++ b/backend/locales/ru/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Статус", + "access_token": { + "title": "Access token", + "help": "Получить access token по адресу https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Refresh token" + }, + "accessTokenBtn": "Получить токен к сервису \"DonationAlerts\"" + } +} diff --git a/backend/locales/ru/ui/integrations/kofi.json b/backend/locales/ru/ui/integrations/kofi.json new file mode 100644 index 000000000..a8179bf1e --- /dev/null +++ b/backend/locales/ru/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Verification token", + "help": "Get your verification token at https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "Webhook URL", + "help": "Set Webhook URL at https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL must have HTTPS", + "origin": "You cannot use localhost for webhooks" + } + } + } +} diff --git a/backend/locales/ru/ui/integrations/lastfm.json b/backend/locales/ru/ui/integrations/lastfm.json new file mode 100644 index 000000000..5a23a4338 --- /dev/null +++ b/backend/locales/ru/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Статус", + "apiKey": "Ключи API", + "username": "Имя пользователя" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/integrations/obswebsocket.json b/backend/locales/ru/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..938bcd529 --- /dev/null +++ b/backend/locales/ru/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Статус", + "accessBy": { + "title": "Доступ к", + "help": "Direct - connect directly from a bot | Overlay - connect via overlay browser source" + }, + "address": "Адрес", + "password": "Пароль" + }, + "noSourceSelected": "No source selected", + "noSceneSelected": "No scene selected", + "empty": "No action sets were created yet.", + "emptyAfterSearch": "No action sets were found by your search for \"$search\".", + "command": "Команда", + "new": "Create new OBS Websocket action set", + "actions": "Actions", + "name": { + "name": "Name" + }, + "mute": "Mute", + "unmute": "Unmute", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Wait X miliseconds" + }, + "Log": { + "name": "Log message" + }, + "StartRecording": { + "name": "StartRecording" + }, + "StopRecording": { + "name": "StopRecording" + }, + "PauseRecording": { + "name": "PauseRecording" + }, + "ResumeRecording": { + "name": "ResumeRecording" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/integrations/pubg.json b/backend/locales/ru/ui/integrations/pubg.json new file mode 100644 index 000000000..3c246de03 --- /dev/null +++ b/backend/locales/ru/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Статус", + "apiKey": { + "title": "Ключ API", + "help": "Получите свой API ключ на https://developer.pubg.com/" + }, + "platform": "Платформа", + "playerName": "Имя игрока", + "playerId": "ID игрока", + "seasonId": { + "title": "ID сезона", + "help": "ID текущего сезона получается каждый час." + }, + "rankedGameModeStatsCustomization": "Настраиваемое сообщение для статистики рейтинга", + "gameModeStatsCustomization": "Настраиваемое сообщение для обычной статистики" + }, + "click_to_fetch": "Нажмите, чтобы получить", + "something_went_wrong": "Что-то пошло не так!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Статистика обновляется каждые 10 минут.", + "player_stats_ranked": "Статистика игрока (рейтинг)", + "player_stats": "Статистика игрока" +} diff --git a/backend/locales/ru/ui/integrations/qiwi.json b/backend/locales/ru/ui/integrations/qiwi.json new file mode 100644 index 000000000..a6cc0571f --- /dev/null +++ b/backend/locales/ru/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Статус", + "secretToken": { + "title": "Секретный ключ", + "help": "Получить секретный токен ва панели инструментов Qiwi Donate_>нажмите показать секретный токен" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/integrations/responsivevoice.json b/backend/locales/ru/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..e9214a1bc --- /dev/null +++ b/backend/locales/ru/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Ключ", + "help": "Получите свой ключ на http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/integrations/spotify.json b/backend/locales/ru/ui/integrations/spotify.json new file mode 100644 index 000000000..28abe3a4c --- /dev/null +++ b/backend/locales/ru/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Исполнители", + "settings": { + "enabled": "Статус", + "songRequests": "Запросы песен", + "fetchCurrentSongWhenOffline": { + "title": "Получить текущую песню, когда стрим не в сети", + "help": "Рекомендуется отключить это во избежание достижения лимитов API" + }, + "allowApprovedArtistsOnly": "Разрешить только одобренные исполнители", + "approvedArtists": { + "title": "Одобренные исполнители", + "help": "Имя или SpotifyURI исполнителя, по одному элементу в строке" + }, + "queueWhenOffline": { + "title": "Очередь треков, когда стрим оффлайн", + "help": "Рекомендуется отключить этот параметр, чтобы избежать очереди, когда вы просто слушаете музыку" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "Идентификатор принудительного устройства", + "help": "Оставьте поле пустым, чтобы данное свойство не задействовано. Принудительно присвоить ID устройства в очереди песен. Проверьте журналы для текущего активного устройства или используйте кнопку воспроизведения песни не менее 10 секунд." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Формат", + "help": "Доступные переменные: $song, $artist, $artists" + }, + "username": "Авторизованный пользователь", + "revokeBtn": "Отменить авторизацию пользователя", + "authorizeBtn": "Авторизовать пользователя", + "scopes": "Разрешения", + "playlistToPlay": { + "title": "Spotify URI главного плейлиста", + "help": "Если установлено, по завершении заказанных песен этот плейлист будет продолжаться" + }, + "continueOnPlaylistAfterRequest": "Продолжить воспроизведение плейлиста после заказанных песен", + "notify": "Отправить сообщение в чат при смене песни" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/integrations/streamelements.json b/backend/locales/ru/ui/integrations/streamelements.json new file mode 100644 index 000000000..c30d235d1 --- /dev/null +++ b/backend/locales/ru/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Статус", + "jwtToken": { + "title": "Токен JWT", + "help": "Get JWT token at StreamElements Channels setting and toggle Show secrets" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/integrations/streamlabs.json b/backend/locales/ru/ui/integrations/streamlabs.json new file mode 100644 index 000000000..6c698d678 --- /dev/null +++ b/backend/locales/ru/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Статус", + "socketToken": { + "title": "Токен сокета", + "help": "Получить токен сокета из streamlabs API настройки->API tokens->Ваш токен API Socket" + }, + "accessToken": { + "title": "Access token", + "help": "Получить ваш доступ по адресу https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "Генератор токена доступа к StreamLabs" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/integrations/tipeeestream.json b/backend/locales/ru/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..d1737ce5a --- /dev/null +++ b/backend/locales/ru/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Статус", + "apiKey": { + "title": "Ключ API", + "help": "Получить токен сокета с панели tipeeestream -> API -> Ваш ключ API" + } + } +} diff --git a/backend/locales/ru/ui/integrations/twitter.json b/backend/locales/ru/ui/integrations/twitter.json new file mode 100644 index 000000000..b6ae7536f --- /dev/null +++ b/backend/locales/ru/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Статус", + "consumerKey": "Consumer Key (API Key)", + "consumerSecret": "Consumer Secret (API Secret)", + "accessToken": "Access Token", + "secretToken": "Access Token Secret" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/managers.json b/backend/locales/ru/ui/managers.json new file mode 100644 index 000000000..7c7cebb89 --- /dev/null +++ b/backend/locales/ru/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "История событий пользователя", + "hostAndRaidViewersCount": "Просмотры: $value", + "receivedSubscribeFrom": "Получена подписка от $value", + "giftedSubscribeTo": "Подарил подписку $value" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/overlays/alerts.json b/backend/locales/ru/ui/overlays/alerts.json new file mode 100644 index 000000000..d1ee56f10 --- /dev/null +++ b/backend/locales/ru/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Кэшировать элементы галереи", + "galleryCacheLimitInMb": "Максимальный размер элемента галереи (в МБ) для кэша" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/overlays/clips.json b/backend/locales/ru/ui/overlays/clips.json new file mode 100644 index 000000000..701628f07 --- /dev/null +++ b/backend/locales/ru/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Громкость", + "cClipsFilter": "Фильтр клипа", + "cClipsLabel": "Показать метку 'clip'" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/overlays/clipscarousel.json b/backend/locales/ru/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..c79be4fd0 --- /dev/null +++ b/backend/locales/ru/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Интервал времени (дни)", + "cClipsNumOfClips": "Число клипов", + "cClipsTimeToNextClip": "Время до следующего клипа (ов)" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/overlays/credits.json b/backend/locales/ru/ui/overlays/credits.json new file mode 100644 index 000000000..2e0e44ab1 --- /dev/null +++ b/backend/locales/ru/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Скорость", + "cCreditsAggregated": "Агрегированные кредиты", + "cShowGameThumbnail": "Show game thumbnail", + "cShowFollowers": "Показать подписчиков", + "cShowRaids": "Показать рейды", + "cShowSubscribers": "Показать сабскрайберов", + "cShowSubgifts": "Показать подаренные сабки", + "cShowSubcommunitygifts": "Показать сабки, подаренные сообществу", + "cShowResubs": "Показать ресабы", + "cShowCheers": "Show cheers", + "cShowClips": "Показать клипы", + "cShowTips": "Показать донаты", + "cTextLastMessage": "Последнее сообщение", + "cTextLastSubMessage": "Последнее сообщение сабскрайба", + "cTextStreamBy": "Стримит от", + "cTextFollow": "Подписка от", + "cTextRaid": "Рейд от", + "cTextCheer": "Cheer by", + "cTextSub": "Сабскрайб от", + "cTextResub": "Ресаб от", + "cTextSubgift": "Подареные сабы", + "cTextSubcommunitygift": "Сабы, подаренные сообществу", + "cTextTip": "Донаты от", + "cClipsPeriod": "Промежуток времени", + "cClipsCustomPeriodInDays": "Пользовательский интервал времени (дней)", + "cClipsNumOfClips": "Число клипов", + "cClipsShouldPlay": "Клипы должны воспроизводиться", + "cClipsVolume": "Громкость" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/overlays/emotes.json b/backend/locales/ru/ui/overlays/emotes.json new file mode 100644 index 000000000..eb2b351b5 --- /dev/null +++ b/backend/locales/ru/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Очистить кэш", + "hypeMessagesEnabled": "Show hype messages in chat", + "btnTestExplosion": "Test emote explosion", + "btnTestEmote": "Test emote", + "btnTestFirework": "Test emote firework", + "cEmotesSize": "Размер эмоций", + "cEmotesMaxEmotesPerMessage": "Maximum of emotes per message", + "cEmotesMaxRotation": "Maximal rotation of emote", + "cEmotesOffsetX": "Maximal offset on X-axis", + "cEmotesAnimation": "Анимация", + "cEmotesAnimationTime": "Продолжительность анимации", + "cExplosionNumOfEmotes": "Количество эмоций", + "cExplosionNumOfEmotesPerExplosion": "Количество эмоций за взрыв", + "cExplosionNumOfExplosions": "Кол-во взрывов", + "enableEmotesCombo": "Enable emotes combo", + "comboBreakMessages": "Combo break messages", + "threshold": "Threshold", + "noMessagesFound": "No messages found.", + "message": "Сообщение", + "showEmoteInOverlayThreshold": "Minimal message threshold to show emote in overlay", + "hideEmoteInOverlayAfter": { + "title": "Hide emote in overlay after inactivity", + "help": "Will hide emote in overlay after certain time in seconds" + }, + "comboCooldown": { + "title": "Combo cooldown", + "help": "Cooldown of combo in seconds" + }, + "comboMessageMinThreshold": { + "title": "Minimal message threshold", + "help": "Minimal message threshold to count emotes as combo (until then won't trigger cooldown)" + }, + "comboMessages": "Combo messages" + }, + "hype": { + "5": "Let's go! We got $amountx $emote combo so far! SeemsGood", + "15": "Keep it going! Can we get more than $amountx $emote? TriHard" + }, + "message": { + "3": "$amountx $emote combo", + "5": "$amountx $emote combo SeemsGood", + "10": "$amountx $emote combo PogChamp", + "15": "$amountx $emote combo TriHard", + "20": "$sender ruined $amountx $emote combo! NotLikeThis" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/overlays/polls.json b/backend/locales/ru/ui/overlays/polls.json new file mode 100644 index 000000000..a7c03f9ad --- /dev/null +++ b/backend/locales/ru/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Тема", + "cDisplayHideAfterInactivity": "Скрыть при бездействии", + "cDisplayAlign": "Выравнивание", + "cDisplayInactivityTime": { + "title": "Бездействие после", + "help": "в миллисекундах" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/overlays/texttospeech.json b/backend/locales/ru/ui/overlays/texttospeech.json new file mode 100644 index 000000000..49fa25622 --- /dev/null +++ b/backend/locales/ru/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "Вы не правильно установили ключ ResponsiveVoice", + "voice": { + "title": "Голос", + "help": "Если голоса не загружаются после обновления ключа ResponsiveVoice, попробуйте обновить браузер" + }, + "volume": "Громкость", + "rate": "Темп", + "pitch": "Высота", + "triggerTTSByHighlightedMessage": "Озвучивать выделенные сообщения" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/properties.json b/backend/locales/ru/ui/properties.json new file mode 100644 index 000000000..e6243cf72 --- /dev/null +++ b/backend/locales/ru/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Command", + "variableName": "Variable name", + "price": "Price (points)", + "priceBits": "Price (bits)", + "thisvalue": "This value", + "promo": { + "shoutoutMessage": "Shoutout message", + "enableShoutoutMessage": "Send shoutout message in chat" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/registry/alerts.json b/backend/locales/ru/ui/registry/alerts.json new file mode 100644 index 000000000..e77ee35e6 --- /dev/null +++ b/backend/locales/ru/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Включено", + "testDlg": { + "alertTester": "Тестер оповещений", + "command": "Команды", + "username": "Имя пользователя", + "recipient": "Получатели", + "message": "Сообщение", + "tier": "Tier", + "amountOfViewers": "Amount of viewers", + "amountOfBits": "Amount of bits", + "amountOfGifts": "Amount of gifts", + "amountOfMonths": "Amount of months", + "amountOfTips": "Донаты", + "event": "Событие", + "service": "Service" + }, + "empty": "Список оповещений пуст, создайте новые оповещения.", + "emptyAfterSearch": "Реестр оповещений пуст при поиске \"$search\"", + "revertcode": "Сбросить к заводским настройкам", + "name": { + "name": "Название", + "placeholder": "Введите название ваших оповещений" + }, + "alertDelayInMs": { + "name": "Задержка оповещения" + }, + "parryEnabled": { + "name": "Оповещения" + }, + "parryDelay": { + "name": "Задержка оповещения" + }, + "profanityFilterType": { + "name": "Фильтр ненормативной лексики", + "disabled": "Выключено", + "replace-with-asterisk": "Заменить звёздочкой", + "replace-with-happy-words": "Заменить счастливыми словами", + "hide-messages": "Скрыть сообщения", + "disable-alerts": "Отключить оповещения" + }, + "loadStandardProfanityList": "Загрузить стандартный список ненормативной лексики", + "customProfanityList": { + "name": "Пользовательский список ненормативной лексики", + "help": "Слова должны быть разделены запятыми." + }, + "event": { + "follow": "Отслеживать", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Subgift to community", + "tip": "Tip", + "raid": "Raid", + "custom": "Пользовательский", + "promo": "Promo", + "rewardredeem": "Reward Redeem" + }, + "title": { + "name": "Название варианта", + "placeholder": "Укажите имя вашего варианта" + }, + "variant": { + "name": "Вариант применения" + }, + "filter": { + "name": "Фильтр", + "operator": "Оператор", + "rule": "Правила", + "addRule": "Добавить правило", + "addGroup": "Добавить группу", + "comparator": "Comparator", + "value": "Значение", + "valueSplitByComma": "Значения, разделенные запятыми (например, val1, val2)", + "isEven": "is even", + "isOdd": "is odd", + "lessThan": "less than", + "lessThanOrEqual": "Меньше чем или равно", + "contain": "содержит", + "contains": "contains", + "equal": "equal", + "notEqual": "not equal", + "present": "is present", + "includes": "Включает", + "greaterThan": "greater than", + "greaterThanOrEqual": "Больше или равно", + "noFilter": "без фильтра" + }, + "speed": { + "name": "Скорость" + }, + "maxTimeToDecrypt": { + "name": "Макс. время для расшифровки" + }, + "characters": { + "name": "Символы" + }, + "random": "Случайный", + "exact-amount": "Точное количество", + "greater-than-or-equal-to-amount": "Больший чем или равный", + "tier-exact-amount": "Tier is exactly", + "tier-greater-than-or-equal-to-amount": "Tier is higher or equal to", + "months-exact-amount": "Months amount is exactly", + "months-greater-than-or-equal-to-amount": "Months amount is higher or equal to", + "gifts-exact-amount": "Gifts amount is exactly", + "gifts-greater-than-or-equal-to-amount": "Gifts amount is higher or equal to", + "very-rarely": "Очень редкие", + "rarely": "Редко", + "default": "По умолчанию", + "frequently": "Периодически", + "very-frequently": "Очень часто", + "exclusive": "Exclusive", + "messageTemplate": { + "name": "Шаблон сообщения", + "placeholder": "Установить шаблон сообщения", + "help": "Available variables: {name}, {amount} (cheers, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). If | is added (see promo) then it will show those values in sequence." + }, + "ttsTemplate": { + "name": "Шаблон TTS", + "placeholder": "Установить шаблон TTS", + "help": "Доступные переменные: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Текст анимации" + }, + "animationType": { + "name": "Тип анимации" + }, + "animationIn": { + "name": "Анимация в" + }, + "animationOut": { + "name": "Анимация из" + }, + "alertDurationInMs": { + "name": "Длительность оповещения" + }, + "alertTextDelayInMs": { + "name": "Задержка текста уведомления" + }, + "layoutPicker": { + "name": "Размещение" + }, + "loop": { + "name": "Играть на цикле" + }, + "scale": { + "name": "Масштаб" + }, + "translateY": { + "name": "Переместить -вверх / +вниз" + }, + "translateX": { + "name": "Переместить -влево / +вправо" + }, + "image": { + "name": "Изображение / Видео (.webm)", + "setting": "Настройки изображения / видео (.webm)" + }, + "sound": { + "name": "Звук", + "setting": "Настройки звука" + }, + "soundVolume": { + "name": "Громкость оповещений" + }, + "enableAdvancedMode": "Включить расширенный режим", + "font": { + "setting": "Настройки шрифта", + "name": "Семейство шрифтов", + "overrideGlobal": "Переопределить общие настройки шрифта", + "align": { + "name": "Расположение", + "left": "Слева", + "center": "По центру", + "right": "Справа" + }, + "size": { + "name": "Размер шрифта" + }, + "weight": { + "name": "Масса шрифта" + }, + "borderPx": { + "name": "Границы шрифты" + }, + "borderColor": { + "name": "Цвет границы шрифта" + }, + "color": { + "name": "Цвет текста" + }, + "highlightcolor": { + "name": "Цвет подсветки шрифта" + } + }, + "minAmountToShow": { + "name": "Минимальная сумма для показа" + }, + "minAmountToPlay": { + "name": "Минимальная сумма для проигрывания" + }, + "allowEmotes": { + "name": "Разрешить смайлики" + }, + "message": { + "setting": "Настройки сообщения" + }, + "voice": "Голос", + "keepAlertShown": "Оповещение сохраняет видимость во время TTS", + "skipUrls": "Не озвучивать ссылки в TTS", + "volume": "Громкость", + "rate": "Темп", + "pitch": "Высота", + "test": "Тест", + "tts": { + "setting": "Настройки TTS" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/registry/goals.json b/backend/locales/ru/ui/registry/goals.json new file mode 100644 index 000000000..be31de36f --- /dev/null +++ b/backend/locales/ru/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Добавить группу целей", + "addGoal": "Добавить цель", + "newGoal": "новая цель", + "newGoalGroup": "новая группа целей", + "goals": "Цели", + "general": "Общее", + "display": "Отображение", + "fontSettings": "Параметры шрифта", + "barSettings": "Настройки панели", + "selectGoalOnLeftSide": "Выберите или добавить цель на левой стороне", + "input": { + "description": { + "title": "Описание" + }, + "goalAmount": { + "title": "Целевая сумма" + }, + "countBitsAsTips": { + "title": "Считать биты как донаты" + }, + "currentAmount": { + "title": "Текущая сумма" + }, + "endAfter": { + "title": "Конец после" + }, + "endAfterIgnore": { + "title": "Срок действия цели не истекает" + }, + "borderPx": { + "title": "Рамка", + "help": "Размер границы в пикселях" + }, + "barHeight": { + "title": "Высота панели", + "help": "Высота панели в пикселях" + }, + "color": { + "title": "Цвет" + }, + "borderColor": { + "title": "Цвет рамки" + }, + "backgroundColor": { + "title": "Цвет фона" + }, + "type": { + "title": "Тип" + }, + "nameGroup": { + "title": "Название этой группы целей" + }, + "name": { + "title": "Название цели" + }, + "displayAs": { + "title": "Отображать как", + "help": "Устанавливает, как будет отображаться группа целей" + }, + "durationMs": { + "title": "Длительность", + "help": "Значение в миллисекундах", + "placeholder": "Сколько времени цель будет отображаться" + }, + "animationInMs": { + "title": "Длительность анимации В", + "help": "Значение в миллисекундах", + "placeholder": "Длительность анимации В" + }, + "animationOutMs": { + "title": "Длительность анимации ИЗ", + "help": "Значение в миллисекундах", + "placeholder": "Длительность анимации ИЗ" + }, + "interval": { + "title": "What interval to count" + }, + "spaceBetweenGoalsInPx": { + "title": "Пространство между целями", + "help": "Это значение в пикселях", + "placeholder": "Настройте пространство между целями" + } + }, + "groupSettings": "Настройки группы" +} \ No newline at end of file diff --git a/backend/locales/ru/ui/registry/overlays.json b/backend/locales/ru/ui/registry/overlays.json new file mode 100644 index 000000000..57f0d2ea5 --- /dev/null +++ b/backend/locales/ru/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Создать новое сопоставление ссылок", + "emptyMapping": "Сопоставление ссылок ещё не создано.", + "allowedIPs": { + "name": "Allowed IPs", + "help": "Allow access from set IPs separated by new line" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/registry/plugins.json b/backend/locales/ru/ui/registry/plugins.json new file mode 100644 index 000000000..00eb1444f --- /dev/null +++ b/backend/locales/ru/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "This node needs to be linked with listeners with sender attributes" + }, + "filter": { + "permission": { + "name": "Permission filter" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Event listener", + "type": { + "twitchChatMessage": "Twitch chat message", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat cleared", + "twitchCommand": "Twitch command", + "twitchFollow": "New Twitch follower", + "twitchSubscription": "New Twitch subscription", + "twitchSubgift": "New Twitch subscription gift", + "twitchSubcommunitygift": "New Twitch subscription community gift", + "twitchResub": "New Twitch recurring subscription", + "twitchGameChanged": "Twitch category changed", + "twitchStreamStarted": "Twitch stream started", + "twitchStreamStopped": "Twitch stream stopped", + "twitchRewardRedeem": "Twitch reward redeemed", + "twitchRaid": "Twitch raid incoming", + "tip": "Tipped by user", + "botStarted": "Bot started" + }, + "command": { + "add-parameter": "Add parameter", + "parameters": "Parameters", + "order-is-important": "order is important" + } + }, + "others": { + "idle": { + "name": "Idle" + } + }, + "output": { + "log": { + "name": "Log message" + }, + "timeout-user": { + "name": "Timeout user" + }, + "ban-user": { + "name": "Ban user" + }, + "send-twitch-message": { + "name": "Send Twitch Message" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/registry/randomizer.json b/backend/locales/ru/ui/registry/randomizer.json new file mode 100644 index 000000000..403b497d6 --- /dev/null +++ b/backend/locales/ru/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Добавить генератор", + "form": { + "name": "Название", + "command": "Команда", + "permission": "Разрешение на команду", + "simple": "Простой", + "tape": "Tape", + "wheelOfFortune": "Колесо фортуны", + "type": "Тип", + "options": "Параметры", + "optionsAreEmpty": "Опции не заполнены.", + "color": "Цвет", + "numOfDuplicates": "Количество дубликатов", + "minimalSpacing": "Минимальное расстояние", + "groupUp": "Сгруппировать", + "ungroup": "Разгруппировать", + "groupedWithOptionAbove": "Сгруппировано с параметром выше", + "generatedOptionsPreview": "Предварительный просмотр генерируемых параметров", + "probability": "Вероятность", + "tick": "Тик звука при вращении" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/registry/textoverlay.json b/backend/locales/ru/ui/registry/textoverlay.json new file mode 100644 index 000000000..5e3a59d29 --- /dev/null +++ b/backend/locales/ru/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Создать новый текстовый оверлей", + "title": "текстовый оверлей", + "name": { + "placeholder": "Задайте имя вашего текстового оверлея" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/stats/commandcount.json b/backend/locales/ru/ui/stats/commandcount.json new file mode 100644 index 000000000..2535f7342 --- /dev/null +++ b/backend/locales/ru/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Команда", + "hour": "Час", + "day": "День", + "week": "Неделя", + "month": "Месяц", + "year": "Год", + "total": "Всего" +} \ No newline at end of file diff --git a/backend/locales/ru/ui/systems/checklist.json b/backend/locales/ru/ui/systems/checklist.json new file mode 100644 index 000000000..40cf8ac2b --- /dev/null +++ b/backend/locales/ru/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Статус", + "itemsArray": "Список" + }, + "check": "Перечень" +} \ No newline at end of file diff --git a/backend/locales/ru/ui/systems/howlongtobeat.json b/backend/locales/ru/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..3287a6f83 --- /dev/null +++ b/backend/locales/ru/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Статус" + }, + "empty": "Нет отслеживаемых игр.", + "emptyAfterSearch": "Не найдено отслеживаемых игр по вашему запросу \"$search\".", + "when": "При трансляции", + "time": "Отслеживаемое время", + "overallTime": "Overall time", + "offset": "Смещение отслеживаемого времени", + "main": "Основной", + "extra": "Основной+Дополнительный", + "completionist": "Пройдено", + "game": "Отслеживаемая игра", + "startedAt": "Отслеживание начато в", + "updatedAt": "Last update", + "showHistory": "Показать историю ($count)", + "hideHistory": "Скрыть историю ($count)", + "searchToAddNewGame": "Поиск новой игры для отслеживания" +} \ No newline at end of file diff --git a/backend/locales/ru/ui/systems/keywords.json b/backend/locales/ru/ui/systems/keywords.json new file mode 100644 index 000000000..d85a8cfc8 --- /dev/null +++ b/backend/locales/ru/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "Новое ключевое слово", + "empty": "Ключевых слов еще не создано.", + "emptyAfterSearch": "Не найдено ключевых слов по вашему запросу \"$search\".", + "keyword": { + "name": "Ключевое слово / регулярное выражение", + "placeholder": "Установите ключевое слово или регулярное выражение для вызова ключевого слова.", + "help": "Вы можете использовать регулярные выражения (без учета регистра) для использования ключевых слов, например привет.*|hi" + }, + "response": { + "name": "Ответ", + "placeholder": "Введите ответ." + }, + "error": { + "isEmpty": "Это поле не может быть пустым" + }, + "no-responses-set": "Нет ответов", + "addResponse": "Добавить ответ", + "filter": { + "name": "фильтр", + "placeholder": "Добавить фильтр для этого ответа" + }, + "warning": "Это действие нельзя будет отменить!", + "settings": { + "enabled": "Статус" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/systems/levels.json b/backend/locales/ru/ui/systems/levels.json new file mode 100644 index 000000000..04c74e2b5 --- /dev/null +++ b/backend/locales/ru/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Статус", + "conversionRate": "Conversion rate 1 XP for x Points", + "firstLevelStartsAt": "First level starts at XP", + "nextLevelFormula": { + "title": "Next level calculation formula", + "help": "Available variables: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "Levels example will be refreshed on save", + "xpName": "Название", + "interval": "Minutes interval to add xp to online users when stream online", + "offlineInterval": "Minutes interval to add xp to online users when stream offline", + "messageInterval": "How many messages to add xp", + "messageOfflineInterval": "How many messages to add xp when stream offline", + "perInterval": "How many xp to add per online interval", + "perOfflineInterval": "How many xp to add per offline interval", + "perMessageInterval": "How many xp to add per message interval", + "perMessageOfflineInterval": "How many xp to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/systems/polls.json b/backend/locales/ru/ui/systems/polls.json new file mode 100644 index 000000000..528512051 --- /dev/null +++ b/backend/locales/ru/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Всего голосов", + "totalPoints": "Total points", + "closedAt": "Закрыто", + "activeFor": "Активно для" +} \ No newline at end of file diff --git a/backend/locales/ru/ui/systems/scrim.json b/backend/locales/ru/ui/systems/scrim.json new file mode 100644 index 000000000..ee8996083 --- /dev/null +++ b/backend/locales/ru/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Статус", + "waitForMatchIdsInSeconds": { + "title": "Интервал для ввода ID матча в чат", + "help": "Продолжительность в секундах" + } + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/systems/top.json b/backend/locales/ru/ui/systems/top.json new file mode 100644 index 000000000..47ba1abdb --- /dev/null +++ b/backend/locales/ru/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Статус" + } +} \ No newline at end of file diff --git a/backend/locales/ru/ui/systems/userinfo.json b/backend/locales/ru/ui/systems/userinfo.json new file mode 100644 index 000000000..ca34f17a5 --- /dev/null +++ b/backend/locales/ru/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Статус", + "formatSeparator": "Разделитель", + "order": "Формат", + "lastSeenFormat": { + "title": "Формат времени", + "help": "Возможные форматы на https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk.json b/backend/locales/uk.json new file mode 100644 index 000000000..8d5cd1c9e --- /dev/null +++ b/backend/locales/uk.json @@ -0,0 +1,1206 @@ +{ + "core": { + "loaded": "завантажується та", + "enabled": "увімкнено", + "disabled": "вимкнено", + "usage": "Користування", + "lang-selected": "Мова бота в даний час налаштована на англійську мову", + "refresh-panel": "Вам потрібно оновити UI щоб побачити зміни.", + "command-parse": "Вибачте, на жаль, $sender, але ця команда не є правильною, використовуйте", + "error": "На жаль, $sender, але щось пішло не так!", + "no-response": "", + "no-response-bool": { + "true": "", + "false": "" + }, + "api": { + "error": "$sender, API не відповідає правильно!", + "not-available": "не доступно" + }, + "percentage": { + "true": "", + "false": "" + }, + "years": "рік|роки|років", + "months": "місяць|місяця|місяців", + "days": "день|дні|днів", + "hours": "година|годин", + "minutes": "хвилина|хвилин", + "seconds": "секунда|секунд", + "messages": "повідомлення|повідомлень", + "bits": "bit|bits", + "links": "посилання|посилань", + "entries": "запис|записи", + "empty": "Пусто", + "isRegistered": "$sender, ви не можете використовувати!$keyword, тому що вже використовується для іншої дії!" + }, + "clip": { + "notCreated": "Щось пішло не так, і кліп не створено.", + "offline": "Наразі трансляція недоступна і кліп не може бути створена." + }, + "uptime": { + "online": "Онлайн-трансляція йде (якщо $days>0|$daysd ) (якщо $hours>0|$hoursh )(якщо $minutes>0|$minutesm )(якщо $seconds>0|$secondss)", + "offline": "Стрім оффлайн (if $days>0|$daysд )(if $hours>0|$hoursч )(if $minutes>0|$minutesм )(if $seconds>0|$secondsс)" + }, + "webpanel": { + "this-system-is-disabled": "Ця система вимкнена", + "or": "або", + "loading": "Завантаження", + "this-may-take-a-while": "Це може зайняти деякий час", + "display-as": "Відображати як", + "go-to-admin": "Перейти до панелі управління", + "go-to-public": "Перейти до публічного", + "logout": "Вихід", + "popout": "Розгорнути", + "not-logged-in": "Вхід не здійснено", + "remove-widget": "Видалити віджет $name", + "join-channel": "Приєднатися бота до каналу", + "leave-channel": "Залишити бота з каналу", + "set-default": "Встановити за замовчуванням", + "add": "Додати", + "placeholders": { + "text-url-generator": "Вставте свій текст або html для генерації base64 нижче і URL вище", + "text-decode-base64": "Вставте свій base64 для створення URL і тексту над", + "creditsSpeed": "Встановіть швидкість прокрутки, нижче = швидше" + }, + "timers": { + "title": "Таймери", + "timer": "Таймер", + "messages": "повідомлення", + "seconds": "секунди", + "badges": { + "enabled": "Увімкнено", + "disabled": "Вимкнено" + }, + "errors": { + "timer_name_must_be_compliant": "Це значення може містити тільки a-zA-Z09_", + "this_value_must_be_a_positive_number_or_0": "Це значення має бути додатнім числом або 0", + "value_cannot_be_empty": "Значення не може бути порожнім" + }, + "dialog": { + "timer": "Таймер", + "name": "Ім’я", + "tickOffline": "Відмітити якщо потік не в мережі", + "interval": "Інтервал", + "responses": "Відповіді", + "messages": "Запустити кожні X повідомлення", + "seconds": "Запустити кожні X секунди", + "title": { + "new": "Новий таймер", + "edit": "Редагувати таймер" + }, + "placeholders": { + "name": "Встановити ім'я таймера, може містити тільки ці символи a-zA-Z0-9_", + "messages": "Запустити таймер кожні X повідомлень", + "seconds": "Запустити таймер кожні X секунди" + }, + "alerts": { + "success": "Таймер був успішно збережено.", + "fail": "Щось пішло не так." + } + }, + "buttons": { + "close": "Закрити", + "save-changes": "Зберегти зміни", + "disable": "Вимкнути", + "enable": "Увімкнути", + "edit": "Редагувати", + "delete": "Видалити", + "yes": "Так", + "no": "Ні" + }, + "popovers": { + "are_you_sure_you_want_to_delete_timer": "Ви впевнені, що ви хочете видалити таймер" + } + }, + "events": { + "event": "Подія", + "noEvents": "Події в базі не знайдено.", + "whatsthis": "Що це таке?", + "myRewardIsNotListed": "Моя нагорода не вказана!", + "redeemAndClickRefreshToSeeReward": "Якщо ви не вистачаєте створеної винагороди в списку, оновити, натиснувши на іконку оновлення.", + "badges": { + "enabled": "Увімкнено", + "disabled": "Вимкнуто" + }, + "buttons": { + "test": "Перевірка", + "enable": "Увімкнути", + "disable": "Вимкнути", + "edit": "Редагувати", + "delete": "Видалити", + "yes": "Так", + "no": "Ні" + }, + "popovers": { + "are_you_sure_you_want_to_delete_event": "Ви впевнені, що хочете видалити подію", + "example_of_user_object_data": "Приклад даних об’єкту користувача" + }, + "errors": { + "command_must_start_with_!": "Команда повинна починатись з !", + "this_value_must_be_a_positive_number_or_0": "Це значення має бути додатнім числом або 0", + "value_cannot_be_empty": "Значення не може бути порожнім" + }, + "dialog": { + "title": { + "new": "Новий слухач подій", + "edit": "Редагування слухача подій" + }, + "placeholders": { + "name": "Введіть ім'я слухача події (якщо не вказано, буде згенероване ім'я)" + }, + "alerts": { + "success": "Таймер було успішно збережено.", + "fail": "Щось пішло не так." + }, + "close": "Закрити", + "save-changes": "Зберегти зміни", + "event": "Подія", + "name": "Ім’я", + "usable-events-variables": "Доступні змінні події", + "settings": "Налаштування", + "filters": "Фільтри", + "operations": "Операції" + }, + "definitions": { + "taskId": { + "label": "Ідентифікатор завдання" + }, + "filter": { + "label": "Фільтр" + }, + "linkFilter": { + "label": "Посилання на фільтр оверлею", + "placeholder": "Якщо використовується оверлей, додайте посилання або ідентифікатор вашого оверлея" + }, + "hashtag": { + "label": "Хештег або ключове слово", + "placeholder": "#ВашеХештегТут або ключове слово" + }, + "fadeOutXCommands": { + "label": "Затухання X команд", + "placeholder": "Кількість команд, віднімаючи кожен інтервал згасання" + }, + "fadeOutXKeywords": { + "label": "Згасання Х ключових слів", + "placeholder": "Кількість ключових слів, які віднімають кожний інтервал згасання" + }, + "fadeOutInterval": { + "label": "Інтервал зникнення (сек)", + "placeholder": "Fade out interval subtracting" + }, + "runEveryXCommands": { + "label": "Запустити кожні X команди", + "placeholder": "Кількість команд перед запуском події" + }, + "runEveryXKeywords": { + "label": "Виконувати кожні X ключові слова", + "placeholder": "Кількість ключових слів перед запуском події" + }, + "commandToWatch": { + "label": "Команда для спостереження", + "placeholder": "Встановіть свій !commandToWatch" + }, + "keywordToWatch": { + "label": "Ключове слово для спостереження", + "placeholder": "Встановіть своє ключове словоспостереження" + }, + "resetCountEachMessage": { + "label": "Скинути лічильник кожного X повідомлення", + "true": "Скинути лічильник", + "false": "Продовжити підрахунок" + }, + "viewersAtLeast": { + "label": "Переглядачів як мінімум", + "placeholder": "Скільки глядачів принаймні повинно бути, щоб запустити подію" + }, + "runInterval": { + "label": "Інтервал запуску (0 = працювати один раз на стрім)", + "placeholder": "Запуск події кожні x секунди" + }, + "runAfterXMinutes": { + "label": "Запустити через X хвилин", + "placeholder": "Запуск події після x хвилин" + }, + "runEveryXMinutes": { + "label": "Запустити кожні X хвилин", + "placeholder": "Запуск події кожні Х хвилину" + }, + "messageToSend": { + "label": "Повідомлення для відправки", + "placeholder": "Введіть ваше повідомлення" + }, + "channel": { + "label": "Канал", + "placeholder": "Ім'я каналу або ID" + }, + "timeout": { + "label": "Тайм-аут", + "placeholder": "Встановити тайм-аут в мілісекунд" + }, + "timeoutType": { + "label": "Тип таймаута", + "placeholder": "Встановити тип тайм-ауту" + }, + "command": { + "label": "Команда", + "placeholder": "Встановіть свій !command" + }, + "commandToRun": { + "label": "Команда для запуску", + "placeholder": "Встановіть свій !commandToRun" + }, + "isCommandQuiet": { + "label": "Вимкнути вивід команд" + }, + "urlOfSoundFile": { + "label": "URL-адреса звукового файлу", + "placeholder": "http://www.pathToYour.url/where/is/file.mp3" + }, + "emotesToExplode": { + "label": "Емоції для вибуху", + "placeholder": "Список смайлів для вибуху, наприклад, Kappa PurpleHeart" + }, + "emotesToFirework": { + "label": "Емоції в феєрверку", + "placeholder": "Список смайлів на феєрверк, наприклад, Kappa PurpleHeart" + }, + "replay": { + "label": "Повторити кліп в накладаннях", + "true": "Будуть грати в якості повтору в накладання/сповіщеннях", + "false": "Повтор не буде відтворюватися" + }, + "announce": { + "label": "Повідомити в чаті", + "true": "Буде сповіщено", + "false": "Не буде сповіщено" + }, + "hasDelay": { + "label": "Кліп повинен мати невелику затримку (щоб бути ближчим до того, що бачить користувач)", + "true": "Буде затримка", + "false": "Не буде затримки" + }, + "durationOfCommercial": { + "label": "Тривалість реклами", + "placeholder": "Доступні тривалості - 30, 60, 90, 120, 150, 180" + }, + "customVariable": { + "label": "$_", + "placeholder": "Змінна для оновлення" + }, + "numberToIncrement": { + "label": "Номер для збільшення", + "placeholder": "" + }, + "value": { + "label": "Значення", + "placeholder": "" + }, + "numberToDecrement": { + "label": "Номер для зменшення", + "placeholder": "" + }, + "": "", + "reward": { + "label": "Reward", + "placeholder": "" + } + } + }, + "eventlist-events": { + "follow": "Followed you", + "raid": "Raided you with $viewers raiders.", + "sub": "Subscribed to you with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "subgift": "has been gifted subscription from $username", + "subcommunitygift": "Gifted subscriptions for community", + "resub": "Resubscribed with $subType. They've been subscribed for $subCumulativeMonths $subCumulativeMonthsName.", + "cheer": "Cheered you", + "tip": "Tipped you", + "tipToCharity": "donated to $campaignName" + }, + "responses": { + "variable": { + "tags": "Tags", + "titleOfPrediction": "Twitch Prediction - Title", + "outcomes": "Twitch Prediction - Outcomes", + "locksAt": "Twitch Prediction - Locks At Date", + "winningOutcomeTitle": "Twitch Prediction - Winning outcome title", + "winningOutcomeTotalPoints": "Twitch Prediction - Winning outcome total points", + "winningOutcomePercentage": "Twitch Prediction - Winning outcome percentage", + "titleOfPoll": "Twitch Poll - Title", + "bitAmountPerVote": "Twitch Poll - Amount of bits to count as 1 vote", + "bitVotingEnabled": "Twitch Poll - Is bit voting enabled (boolean)", + "channelPointsAmountPerVote": "Twitch Poll - Amount of channel points to count as 1 vote", + "channelPointsVotingEnabled": "Twitch Poll - Is channel points voting enabled (boolean)", + "votes": "Twitch Poll - votes count", + "winnerChoice": "Twitch Poll - Winner choice", + "winnerPercentage": "Twitch Poll - Winner choice percentage", + "winnerVotes": "Twitch Poll - Winner choice votes", + "goal": "Ціль", + "total": "Разом", + "lastContributionTotal": "Останній внесок - Всього", + "lastContributionType": "Останній внесок - Тип", + "lastContributionUserId": "Останній внесок - ID користувача", + "lastContributionUsername": "Останній внесок - Ім’я користувача", + "level": "Рівень", + "topContributionsBitsTotal": "Top Bits Contribution - Total", + "topContributionsBitsUserId": "Top Bits Contribution - User ID", + "topContributionsBitsUsername": "Top Bits Contribution - Username", + "topContributionsSubsTotal": "Top Subs Contribution - Total", + "topContributionsSubsUserId": "Top Subs Contribution - User ID", + "topContributionsSubsUsername": "Top Subs Contribution - Username", + "sender": "Користувач, яикй ініціював", + "title": "Поточна назва", + "game": "Current category", + "language": "Поточна мова трансляції", + "viewers": "Поточне число глядачів", + "hostViewers": "Raid viewers count", + "followers": "Поточна кількість підписників", + "subscribers": "Поточна кількість сабс підписників", + "arg": "Аргумент", + "param": "Параметр (обов'язково)", + "touser": "Параметр імені користувача", + "!param": "Параметр (не обов'язковий)", + "alias": "Псевдонім", + "command": "Команда", + "keyword": "Ключове слово", + "response": "Відповідь", + "list": "Популярний список", + "type": "Тип", + "days": "Дні", + "hours": "Години", + "minutes": "Хвилини", + "seconds": "Секунди", + "description": "Опис", + "quiet": "Тихий (bool)", + "id": "ID", + "name": "Ім’я", + "messages": "Повідомлення", + "amount": "Сума", + "amountInBotCurrency": "Сума в валюті бота", + "currency": "Валюта", + "currencyInBot": "Валюта в боті", + "pointsName": "Назва балів", + "points": "Бали", + "rank": "Ранг", + "nextrank": "Наступний ранг", + "username": "Ім'я користувача", + "value": "Значення", + "variable": "Змінна", + "count": "Кількість", + "link": "Посилання (перекладено)", + "winner": "Переможець", + "loser": "Той, хто програв", + "challenger": "Челленджер", + "min": "Мінімальний", + "max": "Максимальний", + "eligibility": "Право на участь", + "probability": "Ймовірність", + "time": "Час", + "options": "Параметри", + "option": "Параметр", + "when": "Коли", + "diff": "Відмінність", + "users": "Користувачі", + "user": "Користувач", + "bank": "Банк", + "nextBank": "Наступний банк", + "cooldown": "Перезарядка", + "tickets": "Квитки", + "ticketsName": "Назва квитка", + "fromUsername": "Від імені користувача", + "toUsername": "Ім'я користувача", + "items": "Предмети", + "bits": "Bits", + "subgifts": "Subgifts", + "subStreakShareEnabled": "Is substreak share enabled (true/false)", + "subStreak": "Поточний стрік саба", + "subStreakName": "localized name of month (1 month, 2 months) for current sub strek", + "subCumulativeMonths": "Загальний час підписки", + "subCumulativeMonthsName": "localized name of month (1 month, 2 months) for cumulative subscribe months", + "message": "Повідомлення", + "reason": "Підстава", + "target": "Ціль", + "duration": "Тривалість", + "method": "Метод", + "tier": "Рівень", + "months": "Місяці", + "monthsName": "localized name of month (1 month, 2 months)", + "oldGame": "Category before change", + "recipientObject": "Повний об'єкт одержувача", + "recipient": "Одержувач", + "ytSong": "Поточна пісня на YouTube", + "spotifySong": "Поточна пісня на Spotify", + "latestFollower": "Останній підписник", + "latestSubscriber": "Останній саб-підписник", + "latestSubscriberMonths": "Latest Subscriber cumulative months", + "latestSubscriberStreak": "Latest Subscriber months streak", + "latestTipAmount": "Останній донат (сума)", + "latestTipCurrency": "Останній донат (валюта)", + "latestTipMessage": "Останній донат (повідомлення)", + "latestTip": "Останній донат (ім'я користувача)", + "toptip": { + "overall": { + "username": "Топ Донат - загалом (ім'я користувача)", + "amount": "Топ Донат - загалом (сума)", + "currency": "Топ Донат - загалом (валюта)", + "message": "Топ Донат - загалом (повідомлення)" + }, + "stream": { + "username": "Топ Донат - під час трансляції (ім'я користувача)", + "amount": "Топ Донат - під час трансляції (сума)", + "currency": "Топ Донат - під час трансляції (валюта)", + "message": "Топ Донат - під час трансляції (повідомлення)" + } + }, + "latestCheerAmount": "Останні біти (сума)", + "latestCheerMessage": "Останні Біти (повідомлення)", + "latestCheer": "Останні Bits (ім'я користувача)", + "version": "Версія бота", + "haveParam": "Have command parameter? (bool)", + "source": "Поточне джерело (twitch або discored)", + "userInput": "User input during reward redeem", + "isBotSubscriber": "Is bot subscriber (bool)", + "isStreamOnline": "Is stream online (bool)", + "uptime": "Час роботи потоку", + "is": { + "moderator": "Користувач модератор (bool)", + "subscriber": "Користувач сабскрайбер (bool)", + "vip": "Користувач VIP (bool)", + "newchatter": "Is user's first message? (bool)", + "follower": "Користувач підписник? (bool)", + "broadcaster": "Користувач стрімер? (bool)", + "bot": "Користувач бот? (bool)", + "owner": "Користувач власник бота? (bool)" + }, + "recipientis": { + "moderator": "Is recipient mod? (bool)", + "subscriber": "Чи є одержувач сабскрайбером? (bool)", + "vip": "Чи є одержувач VIP? (bool)", + "follower": "Чи є одержувач підписником? (bool)", + "broadcaster": "Чи є одержувач броадкастером? (bool)", + "bot": "Чи є одержувач ботом? (bool)", + "owner": "Чи є одержувач власником бота? (bool)" + }, + "sceneName": "Назва сцени", + "inputName": "Name of input", + "inputMuted": "Mute state (bool)" + } + }, + "page-settings": { + "systems": { + "others": { + "title": "Інші", + "currency": "Валюта" + }, + "whispers": { + "title": "Вісперси", + "toggle": { + "listener": "Слухати команди у вісперсах", + "settings": "Оповіщувати в вісперс при зміні налаштування", + "raffle": "Вісперс при приєднанні до розіграшу", + "permissions": "Whispers on insufficient permissions", + "cooldowns": "Вісперс при кулдауні " + } + } + } + }, + "page-logger": { + "buttons": { + "messages": "Повідомлення", + "follows": "Підписки", + "subs": "Саби & Ресаби", + "cheers": "Бітси", + "responses": "Відповіді бота", + "whispers": "Вісперс", + "bans": "Заборонити", + "timeouts": "Таймаути" + }, + "range": { + "day": "день", + "week": "тиждень", + "month": "місяць", + "year": "рік", + "all": "За весь час" + }, + "order": { + "asc": "За зростанням", + "desc": "По убуванню" + }, + "labels": { + "order": "Заказ", + "range": "Область", + "filters": "ФІЛЬТРИ" + } + }, + "stats-panel": { + "show": "Показати статистику", + "hide": "Приховати статистику" + }, + "translations": "Власні переклади", + "bot-responses": "Відповідь бота", + "duration": "Тривалість", + "viewers-reset-attributes": "Скинути всі атрибути", + "viewers-points-of-all-users": "Бали всіх користувачів", + "viewers-watchtime-of-all-users": "Час спостереження всіх користувачів", + "viewers-messages-of-all-users": "Повідомлення усіх користувачів", + "events-game-after-change": "category after change", + "events-game-before-change": "category before change", + "events-user-triggered-event": "користувач викликливший подію", + "events-method-used-to-subscribe": "спосіб сабскрайба", + "events-months-of-subscription": "тривалість сабскрайба", + "events-monthsName-of-subscription": "слово \"місяць\" на номер (1 місяць, 2 місяці)", + "events-user-message": "Повідомлення користувача", + "events-bits-user-sent": "бітсов відправлено користувачем", + "events-reason-for-ban-timeout": "причина бану/тайм-ауту", + "events-duration-of-timeout": "тривалість таймауту", + "events-duration-of-commercial": "тривалість реклами", + "overlays-eventlist-resub": "resub", + "overlays-eventlist-subgift": "subgift", + "overlays-eventlist-subcommunitygift": "саб подарунок спільноти", + "overlays-eventlist-sub": "sub", + "overlays-eventlist-follow": "Підписатися", + "overlays-eventlist-cheer": "бітc", + "overlays-eventlist-tip": "чайові", + "overlays-eventlist-raid": "Рейд", + "requested-by": "Автор —", + "description": "Опис", + "raffle-type": "Тип розіграшу", + "raffle-type-keywords": "Тільки ключове слово", + "raffle-type-tickets": "З квитками", + "raffle-tickets-range": "Діапазон квитків", + "video_id": "Ідентифікатор відео", + "highlights": "Яскраві моменти", + "cooldown-quiet-header": "Показати повідомлення про кулдаун", + "cooldown-quiet-toggle-no": "Сповіщати", + "cooldown-quiet-toggle-yes": "Не сповіщати", + "cooldown-moderators": "Модератори", + "cooldown-owners": "Власники", + "cooldown-subscribers": "Сабкрайбери", + "cooldown-followers": "Підписники", + "in-seconds": "в секундах", + "songs": "Пісні", + "show-usernames-with-at": "Показувати імена користувачів з @", + "send-message-as-a-bot": "Надіслати повідомлення як бота", + "chat-as-bot": "Чат (як бот)", + "product": "Продукт", + "optional": "опціонально", + "placeholder-search": "Пошук", + "placeholder-enter-product": "Введіть ім’я продукту", + "placeholder-enter-keyword": "Введіть ключове слово", + "credits": "Подяка", + "fade-out-top": "згасання", + "fade-out-zoom": "маштаб згасаггя ", + "global": "Загальні", + "user": "Користувач", + "alerts": "Сповіщення", + "eventlist": "Список подій", + "dashboard": "Панель керування", + "carousel": "Карусель зображення", + "text": "Текст", + "filter": "Filter", + "filters": "Filters", + "isUsed": "Is used", + "permissions": "Права доступу", + "permission": "Дозвіл", + "viewers": "Глядачі", + "systems": "Система", + "overlays": "Оверлей", + "gallery": "Медіа-галерея", + "aliases": "Псевдоніми", + "alias": "Псевдонім", + "command": "Команда", + "cooldowns": "Кулдаун", + "title-template": "Назва шаблону", + "keyword": "Ключове слово", + "moderation": "Модерація", + "timer": "Таймер", + "price": "Вартість", + "rank": "Ранг", + "previous": "Попередній", + "next": "Наступна", + "close": "Закрити", + "save-changes": "Зберегти зміни", + "saving": "Збереження...", + "deleting": "Видаляємо...", + "done": "Готово", + "error": "Помилка", + "title": "Назва", + "change-title": "Змінити назву", + "game": "category", + "tags": "Теги", + "change-game": "Change category", + "click-to-change": "натисніть, щоб змінити", + "uptime": "Час роботи", + "not-affiliate-or-partner": "Не компаньйон/партнер", + "not-available": "Недоступно", + "max-viewers": "Максимум глядачів", + "new-chatters": "Нові в чаті", + "chat-messages": "Повідомлення в чаті", + "followers": "Підписників", + "subscribers": "Сабскрайберів", + "bits": "Bits", + "subgifts": "Сабгіфт", + "subStreak": "Поточна серія саба", + "subCumulativeMonths": "Загальна серія саба", + "tips": "Донати", + "tier": "Тір", + "status": "Стан", + "add-widget": "Додати віджет", + "remove-dashboard": "Видалити панель", + "close-bet-after": "Закрити ставки через", + "refund": "повернення", + "roll-again": "Повторити знову", + "no-eligible-participants": "Немає потрібних учасників", + "follower": "Підписник", + "subscriber": "Сабкрайбер", + "minutes": "хвилини", + "seconds": "секунди", + "hours": "години", + "months": "місяці", + "eligible-to-enter": "Eligible to enter", + "everyone": "Всім", + "roll-a-winner": "Обрати переможця", + "send-message": "Відправити повідомлення", + "messages": "Повідомлення", + "level": "Рівень", + "create": "Створити", + "cooldown": "Кулдаун", + "confirm": "Підтвердити", + "delete": "Видалити", + "enabled": "Увімкнено", + "disabled": "Відключено", + "enable": "Увімкнути", + "disable": "Вимкнути", + "slug": "Slug", + "posted-by": "Автор", + "time": "Час", + "type": "Тип", + "response": "Відповідь", + "cost": "Вартість", + "name": "Ім’я", + "playlist": "Список відтворення", + "length": "Довжина", + "volume": "Гучність", + "start-time": "Час початку", + "end-time": "Час завершення", + "watched-time": "Час перегляду", + "currentsong": "Поточна пісня", + "group": "Група", + "followed-since": "Підписаний з", + "subscribed-since": "Сабскрайб з", + "username": "Ім'я користувача", + "hashtag": "Хештег", + "accessToken": "AccessToken", + "refreshToken": "RefreshToken", + "scopes": "Дозволи", + "last-seen": "Остання активність", + "date": "Дата", + "points": "Бали", + "calendar": "Календар", + "string": "рядок", + "interval": "Інтервал", + "number": "число", + "minimal-messages-required": "Мінімальний текст повідомлення", + "max-duration": "Макс. довжина", + "shuffle": "Перемішати", + "song-request": "Запит про пісню", + "format": "Формат", + "available": "Доступні", + "one-record-per-line": "один запис в рядку", + "on": "увімк.", + "off": "вимкн.", + "search-by-username": "Пошук за іменем користувача", + "widget-title-custom": "КОРИСТУВАЦЬКА", + "widget-title-eventlist": "ПОДІЇ", + "widget-title-chat": "ЧАТ", + "widget-title-queue": "ЧЕРГА", + "widget-title-raffles": "РОЗІГРАШ", + "widget-title-social": "СОЦІАЛЬНІ", + "widget-title-ytplayer": "Музичний програвач ", + "widget-title-monitor": "МОНІТОР", + "event": "подія", + "operation": "операція", + "tweet-post-with-hashtag": "Твіт опубліковано з хештегом", + "user-joined-channel": "користувач приєднується до каналу", + "user-parted-channel": "користувач вийшов з каналу", + "follow": "новий підписник", + "tip": "новий донат", + "obs-scene-changed": "Сцена OBS змінена", + "obs-input-mute-state-changed": "OBS input source mute state changed", + "unfollow": "відписка", + "hypetrain-started": "Hype Train запущено", + "hypetrain-ended": "Hype Train закінчився", + "prediction-started": "Twitch Prediction started", + "prediction-locked": "Twitch Prediction locked", + "prediction-ended": "Twitch Prediction ended", + "poll-started": "Twitch Poll started", + "poll-ended": "Twitch Poll ended", + "hypetrain-level-reached": "Hype Train new level reached", + "subscription": "новий сарскрайбер", + "subgift": "новий подарований саб", + "subcommunitygift": "new sub given to community", + "resub": "користувач ресабнувся", + "command-send-x-times": "команда була надіслана х раз", + "keyword-send-x-times": "ключове слово було відправлено x разів", + "number-of-viewers-is-at-least-x": "кількість глядачів мінімум х", + "stream-started": "трансляція розпочата", + "reward-redeemed": "reward redeemed", + "stream-stopped": "трансляцію зупинено", + "stream-is-running-x-minutes": "Потік працює Х хвилин", + "chatter-first-message": "перше повідомлення в чаті", + "every-x-minutes-of-stream": "кожні X хвилин потоку", + "game-changed": "category changed", + "cheer": "received bits", + "clearchat": "чат було очищено", + "action": "користувач використав /me", + "ban": "користувач заблокований", + "raid": "ваш канал зарейдили", + "mod": "користувач — новий модератор", + "timeout": "користувач отримав таймаут", + "create-a-new-event-listener": "Створити нового прослуховувача подій", + "send-discord-message": "надіслати повідомлення в Discord", + "send-chat-message": "надіслати повідомлення в чат (twitch)", + "send-whisper": "відправити повідомлення у вісперс", + "run-command": "виконати команду", + "run-obswebsocket-command": "запускати команду OBS Websocket", + "do-nothing": "--- нічого не робити --", + "count": "кількість", + "timestamp": "часова мітка", + "message": "повідомлення", + "sound": "звук", + "emote-explosion": "емоційний вибух", + "emote-firework": "emote firework", + "quiet": "тихий", + "noisy": "галасливий", + "true": "Вірно", + "false": "хибність", + "light": "Світла тема", + "dark": "Темна тема", + "gambling": "Азартні ігри", + "seppukuTimeout": "Таймаут для !seppuku", + "rouletteTimeout": "Тайм-аут для !roulett", + "fightmeTimeout": "Тайм-аут для !fightme", + "duelCooldown": "Кулдаун для !duel", + "fightmeCooldown": "Кулдаун для !fightme", + "gamblingCooldownBypass": "Ігнорувати кулдаун азартнних ігор для модераторів/стрімера", + "click-to-highlight": "яскраві моменти", + "click-to-toggle-display": "перемкнутись відображення", + "commercial": "реклама розпочалась", + "start-commercial": "запускати рекламу", + "bot-will-join-channel": "бот приєднається до каналу", + "bot-will-leave-channel": "бот покине канал", + "create-a-clip": "Створити кліп", + "increment-custom-variable": "збільшити задану змінну", + "set-custom-variable": "встановити користувацьку змінну", + "decrement-custom-variable": "зменшити задану змінну", + "omit": "пропустити", + "comply": "дотримуватися", + "visible": "видимість", + "hidden": "прихований", + "gamblingChanceToWin": "Шанс виграти !gamble", + "gamblingMinimalBet": "Мінімальна ставка для !gamble", + "duelDuration": "Тривалість !duel", + "duelMinimalBet": "Мінімальна ставка для !duel" + }, + "raffles": { + "announceInterval": "Opened raffles will be announced every $value minute", + "eligibility-followers-item": "підписники", + "eligibility-subscribers-item": "сабскрайберів", + "eligibility-everyone-item": "всі", + "raffle-is-running": "Raffle увімкнено ($count $l10n_entries).", + "to-enter-raffle": "To enter type \"$keyword\". Raffle is opened for $eligibility.", + "to-enter-ticket-raffle": "To enter type \"$keyword <$min-$max>\". Raffle is opened for $eligibility.", + "added-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-raffle}", + "added-ticket-entries": "Added $count $l10n_entries to raffle ($countTotal total). {raffles.to-enter-ticket-raffle}", + "join-messages-will-be-deleted": "Your raffle messages will be deleted on join.", + "announce-raffle": "{raffles.raffle-is-running} {raffles.to-enter-raffle}", + "announce-ticket-raffle": "{raffles.raffle-is-running} {raffles.to-enter-ticket-raffle}", + "announce-new-entries": "{raffles.added-entries} {raffles.to-enter-raffle}", + "announce-new-ticket-entries": "{raffles.added-entries} {raffles.to-enter-ticket-raffle}", + "cannot-create-raffle-without-keyword": "Вибачте, $sender, але без ключового слова неможливо створити розіграш", + "raffle-is-already-running": "Вибачте, $sender, розіграш вже працює з ключовим словом $keyword", + "no-raffle-is-currently-running": "$sender, no raffles without winners are currently running", + "no-participants-to-pick-winner": "$sender, ніхто не вступив до лотереї", + "raffle-winner-is": "Winner of raffle $keyword is $username! Win probability was $probability%!" + }, + "bets": { + "running": "$sender, bet is already opened! Bet options: $options. Use $command close 1-$maxIndex", + "notRunning": "На цей час жодна ставка не відкривається, попросіть модераторів, щоб відкрити її!", + "opened": "New bet '$title' is opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "closeNotEnoughOptions": "$sender, you need to select winning option for bet close.", + "notEnoughOptions": "$sender, нові ставки потребують не менше 2 варіантів!", + "info": "Bet '$title' is still opened! Bet options: $options. Use $command 1-$maxIndex to win! You have only $minutesmin to bet!", + "diffBet": "$sender, you already made a bet on $option and you cannot bet to different option!", + "undefinedBet": "Sorry, $sender, but this bet option doesn't exist, use $command to check usage", + "betPercentGain": "Bet percent gain per option was set to $value%", + "betCloseTimer": "Ставки будуть автоматично закриватися після $valuemin", + "refund": "Ставки закрилися без перемоги. Усі користувачі отримали ставки назад!", + "notOption": "$sender, ця опція не існує! Ставка не закрита, перевірте $command", + "closed": "Bets was closed and winning option was $option! $amount users won in total $points $pointsName!", + "timeUpBet": "I guess you are too late, $sender, your time for betting is up!", + "locked": "Час ставок вичерпано! Більше ставок немає.", + "zeroBet": "$sender, Ви не можете поставити 0 $pointsName", + "lockedInfo": "Ставка «$title» все ще відкрита, але час на стадію ставок вичерпано!", + "removed": "Час ставки вичерпано! Жодних ставок не було відправлено -> автоматично закривається", + "error": "На жаль, $sender, ця команда невірна! Використовуйте $command 1 -$maxIndex . Наприклад $command 0 100 поставить 100 балів на елемент 0." + }, + "alias": { + "alias-parse-failed": "{core.command-parse} !alias", + "alias-was-not-found": "$sender, псевдонім $alias не знайдений в базі даних", + "alias-was-edited": "$sender, псевдонім $alias змінений на $command", + "alias-was-added": "$sender, псевдонім $alias за $command було додано", + "list-is-not-empty": "$sender, перелік псевдонімів: $list", + "list-is-empty": "$sender, перелік псевдонімів порожній", + "alias-was-enabled": "$sender, псевдонім $alias був увімкнений", + "alias-was-disabled": "$sender, псевдонім $alias був вимкнений", + "alias-was-concealed": "$sender, псевдонім $alias був прихований", + "alias-was-exposed": "$sender, псевдонім $alias був розкритий", + "alias-was-removed": "$sender, псевдонім $alias було видалено", + "alias-group-set": "$sender, псевдонім $alias встановлений для групи $group", + "alias-group-unset": "$sender, псевдонім $alias групи було видалено", + "alias-group-list": "$sender, список груп псевдоніму: $list", + "alias-group-list-aliases": "$sender, список псевдонімів в $group: $list", + "alias-group-list-enabled": "$sender, псевдоніми в $group включені.", + "alias-group-list-disabled": "$sender, псевдоніми в $group відключені." + }, + "customcmds": { + "commands-parse-failed": "{core.command-parse} $command", + "command-was-not-found": "$sender, команда $command не була знайдена в базі даних", + "response-was-not-found": "$sender, відповідь #$response команди $command не знайдена в базі", + "command-was-edited": "$sender, команда $command змінена на '$response", + "command-was-added": "$sender, команда $command додано", + "list-is-not-empty": "$sender, перелік команд: $list", + "list-is-empty": "$sender, перелік команд порожній", + "command-was-enabled": "$sender, команду $command увімкнено", + "command-was-disabled": "$sender, команду $command вимкнуто", + "command-was-concealed": "$sender, команду $command приховано", + "command-was-exposed": "$sender, команду $command відкрита", + "command-was-removed": "$sender, команда $command видалена", + "response-was-removed": "$sender, відповідь #$response з $command було видалено", + "list-of-responses-is-empty": "$sender, $command не має відповідей або не існує", + "response": "$command#$index ($permission) $after| $response" + }, + "keywords": { + "keyword-parse-failed": "{core.command-parse} !keyword", + "keyword-is-ambiguous": "$sender, keyword $keyword is ambiguous, use ID of keyword", + "keyword-was-not-found": "$sender, keyword $keyword was not found in database", + "response-was-not-found": "$sender, response #$response of keyword $keyword was not found in database", + "keyword-was-edited": "$sender, ключове слово $keyword змінено на '$response'", + "keyword-was-added": "$sender, ключове слово $keyword ($id) було додано", + "list-is-not-empty": "$sender, перелік ключових слів: $list", + "list-is-empty": "$sender, список ключових слів порожній", + "keyword-was-enabled": "$sender, ключове слово $keyword було увімкнено", + "keyword-was-disabled": "$sender, ключове слово $keyword було вимкнено", + "keyword-was-removed": "$sender, ключове слово $keyword було видалено", + "list-of-responses-is-empty": "$sender, $keyword не має відповідей або не існує", + "response": "$keyword#$index ($permission) $after| $response" + }, + "points": { + "success": { + "undo": "$sender, бали '$command' для $username було повернуто ($updatedValue $updatedValuePointsLocale на $originalValue $originalValuePointsLocale).", + "set": "$username було встановлено $amount $pointsName", + "give": "$sender щойно дав $amount $pointsName $username", + "online": { + "positive": "Всі онлайн користувачі тільки що отримали $amount $pointsName!", + "negative": "Всі користувачі онлайн щойно втратили $amount $pointsName!" + }, + "all": { + "positive": "Всі користувачі щойно отримали $amount $pointsName!", + "negative": "Всі користувачі просто втратили $amount $pointsName!" + }, + "rain": "Дощ! Всі онлайн-користувачі можуть отримати до $amount $pointsName!", + "add": "$username щойно отримав(-ла) $amount $pointsName!", + "remove": "Отакої, $amount $pointsName було видалено з $username!" + }, + "failed": { + "undo": "$sender, ім'я користувача не знайдено в базі даних або користувач не має записів скасування операцій", + "set": "{core.command-parse} $command [username] [amount]", + "give": "{core.command-parse} $command [username] [amount]", + "giveNotEnough": "Вибачте, $sender, у вас немає $amount $pointsName щоб дати це $username", + "cannotGiveZeroPoints": "Sorry, $sender, you cannot give $amount $pointsName to $username", + "get": "{core.command-parse} $command [username]", + "online": "{core.command-parse} $command [amount]", + "all": "{core.command-parse} $command [amount]", + "rain": "{core.command-parse} $command [amount]", + "add": "{core.command-parse} $command [username] [amount]", + "remove": "{core.command-parse} $command [username] [amount]" + }, + "defaults": { + "pointsResponse": "$username має $amount $pointsName. Ваша позиція $order/$count." + } + }, + "songs": { + "playlist-is-empty": "$sender, список відтворення для імпорту порожнє", + "playlist-imported": "$sender, імпортовано $imported і пропущено $skipped в список відтворення", + "not-playing": "Нічого не відтворюється", + "song-was-banned": "Пісня $name була заблокована і ніколи не буде грати знову!", + "song-was-banned-timeout-message": "Ви отримали тайм-аут за публікацію забороненої пісні", + "song-was-unbanned": "Пісня була успішно розблокована", + "song-was-not-banned": "Цю пісню не заблоковано", + "no-song-is-currently-playing": "На даний момент немає пісні", + "current-song-from-playlist": "Поточна пісня $name з плейлиста", + "current-song-from-songrequest": "Поточна пісня $name замовлена $username", + "songrequest-disabled": "Вибачте, $sender, запити на пісню відключено", + "song-is-banned": "Вибачте, $sender, але ця пісня заблокована", + "youtube-is-not-responding-correctly": "На жаль, $sender, але YouTube надсилає неочікувані відповіді, будь ласка, спробуйте пізніше.", + "song-was-not-found": "Вибачте, $sender, але ця пісня не знайдено", + "song-is-too-long": "Вибачте $sender, але ця пісня занадто довга", + "this-song-is-not-in-playlist": "Вибачте, на жаль, $sender, але ця пісня відсутня в поточному списку відтворення", + "incorrect-category": "Вибачте, на жаль, $sender, але ця пісня повинна бути в категорії музика", + "song-was-added-to-queue": "$sender, пісня $name була додана в чергу", + "song-was-added-to-playlist": "$sender, пісня $name була додана до списку відтворення", + "song-is-already-in-playlist": "$sender, пісня $name вже у списку відтворення", + "song-was-removed-from-playlist": "$sender, пісня $name видалена з плейлиста", + "song-was-removed-from-queue": "$sender, ваша пісня $name видалена з черги", + "playlist-current": "$sender, поточний плейліст $playlist.", + "playlist-list": "$sender, доступні плейлісти: $list.", + "playlist-not-exist": "$sender, your requested playlist $playlist doesn't exist.", + "playlist-set": "$sender, ви змінили плейліст на $playlist." + }, + "price": { + "price-parse-failed": "{core.command-parse} !price", + "price-was-set": "$sender, price for $command was set to $amount $pointsName", + "price-was-unset": "$sender, price for $command was unset", + "price-was-not-found": "$sender, price for $command was not found", + "price-was-enabled": "$sender, price for $command was enabled", + "price-was-disabled": "$sender, price for $command was disabled", + "user-have-not-enough-points": "Sorry, $sender, but you don't have $amount $pointsName to use $command", + "user-have-not-enough-points-or-bits": "Sorry, $sender, but you don't have $amount $pointsName or redeem command by $bitsAmount bits to use $command", + "user-have-not-enough-bits": "Sorry, $sender, but you need to redeem command by $bitsAmount bits to use $command", + "list-is-empty": "$sender, list of prices is empty", + "list-is-not-empty": "$sender, list of prices: $list" + }, + "ranks": { + "rank-parse-failed": "{core.command-parse} !rank help", + "rank-was-added": "$sender, додано нове звання $type $rank($hours$hlocale)", + "rank-was-edited": "$sender, rank for $type $hours$hlocale was changed to $rank", + "rank-was-removed": "$sender, rank for $type $hours$hlocale was removed", + "rank-already-exist": "$sender, there is already a rank for $type $hours$hlocale", + "rank-was-not-found": "$sender, rank for $type $hours$hlocale was not found", + "custom-rank-was-set-to-user": "$sender, ви встановили $rank для $username", + "custom-rank-was-unset-for-user": "$sender, custom rank for $username was unset", + "list-is-empty": "$sender, ранги не знайдено", + "list-is-not-empty": "$sender, список звань: $list", + "show-rank-without-next-rank": "$sender, у вас $rank ранг", + "show-rank-with-next-rank": "$sender, у вас звання $rank. Наступний ранг — $nextrank", + "user-dont-have-rank": "$sender, у вас ще немає звання" + }, + "followage": { + "success": { + "never": "$sender, $username не є підписником каналу", + "time": "$sender, $username підписаний $diff" + }, + "successSameUsername": { + "never": "$sender, ви не підписані на цей канал", + "time": "$sender, ви підписаний на цей канал $diff" + } + }, + "subage": { + "success": { + "never": "$sender, $username не сабскрайбер.", + "notNow": "$sender, $username is currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, $username is subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, $username is subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + }, + "successSameUsername": { + "never": "$sender, ви не сабскрайбер.", + "notNow": "$sender, you are currently not a channel subscriber. In total of $subCumulativeMonths $subCumulativeMonthsName.", + "timeWithSubStreak": "$sender, you are subscriber of channel. Current sub streak for $diff ($subStreak $subStreakMonthsName) and in total of $subCumulativeMonths $subCumulativeMonthsName.", + "time": "$sender, you are subscriber of channel. In total of $subCumulativeMonths $subCumulativeMonthsName." + } + }, + "age": { + "failed": "$sender, I don't have data for $username account age", + "success": { + "withUsername": "$sender, вік облікового запису $username — $diff", + "withoutUsername": "$sender, вік вашого облікового запису $diff" + } + }, + "lastseen": { + "success": { + "never": "$username ніколи не був на цьому каналі!", + "time": "$username was last seen at $when in this channel" + }, + "failed": { + "parse": "{core.command-parse} !lastseen [username]" + } + }, + "watched": { + "success": { + "time": "$username watched this channel for $time hours" + }, + "failed": { + "parse": "{core.command-parse} !watched або !watched [username]" + } + }, + "permissions": { + "without-permission": "You don't have enough permissions for '$command'" + }, + "moderation": { + "user-have-immunity": "$sender, користувач $username має імунітет $type на $time секунд", + "user-have-immunity-parameterError": "$sender, помилка параметра. $command ", + "user-have-link-permit": "Користувач $username може опублікувати повідомлення $count $link", + "permit-parse-failed": "{core.command-parse} !permit [username]", + "user-is-warned-about-links": "No links allowed, ask for !permit [$count warnings left]", + "user-is-warned-about-symbols": "Спам символів заборонено [$count попереджень залишилось]", + "user-is-warned-about-long-message": "Довгі повідомлення не дозволені [$count попереджень залишилося]", + "user-is-warned-about-caps": "Капс заборонено [$count попереджень]", + "user-is-warned-about-spam": "Спам заборонено [$count попереджень залишилось]", + "user-is-warned-about-color": "Курсив і /me не дозволено [$count попереджень залишилось]", + "user-is-warned-about-emotes": "Спам смайликів заборонено [$count попереджень залишилось]", + "user-is-warned-about-forbidden-words": "Слово в чорному списку [$count попереджень залишилось]", + "user-have-timeout-for-links": "Посилання заборонені, запитайте !permit", + "user-have-timeout-for-symbols": "Надмірна кількість символів", + "user-have-timeout-for-long-message": "Довгі повідомлення заборонені", + "user-have-timeout-for-caps": "Надмірне використання капса", + "user-have-timeout-for-spam": "Спам заборонений", + "user-have-timeout-for-color": "Курсив і /me неприпустимі", + "user-have-timeout-for-emotes": "Спам смайликами заборонено", + "user-have-timeout-for-forbidden-words": "Заборонені слова відсутні" + }, + "queue": { + "list": "$sender, поточний пул черги: $users", + "info": { + "closed": "$sender, {queue.close}", + "opened": "$sender, {queue.open}" + }, + "join": { + "closed": "Вибачте $sender, черга наразі закрита", + "opened": "$sender був доданий в чергу" + }, + "open": "Queue is currently OPENED! Join to queue with !queue join", + "close": "Черга в даний час закрита!", + "clear": "Черга була повністю очищена", + "picked": { + "single": "This user was picked from queue: $users", + "multi": "These users were picked from queue: $users", + "none": "Немає користувачів у черзі" + } + }, + "marker": "Stream marker has been created at $time.", + "title": { + "current": "$sender, назва трансляції '$title.", + "change": { + "success": "$sender, назва була встановлена на: $title" + } + }, + "game": { + "current": "$sender, стрімер на даний момент грає в $game.", + "change": { + "success": "$sender, category was set to: $game" + } + }, + "cooldowns": { + "cooldown-was-set": "$sender, $type кулдаун для $command було встановлено на $secondss", + "cooldown-was-unset": "$sender, cooldown for $command was unset", + "cooldown-triggered": "$sender, '$command' is on cooldown, remaining $secondss", + "cooldown-not-found": "$sender, час очікування для $command не знайдений", + "cooldown-was-enabled": "$sender, час очікування для $command був включений", + "cooldown-was-disabled": "$sender, час очікування для $command відключений", + "cooldown-was-enabled-for-moderators": "$sender, час очікування для $command увімкнено для модераторів", + "cooldown-was-disabled-for-moderators": "$sender, cooldown for $command was disabled for moderators", + "cooldown-was-enabled-for-owners": "$sender, cooldown for $command was enabled for owners", + "cooldown-was-disabled-for-owners": "$sender, cooldown for $command was disabled for owners", + "cooldown-was-enabled-for-subscribers": "$sender, cooldown for $command was enabled for subscribers", + "cooldown-was-disabled-for-subscribers": "$sender, cooldown for $command was disabled for subscribers", + "cooldown-was-enabled-for-followers": "$sender, cooldown for $command was enabled for followers", + "cooldown-was-disabled-for-followers": "$sender, cooldown for $command was disabled for followers" + }, + "timers": { + "id-must-be-defined": "$sender, response id must be defined.", + "id-or-name-must-be-defined": "$sender, response id or timer name must be defined.", + "name-must-be-defined": "$sender, timer name must be defined.", + "response-must-be-defined": "$sender, timer response must be defined.", + "cannot-set-messages-and-seconds-0": "$sender, you cannot set both messages and seconds to 0.", + "timer-was-set": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger", + "timer-was-set-with-offline-flag": "$sender, timer $name was set with $messages messages and $seconds seconds to trigger even when stream is offline", + "timer-not-found": "$sender, timer (name: $name) was not found in database. Check timers with !timers list", + "timer-deleted": "$sender, timer $name and its responses was deleted.", + "timer-enabled": "$sender, таймер (ім'я: $name) було увімкнено", + "timer-disabled": "$sender, таймер (назва: $name) було вимкнено", + "timers-list": "$sender, список таймерів: $list", + "responses-list": "$sender, таймер (назва: $name) список", + "response-deleted": "$sender, відповідь (id: $id) була видалена.", + "response-was-added": "$sender, відповідь (id: $id) для таймера (ім'я: $name) було додано - '$response", + "response-not-found": "$sender, відповідь (id: $id) не знайдено в базі даних", + "response-enabled": "$sender, відповідь (id: $id) було увімкнено", + "response-disabled": "$sender, відповідь (id: $id) було вимкнено" + }, + "gambling": { + "duel": { + "bank": "$sender, поточний банк для $command є $points $pointsName", + "lowerThanMinimalBet": "$sender, мінімальна ставка для $command - $points $pointsName", + "cooldown": "$sender, ви не можете використовувати $command ще $cooldown $minutesName.", + "joined": "$sender, good luck with your dueling skills. You bet on yourself $points $pointsName!", + "added": "$sender really thinks he is better than others raising his bet to $points $pointsName!", + "new": "$sender is your new duel challenger! To participate use $command [points], you have $minutes $minutesName left to join.", + "zeroBet": "$sender, you cannot duel 0 $pointsName", + "notEnoughOptions": "$sender, you need to specify points to dueling", + "notEnoughPoints": "$sender, you don't have $points $pointsName to duel!", + "noContestant": "Only $winner have courage to join duel! Your bet of $points $pointsName are returned to you.", + "winner": "Congratulations to $winner! He is last man standing and he won $points $pointsName ($probability% with bet of $tickets $ticketsName)!" + }, + "roulette": { + "trigger": "$sender грає з удачею, але йому не пощастило", + "alive": "$sender живий! Нічого не сталося.", + "dead": "Мозок $sender було розтерто по стіні!", + "mod": "$sender просто не зможе влучити собі в голову!", + "broadcaster": "$sender вистрелив собі в ногу!", + "timeout": "Тайм-аут рулетки встановлено на $values" + }, + "gamble": { + "chanceToWin": "$sender, шанс виграти !gamble встановлений на $value%", + "zeroBet": "$sender, ви не можете грати в гру з 0 $pointsName", + "minimalBet": "$sender, мінімальна ставка для !gamble встановлена на $value", + "lowerThanMinimalBet": "$sender, мінімальна ставка для !gamble — $points $pointsName", + "notEnoughOptions": "$sender, потрібно вказати очки для гри", + "notEnoughPoints": "$sender, ви не маєте $points $pointsName для гри", + "win": "$sender, ви перемогли! Зараз у вас $points $pointsName", + "winJackpot": "$sender, you hit JACKPOT! You won $jackpot $jackpotName in addition to your bet. You now have $points $pointsName", + "loseWithJackpot": "$sender, you LOST! You now have $points $pointsName. Jackpot increased to $jackpot $jackpotName", + "lose": "$sender, you LOST! You now have $points $pointsName", + "currentJackpot": "$sender, current jackpot for $command is $points $pointsName", + "winJackpotCount": "$sender, ви виграли $count джекпотів", + "jackpotIsDisabled": "$sender, джекпот вимкнено для $command." + } + }, + "highlights": { + "saved": "$sender, highlight was saved for $hoursh$minutesm$secondss", + "list": { + "items": "$sender, list of saved highlights for latest stream: $items", + "empty": "$sender, жоден яскравий момент не було збережено" + }, + "offline": "$sender, неможливо зберегти яскравий момент, потік офлайн" + }, + "whisper": { + "settings": { + "disablePermissionWhispers": { + "true": "Bot won't send errors on insufficient permissions", + "false": "Bot won't send errors on insufficient permissions through whispers" + }, + "disableCooldownWhispers": { + "true": "Бот не надсилатиме оповіщення", + "false": "Бот надсилатиме сповіщень про кулдаун через віспери" + } + } + }, + "time": "Поточний час в часовому поясі стрімера: $time", + "subs": "$sender, there is currently $onlineSubCount online subscribers. Last sub/resub was $lastSubUsername $lastSubAgo", + "followers": "$sender, last follow was $lastFollowUsername $lastFollowAgo", + "ignore": { + "user": { + "is": { + "not": { + "ignored": "$sender, користувач $username не ігнорується ботом" + }, + "added": "$sender, користувач $username доданий в список ігнорованих ботом", + "removed": "$sender, користувач $username видалений з ігнор списку", + "ignored": "$sender, користувач $username ігнорується ботом" + } + } + }, + "filters": { + "setVariable": "$sender, $variable був встановлений на $value." + } +} diff --git a/backend/locales/uk/api.clips.json b/backend/locales/uk/api.clips.json new file mode 100644 index 000000000..861b8a548 --- /dev/null +++ b/backend/locales/uk/api.clips.json @@ -0,0 +1,3 @@ +{ + "created": "Кліп створено і він доступний $link" +} \ No newline at end of file diff --git a/backend/locales/uk/core/permissions.json b/backend/locales/uk/core/permissions.json new file mode 100644 index 000000000..94254f288 --- /dev/null +++ b/backend/locales/uk/core/permissions.json @@ -0,0 +1,8 @@ +{ + "list": "Перелік ваших дозволів:", + "excludeAddSuccessful": "$sender, you added $username to exclude list for permission $permissionName", + "excludeRmSuccessful": "$sender, you removed $username from exclude list for permission $permissionName", + "userNotFound": "$sender, користувач $username не був знайдений в базі даних.", + "permissionNotFound": "$sender, дозвіл $userlevel не знайдено в базі даних.", + "cannotIgnoreForCorePermission": "$sender, you cannot manually exclude user for core permission $userlevel" +} \ No newline at end of file diff --git a/backend/locales/uk/games.heist.json b/backend/locales/uk/games.heist.json new file mode 100644 index 000000000..86371a40f --- /dev/null +++ b/backend/locales/uk/games.heist.json @@ -0,0 +1,29 @@ +{ + "copsOnPatrol": "$sender, cops are still searching for last heist team. Try again after $cooldown.", + "copsCooldownMessage": "Alright guys, looks like police forces are eating donuts and we can get that sweet money!", + "entryMessage": "$sender has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type $command to enter.", + "lateEntryMessage": "$sender, heist is currently in progress!", + "entryInstruction": "$sender, type $command to enter.", + "levelMessage": "With this crew, we can heist $bank! Let's see if we can get enough crew to heist $nextBank", + "maxLevelMessage": "With this crew, we can heist $bank! It cannot be any better!", + "started": "Alright guys, check your equipment, this is what we trained for. This is not a game, this is real life. We will get money from $bank!", + "noUser": "Nobody joins a crew to heist.", + "singleUserSuccess": "$user was like a ninja. Nobody noticed missing money.", + "singleUserFailed": "$user failed to get rid of police and will be spending his time in jail.", + "result": { + "0": "Everyone was mercilessly obliterated. This is slaughter.", + "33": "Only 1/3rd of team get its money from heist.", + "50": "Half of heist team was killed or catched by police.", + "99": "Some loses of heist team is nothing of what remaining crew have in theirs pockets.", + "100": "God divinity, nobody is dead, everyone won!" + }, + "levels": { + "bankVan": "Bank van", + "cityBank": "City bank", + "stateBank": "State bank", + "nationalReserve": "National reserve", + "federalReserve": "Federal reserve" + }, + "results": "The heist payouts are: $users", + "andXMore": "and $count more..." +} \ No newline at end of file diff --git a/backend/locales/uk/integrations/discord.json b/backend/locales/uk/integrations/discord.json new file mode 100644 index 000000000..1825757d1 --- /dev/null +++ b/backend/locales/uk/integrations/discord.json @@ -0,0 +1,13 @@ +{ + "your-account-is-not-linked": "ваш обліковий запис не підключено, використовуйте `$command`", + "all-your-links-were-deleted": "всі ваші посилання були видалені", + "all-your-links-were-deleted-with-sender": "$sender, {integrations.discord.all-your-links-were-deleted}", + "this-account-was-linked-with": "$sender, цей обліковий запис був пов'язаний з $discordTag.", + "invalid-or-expired-token": "$sender, неприпустимий або прострочений Токен.", + "help-message": "$sender, щоб прив'язати ваш обліковий запис у Discord: 1. Перейдіть до серверу Discord та надішліть $command у каналі ботів. | 2. Зачекайте на ПП від бота | 3. Надішліть команду з Discord ПП в чаті Twitch.", + "started-at": "Розпочато о", + "announced-by": "Оголошення від sogebot", + "streamed-at": "Стрімить в", + "link-whisper": "Вітаємо, $tag, щоб прив'язати цей обліковий запис Discord до вашого облікового запису Twitch на каналі $broadcaster, перейдіть на увійдіть у свій обліковий запис і надішліть цю команду у чат \n\n\t\t`$command $id`\n\nПРИМІТКА: Це закінчується через 10 хвилин.", + "check-your-dm": "перевірте особисті повідомлення для прив'язки до облікового запису." +} \ No newline at end of file diff --git a/backend/locales/uk/integrations/lastfm.json b/backend/locales/uk/integrations/lastfm.json new file mode 100644 index 000000000..79a075396 --- /dev/null +++ b/backend/locales/uk/integrations/lastfm.json @@ -0,0 +1,3 @@ +{ + "current-song-changed": "Current song is $name" +} \ No newline at end of file diff --git a/backend/locales/uk/integrations/obswebsocket.json b/backend/locales/uk/integrations/obswebsocket.json new file mode 100644 index 000000000..1058ed4b6 --- /dev/null +++ b/backend/locales/uk/integrations/obswebsocket.json @@ -0,0 +1,7 @@ +{ + "runTask": { + "EntityNotFound": "$sender, there is no action set for id:$id!", + "ParameterError": "$sender, you need to specify id!", + "UnknownError": "$sender, something went wrong. Check bot logs for additional informations." + } +} \ No newline at end of file diff --git a/backend/locales/uk/integrations/protondb.json b/backend/locales/uk/integrations/protondb.json new file mode 100644 index 000000000..0e7df5a0f --- /dev/null +++ b/backend/locales/uk/integrations/protondb.json @@ -0,0 +1,5 @@ +{ + "responseOk": "$game | $rating rated | Native on $native | Details: $url", + "responseNg": "Rating for game $game was not found on ProtonDB.", + "responseNotFound": "Game $game was not found on ProtonDB." +} \ No newline at end of file diff --git a/backend/locales/uk/integrations/pubg.json b/backend/locales/uk/integrations/pubg.json new file mode 100644 index 000000000..b8d479774 --- /dev/null +++ b/backend/locales/uk/integrations/pubg.json @@ -0,0 +1,3 @@ +{ + "expected_one_of_these_parameters": "$sender, очікувалось одне з цих параметрів: $list" +} \ No newline at end of file diff --git a/backend/locales/uk/integrations/spotify.json b/backend/locales/uk/integrations/spotify.json new file mode 100644 index 000000000..5af41087a --- /dev/null +++ b/backend/locales/uk/integrations/spotify.json @@ -0,0 +1,15 @@ +{ + "song-not-found": "На жаль, $sender, трек не знайдено в spotify", + "song-requested": "$sender, вам запитано пісню $name - $artist", + "not-banned-song-not-playing": "$sender, no song is currently playing to ban.", + "song-banned": "$sender, song $name from $artist is banned.", + "song-unbanned": "$sender, song $name from $artist is unbanned.", + "song-not-found-in-banlist": "$sender, пісня від spotifyURI $uri не знайдена в списку заблокованих.", + "cannot-request-song-is-banned": "$sender, cannot request banned song $name from $artist.", + "cannot-request-song-from-unapproved-artist": "$sender, cannot request song from unapproved artist.", + "no-songs-found-in-history": "$sender, зараз немає пісні в списку історії.", + "return-one-song-from-history": "$sender, попередня пісня була $name від $artist.", + "return-multiple-song-from-history": "$sender, $count previous songs were:", + "return-multiple-song-from-history-item": "$index - $name від $artist", + "song-notify": "Зараз грає пісня $name від $artist." +} \ No newline at end of file diff --git a/backend/locales/uk/integrations/tiltify.json b/backend/locales/uk/integrations/tiltify.json new file mode 100644 index 000000000..aa574fb09 --- /dev/null +++ b/backend/locales/uk/integrations/tiltify.json @@ -0,0 +1,4 @@ +{ + "no_active_campaigns": "$sender, there are currently no active campaigns.", + "active_campaigns": "$sender, list of currently active campaigns:" +} \ No newline at end of file diff --git a/backend/locales/uk/systems.quotes.json b/backend/locales/uk/systems.quotes.json new file mode 100644 index 000000000..92025a17c --- /dev/null +++ b/backend/locales/uk/systems.quotes.json @@ -0,0 +1,30 @@ +{ + "add": { + "ok": "$sender, quote $id '$quote' was added. (tags: $tags)", + "error": "$sender, $command is not correct or missing -quote parameter" + }, + "remove": { + "ok": "$sender, quote $id was successfully deleted.", + "error": "$sender, quote ID is missing.", + "not-found": "$sender, quote $id was not found." + }, + "show": { + "ok": "Quote $id by $quotedBy '$quote'", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found.", + "not-found-by-tag": "$sender, no quotes with tag $tag was not found." + } + }, + "set": { + "ok": "$sender, quote $id tags were set. (tags: $tags)", + "error": { + "no-parameters": "$sender, $command is missing -id or -tag.", + "not-found-by-id": "$sender, quote $id was not found." + } + }, + "list": { + "ok": "$sender, You can find quote list at http://$urlBase/public/#/quotes", + "is-localhost": "$sender, quote list url is not properly specified." + } +} \ No newline at end of file diff --git a/backend/locales/uk/systems/antihateraid.json b/backend/locales/uk/systems/antihateraid.json new file mode 100644 index 000000000..7ad602a98 --- /dev/null +++ b/backend/locales/uk/systems/antihateraid.json @@ -0,0 +1,8 @@ +{ + "announce": "This chat was set to $mode by $username to get rid of hate raid. Sorry for inconvenience!", + "mode": { + "0": "subs-only", + "1": "follow-only", + "2": "emotes-only" + } +} \ No newline at end of file diff --git a/backend/locales/uk/systems/howlongtobeat.json b/backend/locales/uk/systems/howlongtobeat.json new file mode 100644 index 000000000..0fcc12bd9 --- /dev/null +++ b/backend/locales/uk/systems/howlongtobeat.json @@ -0,0 +1,5 @@ +{ + "error": "$sender, $game not found in db.", + "game": "$sender, $game | Main: $currentMain/$hltbMainh - $percentMain% | Main+Extra: $currentMainExtra/$hltbMainExtrah - $percentMainExtra% | Completionist: $currentCompletionist/$hltbCompletionisth - $percentCompletionist%", + "multiplayer-game": "$sender, $game | Main: $currentMainh | Main+Extra: $currentMainExtrah | Completionist: $currentCompletionisth" +} \ No newline at end of file diff --git a/backend/locales/uk/systems/levels.json b/backend/locales/uk/systems/levels.json new file mode 100644 index 000000000..c2c9bb7f9 --- /dev/null +++ b/backend/locales/uk/systems/levels.json @@ -0,0 +1,7 @@ +{ + "currentLevel": "$username, level: $currentLevel ($currentXP $xpName), $nextXP $xpName to next level.", + "changeXP": "$sender, you changed $xpName by $amount $xpName to $username.", + "notEnoughPointsToBuy": "Sorry $sender, but you don't have $points $pointsName to buy $amount $xpName for level $level.", + "XPBoughtByPoints": "$sender, you bought $amount $xpName with $points $pointsName and reached level $level.", + "somethingGetWrong": "$sender, something get wrong with your request." +} \ No newline at end of file diff --git a/backend/locales/uk/systems/scrim.json b/backend/locales/uk/systems/scrim.json new file mode 100644 index 000000000..5f013eccb --- /dev/null +++ b/backend/locales/uk/systems/scrim.json @@ -0,0 +1,7 @@ +{ + "countdown": "Снайперська гра ($type) розпочнется через $time $unit", + "go": "Почати зараз! Рушай!", + "putMatchIdInChat": "Будь ласка, введіть ID матчу в чаті => $command xxx", + "currentMatches": "Поточні матчі: $matches", + "stopped": "Снаймерська гра була скасована." +} \ No newline at end of file diff --git a/backend/locales/uk/systems/top.json b/backend/locales/uk/systems/top.json new file mode 100644 index 000000000..c40d2fdbd --- /dev/null +++ b/backend/locales/uk/systems/top.json @@ -0,0 +1,12 @@ +{ + "time": "Топ $amount (час перегляду): ", + "tips": "Топ $amount (чайових): ", + "level": "Top $amount (level): ", + "points": "Топ $amount (балів): ", + "messages": "Топ $amount (повідомлень): ", + "followage": "Топ $amount (послідовник): ", + "subage": "Top $amount (subage): ", + "submonths": "Top $amount (submonths): ", + "bits": "Топ $amount (біт): ", + "gifts": "Топ $amount (САБ подарунків): " +} \ No newline at end of file diff --git a/backend/locales/uk/ui.commons.json b/backend/locales/uk/ui.commons.json new file mode 100644 index 000000000..22d3a3b5b --- /dev/null +++ b/backend/locales/uk/ui.commons.json @@ -0,0 +1,18 @@ +{ + "additional-settings": "Additional settings", + "never": "never", + "reset": "reset", + "moveUp": "move up", + "moveDown": "move down", + "stop-if-executed": "stop, if executed", + "continue-if-executed": "continue, if executed", + "generate": "Generate", + "thumbnail": "Thumbnail", + "yes": "Yes", + "no": "No", + "show-more": "Show more", + "show-less": "Show less", + "allowed": "Allowed", + "disallowed": "Disallowed", + "back": "Back" +} diff --git a/backend/locales/uk/ui.dialog.json b/backend/locales/uk/ui.dialog.json new file mode 100644 index 000000000..117d0b9a3 --- /dev/null +++ b/backend/locales/uk/ui.dialog.json @@ -0,0 +1,70 @@ +{ + "title": { + "edit": "Редагувати", + "add": "Додати" + }, + "position": { + "settings": "Position settings", + "anchorX": "Anchor X position", + "anchorY": "Anchor Y position", + "left": "Left", + "right": "Right", + "middle": "Middle", + "top": "Top", + "bottom": "Bottom", + "x": "X", + "y": "Y" + }, + "font": { + "shadowShiftRight": "Shift Right", + "shadowShiftDown": "Shift Down", + "shadowBlur": "Blur", + "shadowOpacity": "Opacity", + "color": "Color" + }, + "errors": { + "required": "This input cannot be empty.", + "minValue": "Lowest value of this input is $value." + }, + "buttons": { + "reorder": "Reorder", + "upload": { + "idle": "Upload", + "progress": "Uploading", + "done": "Uploaded" + }, + "cancel": "Cancel", + "close": "Close", + "test": { + "idle": "Test", + "progress": "Testing in progress", + "done": "Testing done" + }, + "saveChanges": { + "idle": "Save changes", + "invalid": "Cannot save changes", + "progress": "Saving changes", + "done": "Changes saved" + }, + "something-went-wrong": "Something went wrong", + "mark-to-delete": "Mark to delete", + "disable": "Disable", + "enable": "Enable", + "disabled": "Disabled", + "enabled": "Enabled", + "edit": "Edit", + "delete": "Delete", + "play": "Play", + "stop": "Stop", + "hold-to-delete": "Hold to delete", + "yes": "Yes", + "no": "No", + "permission": "Permission", + "group": "Group", + "visibility": "Visibility", + "reset": "Reset " + }, + "changesPending": "Your changes was not saved.", + "formNotValid": "Form is invalid.", + "nothingToShow": "Nothing to show here." +} \ No newline at end of file diff --git a/backend/locales/uk/ui.menu.json b/backend/locales/uk/ui.menu.json new file mode 100644 index 000000000..6ee4870cf --- /dev/null +++ b/backend/locales/uk/ui.menu.json @@ -0,0 +1,101 @@ +{ + "services": "Сервіси", + "updater": "Оновлення", + "index": "Панель керування", + "core": "Бот", + "users": "Користувачі", + "tmi": "TMI", + "ui": "UI", + "eventsub": "EventSub", + "twitch": "Twitch", + "general": "Головна", + "timers": "Таймери", + "new": "Новий елемент (Automatic Translation)", + "keywords": "Ключові слова", + "customcommands": "Користувацькі команди", + "botcommands": "Команди бота", + "commands": "Команди", + "events": "Події", + "ranks": "Ранги", + "songs": "Пісні", + "modules": "Модулі", + "viewers": "Глядачі", + "alias": "Псевдоніми", + "cooldowns": "Відлік", + "cooldown": "Відлік", + "highlights": "Яскраві моменти", + "price": "Ціна", + "logs": "Журнал", + "systems": "Система", + "permissions": "Повноваження", + "translations": "Довільні переклади", + "moderation": "Модерація", + "overlays": "Оверлей", + "gallery": "Медіа-галерея", + "games": "Ігри", + "spotify": "Spotify", + "integrations": "Інтеграції", + "customvariables": "Користувацькі змінні", + "registry": "Реєстр", + "quotes": "Цитати", + "settings": "Налаштування", + "commercial": "Комерційния", + "bets": "Ставки", + "points": "Бали", + "raffles": "Лотерея", + "queue": "Черга", + "playlist": "Список відтворення", + "bannedsongs": "Заблоковані пісні", + "spotifybannedsongs": "Заблоковані пісні Spotify", + "duel": "Двобій", + "fightme": "Дуель", + "seppuku": "Seppuku", + "gamble": "Gamble", + "roulette": "Рулетка", + "heist": "Пограбування", + "oauth": "OAuth", + "socket": "Socket", + "carouseloverlay": "Carousel overlay", + "alerts": "Сповіщення", + "carousel": "Карусель зображень", + "clips": "Кліпи", + "credits": "Кредити", + "emotes": "Емоції", + "stats": "Статистика", + "text": "Текст", + "currency": "Валюта", + "eventlist": "Список подій", + "clipscarousel": "Карусель кліпів", + "streamlabs": "Streamlabs", + "streamelements": "StreamElements", + "donationalerts": "DonationAlerts.ru", + "qiwi": "Qiwi Donate", + "tipeeestream": "TipeeeStream", + "twitter": "Twitter", + "checklist": "Контрольний список", + "bot": "Бот", + "api": "API", + "manage": "Керування", + "top": "Топ", + "goals": "Цілі", + "userinfo": "Дані користувача", + "scrim": "Scrim", + "commandcount": "Кількість команд", + "profiler": "Профіль", + "howlongtobeat": "Час проходження", + "responsivevoice": "ResponsiveVoice", + "randomizer": "Генератор", + "tips": "Чайові", + "bits": "Bits", + "discord": "Discord", + "texttospeech": "Синтезатор мовлення", + "lastfm": "Last.fm", + "pubg": "PLAYERUNKNOWN'S BATTLEGROUNDS", + "levels": "Рівні", + "obswebsocket": "OBS Websocket", + "api-explorer": "Провідник API", + "emotescombo": "Емоції комбо", + "notifications": "Сповіщення", + "plugins": "Plugins", + "tts": "TTS" +} diff --git a/backend/locales/uk/ui.page.settings.overlays.carousel.json b/backend/locales/uk/ui.page.settings.overlays.carousel.json new file mode 100644 index 000000000..7ca51081f --- /dev/null +++ b/backend/locales/uk/ui.page.settings.overlays.carousel.json @@ -0,0 +1,24 @@ +{ + "options": "options", + "popover": { + "are_you_sure_you_want_to_delete_this_image": "Are you sure to delete this image?" + }, + "button": { + "update": "Update", + "fix_your_errors_first": "Fix errors before save" + }, + "errors": { + "number_greater_or_equal_than_0": "Value must be a number >= 0", + "value_must_not_be_empty": "Value must not be empty" + }, + "titles": { + "waitBefore": "Wait before image show (in ms)", + "waitAfter": "Wait after image disappear (in ms)", + "duration": "How long image should be shown (in ms)", + "animationIn": "Animation In", + "animationOut": "Animation Out", + "animationInDuration": "Animation In duration (in ms)", + "animationOutDuration": "Animation Out duration (in ms)", + "showOnlyOncePerStream": "Show only once per stream" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.registry.customvariables.json b/backend/locales/uk/ui.registry.customvariables.json new file mode 100644 index 000000000..eab91fde8 --- /dev/null +++ b/backend/locales/uk/ui.registry.customvariables.json @@ -0,0 +1,79 @@ +{ + "urls": "URLs", + "generateurl": "Generate new URL", + "show-examples": "show CURL examples", + "response": { + "show": "Show response after POST", + "name": "Response after variable set", + "default": "Default", + "default-placeholder": "Set your bot response", + "default-help": "Use $value to get new variable value", + "custom": "Custom", + "command": "Command" + }, + "useIfInCommand": "Use if you use variable in command. Will return only updated variable without response.", + "permissionToChange": "Permission to change", + "isReadOnly": "read-only in chat", + "isNotReadOnly": "can be changed through chat", + "no-variables-found": "No variables found", + "additional-info": "Additional info", + "run-script": "Run script", + "last-run": "Last run at", + "variable": { + "name": "Variable name", + "help": "Variable name must be unique, e.g. $_wins, $_loses, $_top3", + "placeholder": "Enter your unique variable name", + "error": { + "isNotUnique": "Variable must have unique name.", + "isEmpty": "Variable name must not be empty." + } + }, + "description": { + "name": "Description", + "help": "Optional description", + "placeholder": "Enter your optional description" + }, + "type": { + "name": "Type", + "error": { + "isNotSelected": "Please choose a variable type." + } + }, + "currentValue": { + "name": "Current value", + "help": "If type is set to Evaluated script, value cannot be manually changed" + }, + "usableOptions": { + "name": "Usable options", + "placeholder": "Enter, your, options, here", + "help": "Options, which can be used with this variable, example: SOLO, DUO, 3-SQ, SQUAD", + "error": { + "atLeastOneValue": "You need to set at least 1 value." + } + }, + "scriptToEvaluate": "Script to evaluate", + "runScript": { + "name": "Run script", + "error": { + "isNotSelected": "Please choose an option." + } + }, + "testCurrentScript": { + "name": "Test current script", + "help": "Click Test current script to see value in Current value input" + }, + "history": "History", + "historyIsEmpty": "History for this variable is empty!", + "warning": "Warning: All data of this variable will be discarded!", + "choose": "Choose...", + "types": { + "number": "Number", + "text": "Text", + "options": "Options", + "eval": "Script" + }, + "runEvery": { + "isUsed": "When variable is used" + } +} + diff --git a/backend/locales/uk/ui.systems.antihateraid.json b/backend/locales/uk/ui.systems.antihateraid.json new file mode 100644 index 000000000..d821c979d --- /dev/null +++ b/backend/locales/uk/ui.systems.antihateraid.json @@ -0,0 +1,8 @@ +{ + "settings": { + "clearChat": "Clear Chat", + "mode": "Mode", + "minFollowTime": "Minimum follow time", + "customAnnounce": "Customize announcement on anti hate raid enable" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.bets.json b/backend/locales/uk/ui.systems.bets.json new file mode 100644 index 000000000..51b9de149 --- /dev/null +++ b/backend/locales/uk/ui.systems.bets.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Status", + "betPercentGain": "Add x% to bet payout each option" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.commercial.json b/backend/locales/uk/ui.systems.commercial.json new file mode 100644 index 000000000..aa565a36e --- /dev/null +++ b/backend/locales/uk/ui.systems.commercial.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Стан" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.cooldown.json b/backend/locales/uk/ui.systems.cooldown.json new file mode 100644 index 000000000..064403519 --- /dev/null +++ b/backend/locales/uk/ui.systems.cooldown.json @@ -0,0 +1,10 @@ +{ + "notify-as-whisper": "Notify as whisper", + "settings": { + "enabled": "Status", + "cooldownNotifyAsWhisper": "Whisper cooldown informations", + "cooldownNotifyAsChat": "Chat message cooldown informations", + "defaultCooldownOfCommandsInSeconds": "Default cooldown for commands (in seconds)", + "defaultCooldownOfKeywordsInSeconds": "Default cooldown for keywords (in seconds)" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.customcommands.json b/backend/locales/uk/ui.systems.customcommands.json new file mode 100644 index 000000000..5c93eb931 --- /dev/null +++ b/backend/locales/uk/ui.systems.customcommands.json @@ -0,0 +1,12 @@ +{ + "no-responses-set": "No responses", + "addResponse": "Add response", + "response": { + "name": "Response", + "placeholder": "Set your response here." + }, + "filter": { + "name": "filter", + "placeholder": "Add filter for this response" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.highlights.json b/backend/locales/uk/ui.systems.highlights.json new file mode 100644 index 000000000..2e43c9e4a --- /dev/null +++ b/backend/locales/uk/ui.systems.highlights.json @@ -0,0 +1,6 @@ +{ + "settings": { + "enabled": "Стан", + "urls": "Згенеровані URL-адреси" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.moderation.json b/backend/locales/uk/ui.systems.moderation.json new file mode 100644 index 000000000..6e6e07b87 --- /dev/null +++ b/backend/locales/uk/ui.systems.moderation.json @@ -0,0 +1,42 @@ +{ + "settings": { + "enabled": "Стан", + "cListsEnabled": "Застосовувати правило", + "cLinksEnabled": "Застосовувати правило", + "cSymbolsEnabled": "Застосовувати правило", + "cLongMessageEnabled": "Застосовувати правило", + "cCapsEnabled": "Застосовувати правило", + "cSpamEnabled": "Застосовувати правило", + "cColorEnabled": "Застосовувати правило", + "cEmotesEnabled": "Застосовувати правило", + "cListsWhitelist": { + "title": "Дозволені слова", + "help": "Щоб дозволити доменам використовувати \"domain:prtzl.io\"" + }, + "autobanMessages": "Автоблокування повідомлень ", + "cListsBlacklist": "Заборонені слова", + "cListsTimeout": "Час очікування", + "cLinksTimeout": "Час очікування", + "cSymbolsTimeout": "Час очікування", + "cLongMessageTimeout": "Час очікування", + "cCapsTimeout": "Час очікування", + "cSpamTimeout": "Час очікування", + "cColorTimeout": "Час очікування", + "cEmotesTimeout": "Час очікування", + "cWarningsShouldClearChat": "Should clear chat (will timeout for 1s)", + "cLinksIncludeSpaces": "Include spaces", + "cLinksIncludeClips": "Include clips", + "cSymbolsTriggerLength": "Trigger length of message", + "cLongMessageTriggerLength": "Trigger length of message", + "cCapsTriggerLength": "Trigger length of message", + "cSpamTriggerLength": "Trigger length of message", + "cSymbolsMaxSymbolsConsecutively": "Max symbols consecutively", + "cSymbolsMaxSymbolsPercent": "Max symbols %", + "cCapsMaxCapsPercent": "Max caps %", + "cSpamMaxLength": "Максимальна довжина", + "cEmotesMaxCount": "Максимальна кількість", + "cWarningsAnnounceTimeouts": "Announce timeouts in chat for everyone", + "cWarningsAllowedCount": "Кількість попереджень", + "cEmotesEmojisAreEmotes": "Treat Emojis as Emotes" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.points.json b/backend/locales/uk/ui.systems.points.json new file mode 100644 index 000000000..b0b011374 --- /dev/null +++ b/backend/locales/uk/ui.systems.points.json @@ -0,0 +1,22 @@ +{ + "settings": { + "enabled": "Status", + "name": { + "title": "Name", + "help": "Possible formats:
point|points
bod|4:body|bodu" + }, + "isPointResetIntervalEnabled": "Interval of points reset", + "resetIntervalCron": { + "name": "Cron interval", + "help": "CronTab generator" + }, + "interval": "Minutes interval to add points to online users when stream online", + "offlineInterval": "Minutes interval to add points to online users when stream offline", + "messageInterval": "How many messages to add points", + "messageOfflineInterval": "How many messages to add points when stream offline", + "perInterval": "How many points to add per online interval", + "perOfflineInterval": "How many points to add per offline interval", + "perMessageInterval": "How many points to add per message interval", + "perMessageOfflineInterval": "How many points to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.price.json b/backend/locales/uk/ui.systems.price.json new file mode 100644 index 000000000..6dcce9ea2 --- /dev/null +++ b/backend/locales/uk/ui.systems.price.json @@ -0,0 +1,14 @@ +{ + "emitRedeemEvent": "Trigger custom alerts on bit redeem", + "price": { + "name": "price", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.queue.json b/backend/locales/uk/ui.systems.queue.json new file mode 100644 index 000000000..621912bd1 --- /dev/null +++ b/backend/locales/uk/ui.systems.queue.json @@ -0,0 +1,8 @@ +{ + "settings": { + "enabled": "Стан", + "eligibilityAll": "Усі", + "eligibilityFollowers": "Підписники", + "eligibilitySubscribers": "Сабкрайбери" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.quotes.json b/backend/locales/uk/ui.systems.quotes.json new file mode 100644 index 000000000..97f22bce8 --- /dev/null +++ b/backend/locales/uk/ui.systems.quotes.json @@ -0,0 +1,34 @@ +{ + "no-quotes-found": "We're sorry, no quotes were found in database.", + "new": "Add new quote", + "empty": "List of quotes is empty, create new quote.", + "emptyAfterSearch": "List of quotes is empty in searching for \"$search\"", + "quote": { + "name": "Quote", + "placeholder": "Set your quote here" + }, + "by": { + "name": "Quoted by" + }, + "tags": { + "name": "Tags", + "placeholder": "Set your tags here", + "help": "Comma-separated tags. Example: tag 1, tag 2, tag 3" + }, + "date": { + "name": "Date" + }, + "error": { + "isEmpty": "This value cannot be empty", + "atLeastOneTag": "You need to set at least one tag" + }, + "tag-filter": "Filtering by tag", + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status", + "urlBase": { + "title": "URL base", + "help": "You should use public endpoint for quotes, to be accessible by everyone" + } + } +} diff --git a/backend/locales/uk/ui.systems.raffles.json b/backend/locales/uk/ui.systems.raffles.json new file mode 100644 index 000000000..9b8075f19 --- /dev/null +++ b/backend/locales/uk/ui.systems.raffles.json @@ -0,0 +1,36 @@ +{ + "widget": { + "subscribers-luck": "Subscribers luck" + }, + "settings": { + "enabled": "Status", + "announceNewEntries": { + "title": "Announce new entries", + "help": "If users joins raffle, announce message will be send to chat after while." + }, + "announceNewEntriesBatchTime": { + "title": "How long to wait before announce new entries (in seconds)", + "help": "Longer time will keep chat cleaner, entries will be aggregated together." + }, + "deleteRaffleJoinCommands": { + "title": "Delete user raffle join command", + "help": "This will delete user message if they use !yourraffle command. Should keep chat cleaner." + }, + "allowOverTicketing": { + "title": "Allow over ticketing", + "help": "Allow user join raffle with over ticket of his points. E.g. user have 10 points but can join with !raffle 100 which will use all of his points." + }, + "raffleAnnounceInterval": { + "title": "Announce interval", + "help": "Minutes" + }, + "raffleAnnounceMessageInterval": { + "title": "Announce message interval", + "help": "How many messages must be sent to chat until announce can be posted." + }, + "subscribersPercent": { + "title": "Additional subscribers luck", + "help": "in percents" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.ranks.json b/backend/locales/uk/ui.systems.ranks.json new file mode 100644 index 000000000..42a6861a6 --- /dev/null +++ b/backend/locales/uk/ui.systems.ranks.json @@ -0,0 +1,20 @@ +{ + "new": "New Rank", + "empty": "No ranks were created yet.", + "emptyAfterSearch": "No ranks were found by your search for \"$search\".", + "rank": { + "name": "rank", + "placeholder": "" + }, + "value": { + "name": "hours", + "placeholder": "" + }, + "error": { + "isEmpty": "This value cannot be empty" + }, + "warning": "This action cannot be reverted!", + "settings": { + "enabled": "Status" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.songs.json b/backend/locales/uk/ui.systems.songs.json new file mode 100644 index 000000000..3360a39ae --- /dev/null +++ b/backend/locales/uk/ui.systems.songs.json @@ -0,0 +1,33 @@ +{ + "settings": { + "enabled": "Стан", + "volume": "Гучність", + "calculateVolumeByLoudness": "Динамічна гучність за гучністю", + "duration": { + "title": "Макс. тривалість", + "help": "Хвилини" + }, + "shuffle": "Перемішати", + "songrequest": "Грати з запита пісні", + "playlist": "Грати зі списку відтворення", + "onlyMusicCategory": "Дозволити тільки музичної категорії", + "allowRequestsOnlyFromPlaylist": "Дозволити запити на пісні тільки з поточного списку відтворення", + "notify": "Надсилати повідомлення про зміну пісні" + }, + "error": { + "isEmpty": "Це параметр не може бути пустим" + }, + "startTime": "Починаючи з", + "endTime": "Завершити пісню о", + "add_song": "Додати пісню", + "add_or_import": "Додати пісню або імпортувати з списку відтворення", + "importing": "Імпортування", + "importing_done": "Імпортування завершено", + "seconds": "Секунди", + "calculated": "Розрахувати", + "set_manually": "Встановити вручну", + "bannedSongsEmptyAfterSearch": "No banned songs were found by your search for \"$search\".", + "emptyAfterSearch": "No songs were found by your search for \"$search\".", + "empty": "No songs were added yet.", + "bannedSongsEmpty": "No songs were added to banlist yet." +} \ No newline at end of file diff --git a/backend/locales/uk/ui.systems.timers.json b/backend/locales/uk/ui.systems.timers.json new file mode 100644 index 000000000..70bcf937f --- /dev/null +++ b/backend/locales/uk/ui.systems.timers.json @@ -0,0 +1,10 @@ +{ + "new": "New Timer", + "empty": "No timers were created yet.", + "emptyAfterSearch": "No timers were found by your search for \"$search\".", + "add_response": "Add Response", + "settings": { + "enabled": "Status" + }, + "warning": "This action cannot be reverted!" +} \ No newline at end of file diff --git a/backend/locales/uk/ui.widgets.customvariables.json b/backend/locales/uk/ui.widgets.customvariables.json new file mode 100644 index 000000000..761875e3b --- /dev/null +++ b/backend/locales/uk/ui.widgets.customvariables.json @@ -0,0 +1,5 @@ +{ + "no-custom-variable-found": "No custom variables found, add at custom variables registry", + "add-variable-into-watchlist": "Add variable to watchlist", + "watchlist": "Watchlist" +} \ No newline at end of file diff --git a/backend/locales/uk/ui.widgets.randomizer.json b/backend/locales/uk/ui.widgets.randomizer.json new file mode 100644 index 000000000..17a70ebb9 --- /dev/null +++ b/backend/locales/uk/ui.widgets.randomizer.json @@ -0,0 +1,4 @@ +{ + "no-randomizer-found": "No randomizer found, add at randomizer registry", + "add-randomizer-to-widget": "Add randomizer to widget" +} \ No newline at end of file diff --git a/backend/locales/uk/ui/categories.json b/backend/locales/uk/ui/categories.json new file mode 100644 index 000000000..f09eed9d3 --- /dev/null +++ b/backend/locales/uk/ui/categories.json @@ -0,0 +1,61 @@ +{ + "announcements": "Оголошення", + "keys": "Ключі", + "currency": "Валюта", + "general": "Головна", + "settings": "Налаштування", + "commands": "Команди", + "bot": "Бот", + "channel": "Канал", + "connection": "Підключення", + "chat": "Чат", + "graceful_exit": "Плавний вихід", + "rewards": "Нагороди", + "levels": "Рівні", + "notifications": "Сповіщення", + "options": "Параметри", + "comboBreakMessages": "Combo Break Messages", + "hypeMessages": "Повідомлення Hype", + "messages": "Повідомлення", + "results": "Результати", + "customization": "Персоналізація", + "status": "Статус", + "mapping": "Mapping", + "player": "Програвач", + "stats": "Статистика", + "api": "API", + "token": "Token", + "text": "Текст", + "custom_texts": "Свій текст", + "credits": "Кредити", + "show": "Показати", + "social": "Соціальні мережі", + "explosion": "Вибух", + "fireworks": "Феєрверки", + "test": "Тест", + "emotes": "Емоції", + "default": "За умовчанням", + "urls": "URLs", + "conversion": "Перетворення", + "xp": "XP", + "caps_filter": "Фільтр Caps", + "color_filter": "Italic (/me) filter", + "links_filter": "Фільтр посилань", + "symbols_filter": "Фільтр символів", + "longMessage_filter": "Message length filter", + "spam_filter": "Фільтри спаму", + "emotes_filter": "Фільтр емоцій", + "warnings": "Попередження", + "reset": "Скинути", + "reminder": "Нагадування", + "eligibility": "Eligibility", + "join": "Приєднатися", + "luck": "Удача", + "lists": "Списки", + "me": "Я", + "emotes_combo": "Емоції комбо", + "tmi": "tmi", + "oauth": "oauth", + "eventsub": "eventSub", + "rules": "rules" +} \ No newline at end of file diff --git a/backend/locales/uk/ui/core/currency.json b/backend/locales/uk/ui/core/currency.json new file mode 100644 index 000000000..12c7fca18 --- /dev/null +++ b/backend/locales/uk/ui/core/currency.json @@ -0,0 +1,5 @@ +{ + "settings": { + "mainCurrency": "Основна валюта" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/core/general.json b/backend/locales/uk/ui/core/general.json new file mode 100644 index 000000000..9544a285e --- /dev/null +++ b/backend/locales/uk/ui/core/general.json @@ -0,0 +1,11 @@ +{ + "settings": { + "lang": "Мова боту", + "numberFormat": "Формат цифр у чаті", + "gracefulExitEachXHours": { + "title": "Граціозний вихід кожні X годин", + "help": "0 - вимкнуто" + }, + "shouldGracefulExitHelp": "Увімкнення граціонального виходу рекомендується якщо ваш бот працює нескінченно на сервері. Ви повинні мати бота працювати на pm2 (або схожому сервісі) або включати, щоб автоматично перезапустити бота. Бот не буде чудово закривати коли потік онлайн." + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/core/oauth.json b/backend/locales/uk/ui/core/oauth.json new file mode 100644 index 000000000..1e86bca6b --- /dev/null +++ b/backend/locales/uk/ui/core/oauth.json @@ -0,0 +1,13 @@ +{ + "settings": { + "generalOwners": "Власники", + "botAccessToken": "AccessToken", + "channelAccessToken": "AccessToken", + "botRefreshToken": "RefreshToken", + "channelRefreshToken": "RefreshToken", + "botUsername": "Ім'я користувача", + "channelUsername": "Ім'я користувача", + "botExpectedScopes": "Дозволи", + "channelExpectedScopes": "Дозволи" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/core/permissions.json b/backend/locales/uk/ui/core/permissions.json new file mode 100644 index 000000000..53d09e287 --- /dev/null +++ b/backend/locales/uk/ui/core/permissions.json @@ -0,0 +1,54 @@ +{ + "addNewPermissionGroup": "Додати нову групу прав доступу", + "higherPermissionHaveAccessToLowerPermissions": "Більш високий дозвіл має доступ з більш низьким дозволами.", + "typeUsernameOrIdToSearch": "Введіть ім'я користувача або ID для пошуку", + "typeUsernameOrIdToTest": "Введіть ім'я користувача або ID для тестування", + "noUsersWereFound": "Користувачі не знайдені.", + "noUsersManuallyAddedToPermissionYet": "Користувачі ще не були додані в дозвіл.", + "done": "Виконано", + "previous": "Попередня", + "next": "Наступна", + "loading": "Завантаження", + "permissionNotFoundInDatabase": "Дозвіл не знайдено в базі даних, будь ласка, збережіть перед тестуванням користувачів.", + "userHaveNoAccessToThisPermissionGroup": "Користувач $username не може отримати доступ до цієї групи дозволів.", + "userHaveAccessToThisPermissionGroup": "Користувач $username отримав доступ до цієї групи.", + "accessDirectlyThrough": "Прямий доступ через", + "accessThroughHigherPermission": "Доступ за допомогою більш високого дозволу", + "somethingWentWrongUserWasNotFoundInBotDatabase": "Щось пішло не так, користувач $username не був знайдений в базі даних ботів.", + "permissionsGroups": "Групи Дозволу", + "allowHigherPermissions": "Дозволити доступ через більш високі дозволи", + "type": "Тип", + "value": "Значення", + "watched": "Час перегляду в годинах", + "followtime": "Час підписки в місяцях", + "points": "Бали", + "tips": "Чайові", + "bits": "Бітc", + "messages": "Повідомлення", + "subtier": "Саб рівень (1, 2 або 3)", + "subcumulativemonths": "Всього місяців саба", + "substreakmonths": "Поточна sub серія", + "ranks": "Поточне звання", + "level": "Поточний рівень", + "isLowerThan": "нижчий ніж", + "isLowerThanOrEquals": "менше або рівне", + "equals": "дорівнює", + "isHigherThanOrEquals": "більше або дорівнює", + "isHigherThan": "більше, ніж", + "addFilter": "Додати фільтр", + "selectPermissionGroup": "Додати нову групу прав доступу", + "settings": "Налаштування", + "name": "Ім’я", + "baseUsersSet": "Основний набір користувачів", + "manuallyAddedUsers": "Вручну додати користувачів", + "manuallyExcludedUsers": "Вручну виключені користувачів", + "filters": "Фільтри", + "testUser": "Тестовий користувач", + "none": "- нічого -", + "casters": "Кастер", + "moderators": "Модератори", + "subscribers": "Підписники", + "vip": "VIP", + "viewers": "Глядачі", + "followers": "Послідовники" +} \ No newline at end of file diff --git a/backend/locales/uk/ui/core/socket.json b/backend/locales/uk/ui/core/socket.json new file mode 100644 index 000000000..2f0189d4d --- /dev/null +++ b/backend/locales/uk/ui/core/socket.json @@ -0,0 +1,11 @@ +{ + "settings": { + "purgeAllConnections": "Очистити всі автентифіковані підключення (також ваші)", + "accessTokenExpirationTime": "Час життя AccessToken (секунди)", + "refreshTokenExpirationTime": "Час життя RefreshToken (секунди)", + "socketToken": { + "title": "Токін сокета", + "help": "Цей токен дасть вам повний доступ адміністратора через сокети. Не передайте його!" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/core/tmi.json b/backend/locales/uk/ui/core/tmi.json new file mode 100644 index 000000000..f66cb4153 --- /dev/null +++ b/backend/locales/uk/ui/core/tmi.json @@ -0,0 +1,10 @@ +{ + "settings": { + "ignorelist": "Чорний список (ID або ім'я користувача)", + "showWithAt": "Показати користувачів з @", + "sendWithMe": "Надсилати повідомлення з /me", + "sendAsReply": "Відправляти повідомлення бота в якості відповіді", + "mute": "Бот приглушений", + "whisperListener": "Слухати команди на віспері" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/core/tts.json b/backend/locales/uk/ui/core/tts.json new file mode 100644 index 000000000..2ddd01d1d --- /dev/null +++ b/backend/locales/uk/ui/core/tts.json @@ -0,0 +1,5 @@ +{ + "settings": { + "service": "Служба" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/core/twitch.json b/backend/locales/uk/ui/core/twitch.json new file mode 100644 index 000000000..d99c721fd --- /dev/null +++ b/backend/locales/uk/ui/core/twitch.json @@ -0,0 +1,5 @@ +{ + "settings": { + "createMarkerOnEvent": "Створити маркер трансляції на подію" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/core/ui.json b/backend/locales/uk/ui/core/ui.json new file mode 100644 index 000000000..9132e8bc5 --- /dev/null +++ b/backend/locales/uk/ui/core/ui.json @@ -0,0 +1,13 @@ +{ + "settings": { + "theme": "Тема за замовчуванням", + "domain": { + "title": "Домен", + "help": "Форматувати без http/https: yourdomain.com або your.domain.com" + }, + "percentage": "Відсоток різниці для статистики", + "shortennumbers": "Короткий формат чисел", + "showdiff": "Показати різницю", + "enablePublicPage": "Увімкнути публічну сторінку" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/core/updater.json b/backend/locales/uk/ui/core/updater.json new file mode 100644 index 000000000..644ae36de --- /dev/null +++ b/backend/locales/uk/ui/core/updater.json @@ -0,0 +1,5 @@ +{ + "settings": { + "isAutomaticUpdateEnabled": "Автоматично оновлювати, якщо доступна новіша версія" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/errors.json b/backend/locales/uk/ui/errors.json new file mode 100644 index 000000000..8b3e4bef8 --- /dev/null +++ b/backend/locales/uk/ui/errors.json @@ -0,0 +1,30 @@ +{ + "errorDialogHeader": "Unexpected errors during validation", + "isNotEmpty": "$property is required.", + "minLength": "$property must be longer than or equal to $constraint1 characters.", + "isPositive": "$property must be greater then 0", + "isCommand": "$property must start with !", + "isCommandOrCustomVariable": "$property must start with ! or $_", + "isCustomVariable": "$property must start with $_", + "min": "$property must be at least $constraint1", + "max": "$property must be lower or equal to $constraint1", + "isInt": "$property must be an integer", + "this_value_must_be_a_positive_number_and_greater_then_0": "This value must be a positive number or greater then 0", + "command_must_start_with_!": "Command must start with !", + "this_value_must_be_a_positive_number_or_0": "This value must be a positive number or 0", + "value_cannot_be_empty": "Value cannot be empty", + "minLength_of_value_is": "Minimal length is $value.", + "this_currency_is_not_supported": "This currency is not supported", + "something_went_wrong": "Something went wrong", + "permission_must_exist": "Permission must exist", + "minValue_of_value_is": "Minimal value is $value", + "value_cannot_be": "Value cannot be $value.", + "invalid_format": "Invalid value format.", + "invalid_regexp_format": "This is not valid regex.", + "owner_and_broadcaster_oauth_is_not_set": "Owner and channel oauth is not set", + "channel_is_not_set": "Channel is not set", + "please_set_your_broadcaster_oauth_or_owners": "Please set your channel oauth or owners, or all users will have access to this dashboard and will be considered as casters.", + "new_update_available": "New update available", + "new_bot_version_available_at": "New bot version {version} available at {link}.", + "one_of_inputs_must_be_set": "One of inputs must be set" +} \ No newline at end of file diff --git a/backend/locales/uk/ui/games/duel.json b/backend/locales/uk/ui/games/duel.json new file mode 100644 index 000000000..84789a717 --- /dev/null +++ b/backend/locales/uk/ui/games/duel.json @@ -0,0 +1,12 @@ +{ + "settings": { + "enabled": "Status", + "cooldown": "Cooldown", + "duration": { + "title": "Duration", + "help": "Minutes" + }, + "minimalBet": "Minimal bet", + "bypassCooldownByOwnerAndMods": "Bypass cooldown by owner and mods" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/games/gamble.json b/backend/locales/uk/ui/games/gamble.json new file mode 100644 index 000000000..6a78309aa --- /dev/null +++ b/backend/locales/uk/ui/games/gamble.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Status", + "minimalBet": "Minimal bet", + "chanceToWin": { + "title": "Chance to win", + "help": "Percent" + }, + "enableJackpot": "Enable jackpot", + "chanceToTriggerJackpot": "Chance to trigger jackpot in %", + "maxJackpotValue": "Maximum value of jackpot", + "lostPointsAddedToJackpot": "How many lost points should be added to jackpot in %" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/games/heist.json b/backend/locales/uk/ui/games/heist.json new file mode 100644 index 000000000..e0ffc9feb --- /dev/null +++ b/backend/locales/uk/ui/games/heist.json @@ -0,0 +1,30 @@ +{ + "name": "Heist", + "settings": { + "enabled": "Status", + "showMaxUsers": "Max users to show in payout", + "copsCooldownInMinutes": { + "title": "Cooldown between heists", + "help": "Minutes" + }, + "entryCooldownInSeconds": { + "title": "Time to entry heist", + "help": "Seconds" + }, + "started": "Heist start message", + "nextLevelMessage": "Message when next level is reached", + "maxLevelMessage": "Message when max level is reached", + "copsOnPatrol": "Response of bot when heist is still on cooldown", + "copsCooldown": "Bot announcement when heist can be started", + "singleUserSuccess": "Success message for one user", + "singleUserFailed": "Fail message for one user", + "noUser": "Message if no user participated" + }, + "message": "Message", + "winPercentage": "Win percentage", + "payoutMultiplier": "Payout multiplier", + "maxUsers": "Max users for level", + "percentage": "Percentage", + "noResultsFound": "No results found. Click button below to add new result.", + "noLevelsFound": "No levels found. Click button below to add new level." +} \ No newline at end of file diff --git a/backend/locales/uk/ui/games/roulette.json b/backend/locales/uk/ui/games/roulette.json new file mode 100644 index 000000000..65696d4e3 --- /dev/null +++ b/backend/locales/uk/ui/games/roulette.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout duration", + "help": "Seconds" + }, + "winnerWillGet": "How many points will be added on win", + "loserWillLose": "How many points will be lost on lose" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/games/seppuku.json b/backend/locales/uk/ui/games/seppuku.json new file mode 100644 index 000000000..4d628e202 --- /dev/null +++ b/backend/locales/uk/ui/games/seppuku.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Status", + "timeout": { + "title": "Timeout duration", + "help": "Seconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/integrations/discord.json b/backend/locales/uk/ui/integrations/discord.json new file mode 100644 index 000000000..89d27e81e --- /dev/null +++ b/backend/locales/uk/ui/integrations/discord.json @@ -0,0 +1,28 @@ +{ + "settings": { + "enabled": "Стан", + "guild": "Сервер", + "listenAtChannels": "Слухати команди на цьому каналі", + "sendOnlineAnnounceToChannel": "Надіслати онлайн оголошення на цей канал", + "onlineAnnounceMessage": "Message in online announcement (can include mentions)", + "sendAnnouncesToChannel": "Налаштування відправки повідомлень каналам", + "deleteMessagesAfterWhile": "Видалити повідомлення через деякий час", + "clientId": "ClientId", + "token": "Token", + "joinToServerBtn": "Натисніть для приєднання бота до серверу", + "joinToServerBtnDisabled": "Please save changes to enable bot join to your server", + "cannotJoinToServerBtn": "Встановіть token і clientId, щоб мати можливість приєднати бота на ваш сервер", + "noChannelSelected": "канал не обрано", + "noRoleSelected": "роль не обрана", + "noGuildSelected": "сервер не обрано", + "noGuildSelectedBox": "Виберіть сервер, де бот повинен працювати, і ви побачите більше параметрів", + "onlinePresenceStatusDefault": "Стан за замовчуванням", + "onlinePresenceStatusDefaultName": "Повідомлення про стан за умовчанням", + "onlinePresenceStatusOnStream": "Статус при трансляції", + "onlinePresenceStatusOnStreamName": "Повідомлення про статус під час трансляції", + "ignorelist": { + "title": "Список ігнорування", + "help": "ім'я, ім'я користувача#0000 або Id користувача" + } + } +} diff --git a/backend/locales/uk/ui/integrations/donatello.json b/backend/locales/uk/ui/integrations/donatello.json new file mode 100644 index 000000000..75bd1598d --- /dev/null +++ b/backend/locales/uk/ui/integrations/donatello.json @@ -0,0 +1,8 @@ +{ + "settings": { + "token": { + "title": "Token", + "help": "Get your token at https://donatello.to/panel/doc-api" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/integrations/donationalerts.json b/backend/locales/uk/ui/integrations/donationalerts.json new file mode 100644 index 000000000..e3d322dfd --- /dev/null +++ b/backend/locales/uk/ui/integrations/donationalerts.json @@ -0,0 +1,13 @@ +{ + "settings": { + "enabled": "Статус", + "access_token": { + "title": "Access token", + "help": "Get your access token at https://www.sogebot.xyz/integrations/#DonationAlerts" + }, + "refresh_token": { + "title": "Refresh token" + }, + "accessTokenBtn": "DonationAlerts access and refresh token generator" + } +} diff --git a/backend/locales/uk/ui/integrations/kofi.json b/backend/locales/uk/ui/integrations/kofi.json new file mode 100644 index 000000000..a8179bf1e --- /dev/null +++ b/backend/locales/uk/ui/integrations/kofi.json @@ -0,0 +1,16 @@ +{ + "settings": { + "verification_token": { + "title": "Verification token", + "help": "Get your verification token at https://ko-fi.com/manage/webhooks" + }, + "webhook_url": { + "title": "Webhook URL", + "help": "Set Webhook URL at https://ko-fi.com/manage/webhooks", + "errors": { + "https": "URL must have HTTPS", + "origin": "You cannot use localhost for webhooks" + } + } + } +} diff --git a/backend/locales/uk/ui/integrations/lastfm.json b/backend/locales/uk/ui/integrations/lastfm.json new file mode 100644 index 000000000..3acc84d8a --- /dev/null +++ b/backend/locales/uk/ui/integrations/lastfm.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Status", + "apiKey": "API key", + "username": "Username" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/integrations/obswebsocket.json b/backend/locales/uk/ui/integrations/obswebsocket.json new file mode 100644 index 000000000..e681b04e4 --- /dev/null +++ b/backend/locales/uk/ui/integrations/obswebsocket.json @@ -0,0 +1,59 @@ +{ + "settings": { + "enabled": "Status", + "accessBy": { + "title": "Access by", + "help": "Direct - connect directly from a bot | Overlay - connect via overlay browser source" + }, + "address": "Address", + "password": "Password" + }, + "noSourceSelected": "No source selected", + "noSceneSelected": "No scene selected", + "empty": "No action sets were created yet.", + "emptyAfterSearch": "No action sets were found by your search for \"$search\".", + "command": "Command", + "new": "Create new OBS Websocket action set", + "actions": "Actions", + "name": { + "name": "Name" + }, + "mute": "Mute", + "unmute": "Unmute", + "SetCurrentScene": { + "name": "SetCurrentScene" + }, + "StartReplayBuffer": { + "name": "StartReplayBuffer" + }, + "StopReplayBuffer": { + "name": "StopReplayBuffer" + }, + "SaveReplayBuffer": { + "name": "SaveReplayBuffer" + }, + "WaitMs": { + "name": "Wait X miliseconds" + }, + "Log": { + "name": "Log message" + }, + "StartRecording": { + "name": "StartRecording" + }, + "StopRecording": { + "name": "StopRecording" + }, + "PauseRecording": { + "name": "PauseRecording" + }, + "ResumeRecording": { + "name": "ResumeRecording" + }, + "SetMute": { + "name": "SetMute" + }, + "SetVolume": { + "name": "SetVolume" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/integrations/pubg.json b/backend/locales/uk/ui/integrations/pubg.json new file mode 100644 index 000000000..42ad7e5dd --- /dev/null +++ b/backend/locales/uk/ui/integrations/pubg.json @@ -0,0 +1,24 @@ +{ + "settings": { + "enabled": "Стан", + "apiKey": { + "title": "Ключ API", + "help": "Отримайте свій ключ на https://developer.pubg.com/" + }, + "platform": "Платформа", + "playerName": "Ім'я гравця", + "playerId": "ID Гравця", + "seasonId": { + "title": "Ідентифікатор сезону", + "help": "Current season ID is being fetch every hour." + }, + "rankedGameModeStatsCustomization": "Customized message for ranked stats", + "gameModeStatsCustomization": "Customized message for normal stats" + }, + "click_to_fetch": "Натисніть, щоб отримати", + "something_went_wrong": "Щось пішло не так!", + "ok": "OK!", + "stats_are_automatically_refreshed_every_10_minutes": "Stats are automatically refreshed every 10 minutes.", + "player_stats_ranked": "Статистика гравця (рейтинг)", + "player_stats": "Статистика гравця" +} diff --git a/backend/locales/uk/ui/integrations/qiwi.json b/backend/locales/uk/ui/integrations/qiwi.json new file mode 100644 index 000000000..a11c6d21f --- /dev/null +++ b/backend/locales/uk/ui/integrations/qiwi.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Статус", + "secretToken": { + "title": "Секретний ключ", + "help": "Отримати секретний токен в Qiwi Donate dashboard налаштування->натисніть Показати секретний токен" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/integrations/responsivevoice.json b/backend/locales/uk/ui/integrations/responsivevoice.json new file mode 100644 index 000000000..00ae8129c --- /dev/null +++ b/backend/locales/uk/ui/integrations/responsivevoice.json @@ -0,0 +1,8 @@ +{ + "settings": { + "key": { + "title": "Ключ", + "help": "Отримайте свій ключ на http://responsivevoice.org" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/integrations/spotify.json b/backend/locales/uk/ui/integrations/spotify.json new file mode 100644 index 000000000..cbe217bb4 --- /dev/null +++ b/backend/locales/uk/ui/integrations/spotify.json @@ -0,0 +1,41 @@ +{ + "artists": "Artists", + "settings": { + "enabled": "Статус", + "songRequests": "Запити на пісню", + "fetchCurrentSongWhenOffline": { + "title": "Завантажити поточну пісню, коли потік не в мережі", + "help": "Рекомендуємо відключити дану функцію для уникнення ліміту API" + }, + "allowApprovedArtistsOnly": "Дозволити тільки затверджених виконавців", + "approvedArtists": { + "title": "Схвалені артисти", + "help": "Ім'я або SpotifyURI виконавця, один елемент у рядку" + }, + "queueWhenOffline": { + "title": "Черга пісень, коли трансляція не в мережі", + "help": "Рекомендується відключити цю функцію, щоб уникнути черги, коли ви просто слухаєте музику" + }, + "clientId": "clientId", + "clientSecret": "clientSecret", + "manualDeviceId": { + "title": "Примусовий ідентифікатор пристрою", + "help": "Empty = disabled, force spotify device ID to be used to queue songs. Check logs for current active device or use button when playing song for at least 10 seconds." + }, + "redirectURI": "redirectURI", + "format": { + "title": "Формат", + "help": "Доступні змінні: $song, $artist, $artists" + }, + "username": "Авторизований користувач", + "revokeBtn": "Відкликати авторизацію користувача", + "authorizeBtn": "Авторизація користувача", + "scopes": "Дозвіл", + "playlistToPlay": { + "title": "URI Spotify з основного плейлісту", + "help": "Якщо призначено, то по завершенню замовлених пісень цей плейлист буде продовжено" + }, + "continueOnPlaylistAfterRequest": "Продовжити відтворення пісні після запиту пісні", + "notify": "Надсилати повідомлення про зміну пісні" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/integrations/streamelements.json b/backend/locales/uk/ui/integrations/streamelements.json new file mode 100644 index 000000000..f69e6d227 --- /dev/null +++ b/backend/locales/uk/ui/integrations/streamelements.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Стан", + "jwtToken": { + "title": "JWT token", + "help": "Отримати токен JWT на Налаштування каналів StreamElements та показати" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/integrations/streamlabs.json b/backend/locales/uk/ui/integrations/streamlabs.json new file mode 100644 index 000000000..b4924b07f --- /dev/null +++ b/backend/locales/uk/ui/integrations/streamlabs.json @@ -0,0 +1,14 @@ +{ + "settings": { + "enabled": "Стан", + "socketToken": { + "title": "Токен сокета", + "help": "Отримати токен сокета із Streamlabs API | Налаштування -> API Tokens -> Ваш токен API Socket" + }, + "accessToken": { + "title": "Access token", + "help": "Отримати свій токен доступу за адресою https://www.sogebot.xyz/integrations/#StreamLabs" + }, + "accessTokenBtn": "Генератор токена доступу StreamLabs" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/integrations/tipeeestream.json b/backend/locales/uk/ui/integrations/tipeeestream.json new file mode 100644 index 000000000..1a3c9c800 --- /dev/null +++ b/backend/locales/uk/ui/integrations/tipeeestream.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Стан", + "apiKey": { + "title": "Ключ API", + "help": "Отримати токен сокета з панелі tipeeestream -> API -> Ваш ключ API" + } + } +} diff --git a/backend/locales/uk/ui/integrations/twitter.json b/backend/locales/uk/ui/integrations/twitter.json new file mode 100644 index 000000000..29066c191 --- /dev/null +++ b/backend/locales/uk/ui/integrations/twitter.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Стан", + "consumerKey": "Consumer Key (API Key)", + "consumerSecret": "Consumer Secret (API Secret)", + "accessToken": "Access Token", + "secretToken": "Access Token Secret" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/managers.json b/backend/locales/uk/ui/managers.json new file mode 100644 index 000000000..6e10d4312 --- /dev/null +++ b/backend/locales/uk/ui/managers.json @@ -0,0 +1,8 @@ +{ + "viewers": { + "eventHistory": "Історія подій користувача", + "hostAndRaidViewersCount": "Переглядів: $value", + "receivedSubscribeFrom": "Отримано підписку від $value", + "giftedSubscribeTo": "Отримано підписку від $value" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/overlays/alerts.json b/backend/locales/uk/ui/overlays/alerts.json new file mode 100644 index 000000000..1ef4d0d11 --- /dev/null +++ b/backend/locales/uk/ui/overlays/alerts.json @@ -0,0 +1,6 @@ +{ + "settings": { + "galleryCache": "Кешувати об’єкти галереї", + "galleryCacheLimitInMb": "Максимальний розмір об’єкта галереї (в МБ) для кешу" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/overlays/clips.json b/backend/locales/uk/ui/overlays/clips.json new file mode 100644 index 000000000..aa6555159 --- /dev/null +++ b/backend/locales/uk/ui/overlays/clips.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsVolume": "Volume", + "cClipsFilter": "Clip filter", + "cClipsLabel": "Show 'clip' label" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/overlays/clipscarousel.json b/backend/locales/uk/ui/overlays/clipscarousel.json new file mode 100644 index 000000000..b50a0a71d --- /dev/null +++ b/backend/locales/uk/ui/overlays/clipscarousel.json @@ -0,0 +1,7 @@ +{ + "settings": { + "cClipsCustomPeriodInDays": "Time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsTimeToNextClip": "Time to next clip (s)" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/overlays/credits.json b/backend/locales/uk/ui/overlays/credits.json new file mode 100644 index 000000000..6b2f72805 --- /dev/null +++ b/backend/locales/uk/ui/overlays/credits.json @@ -0,0 +1,32 @@ +{ + "settings": { + "cCreditsSpeed": "Speed", + "cCreditsAggregated": "Aggregated credits", + "cShowGameThumbnail": "Show game thumbnail", + "cShowFollowers": "Show followers", + "cShowRaids": "Show raids", + "cShowSubscribers": "Show subscribers", + "cShowSubgifts": "Show gifted subs", + "cShowSubcommunitygifts": "Show subs gifted to community", + "cShowResubs": "Show resubs", + "cShowCheers": "Show cheers", + "cShowClips": "Show clips", + "cShowTips": "Show tips", + "cTextLastMessage": "Last message", + "cTextLastSubMessage": "Last submessge", + "cTextStreamBy": "Streamed by", + "cTextFollow": "Follow by", + "cTextRaid": "Raided by", + "cTextCheer": "Cheer by", + "cTextSub": "Subscribe by", + "cTextResub": "Resub by", + "cTextSubgift": "Gifted subs", + "cTextSubcommunitygift": "Subs gifted to community", + "cTextTip": "Tips by", + "cClipsPeriod": "Time interval", + "cClipsCustomPeriodInDays": "Custom time interval (days)", + "cClipsNumOfClips": "Number of clips", + "cClipsShouldPlay": "Clips should be played", + "cClipsVolume": "Volume" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/overlays/emotes.json b/backend/locales/uk/ui/overlays/emotes.json new file mode 100644 index 000000000..8a961ad91 --- /dev/null +++ b/backend/locales/uk/ui/overlays/emotes.json @@ -0,0 +1,48 @@ +{ + "settings": { + "btnRemoveCache": "Delete cache", + "hypeMessagesEnabled": "Show hype messages in chat", + "btnTestExplosion": "Test emote explosion", + "btnTestEmote": "Test emote", + "btnTestFirework": "Test emote firework", + "cEmotesSize": "Emotes size", + "cEmotesMaxEmotesPerMessage": "Maximum of emotes per message", + "cEmotesMaxRotation": "Maximal rotation of emote", + "cEmotesOffsetX": "Maximal offset on X-axis", + "cEmotesAnimation": "Animation", + "cEmotesAnimationTime": "Animation duration", + "cExplosionNumOfEmotes": "No. of emotes", + "cExplosionNumOfEmotesPerExplosion": "No. of emotes per explosion", + "cExplosionNumOfExplosions": "No. of explosions", + "enableEmotesCombo": "Enable emotes combo", + "comboBreakMessages": "Combo break messages", + "threshold": "Threshold", + "noMessagesFound": "No messages found.", + "message": "Message", + "showEmoteInOverlayThreshold": "Minimal message threshold to show emote in overlay", + "hideEmoteInOverlayAfter": { + "title": "Hide emote in overlay after inactivity", + "help": "Will hide emote in overlay after certain time in seconds" + }, + "comboCooldown": { + "title": "Combo cooldown", + "help": "Cooldown of combo in seconds" + }, + "comboMessageMinThreshold": { + "title": "Minimal message threshold", + "help": "Minimal message threshold to count emotes as combo (until then won't trigger cooldown)" + }, + "comboMessages": "Combo messages" + }, + "hype": { + "5": "Let's go! We got $amountx $emote combo so far! SeemsGood", + "15": "Keep it going! Can we get more than $amountx $emote? TriHard" + }, + "message": { + "3": "$amountx $emote combo", + "5": "$amountx $emote combo SeemsGood", + "10": "$amountx $emote combo PogChamp", + "15": "$amountx $emote combo TriHard", + "20": "$sender ruined $amountx $emote combo! NotLikeThis" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/overlays/polls.json b/backend/locales/uk/ui/overlays/polls.json new file mode 100644 index 000000000..da094ce9b --- /dev/null +++ b/backend/locales/uk/ui/overlays/polls.json @@ -0,0 +1,11 @@ +{ + "settings": { + "cDisplayTheme": "Theme", + "cDisplayHideAfterInactivity": "Hide on inactivity", + "cDisplayAlign": "Align", + "cDisplayInactivityTime": { + "title": "Inactivity after", + "help": "in miliseconds" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/overlays/texttospeech.json b/backend/locales/uk/ui/overlays/texttospeech.json new file mode 100644 index 000000000..c61ee3567 --- /dev/null +++ b/backend/locales/uk/ui/overlays/texttospeech.json @@ -0,0 +1,13 @@ +{ + "settings": { + "responsiveVoiceKeyNotSet": "You haven't properly set ResponsiveVoice key", + "voice": { + "title": "Voice", + "help": "If voices are not properly loading after ResponsiveVoice key update, try to refresh browser" + }, + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "triggerTTSByHighlightedMessage": "Text to Speech will be triggered by highlighted message" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/properties.json b/backend/locales/uk/ui/properties.json new file mode 100644 index 000000000..e6243cf72 --- /dev/null +++ b/backend/locales/uk/ui/properties.json @@ -0,0 +1,12 @@ +{ + "alias": "Alias", + "command": "Command", + "variableName": "Variable name", + "price": "Price (points)", + "priceBits": "Price (bits)", + "thisvalue": "This value", + "promo": { + "shoutoutMessage": "Shoutout message", + "enableShoutoutMessage": "Send shoutout message in chat" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/registry/alerts.json b/backend/locales/uk/ui/registry/alerts.json new file mode 100644 index 000000000..a019b0625 --- /dev/null +++ b/backend/locales/uk/ui/registry/alerts.json @@ -0,0 +1,220 @@ +{ + "enabled": "Enabled", + "testDlg": { + "alertTester": "Alert tester", + "command": "Command", + "username": "Username", + "recipient": "Recipient", + "message": "Message", + "tier": "Tier", + "amountOfViewers": "Amount of viewers", + "amountOfBits": "Amount of bits", + "amountOfGifts": "Amount of gifts", + "amountOfMonths": "Amount of months", + "amountOfTips": "Tip", + "event": "Event", + "service": "Service" + }, + "empty": "Alerts registry is empty, create new alerts.", + "emptyAfterSearch": "Alerts registry is empty in searching for \"$search\"", + "revertcode": "Revert code to defaults", + "name": { + "name": "Name", + "placeholder": "Set name of your alerts" + }, + "alertDelayInMs": { + "name": "Alert delay" + }, + "parryEnabled": { + "name": "Alert parries" + }, + "parryDelay": { + "name": "Alert parry delay" + }, + "profanityFilterType": { + "name": "Profanity filter", + "disabled": "Disabled", + "replace-with-asterisk": "Replace with asterisk", + "replace-with-happy-words": "Replace with happy words", + "hide-messages": "Hide messages", + "disable-alerts": "Disable alerts" + }, + "loadStandardProfanityList": "Load standard profanity list", + "customProfanityList": { + "name": "Custom profanity list", + "help": "Words should be separated with comma." + }, + "event": { + "follow": "Follow", + "cheer": "Cheer", + "sub": "Sub", + "resub": "Resub", + "subgift": "Subgift", + "subcommunitygift": "Subgift to community", + "tip": "Tip", + "raid": "Raid", + "custom": "Custom", + "promo": "Promo", + "rewardredeem": "Reward Redeem" + }, + "title": { + "name": "Variant name", + "placeholder": "Set your variant name" + }, + "variant": { + "name": "Variant occurence" + }, + "filter": { + "name": "Filter", + "operator": "Operator", + "rule": "Rule", + "addRule": "Add rule", + "addGroup": "Add group", + "comparator": "Comparator", + "value": "Value", + "valueSplitByComma": "Values split by comma (e.g. val1, val2)", + "isEven": "is even", + "isOdd": "is odd", + "lessThan": "less than", + "lessThanOrEqual": "less than or equal", + "contain": "contains", + "contains": "contains", + "equal": "equal", + "notEqual": "not equal", + "present": "is present", + "includes": "includes", + "greaterThan": "greater than", + "greaterThanOrEqual": "greater than or equal", + "noFilter": "no filter" + }, + "speed": { + "name": "Speed" + }, + "maxTimeToDecrypt": { + "name": "Max time to decrypt" + }, + "characters": { + "name": "Characters" + }, + "random": "Random", + "exact-amount": "Exact amount", + "greater-than-or-equal-to-amount": "Greater than or equal to amount", + "tier-exact-amount": "Tier is exactly", + "tier-greater-than-or-equal-to-amount": "Tier is higher or equal to", + "months-exact-amount": "Months amount is exactly", + "months-greater-than-or-equal-to-amount": "Months amount is higher or equal to", + "gifts-exact-amount": "Gifts amount is exactly", + "gifts-greater-than-or-equal-to-amount": "Gifts amount is higher or equal to", + "very-rarely": "Very rarely", + "rarely": "Rarely", + "default": "Default", + "frequently": "Frequently", + "very-frequently": "Very frequently", + "exclusive": "Exclusive", + "messageTemplate": { + "name": "Message template", + "placeholder": "Set your message template", + "help": "Available variables: {name}, {amount} (cheers, subs, tips, subgifts, sub community gifts, command redeems), {recipient} (subgifts, command redeems), {monthsName} (subs, subgifts), {currency} (tips), {game} (promo). If | is added (see promo) then it will show those values in sequence." + }, + "ttsTemplate": { + "name": "TTS template", + "placeholder": "Set your TTS template", + "help": "Available variables: {name}, {amount} {monthsName} {currency} {message}" + }, + "animationText": { + "name": "Animation text" + }, + "animationType": { + "name": "Type of animation" + }, + "animationIn": { + "name": "Animation in" + }, + "animationOut": { + "name": "Animation out" + }, + "alertDurationInMs": { + "name": "Alert duration" + }, + "alertTextDelayInMs": { + "name": "Alert text delay" + }, + "layoutPicker": { + "name": "Шар" + }, + "loop": { + "name": "Play on loop" + }, + "scale": { + "name": "Scale" + }, + "translateY": { + "name": "Move -Up / +Down" + }, + "translateX": { + "name": "Move -Left / +Right" + }, + "image": { + "name": "Image / Video(.webm)", + "setting": "Image / Video(.webm) settings" + }, + "sound": { + "name": "Sound", + "setting": "Sound settings" + }, + "soundVolume": { + "name": "Alert volume" + }, + "enableAdvancedMode": "Enable advanced mode", + "font": { + "setting": "Font settings", + "name": "Font family", + "overrideGlobal": "Override global font settings", + "align": { + "name": "Alignment", + "left": "Left", + "center": "Center", + "right": "Right" + }, + "size": { + "name": "Font size" + }, + "weight": { + "name": "Font weight" + }, + "borderPx": { + "name": "Font border" + }, + "borderColor": { + "name": "Font border color" + }, + "color": { + "name": "Font color" + }, + "highlightcolor": { + "name": "Font highlight color" + } + }, + "minAmountToShow": { + "name": "Minimal amount to show" + }, + "minAmountToPlay": { + "name": "Minimal amount to play" + }, + "allowEmotes": { + "name": "Allow emotes" + }, + "message": { + "setting": "Message settings" + }, + "voice": "Voice", + "keepAlertShown": "Alert keeps visible during TTS", + "skipUrls": "Skip URLs during TTS", + "volume": "Volume", + "rate": "Rate", + "pitch": "Pitch", + "test": "Test", + "tts": { + "setting": "TTS settings" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/registry/goals.json b/backend/locales/uk/ui/registry/goals.json new file mode 100644 index 000000000..8c486828d --- /dev/null +++ b/backend/locales/uk/ui/registry/goals.json @@ -0,0 +1,86 @@ +{ + "addGoalGroup": "Add Goal Group", + "addGoal": "Add Goal", + "newGoal": "new Goal", + "newGoalGroup": "new Goal Group", + "goals": "Goals", + "general": "General", + "display": "Display", + "fontSettings": "Font Settings", + "barSettings": "Bar Settings", + "selectGoalOnLeftSide": "Select or add goal on left side", + "input": { + "description": { + "title": "Description" + }, + "goalAmount": { + "title": "Goal Amount" + }, + "countBitsAsTips": { + "title": "Count Bits as Tips" + }, + "currentAmount": { + "title": "Current Amount" + }, + "endAfter": { + "title": "End After" + }, + "endAfterIgnore": { + "title": "Goal will not expire" + }, + "borderPx": { + "title": "Border", + "help": "Border size is in pixels" + }, + "barHeight": { + "title": "Bar Height", + "help": "Bar height is in pixels" + }, + "color": { + "title": "Color" + }, + "borderColor": { + "title": "Border Color" + }, + "backgroundColor": { + "title": "Background Color" + }, + "type": { + "title": "Type" + }, + "nameGroup": { + "title": "Name of this goal group" + }, + "name": { + "title": "Name of this goal" + }, + "displayAs": { + "title": "Display as", + "help": "Sets how goal group will be shown" + }, + "durationMs": { + "title": "Duration", + "help": "This value is in milliseconds", + "placeholder": "How long goal should be shown" + }, + "animationInMs": { + "title": "Animation In duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation In duration" + }, + "animationOutMs": { + "title": "Animation Out duration", + "help": "This value is in milliseconds", + "placeholder": "Set your animation Out duration" + }, + "interval": { + "title": "What interval to count" + }, + "spaceBetweenGoalsInPx": { + "title": "Space between goals", + "help": "This value is in pixels", + "placeholder": "Set your space between goals" + } + }, + "groupSettings": "Group Settings" +} \ No newline at end of file diff --git a/backend/locales/uk/ui/registry/overlays.json b/backend/locales/uk/ui/registry/overlays.json new file mode 100644 index 000000000..f56199cb8 --- /dev/null +++ b/backend/locales/uk/ui/registry/overlays.json @@ -0,0 +1,8 @@ +{ + "newMapping": "Create new overlay link mapping", + "emptyMapping": "No overlay link mapping were created yet.", + "allowedIPs": { + "name": "Allowed IPs", + "help": "Allow access from set IPs separated by new line" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/registry/plugins.json b/backend/locales/uk/ui/registry/plugins.json new file mode 100644 index 000000000..00eb1444f --- /dev/null +++ b/backend/locales/uk/ui/registry/plugins.json @@ -0,0 +1,58 @@ +{ + "common-errors": { + "missing-sender-attributes": "This node needs to be linked with listeners with sender attributes" + }, + "filter": { + "permission": { + "name": "Permission filter" + } + }, + "cron": { + "name": "Cron" + }, + "listener": { + "name": "Event listener", + "type": { + "twitchChatMessage": "Twitch chat message", + "twitchCheer": "Twitch cheer received", + "twitchClearChat": "Twitch chat cleared", + "twitchCommand": "Twitch command", + "twitchFollow": "New Twitch follower", + "twitchSubscription": "New Twitch subscription", + "twitchSubgift": "New Twitch subscription gift", + "twitchSubcommunitygift": "New Twitch subscription community gift", + "twitchResub": "New Twitch recurring subscription", + "twitchGameChanged": "Twitch category changed", + "twitchStreamStarted": "Twitch stream started", + "twitchStreamStopped": "Twitch stream stopped", + "twitchRewardRedeem": "Twitch reward redeemed", + "twitchRaid": "Twitch raid incoming", + "tip": "Tipped by user", + "botStarted": "Bot started" + }, + "command": { + "add-parameter": "Add parameter", + "parameters": "Parameters", + "order-is-important": "order is important" + } + }, + "others": { + "idle": { + "name": "Idle" + } + }, + "output": { + "log": { + "name": "Log message" + }, + "timeout-user": { + "name": "Timeout user" + }, + "ban-user": { + "name": "Ban user" + }, + "send-twitch-message": { + "name": "Send Twitch Message" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/registry/randomizer.json b/backend/locales/uk/ui/registry/randomizer.json new file mode 100644 index 000000000..5f728918b --- /dev/null +++ b/backend/locales/uk/ui/registry/randomizer.json @@ -0,0 +1,23 @@ +{ + "addRandomizer": "Add Randomizer", + "form": { + "name": "Name", + "command": "Command", + "permission": "Command permission", + "simple": "Simple", + "tape": "Tape", + "wheelOfFortune": "Wheel of Fortune", + "type": "Type", + "options": "Options", + "optionsAreEmpty": "Options are empty.", + "color": "Color", + "numOfDuplicates": "No. of duplicates", + "minimalSpacing": "Minimal spacing", + "groupUp": "Group Up", + "ungroup": "Ungroup", + "groupedWithOptionAbove": "Grouped with option above", + "generatedOptionsPreview": "Preview of generated options", + "probability": "Probability", + "tick": "Tick sound during spin" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/registry/textoverlay.json b/backend/locales/uk/ui/registry/textoverlay.json new file mode 100644 index 000000000..03e974b69 --- /dev/null +++ b/backend/locales/uk/ui/registry/textoverlay.json @@ -0,0 +1,7 @@ +{ + "new": "Create new text overlay", + "title": "text overlay", + "name": { + "placeholder": "Set your text overlay name" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/stats/commandcount.json b/backend/locales/uk/ui/stats/commandcount.json new file mode 100644 index 000000000..0a2b0d88e --- /dev/null +++ b/backend/locales/uk/ui/stats/commandcount.json @@ -0,0 +1,9 @@ +{ + "command": "Команда", + "hour": "Година", + "day": "День", + "week": "Тиждень", + "month": "Місяць", + "year": "Рік", + "total": "Разом" +} \ No newline at end of file diff --git a/backend/locales/uk/ui/systems/checklist.json b/backend/locales/uk/ui/systems/checklist.json new file mode 100644 index 000000000..1559c40f8 --- /dev/null +++ b/backend/locales/uk/ui/systems/checklist.json @@ -0,0 +1,7 @@ +{ + "settings": { + "enabled": "Стан", + "itemsArray": "Список" + }, + "check": "Контрольний список" +} \ No newline at end of file diff --git a/backend/locales/uk/ui/systems/howlongtobeat.json b/backend/locales/uk/ui/systems/howlongtobeat.json new file mode 100644 index 000000000..83c15eb6f --- /dev/null +++ b/backend/locales/uk/ui/systems/howlongtobeat.json @@ -0,0 +1,20 @@ +{ + "settings": { + "enabled": "Стан" + }, + "empty": "Поки що жодна гра не була відслідковована.", + "emptyAfterSearch": "No tracked games were found by your search for \"$search\".", + "when": "При потоці", + "time": "Час відстеження", + "overallTime": "Overall time", + "offset": "Offset of tracked time", + "main": "Основне", + "extra": "Основне + додаткове", + "completionist": "Completionist", + "game": "Tracked game", + "startedAt": "Tracking started at", + "updatedAt": "Last update", + "showHistory": "Показати історію ($count)", + "hideHistory": "Сховати історію ($count)", + "searchToAddNewGame": "Шукати, щоб додати нову гру для відстеження" +} \ No newline at end of file diff --git a/backend/locales/uk/ui/systems/keywords.json b/backend/locales/uk/ui/systems/keywords.json new file mode 100644 index 000000000..18d683089 --- /dev/null +++ b/backend/locales/uk/ui/systems/keywords.json @@ -0,0 +1,27 @@ +{ + "new": "Нове ключове слово", + "empty": "Жодного ключового слова ще не було створено.", + "emptyAfterSearch": "По вашому запиту не знайдено жодних ключових слів для пошуку \"$search..", + "keyword": { + "name": "Ключове слово / Регулярний вираз", + "placeholder": "Встановіть своє ключове слово або регулярне вираз, аби викликати ключове слово.", + "help": "Можна використовувати regexp (чутливе до регістру) для використання ключових слів, наприклад hello.*|hi" + }, + "response": { + "name": "Відповідь", + "placeholder": "Встановіть свою відповідь тут." + }, + "error": { + "isEmpty": "Це параметр не може бути пустим" + }, + "no-responses-set": "Немає відповіді", + "addResponse": "Добавити відповідь", + "filter": { + "name": "фільтри", + "placeholder": "Додати фільтр для цієї відповіді" + }, + "warning": "Цю дію не можна скасувати!", + "settings": { + "enabled": "Стан" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/systems/levels.json b/backend/locales/uk/ui/systems/levels.json new file mode 100644 index 000000000..cdf883ab1 --- /dev/null +++ b/backend/locales/uk/ui/systems/levels.json @@ -0,0 +1,21 @@ +{ + "settings": { + "enabled": "Стан", + "conversionRate": "Курс обміну 1 XP для x точок", + "firstLevelStartsAt": "Перший рівень починається з XP", + "nextLevelFormula": { + "title": "Формула наступного рівня розрахунку", + "help": "Доступні змінні: $prevLevel, $prevLevelXP" + }, + "levelShowcaseHelp": "Levels example will be refreshed on save", + "xpName": "Ім’я", + "interval": "Minutes interval to add xp to online users when stream online", + "offlineInterval": "Minutes interval to add xp to online users when stream offline", + "messageInterval": "How many messages to add xp", + "messageOfflineInterval": "How many messages to add xp when stream offline", + "perInterval": "How many xp to add per online interval", + "perOfflineInterval": "How many xp to add per offline interval", + "perMessageInterval": "How many xp to add per message interval", + "perMessageOfflineInterval": "How many xp to add per message offline interval" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/systems/polls.json b/backend/locales/uk/ui/systems/polls.json new file mode 100644 index 000000000..f31a08052 --- /dev/null +++ b/backend/locales/uk/ui/systems/polls.json @@ -0,0 +1,6 @@ +{ + "totalVotes": "Total votes", + "totalPoints": "Total points", + "closedAt": "Closed at", + "activeFor": "Active for" +} \ No newline at end of file diff --git a/backend/locales/uk/ui/systems/scrim.json b/backend/locales/uk/ui/systems/scrim.json new file mode 100644 index 000000000..6cc56d7e3 --- /dev/null +++ b/backend/locales/uk/ui/systems/scrim.json @@ -0,0 +1,9 @@ +{ + "settings": { + "enabled": "Стан", + "waitForMatchIdsInSeconds": { + "title": "Інтервал запису ID матчу в чат", + "help": "Встановити в секундах" + } + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/systems/top.json b/backend/locales/uk/ui/systems/top.json new file mode 100644 index 000000000..47ba1abdb --- /dev/null +++ b/backend/locales/uk/ui/systems/top.json @@ -0,0 +1,5 @@ +{ + "settings": { + "enabled": "Статус" + } +} \ No newline at end of file diff --git a/backend/locales/uk/ui/systems/userinfo.json b/backend/locales/uk/ui/systems/userinfo.json new file mode 100644 index 000000000..aae0d3fba --- /dev/null +++ b/backend/locales/uk/ui/systems/userinfo.json @@ -0,0 +1,11 @@ +{ + "settings": { + "enabled": "Статус", + "formatSeparator": "Розділювач форматів", + "order": "Формат", + "lastSeenFormat": { + "title": "Формат часу", + "help": "Можливі формати на https://momentjs.com/docs/#/displaying/format/" + } + } +} \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 000000000..b6189b1ce --- /dev/null +++ b/backend/package.json @@ -0,0 +1,168 @@ +{ + "name": "@sogebot/backend", + "version": "21.2.0-SNAPSHOT", + "description": "Free Twitch Bot built on Node.js", + "private": true, + "engines": { + "npm": ">=8.0.0", + "node": ">=16.11.1" + }, + "type": "module", + "main": "main.js", + "exports": { + "./dest/*": "./dest/*", + "./src/*": "./src/*" + }, + "author": "Michal Orlik ", + "license": "MIT", + "dependencies": { + "@d-fischer/shared-utils": "^3.6.3", + "@sogebot/ui-admin": "^78.0.3", + "@sogebot/ui-helpers": "^3.1.0", + "@sogebot/ui-oauth": "~3.1.1", + "@sogebot/ui-overlay": "^36.1.0", + "@sogebot/ui-public": "^5.0.0", + "@twurple/api": "7.0.4", + "@twurple/auth": "7.0.4", + "@twurple/chat": "7.0.4", + "@twurple/eventsub-base": "^7.0.4", + "@twurple/eventsub-ws": "7.0.4", + "@twurple/pubsub": "7.0.4", + "async-mutex": "^0.4.0", + "axios": "1.5.1", + "basic-auth": "2.0.1", + "better-sqlite3": "^8.0.0", + "blocked-at": "1.2.0", + "chalk": "5.3.0", + "class-validator": "^0.14.0", + "cors": "2.8.5", + "cron-parser": "4.9.0", + "cross-env": "7.0.3", + "crypto-browserify": "3.12.0", + "currency-symbol-map": "5.1.0", + "dayjs": "1.11.10", + "decode-html": "2.0.0", + "discord.js": "14.13.0", + "dotenv": "16.3.1", + "emoji-regex": "10.3.0", + "express": "^4.18.2", + "express-rate-limit": "7.1.2", + "figlet": "1.7.0", + "file-type": "^18.5.0", + "get-changelog-lib": "^3.0.0", + "git-commit-info": "2.0.2", + "glob": "10.3.10", + "global-tld-list": "^1.5.6", + "googleapis": "^128.0.0", + "howlongtobeat": "1.8.0", + "humanize-duration": "^3.30.0", + "jsonwebtoken": "9.0.2", + "lodash-es": "^4.17.21", + "mathjs": "12.0.0", + "mkdir": "0.0.2", + "multer": "^1.4.4", + "mysql2": "3.6.2", + "nanoid": "^5.0.2", + "node-fetch": "^3.3.2", + "npm-check-updates": "^16.14.6", + "obs-websocket-js": "5.0.3", + "pg": "8.11.3", + "proxy-deep": "3.1.1", + "reflect-metadata": "0.1.13", + "request": "2.88.2", + "rotating-file-stream": "3.1.1", + "safe-eval": "^0.4.1", + "sanitize-filename": "1.6.3", + "socket.io": "4.7.2", + "socket.io-client": "4.7.2", + "source-map-support": "0.5.21", + "spotify-web-api-node": "5.0.2", + "strip-ansi": "7.1.0", + "terser": "^5.22.0", + "tiny-typed-emitter": "2.1.0", + "trigram-similarity": "^1.0.7", + "typeorm": "0.3.17", + "typescript": "5.2.2", + "url-join": "5.0.0", + "uuid": "9.0.1", + "velocity-animate": "1.5.2", + "vm2": "^3.9.19", + "ws": "8.14.2", + "xregexp": "5.1.1", + "yargs": "17.7.2", + "ytdl-core": "4.11.5", + "ytpl": "2.3.0", + "ytsr": "3.8.4" + }, + "scripts": { + "postinstall": "(custompatch || (exit 0))", + "build": "cross-env ENV=development make bot", + "start": "cross-env NODE_ENV=production node --experimental-report --report-on-fatalerror --report-directory=./logs/ --optimize_for_size --gc_interval=100 -r source-map-support/register ./dest/main", + "debug": "cross-env NODE_ENV=development node --inspect --experimental-report --report-on-fatalerror --report-directory=./logs/ --trace-warnings --optimize_for_size --gc_interval=100 -r source-map-support/register --inspect=0.0.0.0:9229 --nolazy ./dest/main", + "gc": "cross-env NODE_ENV=development node --inspect --experimental-report --report-on-fatalerror --report-directory=./logs/ --trace-warnings --trace_gc --trace_gc_verbose --optimize_for_size --gc_interval=100 -r source-map-support/register --inspect=0.0.0.0:9229 --nolazy ./dest/main" + }, + "devDependencies": { + "@devexpress/dx-react-grid": "^4.0.5", + "@types/basic-auth": "1.1.5", + "@types/color": "3.0.5", + "@types/cookie": "0.5.3", + "@types/core-js": "^2.5.7", + "@types/cors": "2.8.15", + "@types/eslint": "8.44.6", + "@types/express-rate-limit": "5.1.3", + "@types/figlet": "1.5.7", + "@types/glob": "8.1.0", + "@types/gsap": "1.20.2", + "@types/humanize-duration": "^3.27.2", + "@types/jquery": "3.5.25", + "@types/jsonwebtoken": "9.0.4", + "@types/lodash": "4.14.200", + "@types/lodash-es": "^4.17.10", + "@types/mathjs": "9.4.1", + "@types/minimist": "1.2.4", + "@types/mocha": "10.0.3", + "@types/module-alias": "2.0.3", + "@types/multer": "^1.4.9", + "@types/node": "20.8.9", + "@types/node-fetch": "^2.6.7", + "@types/page": "1.11.8", + "@types/pg": "8.10.7", + "@types/prismjs": "1.26.2", + "@types/qs": "6.9.9", + "@types/request": "2.48.11", + "@types/shortid": "0.0.31", + "@types/sinon": "10.0.20", + "@types/source-map-support": "0.5.9", + "@types/spotify-web-api-node": "5.0.9", + "@types/swagger-ui-express": "^4.1.5", + "@types/url-join": "4.0.2", + "@types/uuid": "9.0.6", + "@types/ws": "8.5.8", + "@types/xregexp": "4.3.0", + "@types/yargs": "17.0.29", + "@typescript-eslint/eslint-plugin": "6.9.0", + "@typescript-eslint/parser": "6.9.0", + "axios-mock-adapter": "1.22.0", + "bestzip": "2.2.1", + "deepmerge": "4.3.1", + "docsify": "4.13.1", + "docsify-cli": "4.4.4", + "empty": "0.10.1", + "escope": "4.0.0", + "eslint": "8.52.0", + "eslint-plugin-import": "2.29.0", + "eslint-plugin-require-extensions": "^0.1.3", + "git-semver-tags": "7.0.1", + "husky": "^8.0.3", + "minimist": "1.2.8", + "mocha": "10.2.0", + "nyc": "15.1.0", + "process": "0.11.10", + "sinon": "17.0.0", + "test-until": "1.1.1", + "tsc-alias": "^1.8.8", + "tsconfig-paths": "^4.2.0", + "url-regex": "5.0.0", + "util": "0.12.5" + } +} diff --git a/backend/patches/.gitkeep b/backend/patches/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/backend/patches/@twurple+api#7.0.4.patch b/backend/patches/@twurple+api#7.0.4.patch new file mode 100644 index 000000000..e40f16d5b --- /dev/null +++ b/backend/patches/@twurple+api#7.0.4.patch @@ -0,0 +1,15 @@ +Index: \@twurple\api\lib\interfaces\endpoints\channel.input.d.ts +=================================================================== +--- \@twurple\api\lib\interfaces\endpoints\channel.input.d.ts ++++ \@twurple\api\lib\interfaces\endpoints\channel.input.d.ts +@@ -29,9 +29,9 @@ + tags?: string[]; + /** + * The content classification labels to apply to the channel. + */ +- contentClassificationLabels?: string[]; ++ contentClassificationLabels?: {id: string, is_enabled: boolean}[]; + /** + * Whether the channel currently displays branded content. + */ + isBrandedContent?: boolean; diff --git a/backend/patches/@twurple+auth#7.0.4.patch b/backend/patches/@twurple+auth#7.0.4.patch new file mode 100644 index 000000000..1cfb851f9 --- /dev/null +++ b/backend/patches/@twurple+auth#7.0.4.patch @@ -0,0 +1,14 @@ +Index: \@twurple\auth\lib\providers\RefreshingAuthProvider.d.ts +=================================================================== +--- \@twurple\auth\lib\providers\RefreshingAuthProvider.d.ts ++++ \@twurple\auth\lib\providers\RefreshingAuthProvider.d.ts +@@ -36,8 +36,9 @@ + private readonly _intentToUserId; + private readonly _userIdToIntents; + private readonly _cachedRefreshFailures; + private readonly _appImpliedScopes; ++ readonly _userAccessTokens; + /** + * Fires when a user token is refreshed. + * + * @param userId The ID of the user whose token was successfully refreshed. diff --git a/backend/patches/google-auth-library#9.1.0.patch b/backend/patches/google-auth-library#9.1.0.patch new file mode 100644 index 000000000..503f68d34 --- /dev/null +++ b/backend/patches/google-auth-library#9.1.0.patch @@ -0,0 +1,26 @@ +Index: \google-auth-library\build\src\auth\oauth2client.js +=================================================================== +--- \google-auth-library\build\src\auth\oauth2client.js ++++ \google-auth-library\build\src\auth\oauth2client.js +@@ -191,9 +191,9 @@ + e.message = JSON.stringify(e.response.data); + } + throw e; + } +- const tokens = res.data; ++ const tokens = JSON.parse(res.data); + // TODO: de-duplicate this code from a few spots + if (res.data && res.data.expires_in) { + tokens.expiry_date = new Date().getTime() + res.data.expires_in * 1000; + delete tokens.expires_in; +@@ -731,9 +731,9 @@ + OAuth2Client.GOOGLE_OAUTH2_AUTH_BASE_URL_ = 'https://accounts.google.com/o/oauth2/v2/auth'; + /** + * The base endpoint for token retrieval. + */ +-OAuth2Client.GOOGLE_OAUTH2_TOKEN_URL_ = 'https://oauth2.googleapis.com/token'; ++OAuth2Client.GOOGLE_OAUTH2_TOKEN_URL_ = 'https://credentials.sogebot.xyz/google'; + /** + * The base endpoint to revoke tokens. + */ + OAuth2Client.GOOGLE_OAUTH2_REVOKE_URL_ = 'https://oauth2.googleapis.com/revoke'; diff --git a/backend/src/_interface.ts b/backend/src/_interface.ts new file mode 100644 index 000000000..6e32eabd2 --- /dev/null +++ b/backend/src/_interface.ts @@ -0,0 +1,816 @@ +import { existsSync } from 'fs'; +import { setTimeout } from 'timers'; + +import chalk from 'chalk'; +import _ from 'lodash-es'; +import type { Namespace } from 'socket.io/dist/namespace'; +import { v4 as uuid } from 'uuid'; + +import { ClientToServerEventsWithNamespace } from '../d.ts/src/helpers/socket.js'; + +import { PermissionCommands, Permissions as PermissionsEntity } from '~/database/entity/permissions.js'; +import { Settings } from '~/database/entity/settings.js'; +import { AppDataSource } from '~/database.js'; +import { getFunctionList } from '~/decorators/on.js'; +import { + commandsToRegister, loadingInProgress, permissions as permissionsList, +} from '~/decorators.js'; +import { isBotStarted } from '~/helpers/database.js'; +import { flatten, unflatten } from '~/helpers/flatten.js'; +import { enabled } from '~/helpers/interface/enabled.js'; +import emitter from '~/helpers/interfaceEmitter.js'; +import { + error, info, warning, +} from '~/helpers/log.js'; +import { + addMenu, addMenuPublic, ioServer, menu, menuPublic, +} from '~/helpers/panel.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { register } from '~/helpers/register.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; +import * as watchers from '~/watchers.js'; + +let socket: import('~/socket').Socket | any = null; + +const modules = new Map(); + +class Module { + public dependsOn: string[] = []; + public showInUI = true; + public timeouts: { [x: string]: NodeJS.Timeout } = {}; + public settingsList: { category?: string; key: string; defaultValue: any }[] = []; + public settingsPermList: { category?: string; key: string; defaultValue: any }[] = []; + public on: InterfaceSettings.On; + public socket: Namespace | null = null; + public uuid = uuid(); + private firstStatusSent = false; + + onStartupTriggered = false; + + __moduleName__ = ''; + + get isDisabledByEnv(): boolean { + const isDisableIgnored = typeof process.env.ENABLE !== 'undefined' && process.env.ENABLE.toLowerCase().split(',').includes(this.__moduleName__.toLowerCase()); + return typeof process.env.DISABLE !== 'undefined' + && (process.env.DISABLE.toLowerCase().split(',').includes(this.__moduleName__.toLowerCase()) || process.env.DISABLE === '*') + && !isDisableIgnored; + } + + areDependenciesEnabled = false; + get _areDependenciesEnabled(): Promise { + return new Promise((resolve) => { + const check = async (retry: number) => { + const status: any[] = []; + for (const dependency of this.dependsOn) { + const module = modules.get(dependency); + if (!module || !_.isFunction(module.status)) { + if (retry > 0) { + setTimeout(() => check(--retry), 10); + } else { + throw new Error(`[${this.__moduleName__}] Dependency error - possibly wrong path`); + } + return; + } else { + status.push(await module.status({ quiet: true })); + } + } + resolve(status.length === 0 || _.every(status)); + }; + check(10000); + }); + } + + get nsp() { + return '/' + this._name + '/' + this.__moduleName__.toLowerCase() as keyof ClientToServerEventsWithNamespace; + } + + get enabled(): boolean { + if (this.areDependenciesEnabled && !this.isDisabledByEnv) { + const isEnabled = _.get(this, '_enabled', true); + isEnabled ? enabled.enable(this.nsp) : enabled.disable(this.nsp); + return isEnabled; + } else { + enabled.disable(this.nsp); + return false; + } + } + + set enabled(value: boolean) { + if (!_.isEqual(_.get(this, '_enabled', true), value)) { + _.set(this, '_enabled', value); + value ? enabled.enable(this.nsp) : enabled.disable(this.nsp); + AppDataSource.getRepository(Settings).findOneBy({ + name: 'enabled', + namespace: this.nsp, + }).then(data => { + AppDataSource.getRepository(Settings).save({ + ...data, + name: 'enabled', + value: JSON.stringify(value), + namespace: this.nsp, + }); + }); + } + } + + public _name: string; + protected _ui: InterfaceSettings.UI; + public _commands: Command[]; + public _parsers: Parser[]; + public _rollback: { name: string }[]; + protected _enabled: boolean | null = true; + + constructor(name = 'core', enabledArg = true) { + this.__moduleName__ = this.constructor.name; + + this.on = { + change: { enabled: [] }, + load: {}, + }; + + this.socket = null; + + this._commands = []; + this._parsers = []; + this._rollback = []; + this._ui = {}; + this._name = name; + this._enabled = enabledArg; + enabledArg ? enabled.enable(this.nsp) : enabled.disable(this.nsp); + + register(this._name as any, this); + + // prepare proxies for variables + this._sockets(); + + emitter.on('set', (nsp, variableName, value, cb) => { + if (nsp === this.nsp) { + (this as any)[variableName] = value; + emitter.emit('change', `${this._name.toLowerCase()}.${this.__moduleName__.toLowerCase()}.${variableName}`, value); + } + if (cb) { + cb(); + } + }); + + const load = async () => { + if (isBotStarted) { + this.areDependenciesEnabled = await this._areDependenciesEnabled; + setInterval(async () => { + this.areDependenciesEnabled = await this._areDependenciesEnabled; + }, 1000); + + const state = this._name === 'core' ? true : await this.loadVariableValue('enabled'); + const onStartup = async () => { + if (loadingInProgress.length > 0) { + // wait until all settings are loaded + setTimeout(() => onStartup(), 100); + return; + } + if (this._enabled !== null) { + // change only if we can enable/disable + this._enabled = typeof state === 'undefined' ? this._enabled : state; + } + this.status({ state: this._enabled }); + const path = this._name === 'core' ? this.__moduleName__.toLowerCase() : `${this._name}.${this.__moduleName__.toLowerCase()}`; + + for (const event of getFunctionList('startup', path)) { + (this as any)[event.fName]('enabled', state); + } + this.onStartupTriggered = true; + + // require panel/socket + socket = (await import('~/socket.js')).default; + + this.registerCommands(); + }; + onStartup(); + } else { + setImmediate(() => load()); + } + }; + load(); + } + + public sockets() { + return; + } + + public emit(event: string, ...args: any[]) { + if (this.socket) { + this.socket.emit(event, ...args); + } + } + + public async loadVariableValue(key: string) { + const variable = await AppDataSource.getRepository(Settings) + .createQueryBuilder('settings') + .select('settings') + .where('namespace=:namespace', { namespace: this.nsp }) + .andWhere('name=:name', { name: key }) + .getOne(); + + const path = this._name === 'core' ? this.__moduleName__.toLowerCase() : `${this._name}.${this.__moduleName__.toLowerCase()}`; + + setTimeout(() => { + for (const event of getFunctionList('load', `${path}.${key}` )) { + (this as any)[event.fName](key, JSON.parse(variable?.value ?? '""')); + } + }, 1000); + + try { + if (variable) { + // check if object and if all keys are same + // e.g. default { 'a': '', 'b': '' }, but loaded have only 'a' key, should not remove 'b' key + const value = JSON.parse(variable.value); + const defaultObject = (this as any)[key]; + if (value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(defaultObject).length > 0) { + return Object.keys(defaultObject).reduce((a, b) => { + return { ...a, [b]: value[b] ?? defaultObject[b] }; + }, {}); + } + return value; + } else { + return undefined; + } + } catch (e: any) { + error({ key, variable }); + error(e); + return undefined; + } + } + + public prepareCommand(opts: Command) { + const defaultPermission = permissionsList[`${this._name}.${this.__moduleName__.toLowerCase()}.${(opts.fnc || '').toLowerCase()}`]; + if (typeof defaultPermission === 'undefined') { + opts.permission = opts.permission || defaultPermissions.VIEWERS; + } else { + opts.permission = defaultPermission; + } + opts.isHelper = opts.isHelper || false; + return opts; + } + + public async registerCommands() { + try { + for (const { opts: options, m } of commandsToRegister.filter(command => { + return command.m.type === this._name + && command.m.name === this.__moduleName__.toLowerCase(); + })) { + const opts = typeof options === 'string' ? { name: options, id: uuid() } : { ...options, id: uuid() }; + opts.fnc = m.fnc; // force function to decorated function + const c = this.prepareCommand(opts); + + if (typeof this._commands === 'undefined') { + this._commands = []; + } + + this.settingsList.push({ + category: 'commands', key: c.name, defaultValue: c.name, + }); + + // load command from db + const dbc = await AppDataSource.getRepository(Settings) + .createQueryBuilder('settings') + .select('settings') + .where('namespace = :namespace', { namespace: this.nsp }) + .andWhere('name = :name', { name: 'commands.' + c.name }) + .getOne(); + if (dbc) { + dbc.value = JSON.parse(dbc.value); + if (c.name === dbc.value) { + // remove if default value + await AppDataSource.getRepository(Settings) + .createQueryBuilder('settings') + .delete() + .where('namespace = :namespace', { namespace: this.nsp }) + .andWhere('name = :name', { name: 'commands.' + c.name }) + .execute(); + } + c.command = dbc.value; + } + this._commands.push(c); + } + } catch (e: any) { + error(e); + } + } + + public _sockets() { + if (socket === null || ioServer === null) { + setTimeout(() => this._sockets(), 100); + } else { + this.socket = ioServer.of(this.nsp).use(socket.authorize); + this.sockets(); + this.sockets = function() { + error(this.nsp + ': Cannot initialize sockets second time'); + }; + + // default socket listeners + adminEndpoint(this.nsp, 'settings', async (cb) => { + try { + cb(null, await this.getAllSettings(), await this.getUI()); + } catch (e: any) { + cb(e.stack, {}, {}); + } + }); + adminEndpoint(this.nsp, 'settings.update', async (opts, cb) => { + // flatten and remove category + const data = flatten(opts); + const remap: ({ key: string; actual: string; toRemove: string[] } | { key: null; actual: null; toRemove: null })[] = Object.keys(flatten(data)).map(o => { + // skip commands, enabled and permissions + if (o.startsWith('commands') || o.startsWith('enabled') || o.startsWith('_permissions')) { + return { + key: o, + actual: o, + toRemove: [], + }; + } + + const toRemove: string[] = []; + for (const possibleVariable of o.split('.')) { + const isVariableFound = this.settingsList.find(o2 => possibleVariable === o2.key); + if (isVariableFound) { + return { + key: o, + actual: isVariableFound.key, + toRemove, + }; + } else { + toRemove.push(possibleVariable); + } + } + return { + key: null, + actual: null, + toRemove: null, + }; + }); + + for (const { key, actual, toRemove } of remap) { + if (key === null || toRemove === null || actual === null) { + continue; + } + + const joinedToRemove = toRemove.join('.'); + for (const key2 of Object.keys(data)) { + if (joinedToRemove.length > 0) { + const value = data[key2]; + data[key2.replace(joinedToRemove + '.', '')] = value; + + if (key2.replace(joinedToRemove + '.', '') !== key2) { + delete data[key2]; + } + } + } + } + try { + for (const [key, value] of Object.entries(unflatten(data))) { + if (key === 'enabled' && this._enabled === null) { + // ignore enabled if we don't want to enable/disable at will + continue; + } else if (key === 'enabled') { + this.status({ state: value }); + } else if (key === '__permission_based__') { + for (const vKey of Object.keys(value as any)) { + (this as any)['__permission_based__' + vKey] = (value as any)[vKey]; + } + } else { + (this as any)[key] = value; + } + } + } catch (e: any) { + error(e.stack); + if (typeof cb === 'function') { + setTimeout(() => cb(e.stack), 1000); + } + } + + await watchers.check(true); // force watcher to refresh + + if (typeof cb === 'function') { + setTimeout(() => cb(null), 1000); + } + }); + + adminEndpoint(this.nsp, 'set.value', async (opts, cb) => { + try { + (this as any)[opts.variable] = opts.value; + if (cb) { + cb(null, { variable: opts.variable, value: opts.value }); + } + } catch (e: any) { + if (cb) { + cb(e.stack, null); + } + } + }); + publicEndpoint(this.nsp, 'get.value', async (variable, cb) => { + try { + cb(null, await (this as any)[variable]); + } catch (e: any) { + cb(e.stack, undefined); + } + }); + } + } + + public async status(opts: any = {}) { + if (['core', 'overlays', 'widgets', 'stats', 'registries', 'services'].includes(this._name) || (opts.state === null && typeof opts.state !== 'undefined')) { + return true; + } + + const isMasterAndStatusOnly = _.isNil(opts.state); + const isStatusChanged = !_.isNil(opts.state) && this.enabled !== opts.state; + + if (existsSync('~/restart.pid') // force quiet if we have restart.pid + || (this.enabled === opts.state && this.firstStatusSent) // force quiet if we actually don't change anything + ) { + opts.quiet = true; + } + + if (isStatusChanged) { + this.enabled = opts.state; + } else { + opts.state = this.enabled; + } + + if (!this.areDependenciesEnabled || this.isDisabledByEnv) { + opts.state = false; + } // force disable if dependencies are disabled or disabled by env + + // on.change handler on enabled + if (isStatusChanged && this.onStartupTriggered) { + const path = this._name === 'core' ? this.__moduleName__.toLowerCase() : `${this._name}.${this.__moduleName__.toLowerCase()}`; + for (const event of getFunctionList('change', path + '.enabled')) { + if (typeof (this as any)[event.fName] === 'function') { + (this as any)[event.fName]('enabled', opts.state); + } else { + error(`${event.fName}() is not function in ${this._name}/${this.__moduleName__.toLowerCase()}`); + } + } + } + + if (!this.firstStatusSent || ((isMasterAndStatusOnly || isStatusChanged) && !opts.quiet)) { + if (this.isDisabledByEnv) { + info(`${chalk.red('DISABLED BY ENV')}: ${this.__moduleName__} (${this._name})`); + } else if (this.areDependenciesEnabled) { + info(`${opts.state ? chalk.green('ENABLED') : chalk.red('DISABLED')}: ${this.__moduleName__} (${this._name})`); + } else { + info(`${chalk.red('DISABLED BY DEP')}: ${this.__moduleName__} (${this._name})`); + } + } + + this.firstStatusSent = true; + modules.set(`${this._name.toLowerCase()}.${this.__moduleName__.toLowerCase()}`, this); + + return opts.state; + } + + public addMenu(opts: typeof menu[number]) { + addMenu(opts); + } + + public addMenuPublic(opts: typeof menuPublic[number]) { + addMenuPublic(opts); + } + + public async getAllSettings(withoutDefaults = false) { + const promisedSettings: { + [x: string]: any; + } = {}; + + // go through expected settings + for (const { category, key, defaultValue } of this.settingsList) { + if (category) { + if (typeof promisedSettings[category] === 'undefined') { + promisedSettings[category] = {}; + } + + if (category === 'commands') { + _.set(promisedSettings, `${category}.${key}`, withoutDefaults ? this.getCommand(key) : [this.getCommand(key), defaultValue]); + } else { + _.set(promisedSettings, `${category}.${key}`, withoutDefaults ? (this as any)[key] : [(this as any)[key], defaultValue]); + } + } else { + _.set(promisedSettings, key, withoutDefaults ? (this as any)[key] : [(this as any)[key], defaultValue]); + } + } + + // go through expected permission based settings + for (const { category, key, defaultValue } of this.settingsPermList) { + if (typeof promisedSettings.__permission_based__ === 'undefined') { + promisedSettings.__permission_based__ = {}; + } + + if (category) { + if (typeof promisedSettings.__permission_based__[category] === 'undefined') { + promisedSettings.__permission_based__[category] = {}; + } + + _.set(promisedSettings, `__permission_based__.${category}.${key}`, withoutDefaults ? await this.getPermissionBasedSettingsValue(key, false) : [await this.getPermissionBasedSettingsValue(key, false), defaultValue]); + } else { + _.set(promisedSettings, `__permission_based__.${key}`, withoutDefaults ? await this.getPermissionBasedSettingsValue(key, false) : [await this.getPermissionBasedSettingsValue(key, false), defaultValue]); + } + } + + // add command permissions + if (this._commands.length > 0) { + promisedSettings._permissions = {}; + for (const command of this._commands) { + const name = typeof command === 'string' ? command : command.name; + const pItem = await PermissionCommands.findOneBy({ name }); + if (pItem) { + promisedSettings._permissions[name] = pItem.permission; + } else { + promisedSettings._permissions[name] = command.permission; + } + } + } + + // add status info + promisedSettings.enabled = this._enabled; + + // check ui ifs + const ui: InterfaceSettings.UI = _.cloneDeep(this._ui); + for (const categoryKey of Object.keys(promisedSettings)) { + if (ui[categoryKey]) { + for (const key of Object.keys(ui[categoryKey])) { + if (typeof (ui as any)[categoryKey][key].if === 'function' && !(ui as any)[categoryKey][key].if()) { + delete promisedSettings[categoryKey][key]; + } + } + } + } + + return promisedSettings; + } + + public async parsers() { + if (!this.enabled) { + return []; + } + + const parsers: { + this: any; + name: string; + fnc: (opts: ParserOptions) => any; + permission: string; + priority: number; + fireAndForget: boolean; + skippable: boolean; + }[] = []; + for (const parser of this._parsers) { + parser.permission = typeof parser.permission !== 'undefined' ? parser.permission : defaultPermissions.VIEWERS; + parser.priority = typeof parser.priority !== 'undefined' ? parser.priority : 3 /* constants.LOW */; + + if (_.isNil(parser.name)) { + throw Error('Parsers name must be defined'); + } + + if (typeof parser.dependsOn !== 'undefined') { + for (const dependency of parser.dependsOn) { + // skip parser if dependency is not enabled + if (!_.isFunction(dependency.status) || !(await dependency.status())) { + continue; + } + } + } + + parsers.push({ + this: this, + name: `${this.__moduleName__}.${parser.name}`, + fnc: (this as any)[parser.name], + permission: parser.permission, + priority: parser.priority, + skippable: parser.skippable ? parser.skippable : false, + fireAndForget: parser.fireAndForget ? parser.fireAndForget : false, + }); + } + return parsers; + } + + public async rollbacks() { + if (!this.enabled) { + return []; + } + + const rollbacks: { + this: any; + name: string; + fnc: (opts: ParserOptions) => any; + }[] = []; + for (const rollback of this._rollback) { + if (_.isNil(rollback.name)) { + throw Error('Rollback name must be defined'); + } + + rollbacks.push({ + this: this, + name: `${this.__moduleName__}.${rollback.name}`, + fnc: (this as any)[rollback.name], + }); + } + return rollbacks; + } + + public async commands() { + if (this.enabled) { + const commands: { + this: any; + id: string; + command: string; + fnc: (opts: CommandOptions) => void; + _fncName: string; + permission: string | null; + isHelper: boolean; + }[] = []; + for (const command of this._commands) { + if (_.isNil(command.name)) { + throw Error('Command name must be defined'); + } + + // if fnc is not set + if (typeof command.fnc === 'undefined') { + command.fnc = 'main'; + if (command.name.split(' ').length > 1) { + command.fnc = ''; + const _fnc = command.name.split(' ')[1].split('-'); + for (const part of _fnc) { + if (command.fnc.length === 0) { + command.fnc = part; + } else { + command.fnc = command.fnc + part.charAt(0).toUpperCase() + part.slice(1); + } + } + } + } + + if (command.dependsOn) { + for (const dependency of command.dependsOn) { + // skip command if dependency is not enabled + if (!_.isFunction(dependency.status) || !(await dependency.status())) { + continue; + } + } + } + + command.permission = typeof command.permission === 'undefined' ? defaultPermissions.VIEWERS : command.permission; + command.command = typeof command.command === 'undefined' ? command.name : command.command; + commands.push({ + this: this, + id: command.name, + command: command.command, + fnc: (this as any)[command.fnc], + _fncName: command.fnc, + permission: command.permission, + isHelper: command.isHelper ? command.isHelper : false, + }); + } + + return commands; + } else { + return []; + } + } + + public async getUI() { + // we need to go through all ui and trigger functions and delete attr if false + const ui: InterfaceSettings.UI = _.cloneDeep(this._ui); + for (const [k, v] of Object.entries(ui)) { + if (typeof v !== 'undefined' && typeof v !== 'boolean') { + if (typeof v.type !== 'undefined') { + // final object + if (typeof v.if === 'function') { + if (!v.if()) { + delete ui[k]; + } + } + + if (v.type === 'selector') { + if (typeof v.values === 'function') { + v.values = v.values(); + } + } + } else { + for (const [k2, v2] of Object.entries(v)) { + if (typeof v2 !== 'undefined') { + if (typeof (v2 as any).if === 'function') { + if (!(v2 as any).if()) { + delete (ui as any)[k][k2]; + } + } + if (typeof (v2 as any).values === 'function') { + (v2 as any).values = (v2 as any).values(); + } + } + } + } + } + } + return ui; + } + + /* + * Returns updated value of command if changed by user + * @param command - default command to serach + */ + public getCommand(command: string): string { + const c = this._commands.find((o) => o.name === command); + if (c && c.command) { + return c.command; + } else { + return command; + } + } + + protected async loadCommand(command: string): Promise { + const cmd = await AppDataSource.getRepository(Settings) + .createQueryBuilder('settings') + .select('settings') + .where('namespace = :namespace', { namespace: this.nsp }) + .andWhere('name = :name', { name: 'commands.' + command }) + .getOne(); + + if (cmd) { + const c = this._commands.find((o) => o.name === command); + if (c) { + c.command = JSON.parse(cmd.value); + } + } else { + const c = this._commands.find((o) => o.name === command); + if (c) { + c.command = c.name; + } + } + } + + /** + * + */ + async setCommand(command: string, updated: string): Promise { + const c = this._commands.find((o) => o.name === command); + if (c) { + if (c.name === updated) { + // default value + await AppDataSource.getRepository(Settings).delete({ + namespace: this.nsp, + name: 'commands.' + command, + }); + delete c.command; + } else { + c.command = updated; + const savedCommand = await AppDataSource.getRepository(Settings).findOneBy({ + namespace: this.nsp, + name: 'commands.' + command, + }); + await AppDataSource.getRepository(Settings).save({ + ...savedCommand, + namespace: this.nsp, + name: 'commands.' + command, + value: JSON.stringify(updated), + }); + } + } else { + warning(`Command ${command} cannot be updated to ${updated}`); + } + } + + protected async getPermissionBasedSettingsValue(key: string, set_default_values = true): Promise<{[permissionId: string]: any}> { + // current permission settings by user + let permId: string = defaultPermissions.VIEWERS; + + // get current full list of permissions + const permissions = await PermissionsEntity.find({ + cache: true, + order: { order: 'DESC' }, + }); + return permissions.reduce((prev, p) => { + // set proper value for permId or default value + if (set_default_values || p.id === defaultPermissions.VIEWERS) { + if (p.id === defaultPermissions.VIEWERS) { + // set default value if viewers + permId = p.id; + return { ...prev, [p.id]: _.get(this, `__permission_based__${key}.${p.id}`, (this as any)[key]) }; + } else { + // set value of permission before if anything else (to have proper waterfall inheriting) + // we should have correct values as we are desc ordering + const value = _.get(this, `__permission_based__${key}.${p.id}`, null); + if (value === null) { + const prevId = permId; + permId = p.id; + return { ...prev, [p.id]: _.get(prev, prevId, _.get(this, `__permission_based__${key}.${p.id}`, (this as any)[key])) }; + } else { + permId = p.id; + return { ...prev, [p.id]: _.get(this, `__permission_based__${key}.${p.id}`, value) }; + } + } + } else { + return { ...prev, [p.id]: _.get(this, `__permission_based__${key}.${p.id}`, null) }; + } + }, {}); + } +} + +export default Module; +export { Module }; diff --git a/backend/src/commons.ts b/backend/src/commons.ts new file mode 100644 index 000000000..f3bfa2b85 --- /dev/null +++ b/backend/src/commons.ts @@ -0,0 +1,98 @@ +import { TextChannel } from 'discord.js'; + +import { timer } from '~/decorators.js'; +import { prepare } from '~/helpers/commons/prepare.js'; +import { sendMessage } from '~/helpers/commons/sendMessage.js'; +import { chatOut, warning } from '~/helpers/log.js'; +import Discord from '~/integrations/discord.js'; +import { Message } from '~/message.js'; + +const isParserOpts = (opts: ParserOptions | { isParserOptions?: boolean, id: string, sender: CommandOptions['sender']; attr?: CommandOptions['attr'] }): opts is ParserOptions => { + return typeof opts.isParserOptions !== 'undefined'; +}; + +// We need to add it to class to be able to use @timer decorator +class Commons { + @timer() + async messageToSend(senderObject: any, response: string | Promise, opts: ParserOptions | { isParserOptions?: boolean, id: string, discord: CommandOptions['discord']; sender: CommandOptions['sender']; attr?: CommandOptions['attr'] }): Promise { + const sender = opts.discord + ? { ...senderObject, discord: { author: opts.discord.author, channel: opts.discord.channel } } : senderObject; + if (isParserOpts(opts) ? opts.skip : opts.attr?.skip) { + return prepare(await response as string, { ...opts, sender, forceWithoutAt: typeof opts.discord !== 'undefined' }, false); + } else { + return new Message(await response as string).parse({ ...opts, sender, forceWithoutAt: typeof opts.discord !== 'undefined' }); + } + } + + @timer() + async parserReply(response: string | Promise, opts: ParserOptions | { isParserOptions?: boolean, id: string, discord: CommandOptions['discord'], sender: CommandOptions['sender']; attr?: CommandOptions['attr'] }, messageType: 'chat' | 'whisper' = 'chat') { + if (!opts.sender) { + return; + } + const messageToSend = await this.messageToSend(opts.sender, response, opts); + if (opts.discord) { + if (Discord.client) { + if (messageType === 'chat') { + const msg = await opts.discord.channel.send(messageToSend); + chatOut(`#${(opts.discord.channel as TextChannel).name}: ${messageToSend} [${Discord.client.user?.tag}]`); + if (Discord.deleteMessagesAfterWhile) { + setTimeout(() => { + msg.delete().catch(() => { + return; + }); + }, 10000); + } + } else { + opts.discord.author.send(messageToSend); + } + } else { + warning('Discord client is not connected'); + } + } else { + // we skip as we are already parsing message + sendMessage(messageToSend, opts.sender, { skip: true, ...(isParserOpts(opts) ? {} : opts.attr ) }, isParserOpts(opts) && opts.forbidReply ? undefined : opts.id); + } + } +} +const commons = new Commons(); + +export async function parserReply(response: string | Promise, opts: ParserOptions | { isParserOptions?: boolean, id: string, sender: CommandOptions['sender']; discord: CommandOptions['discord']; attr?: CommandOptions['attr'] }, messageType: 'chat' | 'whisper' = 'chat') { + commons.parserReply(response, opts, messageType); +} + +/** + * Return diff object + * @param x timestamp ms + * @param y timestamp ms + */ +export function dateDiff(x: number, y: number) { + let diff; + + if (x > y) { + diff = x - y; + } else { + diff = y - x; + } + + const years = Math.floor(diff / (1000 * 60 * 60 * 24 * 365)); + diff = diff - (years * 1000 * 60 * 60 * 24 * 365); + + const months = Math.floor(diff / (1000 * 60 * 60 * 24 * 30)); + diff = diff - (months * 1000 * 60 * 60 * 24 * 30); + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + diff = diff - (days * 1000 * 60 * 60 * 24); + + const hours = Math.floor(diff / (1000 * 60 * 60)); + diff = diff - (hours * 1000 * 60 * 60); + + const minutes = Math.floor(diff / (1000 * 60)); + + return { + years, + months, + days, + hours, + minutes, + }; +} \ No newline at end of file diff --git a/backend/src/currency.ts b/backend/src/currency.ts new file mode 100644 index 000000000..799bf7885 --- /dev/null +++ b/backend/src/currency.ts @@ -0,0 +1,54 @@ +import chalk from 'chalk'; +import _ from 'lodash-es'; + +import currentRates from './helpers/currency/rates.js'; + +import Core from '~/_interface.js'; +import { Currency as CurrencyType, UserTip } from '~/database/entity/user.js'; +import { AppDataSource } from '~/database.js'; +import { + onChange, onLoad, +} from '~/decorators/on.js'; +import { settings, ui } from '~/decorators.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import { info } from '~/helpers/log.js'; + +class Currency extends Core { + mainCurrencyLoaded = false; + + @settings('currency') + @ui({ + type: 'selector', + values: Object.keys(currentRates), + }) + public mainCurrency: CurrencyType = 'EUR'; + + public timeouts: any = {}; + + public isCodeSupported(code: CurrencyType) { + return code === 'USD' || !_.isNil(currentRates[code]); + } + + @onLoad('mainCurrency') + @onChange('mainCurrency') + setMainCurrencyLoaded() { + this.mainCurrencyLoaded = true; + mainCurrency.value = this.mainCurrency; + } + + @onChange('mainCurrency') + public async recalculateSortAmount() { + info(chalk.yellow('CURRENCY:') + ' Recalculating tips (in progress).'); + const result = await AppDataSource.getRepository(UserTip).find(); + for (const tip of result) { + await AppDataSource.getRepository(UserTip).save({ + ...tip, + sortAmount: exchange(tip.amount, tip.currency as CurrencyType, this.mainCurrency, tip.exchangeRates), + }); + } + info(chalk.yellow('CURRENCY:') + ' Recalculating tips (completed).'); + } +} + +export default new Currency(); diff --git a/backend/src/customvariables.ts b/backend/src/customvariables.ts new file mode 100644 index 000000000..92cbabe7d --- /dev/null +++ b/backend/src/customvariables.ts @@ -0,0 +1,131 @@ +import { setTimeout } from 'timers'; + +import { isNil, merge } from 'lodash-es'; + +import { isValidationError } from './helpers/errors.js'; +import { eventEmitter } from './helpers/events/index.js'; +import getBotUserName from './helpers/user/getBotUserName.js'; +import { Types } from './plugins/ListenTo.js'; + +import Core from '~/_interface.js'; +import { + Variable, VariableWatch, +} from '~/database/entity/variable.js'; +import { AppDataSource } from '~/database.js'; +import { onStartup } from '~/decorators/on.js'; +import { runScript, updateWidgetAndTitle } from '~/helpers/customvariables/index.js'; +import { isDbConnected } from '~/helpers/database.js'; +import { adminEndpoint } from '~/helpers/socket.js'; + +class CustomVariables extends Core { + timeouts: { + [x: string]: NodeJS.Timeout; + } = {}; + + @onStartup() + onStartup() { + this.addMenu({ + category: 'registry', name: 'customvariables', id: 'registry/customvariables', this: null, + }); + this.checkIfCacheOrRefresh(); + } + + sockets () { + adminEndpoint('/core/customvariables', 'customvariables::list', async (cb) => { + const variables = await Variable.find(); + cb(null, variables); + }); + adminEndpoint('/core/customvariables', 'customvariables::runScript', async (id, cb) => { + try { + const item = await Variable.findOneBy({ id: String(id) }); + if (!item) { + throw new Error('Variable not found'); + } + const newCurrentValue = await runScript(item.evalValue, { + sender: null, _current: item.currentValue, isUI: true, + }); + item.runAt = new Date().toISOString(); + item.currentValue = newCurrentValue; + + cb(null, await item.save()); + } catch (e: any) { + cb(e.stack, null); + } + }); + adminEndpoint('/core/customvariables', 'customvariables::testScript', async (opts, cb) => { + let returnedValue; + try { + returnedValue = await runScript(opts.evalValue, { + isUI: true, _current: opts.currentValue, sender: { + userName: 'testuser', userId: '0', source: 'twitch', + }, + }); + } catch (e: any) { + cb(e.stack, null); + } + cb(null, returnedValue); + }); + adminEndpoint('/core/customvariables', 'customvariables::isUnique', async ({ variable, id }, cb) => { + cb(null, (await Variable.find({ where: { variableName: String(variable) } })).filter(o => o.id !== id).length === 0); + }); + adminEndpoint('/core/customvariables', 'customvariables::delete', async (id, cb) => { + const item = await Variable.findOneBy({ id: String(id) }); + if (item) { + await Variable.remove(item); + await AppDataSource.getRepository(VariableWatch).delete({ variableId: String(id) }); + updateWidgetAndTitle(); + } + if (cb) { + cb(null); + } + }); + adminEndpoint('/core/customvariables', 'customvariables::save', async (item, cb) => { + try { + const itemToSave = new Variable(); + merge(itemToSave, item); + await itemToSave.validateAndSave(); + updateWidgetAndTitle(itemToSave.variableName); + eventEmitter.emit(Types.CustomVariableOnChange, itemToSave.variableName, itemToSave.currentValue, null); + cb(null, itemToSave.id); + } catch (e) { + if (e instanceof Error) { + cb(e.message, null); + } + if (isValidationError(e)) { + cb(e, null); + } + } + }); + } + + async checkIfCacheOrRefresh () { + if (!isDbConnected) { + setTimeout(() => this.checkIfCacheOrRefresh(), 1000); + return; + } + + clearTimeout(this.timeouts[`${this.constructor.name}.checkIfCacheOrRefresh`]); + const items = await Variable.find({ where: { type: 'eval' } }); + + for (const item of items) { + try { + item.runAt = isNil(item.runAt) ? new Date().toISOString() : item.runAt; + const shouldRun = item.runEvery > 0 && Date.now() - new Date(item.runAt).getTime() >= item.runEvery; + if (shouldRun) { + const newValue = await runScript(item.evalValue, { + _current: item.currentValue, sender: getBotUserName(), isUI: false, + }); + item.runAt = new Date().toISOString(); + item.currentValue = newValue; + await Variable.save(item); + await updateWidgetAndTitle(item.variableName); + } + } catch (e: any) { + continue; + } // silence errors + } + this.timeouts[`${this.constructor.name}.checkIfCacheOrRefresh`] = setTimeout(() => this.checkIfCacheOrRefresh(), 1000); + } +} + +export default new CustomVariables(); diff --git a/backend/src/dashboard.ts b/backend/src/dashboard.ts new file mode 100644 index 000000000..9a3d7fb0a --- /dev/null +++ b/backend/src/dashboard.ts @@ -0,0 +1,25 @@ +import { v4 } from 'uuid'; + +import Core from '~/_interface.js'; +import { settings } from '~/decorators.js'; + +class Dashboard extends Core { + @settings() + µWidgets = [ + 'twitch|status|' + v4(), + 'twitch|uptime|' + v4(), + 'twitch|viewers|' + v4(), + 'twitch|maxViewers|' + v4(), + 'twitch|newChatters|' + v4(), + 'twitch|chatMessages|' + v4(), + 'twitch|followers|' + v4(), + 'twitch|subscribers|' + v4(), + 'twitch|bits|' + v4(), + 'general|tips|' + v4(), + 'twitch|watchedTime|' + v4(), + 'general|currentSong|' + v4(), + ]; +} + +const dashboard = new Dashboard(); +export default dashboard; \ No newline at end of file diff --git a/backend/src/data/.env.mysql b/backend/src/data/.env.mysql new file mode 100644 index 000000000..e8bd7b83b --- /dev/null +++ b/backend/src/data/.env.mysql @@ -0,0 +1,6 @@ +TYPEORM_CONNECTION=mysql +TYPEORM_HOST=localhost +TYPEORM_PORT=3306 +TYPEORM_USERNAME=root +TYPEORM_PASSWORD=Passw0rd +TYPEORM_DATABASE=sogebot \ No newline at end of file diff --git a/backend/src/data/.env.postgres b/backend/src/data/.env.postgres new file mode 100644 index 000000000..df8f189f7 --- /dev/null +++ b/backend/src/data/.env.postgres @@ -0,0 +1,6 @@ +TYPEORM_CONNECTION=postgres +TYPEORM_HOST=localhost +TYPEORM_PORT=5432 +TYPEORM_USERNAME=postgres +TYPEORM_PASSWORD=postgres +TYPEORM_DATABASE=sogebot \ No newline at end of file diff --git a/backend/src/data/.env.sqlite b/backend/src/data/.env.sqlite new file mode 100644 index 000000000..91433daf5 --- /dev/null +++ b/backend/src/data/.env.sqlite @@ -0,0 +1,2 @@ +TYPEORM_CONNECTION=better-sqlite3 +TYPEORM_DATABASE=./sogebot.db \ No newline at end of file diff --git a/backend/src/database.ts b/backend/src/database.ts new file mode 100644 index 000000000..1451ef285 --- /dev/null +++ b/backend/src/database.ts @@ -0,0 +1,72 @@ +import { DataSource, DataSourceOptions } from 'typeorm'; + +import { warning } from './helpers/log.js'; + +import { TypeORMLogger } from '~/helpers/logTypeorm.js'; + +if (process.env.FORCE_DB_SYNC === 'IKnowWhatIamDoing') { + setTimeout(() => { + warning('FORCE_DB_SYNC is enabled! Are you sure this should be enabled?'); + }, 5000); +} + +const MySQLDataSourceOptions = { + type: 'mysql', + connectTimeout: 60000, + acquireTimeout: 120000, + host: process.env.TYPEORM_HOST, + port: Number(process.env.TYPEORM_PORT ?? 3306), + username: process.env.TYPEORM_USERNAME, + password: process.env.TYPEORM_PASSWORD, + database: process.env.TYPEORM_DATABASE, + logging: ['error'], + logger: new TypeORMLogger(), + synchronize: process.env.FORCE_DB_SYNC === 'IKnowWhatIamDoing', + migrationsRun: true, + entities: [ 'dest/database/entity/*.js' ], + subscribers: [ 'dest/database/entity/*.js' ], + migrations: [ `dest/database/migration/mysql/**/*.js` ], +} satisfies DataSourceOptions; + +const PGDataSourceOptions = { + type: 'postgres', + host: process.env.TYPEORM_HOST, + port: Number(process.env.TYPEORM_PORT ?? 3306), + username: process.env.TYPEORM_USERNAME, + password: process.env.TYPEORM_PASSWORD, + database: process.env.TYPEORM_DATABASE, + logging: ['error'], + logger: new TypeORMLogger(), + synchronize: process.env.FORCE_DB_SYNC === 'IKnowWhatIamDoing', + migrationsRun: true, + entities: [ 'dest/database/entity/*.js' ], + subscribers: [ 'dest/database/entity/*.js' ], + migrations: [ `dest/database/migration/postgres/**/*.js` ], +} satisfies DataSourceOptions; + +const SQLiteDataSourceOptions = { + type: 'better-sqlite3', + database: process.env.TYPEORM_DATABASE ?? 'sogebot.db', + logging: ['error'], + logger: new TypeORMLogger(), + synchronize: process.env.FORCE_DB_SYNC === 'IKnowWhatIamDoing', + migrationsRun: true, + entities: [ 'dest/database/entity/*.js' ], + subscribers: [ 'dest/database/entity/*.js' ], + migrations: [ `dest/database/migration/sqlite/**/*.js` ], +} satisfies DataSourceOptions; + +let AppDataSource: DataSource; +if (process.env.TYPEORM_CONNECTION === 'mysql' || process.env.TYPEORM_CONNECTION === 'mariadb') { + AppDataSource = new DataSource(MySQLDataSourceOptions); +} else if (process.env.TYPEORM_CONNECTION === 'postgres') { + AppDataSource = new DataSource(PGDataSourceOptions); +} else { + AppDataSource = new DataSource(SQLiteDataSourceOptions); +} + +if (typeof (global as any).it === 'function') { + console.log(AppDataSource.options); +} + +export { AppDataSource }; \ No newline at end of file diff --git a/backend/src/database/BotEntity.ts b/backend/src/database/BotEntity.ts new file mode 100644 index 000000000..03cf4606d --- /dev/null +++ b/backend/src/database/BotEntity.ts @@ -0,0 +1,19 @@ +import { validateOrReject } from 'class-validator'; +import { BaseEntity } from 'typeorm'; + +export class BotEntity extends BaseEntity { + validateAndSave() { + return new Promise((resolve, reject) => { + validateOrReject(this) + .then(() => { + this.save() + .then(resolve) + .catch(reject); + }) + .catch(reject); + }); + } + validate() { + return validateOrReject(this); + } +} \ No newline at end of file diff --git a/backend/src/database/entity/__init__.ts b/backend/src/database/entity/__init__.ts new file mode 100644 index 000000000..0b172b7ba --- /dev/null +++ b/backend/src/database/entity/__init__.ts @@ -0,0 +1 @@ +import 'dotenv/config'; diff --git a/backend/src/database/entity/_transformer.ts b/backend/src/database/entity/_transformer.ts new file mode 100644 index 000000000..4563191bc --- /dev/null +++ b/backend/src/database/entity/_transformer.ts @@ -0,0 +1,38 @@ +/// ColumnNumericTransformer +export class ColumnNumericTransformer { + to(data: number): number { + return data; + } + from(data: string): number { + return parseFloat(data); + } +} + +export class ColumnStringTransformer { + to(data: string): string { + return data; + } + from(data: number): string { + return String(data).trim(); + } +} + +export class SafeNumberTransformer { + to(data: number | undefined): number | undefined { + if (data) { + return data <= Number.MAX_SAFE_INTEGER + ? data + : Number.MAX_SAFE_INTEGER; + } else { + return data; + } + } + from(data: number | string): number { + if (typeof data === 'string') { + data = parseFloat(data); + } + return data <= Number.MAX_SAFE_INTEGER + ? data + : Number.MAX_SAFE_INTEGER; + } +} \ No newline at end of file diff --git a/backend/src/database/entity/alert.ts b/backend/src/database/entity/alert.ts new file mode 100644 index 000000000..bd4eb63b3 --- /dev/null +++ b/backend/src/database/entity/alert.ts @@ -0,0 +1,262 @@ +import { IsNotEmpty, MinLength } from 'class-validator'; +import { BeforeInsert, BeforeUpdate } from 'typeorm'; +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +import { BotEntity } from '../BotEntity.js'; + +export interface EmitData { + alertId?: string; + name: string; + amount: number; + tier: null | 'Prime' | '1' | '2' | '3'; + recipient?: string; + game?: string; + service?: string; + rewardId?: string; + currency: string; + monthsName: string; + event: Alert['items'][number]['type']; + message: string; + customOptions?: { + volume?: number; + alertDuration? : number; + textDelay? : number; + layout? : number; + messageTemplate? : string; + audioId? : string; + mediaId? : string; + + animationIn?: string; + animationInDuration?: number; + animationInWindowBoundaries?: boolean; + + animationOut?: string; + animationOutDuration?: number; + animationOutWindowBoundaries?: boolean; + + animationText?: any; + animationTextOptions?: any; + + components?: { + [componentId: string]: any + } + } +} + +export type Filter = { + operator: string; + items: (Filter | { + comparator: string; + value: string | number; + type: string; + typeof: string; + })[] +} | null; + +type Item = { + id: string; + type: T, + alertId?: string; + enabled: boolean; + title: string; + variantAmount: number; + messageTemplate: string; + ttsTemplate: string; + layout: '0' | '1' | '2' | '3' | '4' | '5'; + /* + * JSON type of Filter + */ + filter: string | null; + animationInDuration: number; + animationIn: 'none' | 'fadeIn' | 'fadeInDown' | 'fadeInLeft' | 'fadeInRight' + | 'fadeInUp' | 'fadeInDownBig' | 'fadeInLeftBig' | 'fadeInRightBig' + | 'fadeInUpBig' | 'bounceIn' | 'bounceInDown' | 'bounceInLeft' + | 'bounceInRight' | 'bounceInUp' | 'flipInX' | 'flipInY' | 'lightSpeedIn' + | 'rotateIn' | 'rotateInDownLeft' | 'rotateInDownRight' | 'rotateInUpLeft' + | 'rotateInUpRight' | 'slideInDown' | 'slideInLeft' | 'slideInRight' + | 'slideInUp' | 'zoomIn' | 'zoomInDown' | 'zoomInLeft' | 'zoomInRight' + | 'zoomInUp' | 'rollIn' | 'jackInTheBox'; + animationOutDuration: number; + animationOut: 'none' | 'fadeOut' | 'fadeOutDown' | 'fadeOutLeft' | 'fadeOutRight' | 'fadeOutUp' + | 'fadeOutDownBig' | 'fadeOutLeftBig' | 'fadeOutRightBig' | 'fadeOutUpBig' + | 'bounceOut' | 'bounceOutDown' | 'bounceOutLeft' | 'bounceOutRight' + | 'bounceOutUp' | 'flipOutX' | 'flipOutY' | 'lightSpeedOut' | 'rotateOut' + | 'rotateOutDownLeft' | 'rotateOutDownRight' | 'rotateOutUpLeft' + | 'rotateOutUpRight' | 'slideOutDown' | 'slideOutLeft' | 'slideOutRight' + | 'slideOutUp' | 'zoomOut' | 'zoomOutDown' | 'zoomOutLeft' | 'zoomOutRight' + | 'zoomOutUp' | 'rollOut'; + animationText: 'none' | 'baffle' | 'bounce' | 'bounce2' | 'flip' | 'flash' | 'pulse2' | 'rubberBand' + | 'shake2' | 'swing' | 'tada' | 'wave' | 'wobble' | 'wiggle' | 'wiggle2' | 'jello' | 'typewriter'; + animationTextOptions: { + speed: number | 'slower' | 'slow' | 'fast' | 'faster'; + maxTimeToDecrypt: number; + characters: string; + }; + imageId: string | null; + imageOptions: { + translateX: number; + translateY: number; + scale: number; + loop: boolean; + }; + soundId: string | null; + soundVolume: number; + tts: { + enabled: boolean; + skipUrls: boolean | null; + keepAlertShown: boolean; + minAmountToPlay: number | null; + }; + alertDurationInMs: number; + alertTextDelayInMs: number; + enableAdvancedMode: boolean; + advancedMode: { + html: null | string; + css: string; + js: null | string; + }; + font: { + align: 'left' | 'center' | 'right'; + family: string; + size: number; + borderPx: number; + borderColor: string; + weight: number; + color: string; + highlightcolor: string; + shadow: { + shiftRight: number; + shiftDown: number; + blur: number; + opacity: number; + color: string; + }[]; + } | null; +}; + +@Entity() +export class Alert extends BotEntity { + @PrimaryColumn({ generated: 'uuid' }) + id: string; + + @Column({ nullable: true, type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length }) + updatedAt: string | null; + @BeforeInsert() + @BeforeUpdate() + generateUpdatedAt() { + this.updatedAt = new Date().toISOString(); + } + + @Column() + @IsNotEmpty() + @MinLength(3) + name: string; + + @Column() + alertDelayInMs: number; + + @Column() + profanityFilterType: 'disabled' | 'replace-with-asterisk' | 'replace-with-happy-words' | 'hide-messages' | 'disable-alerts'; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + loadStandardProfanityList: { + cs: boolean; + en: boolean; + ru: boolean; + }; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + parry: { + enabled: boolean, + delay: number, + }; + + @Column({ nullable: true, type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + tts: { + voice: string; + pitch: number; + volume: number; + rate: number; + } | null; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + fontMessage: { + align: 'left' | 'center' | 'right'; + family: string; + size: number; + borderPx: number; + borderColor: string; + weight: number; + color: string; + shadow: { + shiftRight: number; + shiftDown: number; + blur: number; + opacity: number; + color: string; + }[] + }; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + font: { + align: 'left' | 'center' | 'right'; + family: string; + size: number; + borderPx: number; + borderColor: string; + weight: number; + color: string; + highlightcolor: string; + shadow: { + shiftRight: number; + shiftDown: number; + blur: number; + opacity: number; + color: string; + }[]; + }; + @Column() + customProfanityList: string; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + items: ( + Item<'follow'> | + Item<'sub'> | + Item<'resub'> & Message | + Item<'subgift'> | + Item<'subcommunitygift'> | + Item<'raid'> | + Item<'custom'> | + Item<'promo'> & Message | + Item<'tip'> & Message | + Item<'cheer'> & Message | + Item<'rewardredeem'> & { rewardId: null | string } + )[]; +} + +type Message = { + message: { + minAmountToShow: minAmountToShow, + allowEmotes: { + twitch: boolean; + ffz: boolean; + bttv: boolean; + }; + font: { + align: 'left' | 'center' | 'right'; + family: string; + size: number; + borderPx: number; + borderColor: string; + weight: number; + color: string; + shadow: { + shiftRight: number; + shiftDown: number; + blur: number; + opacity: number; + color: string; + }[]; + } | null; + }; +}; \ No newline at end of file diff --git a/backend/src/database/entity/alias.ts b/backend/src/database/entity/alias.ts new file mode 100644 index 000000000..56918a4bc --- /dev/null +++ b/backend/src/database/entity/alias.ts @@ -0,0 +1,49 @@ +import { IsNotEmpty, MinLength } from 'class-validator'; +import { BaseEntity, Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { IsCommand } from '../validators/IsCommand.js'; +import { IsCommandOrCustomVariable } from '../validators/IsCommandOrCustomVariable.js'; + +@Entity() +export class Alias extends BaseEntity { + @PrimaryColumn({ generated: 'uuid' }) + id: string; + + @Column() + @IsNotEmpty() + @MinLength(2) + @IsCommand() + @Index('IDX_6a8a594f0a5546f8082b0c405c') + alias: string; + + @Column({ type: 'text' }) + @IsCommandOrCustomVariable() + @MinLength(2) + @IsNotEmpty() + command: string; + + @Column() + enabled: boolean; + + @Column() + visible: boolean; + + @Column({ nullable: true, type: String }) + permission: string | null; + + @Column({ nullable: true, type: String }) + group: string | null; +} + +@Entity() +export class AliasGroup extends BaseEntity { + @PrimaryColumn() + @Index('IDX_alias_group_unique_name', { unique: true }) + name: string; + + @Column({ type: 'simple-json' }) + options: { + filter: string | null; + permission: string | null; + }; +} \ No newline at end of file diff --git a/backend/src/database/entity/cacheGames.ts b/backend/src/database/entity/cacheGames.ts new file mode 100644 index 000000000..410067659 --- /dev/null +++ b/backend/src/database/entity/cacheGames.ts @@ -0,0 +1,19 @@ +import { EntitySchema } from 'typeorm'; + +export interface CacheGamesInterface { + id?: number; + name: string; + thumbnail: string | null; +} + +export const CacheGames = new EntitySchema>>({ + name: 'cache_games', + columns: { + id: { type: Number, primary: true }, + name: { type: String }, + thumbnail: { type: String, default: null, nullable: true }, + }, + indices: [ + { name: 'IDX_f37be3c66dbd449a8cb4fe7d59', columns: ['name'] }, + ], +}); \ No newline at end of file diff --git a/backend/src/database/entity/cacheTitles.ts b/backend/src/database/entity/cacheTitles.ts new file mode 100644 index 000000000..380b35d95 --- /dev/null +++ b/backend/src/database/entity/cacheTitles.ts @@ -0,0 +1,29 @@ +import { EntitySchema } from 'typeorm'; + +import { ColumnNumericTransformer } from './_transformer.js'; + +export interface CacheTitlesInterface { + id?: number; + game: string; + title: string; + tags: string[]; + content_classification_labels: string[]; + timestamp: number; +} + +export const CacheTitles = new EntitySchema>>({ + name: 'cache_titles', + columns: { + id: { + type: Number, primary: true, generated: 'increment', + }, + game: { type: String }, + title: { type: String }, + tags: { type: 'simple-array' }, + content_classification_labels: { type: 'simple-array' }, + timestamp: { type: 'bigint', transformer: new ColumnNumericTransformer() }, + }, + indices: [ + { name: 'IDX_a0c6ce833b5b3b13325e6f49b0', columns: ['game'] }, + ], +}); \ No newline at end of file diff --git a/backend/src/database/entity/checklist.ts b/backend/src/database/entity/checklist.ts new file mode 100644 index 000000000..7ff8da32e --- /dev/null +++ b/backend/src/database/entity/checklist.ts @@ -0,0 +1,15 @@ +import { EntitySchema } from 'typeorm'; + +export interface ChecklistInterface { + id: string; isCompleted: boolean; +} + +export const Checklist = new EntitySchema>>({ + name: 'checklist', + columns: { + id: { + type: String, primary: true, + }, + isCompleted: { type: Boolean }, + }, +}); \ No newline at end of file diff --git a/backend/src/database/entity/commands.ts b/backend/src/database/entity/commands.ts new file mode 100644 index 000000000..1a2122c9e --- /dev/null +++ b/backend/src/database/entity/commands.ts @@ -0,0 +1,65 @@ +import { IsNotEmpty, MinLength } from 'class-validator'; +import { BaseEntity, Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { IsCommand } from '../validators/IsCommand.js'; + +@Entity() +export class Commands extends BaseEntity { + @PrimaryColumn({ generated: 'uuid', type: 'uuid' }) + id: string; + + @Column() + @IsNotEmpty() + @MinLength(2) + @IsCommand() + @Index('IDX_1a8c40f0a581447776c325cb4f') + command: string; + + @Column() + enabled: boolean; + + @Column() + visible: boolean; + + @Column({ nullable: true, type: String }) + group: string | null; + + @Column({ default: false }) + areResponsesRandomized: boolean; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + responses: { + id: string; + order: number; + response: string; + stopIfExecuted: boolean; + permission: string | null; + filter: string; + }[] = []; +} + +@Entity() +export class CommandsGroup extends BaseEntity { + @PrimaryColumn() + @Index('IDX_commands_group_unique_name', { unique: true }) + name: string; + + @Column({ type: 'simple-json' }) + options: { + filter: string | null; + permission: string | null; + }; +} + +@Entity() +export class CommandsCount extends BaseEntity { + @PrimaryColumn({ generated: 'uuid', type: 'uuid' }) + id: string; + + @Index('IDX_2ccf816b1dd74e9a02845c4818') + @Column() + command: string; + + @Column({ type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length }) + timestamp: string; +} \ No newline at end of file diff --git a/backend/src/database/entity/cooldown.ts b/backend/src/database/entity/cooldown.ts new file mode 100644 index 000000000..263ab02f5 --- /dev/null +++ b/backend/src/database/entity/cooldown.ts @@ -0,0 +1,38 @@ +import { IsNotEmpty, MinLength } from 'class-validator'; +import { BaseEntity, Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity() +export class Cooldown extends BaseEntity { + @PrimaryColumn({ generated: 'uuid', type: 'uuid' }) + id: string; + + @Column() + @IsNotEmpty() + @MinLength(2) + @Index('IDX_aa85aa267ec6eaddf7f93e3665', { unique: true }) + name: string; + + @Column() + miliseconds: number; + + @Column({ type: 'varchar', length: 10 }) + type: 'global' | 'user'; + + @Column({ type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length }) + timestamp: string; + + @Column() + isEnabled: boolean; + + @Column() + isErrorMsgQuiet: boolean; + + @Column() + isOwnerAffected: boolean; + + @Column() + isModeratorAffected: boolean; + + @Column() + isSubscriberAffected: boolean; +} \ No newline at end of file diff --git a/backend/src/database/entity/dashboard.ts b/backend/src/database/entity/dashboard.ts new file mode 100644 index 000000000..1d5d7af30 --- /dev/null +++ b/backend/src/database/entity/dashboard.ts @@ -0,0 +1,77 @@ +import { EntitySchema } from 'typeorm'; + +class QuickActionsDefaultAttributes { + id: string; + userId: string; + order: number; +} + +class QuickActionsDefaultOptions { + label: string; + color: string; +} + +class CommandItemOptions extends QuickActionsDefaultOptions { + command: string; +} +export class CommandItem extends QuickActionsDefaultAttributes { + type: 'command'; + options: CommandItemOptions; +} + +class CustomVariableItemOptions extends QuickActionsDefaultOptions { + customvariable: string; +} +export class CustomVariableItem extends QuickActionsDefaultAttributes { + type: 'customvariable'; + options: CustomVariableItemOptions; +} + +class RandomizerItemOptions extends QuickActionsDefaultOptions { + randomizerId: string; +} +export class RandomizerItem extends QuickActionsDefaultAttributes { + type: 'randomizer'; + options: RandomizerItemOptions; +} + +class OverlayCountdownItemOptions extends QuickActionsDefaultOptions { + countdownId: string; +} +export class OverlayCountdownItem extends QuickActionsDefaultAttributes { + type: 'overlayCountdown'; + options: OverlayCountdownItemOptions; +} + +class OverlayMarathonItemOptions extends QuickActionsDefaultOptions { + marathonId: string; +} +export class OverlayMarathonItem extends QuickActionsDefaultAttributes { + type: 'overlayMarathon'; + options: OverlayMarathonItemOptions; +} + +class OverlayStopwatchItemOptions extends QuickActionsDefaultOptions { + stopwatchId: string; +} +export class OverlayStopwatchItem extends QuickActionsDefaultAttributes { + type: 'overlayStopwatch'; + options: OverlayStopwatchItemOptions; +} + +export declare namespace QuickActions { + type Item = CommandItem | CustomVariableItem | RandomizerItem | OverlayCountdownItem | OverlayStopwatchItem | OverlayMarathonItem; +} + +export const QuickAction = new EntitySchema>>({ + name: 'quickaction', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + userId: { type: String }, + order: { type: Number }, + type: { type: String }, + options: { type: 'simple-json' }, + }, +}); \ No newline at end of file diff --git a/backend/src/database/entity/discord.ts b/backend/src/database/entity/discord.ts new file mode 100644 index 000000000..c2d879a44 --- /dev/null +++ b/backend/src/database/entity/discord.ts @@ -0,0 +1,24 @@ +import { EntitySchema } from 'typeorm'; + +import { ColumnNumericTransformer } from './_transformer.js'; + +export interface DiscordLinkInterface { + id: string | undefined; + tag: string; + discordId: string; + createdAt: number; + userId: null | string; +} + +export const DiscordLink = new EntitySchema>>({ + name: 'discord_link', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + tag: { type: String }, + discordId: { type: String }, + createdAt: { type: 'bigint', transformer: new ColumnNumericTransformer() }, + userId: { type: String, nullable: true }, + }, +}); diff --git a/backend/src/database/entity/duel.ts b/backend/src/database/entity/duel.ts new file mode 100644 index 000000000..38f088e45 --- /dev/null +++ b/backend/src/database/entity/duel.ts @@ -0,0 +1,16 @@ +import { EntitySchema } from 'typeorm'; + +export interface DuelInterface { + id?: string; + username: string; + tickets: number; +} + +export const Duel = new EntitySchema>>({ + name: 'duel', + columns: { + id: { type: String, primary: true }, + username: { type: String }, + tickets: { type: Number }, + }, +}); \ No newline at end of file diff --git a/backend/src/database/entity/event.ts b/backend/src/database/entity/event.ts new file mode 100644 index 000000000..3bd28b0d2 --- /dev/null +++ b/backend/src/database/entity/event.ts @@ -0,0 +1,110 @@ +import { EntitySchema } from 'typeorm'; + +export declare namespace Events { + export type Event = { + id: string, + key: string, + name: string, + enabled: boolean, + triggered: any, + definitions: Events.OperationDefinitions, + }; + + export type SupportedOperation = { + id: string, + definitions: { [x: string]: string | boolean | number | string[] | boolean[] | number[] }, + fire: () => void, + }; + + export type SupportedEvent = { + id: string, + definitions: Events.OperationDefinitions, + variables: string[], + }; + + export type Filter = { + eventId: string, + filters: string, + }; + + export type Operation = { + key: string, + eventId: string, + definitions: OperationDefinitions, + }; + + type OperationDefinitions = { + [x: string]: string | boolean | number; + }; + + type Attributes = { + userId?: string, + username?: string, + reset?: boolean, + [x: string]: any, + }; +} +export interface EventInterface { + id?: string; + operations: Omit[]; + name: string; + isEnabled: boolean; + triggered: any; + definitions: Events.OperationDefinitions; + filter: string; +} + +export interface EventOperationInterface { + id?: string; + event: EventInterface; + name: string; + definitions: Events.OperationDefinitions; +} + +export const Event = new EntitySchema>>({ + name: 'event', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + name: { type: String }, + isEnabled: { type: Boolean }, + triggered: { type: 'simple-json' }, + definitions: { type: 'simple-json' }, + filter: { type: String }, + }, + relations: { + operations: { + type: 'one-to-many', + target: 'event_operation', + inverseSide: 'event', + cascade: true, + }, + }, + indices: [ + { name: 'IDX_b535fbe8ec6d832dde22065ebd', columns: ['name'] }, + ], +}); + +export const EventOperation = new EntitySchema>>({ + name: 'event_operation', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + name: { type: String }, + definitions: { type: 'simple-json' }, + }, + relations: { + event: { + type: 'many-to-one', + target: 'event', + inverseSide: 'operations', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + }, + indices: [ + { name: 'IDX_daf6b97e1e5a5c779055fbb22d', columns: ['name'] }, + ], +}); \ No newline at end of file diff --git a/backend/src/database/entity/eventList.ts b/backend/src/database/entity/eventList.ts new file mode 100644 index 000000000..2d6f3b48c --- /dev/null +++ b/backend/src/database/entity/eventList.ts @@ -0,0 +1,31 @@ +import { EntitySchema } from 'typeorm'; + +import { ColumnNumericTransformer } from './_transformer.js'; + +export interface EventListInterface { + id?: string; + event: string; + userId: string; + timestamp: number; + isTest: boolean; + isHidden?: boolean; + values_json: string; +} + +export const EventList = new EntitySchema>({ + name: 'event_list', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + event: { type: String }, + userId: { type: String }, + timestamp: { type: 'bigint', transformer: new ColumnNumericTransformer() }, + isTest: { type: 'boolean' }, + isHidden: { type: 'boolean', default: false }, + values_json: { type: 'text' }, + }, + indices: [ + { name: 'IDX_8a80a3cf6b2d815920a390968a', columns: ['userId'] }, + ], +}); \ No newline at end of file diff --git a/backend/src/database/entity/gallery.ts b/backend/src/database/entity/gallery.ts new file mode 100644 index 000000000..dd8727b50 --- /dev/null +++ b/backend/src/database/entity/gallery.ts @@ -0,0 +1,22 @@ +import { EntitySchema } from 'typeorm'; + +export interface GalleryInterface { + id?: string; + type: string; + data: string; + name: string; + folder: string; +} + +export const Gallery = new EntitySchema>>({ + name: 'gallery', + columns: { + id: { + type: String, primary: true, + }, + type: { type: String }, + data: { type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 'longtext' : 'text' }, + name: { type: String }, + folder: { type: String, default: '/' }, + }, +}); \ No newline at end of file diff --git a/backend/src/database/entity/google.ts b/backend/src/database/entity/google.ts new file mode 100644 index 000000000..e2e59c9d3 --- /dev/null +++ b/backend/src/database/entity/google.ts @@ -0,0 +1,20 @@ +import { EntitySchema } from 'typeorm'; + +export class GooglePrivateKeysInterface { + id: string | undefined; + clientEmail: string; + privateKey: string; + createdAt: string; +} + +export const GooglePrivateKeys = new EntitySchema>>({ + name: 'google_private_keys', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + clientEmail: { type: String }, + privateKey: { type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 'longtext' : 'text' }, + createdAt: { type: String }, + }, +}); diff --git a/backend/src/database/entity/heist.ts b/backend/src/database/entity/heist.ts new file mode 100644 index 000000000..5b2684311 --- /dev/null +++ b/backend/src/database/entity/heist.ts @@ -0,0 +1,18 @@ +import { EntitySchema } from 'typeorm'; + +import { ColumnNumericTransformer } from './_transformer.js'; + +export interface HeistUserInterface { + userId: string; + username: string; + points: number; +} + +export const HeistUser = new EntitySchema>>({ + name: 'heist_user', + columns: { + userId: { type: String, primary: true }, + username: { type: String }, + points: { type: 'bigint', transformer: new ColumnNumericTransformer() }, + }, +}); \ No newline at end of file diff --git a/backend/src/database/entity/highlight.ts b/backend/src/database/entity/highlight.ts new file mode 100644 index 000000000..81deb2d59 --- /dev/null +++ b/backend/src/database/entity/highlight.ts @@ -0,0 +1,33 @@ +import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm'; +import { BotEntity } from '../BotEntity.js'; + +@Entity() +export class Highlight extends BotEntity { + @PrimaryColumn({ generated: 'uuid', type: 'uuid' }) + id: string; + + @Column() + videoId: string; + + @Column() + game: string; + + @Column() + title: string; + + @Column({ default: false }) + expired: boolean; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + timestamp: { + hours: number; minutes: number; seconds: number; + }; + + @Column({ nullable: false, type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length }) + createdAt?: string; + + @BeforeInsert() + generateCreatedAt() { + this.createdAt = new Date().toISOString(); + } +} \ No newline at end of file diff --git a/backend/src/database/entity/howLongToBeatGame.ts b/backend/src/database/entity/howLongToBeatGame.ts new file mode 100644 index 000000000..a0bc9e23c --- /dev/null +++ b/backend/src/database/entity/howLongToBeatGame.ts @@ -0,0 +1,56 @@ +import { BeforeInsert, BeforeUpdate, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { BotEntity } from '../BotEntity.js'; +import { IsNotEmpty, MinLength } from 'class-validator'; + +import { ColumnNumericTransformer } from './_transformer.js'; + +@Entity() +@Index('IDX_301758e0e3108fc902d5436527', ['game'], { unique: true }) +export class HowLongToBeatGame extends BotEntity { + @PrimaryColumn({ generated: 'uuid', type: 'uuid' }) + id: string; + + @Column() + @MinLength(2) + @IsNotEmpty() + game: string; + + @Column({ nullable: false, type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length }) + startedAt?: string; + + @BeforeInsert() + generateStartedAt() { + this.startedAt = new Date().toISOString(); + } + + @Column({ nullable: false, type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length }) + updatedAt?: string; + + @BeforeInsert() + @BeforeUpdate() + generateUpdatedAt() { + this.updatedAt = new Date().toISOString(); + } + + @Column({ type: 'float', default: 0, precision: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 12 : undefined }) + gameplayMain: number; + + @Column({ type: 'float', default: 0, precision: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 12 : undefined }) + gameplayMainExtra: number; + + @Column({ type: 'float', default: 0, precision: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 12 : undefined }) + gameplayCompletionist: number; + + @Column({ type: 'bigint', transformer: new ColumnNumericTransformer(), default: 0 }) + offset: number; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + streams: { + createdAt: string; + timestamp: number; + offset: number; + isMainCounted: boolean; + isCompletionistCounted: boolean; + isExtraCounted: boolean; + }[] = []; +} \ No newline at end of file diff --git a/backend/src/database/entity/keyword.ts b/backend/src/database/entity/keyword.ts new file mode 100644 index 000000000..4bde61a22 --- /dev/null +++ b/backend/src/database/entity/keyword.ts @@ -0,0 +1,61 @@ +import { IsNotEmpty, MinLength } from 'class-validator'; +import { ManyToOne, OneToMany } from 'typeorm'; +import { BaseEntity, Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +@Entity() +export class Keyword extends BaseEntity { + @PrimaryColumn({ generated: 'uuid', type: 'uuid' }) + id: string; + + @Column() + @IsNotEmpty() + @MinLength(2) + @Index('IDX_35e3ff88225eef1d85c951e229') + keyword: string; + + @Column() + enabled: boolean; + + @Column({ nullable: true, type: String }) + group: string | null; + + @OneToMany(() => KeywordResponses, (item) => item.keyword) + responses: KeywordResponses[]; +} + +@Entity() +export class KeywordResponses extends BaseEntity { + @PrimaryColumn({ generated: 'uuid', type: 'uuid' }) + id: string; + + @Column() + order: number; + + @Column({ type: 'text' }) + response: string; + + @Column() + stopIfExecuted: boolean; + + @Column({ nullable: true, type: String }) + permission: string | null; + + @Column() + filter: string; + + @ManyToOne(() => Keyword, (item) => item.responses, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + keyword: Keyword; +} + +@Entity() +export class KeywordGroup extends BaseEntity { + @PrimaryColumn() + @Index('IDX_keyword_group_unique_name', { unique: true }) + name: string; + + @Column({ type: 'simple-json' }) + options: { + filter: string | null; + permission: string | null; + }; +} \ No newline at end of file diff --git a/backend/src/database/entity/moderation.ts b/backend/src/database/entity/moderation.ts new file mode 100644 index 000000000..705f623e2 --- /dev/null +++ b/backend/src/database/entity/moderation.ts @@ -0,0 +1,49 @@ +import { EntitySchema } from 'typeorm'; + +import { ColumnNumericTransformer } from './_transformer.js'; + +export interface ModerationWarningInterface { + id?: string; + userId: string; + timestamp?: number; +} + +export interface ModerationPermitInterface { + id?: string; + userId: string; +} + +export interface ModerationMessageCooldownInterface { + id?: string; + name: string; + timestamp: number; +} + +export const ModerationWarning = new EntitySchema>>({ + name: 'moderation_warning', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + userId: { type: String }, + timestamp: { + type: 'bigint', transformer: new ColumnNumericTransformer(), default: 0, + }, + }, + indices: [ + { name: 'IDX_f941603aef2741795a9108d0d2', columns: ['userId'] }, + ], +}); + +export const ModerationPermit = new EntitySchema>>({ + name: 'moderation_permit', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + userId: { type: String }, + }, + indices: [ + { name: 'IDX_69499e78c9ee1602baee77b97d', columns: ['userId'] }, + ], +}); \ No newline at end of file diff --git a/backend/src/database/entity/obswebsocket.ts b/backend/src/database/entity/obswebsocket.ts new file mode 100644 index 000000000..912741176 --- /dev/null +++ b/backend/src/database/entity/obswebsocket.ts @@ -0,0 +1,14 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { BotEntity } from '../BotEntity.js'; + +@Entity('obswebsocket') +export class OBSWebsocket extends BotEntity { + @PrimaryColumn({ type: 'varchar', length: '14' }) + id: string; + + @Column() + name: string; + + @Column({ type: 'text' }) + code: string; +} \ No newline at end of file diff --git a/backend/src/database/entity/overlay.ts b/backend/src/database/entity/overlay.ts new file mode 100644 index 000000000..5f92aed9c --- /dev/null +++ b/backend/src/database/entity/overlay.ts @@ -0,0 +1,636 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +import { Alert, Filter } from './alert.js'; +import { BotEntity } from '../BotEntity.js'; + +// expands object types recursively +type ExpandRecursively = T extends object + ? T extends infer O ? { [K in keyof O]: ExpandRecursively } : never + : T; +type RemoveNull = { + [K in keyof T]: NonNullable; +}; +type Font = { + family: string; + size: number; + borderPx: number; + borderColor: string; + weight: number; + color: string; + shadow: { + shiftRight: number; + shiftDown: number; + blur: number; + opacity: number; + color: string; + }[]; +}; + +export interface Reference { + typeId: 'reference', + overlayId: string; + groupId: string; +} + +export interface Randomizer { + typeId: 'randomizer', +} + +export interface Chat { + typeId: 'chat'; + type: 'vertical' | 'horizontal' | 'niconico'; + hideMessageAfter: number; + showCommandMessages: boolean, + showTimestamp: boolean; + reverseOrder: boolean; + showBadges: boolean; + useCustomLineHeight: boolean; + customLineHeight: number; + useCustomBadgeSize: boolean; + customBadgeSize: number; + useCustomEmoteSize: boolean; + customEmoteSize: number; + useCustomSpaceBetweenMessages: boolean; + customSpaceBetweenMessages: number; + font: Font; + useGeneratedColors: boolean; + useCustomUsernameColor?: boolean; + usernameFont: Font | null; + separatorFont: Font | null; + separator: string; + messageBackgroundColor: string; + messagePadding: number; +} + +export interface Marathon { + typeId: 'marathon'; + disableWhenReachedZero: boolean; + showProgressGraph: boolean; + endTime: number; + maxEndTime: number | null; + showMilliseconds: boolean; + values: { + sub: { + tier1: number; + tier2: number; + tier3: number; + }, + resub: { + tier1: number; + tier2: number; + tier3: number; + }, + bits: { + /* + * true: value:bits is set to 10 and we got 15 -> 1.5x value will get added + * false: value:bits is set to 10 and we got 15 -> 1.0x value will get added + */ + addFraction: boolean; + bits: number; + time: number; + }, + tips: { + /* + * true: value:tip is set to 10 and we got 15 -> 1.5x value will get added + * false: value:tip is set to 10 and we got 15 -> 1.0x value will get added + */ + addFraction: boolean; + tips: number; + time: number; + }, + } + marathonFont: Font +} + +export interface Stopwatch { + typeId: 'stopwatch'; + currentTime: number; + isPersistent: boolean; + isStartedOnSourceLoad: boolean; + showMilliseconds: boolean; + stopwatchFont: Font +} + +export interface Wordcloud { + typeId: 'wordcloud'; + fadeOutInterval: number; + fadeOutIntervalType: 'seconds' | 'minutes' | 'hours'; + wordFont: { + family: string; + weight: number; + color: string; + } +} + +export interface Countdown { + typeId: 'countdown'; + time: number; + currentTime: number; + isPersistent: boolean; + isStartedOnSourceLoad: boolean; + messageWhenReachedZero: string; + showMessageWhenReachedZero: boolean; + showMilliseconds: boolean; + countdownFont: Font + messageFont: Font +} + +type CreditsScreenOptions = { + id: string; + /** wait at the end of the screen roll */ + waitBetweenScreens: null | number ; + /** speed of rolling */ + speed: null | 'very slow' | 'slow' | 'medium' | 'fast' | 'very fast', + name: string, +}; +type CreditsCommonOptions = { + id: string; + width: number; + height: number; + alignX: number; + alignY: number; + rotation: number; +}; +export type CreditsScreenEvents = ExpandRecursively<{ + type: 'events', + name: string, + columns: number, + excludeEvents: Alert['items'][number]['type'][], + headers: Record, + headerFont: ExpandRecursively, + itemFont: ExpandRecursively, + highlightFont: ExpandRecursively, +} & CreditsScreenOptions>; +export type CreditsScreenClips = ExpandRecursively<{ + type: 'clips', + play: boolean, + period: 'custom' | 'stream', + periodValue: number, + numOfClips: number, + volume: number, + gameFont: ExpandRecursively, + titleFont: ExpandRecursively, + createdByFont: ExpandRecursively, +} & CreditsScreenOptions>; + +export type CreditsScreenCustom = ExpandRecursively<{ + type: 'custom', + height: number, + items: ExpandRecursively<{ + css: string, + html: string, + font: ExpandRecursively, + } & CreditsCommonOptions>[] +} & CreditsScreenOptions>; +export type Credits = ExpandRecursively<{ + typeId: 'credits'; + screens: (ExpandRecursively)[], +} & RemoveNull>>; + +export interface Eventlist { + typeId: 'eventlist'; + count: number, + ignore: string[] + display: string[], + order: 'asc' | 'desc', + /** set item fadeout */ + fadeOut: boolean, + /** set eventlist horizontal */ + inline: boolean, + /** space between two event items */ + spaceBetweenItems: number, + /** space between event and username of one item */ + spaceBetweenEventAndUsername: number, + usernameFont: { + align: 'left' | 'center' | 'right'; + family: string; + size: number; + borderPx: number; + borderColor: string; + weight: number; + color: string; + shadow: { + shiftRight: number; + shiftDown: number; + blur: number; + opacity: number; + color: string; + }[]; + } + eventFont: { + align: 'left' | 'center' | 'right'; + family: string; + size: number; + borderPx: number; + borderColor: string; + weight: number; + color: string; + shadow: { + shiftRight: number; + shiftDown: number; + blur: number; + opacity: number; + color: string; + }[]; + } +} + +export interface Clips { + typeId: 'clips'; + volume: number, + filter: 'none' | 'grayscale' | 'sepia' | 'tint' | 'washed', + showLabel: boolean, +} + +export interface Media { + typeId: 'media'; +} + +export interface Alerts { + typeId: 'alerts'; + alertDelayInMs: number; + parry: { + enabled: boolean; + delay: number; + }; + profanityFilter: { + type: 'disabled' | 'replace-with-asterisk' | 'replace-with-happy-words' | 'hide-messages' | 'disable-alerts'; + list: { [x:string]: boolean }; + customWords: string; + }; + globalFont1: ExpandRecursively; + globalFont2: ExpandRecursively; + tts: { + voice: string; + pitch: number; + volume: number; + rate: number; + }; + items: ExpandRecursively<{ + id: string; + enabled: boolean; + name: string; + variantName?: string | null; + variants: ExpandRecursively>[]; + /** + * weight of this alert, higher weight means higher priority + */ + weight: number; + /** + * Hooks determinate what events will trigger this alert + */ + hooks: string[]; + items: ExpandRecursively[] + /** + * additional hook filters + */ + filter: Filter; + /** + * what rewardId should trigger this + * available only for reward alerts + */ + rewardId?: string; + + alertDuration: number; + + /** + * animations + */ + animationInWindowBoundaries?: boolean; + animationInDuration: number; + animationIn: 'none' | 'fadeIn' | 'fadeInDown' | 'fadeInLeft' | 'fadeInRight' + | 'fadeInUp' | 'fadeInDownBig' | 'fadeInLeftBig' | 'fadeInRightBig' + | 'fadeInUpBig' | 'bounceIn' | 'bounceInDown' | 'bounceInLeft' + | 'bounceInRight' | 'bounceInUp' | 'flipInX' | 'flipInY' | 'lightSpeedIn' + | 'rotateIn' | 'rotateInDownLeft' | 'rotateInDownRight' | 'rotateInUpLeft' + | 'rotateInUpRight' | 'slideInDown' | 'slideInLeft' | 'slideInRight' + | 'slideInUp' | 'zoomIn' | 'zoomInDown' | 'zoomInLeft' | 'zoomInRight' + | 'zoomInUp' | 'rollIn' | 'jackInTheBox'; + animationOutWindowBoundaries?: boolean; + animationOutDuration: number; + animationOut: 'none' | 'fadeOut' | 'fadeOutDown' | 'fadeOutLeft' | 'fadeOutRight' | 'fadeOutUp' + | 'fadeOutDownBig' | 'fadeOutLeftBig' | 'fadeOutRightBig' | 'fadeOutUpBig' + | 'bounceOut' | 'bounceOutDown' | 'bounceOutLeft' | 'bounceOutRight' + | 'bounceOutUp' | 'flipOutX' | 'flipOutY' | 'lightSpeedOut' | 'rotateOut' + | 'rotateOutDownLeft' | 'rotateOutDownRight' | 'rotateOutUpLeft' + | 'rotateOutUpRight' | 'slideOutDown' | 'slideOutLeft' | 'slideOutRight' + | 'slideOutUp' | 'zoomOut' | 'zoomOutDown' | 'zoomOutLeft' | 'zoomOutRight' + | 'zoomOutUp' | 'rollOut'; + animationText: 'none' | 'baffle' | 'bounce' | 'bounce2' | 'flip' | 'flash' | 'pulse2' | 'rubberBand' + | 'shake2' | 'swing' | 'tada' | 'wave' | 'wobble' | 'wiggle' | 'wiggle2' | 'jello' | 'typewriter'; + animationTextOptions: { + speed: 'slower' | 'slow' | 'normal' | 'fast' | 'faster'; + maxTimeToDecrypt: number; + characters: string; + }; + }>[]; +} + +type AlertCommonOptions = ExpandRecursively; + +export type AlertAnimationOptions = { + animationDelay: number; + animationInDuration: null | number; + animationOutDuration: null | number; + animationIn: null | Alerts['items'][number]['animationIn']; + animationOut: null | Alerts['items'][number]['animationOut']; +}; + +export type AlertAudio = ExpandRecursively; + +export type AlertProfileImage = ExpandRecursively; + +export type AlertImage = ExpandRecursively; + +export type AlertTTS = ExpandRecursively; + +export type AlertCustom = ExpandRecursively; + +export type AlertText = ExpandRecursively; +export interface Emotes { + typeId: 'emotes'; + emotesSize: 1 | 2 | 3, + maxEmotesPerMessage: number, + animation: 'fadeup' | 'fadezoom' | 'facebook', + animationTime: number, + maxRotation: number, + offsetX: number, +} + +export interface EmotesCombo { + typeId: 'emotescombo'; + showEmoteInOverlayThreshold: number, + hideEmoteInOverlayAfter: number, +} + +export interface EmotesFireworks { + typeId: 'emotesfireworks'; + emotesSize: 1 | 2 | 3, + animationTime: number, + numOfEmotesPerExplosion: number, + numOfExplosions: number, +} +export interface EmotesExplode { + typeId: 'emotesexplode'; + emotesSize: 1 | 2 | 3, + animationTime: number, + numOfEmotes: number, +} +export interface Carousel { + typeId: 'carousel'; + images: { + waitBefore: number; + waitAfter: number; + duration: number; + animationInDuration: number; + animationIn: string; + animationOutDuration: number; + animationOut: string; + url: string; + id: string; + }[] +} + +export interface HypeTrain { + typeId: 'hypetrain'; +} + +export interface ClipsCarousel { + typeId: 'clipscarousel'; + customPeriod: number, + numOfClips: number, + volume: number, + animation: string, + spaceBetween: number, +} + +export interface TTS { + typeId: 'tts'; + voice: string, + volume: number, + rate: number, + pitch: number, + triggerTTSByHighlightedMessage: boolean, +} + +export interface Polls { + typeId: 'polls'; + theme: 'light' | 'dark' | 'Soge\'s green', + hideAfterInactivity: boolean, + inactivityTime: number, + align: 'top' | 'bottom', +} + +export interface OBSWebsocket { + typeId: 'obswebsocket'; + allowedIPs: string[]; + port: string; + password: string; +} + +export interface AlertsRegistry { + typeId: 'alertsRegistry' | 'textRegistry'; + id: string, +} + +export interface Plugin { + typeId: 'plugin'; + pluginId: string; + overlayId: string; +} + +export interface URL { + typeId: 'url'; + url: string, +} + +export interface HTML { + typeId: 'html'; + html: string; + javascript: string; + css: string; +} + +export interface Goal { + typeId: 'goal'; + display: { + type: 'fade'; + durationMs: number; + animationInMs: number; + animationOutMs: number; + } | { + type: 'multi'; + spaceBetweenGoalsInPx: number; + }; + campaigns: { + name: string; + type: + 'followers' | 'currentFollowers' | 'currentSubscribers' + | 'subscribers' | 'tips' | 'bits' | 'intervalSubscribers' + | 'intervalFollowers' | 'intervalTips' | 'intervalBits' | 'tiltifyCampaign'; + countBitsAsTips: boolean; + display: 'simple' | 'full' | 'custom'; + timestamp?: string; + tiltifyCampaign?: number | null, + interval?: 'hour' | 'day' | 'week' | 'month' | 'year'; + goalAmount?: number; + currentAmount?: number; + endAfter: string; + endAfterIgnore: boolean; + customization: { + html: string; + js: string; + css: string; + }; + customizationBar: { + color: string; + backgroundColor: string; + borderColor: string; + borderPx: number; + height: number; + }; + customizationFont: { + family: string; + color: string; + size: number; + weight: number; + borderColor: string; + borderPx: number; + shadow: { + shiftRight: number; + shiftDown: number; + blur: number; + opacity: number; + color: string; + }[]; + }; + }[]; +} + +export interface Stats { + typeId: 'stats'; +} + +export interface Group { + typeId: 'group'; + canvas: { + width: number; + height: number; + }, + items: { + id: string; + width: number; + height: number; + alignX: number; + alignY: number; + }[], +} + +@Entity() +export class Overlay extends BotEntity { + @PrimaryColumn({ generated: 'uuid' }) + id: string; + + @Column() + name: string; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + canvas: { + width: number; + height: number; + }; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + items: { + id: string; + isVisible: boolean; + width: number; + height: number; + alignX: number; + alignY: number; + rotation: number; + name: string; + opts: + URL | Chat | Reference | AlertsRegistry | Carousel | Marathon | Stopwatch | + Countdown | Group | Eventlist | EmotesCombo | Credits | Clips | Alerts | + Emotes | EmotesExplode | EmotesFireworks | Polls | TTS | OBSWebsocket | + ClipsCarousel | HypeTrain | Wordcloud | HTML | Stats | Randomizer | Goal | Credits | + Plugin | Media; + }[]; +} \ No newline at end of file diff --git a/backend/src/database/entity/permissions.ts b/backend/src/database/entity/permissions.ts new file mode 100644 index 000000000..f4d0b58ba --- /dev/null +++ b/backend/src/database/entity/permissions.ts @@ -0,0 +1,48 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { BotEntity } from '../BotEntity.js'; + +@Entity() +export class Permissions extends BotEntity { + @PrimaryColumn({ generated: 'uuid' }) + id: string; + + @Column() + name: string; + + @Column() + order: number; + + @Column() + isCorePermission: boolean; + + @Column() + isWaterfallAllowed: boolean; + + @Column({ type: 'varchar', length: 12 }) + automation: string; + + @Column({ type: 'simple-array' }) + userIds: string[]; + @Column({ type: 'simple-array' }) + excludeUserIds: string[]; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + filters: { + comparator: '<' | '>' | '==' | '<=' | '>='; + type: 'level' | 'ranks' | 'points' | 'watched' | 'tips' | 'bits' | 'messages' | 'subtier' | 'subcumulativemonths' | 'substreakmonths'; + value: string; + }[]; +} + +@Entity() +export class PermissionCommands extends BotEntity{ + @PrimaryColumn({ generated: 'uuid' }) + id: string; + + @Column() + @Index('IDX_ba6483f5c5882fa15299f22c0a') + name: string; + + @Column({ type: 'varchar', length: 36, nullable: true }) + permission: string | null; +} \ No newline at end of file diff --git a/backend/src/database/entity/plugins.ts b/backend/src/database/entity/plugins.ts new file mode 100644 index 000000000..d2301505c --- /dev/null +++ b/backend/src/database/entity/plugins.ts @@ -0,0 +1,43 @@ +import { IsNotEmpty } from 'class-validator'; +import { Entity, PrimaryColumn, Column, BaseEntity } from 'typeorm'; + +import { BotEntity } from '../BotEntity.js'; + +@Entity() +export class Plugin extends BotEntity { + + @PrimaryColumn() + id: string; + + @Column() + @IsNotEmpty() + name: string; + + @Column() + enabled: boolean; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 'longtext' : 'text' }) + workflow: string; + + @Column('simple-json', { nullable: true }) + settings: { + name: string; + type: 'string' | 'number' | 'array' | 'boolean'; + description: string; + defaultValue: string | number | string[]; + currentValue: string | number | string[]; + }[] | null; +} + +@Entity() +export class PluginVariable extends BaseEntity { + + @PrimaryColumn() + variableName: string; + + @PrimaryColumn() + pluginId: string; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 'longtext' : 'text' }) + value: string; +} \ No newline at end of file diff --git a/backend/src/database/entity/points.ts b/backend/src/database/entity/points.ts new file mode 100644 index 000000000..b1a444f5d --- /dev/null +++ b/backend/src/database/entity/points.ts @@ -0,0 +1,29 @@ +import { EntitySchema } from 'typeorm'; + +import { ColumnNumericTransformer } from './_transformer.js'; + +export interface PointsChangelogInterface { + id: number; + userId: string; + originalValue: number; + updatedValue: number; + updatedAt: number; + command: 'set' | 'add' | 'remove'; +} + +export const PointsChangelog = new EntitySchema>>({ + name: 'points_changelog', + columns: { + id: { + type: Number, primary: true, generated: 'increment', + }, + userId: { type: String }, + originalValue: { type: Number }, + updatedValue: { type: Number }, + updatedAt: { type: 'bigint', transformer: new ColumnNumericTransformer() }, + command: { type: String }, + }, + indices: [ + { name: 'IDX_points_changelog_userId', columns: ['userId'] }, + ], +}); \ No newline at end of file diff --git a/backend/src/database/entity/price.ts b/backend/src/database/entity/price.ts new file mode 100644 index 000000000..c9f6312af --- /dev/null +++ b/backend/src/database/entity/price.ts @@ -0,0 +1,33 @@ +import { IsNotEmpty, IsPositive, MinLength, ValidateIf } from 'class-validator'; +import { BaseEntity, Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { IsCommand } from '../validators/IsCommand.js'; + +@Entity() +export class Price extends BaseEntity { + @PrimaryColumn({ generated: 'uuid' }) + id: string; + + @Column() + @IsNotEmpty() + @MinLength(2) + @IsCommand() + @Index('IDX_d12db23d28020784096bcb41a3', { unique: true }) + command: string; + + @Column({ default: true }) + enabled: boolean; + + @Column({ default: false }) + emitRedeemEvent: boolean; + + @Column() + @IsPositive() + @ValidateIf(o => o.priceBits <= 0) + price: number; + + @Column({ default: 0 }) + @ValidateIf(o => o.price <= 0) + @IsPositive() + priceBits: number; +} \ No newline at end of file diff --git a/backend/src/database/entity/queue.ts b/backend/src/database/entity/queue.ts new file mode 100644 index 000000000..99e54919e --- /dev/null +++ b/backend/src/database/entity/queue.ts @@ -0,0 +1,31 @@ +import { EntitySchema } from 'typeorm'; + +import { ColumnNumericTransformer } from './_transformer.js'; + +export interface QueueInterface { + id?: number; + createdAt: number; + username: string; + isModerator: boolean; + isSubscriber: boolean; + message: string | null; +} + +export const Queue = new EntitySchema>>({ + name: 'queue', + columns: { + id: { + type: Number, primary: true, generated: 'increment', + }, + createdAt: { type: 'bigint', transformer: new ColumnNumericTransformer() }, + username: { type: String }, + isModerator: { type: Boolean }, + isSubscriber: { type: Boolean }, + message: { type: String, nullable: true }, + }, + indices: [ + { + name: 'IDX_7401b4e0c30f5de6621b38f7a0', columns: ['username'], unique: true, + }, + ], +}); \ No newline at end of file diff --git a/backend/src/database/entity/quotes.ts b/backend/src/database/entity/quotes.ts new file mode 100644 index 000000000..db64b59e5 --- /dev/null +++ b/backend/src/database/entity/quotes.ts @@ -0,0 +1,21 @@ +import { IsNotEmpty } from 'class-validator'; +import { BaseEntity, Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity() +export class Quotes extends BaseEntity { + @PrimaryColumn({ type: 'int', generated: 'increment' }) + id: number; + + @Column({ type: 'simple-array' }) + tags: string[]; + + @IsNotEmpty() + @Column() + quote: string; + + @Column() + quotedBy: string; + + @Column({ type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length, default: '1970-01-01T00:00:00.000Z' }) + createdAt: string; +} \ No newline at end of file diff --git a/backend/src/database/entity/raffle.ts b/backend/src/database/entity/raffle.ts new file mode 100644 index 000000000..c16da986b --- /dev/null +++ b/backend/src/database/entity/raffle.ts @@ -0,0 +1,118 @@ +import { EntitySchema } from 'typeorm'; + +import { ColumnNumericTransformer } from './_transformer.js'; + +export interface RaffleInterface { + id?: string; + winner: string | null; + timestamp?: number; + keyword: string; + minTickets?: number; + maxTickets?: number; + type: number; + forSubscribers: boolean; + isClosed?: boolean; + participants: RaffleParticipantInterface[]; +} + +export interface RaffleParticipantInterface { + id?: string; + raffle: RaffleInterface; + username: string; + tickets: number; + isEligible: boolean; + isSubscriber: boolean; + messages: RaffleParticipantMessageInterface[]; +} + +export interface RaffleParticipantMessageInterface { + id?: string; + participant?: RaffleParticipantInterface; + timestamp: number; + text: string; +} + +export const Raffle = new EntitySchema>>({ + name: 'raffle', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + winner: { type: 'text', nullable: true }, + timestamp: { + type: 'bigint', transformer: new ColumnNumericTransformer(), default: 0, + }, + keyword: { type: String }, + minTickets: { + type: 'bigint', transformer: new ColumnNumericTransformer(), default: 0, + }, + maxTickets: { + type: 'bigint', transformer: new ColumnNumericTransformer(), default: 0, + }, + type: { type: Number }, + forSubscribers: { type: Boolean }, + isClosed: { type: Boolean, default: false }, + }, + indices: [ + { name: 'IDX_e83facaeb8fbe8b8ce9577209a', columns: ['keyword'] }, + { name: 'IDX_raffleIsClosed', columns: ['isClosed'] }, + ], + relations: { + participants: { + type: 'one-to-many', + target: 'raffle_participant', + inverseSide: 'raffle', + cascade: true, + }, + }, +}); + +export const RaffleParticipant = new EntitySchema>>({ + name: 'raffle_participant', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + username: { type: String }, + tickets: { type: Number }, + isEligible: { type: Boolean }, + isSubscriber: { type: Boolean }, + }, + relations: { + raffle: { + type: 'many-to-one', + target: 'raffle', + inverseSide: 'participants', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + messages: { + type: 'one-to-many', + target: 'raffle_participant_message', + inverseSide: 'participant', + cascade: true, + }, + }, +}); + +export const RaffleParticipantMessage = new EntitySchema>>({ + name: 'raffle_participant_message', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + timestamp: { + type: 'bigint', transformer: new ColumnNumericTransformer(), default: 0, + }, + text: { type: 'text' }, + }, + relations: { + participant: { + type: 'many-to-one', + target: 'raffle_participant', + inverseSide: 'participant', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + }, +}); \ No newline at end of file diff --git a/backend/src/database/entity/randomizer.ts b/backend/src/database/entity/randomizer.ts new file mode 100644 index 000000000..0c37bb8ba --- /dev/null +++ b/backend/src/database/entity/randomizer.ts @@ -0,0 +1,93 @@ +import { IsNotEmpty, MinLength } from 'class-validator'; +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { Alert } from './alert.js'; +import { BotEntity } from '../BotEntity.js'; +import { IsCommand } from '../validators/IsCommand.js'; + +@Entity() +@Index('idx_randomizer_cmdunique', [ 'command' ], { unique: true }) +export class Randomizer extends BotEntity { + @BeforeInsert() + generateCreatedAt() { + this.createdAt = new Date().toISOString(); + } + + @PrimaryColumn({ generated: 'uuid' }) + id: string; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + items: { + id: string; + /* + * This should hlp with grouping things like Bancrupcy, WIN, Bancrupcy, to always appear beside + */ + groupId: string | null; // Will be used to group items together + name: string; + color: string; + numOfDuplicates?: number; // number of duplicates + minimalSpacing?: number; // minimal space between duplicates + order: number; + }[]; + + @Column({ nullable: false, type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length }) + createdAt?: string; + + @Column() + @IsNotEmpty() + @MinLength(2) + @IsCommand() + command: string; + + @Column() + permissionId: string; + + @Column() + @IsNotEmpty() + @MinLength(2) + name: string; + + @Column({ type: Boolean, default: false }) + isShown?: boolean; + + @Column({ type: Boolean }) + shouldPlayTick: boolean; + + @Column() + tickVolume: number; + + @Column() + widgetOrder: number; + + @Column({ + type: 'varchar', length: 20, default: 'simple', + }) + type: 'simple' | 'wheelOfFortune' | 'tape'; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + position: { + x: number; + y: number; + anchorX: 'left' | 'middle' | 'right'; + anchorY: 'top' | 'middle' | 'bottom'; + }; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + customizationFont: { + family: string; + size: number; + borderColor: string; + borderPx: number; + weight: number; + shadow: { + shiftRight: number; + shiftDown: number; + blur: number; + opacity: number; + color: string; + }[]; + }; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + tts: Alert['tts'] & { enabled: boolean }; +} \ No newline at end of file diff --git a/backend/src/database/entity/rank.ts b/backend/src/database/entity/rank.ts new file mode 100644 index 000000000..e53126468 --- /dev/null +++ b/backend/src/database/entity/rank.ts @@ -0,0 +1,22 @@ +import { IsNotEmpty, Min, MinLength } from 'class-validator'; +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { BotEntity } from '../BotEntity.js'; + +@Entity() +@Index('IDX_93c78c94804a13befdace81904', ['type', 'value'], { unique: true }) +export class Rank extends BotEntity { + @PrimaryColumn({ generated: 'uuid' }) + id: string; + + @Column() + @Min(0) + value: number; + + @Column() + @MinLength(2) + @IsNotEmpty() + rank: string; + + @Column() + type: 'viewer' | 'subscriber'; +} \ No newline at end of file diff --git a/backend/src/database/entity/scrimMatchId.ts b/backend/src/database/entity/scrimMatchId.ts new file mode 100644 index 000000000..dfd62233e --- /dev/null +++ b/backend/src/database/entity/scrimMatchId.ts @@ -0,0 +1,23 @@ +import { EntitySchema } from 'typeorm'; + +export interface ScrimMatchIdInterface { + id?: string; + username: string; + matchId: string; +} + +export const ScrimMatchId = new EntitySchema>>({ + name: 'scrim_match_id', + columns: { + id: { + type: 'uuid', primary: true, generated: 'uuid', + }, + username: { type: String }, + matchId: { type: String }, + }, + indices: [ + { + name: 'IDX_5af6da125c1745151e0dfaf087', unique: true, columns: [ 'username' ], + }, + ], +}); \ No newline at end of file diff --git a/backend/src/database/entity/settings.ts b/backend/src/database/entity/settings.ts new file mode 100644 index 000000000..2cd9bcd70 --- /dev/null +++ b/backend/src/database/entity/settings.ts @@ -0,0 +1,18 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { BotEntity } from '../BotEntity.js'; + +@Entity() +@Index('IDX_d8a83b9ffce680092c8dfee37d', [ 'namespace', 'name' ], { unique: true }) +export class Settings extends BotEntity { + @PrimaryColumn({ generated: 'rowid' }) + id: number; + + @Column() + namespace: string; + + @Column() + name: string; + + @Column({ type: ['mysql', 'mariadb'].includes(process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') ? 'longtext' : 'text' }) + value: string; +} diff --git a/backend/src/database/entity/song.ts b/backend/src/database/entity/song.ts new file mode 100644 index 000000000..5bf0aea86 --- /dev/null +++ b/backend/src/database/entity/song.ts @@ -0,0 +1,77 @@ +import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm'; +import { BotEntity } from '../BotEntity.js'; + +export type currentSongType = { + videoId: null | string, title: string, type: string, username: string, volume: number; loudness: number; forceVolume: boolean; startTime: number; endTime: number; +}; + +@Entity() +export class SongBan extends BotEntity { + @PrimaryColumn() + videoId: string; + + @Column() + title: string; +} + +@Entity() +export class SongPlaylist extends BotEntity { + @PrimaryColumn() + videoId: string; + + @Column({ type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length, default: '1970-01-01T00:00:00.000Z' }) + lastPlayedAt?: string; + + @Column() + title: string; + + @Column({ type: 'float', precision: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 12 : undefined }) + seed: number; + + @Column({ type: 'float', precision: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 12 : undefined }) + loudness: number; + + @Column({ type: 'simple-array' }) + tags: string[]; + + @Column() + length: number; + + @Column() + volume: number; + + @Column() + startTime: number; + + @Column() + endTime: number; + + @Column({ type: Boolean, default: false }) + forceVolume: boolean; +} + +@Entity() +export class SongRequest extends BotEntity { + @PrimaryColumn({ generated: 'uuid' }) + id: string; + + @Column() + videoId: string; + + @Column({ type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length }) + addedAt?: string; + + @BeforeInsert() + generateAddedAt() { + this.addedAt = new Date().toISOString(); + } + + @Column() + title: string; + @Column({ type: 'float', precision: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 12 : undefined }) + loudness: number; + @Column() + length: number; + @Column() + username: string; +} \ No newline at end of file diff --git a/backend/src/database/entity/spotify.ts b/backend/src/database/entity/spotify.ts new file mode 100644 index 000000000..1f757889e --- /dev/null +++ b/backend/src/database/entity/spotify.ts @@ -0,0 +1,12 @@ +import { BotEntity } from '../BotEntity.js'; +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity() +export class SpotifySongBan extends BotEntity { + @PrimaryColumn() + spotifyUri: string; + @Column() + title: string; + @Column({ type: 'simple-array' }) + artists: string[]; +} \ No newline at end of file diff --git a/backend/src/database/entity/timer.ts b/backend/src/database/entity/timer.ts new file mode 100644 index 000000000..d8d691dfe --- /dev/null +++ b/backend/src/database/entity/timer.ts @@ -0,0 +1,56 @@ +import { IsNotEmpty, MinLength, Matches, Min } from 'class-validator'; +import { ManyToOne, OneToMany } from 'typeorm'; +import { BaseEntity, Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity() +export class Timer extends BaseEntity { + @PrimaryColumn({ generated: 'uuid', type: 'uuid' }) + id: string; + + @Column() + @IsNotEmpty() + @MinLength(2) + @Matches(/^[a-zA-Z0-9_]*$/) + name: string = ''; + + @Column() + isEnabled: boolean = true; + + @Column({ default: false }) + tickOffline: boolean = false; + + @Column() + @Min(0) + triggerEveryMessage: number = 30; + + @Column() + @Min(0) + triggerEverySecond: number = 60; + + @Column({ type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length, default: '1970-01-01T00:00:00.000Z' }) + triggeredAtTimestamp?: string; + + @Column({ default: 0 }) + triggeredAtMessages?: number; + + @OneToMany(() => TimerResponse, (item) => item.timer) + messages: TimerResponse[]; +} + +@Entity() +export class TimerResponse extends BaseEntity { + @PrimaryColumn({ generated: 'uuid', type: 'uuid' }) + id: string; + + @Column({ type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length, default: '1970-01-01T00:00:00.000Z' }) + timestamp: string; + + @Column({ default: true }) + isEnabled: boolean; + + @Column({ type: 'text' }) + response: string; + + @ManyToOne(() => Timer, (item) => item.messages, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + timer: Timer; +} \ No newline at end of file diff --git a/backend/src/database/entity/translation.ts b/backend/src/database/entity/translation.ts new file mode 100644 index 000000000..2a80d72c7 --- /dev/null +++ b/backend/src/database/entity/translation.ts @@ -0,0 +1,13 @@ +import { EntitySchema } from 'typeorm'; + +export interface TranslationInterface { + value: string; name: string; +} + +export const Translation = new EntitySchema>>({ + name: 'translation', + columns: { + name: { type: String, primary: true }, + value: { type: String }, + }, +}); \ No newline at end of file diff --git a/backend/src/database/entity/twitch.ts b/backend/src/database/entity/twitch.ts new file mode 100644 index 000000000..8a9d0529b --- /dev/null +++ b/backend/src/database/entity/twitch.ts @@ -0,0 +1,49 @@ +import { EntitySchema } from 'typeorm'; + +import { ColumnNumericTransformer } from './_transformer.js'; + +export interface TwitchStatsInterface { + whenOnline: number; + currentViewers?: number; + currentSubscribers?: number; + currentBits: number; + currentTips: number; + chatMessages: number; + currentFollowers?: number; + maxViewers?: number; + newChatters?: number; + currentWatched: number; +} + +export interface TwitchClipsInterface { + clipId: string; isChecked: boolean; shouldBeCheckedAt: number; +} + +export const TwitchStats = new EntitySchema>>({ + name: 'twitch_stats', + columns: { + whenOnline: { + type: 'bigint', transformer: new ColumnNumericTransformer(), primary: true, + }, + currentViewers: { type: Number, default: 0 }, + currentSubscribers: { type: Number, default: 0 }, + chatMessages: { type: 'bigint' }, + currentFollowers: { type: Number, default: 0 }, + maxViewers: { type: Number, default: 0 }, + newChatters: { type: Number, default: 0 }, + currentBits: { type: 'bigint', transformer: new ColumnNumericTransformer() }, + currentTips: { + type: 'float', transformer: new ColumnNumericTransformer(), precision: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 12 : undefined, + }, + currentWatched: { type: 'bigint', transformer: new ColumnNumericTransformer() }, + }, +}); + +export const TwitchClips = new EntitySchema>>({ + name: 'twitch_clips', + columns: { + clipId: { type: String, primary: true }, + isChecked: { type: Boolean }, + shouldBeCheckedAt: { type: 'bigint', transformer: new ColumnNumericTransformer() }, + }, +}); \ No newline at end of file diff --git a/backend/src/database/entity/user.ts b/backend/src/database/entity/user.ts new file mode 100644 index 000000000..710414625 --- /dev/null +++ b/backend/src/database/entity/user.ts @@ -0,0 +1,158 @@ +import { EntitySchema } from 'typeorm'; + +import { ColumnNumericTransformer, SafeNumberTransformer } from './_transformer.js'; + +export type Currency = 'USD' | 'AUD' | 'BGN' | 'BRL' | 'CAD' | 'CHF' | 'CNY' | 'CZK' | 'DKK' | 'EUR' | 'GBP' | 'HKD' | 'HRK' | 'HUF' | 'IDR' | 'ILS' | 'INR' | 'ISK' | 'JPY' | 'KRW' | 'MXN' | 'MYR' | 'NOK' | 'NZD' | 'PHP' | 'PLN' | 'RON' | 'RUB' | 'SEK' | 'SGD' | 'THB' | 'TRY' | 'ZAR' | 'UAH'; + +export interface UserInterface { + userId: string; userName: string; displayname?: string; profileImageUrl?: string; + isOnline?: boolean; isVIP?: boolean; isModerator?: boolean; isSubscriber?: boolean; + haveSubscriberLock?: boolean; haveSubscribedAtLock?: boolean; rank?: string; haveCustomRank?: boolean; + subscribedAt?: string | null; seenAt?: string | null; createdAt?: string | null; + watchedTime?: number; chatTimeOnline?: number; chatTimeOffline?: number; + points?: number; pointsOnlineGivenAt?: number; pointsOfflineGivenAt?: number; pointsByMessageGivenAt?: number; + subscribeTier?: string; subscribeCumulativeMonths?: number; subscribeStreak?: number; giftedSubscribes?: number; + messages?: number; + extra: { + jackpotWins?: number; + levels?: { + xp: string; // we need to use string as we cannot stringify bigint in typeorm + xpOfflineGivenAt: number; + xpOfflineMessages: number; + xpOnlineGivenAt: number; + xpOnlineMessages: number; + }, + } | null +} + +export interface UserTipInterface { + id?: string; amount: number; currency: Currency; message: string; tippedAt?: number; sortAmount: number; + exchangeRates: { [key in Currency]: number }; userId: string; +} + +export interface UserBitInterface { + id?: string; amount: number; message: string; cheeredAt?: number; + userId: string; +} + +export const User = new EntitySchema>>({ + name: 'user', + columns: { + userId: { + type: String, + primary: true, + }, + userName: { type: String }, + displayname: { + type: String, + default: '', + }, + profileImageUrl: { + type: String, + default: '', + }, + isOnline: { type: Boolean, default: false }, + isVIP: { type: Boolean, default: false }, + isModerator: { type: Boolean, default: false }, + isSubscriber: { type: Boolean, default: false }, + haveSubscriberLock: { type: Boolean, default: false }, + haveSubscribedAtLock: { type: Boolean, default: false }, + rank: { type: String, default: '' }, + haveCustomRank: { type: Boolean, default: false }, + subscribedAt: { + type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length, nullable: true, + }, + seenAt: { + type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length, nullable: true, + }, + createdAt: { + type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length, nullable: true, + }, + watchedTime: { + type: 'bigint', default: 0, transformer: new ColumnNumericTransformer(), + }, + chatTimeOnline: { + type: 'bigint', default: 0, transformer: new ColumnNumericTransformer(), + }, + chatTimeOffline: { + type: 'bigint', default: 0, transformer: new ColumnNumericTransformer(), + }, + points: { + type: 'bigint', default: 0, transformer: new SafeNumberTransformer(), + }, + pointsOnlineGivenAt: { + type: 'bigint', default: 0, transformer: new ColumnNumericTransformer(), + }, + pointsOfflineGivenAt: { + type: 'bigint', default: 0, transformer: new ColumnNumericTransformer(), + }, + pointsByMessageGivenAt: { + type: 'bigint', default: 0, transformer: new ColumnNumericTransformer(), + }, + subscribeTier: { type: String, default: '0' }, + subscribeCumulativeMonths: { type: Number, default: 0 }, + subscribeStreak: { type: Number, default: 0 }, + giftedSubscribes: { + type: 'bigint', default: 0, transformer: new ColumnNumericTransformer(), + }, + messages: { + type: 'bigint', default: 0, transformer: new ColumnNumericTransformer(), + }, + extra: { type: 'simple-json', nullable: true }, + }, + indices: [ + { + name: 'IDX_78a916df40e02a9deb1c4b75ed', + columns: [ 'userName' ], + }, + ], +}); + +export const UserTip = new EntitySchema>>({ + name: 'user_tip', + columns: { + id: { + type: Number, + primary: true, + generated: 'increment', + }, + amount: { type: 'float', precision: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 12 : undefined }, + sortAmount: { type: 'float', precision: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') === 'mysql' ? 12 : undefined }, + exchangeRates: { type: 'simple-json' }, + currency: { type: String }, + message: { type: 'text' }, + tippedAt: { + type: 'bigint', default: 0, transformer: new ColumnNumericTransformer(), + }, + userId: { type: String, nullable: true }, + }, + indices: [ + { + name: 'IDX_user_tip_userId', + columns: [ 'userId' ], + }, + ], +}); + +export const UserBit = new EntitySchema>>({ + name: 'user_bit', + columns: { + id: { + type: Number, + primary: true, + generated: 'increment', + }, + amount: { type: 'bigint', transformer: new ColumnNumericTransformer() }, + message: { type: 'text' }, + cheeredAt: { + type: 'bigint', default: 0, transformer: new ColumnNumericTransformer(), + }, + userId: { type: String, nullable: true }, + }, + indices: [ + { + name: 'IDX_user_bit_userId', + columns: [ 'userId' ], + }, + ], +}); \ No newline at end of file diff --git a/backend/src/database/entity/variable.ts b/backend/src/database/entity/variable.ts new file mode 100644 index 000000000..13a15ab76 --- /dev/null +++ b/backend/src/database/entity/variable.ts @@ -0,0 +1,111 @@ +import { BeforeInsert, Column, Entity, PrimaryColumn } from 'typeorm'; + +import { BotEntity } from '../BotEntity.js'; +import { IsNotEmpty, IsNumber, MinLength } from 'class-validator'; +import defaultPermissions from '../../helpers/permissions/defaultPermissions.js'; +import { IsCustomVariable } from '../validators/isCustomVariable.js'; + +@Entity() +export class VariableWatch extends BotEntity { + @PrimaryColumn({ + type: Number, + primary: true, + generated: 'increment', + }) + id: string; + @Column({ + type: String, nullable: false, name: 'variableId', + }) + variableId: string; + @Column() + order: number; +} + +@Entity() +export class Variable extends BotEntity { + @BeforeInsert() + generateCreatedAt() { + if (!this.runAt) { + this.runAt = new Date(0).toISOString(); + } + if (!this.urls) { + this.urls = []; + } + if (!this.history) { + this.history = []; + } + if (!this.permission) { + this.permission = defaultPermissions.MODERATORS; + } + } + + @PrimaryColumn({ + type: 'uuid', + primary: true, + generated: 'uuid', + }) + id: string; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + history: { + userId: string; + username: string; + currentValue: string; + oldValue: string; + changedAt: string; + }[]; + + @Column({ type: (process.env.TYPEORM_CONNECTION ?? 'better-sqlite3') !== 'better-sqlite3' ? 'json' : 'simple-json' }) + urls: { + id: string; + GET: boolean; + POST: boolean; + showResponse: boolean; + }[]; + + @Column({ unique: true }) + @MinLength(3) + @IsCustomVariable() + variableName: string; + + @Column({ default: '' }) + description: string; + + @Column({ type: String }) + type: 'eval' | 'number' | 'options' | 'text'; + + @Column({ + type: String, + nullable: true, + }) + currentValue: string; + + @Column({ type: 'text' }) + evalValue: string; + + @Column({ default: 60000 }) + @IsNotEmpty() + @IsNumber() + runEvery: number; + + @Column() + responseType: number; + + @Column({ default: '' }) + responseText: string; + + @Column() + permission: string; + + @Column({ + type: Boolean, + default: false, + }) + readOnly: boolean; + + @Column({ type: 'simple-array' }) + usableOptions: string[]; + + @Column({ type: 'varchar', length: '2022-07-27T00:30:34.569259834Z'.length }) + runAt: string; +} \ No newline at end of file diff --git a/backend/src/database/entity/widget.ts b/backend/src/database/entity/widget.ts new file mode 100644 index 000000000..5e0ba4b77 --- /dev/null +++ b/backend/src/database/entity/widget.ts @@ -0,0 +1,21 @@ +import { EntitySchema } from 'typeorm'; + +export class WidgetCustomInterface { + id: string; + userId: string; + url: string; + name: string; +} + +export const WidgetCustom = new EntitySchema>>({ + name: 'widget_custom', + columns: { + id: { + type: String, + primary: true, + }, + userId: { type: String }, + url: { type: String }, + name: { type: String }, + }, +}); diff --git a/backend/src/database/getAccessTokenInMigration.ts b/backend/src/database/getAccessTokenInMigration.ts new file mode 100644 index 000000000..8c3ce787f --- /dev/null +++ b/backend/src/database/getAccessTokenInMigration.ts @@ -0,0 +1,65 @@ +import axios from 'axios'; +import fetch from 'node-fetch'; +import { QueryRunner } from 'typeorm'; + +const urls = { + 'SogeBot Token Generator': 'https://twitch-token-generator.soge.workers.dev/refresh/', + 'SogeBot Token Generator v2': 'https://credentials.sogebot.xyz/twitch/refresh/', +}; +let accessToken: null | string = null; + +export const getAccessTokenInMigration = async (queryRunner: QueryRunner, type: 'broadcaster' | 'bot'): Promise => { + if (accessToken) { + return accessToken; + } + + const tokenService = JSON.parse((await queryRunner.query(`SELECT * from settings `)).find((o: any) => { + return o.namespace === '/services/twitch' && o.name === 'tokenService'; + })?.value ?? '"SogeBot Token Generator"'); + const url = urls[tokenService as keyof typeof urls]; + const refreshToken = JSON.parse((await queryRunner.query(`SELECT * from settings `)).find((o: any) => { + return o.namespace === '/services/twitch' && o.name === type + 'RefreshToken'; + })?.value ?? '""'); + + if (!url) { + const tokenServiceCustomClientId = JSON.parse((await queryRunner.query(`SELECT * from settings `)).find((o: any) => { + return o.namespace === '/services/twitch' && o.name === 'tokenServiceCustomClientId'; + })?.value ?? '""'); + const tokenServiceCustomClientSecret = JSON.parse((await queryRunner.query(`SELECT * from settings `)).find((o: any) => { + return o.namespace === '/services/twitch' && o.name === 'tokenServiceCustomClientSecret'; + })?.value ?? '""'); + const response = await fetch(`https://id.twitch.tv/oauth2/token?client_id=${tokenServiceCustomClientId}&client_secret=${tokenServiceCustomClientSecret}&refresh_token=${refreshToken}&grant_type=refresh_token`, { + method: 'POST', + }); + if (response.ok) { + const data = await response.json() as { access_token: string }; + accessToken = data.access_token; + return data.access_token; + } else { + throw new Error('Custom token refresh failed'); + } + } else { + const broadcasterId = JSON.parse((await queryRunner.query(`SELECT * from settings `)).find((o: any) => { + return o.namespace === '/services/twitch' && o.name === 'broadcasterId'; + })?.value ?? '""'); + + const request = await axios(url + encodeURIComponent(refreshToken.trim()), { + method: 'POST', + headers: { + 'SogeBot-Channel': 'Done by Migration', + 'SogeBot-Owners': 'Migration done for ' + broadcasterId, + }, + }) as any; + if (!request.data.success) { + throw new Error(`Token refresh: ${request.data.message}`); + } + if (typeof request.data.token !== 'string' || request.data.token.length === 0) { + throw new Error(`Access token was not correctly fetched (not a string)`); + } + if (typeof request.data.refresh !== 'string' || request.data.refresh.length === 0) { + throw new Error(`Refresh token was not correctly fetched (not a string)`); + } + accessToken = request.data.token; + return request.data.token; + } +}; \ No newline at end of file diff --git a/backend/src/database/insertItemIntoTable.ts b/backend/src/database/insertItemIntoTable.ts new file mode 100644 index 000000000..54ad91b42 --- /dev/null +++ b/backend/src/database/insertItemIntoTable.ts @@ -0,0 +1,11 @@ +import { QueryRunner } from 'typeorm'; + +export const insertItemIntoTable = async (tableName: string, item: any, queryRunner: QueryRunner) => { + const quote = process.env.TYPEORM_CONNECTION === 'mysql' ? '`' : '"'; + + const keys = Object.keys(item); + await queryRunner.query( + `INSERT INTO ${quote}${tableName}${quote}(${keys.map(o => `${quote}${o}${quote}`).join(', ')}) values (${keys.map(o => `?`).join(', ')})`, + keys.map(key => item[key]), + ); +}; \ No newline at end of file diff --git a/backend/src/database/migration/mysql/1000000000001-initialize.ts b/backend/src/database/migration/mysql/1000000000001-initialize.ts new file mode 100644 index 000000000..cf9f8bba8 --- /dev/null +++ b/backend/src/database/migration/mysql/1000000000001-initialize.ts @@ -0,0 +1,90 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class initialize1000000000001 implements MigrationInterface { + transaction?: boolean | undefined; + name = 'initialize1000000000001'; + + public async up(queryRunner: QueryRunner): Promise { + const migrations = await queryRunner.query(`SELECT * FROM \`migrations\``); + if (migrations.length > 0) { + console.log('Skipping migration zero, migrations are already in bot'); + return; + } + await queryRunner.query(`CREATE TABLE \`alert\` (\`id\` varchar(36) NOT NULL, \`updatedAt\` varchar(30) NULL, \`name\` varchar(255) NOT NULL, \`alertDelayInMs\` int NOT NULL, \`profanityFilterType\` varchar(255) NOT NULL, \`loadStandardProfanityList\` json NOT NULL, \`parry\` json NOT NULL, \`tts\` json NULL, \`fontMessage\` json NOT NULL, \`font\` json NOT NULL, \`customProfanityList\` varchar(255) NOT NULL, \`items\` json NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`alias\` (\`id\` varchar(36) NOT NULL, \`alias\` varchar(255) NOT NULL, \`command\` text NOT NULL, \`enabled\` tinyint NOT NULL, \`visible\` tinyint NOT NULL, \`permission\` varchar(255) NULL, \`group\` varchar(255) NULL, INDEX \`IDX_6a8a594f0a5546f8082b0c405c\` (\`alias\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`alias_group\` (\`name\` varchar(255) NOT NULL, \`options\` text NOT NULL, UNIQUE INDEX \`IDX_alias_group_unique_name\` (\`name\`), PRIMARY KEY (\`name\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`bets\` (\`id\` varchar(36) NOT NULL, \`createdAt\` varchar(30) NOT NULL, \`endedAt\` varchar(30) NOT NULL, \`isLocked\` tinyint NOT NULL DEFAULT 0, \`arePointsGiven\` tinyint NOT NULL DEFAULT 0, \`options\` text NOT NULL, \`title\` varchar(255) NOT NULL, \`participants\` json NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`commands\` (\`id\` varchar(36) NOT NULL, \`command\` varchar(255) NOT NULL, \`enabled\` tinyint NOT NULL, \`visible\` tinyint NOT NULL, \`group\` varchar(255) NULL, \`areResponsesRandomized\` tinyint NOT NULL DEFAULT 0, \`responses\` json NOT NULL, INDEX \`IDX_1a8c40f0a581447776c325cb4f\` (\`command\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`commands_group\` (\`name\` varchar(255) NOT NULL, \`options\` text NOT NULL, UNIQUE INDEX \`IDX_commands_group_unique_name\` (\`name\`), PRIMARY KEY (\`name\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`commands_count\` (\`id\` varchar(36) NOT NULL, \`command\` varchar(255) NOT NULL, \`timestamp\` varchar(30) NOT NULL, INDEX \`IDX_2ccf816b1dd74e9a02845c4818\` (\`command\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`cooldown\` (\`id\` varchar(36) NOT NULL, \`name\` varchar(255) NOT NULL, \`miliseconds\` int NOT NULL, \`type\` varchar(10) NOT NULL, \`timestamp\` varchar(30) NOT NULL, \`isEnabled\` tinyint NOT NULL, \`isErrorMsgQuiet\` tinyint NOT NULL, \`isOwnerAffected\` tinyint NOT NULL, \`isModeratorAffected\` tinyint NOT NULL, \`isSubscriberAffected\` tinyint NOT NULL, UNIQUE INDEX \`IDX_aa85aa267ec6eaddf7f93e3665\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`highlight\` (\`id\` varchar(36) NOT NULL, \`videoId\` varchar(255) NOT NULL, \`game\` varchar(255) NOT NULL, \`title\` varchar(255) NOT NULL, \`expired\` tinyint NOT NULL DEFAULT 0, \`timestamp\` json NOT NULL, \`createdAt\` varchar(30) NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`how_long_to_beat_game\` (\`id\` varchar(36) NOT NULL, \`game\` varchar(255) NOT NULL, \`startedAt\` varchar(30) NOT NULL, \`updatedAt\` varchar(30) NOT NULL, \`gameplayMain\` float(12) NOT NULL DEFAULT '0', \`gameplayMainExtra\` float(12) NOT NULL DEFAULT '0', \`gameplayCompletionist\` float(12) NOT NULL DEFAULT '0', \`offset\` bigint NOT NULL DEFAULT '0', \`streams\` json NOT NULL, UNIQUE INDEX \`IDX_301758e0e3108fc902d5436527\` (\`game\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`keyword\` (\`id\` varchar(36) NOT NULL, \`keyword\` varchar(255) NOT NULL, \`enabled\` tinyint NOT NULL, \`group\` varchar(255) NULL, INDEX \`IDX_35e3ff88225eef1d85c951e229\` (\`keyword\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`keyword_responses\` (\`id\` varchar(36) NOT NULL, \`order\` int NOT NULL, \`response\` text NOT NULL, \`stopIfExecuted\` tinyint NOT NULL, \`permission\` varchar(255) NULL, \`filter\` varchar(255) NOT NULL, \`keywordId\` varchar(36) NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`keyword_group\` (\`name\` varchar(255) NOT NULL, \`options\` text NOT NULL, UNIQUE INDEX \`IDX_keyword_group_unique_name\` (\`name\`), PRIMARY KEY (\`name\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`obswebsocket\` (\`id\` varchar(14) NOT NULL, \`name\` varchar(255) NOT NULL, \`code\` text NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`overlay\` (\`id\` varchar(36) NOT NULL, \`name\` varchar(255) NOT NULL, \`canvas\` json NOT NULL, \`items\` json NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`permissions\` (\`id\` varchar(36) NOT NULL, \`name\` varchar(255) NOT NULL, \`order\` int NOT NULL, \`isCorePermission\` tinyint NOT NULL, \`isWaterfallAllowed\` tinyint NOT NULL, \`automation\` varchar(12) NOT NULL, \`userIds\` text NOT NULL, \`excludeUserIds\` text NOT NULL, \`filters\` json NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`permission_commands\` (\`id\` varchar(36) NOT NULL, \`name\` varchar(255) NOT NULL, \`permission\` varchar(36) NULL, INDEX \`IDX_ba6483f5c5882fa15299f22c0a\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`settings\` (\`id\` int NOT NULL AUTO_INCREMENT, \`namespace\` varchar(255) NOT NULL, \`name\` varchar(255) NOT NULL, \`value\` longtext NOT NULL, UNIQUE INDEX \`IDX_d8a83b9ffce680092c8dfee37d\` (\`namespace\`, \`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`plugin\` (\`id\` varchar(255) NOT NULL, \`name\` varchar(255) NOT NULL, \`enabled\` tinyint NOT NULL, \`workflow\` longtext NOT NULL, \`settings\` text NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`plugin_variable\` (\`variableName\` varchar(255) NOT NULL, \`pluginId\` varchar(255) NOT NULL, \`value\` longtext NOT NULL, PRIMARY KEY (\`variableName\`, \`pluginId\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`poll\` (\`id\` varchar(36) NOT NULL, \`type\` varchar(7) NOT NULL, \`title\` varchar(255) NOT NULL, \`openedAt\` varchar(30) NOT NULL, \`closedAt\` varchar(30) NULL, \`options\` text NOT NULL, \`votes\` json NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`price\` (\`id\` varchar(36) NOT NULL, \`command\` varchar(255) NOT NULL, \`enabled\` tinyint NOT NULL DEFAULT 1, \`emitRedeemEvent\` tinyint NOT NULL DEFAULT 0, \`price\` int NOT NULL, \`priceBits\` int NOT NULL DEFAULT '0', UNIQUE INDEX \`IDX_d12db23d28020784096bcb41a3\` (\`command\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`quotes\` (\`id\` int NOT NULL AUTO_INCREMENT, \`tags\` text NOT NULL, \`quote\` varchar(255) NOT NULL, \`quotedBy\` varchar(255) NOT NULL, \`createdAt\` varchar(30) NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`randomizer\` (\`id\` varchar(36) NOT NULL, \`items\` json NOT NULL, \`createdAt\` varchar(30) NOT NULL, \`command\` varchar(255) NOT NULL, \`permissionId\` varchar(255) NOT NULL, \`name\` varchar(255) NOT NULL, \`isShown\` tinyint NOT NULL DEFAULT 0, \`shouldPlayTick\` tinyint NOT NULL, \`tickVolume\` int NOT NULL, \`widgetOrder\` int NOT NULL, \`type\` varchar(20) NOT NULL DEFAULT 'simple', \`position\` json NOT NULL, \`customizationFont\` json NOT NULL, \`tts\` json NOT NULL, UNIQUE INDEX \`idx_randomizer_cmdunique\` (\`command\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`rank\` (\`id\` varchar(36) NOT NULL, \`value\` int NOT NULL, \`rank\` varchar(255) NOT NULL, \`type\` varchar(255) NOT NULL, UNIQUE INDEX \`IDX_93c78c94804a13befdace81904\` (\`type\`, \`value\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`song_ban\` (\`videoId\` varchar(255) NOT NULL, \`title\` varchar(255) NOT NULL, PRIMARY KEY (\`videoId\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`song_playlist\` (\`videoId\` varchar(255) NOT NULL, \`lastPlayedAt\` varchar(30) NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', \`title\` varchar(255) NOT NULL, \`seed\` float(12) NOT NULL, \`loudness\` float(12) NOT NULL, \`tags\` text NOT NULL, \`length\` int NOT NULL, \`volume\` int NOT NULL, \`startTime\` int NOT NULL, \`endTime\` int NOT NULL, \`forceVolume\` tinyint NOT NULL DEFAULT 0, PRIMARY KEY (\`videoId\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`song_request\` (\`id\` varchar(36) NOT NULL, \`videoId\` varchar(255) NOT NULL, \`addedAt\` varchar(30) NOT NULL, \`title\` varchar(255) NOT NULL, \`loudness\` float(12) NOT NULL, \`length\` int NOT NULL, \`username\` varchar(255) NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`spotify_song_ban\` (\`spotifyUri\` varchar(255) NOT NULL, \`title\` varchar(255) NOT NULL, \`artists\` text NOT NULL, PRIMARY KEY (\`spotifyUri\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`timer\` (\`id\` varchar(36) NOT NULL, \`name\` varchar(255) NOT NULL, \`isEnabled\` tinyint NOT NULL, \`tickOffline\` tinyint NOT NULL DEFAULT 0, \`triggerEveryMessage\` int NOT NULL, \`triggerEverySecond\` int NOT NULL, \`triggeredAtTimestamp\` varchar(30) NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', \`triggeredAtMessages\` int NOT NULL DEFAULT '0', PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`timer_response\` (\`id\` varchar(36) NOT NULL, \`timestamp\` varchar(30) NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', \`isEnabled\` tinyint NOT NULL DEFAULT 1, \`response\` text NOT NULL, \`timerId\` varchar(36) NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`variable_watch\` (\`id\` int NOT NULL AUTO_INCREMENT, \`variableId\` varchar(255) NOT NULL, \`order\` int NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`variable\` (\`id\` varchar(36) NOT NULL, \`history\` json NOT NULL, \`urls\` json NOT NULL, \`variableName\` varchar(255) NOT NULL, \`description\` varchar(255) NOT NULL DEFAULT '', \`type\` varchar(255) NOT NULL, \`currentValue\` varchar(255) NULL, \`evalValue\` text NOT NULL, \`runEvery\` int NOT NULL DEFAULT '60000', \`responseType\` int NOT NULL, \`responseText\` varchar(255) NOT NULL DEFAULT '', \`permission\` varchar(255) NOT NULL, \`readOnly\` tinyint NOT NULL DEFAULT 0, \`usableOptions\` text NOT NULL, \`runAt\` varchar(30) NOT NULL, UNIQUE INDEX \`IDX_dd084634ad76dbefdca837b8de\` (\`variableName\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`cache_games\` (\`id\` int NOT NULL, \`name\` varchar(255) NOT NULL, \`thumbnail\` varchar(255) NULL, INDEX \`IDX_f37be3c66dbd449a8cb4fe7d59\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`cache_titles\` (\`id\` int NOT NULL AUTO_INCREMENT, \`game\` varchar(255) NOT NULL, \`title\` varchar(255) NOT NULL, \`tags\` text NOT NULL, \`timestamp\` bigint NOT NULL, INDEX \`IDX_a0c6ce833b5b3b13325e6f49b0\` (\`game\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`carousel\` (\`id\` varchar(36) NOT NULL, \`order\` int NOT NULL, \`type\` varchar(255) NOT NULL, \`waitAfter\` int NOT NULL, \`waitBefore\` int NOT NULL, \`duration\` int NOT NULL, \`animationIn\` varchar(255) NOT NULL, \`animationInDuration\` int NOT NULL, \`animationOut\` varchar(255) NOT NULL, \`animationOutDuration\` int NOT NULL, \`showOnlyOncePerStream\` tinyint NOT NULL, \`base64\` longtext NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`checklist\` (\`id\` varchar(255) NOT NULL, \`isCompleted\` tinyint NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`quickaction\` (\`id\` varchar(36) NOT NULL, \`userId\` varchar(255) NOT NULL, \`order\` int NOT NULL, \`type\` varchar(255) NOT NULL, \`options\` text NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`discord_link\` (\`id\` varchar(36) NOT NULL, \`tag\` varchar(255) NOT NULL, \`discordId\` varchar(255) NOT NULL, \`createdAt\` bigint NOT NULL, \`userId\` varchar(255) NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`duel\` (\`id\` varchar(255) NOT NULL, \`username\` varchar(255) NOT NULL, \`tickets\` int NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`event\` (\`id\` varchar(36) NOT NULL, \`name\` varchar(255) NOT NULL, \`isEnabled\` tinyint NOT NULL, \`triggered\` text NOT NULL, \`definitions\` text NOT NULL, \`filter\` varchar(255) NOT NULL, INDEX \`IDX_b535fbe8ec6d832dde22065ebd\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`event_operation\` (\`id\` varchar(36) NOT NULL, \`name\` varchar(255) NOT NULL, \`definitions\` text NOT NULL, \`eventId\` varchar(36) NULL, INDEX \`IDX_daf6b97e1e5a5c779055fbb22d\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`event_list\` (\`id\` varchar(36) NOT NULL, \`event\` varchar(255) NOT NULL, \`userId\` varchar(255) NOT NULL, \`timestamp\` bigint NOT NULL, \`isTest\` tinyint NOT NULL, \`isHidden\` tinyint NOT NULL DEFAULT 0, \`values_json\` text NOT NULL, INDEX \`IDX_8a80a3cf6b2d815920a390968a\` (\`userId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`gallery\` (\`id\` varchar(255) NOT NULL, \`type\` varchar(255) NOT NULL, \`data\` longtext NOT NULL, \`name\` varchar(255) NOT NULL, \`folder\` varchar(255) NOT NULL DEFAULT '/', PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`goal_group\` (\`id\` varchar(36) NOT NULL, \`createdAt\` varchar(255) NOT NULL, \`name\` varchar(255) NOT NULL, \`display\` text NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`goal\` (\`id\` varchar(36) NOT NULL, \`name\` varchar(255) NOT NULL, \`groupId\` varchar(255) NULL, \`type\` varchar(20) NOT NULL, \`countBitsAsTips\` tinyint NOT NULL, \`display\` varchar(20) NOT NULL, \`timestamp\` varchar(255) NULL, \`interval\` varchar(255) NOT NULL DEFAULT 'hour', \`tiltifyCampaign\` int NULL, \`goalAmount\` float(12) NOT NULL DEFAULT '0', \`currentAmount\` float(12) NOT NULL DEFAULT '0', \`endAfter\` varchar(255) NOT NULL, \`endAfterIgnore\` tinyint NOT NULL, \`customizationBar\` text NOT NULL, \`customizationFont\` text NOT NULL, \`customizationHtml\` text NOT NULL, \`customizationJs\` text NOT NULL, \`customizationCss\` text NOT NULL, INDEX \`IDX_a1a6bd23cb8ef7ddf921f54c0b\` (\`groupId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`google_private_keys\` (\`id\` varchar(36) NOT NULL, \`clientEmail\` varchar(255) NOT NULL, \`privateKey\` longtext NOT NULL, \`createdAt\` varchar(255) NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`heist_user\` (\`userId\` varchar(255) NOT NULL, \`username\` varchar(255) NOT NULL, \`points\` bigint NOT NULL, PRIMARY KEY (\`userId\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`moderation_warning\` (\`id\` varchar(36) NOT NULL, \`userId\` varchar(255) NOT NULL, \`timestamp\` bigint NOT NULL DEFAULT '0', INDEX \`IDX_f941603aef2741795a9108d0d2\` (\`userId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`moderation_permit\` (\`id\` varchar(36) NOT NULL, \`userId\` varchar(255) NOT NULL, INDEX \`IDX_69499e78c9ee1602baee77b97d\` (\`userId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`points_changelog\` (\`id\` int NOT NULL AUTO_INCREMENT, \`userId\` varchar(255) NOT NULL, \`originalValue\` int NOT NULL, \`updatedValue\` int NOT NULL, \`updatedAt\` bigint NOT NULL, \`command\` varchar(255) NOT NULL, INDEX \`IDX_points_changelog_userId\` (\`userId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`queue\` (\`id\` int NOT NULL AUTO_INCREMENT, \`createdAt\` bigint NOT NULL, \`username\` varchar(255) NOT NULL, \`isModerator\` tinyint NOT NULL, \`isSubscriber\` tinyint NOT NULL, \`message\` varchar(255) NULL, UNIQUE INDEX \`IDX_7401b4e0c30f5de6621b38f7a0\` (\`username\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`raffle\` (\`id\` varchar(36) NOT NULL, \`winner\` text NULL, \`timestamp\` bigint NOT NULL DEFAULT '0', \`keyword\` varchar(255) NOT NULL, \`minTickets\` bigint NOT NULL DEFAULT '0', \`maxTickets\` bigint NOT NULL DEFAULT '0', \`type\` int NOT NULL, \`forSubscribers\` tinyint NOT NULL, \`isClosed\` tinyint NOT NULL DEFAULT 0, INDEX \`IDX_e83facaeb8fbe8b8ce9577209a\` (\`keyword\`), INDEX \`IDX_raffleIsClosed\` (\`isClosed\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`raffle_participant\` (\`id\` varchar(36) NOT NULL, \`username\` varchar(255) NOT NULL, \`tickets\` int NOT NULL, \`isEligible\` tinyint NOT NULL, \`isSubscriber\` tinyint NOT NULL, \`raffleId\` varchar(36) NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`raffle_participant_message\` (\`id\` varchar(36) NOT NULL, \`timestamp\` bigint NOT NULL DEFAULT '0', \`text\` text NOT NULL, \`participantId\` varchar(36) NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`scrim_match_id\` (\`id\` varchar(36) NOT NULL, \`username\` varchar(255) NOT NULL, \`matchId\` varchar(255) NOT NULL, UNIQUE INDEX \`IDX_5af6da125c1745151e0dfaf087\` (\`username\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`text\` (\`id\` varchar(36) NOT NULL, \`name\` varchar(255) NOT NULL, \`text\` text NOT NULL, \`css\` text NOT NULL, \`js\` text NOT NULL, \`external\` text NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`translation\` (\`name\` varchar(255) NOT NULL, \`value\` varchar(255) NOT NULL, PRIMARY KEY (\`name\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`twitch_stats\` (\`whenOnline\` bigint NOT NULL, \`currentViewers\` int NOT NULL DEFAULT '0', \`currentSubscribers\` int NOT NULL DEFAULT '0', \`chatMessages\` bigint NOT NULL, \`currentFollowers\` int NOT NULL DEFAULT '0', \`maxViewers\` int NOT NULL DEFAULT '0', \`newChatters\` int NOT NULL DEFAULT '0', \`currentBits\` bigint NOT NULL, \`currentTips\` float(12) NOT NULL, \`currentWatched\` bigint NOT NULL, PRIMARY KEY (\`whenOnline\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`twitch_clips\` (\`clipId\` varchar(255) NOT NULL, \`isChecked\` tinyint NOT NULL, \`shouldBeCheckedAt\` bigint NOT NULL, PRIMARY KEY (\`clipId\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`user\` (\`userId\` varchar(255) NOT NULL, \`userName\` varchar(255) NOT NULL, \`displayname\` varchar(255) NOT NULL DEFAULT '', \`profileImageUrl\` varchar(255) NOT NULL DEFAULT '', \`isOnline\` tinyint NOT NULL DEFAULT 0, \`isVIP\` tinyint NOT NULL DEFAULT 0, \`isModerator\` tinyint NOT NULL DEFAULT 0, \`isSubscriber\` tinyint NOT NULL DEFAULT 0, \`haveSubscriberLock\` tinyint NOT NULL DEFAULT 0, \`haveSubscribedAtLock\` tinyint NOT NULL DEFAULT 0, \`rank\` varchar(255) NOT NULL DEFAULT '', \`haveCustomRank\` tinyint NOT NULL DEFAULT 0, \`subscribedAt\` varchar(30) NULL, \`seenAt\` varchar(30) NULL, \`createdAt\` varchar(30) NULL, \`watchedTime\` bigint NOT NULL DEFAULT '0', \`chatTimeOnline\` bigint NOT NULL DEFAULT '0', \`chatTimeOffline\` bigint NOT NULL DEFAULT '0', \`points\` bigint NOT NULL DEFAULT '0', \`pointsOnlineGivenAt\` bigint NOT NULL DEFAULT '0', \`pointsOfflineGivenAt\` bigint NOT NULL DEFAULT '0', \`pointsByMessageGivenAt\` bigint NOT NULL DEFAULT '0', \`subscribeTier\` varchar(255) NOT NULL DEFAULT '0', \`subscribeCumulativeMonths\` int NOT NULL DEFAULT '0', \`subscribeStreak\` int NOT NULL DEFAULT '0', \`giftedSubscribes\` bigint NOT NULL DEFAULT '0', \`messages\` bigint NOT NULL DEFAULT '0', \`extra\` text NULL, INDEX \`IDX_78a916df40e02a9deb1c4b75ed\` (\`userName\`), PRIMARY KEY (\`userId\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`user_tip\` (\`id\` int NOT NULL AUTO_INCREMENT, \`amount\` float(12) NOT NULL, \`sortAmount\` float(12) NOT NULL, \`exchangeRates\` text NOT NULL, \`currency\` varchar(255) NOT NULL, \`message\` text NOT NULL, \`tippedAt\` bigint NOT NULL DEFAULT '0', \`userId\` varchar(255) NULL, INDEX \`IDX_user_tip_userId\` (\`userId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`user_bit\` (\`id\` int NOT NULL AUTO_INCREMENT, \`amount\` bigint NOT NULL, \`message\` text NOT NULL, \`cheeredAt\` bigint NOT NULL DEFAULT '0', \`userId\` varchar(255) NULL, INDEX \`IDX_user_bit_userId\` (\`userId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`widget_custom\` (\`id\` varchar(255) NOT NULL, \`userId\` varchar(255) NOT NULL, \`url\` varchar(255) NOT NULL, \`name\` varchar(255) NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`widget_social\` (\`id\` varchar(255) NOT NULL, \`type\` varchar(255) NOT NULL, \`hashtag\` varchar(255) NOT NULL, \`text\` text NOT NULL, \`username\` varchar(255) NOT NULL, \`displayname\` varchar(255) NOT NULL, \`url\` varchar(255) NOT NULL, \`timestamp\` bigint NOT NULL DEFAULT '0', PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`ALTER TABLE \`keyword_responses\` ADD CONSTRAINT \`FK_d12716a3805d58dd75ab09c8c67\` FOREIGN KEY (\`keywordId\`) REFERENCES \`keyword\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`timer_response\` ADD CONSTRAINT \`FK_3192b176b66d4375368c9e960de\` FOREIGN KEY (\`timerId\`) REFERENCES \`timer\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`event_operation\` ADD CONSTRAINT \`FK_a9f07bd7a9f0b7b9d41f48b476d\` FOREIGN KEY (\`eventId\`) REFERENCES \`event\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`goal\` ADD CONSTRAINT \`FK_a1a6bd23cb8ef7ddf921f54c0bb\` FOREIGN KEY (\`groupId\`) REFERENCES \`goal_group\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`raffle_participant\` ADD CONSTRAINT \`FK_bc112542267bdd487f4479a94a1\` FOREIGN KEY (\`raffleId\`) REFERENCES \`raffle\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE \`raffle_participant_message\` ADD CONSTRAINT \`FK_e6eda53bcd6ceb62b5edd9e02b5\` FOREIGN KEY (\`participantId\`) REFERENCES \`raffle_participant\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} \ No newline at end of file diff --git a/backend/src/database/migration/mysql/17.0.x/1678892044030-removeBets.ts b/backend/src/database/migration/mysql/17.0.x/1678892044030-removeBets.ts new file mode 100644 index 000000000..af50e18ec --- /dev/null +++ b/backend/src/database/migration/mysql/17.0.x/1678892044030-removeBets.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeBets678892044030 implements MigrationInterface { + name = 'removeBets1678892044030'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`bets\``); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/mysql/17.0.x/1678892044030-removePolls.ts b/backend/src/database/migration/mysql/17.0.x/1678892044030-removePolls.ts new file mode 100644 index 000000000..d1b0925d2 --- /dev/null +++ b/backend/src/database/migration/mysql/17.0.x/1678892044030-removePolls.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removePoll1678892044030 implements MigrationInterface { + name = 'removePoll1678892044030'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`poll\``); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/mysql/17.0.x/1678892044031-revertInactiveUsers.ts b/backend/src/database/migration/mysql/17.0.x/1678892044031-revertInactiveUsers.ts new file mode 100644 index 000000000..b604982b9 --- /dev/null +++ b/backend/src/database/migration/mysql/17.0.x/1678892044031-revertInactiveUsers.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class revertInactiveUsers1678892044031 implements MigrationInterface { + name = 'revertInactiveUsers1678892044031'; + + public async up(queryRunner: QueryRunner): Promise { + const users = await queryRunner.query(`SELECT * FROM \`user\``); + for (const user of users) { + await queryRunner.query(`UPDATE \`user\` SET \`userName\`=? WHERE \`userId\`=?`, + [user.userName.replace(/__inactive__/g, ''), user.userId]); + } + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/mysql/17.0.x/1678892044032-removeCarousel.ts b/backend/src/database/migration/mysql/17.0.x/1678892044032-removeCarousel.ts new file mode 100644 index 000000000..d9869764d --- /dev/null +++ b/backend/src/database/migration/mysql/17.0.x/1678892044032-removeCarousel.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeCarousel1678892044032 implements MigrationInterface { + name = 'removeCarousel1678892044032'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`carousel\``); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/mysql/17.0.x/1678892044033-removeGoal.ts b/backend/src/database/migration/mysql/17.0.x/1678892044033-removeGoal.ts new file mode 100644 index 000000000..36f11ebe4 --- /dev/null +++ b/backend/src/database/migration/mysql/17.0.x/1678892044033-removeGoal.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeGoal1678892044033 implements MigrationInterface { + name = 'removeGoal1678892044033'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`goal\``); + await queryRunner.query(`DROP TABLE \`goal_group\``); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/mysql/18.0.x/1678892044032-removeText.ts b/backend/src/database/migration/mysql/18.0.x/1678892044032-removeText.ts new file mode 100644 index 000000000..e0df100d2 --- /dev/null +++ b/backend/src/database/migration/mysql/18.0.x/1678892044032-removeText.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeText1678892044032 implements MigrationInterface { + name = 'removeText1678892044032'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`text\``); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/mysql/18.0.x/1678892044034-addContentClasificationLabels.ts b/backend/src/database/migration/mysql/18.0.x/1678892044034-addContentClasificationLabels.ts new file mode 100644 index 000000000..068bd9157 --- /dev/null +++ b/backend/src/database/migration/mysql/18.0.x/1678892044034-addContentClasificationLabels.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class addContentClasificationLabels1678892044034 implements MigrationInterface { + name = 'addContentClasificationLabels1678892044034'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`cache_titles\` ADD \`content_classification_labels\` text NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } +} diff --git a/backend/src/database/migration/mysql/18.0.x/1678892044035-removeSocial.ts b/backend/src/database/migration/mysql/18.0.x/1678892044035-removeSocial.ts new file mode 100644 index 000000000..a9b26d7e3 --- /dev/null +++ b/backend/src/database/migration/mysql/18.0.x/1678892044035-removeSocial.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeSocial1678892044035 implements MigrationInterface { + name = 'removeSocial1678892044035'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE \`widget_social\``); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/postgres/1000000000001-initialize.ts b/backend/src/database/migration/postgres/1000000000001-initialize.ts new file mode 100644 index 000000000..a62c48f73 --- /dev/null +++ b/backend/src/database/migration/postgres/1000000000001-initialize.ts @@ -0,0 +1,120 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class initialize1000000000001 implements MigrationInterface { + transaction?: boolean | undefined; + name = 'initialize1000000000001'; + + public async up(queryRunner: QueryRunner): Promise { + const migrations = await queryRunner.query(`SELECT * FROM "migrations"`); + if (migrations.length > 0) { + console.log('Skipping migration zero, migrations are already in bot'); + return; + } + await queryRunner.query(`CREATE TABLE "alert" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "updatedAt" character varying(30), "name" character varying NOT NULL, "alertDelayInMs" integer NOT NULL, "profanityFilterType" character varying NOT NULL, "loadStandardProfanityList" json NOT NULL, "parry" json NOT NULL, "tts" json, "fontMessage" json NOT NULL, "font" json NOT NULL, "customProfanityList" character varying NOT NULL, "items" json NOT NULL, CONSTRAINT "PK_ad91cad659a3536465d564a4b2f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "alias" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "alias" character varying NOT NULL, "command" text NOT NULL, "enabled" boolean NOT NULL, "visible" boolean NOT NULL, "permission" character varying, "group" character varying, CONSTRAINT "PK_b1848d04b41d10a5712fc2e673c" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_6a8a594f0a5546f8082b0c405c" ON "alias" ("alias") `); + await queryRunner.query(`CREATE TABLE "alias_group" ("name" character varying NOT NULL, "options" text NOT NULL, CONSTRAINT "PK_2d40a2a41c8eb8d436b6ce1387c" PRIMARY KEY ("name"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_alias_group_unique_name" ON "alias_group" ("name") `); + await queryRunner.query(`CREATE TABLE "bets" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" character varying(30) NOT NULL, "endedAt" character varying(30) NOT NULL, "isLocked" boolean NOT NULL DEFAULT false, "arePointsGiven" boolean NOT NULL DEFAULT false, "options" text NOT NULL, "title" character varying NOT NULL, "participants" json NOT NULL, CONSTRAINT "PK_7ca91a6a39623bd5c21722bcedd" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "commands" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "command" character varying NOT NULL, "enabled" boolean NOT NULL, "visible" boolean NOT NULL, "group" character varying, "areResponsesRandomized" boolean NOT NULL DEFAULT false, "responses" json NOT NULL, CONSTRAINT "PK_7ac292c3aa19300482b2b190d1e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_1a8c40f0a581447776c325cb4f" ON "commands" ("command") `); + await queryRunner.query(`CREATE TABLE "commands_group" ("name" character varying NOT NULL, "options" text NOT NULL, CONSTRAINT "PK_34de021816f3e460bf084d25aba" PRIMARY KEY ("name"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_commands_group_unique_name" ON "commands_group" ("name") `); + await queryRunner.query(`CREATE TABLE "commands_count" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "command" character varying NOT NULL, "timestamp" character varying(30) NOT NULL, CONSTRAINT "PK_80e221b846abb1a84ab81281a7a" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_2ccf816b1dd74e9a02845c4818" ON "commands_count" ("command") `); + await queryRunner.query(`CREATE TABLE "cooldown" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "miliseconds" integer NOT NULL, "type" character varying(10) NOT NULL, "timestamp" character varying(30) NOT NULL, "isEnabled" boolean NOT NULL, "isErrorMsgQuiet" boolean NOT NULL, "isOwnerAffected" boolean NOT NULL, "isModeratorAffected" boolean NOT NULL, "isSubscriberAffected" boolean NOT NULL, CONSTRAINT "PK_0f01954311dda5b3d353603c7c5" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_aa85aa267ec6eaddf7f93e3665" ON "cooldown" ("name") `); + await queryRunner.query(`CREATE TABLE "highlight" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "videoId" character varying NOT NULL, "game" character varying NOT NULL, "title" character varying NOT NULL, "expired" boolean NOT NULL DEFAULT false, "timestamp" json NOT NULL, "createdAt" character varying(30) NOT NULL, CONSTRAINT "PK_0f4191998a1e1e8f8455f1d4adb" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "how_long_to_beat_game" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "game" character varying NOT NULL, "startedAt" character varying(30) NOT NULL, "updatedAt" character varying(30) NOT NULL, "gameplayMain" double precision NOT NULL DEFAULT '0', "gameplayMainExtra" double precision NOT NULL DEFAULT '0', "gameplayCompletionist" double precision NOT NULL DEFAULT '0', "offset" bigint NOT NULL DEFAULT '0', "streams" json NOT NULL, CONSTRAINT "PK_c6fbf5fc15e97e46c2659dccea1" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_301758e0e3108fc902d5436527" ON "how_long_to_beat_game" ("game") `); + await queryRunner.query(`CREATE TABLE "keyword" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "keyword" character varying NOT NULL, "enabled" boolean NOT NULL, "group" character varying, CONSTRAINT "PK_affdb8c8fa5b442900cb3aa21dc" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_35e3ff88225eef1d85c951e229" ON "keyword" ("keyword") `); + await queryRunner.query(`CREATE TABLE "keyword_responses" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "order" integer NOT NULL, "response" text NOT NULL, "stopIfExecuted" boolean NOT NULL, "permission" character varying, "filter" character varying NOT NULL, "keywordId" uuid, CONSTRAINT "PK_3049091cd170cc88ad38bcca63f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "keyword_group" ("name" character varying NOT NULL, "options" text NOT NULL, CONSTRAINT "PK_25e81b041cf1f67ea9ce294fd91" PRIMARY KEY ("name"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_keyword_group_unique_name" ON "keyword_group" ("name") `); + await queryRunner.query(`CREATE TABLE "obswebsocket" ("id" character varying(14) NOT NULL, "name" character varying NOT NULL, "code" text NOT NULL, CONSTRAINT "PK_e02d10a34d5a7da25a92d4572de" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "overlay" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "canvas" json NOT NULL, "items" json NOT NULL, CONSTRAINT "PK_2abda96f999ea44fd200bfef741" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "permissions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "order" integer NOT NULL, "isCorePermission" boolean NOT NULL, "isWaterfallAllowed" boolean NOT NULL, "automation" character varying(12) NOT NULL, "userIds" text NOT NULL, "excludeUserIds" text NOT NULL, "filters" json NOT NULL, CONSTRAINT "PK_920331560282b8bd21bb02290df" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "permission_commands" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "permission" character varying(36), CONSTRAINT "PK_bfbb3cdf4fc0add3e790ba7ce59" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_ba6483f5c5882fa15299f22c0a" ON "permission_commands" ("name") `); + await queryRunner.query(`CREATE TABLE "settings" ("id" SERIAL NOT NULL, "namespace" character varying NOT NULL, "name" character varying NOT NULL, "value" text NOT NULL, CONSTRAINT "PK_0669fe20e252eb692bf4d344975" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d8a83b9ffce680092c8dfee37d" ON "settings" ("namespace", "name") `); + await queryRunner.query(`CREATE TABLE "plugin" ("id" character varying NOT NULL, "name" character varying NOT NULL, "enabled" boolean NOT NULL, "workflow" text NOT NULL, "settings" text, CONSTRAINT "PK_9a65387180b2e67287345684c03" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "plugin_variable" ("variableName" character varying NOT NULL, "pluginId" character varying NOT NULL, "value" text NOT NULL, CONSTRAINT "PK_8c7cf84aebae071dcbdb47381d6" PRIMARY KEY ("variableName", "pluginId"))`); + await queryRunner.query(`CREATE TABLE "poll" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying(7) NOT NULL, "title" character varying NOT NULL, "openedAt" character varying(30) NOT NULL, "closedAt" character varying(30), "options" text NOT NULL, "votes" json NOT NULL, CONSTRAINT "PK_03b5cf19a7f562b231c3458527e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "price" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "command" character varying NOT NULL, "enabled" boolean NOT NULL DEFAULT true, "emitRedeemEvent" boolean NOT NULL DEFAULT false, "price" integer NOT NULL, "priceBits" integer NOT NULL DEFAULT '0', CONSTRAINT "PK_d163e55e8cce6908b2e0f27cea4" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d12db23d28020784096bcb41a3" ON "price" ("command") `); + await queryRunner.query(`CREATE TABLE "quotes" ("id" SERIAL NOT NULL, "tags" text NOT NULL, "quote" character varying NOT NULL, "quotedBy" character varying NOT NULL, "createdAt" character varying(30) NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', CONSTRAINT "PK_99a0e8bcbcd8719d3a41f23c263" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "randomizer" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "items" json NOT NULL, "createdAt" character varying(30) NOT NULL, "command" character varying NOT NULL, "permissionId" character varying NOT NULL, "name" character varying NOT NULL, "isShown" boolean NOT NULL DEFAULT false, "shouldPlayTick" boolean NOT NULL, "tickVolume" integer NOT NULL, "widgetOrder" integer NOT NULL, "type" character varying(20) NOT NULL DEFAULT 'simple', "position" json NOT NULL, "customizationFont" json NOT NULL, "tts" json NOT NULL, CONSTRAINT "PK_027539f48a550dda46773420ad7" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "idx_randomizer_cmdunique" ON "randomizer" ("command") `); + await queryRunner.query(`CREATE TABLE "rank" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "value" integer NOT NULL, "rank" character varying NOT NULL, "type" character varying NOT NULL, CONSTRAINT "PK_a5dfd2e605e5e4fb8578caec083" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_93c78c94804a13befdace81904" ON "rank" ("type", "value") `); + await queryRunner.query(`CREATE TABLE "song_ban" ("videoId" character varying NOT NULL, "title" character varying NOT NULL, CONSTRAINT "PK_387b2109574f60ff7797b9206e7" PRIMARY KEY ("videoId"))`); + await queryRunner.query(`CREATE TABLE "song_playlist" ("videoId" character varying NOT NULL, "lastPlayedAt" character varying(30) NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', "title" character varying NOT NULL, "seed" double precision NOT NULL, "loudness" double precision NOT NULL, "tags" text NOT NULL, "length" integer NOT NULL, "volume" integer NOT NULL, "startTime" integer NOT NULL, "endTime" integer NOT NULL, "forceVolume" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_47041c19b2a8a264b51a592c9d0" PRIMARY KEY ("videoId"))`); + await queryRunner.query(`CREATE TABLE "song_request" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "videoId" character varying NOT NULL, "addedAt" character varying(30) NOT NULL, "title" character varying NOT NULL, "loudness" double precision NOT NULL, "length" integer NOT NULL, "username" character varying NOT NULL, CONSTRAINT "PK_c2b53ff7f5fc5bf370a3f32ebf8" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "spotify_song_ban" ("spotifyUri" character varying NOT NULL, "title" character varying NOT NULL, "artists" text NOT NULL, CONSTRAINT "PK_f9ba62ed678a1e426db17acc387" PRIMARY KEY ("spotifyUri"))`); + await queryRunner.query(`CREATE TABLE "timer" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "isEnabled" boolean NOT NULL, "tickOffline" boolean NOT NULL DEFAULT false, "triggerEveryMessage" integer NOT NULL, "triggerEverySecond" integer NOT NULL, "triggeredAtTimestamp" character varying(30) NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', "triggeredAtMessages" integer NOT NULL DEFAULT '0', CONSTRAINT "PK_b476163e854c74bff55b29d320a" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "timer_response" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "timestamp" character varying(30) NOT NULL DEFAULT '1970-01-01T00:00:00.000Z', "isEnabled" boolean NOT NULL DEFAULT true, "response" text NOT NULL, "timerId" uuid, CONSTRAINT "PK_785cbaf79acecb2971252bf609e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "variable_watch" ("id" SERIAL NOT NULL, "variableId" character varying NOT NULL, "order" integer NOT NULL, CONSTRAINT "PK_fa090e3c43468f9b1793439cb5e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "variable" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "history" json NOT NULL, "urls" json NOT NULL, "variableName" character varying NOT NULL, "description" character varying NOT NULL DEFAULT '', "type" character varying NOT NULL, "currentValue" character varying, "evalValue" text NOT NULL, "runEvery" integer NOT NULL DEFAULT '60000', "responseType" integer NOT NULL, "responseText" character varying NOT NULL DEFAULT '', "permission" character varying NOT NULL, "readOnly" boolean NOT NULL DEFAULT false, "usableOptions" text NOT NULL, "runAt" character varying(30) NOT NULL, CONSTRAINT "UQ_dd084634ad76dbefdca837b8de4" UNIQUE ("variableName"), CONSTRAINT "PK_f4e200785984484787e6b47e6fb" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "cache_games" ("id" integer NOT NULL, "name" character varying NOT NULL, "thumbnail" character varying, CONSTRAINT "PK_83498942a78ff5d6309d91cf13e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_f37be3c66dbd449a8cb4fe7d59" ON "cache_games" ("name") `); + await queryRunner.query(`CREATE TABLE "cache_titles" ("id" SERIAL NOT NULL, "game" character varying NOT NULL, "title" character varying NOT NULL, "tags" text NOT NULL, "timestamp" bigint NOT NULL, CONSTRAINT "PK_a2a0f1db2d0b215a771c14538a2" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_a0c6ce833b5b3b13325e6f49b0" ON "cache_titles" ("game") `); + await queryRunner.query(`CREATE TABLE "carousel" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "order" integer NOT NULL, "type" character varying NOT NULL, "waitAfter" integer NOT NULL, "waitBefore" integer NOT NULL, "duration" integer NOT NULL, "animationIn" character varying NOT NULL, "animationInDuration" integer NOT NULL, "animationOut" character varying NOT NULL, "animationOutDuration" integer NOT NULL, "showOnlyOncePerStream" boolean NOT NULL, "base64" text NOT NULL, CONSTRAINT "PK_d59e0674c5a5efe523df247f67b" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "checklist" ("id" character varying NOT NULL, "isCompleted" boolean NOT NULL, CONSTRAINT "PK_e4b437f5107f2a9d5b744d4eb4c" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "quickaction" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" character varying NOT NULL, "order" integer NOT NULL, "type" character varying NOT NULL, "options" text NOT NULL, CONSTRAINT "PK_b77fe99fe6a95cf4119e6756ca5" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "discord_link" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "tag" character varying NOT NULL, "discordId" character varying NOT NULL, "createdAt" bigint NOT NULL, "userId" character varying, CONSTRAINT "PK_51c82ec49736e25315b01dad663" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "duel" ("id" character varying NOT NULL, "username" character varying NOT NULL, "tickets" integer NOT NULL, CONSTRAINT "PK_1575a4255b3bdf1f11398841d0d" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "event" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "isEnabled" boolean NOT NULL, "triggered" text NOT NULL, "definitions" text NOT NULL, "filter" character varying NOT NULL, CONSTRAINT "PK_30c2f3bbaf6d34a55f8ae6e4614" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_b535fbe8ec6d832dde22065ebd" ON "event" ("name") `); + await queryRunner.query(`CREATE TABLE "event_operation" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "definitions" text NOT NULL, "eventId" uuid, CONSTRAINT "PK_ac1058d607aa18a9af827c36247" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_daf6b97e1e5a5c779055fbb22d" ON "event_operation" ("name") `); + await queryRunner.query(`CREATE TABLE "event_list" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "event" character varying NOT NULL, "userId" character varying NOT NULL, "timestamp" bigint NOT NULL, "isTest" boolean NOT NULL, "isHidden" boolean NOT NULL DEFAULT false, "values_json" text NOT NULL, CONSTRAINT "PK_1cc2e9353e9ae8acf95d976cf6f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_8a80a3cf6b2d815920a390968a" ON "event_list" ("userId") `); + await queryRunner.query(`CREATE TABLE "gallery" ("id" character varying NOT NULL, "type" character varying NOT NULL, "data" text NOT NULL, "name" character varying NOT NULL, "folder" character varying NOT NULL DEFAULT '/', CONSTRAINT "PK_65d7a1ef91ddafb3e7071b188a0" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "goal_group" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" character varying NOT NULL, "name" character varying NOT NULL, "display" text NOT NULL, CONSTRAINT "PK_22b802b42def291fab90fdcda14" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "goal" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "groupId" uuid, "type" character varying(20) NOT NULL, "countBitsAsTips" boolean NOT NULL, "display" character varying(20) NOT NULL, "timestamp" character varying, "interval" character varying NOT NULL DEFAULT 'hour', "tiltifyCampaign" integer, "goalAmount" double precision NOT NULL DEFAULT '0', "currentAmount" double precision NOT NULL DEFAULT '0', "endAfter" character varying NOT NULL, "endAfterIgnore" boolean NOT NULL, "customizationBar" text NOT NULL, "customizationFont" text NOT NULL, "customizationHtml" text NOT NULL, "customizationJs" text NOT NULL, "customizationCss" text NOT NULL, CONSTRAINT "PK_88c8e2b461b711336c836b1e130" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_a1a6bd23cb8ef7ddf921f54c0b" ON "goal" ("groupId") `); + await queryRunner.query(`CREATE TABLE "google_private_keys" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "clientEmail" character varying NOT NULL, "privateKey" text NOT NULL, "createdAt" character varying NOT NULL, CONSTRAINT "PK_dd2e74a8b7a602b6b4a1f1e1816" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "heist_user" ("userId" character varying NOT NULL, "username" character varying NOT NULL, "points" bigint NOT NULL, CONSTRAINT "PK_0a41172961540da3de15a9d223d" PRIMARY KEY ("userId"))`); + await queryRunner.query(`CREATE TABLE "moderation_warning" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" character varying NOT NULL, "timestamp" bigint NOT NULL DEFAULT '0', CONSTRAINT "PK_0e90c9d7ff04a18218299cfc0e9" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_f941603aef2741795a9108d0d2" ON "moderation_warning" ("userId") `); + await queryRunner.query(`CREATE TABLE "moderation_permit" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" character varying NOT NULL, CONSTRAINT "PK_ba3b81de5de7feff025898b4a63" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_69499e78c9ee1602baee77b97d" ON "moderation_permit" ("userId") `); + await queryRunner.query(`CREATE TABLE "points_changelog" ("id" SERIAL NOT NULL, "userId" character varying NOT NULL, "originalValue" integer NOT NULL, "updatedValue" integer NOT NULL, "updatedAt" bigint NOT NULL, "command" character varying NOT NULL, CONSTRAINT "PK_0c0431424ad9af4002e606a5337" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_points_changelog_userId" ON "points_changelog" ("userId") `); + await queryRunner.query(`CREATE TABLE "queue" ("id" SERIAL NOT NULL, "createdAt" bigint NOT NULL, "username" character varying NOT NULL, "isModerator" boolean NOT NULL, "isSubscriber" boolean NOT NULL, "message" character varying, CONSTRAINT "PK_4adefbd9c73b3f9a49985a5529f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_7401b4e0c30f5de6621b38f7a0" ON "queue" ("username") `); + await queryRunner.query(`CREATE TABLE "raffle" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "winner" text, "timestamp" bigint NOT NULL DEFAULT '0', "keyword" character varying NOT NULL, "minTickets" bigint NOT NULL DEFAULT '0', "maxTickets" bigint NOT NULL DEFAULT '0', "type" integer NOT NULL, "forSubscribers" boolean NOT NULL, "isClosed" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_f9dee47f552e25482a1f65c282e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_e83facaeb8fbe8b8ce9577209a" ON "raffle" ("keyword") `); + await queryRunner.query(`CREATE INDEX "IDX_raffleIsClosed" ON "raffle" ("isClosed") `); + await queryRunner.query(`CREATE TABLE "raffle_participant" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "username" character varying NOT NULL, "tickets" integer NOT NULL, "isEligible" boolean NOT NULL, "isSubscriber" boolean NOT NULL, "raffleId" uuid, CONSTRAINT "PK_5d6f2b4fadbd927710cc2dd1e9f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "raffle_participant_message" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "timestamp" bigint NOT NULL DEFAULT '0', "text" text NOT NULL, "participantId" uuid, CONSTRAINT "PK_0355c24ac848612dc4232be2c0a" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "scrim_match_id" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "username" character varying NOT NULL, "matchId" character varying NOT NULL, CONSTRAINT "PK_1cfb598145201e3f643598cbffe" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_5af6da125c1745151e0dfaf087" ON "scrim_match_id" ("username") `); + await queryRunner.query(`CREATE TABLE "text" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "text" text NOT NULL, "css" text NOT NULL, "js" text NOT NULL, "external" text NOT NULL, CONSTRAINT "PK_ef734161ea7c326fedf699309f9" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "translation" ("name" character varying NOT NULL, "value" character varying NOT NULL, CONSTRAINT "PK_a672a9127c15aa989a43e25074b" PRIMARY KEY ("name"))`); + await queryRunner.query(`CREATE TABLE "twitch_stats" ("whenOnline" bigint NOT NULL, "currentViewers" integer NOT NULL DEFAULT '0', "currentSubscribers" integer NOT NULL DEFAULT '0', "chatMessages" bigint NOT NULL, "currentFollowers" integer NOT NULL DEFAULT '0', "maxViewers" integer NOT NULL DEFAULT '0', "newChatters" integer NOT NULL DEFAULT '0', "currentBits" bigint NOT NULL, "currentTips" double precision NOT NULL, "currentWatched" bigint NOT NULL, CONSTRAINT "PK_78b460c61f065e858da863e8102" PRIMARY KEY ("whenOnline"))`); + await queryRunner.query(`CREATE TABLE "twitch_clips" ("clipId" character varying NOT NULL, "isChecked" boolean NOT NULL, "shouldBeCheckedAt" bigint NOT NULL, CONSTRAINT "PK_5692cf462da9f11803b3cf8f3dc" PRIMARY KEY ("clipId"))`); + await queryRunner.query(`CREATE TABLE "user" ("userId" character varying NOT NULL, "userName" character varying NOT NULL, "displayname" character varying NOT NULL DEFAULT '', "profileImageUrl" character varying NOT NULL DEFAULT '', "isOnline" boolean NOT NULL DEFAULT false, "isVIP" boolean NOT NULL DEFAULT false, "isModerator" boolean NOT NULL DEFAULT false, "isSubscriber" boolean NOT NULL DEFAULT false, "haveSubscriberLock" boolean NOT NULL DEFAULT false, "haveSubscribedAtLock" boolean NOT NULL DEFAULT false, "rank" character varying NOT NULL DEFAULT '', "haveCustomRank" boolean NOT NULL DEFAULT false, "subscribedAt" character varying(30), "seenAt" character varying(30), "createdAt" character varying(30), "watchedTime" bigint NOT NULL DEFAULT '0', "chatTimeOnline" bigint NOT NULL DEFAULT '0', "chatTimeOffline" bigint NOT NULL DEFAULT '0', "points" bigint NOT NULL DEFAULT '0', "pointsOnlineGivenAt" bigint NOT NULL DEFAULT '0', "pointsOfflineGivenAt" bigint NOT NULL DEFAULT '0', "pointsByMessageGivenAt" bigint NOT NULL DEFAULT '0', "subscribeTier" character varying NOT NULL DEFAULT '0', "subscribeCumulativeMonths" integer NOT NULL DEFAULT '0', "subscribeStreak" integer NOT NULL DEFAULT '0', "giftedSubscribes" bigint NOT NULL DEFAULT '0', "messages" bigint NOT NULL DEFAULT '0', "extra" text, CONSTRAINT "PK_d72ea127f30e21753c9e229891e" PRIMARY KEY ("userId"))`); + await queryRunner.query(`CREATE INDEX "IDX_78a916df40e02a9deb1c4b75ed" ON "user" ("userName") `); + await queryRunner.query(`CREATE TABLE "user_tip" ("id" SERIAL NOT NULL, "amount" double precision NOT NULL, "sortAmount" double precision NOT NULL, "exchangeRates" text NOT NULL, "currency" character varying NOT NULL, "message" text NOT NULL, "tippedAt" bigint NOT NULL DEFAULT '0', "userId" character varying, CONSTRAINT "PK_0bea18dcc7e730784d58261dffd" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_user_tip_userId" ON "user_tip" ("userId") `); + await queryRunner.query(`CREATE TABLE "user_bit" ("id" SERIAL NOT NULL, "amount" bigint NOT NULL, "message" text NOT NULL, "cheeredAt" bigint NOT NULL DEFAULT '0', "userId" character varying, CONSTRAINT "PK_a944c6c776bab8b2e69126ed141" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_user_bit_userId" ON "user_bit" ("userId") `); + await queryRunner.query(`CREATE TABLE "widget_custom" ("id" character varying NOT NULL, "userId" character varying NOT NULL, "url" character varying NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_6e587fd12023c57ce45562ba99a" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "widget_social" ("id" character varying NOT NULL, "type" character varying NOT NULL, "hashtag" character varying NOT NULL, "text" text NOT NULL, "username" character varying NOT NULL, "displayname" character varying NOT NULL, "url" character varying NOT NULL, "timestamp" bigint NOT NULL DEFAULT '0', CONSTRAINT "PK_e57865605d678d69c5e4450f1fe" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "keyword_responses" ADD CONSTRAINT "FK_d12716a3805d58dd75ab09c8c67" FOREIGN KEY ("keywordId") REFERENCES "keyword"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "timer_response" ADD CONSTRAINT "FK_3192b176b66d4375368c9e960de" FOREIGN KEY ("timerId") REFERENCES "timer"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "event_operation" ADD CONSTRAINT "FK_a9f07bd7a9f0b7b9d41f48b476d" FOREIGN KEY ("eventId") REFERENCES "event"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "goal" ADD CONSTRAINT "FK_a1a6bd23cb8ef7ddf921f54c0bb" FOREIGN KEY ("groupId") REFERENCES "goal_group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "raffle_participant" ADD CONSTRAINT "FK_bc112542267bdd487f4479a94a1" FOREIGN KEY ("raffleId") REFERENCES "raffle"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "raffle_participant_message" ADD CONSTRAINT "FK_e6eda53bcd6ceb62b5edd9e02b5" FOREIGN KEY ("participantId") REFERENCES "raffle_participant"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} \ No newline at end of file diff --git a/backend/src/database/migration/postgres/17.0.x/1678892044030-removeBets.ts b/backend/src/database/migration/postgres/17.0.x/1678892044030-removeBets.ts new file mode 100644 index 000000000..4091f04e5 --- /dev/null +++ b/backend/src/database/migration/postgres/17.0.x/1678892044030-removeBets.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeBets678892044030 implements MigrationInterface { + name = 'removeBets1678892044030'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "bets"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/postgres/17.0.x/1678892044030-removePolls.ts b/backend/src/database/migration/postgres/17.0.x/1678892044030-removePolls.ts new file mode 100644 index 000000000..382bf8d17 --- /dev/null +++ b/backend/src/database/migration/postgres/17.0.x/1678892044030-removePolls.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removePoll1678892044030 implements MigrationInterface { + name = 'removePoll1678892044030'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "poll"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/postgres/17.0.x/1678892044031-revertInactiveUsers.ts b/backend/src/database/migration/postgres/17.0.x/1678892044031-revertInactiveUsers.ts new file mode 100644 index 000000000..5417fb86b --- /dev/null +++ b/backend/src/database/migration/postgres/17.0.x/1678892044031-revertInactiveUsers.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class revertInactiveUsers1678892044031 implements MigrationInterface { + name = 'revertInactiveUsers1678892044031'; + + public async up(queryRunner: QueryRunner): Promise { + const users = await queryRunner.query(`SELECT * FROM "user"`); + for (const user of users) { + await queryRunner.query(`UPDATE "user" SET "userName"=? WHERE "userId"=?`, + [user.userName.replace(/__inactive__/g, ''), user.userId]); + } + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/postgres/17.0.x/1678892044032-removeCarousel.ts b/backend/src/database/migration/postgres/17.0.x/1678892044032-removeCarousel.ts new file mode 100644 index 000000000..9d05cb599 --- /dev/null +++ b/backend/src/database/migration/postgres/17.0.x/1678892044032-removeCarousel.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeCarousel1678892044032 implements MigrationInterface { + name = 'removeCarousel1678892044032'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "carousel"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/postgres/17.0.x/1678892044033-removeGoal.ts b/backend/src/database/migration/postgres/17.0.x/1678892044033-removeGoal.ts new file mode 100644 index 000000000..6e381915c --- /dev/null +++ b/backend/src/database/migration/postgres/17.0.x/1678892044033-removeGoal.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeGoal1678892044033 implements MigrationInterface { + name = 'removeGoal1678892044033'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "goal"`); + await queryRunner.query(`DROP TABLE "goal_group"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/postgres/18.0.x/1678892044032-removeText.ts b/backend/src/database/migration/postgres/18.0.x/1678892044032-removeText.ts new file mode 100644 index 000000000..4827deebc --- /dev/null +++ b/backend/src/database/migration/postgres/18.0.x/1678892044032-removeText.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeText1678892044032 implements MigrationInterface { + name = 'removeText1678892044032'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "text"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/postgres/18.0.x/1678892044034-addContentClasificationLabels.ts b/backend/src/database/migration/postgres/18.0.x/1678892044034-addContentClasificationLabels.ts new file mode 100644 index 000000000..8aed667ad --- /dev/null +++ b/backend/src/database/migration/postgres/18.0.x/1678892044034-addContentClasificationLabels.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class addContentClasificationLabels1678892044034 implements MigrationInterface { + name = 'addContentClasificationLabels1678892044034'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "cache_titles" ADD "content_classification_labels" text NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } +} diff --git a/backend/src/database/migration/postgres/18.0.x/1678892044035-removeSocial.ts b/backend/src/database/migration/postgres/18.0.x/1678892044035-removeSocial.ts new file mode 100644 index 000000000..633f42d46 --- /dev/null +++ b/backend/src/database/migration/postgres/18.0.x/1678892044035-removeSocial.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeSocial1678892044035 implements MigrationInterface { + name = 'removeSocial1678892044035'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "widget_social"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/sqlite/1000000000001-initialize.ts b/backend/src/database/migration/sqlite/1000000000001-initialize.ts new file mode 100644 index 000000000..df4dba4e5 --- /dev/null +++ b/backend/src/database/migration/sqlite/1000000000001-initialize.ts @@ -0,0 +1,142 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class initialize1000000000001 implements MigrationInterface { + transaction?: boolean | undefined; + name = 'initialize1000000000001'; + + public async up(queryRunner: QueryRunner): Promise { + const migrations = await queryRunner.query(`SELECT * FROM "migrations"`); + if (migrations.length > 0) { + console.log('Skipping migration zero, migrations are already in bot'); + return; + } + await queryRunner.query(`CREATE TABLE "alert" ("id" varchar PRIMARY KEY NOT NULL, "updatedAt" varchar(30), "name" varchar NOT NULL, "alertDelayInMs" integer NOT NULL, "profanityFilterType" varchar NOT NULL, "loadStandardProfanityList" text NOT NULL, "parry" text NOT NULL, "tts" text, "fontMessage" text NOT NULL, "font" text NOT NULL, "customProfanityList" varchar NOT NULL, "items" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "alias" ("id" varchar PRIMARY KEY NOT NULL, "alias" varchar NOT NULL, "command" text NOT NULL, "enabled" boolean NOT NULL, "visible" boolean NOT NULL, "permission" varchar, "group" varchar)`); + await queryRunner.query(`CREATE INDEX "IDX_6a8a594f0a5546f8082b0c405c" ON "alias" ("alias") `); + await queryRunner.query(`CREATE TABLE "alias_group" ("name" varchar PRIMARY KEY NOT NULL, "options" text NOT NULL)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_alias_group_unique_name" ON "alias_group" ("name") `); + await queryRunner.query(`CREATE TABLE "bets" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" varchar(30) NOT NULL, "endedAt" varchar(30) NOT NULL, "isLocked" boolean NOT NULL DEFAULT (0), "arePointsGiven" boolean NOT NULL DEFAULT (0), "options" text NOT NULL, "title" varchar NOT NULL, "participants" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "commands" ("id" varchar PRIMARY KEY NOT NULL, "command" varchar NOT NULL, "enabled" boolean NOT NULL, "visible" boolean NOT NULL, "group" varchar, "areResponsesRandomized" boolean NOT NULL DEFAULT (0), "responses" text NOT NULL)`); + await queryRunner.query(`CREATE INDEX "IDX_1a8c40f0a581447776c325cb4f" ON "commands" ("command") `); + await queryRunner.query(`CREATE TABLE "commands_group" ("name" varchar PRIMARY KEY NOT NULL, "options" text NOT NULL)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_commands_group_unique_name" ON "commands_group" ("name") `); + await queryRunner.query(`CREATE TABLE "commands_count" ("id" varchar PRIMARY KEY NOT NULL, "command" varchar NOT NULL, "timestamp" varchar(30) NOT NULL)`); + await queryRunner.query(`CREATE INDEX "IDX_2ccf816b1dd74e9a02845c4818" ON "commands_count" ("command") `); + await queryRunner.query(`CREATE TABLE "cooldown" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "miliseconds" integer NOT NULL, "type" varchar(10) NOT NULL, "timestamp" varchar(30) NOT NULL, "isEnabled" boolean NOT NULL, "isErrorMsgQuiet" boolean NOT NULL, "isOwnerAffected" boolean NOT NULL, "isModeratorAffected" boolean NOT NULL, "isSubscriberAffected" boolean NOT NULL)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_aa85aa267ec6eaddf7f93e3665" ON "cooldown" ("name") `); + await queryRunner.query(`CREATE TABLE "highlight" ("id" varchar PRIMARY KEY NOT NULL, "videoId" varchar NOT NULL, "game" varchar NOT NULL, "title" varchar NOT NULL, "expired" boolean NOT NULL DEFAULT (0), "timestamp" text NOT NULL, "createdAt" varchar(30) NOT NULL)`); + await queryRunner.query(`CREATE TABLE "how_long_to_beat_game" ("id" varchar PRIMARY KEY NOT NULL, "game" varchar NOT NULL, "startedAt" varchar(30) NOT NULL, "updatedAt" varchar(30) NOT NULL, "gameplayMain" float NOT NULL DEFAULT (0), "gameplayMainExtra" float NOT NULL DEFAULT (0), "gameplayCompletionist" float NOT NULL DEFAULT (0), "offset" bigint NOT NULL DEFAULT (0), "streams" text NOT NULL)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_301758e0e3108fc902d5436527" ON "how_long_to_beat_game" ("game") `); + await queryRunner.query(`CREATE TABLE "keyword" ("id" varchar PRIMARY KEY NOT NULL, "keyword" varchar NOT NULL, "enabled" boolean NOT NULL, "group" varchar)`); + await queryRunner.query(`CREATE INDEX "IDX_35e3ff88225eef1d85c951e229" ON "keyword" ("keyword") `); + await queryRunner.query(`CREATE TABLE "keyword_responses" ("id" varchar PRIMARY KEY NOT NULL, "order" integer NOT NULL, "response" text NOT NULL, "stopIfExecuted" boolean NOT NULL, "permission" varchar, "filter" varchar NOT NULL, "keywordId" varchar)`); + await queryRunner.query(`CREATE TABLE "keyword_group" ("name" varchar PRIMARY KEY NOT NULL, "options" text NOT NULL)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_keyword_group_unique_name" ON "keyword_group" ("name") `); + await queryRunner.query(`CREATE TABLE "obswebsocket" ("id" varchar(14) PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "code" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "overlay" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "canvas" text NOT NULL, "items" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "permissions" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "order" integer NOT NULL, "isCorePermission" boolean NOT NULL, "isWaterfallAllowed" boolean NOT NULL, "automation" varchar(12) NOT NULL, "userIds" text NOT NULL, "excludeUserIds" text NOT NULL, "filters" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "permission_commands" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "permission" varchar(36))`); + await queryRunner.query(`CREATE INDEX "IDX_ba6483f5c5882fa15299f22c0a" ON "permission_commands" ("name") `); + await queryRunner.query(`CREATE TABLE "settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "namespace" varchar NOT NULL, "name" varchar NOT NULL, "value" text NOT NULL)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d8a83b9ffce680092c8dfee37d" ON "settings" ("namespace", "name") `); + await queryRunner.query(`CREATE TABLE "plugin" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "enabled" boolean NOT NULL, "workflow" text NOT NULL, "settings" text)`); + await queryRunner.query(`CREATE TABLE "plugin_variable" ("variableName" varchar NOT NULL, "pluginId" varchar NOT NULL, "value" text NOT NULL, PRIMARY KEY ("variableName", "pluginId"))`); + await queryRunner.query(`CREATE TABLE "poll" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar(7) NOT NULL, "title" varchar NOT NULL, "openedAt" varchar(30) NOT NULL, "closedAt" varchar(30), "options" text NOT NULL, "votes" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "price" ("id" varchar PRIMARY KEY NOT NULL, "command" varchar NOT NULL, "enabled" boolean NOT NULL DEFAULT (1), "emitRedeemEvent" boolean NOT NULL DEFAULT (0), "price" integer NOT NULL, "priceBits" integer NOT NULL DEFAULT (0))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d12db23d28020784096bcb41a3" ON "price" ("command") `); + await queryRunner.query(`CREATE TABLE "quotes" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "tags" text NOT NULL, "quote" varchar NOT NULL, "quotedBy" varchar NOT NULL, "createdAt" varchar(30) NOT NULL DEFAULT ('1970-01-01T00:00:00.000Z'))`); + await queryRunner.query(`CREATE TABLE "randomizer" ("id" varchar PRIMARY KEY NOT NULL, "items" text NOT NULL, "createdAt" varchar(30) NOT NULL, "command" varchar NOT NULL, "permissionId" varchar NOT NULL, "name" varchar NOT NULL, "isShown" boolean NOT NULL DEFAULT (0), "shouldPlayTick" boolean NOT NULL, "tickVolume" integer NOT NULL, "widgetOrder" integer NOT NULL, "type" varchar(20) NOT NULL DEFAULT ('simple'), "position" text NOT NULL, "customizationFont" text NOT NULL, "tts" text NOT NULL)`); + await queryRunner.query(`CREATE UNIQUE INDEX "idx_randomizer_cmdunique" ON "randomizer" ("command") `); + await queryRunner.query(`CREATE TABLE "rank" ("id" varchar PRIMARY KEY NOT NULL, "value" integer NOT NULL, "rank" varchar NOT NULL, "type" varchar NOT NULL)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_93c78c94804a13befdace81904" ON "rank" ("type", "value") `); + await queryRunner.query(`CREATE TABLE "song_ban" ("videoId" varchar PRIMARY KEY NOT NULL, "title" varchar NOT NULL)`); + await queryRunner.query(`CREATE TABLE "song_playlist" ("videoId" varchar PRIMARY KEY NOT NULL, "lastPlayedAt" varchar(30) NOT NULL DEFAULT ('1970-01-01T00:00:00.000Z'), "title" varchar NOT NULL, "seed" float NOT NULL, "loudness" float NOT NULL, "tags" text NOT NULL, "length" integer NOT NULL, "volume" integer NOT NULL, "startTime" integer NOT NULL, "endTime" integer NOT NULL, "forceVolume" boolean NOT NULL DEFAULT (0))`); + await queryRunner.query(`CREATE TABLE "song_request" ("id" varchar PRIMARY KEY NOT NULL, "videoId" varchar NOT NULL, "addedAt" varchar(30) NOT NULL, "title" varchar NOT NULL, "loudness" float NOT NULL, "length" integer NOT NULL, "username" varchar NOT NULL)`); + await queryRunner.query(`CREATE TABLE "spotify_song_ban" ("spotifyUri" varchar PRIMARY KEY NOT NULL, "title" varchar NOT NULL, "artists" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "timer" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "isEnabled" boolean NOT NULL, "tickOffline" boolean NOT NULL DEFAULT (0), "triggerEveryMessage" integer NOT NULL, "triggerEverySecond" integer NOT NULL, "triggeredAtTimestamp" varchar(30) NOT NULL DEFAULT ('1970-01-01T00:00:00.000Z'), "triggeredAtMessages" integer NOT NULL DEFAULT (0))`); + await queryRunner.query(`CREATE TABLE "timer_response" ("id" varchar PRIMARY KEY NOT NULL, "timestamp" varchar(30) NOT NULL DEFAULT ('1970-01-01T00:00:00.000Z'), "isEnabled" boolean NOT NULL DEFAULT (1), "response" text NOT NULL, "timerId" varchar)`); + await queryRunner.query(`CREATE TABLE "variable_watch" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "variableId" varchar NOT NULL, "order" integer NOT NULL)`); + await queryRunner.query(`CREATE TABLE "variable" ("id" varchar PRIMARY KEY NOT NULL, "history" text NOT NULL, "urls" text NOT NULL, "variableName" varchar NOT NULL, "description" varchar NOT NULL DEFAULT (''), "type" varchar NOT NULL, "currentValue" varchar, "evalValue" text NOT NULL, "runEvery" integer NOT NULL DEFAULT (60000), "responseType" integer NOT NULL, "responseText" varchar NOT NULL DEFAULT (''), "permission" varchar NOT NULL, "readOnly" boolean NOT NULL DEFAULT (0), "usableOptions" text NOT NULL, "runAt" varchar(30) NOT NULL, CONSTRAINT "UQ_dd084634ad76dbefdca837b8de4" UNIQUE ("variableName"))`); + await queryRunner.query(`CREATE TABLE "cache_games" ("id" integer PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "thumbnail" varchar)`); + await queryRunner.query(`CREATE INDEX "IDX_f37be3c66dbd449a8cb4fe7d59" ON "cache_games" ("name") `); + await queryRunner.query(`CREATE TABLE "cache_titles" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "game" varchar NOT NULL, "title" varchar NOT NULL, "tags" text NOT NULL, "timestamp" bigint NOT NULL)`); + await queryRunner.query(`CREATE INDEX "IDX_a0c6ce833b5b3b13325e6f49b0" ON "cache_titles" ("game") `); + await queryRunner.query(`CREATE TABLE "carousel" ("id" varchar PRIMARY KEY NOT NULL, "order" integer NOT NULL, "type" varchar NOT NULL, "waitAfter" integer NOT NULL, "waitBefore" integer NOT NULL, "duration" integer NOT NULL, "animationIn" varchar NOT NULL, "animationInDuration" integer NOT NULL, "animationOut" varchar NOT NULL, "animationOutDuration" integer NOT NULL, "showOnlyOncePerStream" boolean NOT NULL, "base64" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "checklist" ("id" varchar PRIMARY KEY NOT NULL, "isCompleted" boolean NOT NULL)`); + await queryRunner.query(`CREATE TABLE "quickaction" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "order" integer NOT NULL, "type" varchar NOT NULL, "options" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "discord_link" ("id" varchar PRIMARY KEY NOT NULL, "tag" varchar NOT NULL, "discordId" varchar NOT NULL, "createdAt" bigint NOT NULL, "userId" varchar)`); + await queryRunner.query(`CREATE TABLE "duel" ("id" varchar PRIMARY KEY NOT NULL, "username" varchar NOT NULL, "tickets" integer NOT NULL)`); + await queryRunner.query(`CREATE TABLE "event" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "isEnabled" boolean NOT NULL, "triggered" text NOT NULL, "definitions" text NOT NULL, "filter" varchar NOT NULL)`); + await queryRunner.query(`CREATE INDEX "IDX_b535fbe8ec6d832dde22065ebd" ON "event" ("name") `); + await queryRunner.query(`CREATE TABLE "event_operation" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "definitions" text NOT NULL, "eventId" varchar)`); + await queryRunner.query(`CREATE INDEX "IDX_daf6b97e1e5a5c779055fbb22d" ON "event_operation" ("name") `); + await queryRunner.query(`CREATE TABLE "event_list" ("id" varchar PRIMARY KEY NOT NULL, "event" varchar NOT NULL, "userId" varchar NOT NULL, "timestamp" bigint NOT NULL, "isTest" boolean NOT NULL, "isHidden" boolean NOT NULL DEFAULT (0), "values_json" text NOT NULL)`); + await queryRunner.query(`CREATE INDEX "IDX_8a80a3cf6b2d815920a390968a" ON "event_list" ("userId") `); + await queryRunner.query(`CREATE TABLE "gallery" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "data" text NOT NULL, "name" varchar NOT NULL, "folder" varchar NOT NULL DEFAULT ('/'))`); + await queryRunner.query(`CREATE TABLE "goal_group" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" varchar NOT NULL, "name" varchar NOT NULL, "display" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "goal" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "groupId" varchar, "type" varchar(20) NOT NULL, "countBitsAsTips" boolean NOT NULL, "display" varchar(20) NOT NULL, "timestamp" varchar, "interval" varchar NOT NULL DEFAULT ('hour'), "tiltifyCampaign" integer, "goalAmount" float NOT NULL DEFAULT (0), "currentAmount" float NOT NULL DEFAULT (0), "endAfter" varchar NOT NULL, "endAfterIgnore" boolean NOT NULL, "customizationBar" text NOT NULL, "customizationFont" text NOT NULL, "customizationHtml" text NOT NULL, "customizationJs" text NOT NULL, "customizationCss" text NOT NULL)`); + await queryRunner.query(`CREATE INDEX "IDX_a1a6bd23cb8ef7ddf921f54c0b" ON "goal" ("groupId") `); + await queryRunner.query(`CREATE TABLE "google_private_keys" ("id" varchar PRIMARY KEY NOT NULL, "clientEmail" varchar NOT NULL, "privateKey" text NOT NULL, "createdAt" varchar NOT NULL)`); + await queryRunner.query(`CREATE TABLE "heist_user" ("userId" varchar PRIMARY KEY NOT NULL, "username" varchar NOT NULL, "points" bigint NOT NULL)`); + await queryRunner.query(`CREATE TABLE "moderation_warning" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "timestamp" bigint NOT NULL DEFAULT (0))`); + await queryRunner.query(`CREATE INDEX "IDX_f941603aef2741795a9108d0d2" ON "moderation_warning" ("userId") `); + await queryRunner.query(`CREATE TABLE "moderation_permit" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL)`); + await queryRunner.query(`CREATE INDEX "IDX_69499e78c9ee1602baee77b97d" ON "moderation_permit" ("userId") `); + await queryRunner.query(`CREATE TABLE "points_changelog" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "userId" varchar NOT NULL, "originalValue" integer NOT NULL, "updatedValue" integer NOT NULL, "updatedAt" bigint NOT NULL, "command" varchar NOT NULL)`); + await queryRunner.query(`CREATE INDEX "IDX_points_changelog_userId" ON "points_changelog" ("userId") `); + await queryRunner.query(`CREATE TABLE "queue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "createdAt" bigint NOT NULL, "username" varchar NOT NULL, "isModerator" boolean NOT NULL, "isSubscriber" boolean NOT NULL, "message" varchar)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_7401b4e0c30f5de6621b38f7a0" ON "queue" ("username") `); + await queryRunner.query(`CREATE TABLE "raffle" ("id" varchar PRIMARY KEY NOT NULL, "winner" text, "timestamp" bigint NOT NULL DEFAULT (0), "keyword" varchar NOT NULL, "minTickets" bigint NOT NULL DEFAULT (0), "maxTickets" bigint NOT NULL DEFAULT (0), "type" integer NOT NULL, "forSubscribers" boolean NOT NULL, "isClosed" boolean NOT NULL DEFAULT (0))`); + await queryRunner.query(`CREATE INDEX "IDX_e83facaeb8fbe8b8ce9577209a" ON "raffle" ("keyword") `); + await queryRunner.query(`CREATE INDEX "IDX_raffleIsClosed" ON "raffle" ("isClosed") `); + await queryRunner.query(`CREATE TABLE "raffle_participant" ("id" varchar PRIMARY KEY NOT NULL, "username" varchar NOT NULL, "tickets" integer NOT NULL, "isEligible" boolean NOT NULL, "isSubscriber" boolean NOT NULL, "raffleId" varchar)`); + await queryRunner.query(`CREATE TABLE "raffle_participant_message" ("id" varchar PRIMARY KEY NOT NULL, "timestamp" bigint NOT NULL DEFAULT (0), "text" text NOT NULL, "participantId" varchar)`); + await queryRunner.query(`CREATE TABLE "scrim_match_id" ("id" varchar PRIMARY KEY NOT NULL, "username" varchar NOT NULL, "matchId" varchar NOT NULL)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_5af6da125c1745151e0dfaf087" ON "scrim_match_id" ("username") `); + await queryRunner.query(`CREATE TABLE "text" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "text" text NOT NULL, "css" text NOT NULL, "js" text NOT NULL, "external" text NOT NULL)`); + await queryRunner.query(`CREATE TABLE "translation" ("name" varchar PRIMARY KEY NOT NULL, "value" varchar NOT NULL)`); + await queryRunner.query(`CREATE TABLE "twitch_stats" ("whenOnline" bigint PRIMARY KEY NOT NULL, "currentViewers" integer NOT NULL DEFAULT (0), "currentSubscribers" integer NOT NULL DEFAULT (0), "chatMessages" bigint NOT NULL, "currentFollowers" integer NOT NULL DEFAULT (0), "maxViewers" integer NOT NULL DEFAULT (0), "newChatters" integer NOT NULL DEFAULT (0), "currentBits" bigint NOT NULL, "currentTips" float NOT NULL, "currentWatched" bigint NOT NULL)`); + await queryRunner.query(`CREATE TABLE "twitch_clips" ("clipId" varchar PRIMARY KEY NOT NULL, "isChecked" boolean NOT NULL, "shouldBeCheckedAt" bigint NOT NULL)`); + await queryRunner.query(`CREATE TABLE "user" ("userId" varchar PRIMARY KEY NOT NULL, "userName" varchar NOT NULL, "displayname" varchar NOT NULL DEFAULT (''), "profileImageUrl" varchar NOT NULL DEFAULT (''), "isOnline" boolean NOT NULL DEFAULT (0), "isVIP" boolean NOT NULL DEFAULT (0), "isModerator" boolean NOT NULL DEFAULT (0), "isSubscriber" boolean NOT NULL DEFAULT (0), "haveSubscriberLock" boolean NOT NULL DEFAULT (0), "haveSubscribedAtLock" boolean NOT NULL DEFAULT (0), "rank" varchar NOT NULL DEFAULT (''), "haveCustomRank" boolean NOT NULL DEFAULT (0), "subscribedAt" varchar(30), "seenAt" varchar(30), "createdAt" varchar(30), "watchedTime" bigint NOT NULL DEFAULT (0), "chatTimeOnline" bigint NOT NULL DEFAULT (0), "chatTimeOffline" bigint NOT NULL DEFAULT (0), "points" bigint NOT NULL DEFAULT (0), "pointsOnlineGivenAt" bigint NOT NULL DEFAULT (0), "pointsOfflineGivenAt" bigint NOT NULL DEFAULT (0), "pointsByMessageGivenAt" bigint NOT NULL DEFAULT (0), "subscribeTier" varchar NOT NULL DEFAULT ('0'), "subscribeCumulativeMonths" integer NOT NULL DEFAULT (0), "subscribeStreak" integer NOT NULL DEFAULT (0), "giftedSubscribes" bigint NOT NULL DEFAULT (0), "messages" bigint NOT NULL DEFAULT (0), "extra" text)`); + await queryRunner.query(`CREATE INDEX "IDX_78a916df40e02a9deb1c4b75ed" ON "user" ("userName") `); + await queryRunner.query(`CREATE TABLE "user_tip" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "amount" float NOT NULL, "sortAmount" float NOT NULL, "exchangeRates" text NOT NULL, "currency" varchar NOT NULL, "message" text NOT NULL, "tippedAt" bigint NOT NULL DEFAULT (0), "userId" varchar)`); + await queryRunner.query(`CREATE INDEX "IDX_user_tip_userId" ON "user_tip" ("userId") `); + await queryRunner.query(`CREATE TABLE "user_bit" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "amount" bigint NOT NULL, "message" text NOT NULL, "cheeredAt" bigint NOT NULL DEFAULT (0), "userId" varchar)`); + await queryRunner.query(`CREATE INDEX "IDX_user_bit_userId" ON "user_bit" ("userId") `); + await queryRunner.query(`CREATE TABLE "widget_custom" ("id" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "url" varchar NOT NULL, "name" varchar NOT NULL)`); + await queryRunner.query(`CREATE TABLE "widget_social" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "hashtag" varchar NOT NULL, "text" text NOT NULL, "username" varchar NOT NULL, "displayname" varchar NOT NULL, "url" varchar NOT NULL, "timestamp" bigint NOT NULL DEFAULT (0))`); + await queryRunner.query(`CREATE TABLE "temporary_keyword_responses" ("id" varchar PRIMARY KEY NOT NULL, "order" integer NOT NULL, "response" text NOT NULL, "stopIfExecuted" boolean NOT NULL, "permission" varchar, "filter" varchar NOT NULL, "keywordId" varchar, CONSTRAINT "FK_d12716a3805d58dd75ab09c8c67" FOREIGN KEY ("keywordId") REFERENCES "keyword" ("id") ON DELETE CASCADE ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "temporary_keyword_responses"("id", "order", "response", "stopIfExecuted", "permission", "filter", "keywordId") SELECT "id", "order", "response", "stopIfExecuted", "permission", "filter", "keywordId" FROM "keyword_responses"`); + await queryRunner.query(`DROP TABLE "keyword_responses"`); + await queryRunner.query(`ALTER TABLE "temporary_keyword_responses" RENAME TO "keyword_responses"`); + await queryRunner.query(`CREATE TABLE "temporary_timer_response" ("id" varchar PRIMARY KEY NOT NULL, "timestamp" varchar(30) NOT NULL DEFAULT ('1970-01-01T00:00:00.000Z'), "isEnabled" boolean NOT NULL DEFAULT (1), "response" text NOT NULL, "timerId" varchar, CONSTRAINT "FK_3192b176b66d4375368c9e960de" FOREIGN KEY ("timerId") REFERENCES "timer" ("id") ON DELETE CASCADE ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "temporary_timer_response"("id", "timestamp", "isEnabled", "response", "timerId") SELECT "id", "timestamp", "isEnabled", "response", "timerId" FROM "timer_response"`); + await queryRunner.query(`DROP TABLE "timer_response"`); + await queryRunner.query(`ALTER TABLE "temporary_timer_response" RENAME TO "timer_response"`); + await queryRunner.query(`DROP INDEX "IDX_daf6b97e1e5a5c779055fbb22d"`); + await queryRunner.query(`CREATE TABLE "temporary_event_operation" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "definitions" text NOT NULL, "eventId" varchar, CONSTRAINT "FK_a9f07bd7a9f0b7b9d41f48b476d" FOREIGN KEY ("eventId") REFERENCES "event" ("id") ON DELETE CASCADE ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "temporary_event_operation"("id", "name", "definitions", "eventId") SELECT "id", "name", "definitions", "eventId" FROM "event_operation"`); + await queryRunner.query(`DROP TABLE "event_operation"`); + await queryRunner.query(`ALTER TABLE "temporary_event_operation" RENAME TO "event_operation"`); + await queryRunner.query(`CREATE INDEX "IDX_daf6b97e1e5a5c779055fbb22d" ON "event_operation" ("name") `); + await queryRunner.query(`DROP INDEX "IDX_a1a6bd23cb8ef7ddf921f54c0b"`); + await queryRunner.query(`CREATE TABLE "temporary_goal" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "groupId" varchar, "type" varchar(20) NOT NULL, "countBitsAsTips" boolean NOT NULL, "display" varchar(20) NOT NULL, "timestamp" varchar, "interval" varchar NOT NULL DEFAULT ('hour'), "tiltifyCampaign" integer, "goalAmount" float NOT NULL DEFAULT (0), "currentAmount" float NOT NULL DEFAULT (0), "endAfter" varchar NOT NULL, "endAfterIgnore" boolean NOT NULL, "customizationBar" text NOT NULL, "customizationFont" text NOT NULL, "customizationHtml" text NOT NULL, "customizationJs" text NOT NULL, "customizationCss" text NOT NULL, CONSTRAINT "FK_a1a6bd23cb8ef7ddf921f54c0bb" FOREIGN KEY ("groupId") REFERENCES "goal_group" ("id") ON DELETE CASCADE ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "temporary_goal"("id", "name", "groupId", "type", "countBitsAsTips", "display", "timestamp", "interval", "tiltifyCampaign", "goalAmount", "currentAmount", "endAfter", "endAfterIgnore", "customizationBar", "customizationFont", "customizationHtml", "customizationJs", "customizationCss") SELECT "id", "name", "groupId", "type", "countBitsAsTips", "display", "timestamp", "interval", "tiltifyCampaign", "goalAmount", "currentAmount", "endAfter", "endAfterIgnore", "customizationBar", "customizationFont", "customizationHtml", "customizationJs", "customizationCss" FROM "goal"`); + await queryRunner.query(`DROP TABLE "goal"`); + await queryRunner.query(`ALTER TABLE "temporary_goal" RENAME TO "goal"`); + await queryRunner.query(`CREATE INDEX "IDX_a1a6bd23cb8ef7ddf921f54c0b" ON "goal" ("groupId") `); + await queryRunner.query(`CREATE TABLE "temporary_raffle_participant" ("id" varchar PRIMARY KEY NOT NULL, "username" varchar NOT NULL, "tickets" integer NOT NULL, "isEligible" boolean NOT NULL, "isSubscriber" boolean NOT NULL, "raffleId" varchar, CONSTRAINT "FK_bc112542267bdd487f4479a94a1" FOREIGN KEY ("raffleId") REFERENCES "raffle" ("id") ON DELETE CASCADE ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "temporary_raffle_participant"("id", "username", "tickets", "isEligible", "isSubscriber", "raffleId") SELECT "id", "username", "tickets", "isEligible", "isSubscriber", "raffleId" FROM "raffle_participant"`); + await queryRunner.query(`DROP TABLE "raffle_participant"`); + await queryRunner.query(`ALTER TABLE "temporary_raffle_participant" RENAME TO "raffle_participant"`); + await queryRunner.query(`CREATE TABLE "temporary_raffle_participant_message" ("id" varchar PRIMARY KEY NOT NULL, "timestamp" bigint NOT NULL DEFAULT (0), "text" text NOT NULL, "participantId" varchar, CONSTRAINT "FK_e6eda53bcd6ceb62b5edd9e02b5" FOREIGN KEY ("participantId") REFERENCES "raffle_participant" ("id") ON DELETE CASCADE ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "temporary_raffle_participant_message"("id", "timestamp", "text", "participantId") SELECT "id", "timestamp", "text", "participantId" FROM "raffle_participant_message"`); + await queryRunner.query(`DROP TABLE "raffle_participant_message"`); + await queryRunner.query(`ALTER TABLE "temporary_raffle_participant_message" RENAME TO "raffle_participant_message"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} \ No newline at end of file diff --git a/backend/src/database/migration/sqlite/17.0.x/1678892044030-removeBets.ts b/backend/src/database/migration/sqlite/17.0.x/1678892044030-removeBets.ts new file mode 100644 index 000000000..4091f04e5 --- /dev/null +++ b/backend/src/database/migration/sqlite/17.0.x/1678892044030-removeBets.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeBets678892044030 implements MigrationInterface { + name = 'removeBets1678892044030'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "bets"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/sqlite/17.0.x/1678892044030-removePolls.ts b/backend/src/database/migration/sqlite/17.0.x/1678892044030-removePolls.ts new file mode 100644 index 000000000..382bf8d17 --- /dev/null +++ b/backend/src/database/migration/sqlite/17.0.x/1678892044030-removePolls.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removePoll1678892044030 implements MigrationInterface { + name = 'removePoll1678892044030'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "poll"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/sqlite/17.0.x/1678892044031-revertInactiveUsers.ts b/backend/src/database/migration/sqlite/17.0.x/1678892044031-revertInactiveUsers.ts new file mode 100644 index 000000000..5417fb86b --- /dev/null +++ b/backend/src/database/migration/sqlite/17.0.x/1678892044031-revertInactiveUsers.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class revertInactiveUsers1678892044031 implements MigrationInterface { + name = 'revertInactiveUsers1678892044031'; + + public async up(queryRunner: QueryRunner): Promise { + const users = await queryRunner.query(`SELECT * FROM "user"`); + for (const user of users) { + await queryRunner.query(`UPDATE "user" SET "userName"=? WHERE "userId"=?`, + [user.userName.replace(/__inactive__/g, ''), user.userId]); + } + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/sqlite/17.0.x/1678892044032-removeCarousel.ts b/backend/src/database/migration/sqlite/17.0.x/1678892044032-removeCarousel.ts new file mode 100644 index 000000000..9d05cb599 --- /dev/null +++ b/backend/src/database/migration/sqlite/17.0.x/1678892044032-removeCarousel.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeCarousel1678892044032 implements MigrationInterface { + name = 'removeCarousel1678892044032'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "carousel"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/sqlite/17.0.x/1678892044033-removeGoal.ts b/backend/src/database/migration/sqlite/17.0.x/1678892044033-removeGoal.ts new file mode 100644 index 000000000..797add2f1 --- /dev/null +++ b/backend/src/database/migration/sqlite/17.0.x/1678892044033-removeGoal.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeGoal1678892044033 implements MigrationInterface { + name = 'removeGoal1678892044033'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "goal_group"`); + await queryRunner.query(`DROP TABLE "goal"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/sqlite/18.0.x/1678892044032-removeText.ts b/backend/src/database/migration/sqlite/18.0.x/1678892044032-removeText.ts new file mode 100644 index 000000000..4827deebc --- /dev/null +++ b/backend/src/database/migration/sqlite/18.0.x/1678892044032-removeText.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeText1678892044032 implements MigrationInterface { + name = 'removeText1678892044032'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "text"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/sqlite/18.0.x/1678892044034-addContentClasificationLabels.ts b/backend/src/database/migration/sqlite/18.0.x/1678892044034-addContentClasificationLabels.ts new file mode 100644 index 000000000..59c583b3a --- /dev/null +++ b/backend/src/database/migration/sqlite/18.0.x/1678892044034-addContentClasificationLabels.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { insertItemIntoTable } from '~/database/insertItemIntoTable.js'; + +export class addContentClasificationLabels1678892044034 implements MigrationInterface { + name = 'addContentClasificationLabels1678892044034'; + + public async up(queryRunner: QueryRunner): Promise { + const items = await queryRunner.query(`SELECT * from "cache_titles"`); + await queryRunner.query(`DROP INDEX "IDX_a0c6ce833b5b3b13325e6f49b0"`); + await queryRunner.query(`DROP TABLE "cache_titles"`); + await queryRunner.query(`CREATE TABLE "cache_titles" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "game" varchar NOT NULL, "title" varchar NOT NULL, "timestamp" bigint NOT NULL, "tags" text NOT NULL, "content_classification_labels" text NOT NULL)`); + await queryRunner.query(`CREATE INDEX "IDX_a0c6ce833b5b3b13325e6f49b0" ON "cache_titles" ("game") `); + + for (const item of items) { + await insertItemIntoTable('cache_titles', { + ...item, + content_classification_labels: '', + }, queryRunner); + } + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/migration/sqlite/18.0.x/1678892044035-removeSocial.ts b/backend/src/database/migration/sqlite/18.0.x/1678892044035-removeSocial.ts new file mode 100644 index 000000000..633f42d46 --- /dev/null +++ b/backend/src/database/migration/sqlite/18.0.x/1678892044035-removeSocial.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class removeSocial1678892044035 implements MigrationInterface { + name = 'removeSocial1678892044035'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "widget_social"`); + } + + public async down(queryRunner: QueryRunner): Promise { + return; + } + +} diff --git a/backend/src/database/validators/IsCommand.ts b/backend/src/database/validators/IsCommand.ts new file mode 100644 index 000000000..3de901de7 --- /dev/null +++ b/backend/src/database/validators/IsCommand.ts @@ -0,0 +1,18 @@ +import { registerDecorator, ValidationOptions } from 'class-validator'; + +export function IsCommand(validationOptions?: ValidationOptions) { + return function (object: any, propertyName: string) { + registerDecorator({ + name: 'isCommand', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + return typeof value === 'string' + && value.startsWith('!'); + }, + }, + }); + }; +} \ No newline at end of file diff --git a/backend/src/database/validators/IsCommandOrCustomVariable.ts b/backend/src/database/validators/IsCommandOrCustomVariable.ts new file mode 100644 index 000000000..d10d6d1bf --- /dev/null +++ b/backend/src/database/validators/IsCommandOrCustomVariable.ts @@ -0,0 +1,18 @@ +import { registerDecorator, ValidationOptions } from 'class-validator'; + +export function IsCommandOrCustomVariable(validationOptions?: ValidationOptions) { + return function (object: any, propertyName: string) { + registerDecorator({ + name: 'IsCommandOrCustomVariable', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + return typeof value === 'string' + && (value.startsWith('!') || (value.length > 2 && value.startsWith('$_'))); + }, + }, + }); + }; +} \ No newline at end of file diff --git a/backend/src/database/validators/isCustomVariable.ts b/backend/src/database/validators/isCustomVariable.ts new file mode 100644 index 000000000..8069331f9 --- /dev/null +++ b/backend/src/database/validators/isCustomVariable.ts @@ -0,0 +1,18 @@ +import { registerDecorator, ValidationOptions } from 'class-validator'; + +export function IsCustomVariable(validationOptions?: ValidationOptions) { + return function (object: any, propertyName: string) { + registerDecorator({ + name: 'IsCustomVariable', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any) { + return typeof value === 'string' + && value.length > 2 && value.startsWith('$_'); + }, + }, + }); + }; +} \ No newline at end of file diff --git a/backend/src/decorators.ts b/backend/src/decorators.ts new file mode 100644 index 000000000..aa51c96fa --- /dev/null +++ b/backend/src/decorators.ts @@ -0,0 +1,479 @@ +import { parse, normalize } from 'path'; + +import * as constants from '@sogebot/ui-helpers/constants.js'; +import * as _ from 'lodash-es'; +import { xor } from 'lodash-es'; + +import type { Module } from '~/_interface.js'; +import { isDbConnected } from '~/helpers/database.js'; +import emitter from '~/helpers/interfaceEmitter.js'; +import { + debug, error, performance, +} from '~/helpers/log.js'; +import { defaultPermissions } from '~/helpers/permissions/defaultPermissions.js'; +import { find, systems } from '~/helpers/register.js'; +import { VariableWatcher } from '~/watchers.js'; + +export let loadingInProgress: (string|symbol)[] = []; +export let areDecoratorsLoaded = false; +export const permissions: { [command: string]: string | null } = {}; + +export const commandsToRegister: { + opts: string | Command; + m: { type: string; name: string; fnc: string }; +}[] = []; + +/* import Message and Parser outside init */ +let Message: null | any = null; +let Parser: null | any = null; +async function loadImports() { + Message = (await import('./message.js')).Message; + Parser = (await import('./parser.js')).Parser; +} +setTimeout(() => loadImports(), 10000); + +const checkIfDecoratorsAreLoaded = () => { + if (!isDbConnected) { + setTimeout(() => { + checkIfDecoratorsAreLoaded(); + }, 2000); + return; + } + if (loadingInProgress.length === 0) { + debug('decorators', 'Loading OK'); + areDecoratorsLoaded = true; + } else { + setTimeout(() => { + checkIfDecoratorsAreLoaded(); + }, 2000); + } +}; +checkIfDecoratorsAreLoaded(); + +function getNameAndTypeFromStackTrace(): { name: string, type: keyof typeof systems} { + const _prepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = (_s, s) => s; + const stack = (new Error().stack as unknown as NodeJS.CallSite[]); + Error.prepareStackTrace = _prepareStackTrace; + + const path = parse(stack[2].getFileName() || ''); + const dir = normalize(path.dir).replace(/\\/g, '/'); + const _type = dir.split('/')[dir.split('/').length - 1]; + const type = (_type === 'dest' ? 'core' : _type) as keyof typeof systems; + const name = path.name; + + return { name, type }; +} + +export function ui(opts: any, category?: string) { + const { name, type } = getNameAndTypeFromStackTrace(); + + return (target: any, key: string) => { + let path = category ? `${category}.${key}` : key; + + const register = async (retries = 0) => { + if (!isDbConnected) { + setTimeout(() => register(0), 1000); + return; + } + try { + const self = find(type as any, name); + if (!self) { + throw new Error(`${type}.${name} not found in list`); + } + // get category from settingsList + if (!category) { + const s = self.settingsList.find(o => o.key === path); + if (s) { + path = s.category? s.category + '.' + path : path; + } else { + if (retries < 500) { // try to wait to settings to be registered + setTimeout(() => register(++retries), 10); + return; + } + } + } + _.set(self, '_ui.' + path, opts); + } catch (e: any) { + error(e); + } + }; + setTimeout(() => { + register(); + }, 10000); + }; +} + +export function example(opts: (string|{if?: string, message: string, replace: { [x:string]: string }})[][], category?: string) { + const { name, type } = getNameAndTypeFromStackTrace(); + + return (target: any, key: string) => { + let path = category ? `${category}.${key}` : key; + + const register = async (retries = 0) => { + if (!isDbConnected) { + setTimeout(() => register(0), 1000); + return; + } + try { + const self = find(type as any, name); + if (!self) { + throw new Error(`${type}.${name} not found in list`); + } + // get category from settingsList + if (!category) { + const s = self.settingsList.find(o => o.key === path); + if (s) { + path = s.category? s.category + '.' + path : path; + } else { + if (retries < 500) { // try to wait to settings to be registered + setTimeout(() => register(++retries), 10); + return; + } + } + } + _.set(self, '_ui.' + path, opts); + } catch (e: any) { + error(e); + } + }; + setTimeout(() => { + register(); + }, 10000); + }; +} + +export function settings(category?: string, isReadOnly = false) { + const { name, type } = getNameAndTypeFromStackTrace(); + + return (target: any, key: string) => { + if (!isReadOnly) { + loadingInProgress.push(`${type}.${name}.${key}`); + } + + const registerSettings = async () => { + const self = find(type as any, name); + if (!self) { + throw new Error(`${type}.${name} not found in list`); + } + if (!isDbConnected) { + setTimeout(() => registerSettings(), 1000); + return; + } + try { + const path = `${type}.${name}.${key}`; + VariableWatcher.add(path, (self as any)[key], isReadOnly); + + if (!isReadOnly) { + // load variable from db + const loadVariableValue = () => { + if (!isDbConnected) { + setTimeout(() => loadVariableValue(), 1000); + return; + } + self.loadVariableValue(key).then((value) => { + if (typeof value !== 'undefined') { + VariableWatcher.add(path, value, isReadOnly); // rewrite value on var load + _.set(self, key, value); + emitter.emit('load', path, _.cloneDeep(value)); + } else { + emitter.emit('load', path, _.cloneDeep((self as any)[key])); + } + loadingInProgress = loadingInProgress.filter(o => o !== path); + }); + }; + setTimeout(() => loadVariableValue(), 5000); + } else { + emitter.emit('load', path, _.cloneDeep((self as any)[key])); + } + + // add variable to settingsList + self.settingsList.push({ + category, key, defaultValue: (self as any)[key], + }); + } catch (e: any) { + error(e.stack); + } + }; + setTimeout(() => { + registerSettings(); + }, 10000); + }; +} + +export function permission_settings(category?: string, exclude: string[] = [], enforcedDefaultValue?: { [permId: string]: any }) { + if (typeof category === 'undefined') { + category = 'settings'; + } + + const { name, type } = getNameAndTypeFromStackTrace(); + + return (target: any, key: string) => { + loadingInProgress.push(`${type}.${name}.${key}`); + + const register = async () => { + if (!isDbConnected) { + setTimeout(() => register(), 1000); + return; + } + try { + const self = find(type as any, name); + if (!self) { + throw new Error(`${type}.${name} not found in list`); + } + + _.set(self, '__permission_based__' + key, {}); // set init value + VariableWatcher.add(`${type}.${name}.__permission_based__${key}`, {}, false); + + // load variable from db + const loadVariableValue = () => { + if (!isDbConnected) { + setTimeout(() => loadVariableValue(), 1000); + return; + } + self.loadVariableValue('__permission_based__' + key).then((value?: { [permissionId: string]: string }) => { + if (typeof value === 'undefined') { + value = {}; + } + + for (const exKey of exclude) { + value[exKey] = '%%%%___ignored___%%%%'; + } + + // set forced default value + if (enforcedDefaultValue) { + for (const enforcedKey of Object.keys(enforcedDefaultValue)) { + if (typeof value[enforcedKey] === 'undefined' || value[enforcedKey] === null) { + // change only if value is not set manually + value[enforcedKey] = enforcedDefaultValue[enforcedKey]; + } + } + } + + VariableWatcher.add(`${type}.${name}.__permission_based__${key}`, value, false); + _.set(self, '__permission_based__' + key, value); + loadingInProgress = loadingInProgress.filter(o => o !== `${type}.${name}.${key}`); + }); + }; + setTimeout(() => loadVariableValue(), 5000); + + // add variable to settingsPermList + self.settingsPermList.push({ + category, key, defaultValue: (self as any)[key], + }); + } catch (e: any) { + // we don't care about error + } + }; + setTimeout(() => { + register(); + }, 10000); + }; +} + +export function persistent() { + const { name, type } = getNameAndTypeFromStackTrace(); + + return (target: any, key: string) => { + const path = `${type}.${name}.${key}`; + loadingInProgress.push(path); + const register = async () => { + if (!isDbConnected) { + setTimeout(() => register(), 1000); + return; + } + try { + const self = find(type as any, name); + if (!self) { + throw new Error(`${type}.${name} not found in list`); + } + const defaultValue = (self as any)[key]; + VariableWatcher.add(path, defaultValue, false); + const loadVariableValue = () => { + self.loadVariableValue(key).then((value) => { + if (typeof value !== 'undefined') { + VariableWatcher.add(path, value, false); // rewrite value on var load + emitter.emit('load', path, _.cloneDeep(value)); + _.set(self, key, value); + } else { + emitter.emit('load', path, _.cloneDeep((self as any)[key])); + } + loadingInProgress = loadingInProgress.filter(o => o !== path); + }); + }; + setTimeout(() => loadVariableValue(), 5000); + } catch (e: any) { + error(e.stack); + } + }; + setTimeout(() => { + register(); + }, 10000); + }; +} + +export function parser( + { skippable = false, fireAndForget = false, permission = defaultPermissions.VIEWERS, priority = constants.MEDIUM, dependsOn = [] }: + { skippable?: boolean; fireAndForget?: boolean; permission?: string; priority?: number; dependsOn?: import('~/_interface').Module[] } = {}) { + const { name, type } = getNameAndTypeFromStackTrace(); + + return (target: any, key: string, descriptor: PropertyDescriptor) => { + registerParser({ + fireAndForget, permission, priority, dependsOn, skippable, + }, { + type, name, fnc: key, + }); + return descriptor; + }; +} + +export function command(opts: string) { + const { name, type } = getNameAndTypeFromStackTrace(); + + return (target: any, key: string, descriptor: PropertyDescriptor) => { + commandsToRegister.push({ + opts, m: { + type, name, fnc: key, + }, + }); + return descriptor; + }; +} + +export function default_permission(uuid: string | null) { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, key: string | symbol, descriptor: PropertyDescriptor) => { + permissions[`${type}.${name.toLowerCase()}.${String(key).toLowerCase()}`] = uuid; + return descriptor; + }; +} + +export function helper() { + const { name, type } = getNameAndTypeFromStackTrace(); + + return (target: any, key: string | symbol, descriptor: PropertyDescriptor) => { + registerHelper({ + type, name, fnc: String(key), + }); + return descriptor; + }; +} + +export function rollback() { + const { name, type } = getNameAndTypeFromStackTrace(); + + return (target: any, key: string, descriptor: PropertyDescriptor) => { + registerRollback({ + type, name, fnc: key, + }); + return descriptor; + }; +} + +function registerHelper(m: { type: string, name: string, fnc: string }, retry = 0) { + setTimeout(() => { + try { + const self = find(m.type as any, m.name); + if (!self) { + throw new Error(`${m.type}.${m.name} not found in list`); + } + // find command with function + const c = self._commands.find((o) => o.fnc === m.fnc); + if (!c) { + throw Error(); + } else { + c.isHelper = true; + } + } catch (e: any) { + if (retry < 100) { + return setTimeout(() => registerHelper(m, retry++), 10); + } else { + error('Command with function ' + m.fnc + ' not found!'); + } + } + }, 5000); +} + +function registerRollback(m: { type: string, name: string, fnc: string }) { + setTimeout(() => { + try { + const self = find(m.type as any, m.name); + if (!self) { + throw new Error(`${m.type}.${m.name} not found in list`); + } self._rollback.push({ name: m.fnc }); + } catch (e: any) { + error(e.stack); + } + }, 5000); +} + +function registerParser(opts: { + permission: string; priority: number, dependsOn: Module[]; fireAndForget: boolean; skippable: boolean; +}, m: { type: keyof typeof systems, name: string, fnc: string }) { + setTimeout(() => { + try { + const self = find(m.type as any, m.name); + if (!self) { + throw new Error(`${m.type}.${m.name} not found in list`); + } + self._parsers.push({ + name: m.fnc, + permission: opts.permission, + priority: opts.priority, + dependsOn: opts.dependsOn, + skippable: opts.skippable, + fireAndForget: opts.fireAndForget, + }); + } catch (e: any) { + error(e.stack); + } + }, 5000); +} + +export function IsLoadingInProgress(name: symbol) { + return loadingInProgress.includes(name); +} + +export function toggleLoadingInProgress(name: symbol) { + loadingInProgress = xor(loadingInProgress, [name]); +} + +export function timer() { + return (_target: any, key: string | symbol, descriptor: any) => { + const method = descriptor.value; + + if (method.constructor.name === 'AsyncFunction') { + descriptor.value = async function (){ + const start = Date.now(); + // eslint-disable-next-line prefer-rest-params + const result = await method.apply(this, arguments); + if (this instanceof Parser) { + performance(`[PARSER#${this.id}|${String(key)}] ${Date.now() - start}ms`); + } else { + if (this instanceof Message) { + performance(`[MESSAGE#${this.id}|${String(key)}] ${Date.now() - start}ms`); + } else { + performance(`[${this.constructor.name.toUpperCase()}|${String(key)}] ${Date.now() - start}ms`); + } + } + return result; + }; + } else { + descriptor.value = function (){ + const start = Date.now(); + // eslint-disable-next-line prefer-rest-params + const result = method.apply(this, arguments); + if (this instanceof Parser) { + performance(`[PARSER#${this.id}|${String(key)}] ${Date.now() - start}ms`); + } else { + if (this instanceof Message) { + performance(`[MESSAGE#${this.id}|${String(key)}] ${Date.now() - start}ms`); + } else { + performance(`[${this.constructor.name.toUpperCase()}|${String(key)}] ${Date.now() - start}ms`); + } + } + return result; + }; + } + }; +} \ No newline at end of file diff --git a/backend/src/decorators/on.ts b/backend/src/decorators/on.ts new file mode 100644 index 000000000..ff1e47dca --- /dev/null +++ b/backend/src/decorators/on.ts @@ -0,0 +1,163 @@ +import { normalize, parse } from 'path'; + +type onEvent = { path: string; fName: string }; +type onEvents = { + streamStart: onEvent[]; + streamEnd: onEvent[]; + change: onEvent[]; + load: onEvent[]; + startup: onEvent[]; + message: onEvent[]; + joinChannel: onEvent[]; + partChannel: onEvent[]; + sub: onEvent[]; + follow: onEvent[]; + bit: onEvent[]; + tip: onEvent[]; +}; + +const on: onEvents = { + streamStart: [], + streamEnd: [], + change: [], + load: [], + startup: [], + message: [], + joinChannel: [], + partChannel: [], + sub: [], + follow: [], + bit: [], + tip: [], +}; + +export function getFunctionList(type: keyof onEvents, path = ''): onEvent[] { + if (path === '') { + return on[type]; + } else { + return on[type].filter(o => o.path === path); + } +} + +export function onChange(variableArg: string | string[]) { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + if (Array.isArray(variableArg)) { + for (const variable of variableArg) { + on.change.push({ path: path + '.' + variable, fName }); + } + } else { + on.change.push({ path: path + '.' + variableArg, fName }); + } + }; +} + +export function onLoad(variableArg: string | string[]) { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + if (Array.isArray(variableArg)) { + for (const variable of variableArg) { + on.load.push({ path: path + '.' + variable, fName }); + } + } else { + on.load.push({ path: path + '.' + variableArg, fName }); + } + }; +} + +export function onStartup() { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + on.startup.push({ path, fName }); + }; +} + +export function onMessage() { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + on.message.push({ path, fName }); + }; +} + +export function onStreamStart() { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + on.streamStart.push({ path, fName }); + }; +} + +export function onStreamEnd() { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + on.streamEnd.push({ path, fName }); + }; +} + +export function onJoinChannel() { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + on.joinChannel.push({ path, fName }); + }; +} + +export function onPartChannel() { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + on.partChannel.push({ path, fName }); + }; +} + +export function onTip() { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + on.tip.push({ path, fName }); + }; +} + +export function onFollow() { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + on.follow.push({ path, fName }); + }; +} + +export function onSub() { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + on.sub.push({ path, fName }); + }; +} + +export function onBit() { + const { name, type } = getNameAndTypeFromStackTrace(); + return (target: any, fName: string) => { + const path = type === 'core' ? name : `${type}.${name.toLowerCase()}`; + on.bit.push({ path, fName }); + }; +} + +function getNameAndTypeFromStackTrace() { + const _prepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = (_s, s) => s; + const stack = (new Error().stack as unknown as NodeJS.CallSite[]); + Error.prepareStackTrace = _prepareStackTrace; + + const path = parse(stack[2].getFileName() || ''); + const dir = normalize(path.dir).replace(/\\/g, '/'); + const _type = dir.split('/')[dir.split('/').length - 1]; + const type = (_type === 'dest' ? 'core' : _type); + const name = path.name; + + return { name, type }; +} diff --git a/backend/src/emotes.ts b/backend/src/emotes.ts new file mode 100644 index 000000000..80b394ea7 --- /dev/null +++ b/backend/src/emotes.ts @@ -0,0 +1,536 @@ +import { shuffle } from '@sogebot/ui-helpers/array.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import axios from 'axios'; +import { v4 as uuid } from 'uuid'; + +import { onStartup } from './decorators/on.js'; +import emitter from './helpers/interfaceEmitter.js'; +import { adminEndpoint, publicEndpoint } from './helpers/socket.js'; +import getBroadcasterId from './helpers/user/getBroadcasterId.js'; +import twitch from './services/twitch.js'; + +import Core from '~/_interface.js'; +import { parser, settings } from '~/decorators.js'; +import { + debug, + error, info, warning, +} from '~/helpers/log.js'; +import { ioServer } from '~/helpers/panel.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import { variables } from '~/watchers.js'; + +let broadcasterWarning = false; + +class Emotes extends Core { + cache: { + code: string; + type: 'twitch' | 'twitch-sub' | 'ffz' | 'bttv' | '7tv'; + urls: { '1': string; '2': string; '3': string }; + }[] = []; + + @settings() + '7tvEmoteSet' = ''; + @settings() + ffz = true; + @settings() + bttv = true; + + fetch = { + global: false, + channel: false, + ffz: false, + bttv: false, + globalBttv: false, + '7tv': false, + }; + + lastGlobalEmoteChk = 1; + lastSubscriberEmoteChk = 1; + lastChannelChk: string | null = null; + lastFFZEmoteChk = 1; + lastBTTVEmoteChk = 1; + lastGlobalBTTVEmoteChk = 1; + last7TVEmoteChk = 1; + + interval: NodeJS.Timer; + + get types() { + const types: Emotes['cache'][number]['type'][] = ['twitch', 'twitch-sub']; + if (this['7tvEmoteSet'].length > 0) { + types.push('7tv'); + } + if (this.bttv) { + types.push('bttv'); + } + if (this.ffz) { + types.push('ffz'); + } + return types; + } + + @onStartup() + onStartup() { + publicEndpoint('/core/emotes', 'getCache', async (cb) => { + try { + cb(null, this.cache.filter(o => this.types.includes(o.type))); + } catch (e: any) { + cb(e.stack, []); + } + }); + + adminEndpoint('/core/emotes', 'testExplosion', (cb) => { + this._testExplosion(); + cb(null, null); + }); + adminEndpoint('/core/emotes', 'testFireworks', (cb) => { + this._testFireworks(); + cb(null, null); + }); + adminEndpoint('/core/emotes', 'test', (cb) => { + this._test(); + cb(null, null); + }); + adminEndpoint('/core/emotes', 'removeCache', (cb) => { + this.removeCache(); + cb(null, null); + }); + + emitter.on('services::twitch::emotes', (type, value) => { + if (type === 'explode') { + this.explode(value); + } + if (type === 'firework') { + this.firework(value); + } + }); + + this.interval = setInterval(() => { + if (!this.fetch.global) { + this.fetchEmotesGlobal(); + } + if (!this.fetch.channel) { + this.fetchEmotesChannel(); + } + if (!this.fetch.ffz) { + this.fetchEmotesFFZ(); + } + if (!this.fetch.bttv) { + this.fetchEmotesBTTV(); + } + if (!this.fetch.globalBttv) { + this.fetchEmotesGlobalBTTV(); + } + if (!this.fetch['7tv']) { + this.fetchEmotes7TV(); + } + }, 10000); + } + + async removeCache () { + this.lastGlobalEmoteChk = 0; + this.lastSubscriberEmoteChk = 0; + this.lastFFZEmoteChk = 0; + this.last7TVEmoteChk = 0; + this.lastBTTVEmoteChk = 0; + this.cache = []; + + if (!this.fetch.global) { + this.fetchEmotesGlobal(); + } + if (!this.fetch.channel) { + this.fetchEmotesChannel(); + } + if (!this.fetch.ffz) { + this.fetchEmotesFFZ(); + } + if (!this.fetch.bttv) { + this.fetchEmotesBTTV(); + } + if (!this.fetch.globalBttv) { + this.fetchEmotesGlobalBTTV(); + } + if (!this.fetch['7tv']) { + this.fetchEmotes7TV(); + } + } + + async fetchEmotesChannel () { + this.fetch.channel = true; + + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + const broadcasterType = variables.get('services.twitch.broadcasterType') as string; + + if (broadcasterId && broadcasterType !== null && (Date.now() - this.lastSubscriberEmoteChk > 1000 * 60 * 60 * 24 * 7 || this.lastChannelChk !== broadcasterId)) { + if (broadcasterType === '' && !broadcasterWarning) { + info(`EMOTES: Skipping fetching of ${broadcasterId} emotes - not subscriber/affiliate`); + broadcasterWarning = true; + } else { + this.lastChannelChk = broadcasterId; + try { + if (this.lastGlobalEmoteChk !== 0) { + info(`EMOTES: Fetching channel ${broadcasterId} emotes`); + } + const emotes = await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.callApi({ url: `chat/emotes?broadcaster_id=${broadcasterId}`, type: 'helix' })); + if (!emotes) { + throw new Error('not found in auth provider'); + } + this.lastSubscriberEmoteChk = Date.now(); + this.cache = this.cache.filter(o => o.type !== 'twitch-sub'); + for (const emote of emotes.data) { + debug('emotes.channel', `Saving to cache ${emote.name}#${emote.id}`); + const template = emotes.template + .replace('{{id}}', emote.id) + .replace('{{format}}', emote.format.includes('animated') ? 'animated' : 'static') + .replace('{{theme_mode}}', 'dark'); + this.cache.push({ + code: emote.name, + type: 'twitch', + urls: { + '1': template.replace('{{scale}}', '1.0'), + '2': template.replace('{{scale}}', '2.0'), + '3': template.replace('{{scale}}', '3.0'), + }, + }); + } + info(`EMOTES: Fetched channel ${broadcasterId} emotes`); + broadcasterWarning = false; + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('not found in auth provider')) { + this.lastSubscriberEmoteChk = 0; // recheck next tick + this.fetch.channel = false; + } else { + error (e.stack ?? e.message); + } + } + } + } + } + this.fetch.channel = false; + } + + async fetchEmotesGlobal () { + this.fetch.global = true; + + // we want to update once every week + if (Date.now() - this.lastGlobalEmoteChk > 1000 * 60 * 60 * 24 * 7) { + try { + if (this.lastGlobalEmoteChk !== 0) { + info('EMOTES: Fetching global emotes'); + } + + const emotes = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.getGlobalEmotes()); + this.lastGlobalEmoteChk = Date.now(); + this.cache = this.cache.filter(o => o.type !== 'twitch'); + for (const emote of emotes ?? []) { + await setImmediateAwait(); + debug('emotes.global', `Saving to cache ${emote.name}#${emote.id}`); + this.cache.push({ + code: emote.name, + type: 'twitch', + urls: { + '1': emote.getImageUrl(1), + '2': emote.getImageUrl(2), + '3': emote.getImageUrl(4), + }, + }); + } + info('EMOTES: Fetched global emotes'); + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('not found in auth provider')) { + this.lastGlobalEmoteChk = 0; // recheck next tick + this.fetch.global = false; + } else { + error (e.stack ?? e.message); + } + } + } + } + + this.fetch.global = false; + } + + async fetchEmotesFFZ () { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + + if (broadcasterUsername.length === 0) { + return; + } + this.fetch.ffz = true; + + // fetch FFZ emotes + if (broadcasterId && Date.now() - this.lastFFZEmoteChk > 1000 * 60 * 60 * 24 * 7) { + info('EMOTES: Fetching ffz emotes'); + this.lastFFZEmoteChk = Date.now(); + try { + const request = await axios.get('https://api.frankerfacez.com/v1/room/id/' + broadcasterId); + + const emoteSet = request.data.room.set; + const emotes = request.data.sets[emoteSet].emoticons; + + for (let i = 0, length = emotes.length; i < length; i++) { + // change 4x to 3x, to be same as Twitch and BTTV + emotes[i].urls['3'] = emotes[i].urls['4']; delete emotes[i].urls['4']; + const cachedEmote = this.cache.find(o => o.code === emotes[i].code && o.type === 'ffz'); + this.cache.push({ + ...cachedEmote, + code: emotes[i].name, + type: 'ffz', + urls: emotes[i].urls, + }); + } + info('EMOTES: Fetched ffz emotes'); + } catch (e: any) { + if (e.response.status === 404) { + warning(`EMOTES: Channel ${broadcasterUsername} not found in ffz`); + } else { + error(e); + } + } + + this.fetch.ffz = false; + } + } + + async fetchEmotes7TV () { + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + + if (broadcasterUsername.length === 0 || this['7tvEmoteSet'].trim().length === 0) { + return; + } + + const getAllChannelEmotes = async (query: string, urlTemplate: string, channel: string): Promise => { + const id = this['7tvEmoteSet'].split('/')[this['7tvEmoteSet'].split('/').length - 1]; + const request = await axios.post('https://7tv.io/v3/gql', { + operationName: 'GetEmoteSet', + query, + variables: { + id, + }, + }); + + if (request.data.data.emoteSet && request.data.data.emoteSet.emotes) { + for (let i = 0, length = request.data.data.emoteSet.emotes.length; i < length; i++) { + await setImmediateAwait(); + const cachedEmote = this.cache.find(o => o.code === request.data.data.emoteSet.emotes[i].name && o.type === '7tv'); + this.cache.push({ + ...cachedEmote, + code: request.data.data.emoteSet.emotes[i].name, + type: '7tv', + urls: { + '1': urlTemplate.replace('{{id}}', request.data.data.emoteSet.emotes[i].id).replace('{{image}}', '1x.avif'), + '2': urlTemplate.replace('{{id}}', request.data.data.emoteSet.emotes[i].id).replace('{{image}}', '2x.avif'), + '3': urlTemplate.replace('{{id}}', request.data.data.emoteSet.emotes[i].id).replace('{{image}}', '3x.avif'), + }, + }); + } + } + }; + + this.fetch['7tv'] = true; + + if (Date.now() - this.last7TVEmoteChk > 1000 * 60 * 60 * 24 * 7) { + info('EMOTES: Fetching 7tv emotes'); + this.last7TVEmoteChk = Date.now(); + this.cache = this.cache.filter(o => o.type !== '7tv'); + try { + const urlTemplate = `https://cdn.7tv.app/emote/{{id}}/{{image}}`; + + const query2 = `query GetEmoteSet($id: ObjectID!, $formats: [ImageFormat!]) { emoteSet(id: $id) { id name capacity emotes { id name data { id name flags listed host { url files(formats: $formats) { name format __typename } __typename } owner { id display_name style { color __typename } roles __typename } __typename } __typename } owner { id username display_name style { color __typename } avatar_url roles connections { emote_capacity __typename } __typename } __typename }}`; + await getAllChannelEmotes(query2, urlTemplate, broadcasterUsername), + info('EMOTES: Fetched 7tv emotes'); + } catch (e: any) { + error(e); + } + } + + this.fetch['7tv'] = false; + } + async fetchEmotesGlobalBTTV () { + this.fetch.globalBttv = true; + + // fetch BTTV emotes + if (Date.now() - this.lastGlobalBTTVEmoteChk > 1000 * 60 * 60 * 24 * 7) { + info('EMOTES: Fetching global bttv emotes'); + this.lastGlobalBTTVEmoteChk = Date.now(); + this.cache = this.cache.filter(o => o.type !== 'bttv'); + try { + const request = await axios.get('https://api.betterttv.net/3/cached/emotes/global'); + + const urlTemplate = 'https://cdn.betterttv.net/emote/{{id}}/{{image}}.webp'; + const emotes = request.data; + + for (let i = 0, length = emotes.length; i < length; i++) { + const cachedEmote = this.cache.find(o => o.code === emotes[i].code && o.type === 'bttv'); + this.cache.push({ + ...cachedEmote, + code: emotes[i].code, + type: 'bttv', + urls: { + '1': urlTemplate.replace('{{id}}', emotes[i].id).replace('{{image}}', '1x'), + '2': urlTemplate.replace('{{id}}', emotes[i].id).replace('{{image}}', '2x'), + '3': urlTemplate.replace('{{id}}', emotes[i].id).replace('{{image}}', '3x'), + }, + }); + } + info('EMOTES: Fetched global bttv emotes'); + } catch (e: any) { + error(e); + } + } + + this.fetch.globalBttv = false; + } + + async fetchEmotesBTTV () { + const broadcasterId = getBroadcasterId(); + + if (broadcasterId.length === 0) { + return; + } + + this.fetch.bttv = true; + + // fetch BTTV emotes + if (Date.now() - this.lastBTTVEmoteChk > 1000 * 60 * 60 * 24 * 7) { + info('EMOTES: Fetching bttv emotes'); + this.lastBTTVEmoteChk = Date.now(); + this.cache = this.cache.filter(o => o.type !== 'bttv'); + try { + const request = await axios.get('https://api.betterttv.net/3/cached/users/twitch/' + broadcasterId); + + const urlTemplate = request.data.urlTemplate; + const emotes = request.data; + + for (let i = 0, length = emotes.length; i < length; i++) { + const cachedEmote = this.cache.find(o => o.code === emotes[i].code && o.type === 'bttv'); + this.cache.push({ + ...cachedEmote, + code: emotes[i].code, + type: 'bttv', + urls: { + '1': urlTemplate.replace('{{id}}', emotes[i].id).replace('{{image}}', '1x'), + '2': urlTemplate.replace('{{id}}', emotes[i].id).replace('{{image}}', '2x'), + '3': urlTemplate.replace('{{id}}', emotes[i].id).replace('{{image}}', '3x'), + }, + }); + } + info('EMOTES: Fetched bttv emotes'); + } catch (e: any) { + if (e.response.status === 404) { + warning(`EMOTES: Channel ${broadcasterId} not found in bttv`); + } else { + error(e); + } + } + } + + this.fetch.bttv = false; + } + + async _testFireworks () { + this.firework(['Kappa', 'GivePLZ', 'PogChamp']); + } + + async _testExplosion () { + this.explode(['Kappa', 'GivePLZ', 'PogChamp']); + } + + async _test () { + ioServer?.of('/services/twitch').emit('emote', { + id: uuid(), + url: { + 1: 'https://static-cdn.jtvnw.net/emoticons/v1/9/1.0', + 2: 'https://static-cdn.jtvnw.net/emoticons/v1/9/2.0', + 3: 'https://static-cdn.jtvnw.net/emoticons/v1/9/3.0', + }, + }); + } + + async firework (data: string[]) { + const emotes = await this.parseEmotes(data); + ioServer?.of('/services/twitch').emit('emote.firework', { emotes, id: uuid() }); + } + + async explode (data: string[]) { + const emotes = await this.parseEmotes(data); + ioServer?.of('/services/twitch').emit('emote.explode', { emotes, id: uuid() }); + } + + @parser({ priority: constants.LOW }) + async containsEmotes (opts: ParserOptions) { + if (!opts.sender) { + return true; + } + + const parsed: string[] = []; + const usedEmotes: { [code: string]: Emotes['cache'][number]} = {}; + + if (opts.emotesOffsets) { + // add emotes from twitch which are not maybe in cache (other partner emotes etc) + for (const emoteId of opts.emotesOffsets.keys()) { + // if emote is already in cache, continue + const firstEmoteOffset = opts.emotesOffsets.get(emoteId)?.shift(); + if (!firstEmoteOffset) { + continue; + } + const emoteCode = opts.message.slice(Number(firstEmoteOffset.split('-')[0]), Number(firstEmoteOffset.split('-')[1])+1); + const idx = this.cache.findIndex(o => o.code === emoteCode); + if (idx === -1) { + const data = { + type: 'twitch', + code: emoteCode, + urls: { + '1': 'https://static-cdn.jtvnw.net/emoticons/v1/' + emoteId + '/1.0', + '2': 'https://static-cdn.jtvnw.net/emoticons/v1/' + emoteId + '/2.0', + '3': 'https://static-cdn.jtvnw.net/emoticons/v1/' + emoteId + '/3.0', + }, + } as const; + + // update emotes in cache + this.cache.push(data); + } + } + } + + for (const potentialEmoteCode of opts.message.trim().split(' ').filter(Boolean)) { + if (parsed.includes(potentialEmoteCode)) { + continue; + } // this emote was already parsed + parsed.push(potentialEmoteCode); + const emoteFromCache = this.cache.find(o => o.code === potentialEmoteCode && this.types.includes(o.type)); + if (emoteFromCache) { + for (let i = 0; i < opts.message.split(' ').filter(word => word === potentialEmoteCode).length; i++) { + usedEmotes[potentialEmoteCode + `${i}`] = emoteFromCache; + } + } + } + + const emotes = shuffle(Object.keys(usedEmotes)); + for (let i = 0; i < emotes.length; i++) { + const id = uuid(); + ioServer?.of('/services/twitch').emit('emote', { id, url: usedEmotes[emotes[i]].urls }); + } + return true; + } + + async parseEmotes (emotes: string[]) { + const emotesArray: {1: string, 2: string, 3:string }[] = []; + + for (let i = 0, length = emotes.length; i < length; i++) { + try { + const items = this.cache.filter(o => o.code === emotes[i]); + if (items.length > 0) { + emotesArray.push(items[0].urls); + } + } catch (e: any) { + continue; + } + } + return emotesArray; + } +} + +export default new Emotes(); diff --git a/backend/src/events.ts b/backend/src/events.ts new file mode 100644 index 000000000..1bb3c321c --- /dev/null +++ b/backend/src/events.ts @@ -0,0 +1,893 @@ +import { setTimeout } from 'timers'; // tslint workaround + +import { sample } from '@sogebot/ui-helpers/array.js'; +import { dayjs } from '@sogebot/ui-helpers/dayjsHelper.js'; +import { generateUsername } from '@sogebot/ui-helpers/generateUsername.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import _, { + clone, cloneDeep, get, isNil, random, +} from 'lodash-es'; +import { VM } from 'vm2'; + +import twitch from './services/twitch.js'; + +import Core from '~/_interface.js'; +import { parserReply } from '~/commons.js'; +import { + Event, EventInterface, Events as EventsEntity, +} from '~/database/entity/event.js'; +import { User } from '~/database/entity/user.js'; +import { AppDataSource } from '~/database.js'; +import { onStreamEnd } from '~/decorators/on.js'; +import events from '~/events.js'; +import { isStreamOnline, rawStatus, stats, streamStatusChangeSince } from '~/helpers/api/index.js'; +import { attributesReplace } from '~/helpers/attributesReplace.js'; +import { + announce, getOwner, getUserSender, isUUID, prepare, +} from '~/helpers/commons/index.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import { + getAll, getValueOf, setValueOf, +} from '~/helpers/customvariables/index.js'; +import { isDbConnected } from '~/helpers/database.js'; +import { eventEmitter } from '~/helpers/events/emitter.js'; +import { fireRunCommand } from '~/helpers/events/run-command.js'; +import emitter from '~/helpers/interfaceEmitter.js'; +import { + debug, error, info, warning, +} from '~/helpers/log.js'; +import { addUIError } from '~/helpers/panel/index.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import { tmiEmitter } from '~/helpers/tmi/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { + isOwner, isSubscriber, isVIP, +} from '~/helpers/user/index.js'; +import { isBot, isBotSubscriber } from '~/helpers/user/isBot.js'; +import { isBroadcaster } from '~/helpers/user/isBroadcaster.js'; +import { isModerator } from '~/helpers/user/isModerator.js'; +import { createClip } from '~/services/twitch/calls/createClip.js'; +import { getCustomRewards } from '~/services/twitch/calls/getCustomRewards.js'; +import { getIdFromTwitch } from '~/services/twitch/calls/getIdFromTwitch.js'; +import { updateChannelInfo } from '~/services/twitch/calls/updateChannelInfo.js'; +import { variables } from '~/watchers.js'; + +const excludedUsers = new Set(); + +class Events extends Core { + public timeouts: { [x: string]: NodeJS.Timeout } = {}; + public supportedEventsList: { + id: string; + variables?: string[]; + check?: (event: EventInterface, attributes: any) => Promise; + definitions?: { + [x: string]: any; + }; + }[]; + public supportedOperationsList: { + id: string; + definitions?: { + [x: string]: any; + }; + fire: (operation: EventsEntity.OperationDefinitions, attributes: EventsEntity.Attributes) => Promise; + }[]; + + constructor() { + super(); + + this.supportedEventsList = [ + { id: 'prediction-started', variables: [ 'titleOfPrediction', 'outcomes', 'locksAt' ] }, + { id: 'prediction-locked', variables: [ 'titleOfPrediction', 'outcomes', 'locksAt' ] }, + { id: 'prediction-ended', variables: [ 'titleOfPrediction', 'outcomes', 'locksAt', 'winningOutcomeTitle', 'winningOutcomeTotalPoints', 'winningOutcomePercentage' ] }, + { id: 'poll-started', variables: [ 'titleOfPoll', 'choices', 'bitVotingEnabled', 'bitAmountPerVote', 'channelPointsVotingEnabled', 'channelPointsAmountPerVote' ] }, + { id: 'poll-ended', variables: [ 'titleOfPoll', 'choices', 'votes', 'winnerVotes', 'winnerPercentage', 'winnerChoice' ] }, + { id: 'hypetrain-started', variables: [ ] }, + { id: 'hypetrain-ended', variables: [ 'level', 'total', 'goal', 'topContributionsBitsUserId', 'topContributionsBitsUsername', 'topContributionsBitsTotal', 'topContributionsSubsUserId', 'topContributionsSubsUsername', 'topContributionsSubsTotal', 'lastContributionType', 'lastContributionUserId', 'lastContributionUsername', 'lastContributionTotal' ] }, + { id: 'hypetrain-level-reached', variables: [ 'level', 'total', 'goal', 'topContributionsBitsUserId', 'topContributionsBitsUsername', 'topContributionsBitsTotal', 'topContributionsSubsUserId', 'topContributionsSubsUsername', 'topContributionsSubsTotal', 'lastContributionType', 'lastContributionUserId', 'lastContributionUsername', 'lastContributionTotal' ] }, + { id: 'user-joined-channel', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner' ] }, + { id: 'user-parted-channel', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner' ] }, + { + id: 'number-of-viewers-is-at-least-x', variables: [ 'count' ], definitions: { viewersAtLeast: 100, runInterval: 0 }, check: this.checkNumberOfViewersIsAtLeast, + }, // runInterval 0 or null - disabled; > 0 every x seconds + { + id: 'stream-is-running-x-minutes', definitions: { runAfterXMinutes: 100 }, check: this.checkStreamIsRunningXMinutes, + }, + { id: 'mod', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner' ] }, + { id: 'commercial', variables: [ 'duration' ] }, + { id: 'timeout', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'duration' ] }, + { + id: 'reward-redeemed', definitions: { rewardId: '' }, variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'userInput' ], check: this.isCorrectReward, + }, + + /* abandoned events */ + { + id: 'command-send-x-times', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'command', 'count', 'source' ], definitions: { + fadeOutXCommands: 0, fadeOutInterval: 0, runEveryXCommands: 10, commandToWatch: '', runInterval: 0, + }, check: this.checkCommandSendXTimes, + }, // runInterval 0 or null - disabled; > 0 every x seconds + { id: 'chatter-first-message', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'source' ] }, + { + id: 'keyword-send-x-times', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'command', 'count', 'source' ], definitions: { + fadeOutXKeywords: 0, fadeOutInterval: 0, runEveryXKeywords: 10, keywordToWatch: '', runInterval: 0, resetCountEachMessage: false, + }, check: this.checkKeywordSendXTimes, + }, // runInterval 0 or null - disabled; > 0 every x seconds + { id: 'action', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner' ] }, + { id: 'ban', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'reason' ] }, + + /* ported to plugins */ + { id: 'cheer', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'bits', 'message' ] }, + { id: 'clearchat' }, + { id: 'game-changed', variables: [ 'oldGame', 'game' ] }, + { id: 'stream-started' }, + { id: 'stream-stopped' }, + { id: 'follow', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner' ] }, + { id: 'subscription', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'method', 'subCumulativeMonths', 'tier' ] }, + { id: 'subgift', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'recipient', 'recipientis.moderator', 'recipientis.subscriber', 'recipientis.vip', 'recipientis.broadcaster', 'recipientis.bot', 'recipientis.owner', 'tier' ] }, + { id: 'subcommunitygift', variables: [ 'username', 'count' ] }, + { id: 'resub', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'subStreakShareEnabled', 'subStreak', 'subStreakName', 'subCumulativeMonths', 'subCumulativeMonthsName', 'tier', 'message' ] }, + { id: 'tip', variables: [ 'username', 'amount', 'currency', 'message', 'amountInBotCurrency', 'currencyInBot' ] }, + { + id: 'raid', variables: [ 'username', 'is.moderator', 'is.subscriber', 'is.vip', 'is.broadcaster', 'is.bot', 'is.owner', 'hostViewers' ], definitions: { viewersAtLeast: 1 }, check: this.checkRaid, + }, + { + // cronable + id: 'every-x-minutes-of-stream', definitions: { runEveryXMinutes: 100 }, check: this.everyXMinutesOfStream, + }, + ]; + + this.supportedOperationsList = [ + { + id: 'send-chat-message', definitions: { messageToSend: '' }, fire: this.fireSendChatMessage, + }, + { + id: 'send-whisper', definitions: { messageToSend: '' }, fire: this.fireSendWhisper, + }, + { + id: 'run-command', definitions: { commandToRun: '', isCommandQuiet: false, timeout: 0, timeoutType: ['normal', 'add', 'reset'] }, fire: fireRunCommand, + }, + { + id: 'emote-explosion', definitions: { emotesToExplode: '' }, fire: this.fireEmoteExplosion, + }, + { + id: 'emote-firework', definitions: { emotesToFirework: '' }, fire: this.fireEmoteFirework, + }, + { + id: 'start-commercial', definitions: { durationOfCommercial: [30, 60, 90, 120, 150, 180] }, fire: this.fireStartCommercial, + }, + { + id: 'bot-will-join-channel', definitions: {}, fire: this.fireBotWillJoinChannel, + }, + { + id: 'bot-will-leave-channel', definitions: {}, fire: this.fireBotWillLeaveChannel, + }, + { + id: 'create-a-clip', definitions: { announce: false, hasDelay: true, replay: false }, fire: this.fireCreateAClip, + }, + { + id: 'increment-custom-variable', definitions: { customVariable: '', numberToIncrement: '1' }, fire: this.fireIncrementCustomVariable, + }, + { + id: 'set-custom-variable', definitions: { customVariable: '', value: '' }, fire: this.fireSetCustomVariable, + }, + { + id: 'decrement-custom-variable', definitions: { customVariable: '', numberToDecrement: '1' }, fire: this.fireDecrementCustomVariable, + }, + ]; + + this.addMenu({ + category: 'manage', name: 'events', id: 'manage/events', this: null, + }); + this.fadeOut(); + + // emitter .on listeners + for (const event of [ + 'prediction-started', + 'prediction-locked', + 'prediction-ended', + 'poll-started', + 'poll-ended', + 'hypetrain-started', + 'hypetrain-ended', + 'hypetrain-level-reached', + 'action', + 'commercial', + 'game-changed', + 'follow', + 'cheer', + 'user-joined-channel', + 'user-parted-channel', + 'subcommunitygift', + 'reward-redeemed', + 'timeout', + 'ban', + 'raid', + 'stream-started', + 'stream-stopped', + 'subscription', + 'resub', + 'clearchat', + 'command-send-x-times', + 'chatter-first-message', + 'keyword-send-x-times', + 'every-x-minutes-of-stream', + 'stream-is-running-x-minutes', + 'subgift', + 'number-of-viewers-is-at-least-x', + 'tip', + 'obs-scene-changed', + 'obs-input-mute-state-changed', + ] as const) { + eventEmitter.on(event, (opts?: EventsEntity.Attributes) => { + if (typeof opts === 'undefined') { + opts = {}; + } + events.fire(event, { ...opts }); + }); + } + } + + @onStreamEnd() + resetExcludedUsers() { + excludedUsers.clear(); + } + + public async fire(eventId: string, attributes: EventsEntity.Attributes): Promise { + attributes = cloneDeep(attributes) || {}; + debug('events', JSON.stringify({ eventId, attributes })); + + if (!attributes.isAnonymous) { + if (attributes.userName !== null && typeof attributes.userName !== 'undefined' && (attributes.userId || !attributes.userId && !excludedUsers.has(attributes.userName))) { + excludedUsers.delete(attributes.userName); // remove from excluded users if passed first if + + await changelog.flush(); + const user = attributes.userId + ? await AppDataSource.getRepository(User).findOneBy({ userId: attributes.userId }) + : await AppDataSource.getRepository(User).findOneBy({ userName: attributes.userName }); + + if (!user) { + try { + const userId = attributes.userId ? attributes.userId : await getIdFromTwitch(attributes.userName); + changelog.update(userId, { userName: attributes.userName }); + return this.fire(eventId, attributes); + } catch (e: any) { + excludedUsers.add(attributes.userName); + warning(`User ${attributes.userName} triggered event ${eventId} was not found on Twitch.`); + warning(`User ${attributes.userName} will be excluded from events, until stream restarts or user writes in chat and his data will be saved.`); + warning(e); + return; + } + } + + attributes.eventId = eventId; + + // add is object + attributes.is = { + moderator: isModerator(user), + subscriber: isSubscriber(user), + vip: isVIP(user), + broadcaster: isBroadcaster(attributes.userName), + bot: isBot(attributes.userName), + owner: isOwner(attributes.userName), + }; + } + } + if (!isNil(get(attributes, 'recipient', null))) { + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userName: attributes.recipient }); + if (!user) { + const userId = await getIdFromTwitch(attributes.recipient); + changelog.update(userId, { userName: attributes.recipient }); + this.fire(eventId, attributes); + return; + } + + // add is object + attributes.recipientis = { + moderator: isModerator(user), + subscriber: isSubscriber(user), + vip: isVIP(user), + broadcaster: isBroadcaster(attributes.recipient), + bot: isBot(attributes.recipient), + owner: isOwner(attributes.recipient), + }; + } + if (get(attributes, 'reset', false)) { + this.reset(eventId); + return; + } + + const eventsFromRepository = await AppDataSource.getRepository(Event).find({ + relations: ['operations'], + where: isUUID(eventId) + ? { id: eventId, isEnabled: true } + : { name: eventId, isEnabled: true }, + }); + + for (const event of eventsFromRepository) { + const [shouldRunByFilter, shouldRunByDefinition] = await Promise.all([ + this.checkFilter(event, cloneDeep(attributes)), + this.checkDefinition(clone(event), cloneDeep(attributes)), + ]); + if ((!shouldRunByFilter || !shouldRunByDefinition) && !attributes.isTriggeredByCommand) { + continue; + } + info(`Event ${eventId} with attributes ${JSON.stringify(attributes)} is triggered and running of operations.`); + for (const operation of event.operations) { + const isOperationSupported = typeof this.supportedOperationsList.find((o) => o.id === operation.name) !== 'undefined'; + if (isOperationSupported) { + const foundOp = this.supportedOperationsList.find((o) => o.id === operation.name); + if (foundOp) { + if (attributes.isTriggeredByCommand && operation.name === 'run-command' && String(operation.definitions.commandToRun).startsWith(attributes.isTriggeredByCommand)) { + warning(`Cannot trigger operation run-command ${operation.definitions.command}, because it would cause infinite loop.`); + } else { + foundOp.fire(operation.definitions, cloneDeep({ ...attributes, eventId: event.id })); + } + } + } + } + } + } + + // set triggered attribute to empty object + public async reset(eventId: string) { + for (const event of await AppDataSource.getRepository(Event).findBy({ name: eventId })) { + await AppDataSource.getRepository(Event).save({ ...event, triggered: {} }); + } + } + + public async fireCreateAClip(operation: EventsEntity.OperationDefinitions) { + const cid = await createClip({ createAfterDelay: !!operation.hasDelay }); + if (cid) { // OK + if (Boolean(operation.announce) === true) { + announce(prepare('api.clips.created', { link: `https://clips.twitch.tv/${cid}` }), 'general'); + } + + if (operation.replay) { + (await import('~/overlays/clips.js')).default.showClip(cid); + } + info('Clip was created successfully'); + return cid; + } else { // NG + warning('Clip was not created successfully'); + return null; + } + } + + public async fireBotWillJoinChannel() { + tmiEmitter.emit('join', 'bot'); + } + + public async fireBotWillLeaveChannel() { + + tmiEmitter.emit('part', 'bot'); + // force all users offline + await changelog.flush(); + await AppDataSource.getRepository(User).update({}, { isOnline: false }); + } + + public async fireStartCommercial(operation: EventsEntity.OperationDefinitions) { + try { + const cid = variables.get('services.twitch.broadcasterId') as string; + const broadcasterCurrentScopes = variables.get('services.twitch.broadcasterCurrentScopes') as string[]; + const duration = operation.durationOfCommercial + ? Number(operation.durationOfCommercial) + : 30; + // check if duration is correct (30, 60, 90, 120, 150, 180) + if ([30, 60, 90, 120, 150, 180].includes(duration)) { + if (!broadcasterCurrentScopes.includes('channel:edit:commercial')) { + warning('Missing Broadcaster oAuth scope channel:edit:commercial to start commercial'); + addUIError({ name: 'OAUTH', message: 'Missing Broadcaster oAuth scope channel:edit:commercial to start commercial' }); + return; + } + if (!broadcasterCurrentScopes.includes('channel:edit:commercial')) { + warning('Missing Broadcaster oAuth scope channel:edit:commercial to start commercial'); + addUIError({ name: 'OAUTH', message: 'Missing Broadcaster oAuth scope channel:edit:commercial to start commercial' }); + return; + } + await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.channels.startChannelCommercial(cid, duration as 30 | 60 | 90 | 120 | 150 | 180)); + eventEmitter.emit('commercial', { duration }); + } else { + throw new Error('Incorrect duration set'); + } + } catch (e) { + if (e instanceof Error) { + error(e.stack ?? e.message); + } + } + } + + public async fireEmoteExplosion(operation: EventsEntity.OperationDefinitions) { + emitter.emit('services::twitch::emotes', 'explode', String(operation.emotesToExplode).split(' ')); + } + + public async fireEmoteFirework(operation: EventsEntity.OperationDefinitions) { + emitter.emit('services::twitch::emotes', 'firework', String(operation.emotesToFirework).split(' ')); + } + + public async fireSendChatMessageOrWhisper(operation: EventsEntity.OperationDefinitions, attributes: EventsEntity.Attributes, whisper: boolean): Promise { + const userName = isNil(attributes.userName) ? getOwner() : attributes.userName; + let userId = attributes.userId; + + let userObj; + if (userId) { + userObj = await changelog.get(userId); + } else { + await changelog.flush(); + userObj = await AppDataSource.getRepository(User).findOneBy({ userName }); + } + await changelog.flush(); + if (!userObj && !attributes.test) { + userId = await getIdFromTwitch(userName); + changelog.update(userId, { userName }); + return this.fireSendChatMessageOrWhisper(operation, { + ...attributes, userId, userName, + }, whisper); + } else if (attributes.test) { + userId = attributes.userId; + } else if (!userObj) { + return; + } + + const message = attributesReplace(attributes, String(operation.messageToSend)); + parserReply(message, { + id: '', + sender: getUserSender(userId ?? '0', userName), + discord: undefined, + }); + } + + public async fireSendWhisper(operation: EventsEntity.OperationDefinitions, attributes: EventsEntity.Attributes) { + events.fireSendChatMessageOrWhisper(operation, attributes, true); + } + + public async fireSendChatMessage(operation: EventsEntity.OperationDefinitions, attributes: EventsEntity.Attributes) { + events.fireSendChatMessageOrWhisper(operation, attributes, false); + } + + public async fireSetCustomVariable(operation: EventsEntity.OperationDefinitions, attributes: EventsEntity.Attributes) { + const customVariableName = operation.customVariable; + const value = attributesReplace(attributes, String(operation.value)); + await setValueOf(String(customVariableName), value, {}); + + // Update widgets and titles + eventEmitter.emit('CustomVariable:OnRefresh'); + + const regexp = new RegExp(`\\$_${customVariableName}`, 'ig'); + const title = rawStatus.value; + if (title.match(regexp)) { + updateChannelInfo({}); + } + } + public async fireIncrementCustomVariable(operation: EventsEntity.OperationDefinitions) { + const customVariableName = String(operation.customVariable).replace('$_', ''); + const numberToIncrement = Number(operation.numberToIncrement); + + // check if value is number + let currentValue: string | number = await getValueOf('$_' + customVariableName); + if (!_.isFinite(parseInt(currentValue, 10))) { + currentValue = String(numberToIncrement); + } else { + currentValue = String(parseInt(currentValue, 10) + numberToIncrement); + } + await setValueOf(String('$_' + customVariableName), currentValue, {}); + + // Update widgets and titles + eventEmitter.emit('CustomVariable:OnRefresh'); + + const regexp = new RegExp(`\\$_${customVariableName}`, 'ig'); + const title = rawStatus.value; + if (title.match(regexp)) { + updateChannelInfo({}); + } + } + + public async fireDecrementCustomVariable(operation: EventsEntity.OperationDefinitions) { + const customVariableName = String(operation.customVariable).replace('$_', ''); + const numberToDecrement = Number(operation.numberToDecrement); + + // check if value is number + let currentValue = await getValueOf('$_' + customVariableName); + if (!_.isFinite(parseInt(currentValue, 10))) { + currentValue = String(numberToDecrement * -1); + } else { + currentValue = String(parseInt(currentValue, 10) - numberToDecrement); + } + await setValueOf(String('$_' + customVariableName), currentValue, {}); + + // Update widgets and titles + eventEmitter.emit('CustomVariable:OnRefresh'); + const regexp = new RegExp(`\\$_${customVariableName}`, 'ig'); + const title = rawStatus.value; + if (title.match(regexp)) { + updateChannelInfo({}); + } + } + + public async everyXMinutesOfStream(event: EventInterface) { + // set to Date.now() because 0 will trigger event immediatelly after stream start + const shouldSave = get(event, 'triggered.runEveryXMinutes', 0) === 0 || typeof get(event, 'triggered.runEveryXMinutes', 0) !== 'number'; + event.triggered.runEveryXMinutes = get(event, 'triggered.runEveryXMinutes', Date.now()); + + const shouldTrigger = Date.now() - new Date(event.triggered.runEveryXMinutes).getTime() >= Number(event.definitions.runEveryXMinutes) * 60 * 1000; + if (shouldTrigger || shouldSave) { + event.triggered.runEveryXMinutes = Date.now(); + await AppDataSource.getRepository(Event).save(event); + } + return shouldTrigger; + } + + public async isCorrectReward(event: EventInterface, attributes: EventsEntity.Attributes) { + const shouldTrigger = (attributes.rewardId === event.definitions.rewardId); + return shouldTrigger; + } + + public async checkRaid(event: EventInterface, attributes: EventsEntity.Attributes) { + event.definitions.viewersAtLeast = Number(event.definitions.viewersAtLeast); // force Integer + const shouldTrigger = (attributes.hostViewers >= event.definitions.viewersAtLeast); + return shouldTrigger; + } + + public async checkStreamIsRunningXMinutes(event: EventInterface) { + if (!isStreamOnline.value) { + return false; + } + event.triggered.runAfterXMinutes = get(event, 'triggered.runAfterXMinutes', 0); + const shouldTrigger = event.triggered.runAfterXMinutes === 0 + && Number(dayjs.utc().unix()) - Number(dayjs.utc(streamStatusChangeSince.value).unix()) > Number(event.definitions.runAfterXMinutes) * 60; + if (shouldTrigger) { + event.triggered.runAfterXMinutes = event.definitions.runAfterXMinutes; + await AppDataSource.getRepository(Event).save(event); + } + return shouldTrigger; + } + + public async checkNumberOfViewersIsAtLeast(event: EventInterface) { + event.triggered.runInterval = get(event, 'triggered.runInterval', 0); + + event.definitions.runInterval = Number(event.definitions.runInterval); // force Integer + event.definitions.viewersAtLeast = Number(event.definitions.viewersAtLeast); // force Integer + + const viewers = stats.value.currentViewers; + + const shouldTrigger = viewers >= event.definitions.viewersAtLeast + && ((event.definitions.runInterval > 0 && Date.now() - event.triggered.runInterval >= event.definitions.runInterval * 1000) + || (event.definitions.runInterval === 0 && event.triggered.runInterval === 0)); + if (shouldTrigger) { + event.triggered.runInterval = Date.now(); + await AppDataSource.getRepository(Event).save(event); + } + return shouldTrigger; + } + + public async checkCommandSendXTimes(event: EventInterface, attributes: EventsEntity.Attributes) { + const regexp = new RegExp(`^${event.definitions.commandToWatch}\\s`, 'i'); + + let shouldTrigger = false; + attributes.message += ' '; + if (attributes.message.match(regexp)) { + event.triggered.runEveryXCommands = get(event, 'triggered.runEveryXCommands', 0); + event.triggered.runInterval = get(event, 'triggered.runInterval', 0); + + event.definitions.runInterval = Number(event.definitions.runInterval); // force Integer + event.triggered.runInterval = Number(event.triggered.runInterval); // force Integer + + event.triggered.runEveryXCommands++; + shouldTrigger + = event.triggered.runEveryXCommands >= event.definitions.runEveryXCommands + && ((event.definitions.runInterval > 0 && Date.now() - event.triggered.runInterval >= event.definitions.runInterval * 1000) + || (event.definitions.runInterval === 0 && event.triggered.runInterval === 0)); + if (shouldTrigger) { + event.triggered.runInterval = Date.now(); + event.triggered.runEveryXCommands = 0; + } + await AppDataSource.getRepository(Event).save(event); + } + return shouldTrigger; + } + + public async checkKeywordSendXTimes(event: EventInterface, attributes: EventsEntity.Attributes) { + const regexp = new RegExp(`${event.definitions.keywordToWatch}`, 'gi'); + + let shouldTrigger = false; + attributes.message += ' '; + const match = attributes.message.match(regexp); + if (match) { + event.triggered.runEveryXKeywords = get(event, 'triggered.runEveryXKeywords', 0); + event.triggered.runInterval = get(event, 'triggered.runInterval', 0); + + event.definitions.runInterval = Number(event.definitions.runInterval); // force Integer + event.triggered.runInterval = Number(event.triggered.runInterval); // force Integer + + if (event.definitions.resetCountEachMessage) { + event.triggered.runEveryXKeywords = 0; + } + + // add count from match + event.triggered.runEveryXKeywords = Number(event.triggered.runEveryXKeywords) + Number(match.length); + + shouldTrigger + = event.triggered.runEveryXKeywords >= event.definitions.runEveryXKeywords + && ((event.definitions.runInterval > 0 && Date.now() - event.triggered.runInterval >= event.definitions.runInterval * 1000) + || (event.definitions.runInterval === 0 && event.triggered.runInterval === 0)); + if (shouldTrigger) { + event.triggered.runInterval = Date.now(); + event.triggered.runEveryXKeywords = 0; + } + await AppDataSource.getRepository(Event).save(event); + } + return shouldTrigger; + } + + public async checkDefinition(event: EventInterface, attributes: EventsEntity.Attributes) { + const foundEvent = this.supportedEventsList.find((o) => o.id === event.name); + if (!foundEvent || !foundEvent.check) { + return true; + } + return foundEvent.check(event, attributes); + } + + public async checkFilter(event: EventInterface, attributes: EventsEntity.Attributes) { + if (event.filter.trim().length === 0) { + return true; + } + + const customVariables = await getAll(); + const toEval = `(function () { return ${event.filter} })`; + const sandbox = { + $username: get(attributes, 'username', null), + $source: get(attributes, 'source', null), + $is: { + moderator: get(attributes, 'is.moderator', false), + subscriber: get(attributes, 'is.subscriber', false), + vip: get(attributes, 'is.vip', false), + broadcaster: get(attributes, 'is.broadcaster', false), + bot: get(attributes, 'is.bot', false), + owner: get(attributes, 'is.owner', false), + }, + $months: get(attributes, 'months', null), + $monthsName: get(attributes, 'monthsName', null), + $message: get(attributes, 'message', null), + $command: get(attributes, 'command', null), + $count: get(attributes, 'count', null), + $bits: get(attributes, 'bits', null), + $reason: get(attributes, 'reason', null), + $target: get(attributes, 'target', null), + $duration: get(attributes, 'duration', null), + $hostViewers: get(attributes, 'hostViewers', null), + // add global variables + $viewers: stats.value.currentViewers, + $game: stats.value.currentGame, + $title: stats.value.currentTitle, + $followers: stats.value.currentFollowers, + $subscribers: stats.value.currentSubscribers, + $isBotSubscriber: isBotSubscriber(), + $isStreamOnline: isStreamOnline.value, + // sub/resub + $method: get(attributes, 'method', null), + $tier: get(attributes, 'tier', null), + $subStreakShareEnabled: get(attributes, 'subStreakShareEnabled', null), + $subStreak: get(attributes, 'subStreak', false), + $subCumulativeMonths: get(attributes, 'subCumulativeMonths', false), + // hypetrain + $level: get(attributes, 'level', null), + $total: get(attributes, 'total', null), + $goal: get(attributes, 'goal', null), + $topContributionsBitsUserId: get(attributes, 'topContributionsBitsUserId', null), + $topContributionsBitsUsername: get(attributes, 'topContributionsBitsUsername', null), + $topContributionsBitsTotal: get(attributes, 'topContributionsBitsTotal', null), + $topContributionsSubsUserId: get(attributes, 'topContributionsSubsUserId', null), + $topContributionsSubsUsername: get(attributes, 'topContributionsSubsUsername', null), + $topContributionsSubsTotal: get(attributes, 'topContributionsSubsTotal', null), + $lastContributionType: get(attributes, 'lastContributionType', null), + $lastContributionUserId: get(attributes, 'lastContributionUserId', null), + $lastContributionUsername: get(attributes, 'lastContributionUsername', null), + $lastContributionTotal: get(attributes, 'lastContributionTotal', null), + // game-changed + $oldGame: get(attributes, 'oldGame', null), + // reward + $userInput: get(attributes, 'userInput', null), + ...customVariables, + }; + let result = false; + try { + const vm = new VM({ sandbox }); + result = vm.run(toEval)(); + } catch (e: any) { + // do nothing + } + return !!result; // force boolean + } + + public sockets() { + adminEndpoint('/core/events', 'events::getRedeemedRewards', async (cb) => { + try { + const rewards = await getCustomRewards() ?? []; + cb(null, [...rewards.map(o => ({ name: o.title, id: o.id }))]); + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/core/events', 'generic::getAll', async (cb) => { + try { + cb(null, await AppDataSource.getRepository(Event).find({ relations: ['operations'] })); + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/core/events', 'generic::getOne', async (id, cb) => { + try { + const event = await AppDataSource.getRepository(Event).findOne({ + relations: ['operations'], + where: { id }, + }); + cb(null, event as any); + } catch (e: any) { + cb(e.stack, undefined); + } + }); + adminEndpoint('/core/events', 'list.supported.events', (cb) => { + try { + cb(null, this.supportedEventsList); + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/core/events', 'list.supported.operations', (cb) => { + try { + cb(null, this.supportedOperationsList); + } catch (e: any) { + cb(e.stack, []); + } + }); + + adminEndpoint('/core/events', 'test.event', async ({ id, randomized, values, variables: variablesArg }, cb) => { + try { + const attributes: Record = { + test: true, + userId: '0', + currency: sample(['CZK', 'USD', 'EUR']), + ...variablesArg.map((variableMap, idx) => { + if (['username', 'recipient', 'target', 'topContributionsBitsUsername', 'topContributionsSubsUsername', 'lastContributionUsername'].includes(variableMap)) { + return { [variableMap]: randomized.includes(variableMap) ? generateUsername() : values[idx] }; + } else if (['userInput', 'message', 'reason'].includes(variableMap)) { + return { [variableMap]: randomized.includes(variableMap) ? sample(['', 'Lorem Ipsum Dolor Sit Amet']) : values[idx] }; + } else if (['source'].includes(variableMap)) { + return { [variableMap]: randomized.includes(variableMap) ? sample(['Twitch', 'Discord']) : values[idx] }; + } else if (['tier'].includes(variableMap)) { + return { [variableMap]: randomized.includes(variableMap) ? random(0, 3, false) : (values[idx] === 'Prime' ? 0 : Number(values[idx])) }; + } else if (['hostViewers', 'lastContributionTotal', 'topContributionsSubsTotal', 'topContributionsBitsTotal', 'duration', 'viewers', 'bits', 'subCumulativeMonths', 'count', 'subStreak', 'amount', 'amountInBotCurrency'].includes(variableMap)) { + return { [variableMap]: randomized.includes(variableMap) ? random(10, 10000000000, false) : values[idx] }; + } else if (['game', 'oldGame'].includes(variableMap)) { + return { + [variableMap]: randomized.includes(variableMap) + ? sample(['Dota 2', 'Escape From Tarkov', 'Star Citizen', 'Elite: Dangerous']) + : values[idx], + }; + } else if (['command'].includes(variableMap)) { + return { [variableMap]: randomized.includes(variableMap) ? sample(['!me', '!top', '!points']) : values[idx] }; + } else if (['subStreakShareEnabled'].includes(variableMap) || variableMap.startsWith('is.') || variableMap.startsWith('recipientis.')) { + return { [variableMap]: randomized.includes(variableMap) ? random(0, 1, false) === 0 : values[idx] }; + } else if (['level'].includes(variableMap)) { + return { [variableMap]: randomized.includes(variableMap) ? random(1, 5, false) : values[idx] }; + } else if (['topContributionsSubsUserId', 'topContributionsBitsUserId', 'lastContributionUserId'].includes(variableMap)) { + return { [variableMap]: randomized.includes(variableMap) ? String(random(90000, 900000, false)) : values[idx] }; + } else if (['lastContributionType'].includes(variableMap)) { + return { [variableMap]: randomized.includes(variableMap) ? sample(['BITS', 'SUBS']) : values[idx] }; + } + }).reduce((prev, cur) => { + return { ...prev, ...cur }; + }, {}), + }; + + if (attributes.subStreak !== undefined) { + attributes.subStreakName = getLocalizedName(attributes.subStreak, 'core.months'); + } + + if (attributes.subCumulativeMonths !== undefined) { + attributes.subCumulativeMonthsName = getLocalizedName(attributes.subCumulativeMonths, 'core.months'); + } + + if (attributes.subCumulativeMonths !== undefined) { + attributes.subCumulativeMonthsName = getLocalizedName(attributes.subCumulativeMonths, 'core.months'); + } + + if (attributes.amountInBotCurrency !== undefined) { + attributes.currencyInBot = mainCurrency.value; + } + + if (attributes.amountInBotCurrency !== undefined) { + attributes.currencyInBot = mainCurrency.value; + } + + if (attributes.amount !== undefined) { + attributes.amount = Number(attributes.amount).toFixed(2); + } + + const event = await AppDataSource.getRepository(Event).findOne({ + relations: ['operations'], + where: { id }, + }); + if (event) { + for (const operation of event.operations) { + const foundOp = this.supportedOperationsList.find((o) => o.id === operation.name); + if (foundOp) { + foundOp.fire(operation.definitions, attributes); + } + } + } + cb(null); + } catch (e: any) { + cb(e.stack); + } + }); + + adminEndpoint('/core/events', 'events::save', async (event, cb) => { + try { + cb(null, await AppDataSource.getRepository(Event).save({ ...event, operations: event.operations.filter(o => o.name !== 'do-nothing') })); + } catch (e: any) { + cb(e.stack, event); + } + }); + + adminEndpoint('/core/events', 'events::remove', async (eventId, cb) => { + const event = await AppDataSource.getRepository(Event).findOneBy({ id: eventId }); + if (event) { + await AppDataSource.getRepository(Event).remove(event); + } + cb(null); + }); + } + + protected async fadeOut() { + if (!isDbConnected) { + setTimeout(() => this.fadeOut, 10); + return; + } + + try { + for (const event of (await AppDataSource.getRepository(Event) + .createQueryBuilder('event') + .where('event.name = :event1', { event1: 'command-send-x-times' }) + .orWhere('event.name = :event2', { event2: 'keyword-send-x-times ' }) + .getMany())) { + if (isNil(get(event, 'triggered.fadeOutInterval', null))) { + // fadeOutInterval init + event.triggered.fadeOutInterval = Date.now(); + await AppDataSource.getRepository(Event).save(event); + } else { + if (Date.now() - event.triggered.fadeOutInterval >= Number(event.definitions.fadeOutInterval) * 1000) { + // fade out commands + if (event.name === 'command-send-x-times') { + if (!isNil(get(event, 'triggered.runEveryXCommands', null))) { + if (event.triggered.runEveryXCommands <= 0) { + continue; + } + + event.triggered.fadeOutInterval = Date.now(); + event.triggered.runEveryXCommands = event.triggered.runEveryXCommands - Number(event.definitions.fadeOutXCommands); + await AppDataSource.getRepository(Event).save(event); + } + } else if (event.name === 'keyword-send-x-times') { + if (!isNil(get(event, 'triggered.runEveryXKeywords', null))) { + if (event.triggered.runEveryXKeywords <= 0) { + continue; + } + + event.triggered.fadeOutInterval = Date.now(); + event.triggered.runEveryXKeywords = event.triggered.runEveryXKeywords - Number(event.definitions.fadeOutXKeywords); + await AppDataSource.getRepository(Event).save(event); + } + } + } + } + } + } catch (e: any) { + error(e.stack); + } finally { + clearTimeout(this.timeouts.fadeOut); + this.timeouts.fadeOut = setTimeout(() => this.fadeOut(), 1000); + } + } +} + +export default new Events(); diff --git a/backend/src/expects.ts b/backend/src/expects.ts new file mode 100644 index 000000000..885a15af6 --- /dev/null +++ b/backend/src/expects.ts @@ -0,0 +1,615 @@ +import { + DAY, HOUR, MINUTE, SECOND, +} from '@sogebot/ui-helpers/constants.js'; +import { + defaults, get, isNil, +} from 'lodash-es'; +import XRegExp from 'xregexp'; + +import { debug } from '~/helpers/log.js'; +import { ParameterError } from '~/helpers/parameterError.js'; + +declare global { + interface RegExpExecArray extends Array { + [x: string]: any; + index: number; + input: string; + } +} + +export class Expects { + originalText = ''; + text = ''; + match: any[] = []; + toExec: {fnc: string; opts: any}[] = []; + isExecuted = false; + + constructor (text?: string) { + if (text) { + this.originalText = text; + this.text = text; + } else { + this.originalText = ''; + this.text = ''; + } + this.match = []; + } + + exec() { + for (const ex of this.toExec) { + (this as any)[ex.fnc]({ ...ex.opts, exec: true }); + } + this.isExecuted = true; + return this; + } + + checkText (opts?: any) { + opts = opts || {}; + if (isNil(this.text)) { + throw new ParameterError('Text cannot be null'); + } + if (this.text.trim().length === 0) { + if (opts.expects) { + if (opts.name) { + throw new ParameterError('Expected parameter <' + get(opts, 'name', '') + ':' + opts.expects + '> at position ' + this.match.length); + } else { + throw new ParameterError('Expected parameter <' + opts.expects + '> at position ' + this.match.length); + } + } else { + // generate expected parameters + const expectedParameters: string[] = []; + for (const param of this.toExec) { + switch(param.fnc) { + case 'command': + expectedParameters.push( + (param.opts.optional ? '[' : '') + + (param.opts.spaces ? '!some command' : '!command') + + (param.opts.optional ? ']' : ''), + ); + break; + case 'points': + expectedParameters.push( + (param.opts.optional ? '[' : '') + + `' + + (param.opts.optional ? ']' : ''), + ); + break; + case 'switch': + expectedParameters.push( + (param.opts.optional ? '[' : '') + + `-${param.opts.name} (${param.opts.values.join(', ')})` + + (param.opts.optional ? ']' : ''), + ); + break; + case 'toggler': + expectedParameters.push( + `[-${param.opts.name}]`, + ); + break; + case 'number': + expectedParameters.push( + (param.opts.optional ? '[' : '') + + `` + + (param.opts.optional ? ']' : ''), + ); + break; + case 'string': + expectedParameters.push( + (param.opts.optional ? '[' : '') + + `` + + (param.opts.optional ? ']' : ''), + ); + break; + case 'username': + expectedParameters.push( + (param.opts.optional ? '[' : '') + + `` + + (param.opts.optional ? ']' : ''), + ); + break; + case 'argument': + expectedParameters.push( + (param.opts.optional ? '[' : '') + + `-${param.opts.name} ${param.opts.type.name === 'Number' ? '5' : '"Example string"'}` + + (param.opts.optional ? ']' : ''), + ); + break; + case 'permission': + expectedParameters.push( + (param.opts.optional ? '[' : '') + + `-${param.opts.name} b38c5adb-e912-47e3-937a-89fabd12393a` + + (param.opts.optional ? ']' : ''), + ); + break; + case 'list': + expectedParameters.push( + `Value1 ${param.opts.delimiter} Another value ${param.opts.delimiter} ...`, + ); + break; + } + } + throw new ParameterError(expectedParameters.join(' ')); + } + } + this.text = this.text.replace(/\s\s+/g, ' ').trim(); + } + + toArray () { + if (!this.isExecuted) { + this.exec(); + } + return this.match; + } + + command (opts?: any) { + opts = opts || {}; + defaults(opts, { exec: false, optional: false }); + if (!opts.exec) { + this.toExec.push({ fnc: 'command', opts }); + return this; + } + if (!opts.optional) { + this.checkText(); + } + + const exclamationMark = opts.canBeWithoutExclamationMark ? '!?' : '!'; + const subCommandRegexp = `(^['"]${exclamationMark}[\\pL0-9 ]*['"])`; + const commandRegexp = `(^${exclamationMark}[\\pL0-9]*)`; + const regexp = XRegExp( + `(? ${[subCommandRegexp, commandRegexp].join('|')})` , 'ix'); + const match = XRegExp.exec(this.text, regexp); + debug('expects.command', JSON.stringify({ + text: this.text, opts, match, + })); + if (match && match.groups) { + this.match.push(match.groups.command.trim().toLowerCase().replace(/['"]/g, '')); + this.text = this.text.replace(match.groups.command, ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError('Command not found'); + } else { + this.match.push(null); + } + } + return this; + } + + points (opts?: { exec?: boolean, optional?: boolean, all?: boolean, negative?: boolean }) { + opts = opts || {}; + defaults(opts, { + exec: false, optional: false, all: false, negative: false, + }); + if (!opts.exec) { + this.toExec.push({ fnc: 'points', opts }); + return this; + } + if (!opts.optional) { + this.checkText(); + } + + let regexp; + if (opts.all) { + regexp = XRegExp('(? all|-?[0-9]+ )', 'ix'); + } else { + regexp = XRegExp('(? -?[0-9]+ )', 'ix'); + } + const match = XRegExp.exec(this.text, regexp); + if (match && match.groups) { + if (match.groups.points === 'all') { + this.match.push(match.groups.points); + } else { + const points = Number(match.groups.points); + this.match.push( + points <= Number.MAX_SAFE_INTEGER + ? (opts.negative ? points : Math.abs(points)) + : Number.MAX_SAFE_INTEGER); // return only max safe + } + this.text = this.text.replace(match.groups.points, ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError('Points not found'); + } else { + this.match.push(null); + } + } + return this; + } + + number (opts?: any) { + opts = opts || {}; + defaults(opts, { + exec: false, optional: false, minus: true, + }); + if (!opts.exec) { + this.toExec.push({ fnc: 'number', opts }); + return this; + } + if (!opts.optional) { + this.checkText({ + expects: 'number', + ...opts, + }); + } + + const regexp = opts.minus ? XRegExp('(? -?[0-9]+ )', 'ix') : XRegExp('(? [0-9]+ )', 'ix'); + const match = XRegExp.exec(this.text, regexp); + + if (match && match.groups) { + this.match.push(Number(match.groups.number)); + this.text = this.text.replace(match.groups.number, ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError('Number not found'); + } else { + this.match.push(null); + } + } + return this; + } + + switch (opts?: any) { + opts = opts || {}; + defaults(opts, { + exec: false, optional: false, default: null, + }); + if (!opts.exec) { + this.toExec.push({ fnc: 'switch', opts }); + return this; + } + + if (isNil(opts.name)) { + throw new ParameterError('Argument name must be defined'); + } + if (isNil(opts.values)) { + throw new ParameterError('Values must be defined'); + } + if (!opts.optional) { + this.checkText(); + } + + const pattern = opts.values.join('|'); + + const regexp = XRegExp(`-(?<${opts.name}>${pattern})`, 'ix'); + const match = XRegExp.exec(this.text, regexp); + if (match && match.groups && match.groups[opts.name].trim().length !== 0) { + this.match.push(match.groups[opts.name]); + this.text = this.text.replace(match[0], ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError('Argument not found'); + } else { + this.match.push(opts.default); + } + } + return this; + } + + /* Toggler is used for toggle true/false with argument + * !command -c => -c is true + * !command => -c is false + */ + toggler (opts?: any) { + opts = opts || {}; + + if (!opts.exec) { + this.toExec.push({ fnc: 'toggler', opts }); + return this; + } + + if (isNil(opts.name)) { + throw new ParameterError('Toggler name must be defined'); + } + + const regexp = XRegExp(`-${opts.name}\\b`, 'ix'); + const match = XRegExp.exec(this.text, regexp); + if (match) { + this.match.push(true); + this.text = this.text.replace(match[0], ''); // remove from text matched pattern + } else { + this.match.push(false); + } + return this; + } + + permission(opts?: { + exec?: boolean; optional?: boolean; default?: null | string; name?: string + }) { + opts = { + exec: false, + optional: false, + default: null, + name: 'p', // default use -p + ...opts, + }; + if (!opts.exec) { + this.toExec.push({ fnc: 'permission', opts }); + return this; + } + if (isNil(opts.name)) { + throw new ParameterError('Permission name must be defined'); + } + if (opts.optional && opts.default === null) { + throw new ParameterError('Permission cannot be optional without default value'); + } + + const pattern = `([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})|(?:(?!-[a-zA-Z]).)+`; // capture until -something or [^-]* + const fullPattern = `-${opts.name}\\s(?<${opts.name}>${pattern})`; + const regexp = XRegExp(fullPattern, 'ix'); + const match = XRegExp.exec(this.text, regexp); + + debug('expects.permission', JSON.stringify({ + fullPattern, text: this.text, opts, match, + })); + if (match && match.groups && match.groups[opts.name].trim().length !== 0) { + this.match.push(String(match.groups[opts.name].trim())); + this.text = this.text.replace(match[0], ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError(`Permission ${opts.name} not found`); + } else { + this.match.push(opts.default); + } + } + return this; + } + + argument (opts?: any) { + opts = opts || {}; + defaults(opts, { + exec: false, + type: String, + optional: false, + default: null, + multi: false, + delimiter: '"', + }); + if (!opts.multi) { + opts.delimiter = ''; + } + opts.delimiter = XRegExp.escape(opts.delimiter); + + if (!opts.exec) { + this.toExec.push({ fnc: 'argument', opts }); + return this; + } + + if (isNil(opts.name)) { + throw new ParameterError('Argument name must be defined'); + } + if (!opts.optional) { + this.checkText(); + } + + let pattern; + if (opts.type === 'uuid') { + pattern = '[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}'; + } else if (opts.type.name === 'Number') { + pattern = '[0-9]*'; + } else if (opts.type.name === 'Boolean') { + pattern = 'true|false'; + } else if (opts.type === 'username') { + pattern = '@?[A-Za-z0-9_]+'; + } else if (!opts.multi) { + pattern = '\\S+'; + } else { + pattern = `(?:(?!-[a-zA-Z]).)+${opts.delimiter !== '' ? '?' : ''}`; + } // capture until -something or [^-]* + + const fullPattern = `-${opts.name}\\s${opts.delimiter}(?<${opts.name}>${pattern})${opts.delimiter}`; + const regexp = XRegExp(fullPattern, 'ix'); + const match = XRegExp.exec(this.text, regexp); + + debug('expects.argument', JSON.stringify({ + fullPattern, text: this.text, opts, match, + })); + if (match && match.groups && match.groups[opts.name].trim().length !== 0) { + if (opts.type.name === 'Boolean') { + this.match.push(opts.type(match.groups[opts.name].trim().toLowerCase() === 'true')); + } else if (['uuid', 'username'].includes(opts.type)) { + this.match.push(match.groups[opts.name].trim()); + } else { + this.match.push(opts.type(match.groups[opts.name].trim())); + } + this.text = this.text.replace(match[0], ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError(`Argument ${opts.name} not found`); + } else { + this.match.push(opts.default); + } + } + return this; + } + + username (opts?: any) { + opts = opts || {}; + defaults(opts, { + exec: false, optional: false, default: null, + }); + if (!opts.exec) { + this.toExec.push({ fnc: 'username', opts }); + return this; + } + if (!opts.optional) { + this.checkText(); + } + + const regexp = XRegExp(`@?(?[A-Za-z0-9_]+)`, 'ix'); + const match = XRegExp.exec(`${this.text}`, regexp); + if (match && match.groups) { + this.match.push(match.groups.username.toLowerCase()); + this.text = this.text.replace(match.groups.username, ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError('Username not found'); + } else { + this.match.push(opts.default); + } + } + return this; + } + + everything (opts?: any) { + opts = opts || {}; + defaults(opts, { exec: false, optional: false }); + if (!opts.exec) { + this.toExec.push({ fnc: 'everything', opts }); + return this; + } + if (!opts.optional) { + this.checkText({ + expects: opts.name ?? 'any', + ...opts, + }); + } + + const regexp = XRegExp(`(? .* )`, 'ix'); + const match = XRegExp.exec(` ${this.text} `, regexp); + if (match && match.groups) { + this.match.push(match.groups.everything.substring(1, match.groups.everything.length - 1).trim()); + this.text = this.text.replace(match.groups.everything, ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError('There is no text found.'); + } else { + this.match.push(null); + } + } + return this; + } + + duration ({ optional = false, exec = false }: { optional?: boolean, exec?: boolean }) { + if (!optional) { + this.checkText({ + expects: 'duration', + optional, + }); + } + if (!exec) { + this.toExec.push({ fnc: 'duration', opts: { optional } }); + return this; + } + + const regexp = XRegExp(`(? (\\d+(s|m|h|d)) )`, 'igx'); + const match = XRegExp.exec(`${this.text.trim()}`, regexp); + if (match && match.groups) { + let value = 0; + if (match.groups.duration.includes('s')) { + value = Number(match.groups.duration.replace('s', '')) * SECOND; + } else if (match.groups.duration.includes('m')) { + value = Number(match.groups.duration.replace('m', '')) * MINUTE; + } else if (match.groups.duration.includes('h')) { + value = Number(match.groups.duration.replace('h', '')) * HOUR; + } else if (match.groups.duration.includes('d')) { + value = Number(match.groups.duration.replace('d', '')) * DAY; + } + this.match.push(value); + this.text = this.text.replace(String(value), ''); // remove from text matched pattern + } else { + if (!optional) { + throw new ParameterError('Duration not found'); + } else { + this.match.push(null); + } + } + return this; + } + + oneOf ({ optional = false, values, exec = false, name }: { exec?: boolean, name?: string, optional?: boolean, values: string[] | Readonly }) { + if (!optional) { + this.checkText({ + expects: 'oneOf', + optional, + values, + }); + } + if (!exec) { + this.toExec.push({ + fnc: 'oneOf', opts: { + optional, values, name, + }, + }); + return this; + } + + const regexp = XRegExp(`(? ${values.join('|')} )`, 'igx'); + const match = XRegExp.exec(`${this.text.trim()}`, regexp); + if (match && match.groups) { + this.match.push(match.groups.oneOf.trim()); + this.text = this.text.replace(match.groups.oneOf, ''); // remove from text matched pattern + } else { + if (!optional) { + throw new ParameterError('OneOf not found'); + } else { + this.match.push(null); + } + } + return this; + } + + string (opts?: any) { + opts = opts || {}; + defaults(opts, { + exec: false, optional: false, additionalChars: '', withSpaces: false, + }); + if (!opts.exec) { + this.toExec.push({ fnc: 'string', opts }); + return this; + } + if (!opts.optional) { + this.checkText({ + expects: 'string', + ...opts, + }); + } + + const regexp = opts.withSpaces + ? XRegExp(`(?('[\\S${opts.additionalChars} ]+')|([\\S${opts.additionalChars}]+))`, 'igx') + : XRegExp(`(?[\\S${opts.additionalChars}]*)`, 'igx'); + + const match = XRegExp.exec(`${this.text.trim()}`, regexp); + if (match && match.groups) { + this.match.push(match.groups.string.trim()); + this.text = this.text.replace(match.groups.string, ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError('String not found'); + } else { + this.match.push(null); + } + } + return this; + } + + list (opts?: any) { + defaults(opts, { + exec: false, optional: false, delimiter: ' ', + }); + if (!opts.exec) { + this.toExec.push({ fnc: 'list', opts }); + return this; + } + this.checkText(); + + const regexp = XRegExp('(? .*)', 'ix'); + const match = XRegExp.exec(this.text, regexp); + + if (match && match.groups) { + this.match.push(match.groups.list.split(opts.delimiter).map((o) => o.trim())); + this.text = this.text.replace(match.groups.list, ''); // remove from text matched pattern + } else { + if (!opts.optional) { + throw new ParameterError('List not found'); + } else { + this.match.push([]); + } + } + return this; + } +} \ No newline at end of file diff --git a/backend/src/filters/command.ts b/backend/src/filters/command.ts new file mode 100644 index 000000000..1c6cfaf90 --- /dev/null +++ b/backend/src/filters/command.ts @@ -0,0 +1,99 @@ +import { parserReply } from '../commons.js'; +import { Parser } from '../parser.js'; +import alias from '../systems/alias.js'; +import customcommands from '../systems/customcommands.js'; + +import type { ResponseFilter } from './index.js'; + +import { getCountOfCommandUsage } from '~/helpers/commands/count.js'; +import { debug, error } from '~/helpers/log.js'; + +const command: ResponseFilter = { + '$count(\'#\')': async function (filter: string) { + const countRegex = new RegExp('\\$count\\(\\\'(?\\!\\S*)\\\'\\)', 'gm'); + const match = countRegex.exec(filter); + if (match && match.groups) { + return String(await getCountOfCommandUsage(match.groups.command)); + } + return '0'; + }, + '$count': async function (_variable, attr) { + if (attr.command) { + return String((await getCountOfCommandUsage(attr.command))); + } + return '0'; + }, + '(!!#)': async function (filter: string, attr) { + const cmd = filter + .replace('!', '') // replace first ! + .replace(/\(|\)/g, '') + .replace(/\$param/g, attr.param ?? ''); + debug('message.process', cmd); + + // check if we already checked cmd + if (!attr.processedCommands) { + attr.processedCommands = []; + } + if (attr.processedCommands.includes(cmd)) { + error(`Response ${filter} seems to be in loop! ${attr.processedCommands.join('->')}->${attr.command}`); + debug('message.error', `Response ${filter} seems to be in loop! ${attr.processedCommands.join('->')}->${attr.command}`); + return ''; + } else { + attr.processedCommands.push(attr.command); + } + + // run custom commands + if (customcommands.enabled) { + await customcommands.run({ + sender: (attr.sender as ParserOptions['sender']), id: 'null', skip: false, message: cmd, parameters: attr.param ?? '', processedCommands: attr.processedCommands, parser: new Parser(), isAction: false, isHighlight: false, emotesOffsets: new Map(), discord: undefined, isParserOptions: true, isFirstTimeMessage: false, + }); + } + // run alias + if (alias.enabled) { + await alias.run({ + sender: (attr.sender as ParserOptions['sender']), id: 'null', skip: false, message: cmd, parameters: attr.param ?? '', parser: new Parser(), isAction: false, isHighlight: false, emotesOffsets: new Map(), discord: undefined, isParserOptions: true, isFirstTimeMessage: false, + }); + } + await new Parser().command(attr.sender, cmd, true); + // we are not sending back any responses! + return ''; + }, + '(!#)': async (filter: string, attr) => { + const cmd = filter + .replace(/\(|\)/g, '') + .replace(/\$param/g, attr.param ?? ''); + debug('message.process', cmd); + + // check if we already checked cmd + if (!attr.processedCommands) { + attr.processedCommands = []; + } + if (attr.processedCommands.includes(cmd)) { + error(`Response ${filter} seems to be in loop! ${attr.processedCommands.join('->')}->${attr.command}`); + debug('message.error', `Response ${filter} seems to be in loop! ${attr.processedCommands.join('->')}->${attr.command}`); + return ''; + } else { + attr.processedCommands.push(attr.command); + } + + // run custom commands + if (customcommands.enabled) { + await customcommands.run({ + sender: (attr.sender as ParserOptions['sender']), id: 'null', skip: false, message: cmd, parameters: attr.param ?? '', processedCommands: attr.processedCommands, parser: new Parser(), isAction: false, isHighlight: false, emotesOffsets: new Map(), discord: undefined, isParserOptions: true, isFirstTimeMessage: false, + }); + } + // run alias + if (alias.enabled) { + await alias.run({ + sender: (attr.sender as ParserOptions['sender']), id: 'null', skip: false, message: cmd, parameters: attr.param ?? '', parser: new Parser(), isAction: false, isHighlight: false,emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, isParserOptions: true, + }); + } + const responses = await new Parser().command(attr.sender, cmd, true); + for (let i = 0; i < responses.length; i++) { + await parserReply(responses[i].response, { sender: responses[i].sender, discord: responses[i].discord, attr: responses[i].attr, id: '' }); + } + return ''; + }, +}; + +export { command }; \ No newline at end of file diff --git a/backend/src/filters/count.ts b/backend/src/filters/count.ts new file mode 100644 index 000000000..fa5d40193 --- /dev/null +++ b/backend/src/filters/count.ts @@ -0,0 +1,48 @@ +import { EventList } from '@entity/eventList.js'; +import { UserBit, UserTip } from '@entity/user.js'; +import { DAY, HOUR } from '@sogebot/ui-helpers/constants.js'; +import { AppDataSource } from '~/database.js'; +import { In, MoreThanOrEqual } from 'typeorm'; + +import type { ResponseFilter } from './index.js'; + +const time: Record = { + 'hour': HOUR, + 'day': DAY, + 'week': 7 * DAY, + 'month': 31 * DAY, + 'year': 365 * DAY, +}; + +const count: ResponseFilter = { + '(count|subs|#)': async function (filter) { + const timestamp: number = time[filter.replace('(count|subs|', '').slice(0, -1).toLowerCase()] ?? DAY; + return await AppDataSource.getRepository(EventList).countBy({ + timestamp: MoreThanOrEqual(Date.now() - timestamp), + event: In(['sub', 'resub']), + }); + }, + '(count|follows|#)': async function (filter) { + const timestamp: number = time[filter.replace('(count|follows|', '').slice(0, -1).toLowerCase()] ?? DAY; + return await AppDataSource.getRepository(EventList).countBy({ + timestamp: MoreThanOrEqual(Date.now() - timestamp), + event: 'follow', + }); + }, + '(count|bits|#)': async function (filter) { + const timestamp: number = time[filter.replace('(count|bits|', '').slice(0, -1).toLowerCase()] ?? DAY; + const events = await AppDataSource.getRepository(UserBit).findBy({ cheeredAt: MoreThanOrEqual(Date.now() - timestamp) }); + return events.reduce((prev, cur) => { + return prev += cur.amount; + }, 0); + }, + '(count|tips|#)': async function (filter) { + const timestamp: number = time[filter.replace('(count|tips|', '').slice(0, -1).toLowerCase()] ?? DAY; + const events = await AppDataSource.getRepository(UserTip).findBy({ tippedAt: MoreThanOrEqual(Date.now() - timestamp) }); + return events.reduce((prev, cur) => { + return prev += cur.sortAmount; + }, 0); + }, +}; + +export { count }; \ No newline at end of file diff --git a/backend/src/filters/custom.ts b/backend/src/filters/custom.ts new file mode 100644 index 000000000..74cf187c0 --- /dev/null +++ b/backend/src/filters/custom.ts @@ -0,0 +1,53 @@ +import { get } from 'lodash-es'; + +import { parserReply } from '../commons.js'; + +import type { ResponseFilter } from './index.js'; + +import { prepare } from '~/helpers/commons/index.js'; +import { getValueOf, setValueOf } from '~/helpers/customvariables/index.js'; + +const custom: ResponseFilter = { + '$_#': async (variable, attr) => { + if (typeof attr.param !== 'undefined' && attr.param.length !== 0) { + const state = await setValueOf(variable, attr.param, { sender: { username: attr.sender.userName, userId: attr.sender.userId, source: typeof attr.discord === 'undefined' ? 'twitch' : 'discord' } }); + if (state.updated.responseType === 0) { + // default + if (state.isOk && !state.isEval) { + const msg = prepare('filters.setVariable', { value: state.setValue, variable: variable }); + parserReply(msg, { sender: attr.sender, discord: attr.discord, attr: { skip: true, quiet: get(attr, 'quiet', false) }, id: '' }); + } + return state.updated.currentValue; + } else if (state.updated.responseType === 1) { + // custom + if (state.updated.responseText) { + parserReply(state.updated.responseText.replace('$value', state.setValue), { sender: attr.sender, discord: attr.discord, attr: { skip: true, quiet: get(attr, 'quiet', false) }, id: '' }); + } + return ''; + } else { + // command + return state.isOk && !state.isEval ? state.setValue : state.updated.currentValue; + } + } + return getValueOf(variable, { sender: { username: attr.sender.userName, userId: attr.sender.userId, source: typeof attr.discord === 'undefined' ? 'twitch' : 'discord' } }); + }, + // force quiet variable set + '$!_#': async (variable, attr) => { + variable = variable.replace('$!_', '$_'); + if (typeof attr.param !== 'undefined' && attr.param.length !== 0) { + const state = await setValueOf(variable, attr.param, { sender: { username: attr.sender.userName, userId: attr.sender.userId, source: typeof attr.discord === 'undefined' ? 'twitch' : 'discord' } }); + return state.updated.currentValue; + } + return getValueOf(variable, { sender: { username: attr.sender.userName, userId: attr.sender.userId, source: typeof attr.discord === 'undefined' ? 'twitch' : 'discord' } }); + }, + // force full quiet variable + '$!!_#': async (variable, attr) => { + variable = variable.replace('$!!_', '$_'); + if (typeof attr.param !== 'undefined' && attr.param.length !== 0) { + await setValueOf(variable, attr.param, { sender: { username: attr.sender.userName, userId: attr.sender.userId, source: typeof attr.discord === 'undefined' ? 'twitch' : 'discord' } }); + } + return ''; + }, +}; + +export { custom }; \ No newline at end of file diff --git a/backend/src/filters/evaluate.ts b/backend/src/filters/evaluate.ts new file mode 100644 index 000000000..f78dec6b1 --- /dev/null +++ b/backend/src/filters/evaluate.ts @@ -0,0 +1,13 @@ +import { runScript } from '../helpers/customvariables/runScript.js'; + +import type { ResponseFilter } from './index.js'; + +const evaluate: ResponseFilter = { + '(eval#)': async function (filter, attr) { + const toEvaluate = filter.replace('(eval ', '').slice(0, -1); + + return await runScript(toEvaluate, { sender: attr.sender.userName, isUI: false, _current: undefined }); + }, +}; + +export { evaluate }; \ No newline at end of file diff --git a/backend/src/filters/ifp.ts b/backend/src/filters/ifp.ts new file mode 100644 index 000000000..ad30cd802 --- /dev/null +++ b/backend/src/filters/ifp.ts @@ -0,0 +1,32 @@ +import { isNil } from 'lodash-es'; +import { VM } from 'vm2'; + +import type { ResponseFilter } from './index.js'; + +const vm = new VM(); + +const ifp: ResponseFilter = { + '(if#)': async function (filter: string, attr) { + // (if $days>2|More than 2 days|Less than 2 days) + try { + const toEvaluate = filter + .replace('(if ', '') + .slice(0, -1) + .replace(/\$param|\$!param/g, attr.param ?? ''); // replace params + let [check, ifTrue, ifFalse] = toEvaluate.split('|'); + check = check.startsWith('>') || check.startsWith('<') || check.startsWith('=') ? 'false' : check; // force check to false if starts with comparation + if (isNil(ifTrue)) { + return; + } + + if (vm.run(check)) { + return ifTrue; + } + return isNil(ifFalse) ? '' : ifFalse; + } catch (e: any) { + return ''; + } + }, +}; + +export{ ifp }; \ No newline at end of file diff --git a/backend/src/filters/index.ts b/backend/src/filters/index.ts new file mode 100644 index 000000000..f83aa87f9 --- /dev/null +++ b/backend/src/filters/index.ts @@ -0,0 +1,31 @@ +export * from './command.js'; +export * from './count.js'; +export * from './custom.js'; +export * from './evaluate.js'; +export * from './ifp.js'; +export * from './info.js'; +export * from './list.js'; +export * from './math.js'; +export * from './online.js'; +export * from './param.js'; +export * from './price.js'; +export * from './qs.js'; +export * from './random.js'; +export * from './stream.js'; +export * from './youtube.js'; +export * from './operation.js'; + +declare function filter ( + message: string, + attr: { + [name: string]: any, + param?: string, + sender: CommandOptions['sender'], + 'message-type'?: string, + forceWithoutAt?: boolean + } +): Promise; + +export type ResponseFilter = { + [x: string]: typeof filter +}; \ No newline at end of file diff --git a/backend/src/filters/info.ts b/backend/src/filters/info.ts new file mode 100644 index 000000000..b5991f93e --- /dev/null +++ b/backend/src/filters/info.ts @@ -0,0 +1,82 @@ +import { EventList } from '@entity/eventList.js'; + +import type { ResponseFilter } from './index.js'; + +import { AppDataSource } from '~/database.js'; +import { + isStreamOnline, stats, streamStatusChangeSince, +} from '~/helpers/api/index.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import getNameById from '~/helpers/user/getNameById.js'; + +async function toptipFilter(type: 'overall' | 'stream', value: 'username' | 'amount' | 'message' | 'currency'): Promise { + let tips = (await AppDataSource.getRepository(EventList).createQueryBuilder('events') + .select('events') + .orderBy('events.timestamp', 'DESC') + .where('events.event >= :event', { event: 'tip' }) + .andWhere('NOT events.isTest') + .andWhere('NOT events.isHidden') + .getMany()) + .sort((a, b) => { + const aValue = JSON.parse(a.values_json); + const bValue = JSON.parse(b.values_json); + const aTip = exchange(aValue.amount, aValue.currency, mainCurrency.value); + const bTip = exchange(bValue.amount, bValue.currency, mainCurrency.value); + return bTip - aTip; + }); + + if (type === 'stream') { + const whenOnline = isStreamOnline.value ? streamStatusChangeSince.value : null; + if (whenOnline) { + tips = tips.filter((o) => o.timestamp >= (new Date(whenOnline)).getTime()); + } else { + return ''; + } + } + + if (tips.length > 0) { + let username = ''; + try { + // first we check if user is even actual user + username = await getNameById(tips[0].userId); + } catch (e) { + // hide tip from unknown user + tips[0].isHidden = true; + await AppDataSource.getRepository(EventList).save(tips[0]); + // re-do again + return toptipFilter(type, value); + } + + switch (value) { + case 'amount': + return Number(JSON.parse(tips[0].values_json).amount).toFixed(2); + case 'currency': + return JSON.parse(tips[0].values_json).currency; + case 'message': + return JSON.parse(tips[0].values_json).message; + case 'username': + return username; + } + } + return ''; +} + +const info: ResponseFilter = { + '$toptip.overall.amount': async () => await toptipFilter('overall', 'amount'), + '$toptip.overall.currency': async () => await toptipFilter('overall', 'currency'), + '$toptip.overall.message': async () => await toptipFilter('overall', 'message'), + '$toptip.overall.username': async () => await toptipFilter('overall', 'username'), + '$toptip.stream.amount': async () => await toptipFilter('stream', 'amount'), + '$toptip.stream.currency': async () => await toptipFilter('stream', 'currency'), + '$toptip.stream.message': async () => await toptipFilter('stream', 'message'), + '$toptip.stream.username': async () => await toptipFilter('stream', 'username'), + '(game)': async function () { + return stats.value.currentGame || 'n/a'; + }, + '(status)': async function () { + return stats.value.currentTitle || 'n/a'; + }, +}; + +export { info }; \ No newline at end of file diff --git a/backend/src/filters/list.ts b/backend/src/filters/list.ts new file mode 100644 index 000000000..15db320e9 --- /dev/null +++ b/backend/src/filters/list.ts @@ -0,0 +1,176 @@ +import { Alias } from '@entity/alias.js'; +import { Commands } from '@entity/commands.js'; +import { Cooldown } from '@entity/cooldown.js'; +import { Price } from '@entity/price.js'; +import { Rank } from '@entity/rank.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import { format } from '@sogebot/ui-helpers/number.js'; +import _ from 'lodash-es'; +import { IsNull } from 'typeorm'; + +import general from '../general.js'; +import { Parser } from '../parser.js'; + +import type { ResponseFilter } from './index.js'; + +import { AppDataSource } from '~/database.js'; +import { enabled } from '~/helpers/interface/enabled.js'; +import { error, warning } from '~/helpers/log.js'; +import { get } from '~/helpers/permissions/get.js'; +import { getCommandPermission } from '~/helpers/permissions/getCommandPermission.js'; +import { getPointsName } from '~/helpers/points/index.js'; +import { translate } from '~/translate.js'; + +const list: ResponseFilter = { + '(list.#)': async function (filter: string) { + const [main, permission] = filter.replace('(list.', '').replace(')', '').split('.'); + let system = main; + let group: null | string | undefined = undefined; + if (main.includes('|')) { + [system, group] = main.split('|'); + if (group.trim().length === 0) { + group = null; + } + } + let [alias, commands, cooldowns, ranks, prices] = await Promise.all([ + AppDataSource.getRepository(Alias).find({ + where: typeof group !== 'undefined' ? { + visible: true, enabled: true, group: group ?? IsNull(), + } : { visible: true, enabled: true }, + }), + AppDataSource.getRepository(Commands).find({ where: typeof group !== 'undefined' ? { + visible: true, enabled: true, group: group ?? IsNull(), + } : { visible: true, enabled: true } }), + AppDataSource.getRepository(Cooldown).find({ where: { isEnabled: true } }), + AppDataSource.getRepository(Rank).find(), + AppDataSource.getRepository(Price).find({ where: { enabled: true } }), + ]); + + let listOutput: any = []; + switch (system) { + case 'alias': + return alias.length === 0 ? ' ' : (alias.map((o: { alias: string; }) => { + const findPrice = prices.find((p: { command: any; }) => p.command === o.alias); + if (findPrice && enabled.status('/systems/price')) { + if (findPrice.price > 0 && findPrice.priceBits === 0) { + return o.alias.replace('!', '') + `(${format(general.numberFormat, 0)(findPrice.price)} ${getPointsName(findPrice.price)})`; + } else if (findPrice.priceBits > 0 && findPrice.price === 0) { + return o.alias.replace('!', '') + `(${format(general.numberFormat, 0)(findPrice.priceBits)} ${getLocalizedName(findPrice.priceBits, translate('core.bits'))})`; + } else { + return o.alias.replace('!', '') + `(${format(general.numberFormat, 0)(findPrice.price)} ${getPointsName(findPrice.price)} or ${findPrice.priceBits} ${getLocalizedName(findPrice.priceBits, translate('core.bits'))})`; + } + } + return o.alias.replace('!', ''); + })).sort().join(', '); + case '!alias': + return alias.length === 0 ? ' ' : (alias.map((o: { alias: string; }) => { + const findPrice = prices.find((p: { command: any; }) => p.command === o.alias); + if (findPrice && enabled.status('/systems/price')) { + if (findPrice.price > 0 && findPrice.priceBits === 0) { + return o.alias + `(${format(general.numberFormat, 0)(findPrice.price)} ${getPointsName(findPrice.price)})`; + } else if (findPrice.priceBits > 0 && findPrice.price === 0) { + return o.alias + `(${format(general.numberFormat, 0)(findPrice.priceBits)} ${getLocalizedName(findPrice.priceBits, translate('core.bits'))})`; + } else { + return o.alias + `(${format(general.numberFormat, 0)(findPrice.price)} ${getPointsName(findPrice.price)} or ${findPrice.priceBits} ${getLocalizedName(findPrice.priceBits, translate('core.bits'))})`; + } + } + return o.alias; + })).sort().join(', '); + case 'core': + case '!core': + if (permission) { + const _permission = await get(permission); + if (_permission) { + const coreCommands = (await Promise.all((await new Parser().getCommandsList()).map(async (item) => { + const customPermission = await getCommandPermission(item.id); + return { ...item, permission: typeof customPermission !== 'undefined' ? customPermission : item.permission }; + }))) + .filter(item => item.permission === _permission.id); + return coreCommands.length === 0 + ? ' ' + : coreCommands.map(item => system === '!core' ? item.command : item.command.replace('!', '')).sort().join(', '); + } else { + error(`Permission for (list.core.${permission}) not found.`); + return ''; + } + } else { + error('Missing permission for (list.core.).'); + return ''; + } + case 'command': + if (permission) { + const responses = commands.map((o: { responses: any; }) => o.responses).flat(); + const _permission = await get(permission); + if (_permission) { + const commandIds = responses.filter((o: { permission: string | undefined; }) => o.permission === _permission.id).map((o: { id: any; }) => o.id); + commands = commands.filter((o: { id: any; }) => commandIds.includes(o.id)); + } else { + commands = []; + } + } + return commands.length === 0 ? ' ' : (commands.map((o: { command: string; }) => { + const findPrice = prices.find((p: { command: any; }) => p.command === o.command); + if (findPrice && enabled.status('/systems/price')) { + if (findPrice.price > 0 && findPrice.priceBits === 0) { + return o.command.replace('!', '') + `(${format(general.numberFormat, 0)(findPrice.price)} ${getPointsName(findPrice.price)})`; + } else if (findPrice.priceBits > 0 && findPrice.price === 0) { + return o.command.replace('!', '') + `(${findPrice.priceBits} ${getLocalizedName(findPrice.priceBits, translate('core.bits'))})`; + } else { + return o.command.replace('!', '') + `(${format(general.numberFormat, 0)(findPrice.price)} ${getPointsName(findPrice.price)} or ${findPrice.priceBits} ${getLocalizedName(findPrice.priceBits, translate('core.bits'))})`; + } + } + return o.command.replace('!', ''); + })).sort().join(', '); + case '!command': + if (permission) { + const responses = commands.map((o: { responses: any; }) => o.responses).flat(); + const _permission = await get(permission); + if (_permission) { + const commandIds = responses.filter((o: { permission: string | undefined; }) => o.permission === _permission.id).map((o: { id: any; }) => o.id); + commands = commands.filter((o: { id: any; }) => commandIds.includes(o.id)); + } else { + commands = []; + } + } + return commands.length === 0 ? ' ' : (commands.map((o: { command: string; }) => { + const findPrice = prices.find((p: { command: any; }) => p.command === o.command); + if (findPrice && enabled.status('/systems/price')) { + if (findPrice.price > 0 && findPrice.priceBits === 0) { + return o.command + `(${format(general.numberFormat, 0)(findPrice.price)} ${getPointsName(findPrice.price)})`; + } else if (findPrice.priceBits > 0 && findPrice.price === 0) { + return o.command + `(${findPrice.priceBits} ${getLocalizedName(findPrice.priceBits, translate('core.bits'))})`; + } else { + return o.command + `(${format(general.numberFormat, 0)(findPrice.price)} ${getPointsName(findPrice.price)} or ${findPrice.priceBits} ${getLocalizedName(findPrice.priceBits, translate('core.bits'))})`; + } + } + return o.command; + })).sort().join(', '); + case 'cooldown': + listOutput = cooldowns.map((o: { miliseconds: any; name: string; }) => { + const time = o.miliseconds; + return o.name + ': ' + (time / 1000) + 's'; + }).sort().join(', '); + return listOutput.length > 0 ? listOutput : ' '; + case 'price': + listOutput = prices.map((o: { command: any; price: any; }) => { + return `${o.command} (${o.price}${getPointsName(o.price)})`; + }).join(', '); + return listOutput.length > 0 ? listOutput : ' '; + case 'ranks': + listOutput = _.orderBy(ranks.filter((o: { type: string; }) => o.type === 'viewer'), 'value', 'asc').map((o) => { + return `${o.rank} (${o.value}h)`; + }).join(', '); + return listOutput.length > 0 ? listOutput : ' '; + case 'ranks.sub': + listOutput = _.orderBy(ranks.filter((o: { type: string; }) => o.type === 'subscriber'), 'value', 'asc').map((o) => { + return `${o.rank} (${o.value} ${getLocalizedName(o.value, translate('core.months'))})`; + }).join(', '); + return listOutput.length > 0 ? listOutput : ' '; + default: + warning('unknown list system ' + system); + return ''; + } + }, +}; + +export { list }; \ No newline at end of file diff --git a/backend/src/filters/math.ts b/backend/src/filters/math.ts new file mode 100644 index 000000000..38970470f --- /dev/null +++ b/backend/src/filters/math.ts @@ -0,0 +1,73 @@ +import { evaluate as mathJsEvaluate } from 'mathjs'; + +import type { ResponseFilter } from './index.js'; + +import { getValueOf } from '~/helpers/customvariables/index.js'; + +const math: ResponseFilter = { + '(math.#)': async function (filter: any) { + let toEvaluate = filter.replace(/\(math./g, '').replace(/\)/g, ''); + + // check if custom variables are here + const regexp = /(\$_\w+)/g; + const match = toEvaluate.match(regexp); + if (match) { + for (const variable of match) { + const currentValue = await getValueOf(variable); + toEvaluate = toEvaluate.replace( + variable, + isNaN(Number(currentValue)) ? 0 : currentValue, + ); + } + } + return mathJsEvaluate(toEvaluate); + }, + '(toPercent|#)': async function (filter: any) { + const _toEvaluate = filter.replace(/\(toPercent\|/g, '').replace(/\)/g, ''); + let [toFixed, toEvaluate] = _toEvaluate.split('|'); + if (!toEvaluate) { + toEvaluate = toFixed; + toFixed = 0; + } + toEvaluate = toEvaluate.replace(`${toFixed}|`, ''); + + // check if custom variables are here + const regexp = /(\$_\w+)/g; + const match = toEvaluate.match(regexp); + if (match) { + for (const variable of match) { + const currentValue = await getValueOf(variable); + toEvaluate = toEvaluate.replace( + variable, + isNaN(Number(currentValue)) ? 0 : currentValue, + ); + } + } + return Number(100*toEvaluate).toFixed(toFixed); + }, + '(toFloat|#)': async function (filter: any) { + const _toEvaluate = filter.replace(/\(toFloat\|/g, '').replace(/\)/g, ''); + let [toFixed, toEvaluate] = _toEvaluate.split('|'); + if (!toEvaluate) { + toEvaluate = toFixed; + toFixed = 0; + } + toEvaluate = toEvaluate.replace(`${toFixed}|`, ''); + + // check if custom variables are here + const regexp = /(\$_\w+)/g; + const match = toEvaluate.match(regexp); + if (match) { + for (const variable of match) { + const currentValue = await getValueOf(variable); + toEvaluate = toEvaluate.replace( + variable, + isNaN(Number(currentValue)) ? 0 : currentValue, + ); + } + } + return Number(toEvaluate).toFixed(toFixed); + }, +}; + +export { math }; \ No newline at end of file diff --git a/backend/src/filters/online.ts b/backend/src/filters/online.ts new file mode 100644 index 000000000..30677f7c9 --- /dev/null +++ b/backend/src/filters/online.ts @@ -0,0 +1,14 @@ +import type { ResponseFilter } from './index.js'; + +import { isStreamOnline } from '~/helpers/api/index.js'; + +const online: ResponseFilter = { + '(onlineonly)': async function () { + return isStreamOnline.value; + }, + '(offlineonly)': async function () { + return !(isStreamOnline.value); + }, +}; + +export { online }; \ No newline at end of file diff --git a/backend/src/filters/operation.ts b/backend/src/filters/operation.ts new file mode 100644 index 000000000..857cf4335 --- /dev/null +++ b/backend/src/filters/operation.ts @@ -0,0 +1,56 @@ +import events from '../events.js'; +import { info } from '../helpers/log.js'; + +import type { ResponseFilter } from './index.js'; + +import { AppDataSource } from '~/database.js'; +import { EmitData } from '~/database/entity/alert.js'; +import { Price } from '~/database/entity/price.js'; +import alerts from '~/registries/alerts.js'; + +const selectedItemRegex = /\$triggerAlert\((?[0-9A-F]{8}(?:-[0-9A-F]{4}){3}-[0-9A-F]{12}),? ?(?.*)?\)/mi; +// expecting 21 symbols for nanoid +const selectedItemRegexNanoId = /\$triggerAlert\((?.{21}),? ?(?.*)?\)/mi; + +export const operation: ResponseFilter = { + '$triggerOperation(#)': async function (filter: string, attributes) { + const countRegex = new RegExp('\\$triggerOperation\\((?\\S*)\\)', 'gm'); + const match = countRegex.exec(filter); + if (match && match.groups) { + info(`Triggering event ${match.groups.id} by command ${attributes.command}`); + await events.fire(match.groups.id, { userId: attributes.sender.userId, username: attributes.sender.userName, isTriggeredByCommand: attributes.command }); + } + return ''; + }, + '$triggerAlert(#)': async function (filter: string, attributes) { + // TODO: selectedItemRegex is deprecated + for (const regex of [selectedItemRegex, selectedItemRegexNanoId]) { + const match = regex.exec(filter); + if (match && match.groups) { + let customOptions: EmitData['customOptions'] = {}; + if (match.groups.options) { + customOptions = JSON.parse(Buffer.from(match.groups.options, 'base64').toString('utf-8')); + info(`Triggering alert ${match.groups.uuid} by command ${attributes.command} with custom options ${JSON.stringify(customOptions)}`); + } else { + info(`Triggering alert ${match.groups.uuid} by command ${attributes.command}`); + } + + const price = await AppDataSource.getRepository(Price).findOneBy({ command: attributes.command, enabled: true }); + + await alerts.trigger({ + amount: price ? price.price : 0, + currency: 'CZK', + event: 'custom', + alertId: match.groups.uuid, + message: attributes.param || '', + monthsName: '', + name: attributes.command, + tier: null, + recipient: attributes.sender.userName, + customOptions, + }); + } + } + return ''; + }, +}; \ No newline at end of file diff --git a/backend/src/filters/param.ts b/backend/src/filters/param.ts new file mode 100644 index 000000000..faf216460 --- /dev/null +++ b/backend/src/filters/param.ts @@ -0,0 +1,32 @@ +import twitch from '../services/twitch.js'; + +import type { ResponseFilter } from './index.js'; + +const param: ResponseFilter = { + '$touser': async function (_variable, attr) { + if (typeof attr.param !== 'undefined') { + attr.param = attr.param.replace('@', ''); + if (attr.param.length > 0) { + if (twitch.showWithAt) { + attr.param = '@' + attr.param; + } + return attr.param; + } + } + return (twitch.showWithAt ? '@' : '') + attr.sender.userName; + }, + '$param': async function (_variable, attr) { + if (typeof attr.param !== 'undefined' && attr.param.length !== 0) { + return attr.param; + } + return ''; + }, + '$!param': async function (_variable, attr) { + if (typeof attr.param !== 'undefined' && attr.param.length !== 0) { + return attr.param; + } + return 'n/a'; + }, +}; + +export { param }; \ No newline at end of file diff --git a/backend/src/filters/price.ts b/backend/src/filters/price.ts new file mode 100644 index 000000000..922e0a37c --- /dev/null +++ b/backend/src/filters/price.ts @@ -0,0 +1,17 @@ +import { Price } from '@entity/price.js'; +import { format } from '@sogebot/ui-helpers/number.js'; + +import type { ResponseFilter } from './index.js'; + +import { AppDataSource } from '~/database.js'; +import { getPointsName } from '~/helpers/points/index.js'; + +const price: ResponseFilter = { + '(price)': async function (_variable, attr) { + const cmd = await AppDataSource.getRepository(Price).findOneBy({ command: attr.cmd, enabled: true }); + const general = (await import('../general.js')).default; + return [format(general.numberFormat, 0)(cmd?.price ?? 0), getPointsName(cmd?.price ?? 0)].join(' '); + }, +}; + +export { price }; \ No newline at end of file diff --git a/backend/src/filters/qs.ts b/backend/src/filters/qs.ts new file mode 100644 index 000000000..d76eac7b4 --- /dev/null +++ b/backend/src/filters/qs.ts @@ -0,0 +1,24 @@ +import querystring from 'querystring'; + +import type { ResponseFilter } from './index.js'; + +const qs: ResponseFilter = { + '$querystring': async function (_variable, attr) { + if (typeof attr.param !== 'undefined' && attr.param.length !== 0) { + return querystring.escape(attr.param); + } + return ''; + }, + '(url|#)': async function (_variable, attr) { + try { + if (!attr.param) { + throw new Error('Missing param.'); + } + return encodeURI(attr.param); + } catch (e: any) { + return ''; + } + }, +}; + +export { qs }; \ No newline at end of file diff --git a/backend/src/filters/random.ts b/backend/src/filters/random.ts new file mode 100644 index 000000000..e89018cc4 --- /dev/null +++ b/backend/src/filters/random.ts @@ -0,0 +1,97 @@ +import { User } from '@entity/user.js'; +import { sample } from '@sogebot/ui-helpers/array.js'; +import { AppDataSource } from '~/database.js'; + +import type { ResponseFilter } from './index.js'; + +import * as changelog from '~/helpers/user/changelog.js'; +import { isIgnored } from '~/helpers/user/isIgnored.js'; +import { variables } from '~/watchers.js'; + +const random: ResponseFilter = { + '(random.online.viewer)': async function () { + await changelog.flush(); + const botUsername = variables.get('services.twitch.botUsername') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const viewers = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .andWhere('user.isOnline = :isOnline', { isOnline: true }) + .cache(true) + .getMany()) + .filter(o => { + return !isIgnored({ userName: o.userName, userId: o.userId }); + }); + if (viewers.length === 0) { + return 'unknown'; + } + return sample(viewers.map(o => o.userName )); + }, + '(random.online.subscriber)': async function () { + await changelog.flush(); + const botUsername = variables.get('services.twitch.botUsername') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const subscribers = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .andWhere('user.isSubscriber = :isSubscriber', { isSubscriber: true }) + .andWhere('user.isOnline = :isOnline', { isOnline: true }) + .cache(true) + .getMany()).filter(o => { + return !isIgnored({ userName: o.userName, userId: o.userId }); + }); + if (subscribers.length === 0) { + return 'unknown'; + } + return sample(subscribers.map(o => o.userName )); + }, + '(random.viewer)': async function () { + await changelog.flush(); + const botUsername = variables.get('services.twitch.botUsername') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const viewers = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .cache(true) + .getMany()).filter(o => { + return !isIgnored({ userName: o.userName, userId: o.userId }); + }); + if (viewers.length === 0) { + return 'unknown'; + } + return sample(viewers.map(o => o.userName )); + }, + '(random.subscriber)': async function () { + await changelog.flush(); + const botUsername = variables.get('services.twitch.botUsername') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const subscribers = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .andWhere('user.isSubscriber = :isSubscriber', { isSubscriber: true }) + .cache(true) + .getMany()).filter(o => { + return !isIgnored({ userName: o.userName, userId: o.userId }); + }); + if (subscribers.length === 0) { + return 'unknown'; + } + return sample(subscribers.map(o => o.userName )); + }, + '(random.number-#-to-#)': async function (filter: string) { + const numbers = filter.replace('(random.number-', '') + .replace(')', '') + .split('-to-'); + + try { + return Math.floor(Number(numbers[0]) + (Math.random() * (Number(numbers[1]) - Number(numbers[0])))); + } catch (e: any) { + return 0; + } + }, + '(random.true-or-false)': async function () { + return Math.random() < 0.5; + }, +}; + +export { random }; \ No newline at end of file diff --git a/backend/src/filters/stream.ts b/backend/src/filters/stream.ts new file mode 100644 index 000000000..b6d792361 --- /dev/null +++ b/backend/src/filters/stream.ts @@ -0,0 +1,115 @@ +import { param } from './param.js'; + +import type { ResponseFilter } from './index.js'; + +import twitch from '~/services/twitch.js'; + +const stream: ResponseFilter = { + '(stream|#|link)': async function (filter, attr) { + let channel = filter.replace('(stream|', '').replace('|link)', ''); + + // handle edge case when channel is parameter in checkFilter + if (channel === '$param' || channel === '$touser') { + channel = await param.$param('', attr); + } + + channel = channel.replace('@', ''); + + if (channel.trim().length === 0) { + return ''; + } + return `twitch.tv/${channel}`; + }, + '(stream|#|game)': async function (filter, attr) { + let channel = filter.replace('(stream|', '').replace('|game)', ''); + + // handle edge case when channel is parameter in checkFilter + if (channel === '$param' || channel === '$touser') { + channel = await param.$param('', attr); + } + + channel = channel.replace('@', ''); + + try { + const getUserByName = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUserByName(channel)); + if (!getUserByName) { + throw new Error(); + } + + const getChannelInfo = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.channels.getChannelInfoById(getUserByName.id)); + if (!getChannelInfo) { + throw new Error(); + } + return `'${getChannelInfo.gameName}'`; + } catch (e) { + return 'n/a'; + } + }, + '(stream|#|title)': async function (filter, attr) { + let channel = filter.replace('(stream|', '').replace('|title)', ''); + + // handle edge case when channel is parameter in checkFilter + if (channel === '$param' || channel === '$touser') { + channel = await param.$param('', attr); + } + + channel = channel.replace('@', ''); + + try { + const getUserByName = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUserByName(channel)); + if (!getUserByName) { + throw new Error(); + } + + const getChannelInfo = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.channels.getChannelInfoById(getUserByName.id)); + if (!getChannelInfo) { + throw new Error(); + } + return `'${getChannelInfo.title}'`; + } catch (e) { + return 'n/a'; + } + }, + '(stream|#|viewers)': async function (filter, attr) { + let channel = filter.replace('(stream|', '').replace('|viewers)', ''); + + // handle edge case when channel is parameter in checkFilter + if (channel === '$param' || channel === '$touser') { + channel = await param.$param('', attr); + } + + channel = channel.replace('@', ''); + + try { + const getStreams = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.streams.getStreams({ userName: channel })); + if (!getStreams) { + throw new Error(); + } + return `${getStreams.data[0].viewers}`; + } catch (e) { + return '0'; + } + }, + '(stream|#|status)': async function (filter, attr) { + let channel = filter.replace('(stream|', '').replace('|status)', ''); + + // handle edge case when channel is parameter in checkFilter + if (channel === '$param' || channel === '$touser') { + channel = await param.$param('', attr); + } + + channel = channel.replace('@', ''); + + try { + const getStreams = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.streams.getStreams({ userName: channel, type: 'live' })); + if (!getStreams || getStreams.data.length === 0) { + throw new Error(); + } + return `live`; + } catch (e) { + return 'offline'; + } + }, +}; + +export { stream }; \ No newline at end of file diff --git a/backend/src/filters/youtube.ts b/backend/src/filters/youtube.ts new file mode 100644 index 000000000..39353417b --- /dev/null +++ b/backend/src/filters/youtube.ts @@ -0,0 +1,54 @@ +import axios from 'axios'; + +import type { ResponseFilter } from './index.js'; + +const youtube: ResponseFilter = { + '$youtube(url, #)': async function (filter: string) { + const channel = filter + .replace('$youtube(url,', '') + .replace(')', '') + .trim(); + try { + const response = await axios.get('https://www.youtube.com/channel/'+channel+'/videos?view=0&sort=dd'); + const match = new RegExp('"videoId":"(.*?)",.*?title":{"runs":\\[{"text":"(.*?)"}]', 'gm').exec(response.data); + if (match) { + return `https://youtu.be/${match[1]}`; + } else { + return 'n/a'; + } + } catch (e: any) { + const response = await axios.get('https://www.youtube.com/user/'+channel+'/videos?view=0&sort=dd'); + const match = new RegExp('"videoId":"(.*?)",.*?title":{"runs":\\[{"text":"(.*?)"}]', 'gm').exec(response.data); + if (match) { + return `https://youtu.be/${match[1]}`; + } else { + return 'n/a'; + } + } + }, + '$youtube(title, #)': async function (filter: string) { + const channel = filter + .replace('$youtube(title,', '') + .replace(')', '') + .trim(); + try { + const response = await axios.get('https://www.youtube.com/channel/'+channel+'/videos?view=0&sort=dd'); + const match = new RegExp('"videoId":"(.*?)",.*?title":{"runs":\\[{"text":"(.*?)"}]', 'gm').exec(response.data); + if (match) { + return match[2]; + } else { + return 'n/a'; + } + } catch (e: any) { + const response = await axios.get('https://www.youtube.com/user/'+channel+'/videos?view=0&sort=dd'); + const match = new RegExp('"videoId":"(.*?)",.*?title":{"runs":\\[{"text":"(.*?)"}]', 'gm').exec(response.data); + if (match) { + return match[2]; + } else { + return 'n/a'; + } + } + }, +}; + +export { youtube }; \ No newline at end of file diff --git a/backend/src/games/_interface.ts b/backend/src/games/_interface.ts new file mode 100644 index 000000000..8838d1428 --- /dev/null +++ b/backend/src/games/_interface.ts @@ -0,0 +1,9 @@ +import Module from '../_interface.js'; + +class Game extends Module { + constructor() { + super('games', false); + } +} + +export default Game; diff --git a/backend/src/games/duel.ts b/backend/src/games/duel.ts new file mode 100644 index 000000000..43604610b --- /dev/null +++ b/backend/src/games/duel.ts @@ -0,0 +1,241 @@ +import { Duel as DuelEntity, DuelInterface } from '@entity/duel.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import { format } from '@sogebot/ui-helpers/number.js'; +import _ from 'lodash-es'; + +import Game from './_interface.js'; +import { onStartup } from '../decorators/on.js'; +import { + command, persistent, settings, +} from '../decorators.js'; +import general from '../general.js'; + +import { AppDataSource } from '~/database.js'; +import { announce, prepare } from '~/helpers/commons/index.js'; +import { isDbConnected } from '~/helpers/database.js'; +import { error } from '~/helpers/log.js'; +import { getPointsName } from '~/helpers/points/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { isBroadcaster } from '~/helpers/user/isBroadcaster.js'; +import { isModerator } from '~/helpers/user/isModerator.js'; +import { translate } from '~/translate.js'; + +const ERROR_NOT_ENOUGH_OPTIONS = '0'; +const ERROR_ZERO_BET = '1'; +const ERROR_NOT_ENOUGH_POINTS = '2'; +const ERROR_MINIMAL_BET = '3'; + +/* + * !duel [points] - start or participate in duel + */ + +class Duel extends Game { + dependsOn = ['systems.points']; + + @persistent() + _timestamp = 0; + _cooldown = Date.now(); + + @settings() + cooldown = 0; + @settings() + duration = 5; + @settings() + minimalBet = 0; + @settings() + bypassCooldownByOwnerAndMods = false; + + @onStartup() + onStartup() { + this.pickDuelWinner(); + } + + async pickDuelWinner () { + clearTimeout(this.timeouts.pickDuelWinner); + + if (!isDbConnected) { + this.timeouts.pickDuelWinner = global.setTimeout(() => this.pickDuelWinner(), 1000); + return; + } + + const [users, timestamp, duelDuration] = await Promise.all([ + AppDataSource.getRepository(DuelEntity).find(), + this._timestamp, + this.duration, + ]); + const total = users.reduce((a, b) => a + b.tickets, 0); + + if (timestamp === 0 || Date.now() - timestamp < 1000 * 60 * duelDuration) { + this.timeouts.pickDuelWinner = global.setTimeout(() => this.pickDuelWinner(), 30000); + return; + } + + if (total === 0 && Date.now() - timestamp >= 1000 * 60 * duelDuration) { + this._timestamp = 0; + return; + } + + let winner = _.random(0, total, false); + let winnerUser: Required | undefined; + for (const user of users) { + winner = winner - user.tickets; + if (winner <= 0) { // winner tickets are <= 0 , we have winner + winnerUser = user; + break; + } + } + + if (winnerUser) { + const probability = winnerUser.tickets / (total / 100); + + const m = prepare(users.length === 1 ? 'gambling.duel.noContestant' : 'gambling.duel.winner', { + pointsName: getPointsName(total), + points: format(general.numberFormat, 0)(total), + probability: _.round(probability, 2), + ticketsName: getPointsName(winnerUser.tickets), + tickets: format(general.numberFormat, 0)(winnerUser.tickets), + winner: winnerUser.username, + }); + announce(m, 'duel'); + + // give user his points + await changelog.flush(); + changelog.increment(winnerUser.id, { points: total }); + + // reset duel + await AppDataSource.getRepository(DuelEntity).clear(); + this._timestamp = 0; + + this.timeouts.pickDuelWinner = global.setTimeout(() => this.pickDuelWinner(), 30000); + } + } + + @command('!duel bank') + async bank (opts: CommandOptions) { + const users = await AppDataSource.getRepository(DuelEntity).find(); + const bank = users.map((o) => o.tickets).reduce((a, b) => a + b, 0); + + return [{ + response: prepare('gambling.duel.bank', { + command: this.getCommand('!duel'), + points: format(general.numberFormat, 0)(bank), + pointsName: getPointsName(bank), + }), + ...opts, + }]; + } + + @command('!duel') + async main (opts: CommandOptions) { + const points = (await import('../systems/points.js')).default; + const responses: CommandResponse[] = []; + let bet; + + try { + const parsed = opts.parameters.trim().match(/^([\d]+|all)$/); + if (_.isNil(parsed)) { + throw Error(ERROR_NOT_ENOUGH_OPTIONS); + } + + const pointsOfUser = await points.getPointsOf(opts.sender.userId); + bet = parsed[1] === 'all' ? pointsOfUser : Number(parsed[1]); + + if (pointsOfUser === 0) { + throw Error(ERROR_ZERO_BET); + } + if (pointsOfUser < bet) { + throw Error(ERROR_NOT_ENOUGH_POINTS); + } + if (bet < (this.minimalBet)) { + throw Error(ERROR_MINIMAL_BET); + } + + // check if user is already in duel and add points + const userFromDB = await AppDataSource.getRepository(DuelEntity).findOneBy({ id: opts.sender.userId }); + const isNewDuelist = !userFromDB; + if (userFromDB) { + await AppDataSource.getRepository(DuelEntity).save({ ...userFromDB, tickets: Number(userFromDB.tickets) + Number(bet) }); + await points.decrement({ userId: opts.sender.userId }, bet); + } else { + // check if under gambling cooldown + const cooldown = this.cooldown; + const isMod = isModerator(opts.sender); + if (Date.now() - new Date(this._cooldown).getTime() > cooldown * 1000 + || (this.bypassCooldownByOwnerAndMods && (isMod || isBroadcaster(opts.sender)))) { + // save new cooldown if not bypassed + if (!(this.bypassCooldownByOwnerAndMods && (isMod || isBroadcaster(opts.sender)))) { + this._cooldown = Date.now(); + } + await AppDataSource.getRepository(DuelEntity).save({ + id: opts.sender.userId, + username: opts.sender.userName, + tickets: Number(bet), + }); + await points.decrement({ userId: opts.sender.userId }, bet); + } else { + const response = prepare('gambling.fightme.cooldown', { + minutesName: getLocalizedName(Math.round(((cooldown * 1000) - (Date.now() - new Date(this._cooldown).getTime())) / 1000 / 60), translate('core.minutes')), + cooldown: Math.round(((cooldown * 1000) - (Date.now() - new Date(this._cooldown).getTime())) / 1000 / 60), + command: opts.command, + }); + return [{ response, ...opts }]; + } + } + + // if new duel, we want to save timestamp + const isNewDuel = (this._timestamp) === 0; + if (isNewDuel) { + this._timestamp = Number(new Date()); + const response = prepare('gambling.duel.new', { + sender: opts.sender, + minutesName: getLocalizedName(5, translate('core.minutes')), + minutes: this.duration, + command: opts.command, + }); + // if we have discord, we want to send notice on twitch channel as well + announce(response, 'duel'); + } + + const tickets = (await AppDataSource.getRepository(DuelEntity).findOneBy({ id: opts.sender.userId }))?.tickets ?? 0; + const response = prepare(isNewDuelist ? 'gambling.duel.joined' : 'gambling.duel.added', { + pointsName: getPointsName(tickets), + points: format(general.numberFormat, 0)(tickets), + }); + responses.push({ response, ...opts }); + } catch (e: any) { + switch (e.message) { + case ERROR_NOT_ENOUGH_OPTIONS: + responses.push({ response: translate('gambling.duel.notEnoughOptions'), ...opts }); + break; + case ERROR_ZERO_BET: + responses.push({ response: prepare('gambling.duel.zeroBet', { pointsName: getPointsName(0) }), ...opts }); + break; + case ERROR_NOT_ENOUGH_POINTS: + responses.push({ + response: prepare('gambling.duel.notEnoughPoints', { + pointsName: getPointsName(bet ?? 0), + points: format(general.numberFormat, 0)(bet ?? 0), + }), ...opts, + }); + break; + case ERROR_MINIMAL_BET: + bet = this.minimalBet; + responses.push({ + response: prepare('gambling.duel.lowerThanMinimalBet', { + pointsName: getPointsName(bet), + points: format(general.numberFormat, 0)(bet), + command: opts.command, + }), ...opts, + }); + break; + /* istanbul ignore next */ + default: + error(e.stack); + responses.push({ response: translate('core.error'), ...opts }); + } + } + return responses; + } +} + +export default new Duel(); diff --git a/backend/src/games/gamble.ts b/backend/src/games/gamble.ts new file mode 100644 index 000000000..9aa0474a8 --- /dev/null +++ b/backend/src/games/gamble.ts @@ -0,0 +1,188 @@ +import { format } from '@sogebot/ui-helpers/number.js'; +import { + get, isNil, random, set, +} from 'lodash-es'; + +import Game from './_interface.js'; +import { + command, permission_settings, persistent, settings, +} from '../decorators.js'; +import general from '../general.js'; +import users from '../users.js'; + +import { prepare } from '~/helpers/commons/index.js'; +import { error } from '~/helpers/log.js'; +import { getUserHighestPermission } from '~/helpers/permissions/getUserHighestPermission.js'; +import { getPointsName } from '~/helpers/points/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { translate } from '~/translate.js'; + +const ERROR_NOT_ENOUGH_OPTIONS = '0'; +const ERROR_ZERO_BET = '1'; +const ERROR_NOT_ENOUGH_POINTS = '2'; + +class MinimalBetError extends Error { + constructor(message: string) { + super(message); + Error.captureStackTrace(this, MinimalBetError); + this.name = 'MinimalBetError'; + } +} + +/* + * !gamble [amount] - gamble [amount] points with `chanceToWin` chance + */ + +class Gamble extends Game { + dependsOn = [ 'systems.points' ]; + + @permission_settings('settings') + minimalBet = 0; + @permission_settings('settings') + chanceToWin = 50; + @permission_settings('settings') + chanceToTriggerJackpot = 5; + + @settings() + enableJackpot = false; + @settings() + maxJackpotValue = 10000; + @settings() + lostPointsAddedToJackpot = 20; + @persistent() + jackpotValue = 0; + + @command('!gamble') + async main (opts: CommandOptions): Promise { + const pointsSystem = (await import('../systems/points.js')).default; + + let points, message; + + try { + const parsed = opts.parameters.trim().match(/^([\d]+|all)$/); + if (isNil(parsed)) { + throw Error(ERROR_NOT_ENOUGH_OPTIONS); + } + + const permId = await getUserHighestPermission(opts.sender.userId); + const pointsOfUser = await pointsSystem.getPointsOf(opts.sender.userId); + points = parsed[1] === 'all' ? pointsOfUser : Number(parsed[1]); + + if (points === 0) { + throw Error(ERROR_ZERO_BET); + } + if (pointsOfUser < points) { + throw Error(ERROR_NOT_ENOUGH_POINTS); + } + + const minimalBet = await this.getPermissionBasedSettingsValue('minimalBet'); + if (points < minimalBet[permId]) { + throw new MinimalBetError(String(minimalBet[permId])); + } + + await pointsSystem.decrement({ userId: opts.sender.userId }, points); + + const chanceToWin = await this.getPermissionBasedSettingsValue('chanceToWin'); + const chanceToTriggerJackpot = await this.getPermissionBasedSettingsValue('chanceToTriggerJackpot'); + if (this.enableJackpot && random(0, 100, false) <= chanceToTriggerJackpot[permId]) { + const incrementPointsWithJackpot = (points * 2) + this.jackpotValue; + changelog.increment(opts.sender.userId, { points: incrementPointsWithJackpot }); + + const user = await users.getUserByUsername(opts.sender.userName); + set(user, 'extra.jackpotWins', get(user, 'extra.jackpotWins', 0) + 1); + changelog.update(user.userId, user); + + const currentPointsOfUser = await pointsSystem.getPointsOf(opts.sender.userId); + message = prepare('gambling.gamble.winJackpot', { + pointsName: getPointsName(currentPointsOfUser), + points: format(general.numberFormat, 0)(currentPointsOfUser), + jackpotName: getPointsName(this.jackpotValue), + jackpot: format(general.numberFormat, 0)(this.jackpotValue), + }); + this.jackpotValue = 0; + } else if (random(0, 100, false) <= chanceToWin[permId]) { + changelog.increment(opts.sender.userId, { points: points * 2 }); + const updatedPoints = await pointsSystem.getPointsOf(opts.sender.userId); + message = prepare('gambling.gamble.win', { + pointsName: getPointsName(updatedPoints), + points: format(general.numberFormat, 0)(updatedPoints), + }); + } else { + if (this.enableJackpot) { + const currentPointsOfUser = await pointsSystem.getPointsOf(opts.sender.userId); + this.jackpotValue = Math.min(Math.ceil(this.jackpotValue + (points * (this.lostPointsAddedToJackpot / 100))), this.maxJackpotValue); + message = prepare('gambling.gamble.loseWithJackpot', { + pointsName: getPointsName(currentPointsOfUser), + points: format(general.numberFormat, 0)(currentPointsOfUser), + jackpotName: getPointsName(this.jackpotValue), + jackpot: format(general.numberFormat, 0)(this.jackpotValue), + }); + } else { + message = prepare('gambling.gamble.lose', { + pointsName: getPointsName(await pointsSystem.getPointsOf(opts.sender.userId)), + points: format(general.numberFormat, 0)(await pointsSystem.getPointsOf(opts.sender.userId)), + }); + } + } + return [{ response: message, ...opts }]; + } catch (e: any) { + if (e instanceof MinimalBetError) { + message = prepare('gambling.gamble.lowerThanMinimalBet', { + pointsName: getPointsName(Number(e.message)), + points: format(general.numberFormat, 0)(Number(e.message)), + }); + return [{ response: message, ...opts }]; + } else { + switch (e.message) { + case ERROR_ZERO_BET: + message = prepare('gambling.gamble.zeroBet', { pointsName: getPointsName(0) }); + return [{ response: message, ...opts }]; + case ERROR_NOT_ENOUGH_OPTIONS: + return [{ response: translate('gambling.gamble.notEnoughOptions'), ...opts }]; + case ERROR_NOT_ENOUGH_POINTS: + message = prepare('gambling.gamble.notEnoughPoints', { + pointsName: getPointsName(points ? Number(points) : 0), + points: format(general.numberFormat, 0)(Number(points)), + }); + return [{ response: message, ...opts }]; + /* istanbul ignore next */ + default: + error(e.stack); + return [{ response: translate('core.error'), ...opts }]; + } + } + } + } + + @command('!gamble jackpot') + async jackpot (opts: CommandOptions): Promise { + let message: string; + if (this.enableJackpot) { + message = prepare('gambling.gamble.currentJackpot', { + command: this.getCommand('!gamble'), + pointsName: getPointsName(this.jackpotValue), + points: format(general.numberFormat, 0)(this.jackpotValue), + }); + } else { + message = prepare('gambling.gamble.jackpotIsDisabled', { command: this.getCommand('!gamble') }); + } + return [{ response: message, ...opts }]; + } + + @command('!gamble wins') + async wins (opts: CommandOptions): Promise { + let message: string; + const user = await users.getUserByUsername(opts.sender.userName); + if (this.enableJackpot) { + message = prepare('gambling.gamble.winJackpotCount', { + command: this.getCommand('!gamble wins'), + count: get(user, 'extra.jackpotWins', 0), + }); + } else { + message = prepare('gambling.gamble.jackpotIsDisabled', { command: this.getCommand('!gamble') }); + } + return [{ response: message, ...opts }]; + } +} + +export default new Gamble(); \ No newline at end of file diff --git a/backend/src/games/heist.ts b/backend/src/games/heist.ts new file mode 100644 index 000000000..8455add88 --- /dev/null +++ b/backend/src/games/heist.ts @@ -0,0 +1,281 @@ +import { HeistUser } from '@entity/heist.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import _ from 'lodash-es'; + +import Game from './_interface.js'; +import { onStartup } from '../decorators/on.js'; +import { command, settings } from '../decorators.js'; +import { Expects } from '../expects.js'; +import twitch from '../services/twitch.js'; + +import { AppDataSource } from '~/database.js'; +import { announce, prepare } from '~/helpers/commons/index.js'; +import { debug, warning } from '~/helpers/log.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { translate } from '~/translate.js'; + +export type Level = { name: string; winPercentage: number; payoutMultiplier: number; maxUsers: number }; +export type Result = { percentage: number; message: string }; + +class Heist extends Game { + dependsOn = [ 'systems.points' ]; + + startedAt: null | number = null; + lastAnnouncedLevel = ''; + lastHeistTimestamp = 0; + lastAnnouncedCops = 0; + lastAnnouncedHeistInProgress = 0; + lastAnnouncedStart = 0; + + @settings('options') + showMaxUsers = 20; + @settings('options') + copsCooldownInMinutes = 10; + @settings('options') + entryCooldownInSeconds = 120; + + @settings('notifications') + started: string = translate('games.heist.started'); + @settings('notifications') + nextLevelMessage: string = translate('games.heist.levelMessage'); + @settings('notifications') + maxLevelMessage: string = translate('games.heist.maxLevelMessage'); + @settings('notifications') + copsOnPatrol: string = translate('games.heist.copsOnPatrol'); + @settings('notifications') + copsCooldown: string = translate('games.heist.copsCooldownMessage'); + + @settings('results') + singleUserSuccess: string = translate('games.heist.singleUserSuccess'); + @settings('results') + singleUserFailed: string = translate('games.heist.singleUserFailed'); + @settings('results') + noUser: string = translate('games.heist.noUser'); + @settings('results') + resultsValues: Result[] = [ + { percentage: 0, message: translate('games.heist.result.0') }, + { percentage: 33, message: translate('games.heist.result.33') }, + { percentage: 50, message: translate('games.heist.result.50') }, + { percentage: 99, message: translate('games.heist.result.99') }, + { percentage: 100, message: translate('games.heist.result.100') }, + ]; + + @settings('levels') + levelsValues: Level[] = [ + { + 'name': translate('games.heist.levels.bankVan'), + 'winPercentage': 60, + 'payoutMultiplier': 1.5, + 'maxUsers': 5, + }, + { + 'name': translate('games.heist.levels.cityBank'), + 'winPercentage': 46, + 'payoutMultiplier': 1.7, + 'maxUsers': 10, + }, + { + 'name': translate('games.heist.levels.stateBank'), + 'winPercentage': 40, + 'payoutMultiplier': 1.9, + 'maxUsers': 20, + }, + { + 'name': translate('games.heist.levels.nationalReserve'), + 'winPercentage': 35, + 'payoutMultiplier': 2.1, + 'maxUsers': 30, + }, + { + 'name': translate('games.heist.levels.federalReserve'), + 'winPercentage': 31, + 'payoutMultiplier': 2.5, + 'maxUsers': 1000, + }, + ]; + + @onStartup() + onStartup() { + this.iCheckFinished(); + } + + async iCheckFinished () { + clearTimeout(this.timeouts.iCheckFinished); + + const levels = _.orderBy(this.levelsValues, 'maxUsers', 'asc'); + + // check if heist is finished + debug('heist', 'Checking heist if finished'); + if (!_.isNil(this.startedAt) && Date.now() - this.startedAt > (this.entryCooldownInSeconds * 1000) + 10000) { + debug('heist', 'Heist finished, processing'); + const users = await AppDataSource.getRepository(HeistUser).find(); + let level = levels.find(o => o.maxUsers >= users.length || _.isNil(o.maxUsers)); // find appropriate level or max level + + if (!level) { + if (levels.length > 0) { + // select last level when max users are over (we have it already sorted) + level = levels[levels.length - 1]; + } else { + debug('heist', 'no level to check'); + return; // don't do anything if there is no level + } + } + + if (users.length === 0) { + // cleanup + this.startedAt = null; + await AppDataSource.getRepository(HeistUser).clear(); + this.timeouts.iCheckFinished = global.setTimeout(() => this.iCheckFinished(), 10000); + announce(this.noUser, 'heist'); + return; + } + + announce(this.started.replace('$bank', level.name), 'heist'); + if (users.length === 1) { + // only one user + const isSurvivor = _.random(0, 100, false) <= level.winPercentage; + const user = users[0]; + const outcome = isSurvivor ? this.singleUserSuccess : this.singleUserFailed; + global.setTimeout(async () => { + announce(outcome.replace('$user', (twitch.showWithAt ? '@' : '') + user.username), 'heist'); + }, 5000); + + if (isSurvivor) { + // add points to user + changelog.increment(user.userId, { points: Math.floor(user.points * level.payoutMultiplier) }); + } + } else { + const winners: string[] = []; + for (const user of users) { + const isSurvivor = _.random(0, 100, false) <= level.winPercentage; + + if (isSurvivor) { + // add points to user + changelog.increment(user.userId, { points: Math.floor(user.points * level.payoutMultiplier) }); + winners.push(user.username); + } + } + const percentage = (100 / users.length) * winners.length; + const ordered = _.orderBy(this.resultsValues, [(o) => o.percentage], 'asc'); + const result = ordered.find(o => o.percentage >= percentage); + global.setTimeout(async () => { + if (!_.isNil(result)) { + announce(result.message, 'heist'); + } + }, 5000); + if (winners.length > 0) { + global.setTimeout(async () => { + const chunk: string[][] = _.chunk(winners, this.showMaxUsers); + const winnersList = chunk.shift() || []; + const andXMore = winners.length - this.showMaxUsers; + + let message = await translate('games.heist.results'); + message = message.replace('$users', winnersList.map((o) => (twitch.showWithAt ? '@' : '') + o).join(', ')); + if (andXMore > 0) { + message = message + ' ' + (await translate('games.heist.andXMore')).replace('$count', andXMore); + } + announce(message, 'heist'); + }, 5500); + } + } + + // cleanup + this.startedAt = null; + this.lastHeistTimestamp = Date.now(); + await AppDataSource.getRepository(HeistUser).clear(); + } + + // check if cops done patrolling + if (this.lastHeistTimestamp !== 0 && Date.now() - this.lastHeistTimestamp >= this.copsCooldownInMinutes * 60000) { + this.lastHeistTimestamp = 0; + announce(this.copsCooldown, 'heist'); + } + this.timeouts.iCheckFinished = global.setTimeout(() => this.iCheckFinished(), 10000); + } + + @command('!bankheist') + async main (opts: CommandOptions): Promise { + const pointsSystem = (await import('../systems/points.js')).default; + const [entryCooldown, lastHeistTimestamp, copsCooldown] = await Promise.all([ + this.entryCooldownInSeconds, + this.lastHeistTimestamp, + this.copsCooldownInMinutes, + ]); + const levels = _.orderBy(this.levelsValues, 'maxUsers', 'asc'); + + // is cops patrolling? + if (Date.now() - lastHeistTimestamp < copsCooldown * 60000) { + const minutesLeft = Number(copsCooldown - (Date.now() - lastHeistTimestamp) / 60000).toFixed(1); + if (Date.now() - (this.lastAnnouncedCops) >= 60000) { + this.lastAnnouncedCops = Date.now(); + return [{ response: this.copsOnPatrol.replace('$cooldown', minutesLeft + ' ' + getLocalizedName(minutesLeft, translate('core.minutes'))), ...opts }]; + } + return []; + } + + let newHeist = false; + if (this.startedAt === null) { // new heist + newHeist = true; + this.startedAt = Date.now(); // set startedAt + if (Date.now() - (this.lastAnnouncedStart) >= 60000) { + this.lastAnnouncedStart = Date.now(); + announce(prepare('games.heist.entryMessage', { command: opts.command, sender: opts.sender }), 'heist'); + } + } + + // is heist in progress? + if (!newHeist && Date.now() - this.startedAt > entryCooldown * 1000 && Date.now() - (this.lastAnnouncedHeistInProgress) >= 60000) { + this.lastAnnouncedHeistInProgress = Date.now(); + return [{ response: translate('games.heist.lateEntryMessage').replace('$command', opts.command), ...opts }]; + } + + let points: number | string = 0; + try { + points = new Expects(opts.parameters).points({ all: true }).toArray()[0] as (number | string); + } catch (e: any) { + if (!newHeist) { + warning(`${opts.command} ${e.message}`); + return [{ response: translate('games.heist.entryInstruction').replace('$command', opts.command), ...opts }]; + } + return []; + } + + const userPoints = await pointsSystem.getPointsOf(opts.sender.userId); + points = points === 'all' ? userPoints : Number(points); // set all points + points = points > userPoints ? userPoints : points; // bet only user points + + if (points === 0 || _.isNil(points) || _.isNaN(points)) { + return [{ response: translate('games.heist.entryInstruction').replace('$command', opts.command), ...opts }]; + } // send entryInstruction if command is not ok + + await Promise.all([ + pointsSystem.decrement({ userId: opts.sender.userId }, Number(points)), + AppDataSource.getRepository(HeistUser).save({ + userId: opts.sender.userId, username: opts.sender.userName, points: Number(points), + }), // add user to heist list + ]); + + // check how many users are in heist + const users = await AppDataSource.getRepository(HeistUser).find(); + const level = levels.find(o => o.maxUsers >= users.length || _.isNil(o.maxUsers)); + if (level) { + const nextLevel = levels.find(o => o.maxUsers > level.maxUsers); + if (this.lastAnnouncedLevel !== level.name) { + this.lastAnnouncedLevel = level.name; + if (nextLevel) { + announce(this.nextLevelMessage + .replace('$bank', level.name) + .replace('$nextBank', nextLevel.name), 'heist'); + return []; + } else { + announce(this.maxLevelMessage + .replace('$bank', level.name), 'heist'); + return []; + } + } + } + return []; + } +} + +export default new Heist(); \ No newline at end of file diff --git a/backend/src/games/roulette.ts b/backend/src/games/roulette.ts new file mode 100644 index 000000000..4692127b5 --- /dev/null +++ b/backend/src/games/roulette.ts @@ -0,0 +1,66 @@ +import _ from 'lodash-es'; + +import Game from './_interface.js'; +import { command, settings } from '../decorators.js'; + +import { tmiEmitter } from '~/helpers/tmi/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { isBroadcaster } from '~/helpers/user/isBroadcaster.js'; +import { isModerator } from '~/helpers/user/isModerator.js'; +import { translate } from '~/translate.js'; + +/* + * !roulette - 50/50 chance to timeout yourself + */ + +class Roulette extends Game { + dependsOn = [ 'systems.points' ]; + + @settings() + timeout = 10; + + @settings('rewards') + winnerWillGet = 0; + @settings('rewards') + loserWillLose = 0; + + @command('!roulette') + async main (opts: CommandOptions): Promise<(CommandResponse & { isAlive?: boolean })[]> { + const isAlive = !!_.random(0, 1, false); + const isMod = isModerator(opts.sender); + const responses: (CommandResponse & { isAlive?: boolean })[] = []; + + responses.push({ response: translate('gambling.roulette.trigger'), ...opts }); + if (isBroadcaster(opts.sender)) { + responses.push({ + response: translate('gambling.roulette.broadcaster'), ...opts, isAlive: true, + }); + return responses; + } + + if (isMod) { + responses.push({ + response: translate('gambling.roulette.mod'), ...opts, isAlive: true, + }); + return responses; + } + + setTimeout(async () => { + if (!isAlive) { + tmiEmitter.emit('timeout', opts.sender.userName, this.timeout, { mod: opts.sender.isMod }); + } + }, 2000); + + if (isAlive) { + changelog.increment(opts.sender.userId, { points: Number(this.winnerWillGet) }); + } else { + changelog.increment(opts.sender.userId, { points: -Number(this.loserWillLose) }); + } + responses.push({ + response: isAlive ? translate('gambling.roulette.alive') : translate('gambling.roulette.dead'), ...opts, isAlive, + }); + return responses; + } +} + +export default new Roulette(); diff --git a/backend/src/general.ts b/backend/src/general.ts new file mode 100644 index 000000000..a3d41cca6 --- /dev/null +++ b/backend/src/general.ts @@ -0,0 +1,296 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'; + +import { HOUR, MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { setLocale } from '@sogebot/ui-helpers/dayjsHelper.js'; +import gitCommitInfo from 'git-commit-info'; +import { + capitalize, + get, isNil, +} from 'lodash-es'; + +import { menu } from './helpers/panel.js'; +import type { Command } from '../d.ts/src/general.js'; + +import Core from '~/_interface.js'; +import { PermissionCommands } from '~/database/entity/permissions.js'; +import { + onChange, onLoad, onStartup, +} from '~/decorators/on.js'; +import { + command, default_permission, settings, ui, +} from '~/decorators.js'; +import { isStreamOnline } from '~/helpers/api/index.js'; +import { setValue } from '~/helpers/general/index.js'; +import { setLang } from '~/helpers/locales.js'; +import { + debug, error, warning, +} from '~/helpers/log.js'; +import { socketsConnected } from '~/helpers/panel/index.js'; +import { addUIWarn } from '~/helpers/panel/index.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { list } from '~/helpers/register.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import { getMuteStatus } from '~/helpers/tmi/muteStatus.js'; +import translateLib, { translate } from '~/translate.js'; +import { variables } from '~/watchers.js'; + +let threadStartTimestamp = Date.now(); +let isInitialLangSet = true; + +const gracefulExit = () => { + if (general.gracefulExitEachXHours > 0) { + debug('thread', 'gracefulExit::check'); + if (Date.now() - threadStartTimestamp >= general.gracefulExitEachXHours * HOUR) { + if (!isStreamOnline.value && socketsConnected === 0) { + warning('Gracefully exiting sogeBot as planned and configured in UI in settings->general.'); + debug('thread', 'gracefulExit::exiting and creating restart file (so we dont have startup logging'); + writeFileSync('~/restart.pid', ' '); + process.exit(0); + } else { + debug('thread', 'gracefulExit::Gracefully exiting process skipped, stream online - moved by 15 minutes'); + // if stream is online move exit by hour + threadStartTimestamp += 15 * MINUTE; + } + } + } else { + threadStartTimestamp = Date.now(); + } +}; + +class General extends Core { + @settings('graceful_exit') + gracefulExitEachXHours = 0; + + @settings('general') + @ui({ + type: 'selector', values: () => { + const f = readdirSync('./locales/'); + return [...new Set(f.map((o) => o.split('.')[0]))]; + }, + }) + lang = 'en'; + + @settings('general') + numberFormat = ''; + + @onStartup() + onStartup() { + this.addMenu({ + name: 'index', id: '', this: this, + }); + this.addMenu({ + category: 'commands', name: 'botcommands', id: 'commands/botcommands', this: this, + }); + this.addMenu({ + category: 'settings', name: 'modules', id: 'settings/modules', this: null, + }); + this.addMenuPublic({ name: 'index', id: '' }); + setInterval(gracefulExit, 1000); + } + + sockets() { + adminEndpoint('/core/general', 'menu::private', async (cb) => { + cb(menu.map((o) => ({ + category: o.category, name: o.name, id: o.id, enabled: o.this ? o.this.enabled : true, + }))); + }); + + adminEndpoint('/core/general', 'generic::getCoreCommands', async (cb) => { + try { + const commands: Command[] = []; + + for (const type of ['overlays', 'integrations', 'core', 'systems', 'games', 'services', 'registries']) { + for (const system of list(type as any)) { + for (const cmd of system._commands) { + const name = typeof cmd === 'string' ? cmd : cmd.name; + commands.push({ + id: cmd.id, + defaultValue: name, + command: cmd.command ?? name, + type: capitalize(type), + name: system.__moduleName__, + permission: await new Promise((resolve: (value: string | null) => void) => { + PermissionCommands.findOneByOrFail({ name }) + .then(data => { + resolve(data.permission); + }) + .catch(() => { + resolve(cmd.permission ?? null); + }); + }), + }); + } + } + } + cb(null, commands); + } catch (e: any) { + cb(e, []); + } + }); + + adminEndpoint('/core/general', 'generic::setCoreCommand', async (commandToSet, cb) => { + // get module + const module = list(commandToSet.type.toLowerCase() as any).find(item => item.__moduleName__ === commandToSet.name); + if (!module) { + throw new Error(`Module ${commandToSet.name} not found`); + } + + const moduleCommand = module._commands.find((o) => o.name === commandToSet.defaultValue); + if (!moduleCommand) { + throw new Error(`Command ${commandToSet.defaultValue} not found in module ${commandToSet.name}`); + } + + // handle permission + if (commandToSet.permission === moduleCommand.permission) { + await PermissionCommands.delete({ name: moduleCommand.name }); + } else { + const entity = await PermissionCommands.findOneBy({ name: moduleCommand.name }) || new PermissionCommands(); + entity.name = moduleCommand.name; + entity.permission = commandToSet.permission; + await entity.save(); + } + + // handle new command value + module.setCommand(commandToSet.defaultValue, commandToSet.command); + cb(null); + }); + } + + @command('!enable') + @default_permission(defaultPermissions.CASTERS) + public async enable(opts: CommandOptions) { + this.setStatus({ ...opts, enable: true }); + } + + @command('!disable') + @default_permission(defaultPermissions.CASTERS) + public async disable(opts: CommandOptions) { + this.setStatus({ ...opts, enable: false }); + } + + @onChange('lang') + @onLoad('lang') + public async onLangUpdate() { + if (!translateLib.isLoaded) { + setTimeout(() => this.onLangUpdate(), 10); + return; + } + if (!(await translateLib.check(this.lang))) { + warning(`Language ${this.lang} not found - fallback to en`); + this.lang = 'en'; + } else { + setLocale(this.lang); + setLang(this.lang); + warning(translate('core.lang-selected')); + if (!isInitialLangSet) { + addUIWarn({ name: 'UI', message: translate('core.lang-selected') + '. ' + translate('core.refresh-panel') }); + } + isInitialLangSet = false; + } + } + + public async onLangLoad() { + await translateLib._load(); + } + + @command('!_debug') + @default_permission(defaultPermissions.CASTERS) + public async debug(opts: CommandOptions): Promise { + const lang = this.lang; + + const enabledSystems: { + systems: string[]; + games: string[]; + integrations: string[]; + } = { + systems: [], games: [], integrations: [], + }; + for (const category of ['systems', 'games', 'integrations']) { + for (const system of list(category as any)) { + const enabled = system.enabled; + const areDependenciesEnabled = system.areDependenciesEnabled; + const isDisabledByEnv = !isNil(process.env.DISABLE) && (process.env.DISABLE.toLowerCase().split(',').includes(system.__moduleName__.toLowerCase()) || process.env.DISABLE === '*'); + + if (!enabled) { + enabledSystems[category as 'systems' | 'games' | 'integrations'].push('-' + system.__moduleName__); + } else if (!areDependenciesEnabled) { + enabledSystems[category as 'systems' | 'games' | 'integrations'].push('-dep-' + system.__moduleName__); + } else if (isDisabledByEnv) { + enabledSystems[category as 'systems' | 'games' | 'integrations'].push('-env-' + system.__moduleName__); + } else { + enabledSystems[category as 'systems' | 'games' | 'integrations'].push(system.__moduleName__); + } + } + } + + const botUsername = variables.get('services.twitch.botUsername') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const botId = variables.get('services.twitch.botId') as string; + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + + const twitch = (await import('./services/twitch.js')).default; + + const version = get(process, 'env.npm_package_version', 'x.y.z'); + const commitFile = existsSync('./.commit') ? readFileSync('./.commit').toString() : null; + debug('*', '======= COPY DEBUG MESSAGE FROM HERE ======='); + debug('*', `GENERAL | OS: ${process.env.npm_config_user_agent}`); + debug('*', ` | Bot version: ${version.replace('SNAPSHOT', commitFile && commitFile.length > 0 ? commitFile : gitCommitInfo().shortHash || 'SNAPSHOT')}`); + debug('*', ` | DB: ${process.env.TYPEORM_CONNECTION}`); + debug('*', ` | HEAP: ${Number(process.memoryUsage().heapUsed / 1048576).toFixed(2)} MB`); + debug('*', ` | Uptime: ${new Date(1000 * process.uptime()).toISOString().substr(11, 8)}`); + debug('*', ` | Language: ${lang}`); + debug('*', ` | Mute: ${getMuteStatus()}`); + debug('*', `SYSTEMS | ${enabledSystems.systems.join(', ')}`); + debug('*', `GAMES | ${enabledSystems.games.join(', ')}`); + debug('*', `INTEGRATIONS | ${enabledSystems.integrations.join(', ')}`); + debug('*', `OAUTH | BOT ${botUsername}#${botId} isConnected: ${twitch.tmi?.client.bot?.isConnected} | BROADCASTER ${broadcasterUsername}#${broadcasterId} isConnected: ${twitch.tmi?.client.broadcaster?.isConnected}`); + debug('*', '======= END OF DEBUG MESSAGE ======='); + return []; + } + + @command('!ping') + ping(opts: CommandOptions): CommandResponse[] { + if (opts.discord) { + const response = `$sender, Pong! \`${Date.now() - opts.createdAt}ms\``; + return [{ response, ...opts }]; + } else { + const response = `$sender, Pong! ${Date.now() - opts.createdAt}ms`; + return [{ response, ...opts }]; + } + } + + @command('!set') + @default_permission(defaultPermissions.CASTERS) + public async setValue(opts: CommandOptions) { + return setValue(opts); + } + + private async setStatus(opts: CommandOptions & { enable: boolean }) { + if (opts.parameters.trim().length === 0) { + return; + } + try { + const [type, name] = opts.parameters.split(' '); + + if (type !== 'system' && type !== 'game') { + throw new Error('Not supported'); + } + + let found = false; + for (const system of list(type + 's' as any)) { + system.status({ state: opts.enable }); + found = true; + break; + } + + if (!found) { + throw new Error(`Not found - ${type}s - ${name}`); + } + } catch (e: any) { + error(e.stack); + } + } +} + +const general = new General(); +export default general; \ No newline at end of file diff --git a/backend/src/helpers/api/authenticate.ts b/backend/src/helpers/api/authenticate.ts new file mode 100644 index 000000000..b9a52cbce --- /dev/null +++ b/backend/src/helpers/api/authenticate.ts @@ -0,0 +1,36 @@ +import * as express from 'express'; +import * as jwt from 'jsonwebtoken'; + +import { UnauthorizedError } from '../errors.js'; + +export async function expressAuthentication( + req: express.Request, + securityName: string, + scopes?: any, +): Promise { + if (securityName === 'bearerAuth') { + const { authorization } = req.headers; + if (!authorization) { + return Promise.reject(new UnauthorizedError('You must send an Authorization header')); + } + + const [authType, token] = authorization.trim().split(' '); + if (authType !== 'Bearer') { + return Promise.reject(new UnauthorizedError('Expected a Bearer token')); + } + const JWTKey = (await import('../../socket.js')).default.JWTKey; + const validatedToken = jwt.verify(token, JWTKey) as { + userId: string; username: string; privileges: any; + }; + + if (validatedToken.privileges.haveAdminPrivileges !== 2 /* authorized */) { + return Promise.reject(new UnauthorizedError('You don\'t have permission to access this resource.')); + } + + return Promise.resolve({ + userId: validatedToken.userId, + username: validatedToken.username, + }); + } + return Promise.resolve({}); +} \ No newline at end of file diff --git a/backend/src/helpers/api/cache.ts b/backend/src/helpers/api/cache.ts new file mode 100644 index 000000000..40db1ebef --- /dev/null +++ b/backend/src/helpers/api/cache.ts @@ -0,0 +1,21 @@ +import { persistent } from '../core/persistent.js'; + +const gameCache = persistent({ + value: '', + name: 'gameCache', + namespace: '/core/api', +}); + +const rawStatus = persistent({ + value: '', + name: 'rawStatus', + namespace: '/core/api', +}); + +const tagsCache = persistent({ + value: '[]', + name: 'tagsCache', + namespace: '/core/api', +}); + +export { rawStatus, gameCache, tagsCache }; \ No newline at end of file diff --git a/backend/src/helpers/api/channelPoll.ts b/backend/src/helpers/api/channelPoll.ts new file mode 100644 index 000000000..7aa81815c --- /dev/null +++ b/backend/src/helpers/api/channelPoll.ts @@ -0,0 +1,82 @@ +import { HelixPollData } from '@twurple/api/lib/interfaces/endpoints/poll.external'; +import { EventSubChannelPollBeginEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPollBeginEvent.external'; +import { EventSubChannelPollEndEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPollEndEvent.external'; +import { EventSubChannelPollProgressEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPollProgressEvent.external'; + +import { eventEmitter } from '~/helpers/events/index.js'; + +let event: null | EventSubChannelPollBeginEventData | EventSubChannelPollProgressEventData | EventSubChannelPollEndEventData | HelixPollData = null; + +function setData(event_data: EventSubChannelPollBeginEventData | EventSubChannelPollProgressEventData | EventSubChannelPollEndEventData | HelixPollData) { + event = event_data; +} +function winnerChoice(choices: EventSubChannelPollEndEventData['choices']) { + let winner = ''; + let votes = 0; + for (const choice of choices) { + if (votes < (choice.votes ?? 0)) { + votes = choice.votes ?? 0; + winner = choice.title; + } + } + return winner; +} + +function winnerVotes(choices: EventSubChannelPollEndEventData['choices']) { + let votes = 0; + for (const choice of choices) { + if (votes < (choice.votes ?? 0)) { + votes = choice.votes ?? 0; + } + } + return votes; +} + +function winnerPercentage(choices: EventSubChannelPollEndEventData['choices']) { + let votes = 0; + let totalVotes = 0; + for (const choice of choices) { + if (votes < (choice.votes ?? 0)) { + votes = choice.votes ?? 0; + } + totalVotes += choice.votes ?? 0; + } + return Math.floor((votes / totalVotes) * 100); +} + +async function triggerPollStart() { + event = event as EventSubChannelPollBeginEventData; + if (event) { + eventEmitter.emit('poll-started', { + choices: event.choices.map(o => o.title).join(', '), + titleOfPoll: event.title, + channelPointsAmountPerVote: event.channel_points_voting.amount_per_vote, + channelPointsVotingEnabled: event.channel_points_voting.is_enabled, + }); + } +} + +async function triggerPollEnd() { + if (event) { + const votes = (event as EventSubChannelPollEndEventData).choices.reduce((total, item) => { + return total + (item.votes ?? 0); + }, 0); + + eventEmitter.emit('poll-ended', { + choices: event.choices.map(o => o.title).join(', '), + titleOfPoll: event.title, + votes, + winnerChoice: winnerChoice((event as EventSubChannelPollEndEventData).choices), + winnerPercentage: winnerPercentage((event as EventSubChannelPollEndEventData).choices), + winnerVotes: winnerVotes((event as EventSubChannelPollEndEventData).choices), + + }); + } +} + +export { + setData, + triggerPollStart, + triggerPollEnd, + event, +}; diff --git a/backend/src/helpers/api/channelPrediction.ts b/backend/src/helpers/api/channelPrediction.ts new file mode 100644 index 000000000..58e7cac63 --- /dev/null +++ b/backend/src/helpers/api/channelPrediction.ts @@ -0,0 +1,157 @@ +import { HelixPrediction, HelixPredictionOutcomeColor } from '@twurple/api/lib'; +import { EventSubChannelPredictionOutcomeData } from '@twurple/eventsub-base/lib/events/common/EventSubChannelPredictionOutcome.external'; +import { EventSubChannelPredictionBeginEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPredictionBeginEvent.external'; +import { EventSubChannelPredictionEndEvent } from '@twurple/eventsub-base/lib/events/EventSubChannelPredictionEndEvent'; +import { EventSubChannelPredictionEndEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPredictionEndEvent.external'; +import { EventSubChannelPredictionLockEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPredictionLockEvent.external'; +import { EventSubChannelPredictionProgressEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPredictionProgressEvent.external'; + +import { eventEmitter } from '~/helpers/events/index.js'; + +let data: null | { + id: string, + title: string, + autoLockAfter: null | string, + creationDate: null | string, + lockDate: null | string; + outcomes: { + id: string, + color: HelixPredictionOutcomeColor, + title: string, + users: number, + totalChannelPoints: number, + }[], + winningOutcomeId: EventSubChannelPredictionEndEvent['winningOutcomeId']; + winningOutcome: null | EventSubChannelPredictionOutcomeData | HelixPrediction['winningOutcome']; +} = null; + +function status(event?: HelixPrediction) { + if (event) { + data = { + id: event.id, + title: event.title, + autoLockAfter: data?.autoLockAfter ?? null, + creationDate: data?.creationDate ?? null, + outcomes: event.outcomes.map(outcome => ({ + id: outcome.id, + color: outcome.color, + title: outcome.title, + users: outcome.users, + totalChannelPoints: outcome.totalChannelPoints, + })), + winningOutcome: event.winningOutcome, + winningOutcomeId: event.winningOutcomeId, + lockDate: event.lockDate ? new Date(event.lockDate).toISOString() : null, + }; + } + return data; +} +function progress(event: EventSubChannelPredictionProgressEventData) { + data = { + id: event.id, + title: event.title, + autoLockAfter: data?.autoLockAfter ?? null, + creationDate: data?.creationDate ?? null, + outcomes: event.outcomes.map(outcome => ({ + id: outcome.id, + color: outcome.color as any, + title: outcome.title, + users: outcome.users, + totalChannelPoints: outcome.channel_points, + })), + winningOutcome: null, + winningOutcomeId: null, + lockDate: null, + }; + eventEmitter.emit('prediction-started', { + outcomes: event.outcomes.map(o => o.title).join(', '), + titleOfPrediction: event.title, + locksAt: new Date(event.locks_at).toISOString(), + }); +} +function start(event: EventSubChannelPredictionBeginEventData) { + data = { + id: event.id, + title: event.title, + autoLockAfter: data?.autoLockAfter ?? null, + creationDate: data?.creationDate ?? null, + outcomes: event.outcomes.map(outcome => ({ + id: outcome.id, + color: outcome.color as any, + title: outcome.title, + users: 0, + totalChannelPoints: 0, + })), + winningOutcome: null, + winningOutcomeId: null, + lockDate: null, + }; + eventEmitter.emit('prediction-started', { + outcomes: event.outcomes.map(o => o.title).join(', '), + titleOfPrediction: event.title, + locksAt: new Date(event.locks_at).toISOString(), + }); +} + +function lock(event: EventSubChannelPredictionLockEventData) { + data = { + id: event.id, + title: event.title, + autoLockAfter: data?.autoLockAfter ?? null, + creationDate: data?.creationDate ?? null, + outcomes: event.outcomes.map(outcome => ({ + id: outcome.id, + color: outcome.color as any, + title: outcome.title, + users: outcome.users, + totalChannelPoints: outcome.channel_points, + })), + winningOutcome: null, + winningOutcomeId: null, + lockDate: new Date(event.locked_at).toISOString(), + }; + eventEmitter.emit('prediction-locked', { + outcomes: event.outcomes.map(o => o.title).join(', '), + titleOfPrediction: event.title, + locksAt: new Date(event.locked_at).toISOString(), + }); +} + +async function end(event: EventSubChannelPredictionEndEventData) { + const winningOutcome = event.outcomes.find(o => o.id === event.winning_outcome_id) ?? null; + data = { + id: event.id, + title: event.title, + autoLockAfter: data?.autoLockAfter ?? null, + creationDate: data?.creationDate ?? null, + outcomes: event.outcomes.map(outcome => ({ + id: outcome.id, + color: outcome.color as any, + title: outcome.title, + users: outcome.users, + totalChannelPoints: outcome.channel_points, + })), + winningOutcome: winningOutcome, + winningOutcomeId: event.winning_outcome_id, + lockDate: data?.lockDate ?? null, + }; + const points = event.outcomes.reduce((total, item) => { + return total + (item.channel_points ?? 0); + }, 0); + eventEmitter.emit('prediction-ended', { + outcomes: event.outcomes.map(o => o.title).join(', '), + titleOfPrediction: event.title, + locksAt: new Date(event.ended_at).toISOString(), + winningOutcomeTitle: winningOutcome?.title || '', + winningOutcomeTotalPoints: winningOutcome?.channel_points || 0, + winningOutcomePercentage: points > 0 ? (winningOutcome?.channel_points || 0) / points : 100, + }); +} + +export { + start, + end, + lock, + status, + progress, +}; diff --git a/backend/src/helpers/api/chatMessagesAtStart.ts b/backend/src/helpers/api/chatMessagesAtStart.ts new file mode 100644 index 000000000..3a1e445f3 --- /dev/null +++ b/backend/src/helpers/api/chatMessagesAtStart.ts @@ -0,0 +1,12 @@ +let _value = 0; + +const chatMessagesAtStart = { + set value(value: typeof _value) { + _value = value; + }, + get value() { + return _value; + }, +}; + +export { chatMessagesAtStart }; \ No newline at end of file diff --git a/backend/src/helpers/api/currentStreamTags.ts b/backend/src/helpers/api/currentStreamTags.ts new file mode 100644 index 000000000..f8ab945c5 --- /dev/null +++ b/backend/src/helpers/api/currentStreamTags.ts @@ -0,0 +1,18 @@ +const _value: string[] = []; + +const currentStreamTags = { + get value() { + return _value; + }, + pop() { + _value.pop(); + }, + push(value: typeof _value[number]) { + _value.push(value); + }, + get length() { + return _value.length; + }, +}; + +export { currentStreamTags }; \ No newline at end of file diff --git a/backend/src/helpers/api/gameOrTitleChangedManually.ts b/backend/src/helpers/api/gameOrTitleChangedManually.ts new file mode 100644 index 000000000..c93d23802 --- /dev/null +++ b/backend/src/helpers/api/gameOrTitleChangedManually.ts @@ -0,0 +1,12 @@ +let _gameOrTitleChangedManually = false; + +const gameOrTitleChangedManually = { + set value(value: typeof _gameOrTitleChangedManually) { + _gameOrTitleChangedManually = value; + }, + get value () { + return _gameOrTitleChangedManually; + }, +}; + +export { gameOrTitleChangedManually }; \ No newline at end of file diff --git a/backend/src/helpers/api/hypeTrain.ts b/backend/src/helpers/api/hypeTrain.ts new file mode 100644 index 000000000..9d0f9fc99 --- /dev/null +++ b/backend/src/helpers/api/hypeTrain.ts @@ -0,0 +1,132 @@ +import { eventEmitter } from '../events/index.js'; + +let latestLevel = 1 as number; +let total = 0; +let goal = 0; + +let isStarted = false; +const subs = new Map(); + +let lastContributionTotal = 0; +let lastContributionType = 'bits' as 'bits' | 'subscription'; +let lastContributionUserId = null as null | string; +let lastContributionUserName = null as null | string; + +let topContributionsBitsTotal = 0; +let topContributionsBitsUserId = null as null | string; +let topContributionsBitsUserName = null as null | string; +let topContributionsSubsTotal = 0; +let topContributionsSubsUserId = null as null | string; +let topContributionsSubsUserName = null as null | string; + +function setIsStarted(value: boolean) { + isStarted = value; + if (!value) { + subs.clear(); + } +} + +async function setCurrentLevel(level: number) { + if (level > latestLevel && level > 1) { + let waitForNextLevel = false; + while(latestLevel < level) { + if (waitForNextLevel) { + // wait for a while before new level is triggered + await new Promise((resolve) => setTimeout(() => resolve(true), 10000)); + } + waitForNextLevel = true; + latestLevel++; + + eventEmitter.emit('hypetrain-level-reached', { + level: latestLevel, + total, + goal, + + topContributionsBitsUserId: topContributionsBitsUserId ? topContributionsBitsUserId : 'n/a', + topContributionsBitsUsername: topContributionsBitsUserName ? topContributionsBitsUserName : 'n/a', + topContributionsBitsTotal, + + topContributionsSubsUserId: topContributionsSubsUserId ? topContributionsSubsUserId : 'n/a', + topContributionsSubsUsername: topContributionsSubsUserName ? topContributionsSubsUserName : 'n/a', + topContributionsSubsTotal, + + lastContributionTotal, + lastContributionType, + lastContributionUserId: lastContributionUserId ? lastContributionUserId : 'n/a', + lastContributionUsername: lastContributionUserName ? lastContributionUserName : 'n/a', + }); + } + } +} + +function getCurrentLevel() { + return latestLevel; +} + +function setLastContribution(total_: typeof lastContributionTotal, type: typeof lastContributionType, userId: typeof lastContributionUserId, username: typeof lastContributionUserName) { + lastContributionTotal = total_; + lastContributionType = type; + lastContributionUserId = userId; + lastContributionUserName = username; +} + +function setTopContributions(type: 'bits' | 'subscription', total_: typeof lastContributionTotal, userId: typeof topContributionsBitsUserId, username: typeof lastContributionUserName) { + if (type === 'bits') { + topContributionsBitsTotal = total_; + topContributionsBitsUserId = userId; + topContributionsBitsUserName = username; + } else { + topContributionsSubsTotal = total_; + topContributionsSubsUserId = userId; + topContributionsSubsUserName = username; + } +} + +function setTotal(value: number) { + total = value; +} + +function setGoal(value: number) { + goal = value; +} + +async function triggerHypetrainEnd() { + setIsStarted(false); + eventEmitter.emit('hypetrain-ended', { + level: latestLevel, + total, + goal, + + topContributionsBitsUserId: topContributionsBitsUserId ? topContributionsBitsUserId : 'n/a', + topContributionsBitsUsername: topContributionsBitsUserName ? topContributionsBitsUserName : 'n/a', + topContributionsBitsTotal, + + topContributionsSubsUserId: topContributionsSubsUserId ? topContributionsSubsUserId : 'n/a', + topContributionsSubsUsername: topContributionsSubsUserName ? topContributionsSubsUserName : 'n/a', + topContributionsSubsTotal, + + lastContributionTotal, + lastContributionType, + lastContributionUserId: lastContributionUserId ? lastContributionUserId : 'n/a', + lastContributionUsername: lastContributionUserName ? lastContributionUserName : 'n/a', + }); +} + +const addSub = (sub: { username: string, profileImageUrl: string }) => { + if (isStarted) { + subs.set(sub.username, sub.profileImageUrl); + } +}; + +export { + addSub, + subs, + getCurrentLevel, + setCurrentLevel, + setTotal, + setGoal, + setTopContributions, + setLastContribution, + triggerHypetrainEnd, + setIsStarted, +}; diff --git a/backend/src/helpers/api/index.ts b/backend/src/helpers/api/index.ts new file mode 100644 index 000000000..ebd9bcff6 --- /dev/null +++ b/backend/src/helpers/api/index.ts @@ -0,0 +1,11 @@ +export * from './cache.js'; +export * from './chatMessagesAtStart.js'; +export * from './currentStreamTags.js'; +export * from './gameOrTitleChangedManually.js'; +export * from './isStreamOnline.js'; +// exclude from index +// export * from './parseTitle'; +export * from './stats.js'; +export * from './streamId.js'; +export * from './streamStatusChangeSince.js'; +export * from './streamType.js'; \ No newline at end of file diff --git a/backend/src/helpers/api/isStreamOnline.ts b/backend/src/helpers/api/isStreamOnline.ts new file mode 100644 index 000000000..bab31faed --- /dev/null +++ b/backend/src/helpers/api/isStreamOnline.ts @@ -0,0 +1,9 @@ +import { persistent } from '../core/persistent.js'; + +const isStreamOnline = persistent({ + value: false, + name: 'isStreamOnline', + namespace: '/core/api', +}); + +export { isStreamOnline }; \ No newline at end of file diff --git a/backend/src/helpers/api/parseTitle.ts b/backend/src/helpers/api/parseTitle.ts new file mode 100644 index 000000000..2d0dfcc27 --- /dev/null +++ b/backend/src/helpers/api/parseTitle.ts @@ -0,0 +1,29 @@ +import { isNil } from 'lodash-es'; + +import { rawStatus } from './cache.js'; +import { translate } from '../../translate.js'; +import { getValueOf, isVariableSet } from '../customvariables/index.js'; + +async function parseTitle (title: string | null) { + if (isNil(title)) { + title = rawStatus.value; + } + + const regexp = new RegExp('\\$_[a-zA-Z0-9_]+', 'g'); + const match = title.match(regexp); + + if (!isNil(match)) { + for (const variable of match) { + let value; + if (await isVariableSet(variable)) { + value = await getValueOf(variable); + } else { + value = translate('webpanel.not-available'); + } + title = title.replace(new RegExp(`\\${variable}`, 'g'), value); + } + } + return title; +} + +export { parseTitle }; \ No newline at end of file diff --git a/backend/src/helpers/api/stats.ts b/backend/src/helpers/api/stats.ts new file mode 100644 index 000000000..75c25582e --- /dev/null +++ b/backend/src/helpers/api/stats.ts @@ -0,0 +1,67 @@ +import { persistent } from '../core/persistent.js'; +import { eventEmitter } from '../events/index.js'; + +import { Types } from '~/plugins/ListenTo.js'; + +const old = new Map(); +const stats = persistent({ + value: { + language: 'en', + channelDisplayName: '', + channelUserName: '', + currentWatchedTime: 0, + currentViewers: 0, + maxViewers: 0, + currentSubscribers: 0, + currentBits: 0, + currentTips: 0, + currentFollowers: 0, + currentGame: null, + currentTitle: null, + newChatters: 0, + currentTags: [], + contentClasificationLabels: [], + + } as { + language: string; + channelDisplayName: string; + channelUserName: string; + currentWatchedTime: number; + currentViewers: number; + maxViewers: number; + currentSubscribers: number; + currentBits: number; + currentTips: number; + currentFollowers: number; + currentGame: string | null; + currentTitle: string | null; + newChatters: number; + currentTags?: string[]; + contentClasificationLabels?: string[]; + }, + name: 'stats', + namespace: '/core/api', + onChange: (cur) => { + const mapper = new Map([ + ['currentGame', 'game'], + ['language', 'language'], + ['currentViewers', 'viewers'], + ['currentFollowers', 'followers'], + ['currentSubscribers', 'subscribers'], + ['currentBits', 'bits'], + ['currentTitle', 'title'], + ['currentTags', 'tags'], + ]); + Object.keys(cur).forEach((key) => { + const variable = mapper.get(key); + if (variable) { + if ((cur as any)[key] !== old.get(key)) { + eventEmitter.emit(Types.CustomVariableOnChange, variable, (cur as any)[key], old.get(key)); + } + old.set(key, (cur as any)[key]); + } + }); + }, +}); + +export { stats }; \ No newline at end of file diff --git a/backend/src/helpers/api/streamId.ts b/backend/src/helpers/api/streamId.ts new file mode 100644 index 000000000..b6ba43473 --- /dev/null +++ b/backend/src/helpers/api/streamId.ts @@ -0,0 +1,9 @@ +import { persistent } from '../core/persistent.js'; + +const streamId = persistent({ + value: null as null | string, + name: 'streamId', + namespace: '/core/api', +}); + +export { streamId }; \ No newline at end of file diff --git a/backend/src/helpers/api/streamStatusChangeSince.ts b/backend/src/helpers/api/streamStatusChangeSince.ts new file mode 100644 index 000000000..9db3de278 --- /dev/null +++ b/backend/src/helpers/api/streamStatusChangeSince.ts @@ -0,0 +1,9 @@ +import { persistent } from '../core/persistent.js'; + +const streamStatusChangeSince = persistent({ + value: Date.now(), + name: 'streamStatusChangeSince', + namespace: '/core/api', +}); + +export { streamStatusChangeSince }; \ No newline at end of file diff --git a/backend/src/helpers/api/streamType.ts b/backend/src/helpers/api/streamType.ts new file mode 100644 index 000000000..67360e70c --- /dev/null +++ b/backend/src/helpers/api/streamType.ts @@ -0,0 +1,9 @@ +import { persistent } from '../core/persistent.js'; + +const streamType = persistent({ + value: 'live', + name: 'streamType', + namespace: '/core/api', +}); + +export { streamType }; \ No newline at end of file diff --git a/backend/src/helpers/attributesReplace.ts b/backend/src/helpers/attributesReplace.ts new file mode 100644 index 000000000..c0cfb467d --- /dev/null +++ b/backend/src/helpers/attributesReplace.ts @@ -0,0 +1,24 @@ +import { Events } from '@entity/event.js'; + +import { flatten } from './flatten.js'; +import twitch from '../services/twitch.js'; + +const attributesReplace = (attributes: Events.Attributes, replaceIn: string) => { + const atUsername = twitch.showWithAt; + const flattenAttributes = flatten(attributes); + + for (const key of Object.keys(flattenAttributes).sort((a, b) => b.length - a.length)) { + let val = flattenAttributes[key]; + if (typeof val === 'object' && Object.keys(val).length === 0) { + continue; + } // skip empty object + if (key.includes('userName') || key.includes('recipient')) { + val = atUsername ? `@${val}` : val; + } + const replace = new RegExp(`\\$${key}`, 'gi'); + replaceIn = replaceIn.replace(replace, val); + } + return replaceIn; +}; + +export { attributesReplace }; \ No newline at end of file diff --git a/backend/src/helpers/autoLoad.ts b/backend/src/helpers/autoLoad.ts new file mode 100644 index 000000000..1ff7e76c5 --- /dev/null +++ b/backend/src/helpers/autoLoad.ts @@ -0,0 +1,20 @@ +import { readdirSync } from 'fs'; +import { join, resolve } from 'path'; + +export async function autoLoad(directory: string): Promise<{ [x: string]: any }> { + const directoryListing = readdirSync(directory); + const loaded: { [x: string]: any } = {}; + for (const file of directoryListing) { + if (file.startsWith('_') || file.endsWith('.d.ts') || !file.endsWith('.js')) { + continue; + } + const path = resolve(join(process.cwd(), directory, file)); + const imported = await import(`file://${path}`); + if (typeof imported.default !== 'undefined') { + loaded[file.split('.')[0]] = imported.default; // remap default to root object + } else { + loaded[file.split('.')[0]] = imported; + } + } + return loaded; +} \ No newline at end of file diff --git a/backend/src/helpers/checkFilter.ts b/backend/src/helpers/checkFilter.ts new file mode 100644 index 000000000..e79ab64af --- /dev/null +++ b/backend/src/helpers/checkFilter.ts @@ -0,0 +1,228 @@ +import { existsSync, readFileSync } from 'fs'; + +import { EventList } from '@entity/eventList.js'; +import { getTime } from '@sogebot/ui-helpers/getTime.js'; +import gitCommitInfo from 'git-commit-info'; +import _, { sortBy } from 'lodash-es'; +import { In } from 'typeorm'; +import { VM } from 'vm2'; + +import { isStreamOnline, stats } from './api/index.js'; +import { getAll } from './customvariables/index.js'; +import * as changelog from './user/changelog.js'; +import getNameById from './user/getNameById.js'; +import { + isOwner, isSubscriber, isVIP, +} from './user/index.js'; +import { isBot, isBotSubscriber } from './user/isBot.js'; +import { isBroadcaster } from './user/isBroadcaster.js'; +import { isModerator } from './user/isModerator.js'; +import { timer } from '../decorators.js'; +import lastfm from '../integrations/lastfm.js'; +import spotify from '../integrations/spotify.js'; +import ranks from '../systems/ranks.js'; +import songs from '../systems/songs.js'; +import { translate } from '../translate.js'; + +import { CacheGames } from '~/database/entity/cacheGames.js'; +import { AppDataSource } from '~/database.js'; +import { Message } from '~/message.js'; +import { variables as vars } from '~/watchers.js'; + +class HelpersFilter { + @timer() + async checkFilter(opts: CommandOptions | ParserOptions, filter: string): Promise { + if (!opts.sender) { + return true; + } + + const $userObject = await changelog.get(opts.sender.userId); + if (!$userObject) { + changelog.update(opts.sender.userId, { + userId: opts.sender.userId, + userName: opts.sender.userName, + }); + return checkFilter(opts, filter); + } + + const processedFilter = await new Message(await filter as string).parse({ ...opts, sender: opts.sender, forceWithoutAt: true, isFilter: true, param: opts.parameters }); + const toEval = `(function () { return ${processedFilter} })`; + let $rank: string | null = null; + if (ranks.enabled) { + const rank = await ranks.get($userObject); + $rank = typeof rank.current === 'string' || rank.current === null ? rank.current : rank.current.rank; + } + + const $is = { + moderator: isModerator($userObject), + subscriber: isSubscriber($userObject), + vip: isVIP($userObject), + broadcaster: isBroadcaster(opts.sender.userName), + bot: isBot(opts.sender.userName), + owner: isOwner(opts.sender.userName), + newchatter: opts.isFirstTimeMessage, + }; + + const customVariables = await getAll(); + const sandbox = { + $source: typeof opts.discord === 'undefined' ? 'twitch' : 'discord', + $sender: opts.sender.userName, + $is, + $rank, + $haveParam: opts.parameters?.length > 0, + $param: opts.parameters, + // add global variables + ...await this.getGlobalVariables(processedFilter, { sender: opts.sender, discord: opts.discord }), + ...customVariables, + }; + let result = false; + try { + const vm = new VM({ sandbox }); + result = vm.run(toEval)(); + } catch (e: any) { + // do nothing + } + return !!result; // force boolean + } + + @timer() + async getGlobalVariables(message: string, opts: { escape?: string, sender?: CommandOptions['sender'] | { userName: string; userId: string }, discord?: CommandOptions['discord'] }) { + if (!message.includes('$')) { + // message doesn't have any variables + return {}; + } + + const uptime = vars.get('services.twitch.uptime') as number; + + const variables: Record = { + $game: stats.value.currentGame, + $language: stats.value.language, + $viewers: isStreamOnline.value ? stats.value.currentViewers : 0, + $followers: stats.value.currentFollowers, + $subscribers: stats.value.currentSubscribers, + $bits: isStreamOnline.value ? stats.value.currentBits : 0, + $title: stats.value.currentTitle, + $source: opts.sender && typeof opts.discord !== 'undefined' ? 'discord' : 'twitch', + $isBotSubscriber: isBotSubscriber(), + $isStreamOnline: isStreamOnline.value, + $uptime: getTime(Date.now() - uptime, false), + $channelDisplayName: stats.value.channelDisplayName, + $channelUserName: stats.value.channelUserName, + }; + + if (message.includes('$thumbnail') && stats.value.currentGame) { + const gameFromDb = await AppDataSource.getRepository(CacheGames).findOneBy({ name: stats.value.currentGame }); + if (gameFromDb && gameFromDb.thumbnail) { + // replace $thumbnail + variables.$thumbnail = gameFromDb.thumbnail; + + // replace $thumbnail with width and height defined + const regex = /\$thumbnail\((?\d+)x(?\d+)\)/gm; + let m; + + while ((m = regex.exec(message)) !== null) { + // This is necessary to avoid infinite loops with zero-width matches + if (m.index === regex.lastIndex) { + regex.lastIndex++; + } + + if (!m.groups) { + continue; + } + const width = m.groups.width; + const height = m.groups.height; + + variables[`$thumbnail(${width}x${height})`] = gameFromDb.thumbnail + .replace('{width}', width) + .replace('{height}', height); + } + } + } + + if (message.includes('$version')) { + const version = _.get(process, 'env.npm_package_version', 'x.y.z'); + const commitFile = existsSync('./.commit') ? readFileSync('./.commit').toString() : null; + variables.$version = version.replace('SNAPSHOT', commitFile && commitFile.length > 0 ? commitFile : gitCommitInfo().shortHash || 'SNAPSHOT'); + } + + if (message.includes('$latestFollower')) { + const latestFollower = await AppDataSource.getRepository(EventList).findOne({ order: { timestamp: 'DESC' }, where: { event: 'follow' } }); + variables.$latestFollower = !_.isNil(latestFollower) ? await getNameById(latestFollower.userId) : 'n/a'; + } + + // latestSubscriber + if (message.includes('$latestSubscriber')) { + const latestSubscriber = await AppDataSource.getRepository(EventList).findOne({ + order: { timestamp: 'DESC' }, + where: { event: In(['sub', 'resub', 'subgift']) }, + }); + + if (latestSubscriber && (message.includes('$latestSubscriberMonths') || message.includes('$latestSubscriberStreak'))) { + const latestSubscriberUser = await changelog.get(latestSubscriber.userId); + variables.$latestSubscriberMonths = latestSubscriberUser ? String(latestSubscriberUser.subscribeCumulativeMonths) : 'n/a'; + variables.$latestSubscriberStreak = latestSubscriberUser ? String(latestSubscriberUser.subscribeStreak) : 'n/a'; + } + variables.$latestSubscriber = !_.isNil(latestSubscriber) ? await getNameById(latestSubscriber.userId) : 'n/a'; + } + + // latestTip, latestTipAmount, latestTipCurrency, latestTipMessage + if (message.includes('$latestTip')) { + const latestTip = await AppDataSource.getRepository(EventList).findOne({ order: { timestamp: 'DESC' }, where: { event: 'tip', isTest: false } }); + variables.$latestTipAmount = !_.isNil(latestTip) ? parseFloat(JSON.parse(latestTip.values_json).amount).toFixed(2) : 'n/a'; + variables.$latestTipCurrency = !_.isNil(latestTip) ? JSON.parse(latestTip.values_json).currency : 'n/a'; + variables.$latestTipMessage = !_.isNil(latestTip) ? JSON.parse(latestTip.values_json).message : 'n/a'; + variables.$latestTip = !_.isNil(latestTip) ? await getNameById(latestTip.userId) : 'n/a'; + } + + // latestCheer, latestCheerAmount, latestCheerCurrency, latestCheerMessage + if (message.includes('$latestCheer')) { + const latestCheer = await AppDataSource.getRepository(EventList).findOne({ order: { timestamp: 'DESC' }, where: { event: 'cheer' } }); + variables.$latestCheerAmount = !_.isNil(latestCheer) ? JSON.parse(latestCheer.values_json).bits : 'n/a'; + variables.$latestCheerMessage = !_.isNil(latestCheer) ? JSON.parse(latestCheer.values_json).message : 'n/a'; + variables.$latestCheer = !_.isNil(latestCheer) ? await getNameById(latestCheer.userId) : 'n/a'; + } + + const spotifySong = JSON.parse(spotify.currentSong); + if (spotifySong !== null && spotifySong.is_playing && spotifySong.is_enabled) { + // load spotify format + const format = spotify.format; + if (opts.escape) { + spotifySong.song = spotifySong.song.replace(new RegExp(opts.escape, 'g'), `\\${opts.escape}`); + spotifySong.artist = spotifySong.artist.replace(new RegExp(opts.escape, 'g'), `\\${opts.escape}`); + } + variables.$spotifySong = format.replace(/\$song/g, spotifySong.song).replace(/\$artist/g, spotifySong.artist); + } else { + variables.$spotifySong = translate('songs.not-playing'); + } + + variables.$lastfmSong = lastfm.currentSong ? lastfm.currentSong : translate('songs.not-playing'); + + if (songs.enabled + && message.includes('$ytSong') + && Object.values(songs.isPlaying).find(o => o)) { + let currentSong = _.get(JSON.parse(await songs.currentSong), 'title', translate('songs.not-playing')); + if (opts.escape) { + currentSong = currentSong.replace(new RegExp(opts.escape, 'g'), `\\${opts.escape}`); + } + variables.$ytSong = currentSong; + } else { + variables.$ytSong = translate('songs.not-playing'); + } + + const variablesSortedByKey: Record = {}; + sortBy(Object.keys(variables), (b => -b.length)).forEach(val => { + variablesSortedByKey[val] = variables[val]; + }); + + return variablesSortedByKey; + } +} +const cl = new HelpersFilter(); + +export const checkFilter = async (opts: CommandOptions | ParserOptions, filter: string): Promise => { + return cl.checkFilter(opts, filter); +}; + +export const getGlobalVariables = async (message: string, opts: { escape?: string, sender?: CommandOptions['sender'] | { userName: string; userId: string }, discord?: CommandOptions['discord'] }): Promise> => { + return cl.getGlobalVariables(message, opts); +}; diff --git a/backend/src/helpers/commandError.ts b/backend/src/helpers/commandError.ts new file mode 100644 index 000000000..9b36e9bd3 --- /dev/null +++ b/backend/src/helpers/commandError.ts @@ -0,0 +1,29 @@ +class CommandError extends Error { + constructor(message: string) { + // Pass remaining arguments (including vendor specific ones) to parent constructor + super(message); + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, CommandError); + } + + this.name = 'CommandError'; + } +} + +class ResponseError extends Error { + constructor(message: string) { + // Pass remaining arguments (including vendor specific ones) to parent constructor + super(message); + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ResponseError); + } + + this.name = 'ResponseError'; + } +} + +export { CommandError, ResponseError }; diff --git a/backend/src/helpers/commands/count.ts b/backend/src/helpers/commands/count.ts new file mode 100644 index 000000000..ad07ad9de --- /dev/null +++ b/backend/src/helpers/commands/count.ts @@ -0,0 +1,39 @@ +import { CommandsCount } from '@entity/commands.js'; +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; + +import { AppDataSource } from '~/database.js'; + +const count: { command: string, timestamp: string }[] = []; + +setInterval(() => { + const length = count.length; + for (let i = 0; i < length; i++) { + const c = new CommandsCount(); + c.command = count[i].command; + c.timestamp = count[i].timestamp; + c.save(); + } + count.splice(0,length); +}, MINUTE); + +export async function getCountOfCommandUsage (command: string): Promise { + return (await CommandsCount.countBy({ command }) + count.filter(o => o.command === command).length); +} + +export function incrementCountOfCommandUsage (command: string): void { + count.push({ command, timestamp: new Date().toISOString() }); +} + +export async function resetCountOfCommandUsage (command: string): Promise { + CommandsCount.delete({ command }); + count.splice(0,count.length); +} + +export async function getAllCountOfCommandUsage (): Promise<{ command: string; count: number }[]> { + return AppDataSource.getRepository(CommandsCount) + .createQueryBuilder() + .select('command') + .addSelect('COUNT(command)', 'count') + .groupBy('command') + .getRawMany(); +} diff --git a/backend/src/helpers/commons/announce.ts b/backend/src/helpers/commons/announce.ts new file mode 100644 index 000000000..6cb64cdc0 --- /dev/null +++ b/backend/src/helpers/commons/announce.ts @@ -0,0 +1,43 @@ +import { ChannelType, TextChannel } from 'discord.js'; + +import { getUserSender } from './getUserSender.js'; +import { chatOut } from '../log.js'; +import getBotId from '../user/getBotId.js'; +import getBotUserName from '../user/getBotUserName.js'; + +import { variables } from '~/watchers.js'; + +/** + * Announce in all channels (discord, twitch) + * @param messageToAnnounce + * + * announce('Lorem Ipsum Dolor', 'timers); + */ +export const announceTypes = ['bets', 'duel', 'heist', 'timers', 'songs', 'scrim', 'raffles', 'polls', 'general'] as const; +export async function announce(messageToAnnounce: string, type: typeof announceTypes[number], replaceCustomVariables = true) { + const botUsername = variables.get('services.twitch.botUsername') as string; + const botId = variables.get('services.twitch.botId') as string; + + // importing here as we want to get rid of import loops + const Discord = (await import('../../integrations/discord.js') as typeof import('../../integrations/discord')).default; + const Message = (await import('../../message.js') as typeof import('../../message')).Message; + const sendMessage = (await import('./sendMessage.js') as typeof import('./sendMessage')).sendMessage; + + messageToAnnounce = await new Message(messageToAnnounce).parse({ sender: getUserSender(getBotId(), getBotUserName()), replaceCustomVariables, discord: undefined }) as string; + sendMessage(messageToAnnounce, getUserSender(botId, botUsername), { force: true, skip: true }); + + if (Discord.sendAnnouncesToChannel[type].length > 0 && Discord.client) { + // search discord channel by ID + for (const [ id, channel ] of Discord.client.channels.cache) { + if (channel.type === ChannelType.GuildText) { + if (id === Discord.sendAnnouncesToChannel[type] || (channel as TextChannel).name === Discord.sendAnnouncesToChannel[type]) { + const ch = Discord.client.channels.cache.find(o => o.id === id); + if (ch) { + (ch as TextChannel).send(await Discord.replaceLinkedUsernameInMessage(messageToAnnounce)); + chatOut(`#${(ch as TextChannel).name}: ${messageToAnnounce} [${Discord.client.user?.tag}]`); + } + } + } + } + } +} \ No newline at end of file diff --git a/backend/src/helpers/commons/getOwner.ts b/backend/src/helpers/commons/getOwner.ts new file mode 100644 index 000000000..ea2d26fe1 --- /dev/null +++ b/backend/src/helpers/commons/getOwner.ts @@ -0,0 +1,15 @@ +import { variables } from '~/watchers.js'; + +export function getOwner() { + const generalOwners = variables.get('services.twitch.generalOwners') as string[]; + + try { + return generalOwners[0].trim(); + } catch (e: any) { + return ''; + } +} +export function getOwners() { + const generalOwners = variables.get('services.twitch.generalOwners') as string[]; + return generalOwners; +} \ No newline at end of file diff --git a/backend/src/helpers/commons/getOwnerAsSender.ts b/backend/src/helpers/commons/getOwnerAsSender.ts new file mode 100644 index 000000000..de545e206 --- /dev/null +++ b/backend/src/helpers/commons/getOwnerAsSender.ts @@ -0,0 +1,22 @@ +import { getOwner } from './getOwner.js'; + +import { variables } from '~/watchers.js'; + +export function getOwnerAsSender(): Omit { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + return { + isMod: true, + isBroadcaster: true, + isFounder: true, + isSubscriber: true, + isVip: true, + userName: getOwner(), + displayName: getOwner(), + userId: broadcasterId, + badges: new Map(), + color: '#000000', + userType: 'empty', + badgeInfo: new Map(), + isArtist: false, + }; +} \ No newline at end of file diff --git a/backend/src/helpers/commons/getUserSender.ts b/backend/src/helpers/commons/getUserSender.ts new file mode 100644 index 000000000..282ae30f3 --- /dev/null +++ b/backend/src/helpers/commons/getUserSender.ts @@ -0,0 +1,19 @@ +import { isBroadcaster } from '../user/index.js'; + +export function getUserSender(userId: string, username: string): Omit { + return { + isMod: false, + isBroadcaster: isBroadcaster(username), + isFounder: false, + isSubscriber: false, + isVip: false, + userName: username, + displayName: username, + userId: userId, + badges: new Map(), + color: '#000000', + userType: 'empty', + badgeInfo: new Map(), + isArtist: false, + }; +} \ No newline at end of file diff --git a/backend/src/helpers/commons/index.ts b/backend/src/helpers/commons/index.ts new file mode 100644 index 000000000..9b1aa4ef6 --- /dev/null +++ b/backend/src/helpers/commons/index.ts @@ -0,0 +1,6 @@ +export * from './announce.js'; +export * from './getUserSender.js'; +export * from './getOwner.js'; +export * from './getOwnerAsSender.js'; +export * from './isUUID.js'; +export * from './prepare.js'; \ No newline at end of file diff --git a/backend/src/helpers/commons/isUUID.ts b/backend/src/helpers/commons/isUUID.ts new file mode 100644 index 000000000..d6807ca76 --- /dev/null +++ b/backend/src/helpers/commons/isUUID.ts @@ -0,0 +1,4 @@ +export function isUUID(s: string): boolean { + const uuidRegex = /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/; + return s.search(uuidRegex) >= 0; +} diff --git a/backend/src/helpers/commons/prepare.ts b/backend/src/helpers/commons/prepare.ts new file mode 100644 index 000000000..020fab486 --- /dev/null +++ b/backend/src/helpers/commons/prepare.ts @@ -0,0 +1,31 @@ +import { translate } from '../../translate.js'; +import { showWithAt } from '../tmi/showWithAt.js'; + +/** + * Prepares strings with replacement attributes + * @param translate Translation key + * @param attr Attributes to replace { 'replaceKey': 'value' } + * @param isTranslationKey consider if translation key to be translate key or pure message + */ +export function prepare(toTranslate: string, attr?: {[x: string]: any }, isTranslationKey = true): string { + attr = attr || {}; + let msg = (() => { + if (isTranslationKey) { + return translate(toTranslate); + } else { + return toTranslate; + } + })(); + for (const key of Object.keys(attr).sort((a, b) => b.length - a.length)) { + let value = attr[key]; + if (['username', 'who', 'winner', 'sender', 'loser'].includes(key.toLowerCase())) { + if (typeof value.username !== 'undefined' || typeof value.userName !== 'undefined') { + value = showWithAt.value ? `@${value.username || value.userName}` : value.username || value.userName; + } else { + value = showWithAt.value ? `@${value}` : value; + } + } + msg = msg.replace(new RegExp('[$]' + key, 'gi'), value); + } + return msg; +} \ No newline at end of file diff --git a/backend/src/helpers/commons/round5.ts b/backend/src/helpers/commons/round5.ts new file mode 100644 index 000000000..00b9e4237 --- /dev/null +++ b/backend/src/helpers/commons/round5.ts @@ -0,0 +1,6 @@ +/* + * returns nearest 5 + */ +export function round5(x: number) { + return Math.round(x / 5 ) * 5; +} \ No newline at end of file diff --git a/backend/src/helpers/commons/sendMessage.ts b/backend/src/helpers/commons/sendMessage.ts new file mode 100644 index 000000000..a227047b6 --- /dev/null +++ b/backend/src/helpers/commons/sendMessage.ts @@ -0,0 +1,172 @@ +import type { HelixChatAnnouncementColor } from '@twurple/api'; +import _ from 'lodash-es'; + +import { getUserSender } from './getUserSender.js'; +import { timer } from '../../decorators.js'; +import { Message } from '../../message.js'; +import { + chatOut, debug, whisperOut, +} from '../log.js'; +import { + getMuteStatus, message, sendWithMe, showWithAt, +} from '../tmi/index.js'; +import getBotId from '../user/getBotId.js'; +import getBotUserName from '../user/getBotUserName.js'; +import getBroadcasterId from '../user/getBroadcasterId.js'; + +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +const getAnnouncementColor = (command: string): HelixChatAnnouncementColor => { + const color = command.replace('/announce', ''); + if (color.trim().length === 0) { + return 'primary'; + } else { + return color.trim() as HelixChatAnnouncementColor; + } +}; + +// exposing functions to @timer decorator +class HelpersCommons { + @timer() + async sendMessage(messageToSend: string | Promise, sender: Omit | null | { userName: string; userId: string }, attr?: { + sender?: Partial>; + discord?: CommandOptions['discord']; + quiet?: boolean; + skip?: boolean; + forceWithoutAt?: boolean; + force?: boolean; + isWhisper?: boolean; + [x: string]: any; + }, id?: string) { + if (id === 'null') { + id = undefined; + } + messageToSend = await messageToSend as string; // await if messageToSend is promise (like prepare) + attr = attr || {}; + sender = sender || null; + + if (messageToSend.length > 470) { + // splitting message + for (const msg of messageToSend.match(/.{1,470}/g) ?? []) { + await sendMessage(msg, sender, attr, id); + } + return; + } + + if (sendWithMe.value) { + // replace /me in message if we are already sending with /me + messageToSend = messageToSend.replace(/^(\/me)/gi, '').trim(); + } + + debug('sendMessage.message', messageToSend); + debug('commons.sendMessage', JSON.stringify({ + messageToSend, sender, attr, + })); + + if (sender) { + attr.sender = sender; + } + + if (!attr.skip) { + messageToSend = await new Message(messageToSend).parse({ ...attr, sender: attr.sender ? attr.sender as UserStateTagsWithId : getUserSender(getBotId(), getBotUserName()), discord: attr.discord }) as string; + } + if (messageToSend.length === 0) { + return false; + } // if message is empty, don't send anything + + // if sender is null/undefined, we can assume, that userName is from dashboard -> bot + if (!sender && !attr.force) { + return false; + } // we don't want to reply on bot commands + + if (sender) { + messageToSend = !_.isNil(sender.userName) ? messageToSend.replace(/\$sender/g, (showWithAt.value ? '@' : '') + sender.userName) : messageToSend; + if (!getMuteStatus() || attr.force) { + if ((!_.isNil(attr.quiet) && attr.quiet)) { + return true; + } + if (attr.isWhisper) { + whisperOut(`${messageToSend} [${sender.userName}]`); + message('whisper', sender.userName, messageToSend, id); + } else { + chatOut(`${messageToSend} [${sender.userName}]`); + if (sendWithMe.value && !messageToSend.startsWith('/')) { + message('me', sender.userName, messageToSend, id); + } else { + if (messageToSend === '/subscribers') { + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + subscriberOnlyModeEnabled: true, + })); + } else if (messageToSend === '/subscribersoff') { + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + subscriberOnlyModeEnabled: false, + })); + } else if (messageToSend === '/emoteonly') { + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + emoteOnlyModeEnabled: true, + })); + } else if (messageToSend === '/emoteonlyoff') { + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + emoteOnlyModeEnabled: false, + })); + } else if (messageToSend === '/followersoff') { + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + followerOnlyModeEnabled: false, + })); + } else if (messageToSend.includes('/followers')) { + const [, duration] = messageToSend.split(' '); + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + followerOnlyModeEnabled: true, + followerOnlyModeDelay: duration ? Number(duration) : undefined, + })); + } else if (messageToSend === '/slowoff') { + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + slowModeEnabled: true, + })); + } else if (messageToSend.includes('/slow')) { + const [, duration] = messageToSend.split(' '); + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + slowModeEnabled: true, + slowModeDelay: duration ? Number(duration) : undefined, + })); + } else if (messageToSend.startsWith('/announce')) { + // get color + const [ announce, ...messageArray ] = messageToSend.split(' '); + + const botCurrentScopes = variables.get('services.twitch.botCurrentScopes') as string[]; + if (!botCurrentScopes.includes('moderator:manage:announcements')) { + message('say', sender.userName, 'Bot is missing moderator:manage:announcements scope, please reauthorize in dashboard.', id); + return true; + } + + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + const color = getAnnouncementColor(announce); + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.sendAnnouncement(broadcasterId, { + message: messageArray.join(' '), + color, + })); + } else { + message('say', sender.userName, messageToSend, id); + } + } + } + } + return true; + } + + } +} +const self = new HelpersCommons(); + +export async function sendMessage(messageToSend: string | Promise, sender: Omit | null | { userName: string; userId: string }, attr?: { + sender?: Partial>; + quiet?: boolean; + skip?: boolean; + force?: boolean; + isWhisper?: boolean; + forceWithoutAt?: boolean; + [x: string]: any; +}, id?: string) { + return self.sendMessage(messageToSend, sender, attr, id); +} \ No newline at end of file diff --git a/backend/src/helpers/constants.ts b/backend/src/helpers/constants.ts new file mode 100644 index 000000000..0190dccad --- /dev/null +++ b/backend/src/helpers/constants.ts @@ -0,0 +1,20 @@ +export const CONTENT_CLASSIFICATION_LABELS = { + 'MatureGame': { + name: 'Mature-rated game', description: 'Games that are rated Mature or less suitable for a younger audience.', + }, + 'DrugsIntoxication': { + name: 'Drugs, Intoxication, or Excessive Tobacco Use', description: 'Excessive tobacco glorification or promotion, any marijuana consumption/use, legal drug and alcohol induced intoxication, discussions of illegal drugs.', + }, + 'Gambling': { + name: 'Gambling', description: 'Participating in online or in-person gambling, poker or fantasy sports, that involve the exchange of real money.', + }, + 'ProfanityVulgarity': { + name: 'Significant Profanity or Vulgarity', description: 'Prolonged, and repeated use of obscenities, profanities, and vulgarities, especially as a regular part of speech.', + }, + 'SexualThemes': { + name: 'Sexual Themes', description: 'Content that focuses on sexualized physical attributes and activities, sexual topics, or experiences.', + }, + 'ViolentGraphic': { + name: 'Violent and Graphic Depictions', description: 'Simulations and/or depictions of realistic violence, gore, extreme injury, or death.', + }, +}; \ No newline at end of file diff --git a/backend/src/helpers/core/persistent.ts b/backend/src/helpers/core/persistent.ts new file mode 100644 index 000000000..5de2642b8 --- /dev/null +++ b/backend/src/helpers/core/persistent.ts @@ -0,0 +1,86 @@ +import { Settings } from '@entity/settings.js'; +import DeepProxy from 'proxy-deep'; + +import { IsLoadingInProgress, toggleLoadingInProgress } from '../../decorators.js'; +import { isDbConnected } from '../database.js'; +import { debug } from '../log.js'; + +function persistent({ value, name, namespace, onChange }: { value: T, name: string, namespace: string, onChange?: (cur: T) => void }) { + const sym = Symbol(name); + + const proxy = new DeepProxy({ __loaded__: false, value }, { + get(target, prop, receiver) { + if (['toJSON', 'constructor'].includes(String(prop))) { + return JSON.stringify(target.value); + } + + if (prop === '__loaded__' + || typeof prop === 'symbol' + || typeof (target as any)[prop] === 'function') { + return Reflect.get(target, prop, receiver); + } + + try { + const val = Reflect.get(target, prop, receiver); + if (typeof val === 'object' && val !== null) { + return this.nest(val); + } else { + return val; + } + } catch (e: any) { + console.log(e); + return undefined; + } + }, + set(target, prop, receiver) { + if (IsLoadingInProgress(sym) || prop === '__loaded__') { + return Reflect.set(target, prop, receiver); + } + setImmediate(() => save()); + return Reflect.set(target, prop, receiver); + }, + }); + toggleLoadingInProgress(sym); + + async function save() { + if (onChange) { + onChange(proxy.value); + } + debug('persistent.set', `Updating ${namespace}/${name}`); + debug('persistent.set', proxy.value); + + Settings.findOneBy({ namespace, name }) + .then((row) => { + Settings.save({ id: row?.id, namespace, name, value: JSON.stringify(proxy.value) }).then(() => { + debug('persistent.set', `Update done on ${namespace}/${name}`); + }); + }); + } + + async function load() { + if (!isDbConnected) { + setImmediate(() => load()); + return; + } + + try { + debug('persistent.load', `Loading ${namespace}/${name}`); + proxy.value = JSON.parse( + (await Settings.findOneByOrFail({ namespace, name })).value, + ); + } catch (e: any) { + debug('persistent.load', `Data not found, using default value`); + proxy.value = value; + } finally { + toggleLoadingInProgress(sym); + proxy.__loaded__ = true; + debug('persistent.load', `Load done ${namespace}/${name}`); + debug('persistent.load', JSON.stringify(proxy.value, null, 2)); + } + } + load(); + + return proxy; +} + +export { persistent }; \ No newline at end of file diff --git a/backend/src/helpers/core/stream.ts b/backend/src/helpers/core/stream.ts new file mode 100644 index 000000000..34a823c9c --- /dev/null +++ b/backend/src/helpers/core/stream.ts @@ -0,0 +1,91 @@ +import { HelixStream } from '@twurple/api/lib'; + +import { getFunctionList } from '../../decorators/on.js'; +import { chatMessagesAtStart, streamType } from '../api/index.js'; +import { isStreamOnline } from '../api/isStreamOnline.js'; +import { stats } from '../api/stats.js'; +import { streamId } from '../api/streamId.js'; +import { streamStatusChangeSince } from '../api/streamStatusChangeSince.js'; +import { eventEmitter } from '../events/emitter.js'; +import { + error, start as startLog, stop, +} from '../log.js'; +import { linesParsed } from '../parser.js'; +import { find } from '../register.js'; + +import { getGameNameFromId } from '~/services/twitch/calls/getGameNameFromId.js'; +import { variables } from '~/watchers.js'; + +async function start(data: HelixStream) { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + startLog( + `id: ${data.id} | startedAt: ${data.startDate.toISOString()} | title: ${data.title} | game: ${await getGameNameFromId(Number(data.gameId))} | type: ${data.type} | channel ID: ${broadcasterId}`, + ); + + // reset quick stats on stream start + stats.value.currentWatchedTime = 0; + stats.value.maxViewers = 0; + stats.value.newChatters = 0; + stats.value.currentViewers = 0; + stats.value.currentBits = 0; + stats.value.currentTips = 0; + chatMessagesAtStart.value = linesParsed; + + streamStatusChangeSince.value = new Date(data.startDate).getTime(); + streamId.value = data.id; + streamType.value = data.type; + isStreamOnline.value = true; + + eventEmitter.emit('stream-started'); + eventEmitter.emit('command-send-x-times', { reset: true }); + eventEmitter.emit('keyword-send-x-times', { reset: true }); + eventEmitter.emit('every-x-minutes-of-stream', { reset: true }); + + for (const event of getFunctionList('streamStart')) { + const type = !event.path.includes('.') ? 'core' : event.path.split('.')[0]; + const module = !event.path.includes('.') ? event.path.split('.')[0] : event.path.split('.')[1]; + const self = find(type as any, module); + if (self) { + (self as any)[event.fName](); + } else { + error(`streamStart: ${event.path} not found`); + } + } +} + +function end() { + // reset quick stats on stream end + stats.value.currentWatchedTime = 0; + stats.value.maxViewers = 0; + stats.value.newChatters = 0; + stats.value.currentViewers = 0; + stats.value.currentBits = 0; + stats.value.currentTips = 0; + + // stream is really offline + if (isStreamOnline.value) { + // online -> offline transition + stop(''); + streamStatusChangeSince.value = Date.now(); + isStreamOnline.value = false; + eventEmitter.emit('stream-stopped'); + eventEmitter.emit('stream-is-running-x-minutes', { reset: true }); + eventEmitter.emit('number-of-viewers-is-at-least-x', { reset: true }); + + for (const event of getFunctionList('streamEnd')) { + const type = !event.path.includes('.') ? 'core' : event.path.split('.')[0]; + const module = !event.path.includes('.') ? event.path.split('.')[0] : event.path.split('.')[1]; + const self = find(type as any, module); + if (self) { + (self as any)[event.fName](); + } else { + error(`streamEnd: ${event.path} not found`); + } + } + + streamId.value = null; + } + +} + +export { end, start }; \ No newline at end of file diff --git a/backend/src/helpers/currency/exchange.ts b/backend/src/helpers/currency/exchange.ts new file mode 100644 index 000000000..d08bbd6c5 --- /dev/null +++ b/backend/src/helpers/currency/exchange.ts @@ -0,0 +1,31 @@ +import _ from 'lodash-es'; + +import currentRates from './rates.js'; + +import { Currency as CurrencyType } from '~/database/entity/user.js'; +import { warning } from '~/helpers/log.js'; + +const base = 'USD'; + +export default function exchange(value: number, from: CurrencyType, to: CurrencyType, rates?: { [key in CurrencyType]: number }): number { + rates ??= _.cloneDeep(currentRates); + + const valueInBaseCurrency = value / rates[from]; + try { + if (from.toLowerCase().trim() === to.toLowerCase().trim()) { + return Number(value); // nothing to do + } + if (_.isNil(rates[from])) { + throw Error(`${from} code was not found`); + } + if (_.isNil(rates[to]) && to.toLowerCase().trim() !== base.toLowerCase().trim()) { + throw Error(`${to} code was not found`); + } + + return valueInBaseCurrency * rates[to]; + } catch (e: any) { + warning(`Currency exchange error - ${e.message}`); + warning(`Available currencies: ${Object.keys(rates).join(', ')}`); + return Number(value); // don't change rate if code not found + } +} \ No newline at end of file diff --git a/backend/src/helpers/currency/index.ts b/backend/src/helpers/currency/index.ts new file mode 100644 index 000000000..09bf0730c --- /dev/null +++ b/backend/src/helpers/currency/index.ts @@ -0,0 +1,2 @@ +export * from './symbol.js'; +export * from './mainCurrency.js'; \ No newline at end of file diff --git a/backend/src/helpers/currency/mainCurrency.ts b/backend/src/helpers/currency/mainCurrency.ts new file mode 100644 index 000000000..0214b4953 --- /dev/null +++ b/backend/src/helpers/currency/mainCurrency.ts @@ -0,0 +1,14 @@ +import { Currency } from '~/database/entity/user.js'; + +let _mainCurrency: Currency = 'EUR'; + +const mainCurrency = { + set value(value: Currency) { + _mainCurrency = value; + }, + get value() { + return _mainCurrency; + }, +}; + +export { mainCurrency }; \ No newline at end of file diff --git a/backend/src/helpers/currency/rates.ts b/backend/src/helpers/currency/rates.ts new file mode 100644 index 000000000..a2a86d0e9 --- /dev/null +++ b/backend/src/helpers/currency/rates.ts @@ -0,0 +1,171 @@ +export default { + 'AED': 3.672475, + 'AFN': 69.085468, + 'ALL': 94.565455, + 'AMD': 402.749931, + 'ANG': 1.803288, + 'AOA': 827.716, + 'ARS': 356.417919, + 'AUD': 1.522897, + 'AWG': 1.768, + 'AZN': 1.7, + 'BAM': 1.795119, + 'BBD': 2, + 'BDT': 110.811272, + 'BGN': 1.7914, + 'BHD': 0.376925, + 'BIF': 2844.286441, + 'BMD': 1, + 'BND': 1.341141, + 'BOB': 6.914117, + 'BRL': 4.9074, + 'BSD': 1, + 'BTC': 0.000026751787, + 'BTN': 83.333613, + 'BWP': 13.539114, + 'BYN': 3.295977, + 'BZD': 2.01687, + 'CAD': 1.368414, + 'CDF': 2483.388387, + 'CHF': 0.883815, + 'CLF': 0.031555, + 'CLP': 870.73, + 'CNH': 7.139357, + 'CNY': 7.0919, + 'COP': 4066.052113, + 'CRC': 531.064229, + 'CUC': 1, + 'CUP': 25.75, + 'CVE': 101.20606, + 'CZK': 22.329501, + 'DJF': 178.1535, + 'DKK': 6.831002, + 'DOP': 56.887961, + 'DZD': 134.360192, + 'EGP': 30.9, + 'ERN': 15, + 'ETB': 56.181677, + 'EUR': 0.916206, + 'FJD': 2.24075, + 'FKP': 0.796395, + 'GBP': 0.796395, + 'GEL': 2.705, + 'GGP': 0.796395, + 'GHS': 11.952162, + 'GIP': 0.796395, + 'GMD': 67.25, + 'GNF': 8595.540991, + 'GTQ': 7.832879, + 'GYD': 209.332581, + 'HKD': 7.797798, + 'HNL': 24.707438, + 'HRK': 6.903198, + 'HTG': 132.7098, + 'HUF': 348.206428, + 'IDR': 15572.445438, + 'ILS': 3.73076, + 'IMP': 0.796395, + 'INR': 83.329692, + 'IQD': 1309.762509, + 'IRR': 42262.5, + 'ISK': 140.27, + 'JEP': 0.796395, + 'JMD': 155.831499, + 'JOD': 0.7093, + 'JPY': 149.2455, + 'KES': 152.85, + 'KGS': 88.8176, + 'KHR': 4115.576579, + 'KMF': 450.949793, + 'KPW': 900, + 'KRW': 1299.174437, + 'KWD': 0.308205, + 'KYD': 0.833861, + 'KZT': 457.866096, + 'LAK': 20703.607709, + 'LBP': 15038.660407, + 'LKR': 328.909418, + 'LRD': 187.999989, + 'LSL': 18.423112, + 'LYD': 4.812255, + 'MAD': 10.141256, + 'MDL': 17.830168, + 'MGA': 4538.71854, + 'MKD': 56.295621, + 'MMK': 2101.226479, + 'MNT': 3450, + 'MOP': 8.03407, + 'MRU': 39.882149, + 'MUR': 44.15, + 'MVR': 15.4, + 'MWK': 1684.340673, + 'MXN': 17.17434, + 'MYR': 4.679, + 'MZN': 63.850001, + 'NAD': 18.62, + 'NGN': 817.065624, + 'NIO': 36.621967, + 'NOK': 10.708212, + 'NPR': 133.334213, + 'NZD': 1.65307, + 'OMR': 0.384937, + 'PAB': 1, + 'PEN': 3.738103, + 'PGK': 3.779646, + 'PHP': 55.458999, + 'PKR': 285.128286, + 'PLN': 3.99659, + 'PYG': 7445.493469, + 'QAR': 3.647236, + 'RON': 4.554, + 'RSD': 107.577717, + 'RUB': 88.379996, + 'RWF': 1235.966606, + 'SAR': 3.750639, + 'SBD': 8.468008, + 'SCR': 13.373035, + 'SDG': 601, + 'SEK': 10.467462, + 'SGD': 1.33999, + 'SHP': 0.796395, + 'SLL': 20969.5, + 'SOS': 571.817014, + 'SRD': 37.8125, + 'SSP': 130.26, + 'STD': 22281.8, + 'STN': 22.486967, + 'SVC': 8.755312, + 'SYP': 2512.53, + 'SZL': 18.691252, + 'THB': 35.2595, + 'TJS': 10.926196, + 'TMT': 3.5, + 'TND': 3.10375, + 'TOP': 2.371827, + 'TRY': 28.765492, + 'TTD': 6.797551, + 'TWD': 31.5565, + 'TZS': 2498, + 'UAH': 36.081173, + 'UGX': 3788.844846, + 'USD': 1, + 'UYU': 39.430571, + 'UZS': 12296.39059, + 'VES': 35.40681, + 'VND': 24248.304045, + 'VUV': 118.722, + 'WST': 2.8, + 'XAF': 600.991981, + 'XAG': 0.04221636, + 'XAU': 0.00050149, + 'XCD': 2.70255, + 'XDR': 0.751622, + 'XOF': 600.991981, + 'XPD': 0.00094415, + 'XPF': 109.332502, + 'XPT': 0.00108253, + 'YER': 250.299958, + 'ZAR': 18.774125, + 'ZMW': 23.338015, + 'ZWL': 322, +}; \ No newline at end of file diff --git a/backend/src/helpers/currency/symbol.ts b/backend/src/helpers/currency/symbol.ts new file mode 100644 index 000000000..32753c792 --- /dev/null +++ b/backend/src/helpers/currency/symbol.ts @@ -0,0 +1,9 @@ +import getSymbolFromCurrency from 'currency-symbol-map'; + +import { Currency } from '~/database/entity/user.js'; + +function symbol(code: string): Currency { + return getSymbolFromCurrency(code) as Currency; +} + +export { symbol }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/addChangeToHistory.ts b/backend/src/helpers/customvariables/addChangeToHistory.ts new file mode 100644 index 000000000..767a86aa2 --- /dev/null +++ b/backend/src/helpers/customvariables/addChangeToHistory.ts @@ -0,0 +1,17 @@ +import { Variable } from '@entity/variable.js'; + +async function addChangeToHistory(opts: { sender: any; item: Variable; oldValue: any }) { + const variable = await Variable.findOneBy({ id: opts.item.id }); + if (variable) { + variable.history.push({ + username: opts.sender?.username ?? 'n/a', + userId: opts.sender?.userId ?? 0, + oldValue: opts.oldValue, + currentValue: opts.item.currentValue, + changedAt: new Date().toISOString(), + }); + await variable.save(); + } +} + +export { addChangeToHistory }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/executeVariablesInText.ts b/backend/src/helpers/customvariables/executeVariablesInText.ts new file mode 100644 index 000000000..3f19cd90e --- /dev/null +++ b/backend/src/helpers/customvariables/executeVariablesInText.ts @@ -0,0 +1,18 @@ +import { getValueOf } from './getValueOf.js'; +import { isVariableSet } from './isVariableSet.js'; + +const customVariableRegex = new RegExp('\\$_[a-zA-Z0-9_]+', 'g'); + +async function executeVariablesInText(text: string, attr: { sender: { userId: string; username: string; source: 'twitch' | 'discord' }} | null): Promise { + for (const variable of text.match(customVariableRegex)?.sort((a, b) => b.length - a.length) || []) { + const isVariable = await isVariableSet(variable); + let value = ''; + if (isVariable) { + value = await getValueOf(variable, attr) || ''; + } + text = text.replace(new RegExp(`\\${variable}`, 'g'), value); + } + return text; +} + +export { executeVariablesInText }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/getAll.ts b/backend/src/helpers/customvariables/getAll.ts new file mode 100644 index 000000000..4df541227 --- /dev/null +++ b/backend/src/helpers/customvariables/getAll.ts @@ -0,0 +1,11 @@ +import { Variable } from '@entity/variable.js'; + +import { AppDataSource } from '~/database.js'; + +async function getAll() { + return (await AppDataSource.getRepository(Variable).find()).reduce((prev: { [x: string]: any }, cur) => { + return { ...prev, [cur.variableName]: cur.currentValue }; + }, {}); +} + +export { getAll }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/getURL.ts b/backend/src/helpers/customvariables/getURL.ts new file mode 100644 index 000000000..6f15440a3 --- /dev/null +++ b/backend/src/helpers/customvariables/getURL.ts @@ -0,0 +1,28 @@ +import { Variable } from '@entity/variable.js'; + +import { getValueOf } from './getValueOf.js'; + +import { AppDataSource } from '~/database.js'; + +async function getURL(req: any, res: any) { + try { + const variable = (await AppDataSource.getRepository(Variable).find()) + .find(v => { + return v.urls.find(url => url.id === req.params.id); + }); + if (variable) { + if (variable.urls.find(url => url.id === req.params.id)?.GET) { + return res.status(200).send({ value: await getValueOf(variable.variableName) }); + } else { + return res.status(403).send({ error: 'This endpoint is not enabled for GET', code: 403 }); + } + } else { + return res.status(404).send({ error: 'Variable not found', code: 404 }); + } + } catch (e: any) /* istanbul ignore next */ { + res.status(500).send({ error: 'Internal Server Error', code: 500 }); + throw e; + } +} + +export { getURL }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/getValueOf.ts b/backend/src/helpers/customvariables/getValueOf.ts new file mode 100644 index 000000000..a4adef574 --- /dev/null +++ b/backend/src/helpers/customvariables/getValueOf.ts @@ -0,0 +1,34 @@ +import { Variable } from '@entity/variable.js'; +import { isNil } from 'lodash-es'; + +import { runScript } from './runScript.js'; +import { check } from '../permissions/check.js'; + +import { AppDataSource } from '~/database.js'; + +async function getValueOf (variableName: string, opts?: any) { + if (!variableName.startsWith('$_')) { + variableName = `$_${variableName}`; + } + const item = await AppDataSource.getRepository(Variable).findOneBy({ variableName }); + if (!item) { + return ''; + } // return empty if variable doesn't exist + + let currentValue = item.currentValue; + if (item.type === 'eval' && item.runEvery === 0 ) { + // recheck permission as this may go outside of setValueOf + const permissionsAreValid = isNil(opts?.sender) || (await check(opts.sender.userId, item.permission, false)).access; + if (permissionsAreValid) { + currentValue = await runScript(item.evalValue, { + _current: item.currentValue, + ...opts, + }); + await AppDataSource.getRepository(Variable).save({ ...item, currentValue }); + } + } + + return currentValue; +} + +export { getValueOf }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/index.ts b/backend/src/helpers/customvariables/index.ts new file mode 100644 index 000000000..7db7ce3f9 --- /dev/null +++ b/backend/src/helpers/customvariables/index.ts @@ -0,0 +1,11 @@ +export * from './addChangeToHistory.js'; +export * from './executeVariablesInText.js'; +export * from './getAll.js'; +export * from './getURL.js'; +export * from './getValueOf.js'; +export * from './postURL.js'; +export * from './isVariableSet.js'; +export * from './isVariableSetById.js'; +export * from './runScript.js'; +export * from './setValueOf.js'; +export * from './updateWidgetAndTitle.js'; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/isVariableSet.ts b/backend/src/helpers/customvariables/isVariableSet.ts new file mode 100644 index 000000000..a7a2f4a80 --- /dev/null +++ b/backend/src/helpers/customvariables/isVariableSet.ts @@ -0,0 +1,9 @@ +import { Variable } from '@entity/variable.js'; + +import { AppDataSource } from '~/database.js'; + +async function isVariableSet (variableName: string) { + return AppDataSource.getRepository(Variable).findOneBy({ variableName }); +} + +export { isVariableSet }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/isVariableSetById.ts b/backend/src/helpers/customvariables/isVariableSetById.ts new file mode 100644 index 000000000..a11c60a2a --- /dev/null +++ b/backend/src/helpers/customvariables/isVariableSetById.ts @@ -0,0 +1,9 @@ +import { Variable } from '@entity/variable.js'; + +import { AppDataSource } from '~/database.js'; + +async function isVariableSetById (id: string) { + return AppDataSource.getRepository(Variable).findOneBy({ id }); +} + +export { isVariableSetById }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/postURL.ts b/backend/src/helpers/customvariables/postURL.ts new file mode 100644 index 000000000..40d2ed4ad --- /dev/null +++ b/backend/src/helpers/customvariables/postURL.ts @@ -0,0 +1,43 @@ +import { Variable } from '@entity/variable.js'; + +import { setValueOf } from './setValueOf.js'; +import { announce, prepare } from '../commons/index.js'; + +import { AppDataSource } from '~/database.js'; + +async function postURL(req: any, res: any) { + try { + const variable = (await AppDataSource.getRepository(Variable).find()) + .find(v => { + return v.urls.find(url => url.id === req.params.id); + }); + if (variable) { + if (variable.urls.find(url => url.id === req.params.id)?.POST) { + const value = await setValueOf(variable, req.body.value, { sender: null, readOnlyBypass: true }); + if (value.isOk) { + if (variable.urls.find(url => url.id === req.params.id)?.showResponse) { + if (value.updated.responseType === 0) { + announce(prepare('filters.setVariable', { value: value.setValue, variable: variable.variableName }), 'general', false); + } else if (value.updated.responseType === 1) { + if (value.updated.responseText) { + announce(value.updated.responseText.replace('$value', value.setValue), 'general'); + } + } + } + return res.status(200).send({ oldValue: value.oldValue, value: value.setValue }); + } else { + return res.status(400).send({ error: 'This value is not applicable for this endpoint', code: 400 }); + } + } else { + return res.status(403).send({ error: 'This endpoint is not enabled for POST', code: 403 }); + } + } else { + return res.status(404).send({ error: 'Variable not found', code: 404 }); + } + } catch (e: any) /* istanbul ignore next */ { + res.status(500).send({ error: 'Internal Server Error', code: 500 }); + throw e; + } +} + +export { postURL }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/runScript.ts b/backend/src/helpers/customvariables/runScript.ts new file mode 100644 index 000000000..546ce0d23 --- /dev/null +++ b/backend/src/helpers/customvariables/runScript.ts @@ -0,0 +1,204 @@ +import { User } from '@entity/user.js'; +import { getTime } from '@sogebot/ui-helpers/getTime.js'; +import axios from 'axios'; +import _ from 'lodash-es'; +import { + get, isNil, +} from 'lodash-es'; +import { minify } from 'terser'; +import { VM } from 'vm2'; + +import { getAll } from './getAll.js'; +import { Message } from '../../message.js'; +import users from '../../users.js'; +import { + chatMessagesAtStart, isStreamOnline, stats, streamStatusChangeSince, +} from '../api/index.js'; +import { mainCurrency, symbol } from '../currency/index.js'; +import { + debug, error, info, warning, +} from '../log.js'; +import { linesParsed } from '../parser.js'; +import * as changelog from '../user/changelog.js'; +import { isModerator } from '../user/isModerator.js'; +import { getRandomOnlineSubscriber, getRandomOnlineViewer, getRandomSubscriber, getRandomViewer } from '../user/random.js'; + +import { AppDataSource } from '~/database.js'; +import twitch from '~/services/twitch.js'; + +async function runScript (script: string, opts: { sender: { userId: string; userName: string; source: 'twitch' | 'discord' } | string | null, isUI: boolean; param?: string | number, _current: any, parameters?: { [x: string]: any }, variables?: { [x: string]: any } }) { + debug('customvariables.eval', opts); + let sender = !isNil(opts.sender) ? opts.sender : null; + const isUI = !isNil(opts.isUI) ? opts.isUI : false; + const param = !isNil(opts.param) ? opts.param : null; + if (typeof sender === 'string') { + sender = { + userName: sender, + userId: await users.getIdByName(sender), + source: 'twitch', + }; + } + + const minified = await minify(script, { + module: true, + parse: { + bare_returns: true, // allows top-level return + }, + output: { + beautify: true, // beautify output + braces: true, // always insert braces even on one line if + }, + compress: { + loops: false, // disable compressing while(true) -> for(;;) + sequences: false, // disable chaining with commas + }, + }); + + if (!minified.code) { + throw new Error('Error during minify'); + } + + let strippedScript = minified.code; + debug('customvariables.eval', { + strippedScript, + }); + + // get custom variables + const customVariables = await getAll(); + + // update globals and replace theirs values + // we need to escape " as stripped script replaces all ' to " and text containing " may cause issues + strippedScript = (await new Message(strippedScript).global({ escape: '"' })); + + const sandbox = { + waitMs: (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms, null)); + }, + url: async (url: string, urlOpts?: { url: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE', headers: undefined, data: undefined }) => { + if (typeof urlOpts === 'undefined') { + urlOpts = { + url, + method: 'GET', + headers: undefined, + data: undefined, + }; + } else { + urlOpts.url = url; + } + + if (!['GET', 'POST', 'PUT', 'DELETE'].includes(urlOpts.method.toUpperCase())) { + throw Error('only GET, POST, PUT, DELETE methods are supported'); + } + + if (urlOpts.url.trim().length === 0) { + throw Error('url was not properly specified'); + } + + const request = await axios(urlOpts); + return { + data: request.data, status: request.status, statusText: request.statusText, + }; + }, + _: _, + stream: { + uptime: getTime(isStreamOnline.value ? streamStatusChangeSince.value : null, false), + currentViewers: stats.value.currentViewers, + currentSubscribers: stats.value.currentSubscribers, + currentBits: stats.value.currentBits, + currentTips: stats.value.currentTips, + currency: symbol(mainCurrency.value), + chatMessages: (isStreamOnline.value) ? linesParsed - chatMessagesAtStart.value : 0, + currentFollowers: stats.value.currentFollowers, + maxViewers: stats.value.maxViewers, + newChatters: stats.value.newChatters, + game: stats.value.currentGame, + status: stats.value.currentTitle, + currentWatched: stats.value.currentWatchedTime, + }, + sender, + info: info, + warning: warning, + param: param, + parameters: opts.parameters, + variables: opts.variables, + _current: opts._current, + randomOnlineSubscriber: async () => getRandomOnlineSubscriber(), + randomOnlineViewer: async () => getRandomOnlineViewer(), + randomSubscriber: async () => getRandomSubscriber(), + randomViewer: async () => getRandomViewer(), + user: async (userName: string) => { + await changelog.flush(); + const _user = await AppDataSource.getRepository(User).findOneBy({ userName }); + if (_user) { + const userObj = { + userName, + id: String(_user.userId), + displayname: _user.displayname, + is: { + online: _user.isOnline ?? false, + vip: get(_user, 'is.vip', false), + subscriber: get(_user, 'is.subscriber', false), + mod: isModerator(_user), + }, + }; + return userObj; + } else { + try { + // we don't have data of user, we will try to get them + const getUserByName = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUserByName(userName)); + if (!getUserByName) { + return null; + } else { + changelog.update(getUserByName.id, { + userName, + userId: getUserByName.id, + displayname: getUserByName.displayName, + profileImageUrl: getUserByName.profilePictureUrl, + }); + + const userObj = { + userName, + id: getUserByName.id, + displayname: getUserByName.displayName, + is: { + online: false, + vip: false, + subscriber: false, + mod: false, + }, + }; + return userObj; + } + } catch (e: any) { + error(e.stack); + return null; + } + } + }, + ...customVariables, + ...opts.variables, + }; + // we need to add operation counter function + const opCounterFnc = 'let __opCount__ = 0; function __opCounter__() { if (__opCount__ > 100000) { throw new Error("Running script seems to be in infinite loop."); } else { __opCount__++; }};'; + // add __opCounter__() after each ; + const toEval = `(async function () { ${opCounterFnc} ${strippedScript.split(';\n').map(line => '__opCounter__();' + line).join(';')} })`.replace(/\n/g, ''); + try { + const vm = new VM({ sandbox }); + const value = await vm.run(toEval)(); + debug('customvariables.eval', value); + return value; + } catch (e: any) { + debug('customvariables.eval', 'Running script seems to be in infinite loop.'); + error(`Script is causing error:`); + error(`${script}`); + error(e.stack); + if (isUI) { + // if we have UI, rethrow error to show in UI + throw(e); + } else { + return ''; + } + } +} + +export { runScript }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/setValueOf.ts b/backend/src/helpers/customvariables/setValueOf.ts new file mode 100644 index 000000000..43755d3b5 --- /dev/null +++ b/backend/src/helpers/customvariables/setValueOf.ts @@ -0,0 +1,115 @@ +import { Variable } from '@entity/variable.js'; +import { isNil } from 'lodash-es'; + +import { addChangeToHistory } from './addChangeToHistory.js'; +import { getValueOf } from './getValueOf.js'; +import { updateWidgetAndTitle } from './updateWidgetAndTitle.js'; +import users from '../../users.js'; +import { eventEmitter } from '../events/index.js'; +import { warning } from '../log.js'; +import { check } from '../permissions/check.js'; +import { defaultPermissions } from '../permissions/defaultPermissions.js'; +import { get } from '../permissions/get.js'; +import { getUserHighestPermission } from '../permissions/getUserHighestPermission.js'; + +import { Types } from '~/plugins/ListenTo.js'; + +async function setValueOf (variable: string | Variable, currentValue: any, opts: any): Promise<{ updated: Variable; isOk: boolean; setValue: string; oldValue: string, isEval: boolean }> { + const item = typeof variable === 'string' + ? await Variable.findOneBy({ variableName: variable }) + : variable; + let isOk = true; + let isEval = false; + const itemOldValue = item?.currentValue ?? currentValue; + let itemCurrentValue = item?.currentValue; + + opts.sender = isNil(opts.sender) ? null : opts.sender; + opts.readOnlyBypass = isNil(opts.readOnlyBypass) ? false : opts.readOnlyBypass; + // add simple text variable, if not existing + if (!item) { + const newItem = Variable.create({ + variableName: variable as string, + currentValue: String(currentValue), + responseType: 0, + evalValue: '', + description: '', + responseText: '', + usableOptions: [], + type: 'text', + permission: defaultPermissions.MODERATORS, + }); + return setValueOf(newItem, currentValue, opts); + } else { + if (typeof opts.sender === 'string') { + opts.sender = { + userName: opts.sender, + userId: await users.getIdByName(opts.sender), + source: 'twitch', + }; + } + const permissionsAreValid = isNil(opts.sender) || (await check(opts.sender.userId, item.permission, false)).access; + if ((item.readOnly && !opts.readOnlyBypass) || !permissionsAreValid) { + const highestPermission = await getUserHighestPermission(opts.sender.userId); + if (highestPermission) { + const userPermission = await get(highestPermission); + const variablePermission = await get(item.permission); + if (userPermission && variablePermission) { + warning(`User ${opts.sender.userName}#${opts.sender.userId}(${userPermission.name}) doesn't have permission to change variable ${item.variableName}(${variablePermission.name})`); + } + } + isOk = false; + } else { + if (item.type === 'number') { + const match = /(?[+-])([ ]*)?(?\d*)?/g.exec(currentValue); + if (match && match.groups) { + const number = Number((match.groups.number || 1)); + if (match.groups.sign === '+') { + itemCurrentValue = String(Number(itemCurrentValue) + number); + } else if (match.groups.sign === '-') { + itemCurrentValue = String(Number(itemCurrentValue) - number); + } + } else { + const isNumber = isFinite(Number(currentValue)); + isOk = isNumber; + // we need to retype to get rid of +/- + itemCurrentValue = isNumber ? String(Number(currentValue)) : String(Number(itemCurrentValue)); + } + } else if (item.type === 'options') { + // check if is in usableOptions + const isUsableOption = item.usableOptions.map((o) => o.trim()).includes(currentValue); + isOk = isUsableOption; + itemCurrentValue = isUsableOption ? currentValue : itemCurrentValue; + } else if (item.type === 'eval') { + opts.param = currentValue; + itemCurrentValue = await getValueOf(item.variableName, opts); + isEval = true; + } else if (item.type === 'text') { + itemCurrentValue = String(currentValue); + isOk = true; + } + } + } + + // do update only on non-eval variables + if (item.type !== 'eval' && isOk) { + item.currentValue = itemCurrentValue ?? ''; + await item.save(); + } + + const setValue = itemCurrentValue ?? ''; + if (isOk) { + updateWidgetAndTitle(item.variableName); + eventEmitter.emit(Types.CustomVariableOnChange, item.variableName, setValue, itemOldValue); + if (!isEval) { + addChangeToHistory({ + sender: opts.sender, item, oldValue: itemOldValue, + }); + } + } + item.currentValue = isOk && !isEval ? '' : setValue; + return { + updated: item, setValue, oldValue: itemOldValue, isOk, isEval, + }; +} + +export { setValueOf }; \ No newline at end of file diff --git a/backend/src/helpers/customvariables/updateWidgetAndTitle.ts b/backend/src/helpers/customvariables/updateWidgetAndTitle.ts new file mode 100644 index 000000000..3cbd1e211 --- /dev/null +++ b/backend/src/helpers/customvariables/updateWidgetAndTitle.ts @@ -0,0 +1,22 @@ +import { isNil } from 'lodash-es'; + +import { default as custom_variables } from '../../widgets/customvariables.js'; +import { rawStatus } from '../api/index.js'; + +import { updateChannelInfo } from '~/services/twitch/calls/updateChannelInfo.js'; + +async function updateWidgetAndTitle (variable: string | null = null) { + if (custom_variables.socket) { + custom_variables.socket.emit('refresh'); + } // send update to widget + + if (isNil(variable)) { + const regexp = new RegExp(`\\${variable}`, 'ig'); + + if (rawStatus.value.match(regexp)) { + updateChannelInfo({}); + } + } +} + +export { updateWidgetAndTitle }; \ No newline at end of file diff --git a/backend/src/helpers/database.ts b/backend/src/helpers/database.ts new file mode 100644 index 000000000..328510b6e --- /dev/null +++ b/backend/src/helpers/database.ts @@ -0,0 +1,18 @@ +export let isDbConnected = false; +export let isBotStarted = false; + +export async function setIsDbConnected () { + isDbConnected = true; +} + +export async function setIsBotStarted () { + isBotStarted = true; +} + +export function getIsBotStarted () { + return isBotStarted; +} + +export function getIsDbConnected () { + return isDbConnected; +} \ No newline at end of file diff --git a/backend/src/helpers/dayjsHelper.ts b/backend/src/helpers/dayjsHelper.ts new file mode 100644 index 000000000..e1016db1c --- /dev/null +++ b/backend/src/helpers/dayjsHelper.ts @@ -0,0 +1,34 @@ +import dayjs from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat.js'; +import duration from 'dayjs/plugin/duration.js'; +import localizedFormat from 'dayjs/plugin/localizedFormat.js'; +import relativeTime from 'dayjs/plugin/relativeTime.js'; +import { default as tz } from 'dayjs/plugin/timezone.js'; +import utc from 'dayjs/plugin/utc.js'; + +dayjs.extend(utc); +dayjs.extend(tz); +dayjs.extend(relativeTime); +dayjs.extend(localizedFormat); +dayjs.extend(customParseFormat); +dayjs.extend(duration); + +import('dayjs/locale/cs.js'); +import('dayjs/locale/de.js'); +import('dayjs/locale/en.js'); +import('dayjs/locale/fr.js'); +import('dayjs/locale/pt.js'); +import('dayjs/locale/ru.js'); +import('dayjs/locale/uk.js'); + +let timezone: string; +if (typeof process !== 'undefined') { + timezone = (process.env.TIMEZONE ?? 'system') === 'system' || !process.env.TIMEZONE ? dayjs.tz.guess() : process.env.TIMEZONE; +} else { + timezone = dayjs.tz.guess(); +} +export const setLocale = (locale: string) => { + dayjs.locale(locale); +}; + +export { dayjs, timezone }; \ No newline at end of file diff --git a/backend/src/helpers/debounce.ts b/backend/src/helpers/debounce.ts new file mode 100644 index 000000000..a17af7c81 --- /dev/null +++ b/backend/src/helpers/debounce.ts @@ -0,0 +1,29 @@ +const debouncing: { [func: string]: number } = {}; + +export const debounce = async (identification: string, ms = 500): Promise => { + if (debouncing[identification]) { + const shouldBeDeleted = Date.now() - debouncing[identification] > ms + 50; + if (shouldBeDeleted) { + delete debouncing[identification]; + } + } + + const isAlreadyWaiting = typeof debouncing[identification] !== 'undefined'; + debouncing[identification] = Date.now(); + if (isAlreadyWaiting) { + return false; // do nothing after this (we have first waiting function) + } else { + // initial function - waiting for expected ms + return new Promise((resolve) => { + const check = () => { + const shouldBeRun = Date.now() - debouncing[identification] > ms; + if (shouldBeRun) { + resolve(true); + } else { + setTimeout(() => check(), 10); + } + }; + check(); + }); + } +}; \ No newline at end of file diff --git a/backend/src/helpers/debug.ts b/backend/src/helpers/debug.ts new file mode 100644 index 000000000..238a9cc65 --- /dev/null +++ b/backend/src/helpers/debug.ts @@ -0,0 +1,217 @@ +import fs from 'node:fs'; +import { Session } from 'node:inspector'; +import { normalize } from 'node:path'; +import { gzip } from 'zlib'; + +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { v4 } from 'uuid'; + +import { logEmitter as log } from './log/emitter.js'; + +import { variables } from '~/watchers.js'; + +const execCommands = { + 'profiler.5': async () => { + const session = new Session(); + session.connect(); + + session.post('Profiler.enable', () => { + session.post('Profiler.start', () => { + log.emit('warning', 'Profiler start at ' + new Date().toLocaleString() + ' | Expected end at ' + new Date(Date.now() + (5 * MINUTE)).toLocaleString()); + setTimeout(() => { + // some time later... + session.post('Profiler.stop', (err, { profile }) => { + session.disconnect(); + // Write profile to disk, upload, etc. + if (!err) { + gzip(JSON.stringify(profile), (err2, buf) => { + if (err2) { + log.emit('error', err2.stack ?? ''); + } else { + fs.writeFileSync('./logs/profile-' + Date.now() + '.cpuprofile.gz', buf); + log.emit('warning', 'Profiler saved at ./logs/profile-' + Date.now() + '.cpuprofile.gz'); + } + }); + } + }); + }, 5 * MINUTE); + }); + }); + }, + 'profiler.15': async () => { + const session = new Session(); + session.connect(); + + session.post('Profiler.enable', () => { + session.post('Profiler.start', () => { + log.emit('warning', 'Profiler start at ' + new Date().toLocaleString() + ' | Expected end at ' + new Date(Date.now() + (15 * MINUTE)).toLocaleString()); + setTimeout(() => { + // some time later... + session.post('Profiler.stop', (err, { profile }) => { + session.disconnect(); + // Write profile to disk, upload, etc. + if (!err) { + gzip(JSON.stringify(profile), (err2, buf) => { + if (err2) { + log.emit('error', err2.stack ?? ''); + } else { + fs.writeFileSync('./logs/profile-' + Date.now() + '.cpuprofile.gz', buf); + log.emit('warning', 'Profiler saved at ./logs/profile-' + Date.now() + '.cpuprofile.gz'); + } + }); + } + }); + }, 15 * MINUTE); + }); + }); + }, + 'profiler.30': async () => { + const session = new Session(); + session.connect(); + + session.post('Profiler.enable', () => { + session.post('Profiler.start', () => { + log.emit('warning', 'Profiler start at ' + new Date().toLocaleString() + ' | Expected end at ' + new Date(Date.now() + (30 * MINUTE)).toLocaleString()); + setTimeout(() => { + // some time later... + session.post('Profiler.stop', (err, { profile }) => { + session.disconnect(); + // Write profile to disk, upload, etc. + if (!err) { + gzip(JSON.stringify(profile), (err2, buf) => { + if (err2) { + log.emit('error', err2.stack ?? ''); + } else { + fs.writeFileSync('./logs/profile-' + Date.now() + '.cpuprofile.gz', buf); + log.emit('warning', 'Profiler saved at ./logs/profile-' + Date.now() + '.cpuprofile.gz'); + } + }); + } + }); + }, 30 * MINUTE); + }); + }); + }, + 'profiler.60': async () => { + const session = new Session(); + session.connect(); + + session.post('Profiler.enable', () => { + session.post('Profiler.start', () => { + log.emit('warning', 'Profiler start at ' + new Date().toLocaleString() + ' | Expected end at ' + new Date(Date.now() + (60 * MINUTE)).toLocaleString()); + setTimeout(() => { + // some time later... + session.post('Profiler.stop', (err, { profile }) => { + session.disconnect(); + // Write profile to disk, upload, etc. + if (!err) { + gzip(JSON.stringify(profile), (err2, buf) => { + if (err2) { + log.emit('error', err2.stack ?? ''); + } else { + fs.writeFileSync('./logs/profile-' + Date.now() + '.cpuprofile.gz', buf); + log.emit('warning', 'Profiler saved at ./logs/profile-' + Date.now() + '.cpuprofile.gz'); + } + }); + } + }); + }, 60 * MINUTE); + }); + }); + }, + 'heap': async () => { + const session = new Session(); + const filename = normalize(`./logs/profile-${Date.now()}.heapsnapshot`); + const fd = fs.openSync(filename, 'w'); + session.connect(); + + session.on('HeapProfiler.addHeapSnapshotChunk', (m) => { + fs.writeSync(fd, m.params.chunk); + }); + + log.emit('warning', `HeapProfiler.takeHeapSnapshot started`); + session.post('HeapProfiler.takeHeapSnapshot', undefined, (err: any, r: any) => { + log.emit('warning', `HeapProfiler.takeHeapSnapshot done: ${err} ${JSON.stringify(r)}`); + log.emit('warning', `Heap saved at ${filename}`); + session.disconnect(); + fs.closeSync(fd); + }); + }, + 'twitch/clear/broadcaster/credentials': () => { + log.emit('warning', 'Clearing up BROADCASTER twitch credentials'); + variables.set('services.twitch.broadcasterTokenValid', false); + variables.set('services.twitch.broadcasterCurrentScopes', []); + variables.set('services.twitch.broadcasterId', ''); + variables.set('services.twitch.broadcasterUsername', ''); + variables.set('services.twitch.broadcasterRefreshToken', ''); + }, + 'twitch/clear/bot/credentials': () => { + log.emit('warning', 'Clearing up BOT twitch credentials'); + variables.set('services.twitch.botTokenValid', false); + variables.set('services.twitch.botCurrentScopes', []); + variables.set('services.twitch.botId', ''); + variables.set('services.twitch.botUsername', ''); + variables.set('services.twitch.botRefreshToken', ''); + return null; + }, +} as const; + +let debugEnv = ''; +export function isDebugEnabled(category: string) { + if (debugEnv.trim().length === 0) { + return false; + } + const categories = category.split('.'); + let bEnabled = false; + bEnabled = debugEnv.includes(category) || debugEnv.includes(categories[0] + '.*'); + bEnabled = debugEnv === '*' || bEnabled; + return bEnabled; +} + +export const setDEBUG = async (newDebugEnv: string) => { + newDebugEnv = newDebugEnv.trim(); + if (newDebugEnv.startsWith('debug::exec::')) { + handleExec(newDebugEnv.replace('debug::exec::', '') as keyof typeof execCommands); + return; + } + if (newDebugEnv.startsWith('debug::confirm::')) { + handleConfirm(newDebugEnv.replace('debug::confirm::', '')); + return; + } + if (newDebugEnv.trim().length === 0) { + log.emit('warning', 'DEBUG unset'); + } else { + log.emit('warning', 'DEBUG set to: ' + newDebugEnv); + } + debugEnv = newDebugEnv.trim(); +}; +export const getDEBUG = () => { + return debugEnv; +}; + +const registeredCommands: { [x: string]: keyof typeof execCommands} = {}; + +export const handleExec = async (command: keyof typeof execCommands) => { + if (command in execCommands) { + // we need to create confirm command + const uuid = v4(); + registeredCommands[uuid] = command; + log.emit('debug', `Received EXEC ${command}. To confirm, paste into debug input\n\t\ndebug::confirm::${uuid}\n`); + setTimeout(() => { + // delete confirm after minute + delete registeredCommands[uuid]; + }, MINUTE); + } else { + log.emit('error', `Received EXEC ${command} not found`); + } +}; + +export const handleConfirm = async (uuid: string) => { + if (uuid in registeredCommands) { + log.emit('debug', `Received CONFIRM ${uuid}.`); + execCommands[registeredCommands[uuid]](); + delete registeredCommands[uuid]; + } else { + log.emit('error', `Unknown CONFIRM.`); + } +}; diff --git a/backend/src/helpers/errors.ts b/backend/src/helpers/errors.ts new file mode 100644 index 000000000..17f26ca20 --- /dev/null +++ b/backend/src/helpers/errors.ts @@ -0,0 +1,9 @@ +import { ValidationError } from 'class-validator'; +import { isArray } from 'lodash-es'; + +export class UnauthorizedError extends Error {} +export class TokenError extends Error {} + +export function isValidationError(e: unknown): e is ValidationError[] { + return isArray(e) && e.length > 0 && e[0].constraints; +} \ No newline at end of file diff --git a/backend/src/helpers/events/cheer.ts b/backend/src/helpers/events/cheer.ts new file mode 100644 index 000000000..d1999d2be --- /dev/null +++ b/backend/src/helpers/events/cheer.ts @@ -0,0 +1,131 @@ +import util from 'util'; + +import type { EventSubChannelCheerEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelCheerEvent.external'; + +import eventlist from '../../overlays/eventlist.js'; +import alerts from '../../registries/alerts.js'; + +import { eventEmitter } from './index.js'; + +import { parserReply } from '~/commons.js'; +import { Price } from '~/database/entity/price.js'; +import { UserBit, UserBitInterface } from '~/database/entity/user.js'; +import { AppDataSource } from '~/database.js'; +import { isStreamOnline, stats } from '~/helpers/api/index.js'; +import { getUserSender } from '~/helpers/commons/getUserSender.js'; +import { triggerInterfaceOnBit } from '~/helpers/interface/index.js'; +import { cheer as cheerLog, debug, error } from '~/helpers/log.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { isIgnored } from '~/helpers/user/isIgnored.js'; +import { Parser } from '~/parser.js'; +import alias from '~/systems/alias.js'; +import customcommands from '~/systems/customcommands.js'; + +export async function cheer(event: EventSubChannelCheerEventData) { + try { + const username = event.user_login; + const userId = event.user_id; + const message = event.message; + const bits = event.bits; + + // remove X or X from message, but exclude from remove #X or !someCommand2 + const messageFromUser = message.replace(/(? void; + [Types.CustomVariableOnChange]: (variableName: string, cur: any, prev: any) => void; + 'prediction-started': (opts: { + titleOfPrediction: string, + outcomes: string, + locksAt: string, + }) => void; + 'prediction-locked': (opts: { + titleOfPrediction: string, + outcomes: string, + locksAt: string, + }) => void; + 'prediction-ended': (opts: { + titleOfPrediction: string, + outcomes: string, + locksAt: string, + winningOutcomeTitle: string; + winningOutcomeTotalPoints: number; + winningOutcomePercentage: number; + }) => void; + 'poll-started': (opts: { + titleOfPoll: string, + choices: string, + channelPointsVotingEnabled: boolean, + channelPointsAmountPerVote: number, + }) => void; + 'poll-ended': (opts: { + titleOfPoll: string, + choices: string, + votes: number; + winnerVotes: number; + winnerPercentage: number; + winnerChoice: string; + }) => void; + 'hypetrain-started': () => void; + 'hypetrain-ended': (opts: { + level: number, total: number, goal: number, + topContributionsBitsUserId: string; topContributionsBitsUsername: string; topContributionsBitsTotal: number; + topContributionsSubsUserId: string; topContributionsSubsUsername: string; topContributionsSubsTotal: number; + lastContributionType: 'bits' | 'subscription'; lastContributionUserId: string; lastContributionUsername: string; lastContributionTotal: number; + }) => void; + 'hypetrain-level-reached': (opts: { + level: number, total: number, goal: number, + topContributionsBitsUserId: string; topContributionsBitsUsername: string; topContributionsBitsTotal: number; + topContributionsSubsUserId: string; topContributionsSubsUsername: string; topContributionsSubsTotal: number; + lastContributionType: 'bits' | 'subscription'; lastContributionUserId: string; lastContributionUsername: string; lastContributionTotal: number; + }) => void; + 'action': (opts: { userName: string; source: 'discord' | 'twitch' }) => void; + 'commercial': (opts: { duration: number }) => void; + 'game-changed': (opts: {oldGame: string, game: string}) => void; + 'follow': (opts: {userName: string, userId: string}) => void; + 'cheer': (opts: {userName: string, userId: string, bits: number, message: string}) => void; + 'user-joined-channel': (opts: {userName: string}) => void; + 'user-parted-channel': (opts: {userName: string}) => void; + 'subcommunitygift': (opts: {userName: string; count: number}) => void; + 'reward-redeemed': (opts: {userId: string; userName: string; rewardId: string; userInput: string;}) => void; + 'timeout': (opts: {userName: string; duration: number}) => void; + 'ban': (opts: {userName: string; reason: string}) => void; + 'raid': (opts: {userName: string, hostViewers: number, event: string, timestamp: number}) => void; + 'highlight': (opts: {userId: string, message: string}) => void; + 'stream-started': () => void; + 'stream-stopped': () => void; + 'subscription': (opts: { userName: string; method: string; subCumulativeMonths: number; tier: string}) => void; + 'resub': (opts: { userName: string; subStreakShareEnabled: boolean, subStreak: number; subStreakName: string; subCumulativeMonthsName: string; message: string; subCumulativeMonths: number; tier: string}) => void; + 'clearchat': () => void; + 'command-send-x-times': (opts: { reset: boolean } | { userName: string, message: string, source: 'discord' | 'twitch' }) => void; + 'keyword-send-x-times': (opts: { reset: boolean } | { userName: string, message: string, source: 'discord' | 'twitch' }) => void; + 'chatter-first-message': (opts: { userName: string, message: string, source: 'twitch' }) => void; + 'every-x-minutes-of-stream': (opts: { reset: boolean }) => void; + 'stream-is-running-x-minutes': (opts: { reset: boolean }) => void; + 'subgift': (opts: { userName: string; recipient: string; tier: number; }) => void; + 'number-of-viewers-is-at-least-x': (opts: { reset: boolean }) => void; + 'tip': (opts: { isAnonymous: boolean, userName: string, amount: string; currency: string; amountInBotCurrency: string; currencyInBot: string; message: string; }) => void; + // OBS Websocket integration + 'obs-scene-changed': (opts: { sceneName: string, linkFilter: string }) => void; + 'obs-input-mute-state-changed': (opts: { inputName: string; inputMuted: boolean; linkFilter: string }) => void; + // Channel Charity + [Types.onChannelCharityCampaignProgress]: (opts: { + broadcasterDisplayName: string; + broadcasterId: string; + broadcasterName: string; + charityDescription: string; + charityLogo: string; + charityWebsite: string; + charityName: string; + currentAmount: number; + currentAmountCurrency: string; + targetAmount: number; + targetAmountCurrency: string; + }) => void; + [Types.onChannelCharityCampaignStart]: (opts: Parameters[0] & { startDate: string }) => void; + [Types.onChannelCharityCampaignStop]: (opts: Parameters[0] & { endDate: string }) => void; + [Types.onChannelCharityDonation]: (opts: { + broadcasterDisplayName: string; + broadcasterId: string; + broadcasterName: string; + charityDescription: string; + charityLogo: string; + charityWebsite: string; + charityName: string; + campaignId: string; + donorDisplayName: string; + donorId: string; + donorName: string; + amount: number; + amountCurrency: string; + }) => void; + [Types.onChannelGoalBegin]: (opts: { + broadcasterDisplayName: string; + broadcasterId: string; + broadcasterName: string; + currentAmount: number; + description: string; + startDate: string; + targetAmount: number; + type: EventSubChannelGoalType; + }) => void; + [Types.onChannelGoalProgress]: (opts: { + broadcasterDisplayName: string; + broadcasterId: string; + broadcasterName: string; + currentAmount: number; + description: string; + startDate: string; + targetAmount: number; + type: EventSubChannelGoalType; + }) => void; + [Types.onChannelGoalEnd]: (opts: { + broadcasterDisplayName: string; + broadcasterId: string; + broadcasterName: string; + currentAmount: number; + description: string; + startDate: string; + endDate: string; + targetAmount: number; + type: EventSubChannelGoalType; + isAchieved: boolean; + }) => void; + [Types.onChannelModeratorAdd]: (opts: { + broadcasterDisplayName: string; + broadcasterId: string; + broadcasterName: string; + userDisplayName: string; + userId: string; + userName: string; + }) => void; + [Types.onChannelModeratorRemove]: Events[Types.onChannelModeratorAdd]; + [Types.onChannelRewardAdd]: (opts: { + broadcasterDisplayName: string; + broadcasterId: string; + broadcasterName: string; + autoApproved: boolean; + backgroundColor: string; + cooldownExpiryDate: string | null; + cost: number; + globalCooldown: number | null; + id: string; + isEnabled: boolean; + isInStock: boolean; + isPaused: boolean; + maxRedemptionsPerStream: number | null; + maxRedemptionsPerUserPerStream: number | null; + prompt: string; + redemptionsThisStream: number | null; + title: string; + userInputRequired: boolean; + }) => void; + [Types.onChannelRewardRemove]: Events[Types.onChannelRewardAdd]; + [Types.onChannelRewardUpdate]: Events[Types.onChannelRewardAdd]; + [Types.onChannelShieldModeBegin]: (opts: { + broadcasterDisplayName: string, + broadcasterId: string, + broadcasterName: string, + moderatorDisplayName: string, + moderatorId: string, + moderatorName: string, + }) => void; + [Types.onChannelShieldModeEnd]: (opts: Parameters[0] & { endDate: string }) => void; + [Types.onChannelShoutoutCreate]: (opts: { + broadcasterDisplayName: string, + broadcasterId: string, + broadcasterName: string, + moderatorDisplayName: string, + moderatorId: string, + moderatorName: string, + cooldownEndDate: string; + shoutedOutBroadcasterDisplayName: string; + shoutedOutBroadcasterId: string; + shoutedOutBroadcasterName: string; + startDate: string; + viewerCount: number; + }) => void; + [Types.onChannelShoutoutReceive]: (opts: { + broadcasterDisplayName: string, + broadcasterId: string, + broadcasterName: string, + startDate: string; + viewerCount: number; + shoutingOutBroadcasterDisplayName: string, + shoutingOutBroadcasterId: string, + shoutingOutBroadcasterName: string, + }) => void; + [Types.onChannelUpdate]: (opts: { + broadcasterDisplayName: string, + broadcasterId: string, + broadcasterName: string, + categoryId: string; + categoryName: string; + isMature: boolean; + streamLanguage: string; + streamTitle: string; + }) => void; + [Types.onUserUpdate]: (opts: { + userDescription: string; + userDisplayName: string; + userId: string; + userEmail: string | null; + userEmailIsVerified: boolean | null; + userName: string; + }) => void; + [Types.onChannelRaidFrom]: (opts: { + raidedBroadcasterDisplayName: string; + raidedBroadcasterName: string; + raidedBroadcasterId: string; + raidingBroadcasterDisplayName: string; + raidingBroadcasterName: string; + raidingBroadcasterId: string; + viewers: number; + }) => void; + [Types.onChannelRedemptionUpdate]: (opts: { + broadcasterDisplayName: string; + broadcasterId: string; + broadcasterName: string; + id: string; + input: string; + redemptionDate: string; + rewardCost: number; + rewardId: string; + rewardPrompt: string; + rewardTitle: string; + status: string; + userDisplayName: string; + userId: string; + userName: string; + }) => void; +} + +class _EventEmitter extends TypedEmitter {} +const eventEmitter = new _EventEmitter(); + +export { eventEmitter }; diff --git a/backend/src/helpers/events/follow.ts b/backend/src/helpers/events/follow.ts new file mode 100644 index 000000000..5728c2c29 --- /dev/null +++ b/backend/src/helpers/events/follow.ts @@ -0,0 +1,79 @@ +import { EventList } from '@entity/eventList.js'; +import { HOUR } from '@sogebot/ui-helpers/constants.js'; + +import eventlist from '../../overlays/eventlist.js'; +import alerts from '../../registries/alerts.js'; +import { triggerInterfaceOnFollow } from '../interface/index.js'; +import { debug, follow as followLog } from '../log.js'; +import { + isBot, isIgnored, isInGlobalIgnoreList, +} from '../user/index.js'; + +import { eventEmitter } from './index.js'; + +import { AppDataSource } from '~/database.js'; +import banUser from '~/services/twitch/calls/banUser.js'; + +const events = new Map(); + +export async function follow(userId: string, userName: string, followedAt: string) { + if (events.has(userId)) { + debug('follow', `User ${userName}#${userId} already processed.`); + return; + } + events.set(userId, new Date(followedAt).getTime()); + + if (isIgnored({ userName, userId })) { + debug('follow', `User ${userName}#${userId} is in ignore list.`); + if (isInGlobalIgnoreList({ userName, userId })) { + // autoban + autoblock + banUser(userId); + // remove from eventslit + AppDataSource.getRepository(EventList).delete({ userId }); + } + return; + } + + const followAlreadyExists = await AppDataSource.getRepository(EventList).findOneBy({ + userId, event: 'follow', timestamp: new Date(followedAt).getTime(), + }); + + // skip events if already saved in db + if (followAlreadyExists) { + return; + } + + // trigger events only if follow was in hour + if (Date.now() - new Date(followedAt).getTime() < HOUR) { + debug('follow', `User ${userName}#${userId} triggered follow event.`); + eventlist.add({ + event: 'follow', + userId: userId, + timestamp: new Date(followedAt).getTime(), + }); + if (!isBot(userName)) { + followLog(`${userName}#${userId}`); + eventEmitter.emit('follow', { userName, userId }); + alerts.trigger({ + event: 'follow', + name: userName, + amount: 0, + tier: null, + currency: '', + monthsName: '', + message: '', + }); + + triggerInterfaceOnFollow({ + userName, userId, + }); + } + + // cleanup + events.forEach((value, key) => { + if (value + HOUR <= Date.now()) { + events.delete(key); + } + }); + } +} \ No newline at end of file diff --git a/backend/src/helpers/events/index.ts b/backend/src/helpers/events/index.ts new file mode 100644 index 000000000..133032850 --- /dev/null +++ b/backend/src/helpers/events/index.ts @@ -0,0 +1 @@ +export * from './emitter.js'; \ No newline at end of file diff --git a/backend/src/helpers/events/raid.ts b/backend/src/helpers/events/raid.ts new file mode 100644 index 000000000..422db5345 --- /dev/null +++ b/backend/src/helpers/events/raid.ts @@ -0,0 +1,41 @@ +import { EventSubChannelRaidEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelRaidEvent.external'; + +import { eventEmitter } from './emitter.js'; + +import { + raid as raidLog, +} from '~/helpers/log.js'; +import eventlist from '~/overlays/eventlist.js'; +import alerts from '~/registries/alerts.js'; +import users from '~/users.js'; + +export async function raid(event: EventSubChannelRaidEventData) { + const userName = event.from_broadcaster_user_login; + const hostViewers = event.viewers; + + raidLog(`${userName}, viewers: ${hostViewers}`); + + const data = { + userName: userName, + hostViewers, + event: 'raid', + timestamp: Date.now(), + }; + + eventlist.add({ + userId: String(await users.getIdByName(userName) ?? '0'), + viewers: hostViewers, + event: 'raid', + timestamp: Date.now(), + }); + eventEmitter.emit('raid', data); + alerts.trigger({ + event: 'raid', + name: userName, + amount: hostViewers, + tier: null, + currency: '', + monthsName: '', + message: '', + }); +} \ No newline at end of file diff --git a/backend/src/helpers/events/run-command.ts b/backend/src/helpers/events/run-command.ts new file mode 100644 index 000000000..97c063b2d --- /dev/null +++ b/backend/src/helpers/events/run-command.ts @@ -0,0 +1,85 @@ +import { isNil, isObject } from 'lodash-es'; +import _ from 'lodash-es'; +import { v4 } from 'uuid'; + +import { getOwner, getUserSender } from '../commons/index.js'; + +import { parserReply } from '~/commons.js'; +import { + Events as EventsEntity, +} from '~/database/entity/event.js'; +import { debug } from '~/helpers/log.js'; +import { parserEmitter } from '~/helpers/parser/index.js'; +import { Message } from '~/message.js'; +import users from '~/users.js'; + +type data = { + command: string; userName: string, userId: string, timeout: number; isCommandQuiet: boolean, +}; + +const commandsToRun = new Map(); + +export async function fireRunCommand(operation: EventsEntity.OperationDefinitions, attributes: EventsEntity.Attributes) { + const userName = isNil(attributes.userName) ? getOwner() : attributes.userName; + const userId = attributes.userId ? attributes.userId : await users.getIdByName(userName); + operation.timeout ??= 0; + operation.timeoutType ??= 'normal'; + operation.isCommandQuiet ??= false; + + let command = String(operation.commandToRun); + for (const key of Object.keys(attributes).sort((a, b) => a.length - b.length)) { + const val = attributes[key]; + if (isObject(val) && Object.keys(val).length === 0) { + return; + } // skip empty object + const replace = new RegExp(`\\$${key}`, 'gi'); + command = command.replace(replace, val); + } + + if (operation.timeoutType === 'normal') { + debug('events.runCommand', `Adding new command to queue`); + // each event command will be triggered + commandsToRun.set(v4(), { + userName, userId, command, timeout: Date.now() + Number(operation.timeout), isCommandQuiet: operation.isCommandQuiet as boolean, + }); + } else { + const originalCommand = commandsToRun.get(attributes.eventId); + if (operation.timeoutType === 'add') { + debug('events.runCommand', `Adding timeout for ${attributes.eventId}`); + const startTime = originalCommand?.timeout ?? Date.now(); + commandsToRun.set(attributes.eventId, { + userName, userId, command, timeout: startTime + Number(operation.timeout), isCommandQuiet: operation.isCommandQuiet as boolean, + }); + } else if (operation.timeoutType === 'reset') { + debug('events.runCommand', `Reseting timeout for ${attributes.eventId}`); + commandsToRun.set(attributes.eventId, { + userName, userId, command, timeout: Date.now() + Number(operation.timeout), isCommandQuiet: operation.isCommandQuiet as boolean, + }); + } + } +} + +setInterval(async () => { + for (const [eventId, data] of commandsToRun.entries()) { + debug('events.runCommand', `Checking ${eventId}, Time to run '${Date.now() - data.timeout}'`); + if (Date.now() - data.timeout > 0) { + debug('events.runCommand', `Triggering ${eventId}, running '${data.command}'`); + commandsToRun.delete(eventId); + + const command = await new Message(data.command).parse({ userName: data.userName, sender: getUserSender(String(data.userId), data.userName), discord: undefined }); + + parserEmitter.emit('process', { + sender: { userName: data.userName, userId: String(data.userId) }, + message: command, + skip: true, + quiet: data.isCommandQuiet, + }, (responses) => { + for (let i = 0; i < responses.length; i++) { + setTimeout(async () => { + parserReply(await responses[i].response, { sender: responses[i].sender, discord: responses[i].discord, attr: responses[i].attr, id: '' }); + }, 500 * i); + } + }); + } + } +}, 100); \ No newline at end of file diff --git a/backend/src/helpers/events/subscription.ts b/backend/src/helpers/events/subscription.ts new file mode 100644 index 000000000..73d58eeae --- /dev/null +++ b/backend/src/helpers/events/subscription.ts @@ -0,0 +1,90 @@ +import util from 'util'; + +import { ChatSubInfo } from '@twurple/chat'; + +import eventlist from '../../overlays/eventlist.js'; +import alerts from '../../registries/alerts.js'; +import { triggerInterfaceOnSub } from '../interface/index.js'; +import { error, sub } from '../log.js'; +import { + isIgnored, +} from '../user/index.js'; + +import { eventEmitter } from './index.js'; + +import { EmitData } from '~/database/entity/alert.js'; +import * as hypeTrain from '~/helpers/api/hypeTrain.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import getUserByName from '~/services/twitch/calls/getUserByName.js'; + +export const subscription = async (username: string , subInfo: ChatSubInfo, userstate: ChatUser) => { + try { + const amount = subInfo.months; + const tier = (subInfo.isPrime ? 'Prime' : String(Number(subInfo.plan ?? 1000) / 1000)) as EmitData['tier']; + + if (isIgnored({ userName: username, userId: userstate.userId })) { + return; + } + + const user = await changelog.get(userstate.userId); + if (!user) { + changelog.update(userstate.userId, { userName: username }); + subscription(username, subInfo, userstate); + return; + } + + let profileImageUrl = null; + if (user.profileImageUrl.length === 0) { + const res = await getUserByName(username); + if (res) { + profileImageUrl = res.profilePictureUrl; + } + } + + changelog.update(user.userId, { + ...user, + isSubscriber: user.haveSubscriberLock ? user.isSubscriber : true, + subscribedAt: user.haveSubscribedAtLock ? user.subscribedAt : new Date().toISOString(), + subscribeTier: String(tier), + subscribeCumulativeMonths: amount, + subscribeStreak: 0, + profileImageUrl: profileImageUrl ? profileImageUrl : user.profileImageUrl, + }); + + hypeTrain.addSub({ + username: user.userName, + profileImageUrl: profileImageUrl ? profileImageUrl : user.profileImageUrl, + }); + + eventlist.add({ + event: 'sub', + tier: String(tier), + userId: String(userstate.userId), + method: subInfo.isPrime ? 'Twitch Prime' : '' , + timestamp: Date.now(), + }); + sub(`${username}#${userstate.userId}, tier: ${tier}`); + eventEmitter.emit('subscription', { + userName: username, method: subInfo.isPrime ? 'Twitch Prime' : '', subCumulativeMonths: amount, tier: String(tier), + }); + alerts.trigger({ + event: 'sub', + name: username, + amount: 0, + tier, + currency: '', + monthsName: '', + message: '', + }); + + triggerInterfaceOnSub({ + userName: username, + userId: userstate.userId, + subCumulativeMonths: amount, + }); + } catch (e: any) { + error('Error parsing subscription event'); + error(util.inspect(userstate)); + error(e.stack); + } +}; \ No newline at end of file diff --git a/backend/src/helpers/flatten.ts b/backend/src/helpers/flatten.ts new file mode 100644 index 000000000..88f5e0bc3 --- /dev/null +++ b/backend/src/helpers/flatten.ts @@ -0,0 +1,47 @@ +/* + * Flatten object keys + * { a: { b: 'c' }} => { 'a.b': 'c' } + */ +export function flatten(data: {[x: string]: any}): { [x: string]: any } { + const result: {[x: string]: any} = {}; + function recurse(cur: {[x: string]: any}, prop: string): void { + if (Object(cur) !== cur || Array.isArray(cur)) { + result[prop] = cur; + } else { + let isEmpty = true; + for (const p of Object.keys(cur)) { + isEmpty = false; + recurse(cur[p], prop ? prop + '.' + p : p); + } + if (isEmpty && prop) { + result[prop] = {}; + } + } + } + recurse(data, ''); + return result; +} + +/* + * Unflatten object keys + * { 'a.b': 'c' } => { a: { b: 'c' }} + */ +export function unflatten(data: {[x: string]: any}): {[x: string]: any} { + let result: {[x: string]: any}; + if (Array.isArray(data)) { + result = []; + // create unflatten each item + for (const o of data) { + result.push(unflatten(o)); + } + } else { + result = {}; + for (const i of Object.keys(data)) { + const keys = i.split('.'); + keys.reduce((r, e, j) => { + return r[e] || (r[e] = isNaN(Number(keys[j + 1])) ? (keys.length - 1 === j ? data[i] : {}) : []); + }, result); + } + } + return result; +} \ No newline at end of file diff --git a/backend/src/helpers/general/index.ts b/backend/src/helpers/general/index.ts new file mode 100644 index 000000000..bdfc5545a --- /dev/null +++ b/backend/src/helpers/general/index.ts @@ -0,0 +1 @@ +export * from './setValue.js'; \ No newline at end of file diff --git a/backend/src/helpers/general/setValue.ts b/backend/src/helpers/general/setValue.ts new file mode 100644 index 000000000..a4c6e76d1 --- /dev/null +++ b/backend/src/helpers/general/setValue.ts @@ -0,0 +1,50 @@ +import { + isBoolean, isNumber, isString, +} from 'lodash-es'; + +import { find } from '../register.js'; + +async function setValue(opts: CommandOptions) { + // get value so we have a type + const splitted = opts.parameters.split(' '); + const pointer = splitted.shift(); + let newValue = splitted.join(' '); + if (!pointer) { + return [{ response: `$sender, settings does not exists`, ...opts }]; + } + + const [ type, module ] = pointer.split('.'); + const self = find(type as any, module); + if (!self) { + throw new Error(`${type}.${module} not found in list`); + } + + const currentValue = (self as any)[pointer.split('.')[2]]; + if (typeof currentValue !== 'undefined') { + if (isBoolean(currentValue)) { + newValue = newValue.toLowerCase().trim(); + if (['true', 'false'].includes(newValue)) { + (self as any)[pointer.split('.')[2]] = newValue === 'true'; + return [{ response: `$sender, ${pointer} set to ${newValue}`, ...opts }]; + } else { + return [{ response: `$sender, !set error: bool is expected`, ...opts }]; + } + } else if (isNumber(currentValue)) { + if (isFinite(Number(newValue))) { + (self as any)[pointer.split('.')[2]] = Number(newValue); + return [{ response: `$sender, ${pointer} set to ${newValue}`, ...opts }]; + } else { + return [{ response: `$sender, !set error: number is expected`, ...opts }]; + } + } else if (isString(currentValue)) { + (self as any)[pointer.split('.')[2]] = newValue; + return [{ response: `$sender, ${pointer} set to '${newValue}'`, ...opts }]; + } else { + return [{ response: `$sender, ${pointer} is not supported settings to change`, ...opts }]; + } + } else { + return [{ response: `$sender, ${pointer} settings not exists`, ...opts }]; + } +} + +export { setValue }; \ No newline at end of file diff --git a/backend/src/helpers/getAllOnlineUsernames.ts b/backend/src/helpers/getAllOnlineUsernames.ts new file mode 100644 index 000000000..414cc7164 --- /dev/null +++ b/backend/src/helpers/getAllOnlineUsernames.ts @@ -0,0 +1,19 @@ +import { User } from '@entity/user.js'; + +import { AppDataSource } from '~/database.js'; +import * as changelog from '~/helpers/user/changelog.js'; + +export const getAllOnlineUsernames = async () => { + await changelog.flush(); + return (await AppDataSource.getRepository(User).find({ where: { isOnline: true } })).map(o => o.userName); +}; + +export const getAllOnlineIds = async () => { + await changelog.flush(); + return (await AppDataSource.getRepository(User).find({ where: { isOnline: true } })).map(o => o.userId); +}; + +export const getAllOnline = async () => { + await changelog.flush(); + return await AppDataSource.getRepository(User).find({ where: { isOnline: true } }); +}; \ No newline at end of file diff --git a/backend/src/helpers/getFunctionName.ts b/backend/src/helpers/getFunctionName.ts new file mode 100644 index 000000000..b4bc5cba6 --- /dev/null +++ b/backend/src/helpers/getFunctionName.ts @@ -0,0 +1,5 @@ +export function getFunctionName() { + const stackLine = (new Error())!.stack!.split('\n')[2].trim(); + const fncName = stackLine.match(/at Object.([^ ]+)/)?.[1]; + return fncName; +} \ No newline at end of file diff --git a/backend/src/helpers/getMigrationType.ts b/backend/src/helpers/getMigrationType.ts new file mode 100644 index 000000000..dc7e7b447 --- /dev/null +++ b/backend/src/helpers/getMigrationType.ts @@ -0,0 +1,13 @@ +function getMigrationType(type: string) { + switch(type) { + case 'mysql': + case 'mariadb': + return 'mysql'; + case 'postgres': + return 'postgres'; + default: + return 'sqlite'; + } +} + +export { getMigrationType }; \ No newline at end of file diff --git a/backend/src/helpers/goals/recountIntervals.ts b/backend/src/helpers/goals/recountIntervals.ts new file mode 100644 index 000000000..3366a4331 --- /dev/null +++ b/backend/src/helpers/goals/recountIntervals.ts @@ -0,0 +1,74 @@ +import { EventList } from '@entity/eventList.js'; +import { Goal, Overlay } from '@entity/overlay.js'; +import { UserBit, UserTip } from '@entity/user.js'; +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import { In, MoreThanOrEqual } from 'typeorm'; + +import { mainCurrency } from '../currency/index.js'; +import { isBotStarted } from '../database.js'; + +import { AppDataSource } from '~/database.js'; +import exchange from '~/helpers/currency/exchange.js'; + +export const types = ['bits', 'tips', 'followers', 'subscribers'] as const; + +const interval = { + 'hour': constants.HOUR, + 'day': constants.DAY, + 'week': 7 * constants.DAY, + 'month': 31 * constants.DAY, + 'year': 365 * constants.DAY, +} as const; + +export async function recountIntervals() { + const overlays = await Overlay.find(); + for (const overlay of overlays) { + let isChanged = false; + const goals = overlay.items.filter(o => o.opts.typeId === 'goal'); + for (const goal of goals) { + goal.opts = goal.opts as Goal; + for (const campaign of goal.opts.campaigns ?? []) { + if (campaign.type === 'intervalBits') { + isChanged = true; + const events = await AppDataSource.getRepository(UserBit).findBy({ cheeredAt: MoreThanOrEqual(Date.now() - interval[campaign.interval!]) }); + campaign.currentAmount = events.reduce((prev, cur) => { + return prev += cur.amount; + }, 0); + } else if (campaign.type === 'intervalTips') { + isChanged = true; + const events = await AppDataSource.getRepository(UserTip).findBy({ tippedAt: MoreThanOrEqual(Date.now() - interval[campaign.interval!]) }); + if (!campaign.countBitsAsTips) { + campaign.currentAmount = events.reduce((prev, cur) => { + return prev += cur.amount; + }, 0); + } else { + const events2 = await AppDataSource.getRepository(UserBit).findBy({ cheeredAt: MoreThanOrEqual(Date.now() - interval[campaign.interval!]) }); + campaign.currentAmount = events.reduce((prev, cur) => { + return prev += cur.sortAmount; + }, 0) + events2.reduce((prev, cur) => { + return prev += Number(exchange(cur.amount / 100, 'USD', mainCurrency.value)); + }, 0); + } + } else if (campaign.type === 'intervalFollowers') { + campaign.currentAmount = await AppDataSource.getRepository(EventList).countBy({ + timestamp: MoreThanOrEqual(Date.now() - interval[campaign.interval!]), + event: 'follow', + }); + } else if (campaign.type === 'intervalSubscribers') { + campaign.currentAmount = await AppDataSource.getRepository(EventList).countBy({ + timestamp: MoreThanOrEqual(Date.now() - interval[campaign.interval!]), + event: In(['sub', 'resub']), + }); + } + } + } + isChanged && await overlay.save(); + } +} + +setInterval(async () => { + if (isBotStarted) { + await recountIntervals(); + } +}, (5 * MINUTE)); \ No newline at end of file diff --git a/backend/src/helpers/interface/enabled.ts b/backend/src/helpers/interface/enabled.ts new file mode 100644 index 000000000..04c2f906c --- /dev/null +++ b/backend/src/helpers/interface/enabled.ts @@ -0,0 +1,20 @@ +const _value: string[] = []; + +const enabled = { + enable(value: string) { + if (!_value.includes(value)) { + _value.push(value); + } + }, + disable(value: string) { + const idx = _value.findIndex(o => o === value); + if (idx > -1) { + _value.splice(idx, 1); + } + }, + status(value: string) { + return _value.includes(value); + }, +}; + +export { enabled }; \ No newline at end of file diff --git a/backend/src/helpers/interface/index.ts b/backend/src/helpers/interface/index.ts new file mode 100644 index 000000000..3a5a5873b --- /dev/null +++ b/backend/src/helpers/interface/index.ts @@ -0,0 +1,2 @@ +export * from './enabled.js'; +export * from './triggers.js'; \ No newline at end of file diff --git a/backend/src/helpers/interface/triggers.ts b/backend/src/helpers/interface/triggers.ts new file mode 100644 index 000000000..7aa64e7cf --- /dev/null +++ b/backend/src/helpers/interface/triggers.ts @@ -0,0 +1,40 @@ +import { getFunctionList } from '../../decorators/on.js'; +import { debug } from '../log.js'; +import { find } from '../register.js'; + +export function triggerInterfaceOnMessage(opts: onEventMessage) { + trigger(opts, 'message'); +} + +export function triggerInterfaceOnSub(opts: onEventSub) { + trigger(opts, 'sub'); +} + +export function triggerInterfaceOnFollow(opts: onEventFollow) { + trigger(opts, 'follow'); +} + +export function triggerInterfaceOnTip(opts: onEventTip) { + trigger(opts, 'tip'); +} + +export function triggerInterfaceOnBit(opts: Omit) { + trigger(opts, 'bit'); +} + +function trigger(opts: onEventMessage | onEventSub | onEventBit | onEventTip | onEventFollow, on: 'bit' | 'tip' | 'sub' | 'follow' | 'message') { + debug('trigger', `event ${on}`); + + for (const event of getFunctionList(on)) { + const [ type, name ] = event.path.split('.'); + const self = find(type as any, name); + if (!self) { + throw new Error(`${type}.${name} not found in list`); + } + + if (typeof (self as any)[event.fName] === 'function') { + debug('trigger', `event ${on} => ${self.__moduleName__}`); + (self as any)[event.fName](opts); + } + } +} \ No newline at end of file diff --git a/backend/src/helpers/interfaceEmitter.ts b/backend/src/helpers/interfaceEmitter.ts new file mode 100644 index 000000000..bf72024a3 --- /dev/null +++ b/backend/src/helpers/interfaceEmitter.ts @@ -0,0 +1,16 @@ +import { TypedEmitter } from 'tiny-typed-emitter'; + +interface Events { + 'services::twitch::emotes': (type: 'explode' | 'firework', emotes: string[]) => void, + + 'change': (path: string, value: any) => void, + 'load': (path: string, value: any) => void, + + 'set': (nsp: string, variableName: string, value: unknown, cb?: () => void) => void, +} + +class interfaceEmitter extends TypedEmitter {} +const emitter = new interfaceEmitter(); +emitter.setMaxListeners(100); + +export default emitter; \ No newline at end of file diff --git a/backend/src/helpers/locales.ts b/backend/src/helpers/locales.ts new file mode 100644 index 000000000..0aaa8fc14 --- /dev/null +++ b/backend/src/helpers/locales.ts @@ -0,0 +1,14 @@ +import { setLocale } from './dayjsHelper.js'; + +let _lang = 'en'; + +function setLang(lang: string) { + _lang = lang; + setLocale(lang); +} + +function getLang() { + return _lang; +} + +export { setLang, getLang }; \ No newline at end of file diff --git a/backend/src/helpers/log.ts b/backend/src/helpers/log.ts new file mode 100644 index 000000000..03c8e63ad --- /dev/null +++ b/backend/src/helpers/log.ts @@ -0,0 +1,249 @@ +import fs from 'fs'; +import os from 'os'; +import util from 'util'; + +import { dayjs, timezone } from '@sogebot/ui-helpers/dayjsHelper.js'; +import { createStream, Generator } from 'rotating-file-stream'; +import stripAnsi from 'strip-ansi'; + +import { isDebugEnabled } from './debug.js'; +import { logEmitter } from './log/emitter.js'; + +import { isDbConnected } from '~/helpers/database.js'; + +let sinon; +try { + // Attempt to import sinon as it is only dev dependency + sinon = await import('sinon'); +} catch { + sinon = null; +} + +const isMochaTestRun = () => typeof (global as any).it === 'function'; + +const logDir = './logs'; + +if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir); +} + +const logLevel = process.env.LOGLEVEL ? process.env.LOGLEVEL.toLowerCase().trim() : 'info'; +const migrationFileName: Generator = (time: Date | number, index?: number) => { + if (!time) { + return './logs/migration.log'; + } + return `./logs/migration.log.${(index ?? 1)-1}.gz`; +}; +const logFileName: Generator = (time: Date | number, index?: number) => { + if (!time) { + return './logs/sogebot.log'; + } + return `./logs/sogebot.log.${(index ?? 1)-1}.gz`; +}; +const perfFileName: Generator = (time: Date | number, index?: number) => { + if (!time) { + return './logs/performance.log'; + } + return `./logs/performance.log.${(index ?? 1)-1}.gz`; +}; +const migrationFile = createStream(migrationFileName, { + size: '5M', + compress: 'gzip', + maxFiles: 5, +}); +const logFile = createStream(logFileName, { + size: '5M', + compress: 'gzip', + maxFiles: 5, +}); +const perfFile = createStream(perfFileName, { + size: '5M', + compress: 'gzip', + maxFiles: 5, +}); + +perfFile.write(`====================== ${dayjs().tz(timezone).format('YYYY-MM-DD[T]HH:mm:ss.SSS')} ======================\n`); + +// until https://github.com/typescript-eslint/typescript-eslint/pull/1898 fixed +/* eslint-disable */ +enum Levels { + debug, + error, + chatIn, + chatOut, + whisperIn, + whisperOut, + raid, + follow, + cheer, + tip, + sub, + subgift, + subcommunitygift, + resub, + redeem, + timeout, + ban, + unban, + warning, + start, + stop, + info, +}; +/* eslint-enable */ + +const levelFormat = { + error: '!!! ERROR !!!', + debug: 'DEBUG:', + chatIn: '<<<', + chatOut: '>>>', + whisperIn: 'w>', + info: '|', + warning: '|!', + timeout: '+timeout', + ban: '+ban', + unban: '-ban', + follow: '+follow', + raid: '+raid', + redeem: '+++ redeem:', + cheer: '+cheer', + tip: '+tip', + sub: '+sub', + subgift: '+subgift', + subcommunitygift: '+subcommunitygift', + resub: '+resub', + start: '== STREAM STARTED =>', + stop: '== STREAM STOPPED', +}; + +function format(level: Levels, message: any, category?: string) { + const timestamp = dayjs().tz(timezone).format('YYYY-MM-DD[T]HH:mm:ss.SSS'); + + if (typeof message === 'object') { + message = util.inspect(message); + } + return [timestamp, levelFormat[Levels[level] as keyof typeof Levels], category, message].filter(Boolean).join(' '); +} + +function log(message: any, level: keyof typeof Levels) { + if (Levels[level] <= Levels[logLevel as keyof typeof Levels]) { + const formattedMessage = format(Levels[level as keyof typeof Levels], message); + process.stdout.write(formattedMessage + '\n'); + logFile.write(stripAnsi(formattedMessage) + os.EOL); + } +} + +logEmitter.on('debug', (message: string) => { + log(message, 'debug'); +}); + +logEmitter.on('warning', (message: string) => { + log(message, 'warning'); +}); + +logEmitter.on('error', (message: string) => { + log(message, 'error'); +}); + +export function performance(message:string) { + if (isDebugEnabled('performance')) { + process.stdout.write(message.replace(/ /g, '\t') + '\n'); + } + perfFile.write(dayjs().tz(timezone).format('YYYY-MM-DD[T]HH:mm:ss.SSS') + ' ' + (message.replace(/ /g, '\t')) + os.EOL); +} + +export function error(message: any) { + // we have custom typeorm logger to show QueryFailedError + // stack from those errors are not usable so we don't need it + if (typeof message !== 'string' || (typeof message === 'string' && !message.startsWith('QueryFailedError: '))) { + log(message, 'error'); + } +} + +export function chatIn(message: any) { + log(message, 'chatIn'); +} + +export const chatOut = isMochaTestRun() && sinon ? sinon.stub() : (message: any) => { + log(message, 'chatOut'); +}; +export const warning = isMochaTestRun() && sinon ? sinon.stub() : (message: any) => { + log(message, 'warning'); +}; +export const debug = isMochaTestRun() && sinon ? sinon.stub() : (category: string, message: any) => { + const categories = category.split('.'); + if (categories.length > 2 && category !== '*') { + throw Error('For debug use only
. or *'); + } + if (isDebugEnabled(category) || category == '*') { + const formattedMessage = format(Levels.debug, message, category); + process.stdout.write(formattedMessage + '\n'); + logFile.write(formattedMessage + os.EOL); + } +}; + +export function whisperIn(message: any) { + log(message, 'whisperIn'); +} +export function whisperOut(message: any) { + log(message, 'whisperOut'); +} +export function info(message: any) { + log(message, 'info'); +} +export function timeout(message: any) { + log(message, 'timeout'); +} +export function ban(message: any) { + log(message, 'ban'); +} +export function unban(message: any) { + log(message, 'unban'); +} +export function follow(message: any) { + log(message, 'follow'); +} +export function raid(message: any) { + log(message, 'raid'); +} +export function cheer(message: any) { + log(message, 'cheer'); +} +export function tip(message: any) { + log(message, 'tip'); +} +export function sub(message: any) { + log(message, 'sub'); +} +export function subgift(message: any) { + log(message, 'subgift'); +} +export function subcommunitygift(message: any) { + log(message, 'subcommunitygift'); +} +export function resub(message: any) { + log(message, 'resub'); +} +export function start(message: any) { + log(message, 'start'); +} +export function stop(message: any) { + log(message, 'stop'); +} +export function redeem(message: any) { + log(message, 'redeem'); +} + +const logTimezone = async () => { + if (!isDbConnected) { + setTimeout(() => logTimezone(), 10); + } else { + info(`Bot timezone set to ${timezone}`); + } +}; +logTimezone(); + +export { + migrationFile, +}; \ No newline at end of file diff --git a/backend/src/helpers/log/emitter.ts b/backend/src/helpers/log/emitter.ts new file mode 100644 index 000000000..d918f1c8f --- /dev/null +++ b/backend/src/helpers/log/emitter.ts @@ -0,0 +1,12 @@ +import { TypedEmitter } from 'tiny-typed-emitter'; + +interface Events { + 'debug': (message: string) => void + 'warning': (message: string) => void + 'error': (message: string) => void +} + +class _logEmitter extends TypedEmitter {} +const logEmitter = new _logEmitter(); + +export { logEmitter }; diff --git a/backend/src/helpers/logTypeorm.ts b/backend/src/helpers/logTypeorm.ts new file mode 100644 index 000000000..a98b56553 --- /dev/null +++ b/backend/src/helpers/logTypeorm.ts @@ -0,0 +1,44 @@ +import { Logger, QueryRunner } from 'typeorm'; + +import { error as errorLog } from '~/helpers/log.js'; + +export class TypeORMLogger implements Logger { + /** + * Logs query and parameters used in it. + */ + logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) { + return; + } + /** + * Logs query that is failed. + */ + logQueryError(error: string | Error, query: string, parameters?: any[], queryRunner?: QueryRunner) { + const sql = query + (parameters && parameters.length ? ' -- PARAMETERS: ' + JSON.stringify(parameters) : ''); + errorLog('QUERY ERROR !!! \n' + sql + ' \n\t --- ' + (typeof error === 'string' ? error : error.message)); + } + /** + * Logs query that is slow. + */ + logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) { + return; + } + /** + * Logs events from the schema build process. + */ + logSchemaBuild(message: string, queryRunner?: QueryRunner) { + return; + } + /** + * Logs events from the migrations run process. + */ + logMigration(message: string, queryRunner?: QueryRunner) { + return; + } + /** + * Perform logging using given logger, or by default to the console. + * Log has its own level and message. + */ + log(level: 'log' | 'info' | 'warn', message: any, queryRunner?: QueryRunner) { + return; + } +} \ No newline at end of file diff --git a/backend/src/helpers/overlaysDefaultValues.ts b/backend/src/helpers/overlaysDefaultValues.ts new file mode 100644 index 000000000..1f1942980 --- /dev/null +++ b/backend/src/helpers/overlaysDefaultValues.ts @@ -0,0 +1,322 @@ +import { MINUTE, SECOND } from '@sogebot/ui-helpers/constants.js'; +import { defaultsDeep } from 'lodash-es'; + +import { Overlay } from '~/database/entity/overlay.js'; + +const values = { + url: { url: '' }, + alertsRegistry: { id: '' }, + textRegistry: { id: '' }, + countdown: { + time: 60000, + currentTime: 60000, + messageWhenReachedZero: '', + isPersistent: false, + isStartedOnSourceLoad: true, + showMessageWhenReachedZero: false, + showMilliseconds: false, + countdownFont: { + family: 'PT Sans', + size: 50, + borderPx: 1, + borderColor: '#000000', + weight: '500', + color: '#ffffff', + shadow: [], + }, + messageFont: { + family: 'PT Sans', + size: 35, + borderPx: 1, + borderColor: '#000000', + weight: '500', + color: '#ffffff', + shadow: [], + }, + }, + marathon: { + showProgressGraph: false, + disableWhenReachedZero: true, + endTime: Date.now(), + maxEndTime: null, + showMilliseconds: false, + values: { + sub: { + tier1: (10 * MINUTE) / SECOND, + tier2: (15 * MINUTE) / SECOND, + tier3: (20 * MINUTE) / SECOND, + }, + resub: { + tier1: (5 * MINUTE) / SECOND, + tier2: (7.5 * MINUTE) / SECOND, + tier3: (10 * MINUTE) / SECOND, + }, + bits: { + addFraction: true, + bits: 100, + time: MINUTE / SECOND, + }, + tips: { + addFraction: true, + tips: 1, + time: MINUTE / SECOND, + }, + }, + marathonFont: { + family: 'PT Sans', + size: 50, + borderPx: 1, + borderColor: '#000000', + weight: '500', + color: '#ffffff', + shadow: [], + }, + }, + stopwatch: { + currentTime: 0, + isPersistent: false, + isStartedOnSourceLoad: true, + showMilliseconds: true, + stopwatchFont: { + family: 'PT Sans', + size: 50, + borderPx: 1, + borderColor: '#000000', + weight: '500', + color: '#ffffff', + shadow: [], + }, + }, + alerts: { + alertDelayInMs: 0, + parry: { + enabled: false, + delay: 0, + }, + profanityFilter: { + type: 'replace-with-asterisk', + list: { + cs: false, + en: true, + ru: false, + }, + customWords: '', + }, + globalFont1: { + align: 'center', + family: 'PT Sans', + size: 24, + borderPx: 1, + borderColor: '#000000', + weight: 800, + color: '#ffffff', + highlightcolor: '#00ff00', + shadow: [], + }, + globalFont2: { + align: 'left', + family: 'PT Sans', + size: 16, + borderPx: 1, + borderColor: '#000000', + highlightcolor: '#00ff00', + weight: 500, + color: '#ffffff', + shadow: [], + }, + tts: { + voice: 'UK English Female', + volume: 50, + rate: 1, + pitch: 1, + }, + items: [], + }, + credits: { + speed: 'medium', + waitBetweenScreens: 0, + screens: [], + }, + eventlist: { + display: ['username', 'event'], + ignore: [], + count: 5, + order: 'desc', + fadeOut: false, + inline: false, + spaceBetweenItems: 5, + spaceBetweenEventAndUsername: 5, + usernameFont: { + family: 'PT Sans', + align: 'right', + size: 30, + borderPx: 1, + borderColor: '#000000', + weight: '500', + color: '#ffffff', + shadow: [], + }, + eventFont: { + align: 'left', + family: 'PT Sans', + size: 40, + borderPx: 1, + borderColor: '#000000', + weight: '900', + color: '#ffffff', + shadow: [], + }, + }, + html: { + html: '\n\n', + css: '// use #wrapper to target this specific overlay widget\n\n#wrapper {\n\n}', + javascript: 'function onLoad() { // triggered on page load\n\n}\n\nfunction onChange() { // triggered on variable change\n\n}', + }, + clips: { + volume: 0, + filter: 'none', + showLabel: true, + }, + media: { + galleryCache: false, + galleryCacheLimitInMb: 50, + }, + emotes: { + emotesSize: 3, + animation: 'fadeup', + animationTime: 1000, + maxEmotesPerMessage: 5, + maxRotation: 2250, + offsetX: 200, + }, + emotescombo: { + showEmoteInOverlayThreshold: 3, + hideEmoteInOverlayAfter: 30, + }, + emotesfireworks: { + emotesSize: 3, + numOfEmotesPerExplosion: 10, + animationTime: 1000, + numOfExplosions: 5, + }, + emotesexplode: { + emotesSize: 3, + animationTime: 1000, + numOfEmotes: 5, + }, + clipscarousel: { + volume: 0, + customPeriod: 31, + numOfClips: 20, + animation: 'slide', + spaceBetween: 200, + }, + tts: { + voice: 'UK English Female', + volume: 50, + rate: 1, + pitch: 1, + triggerTTSByHighlightedMessage: false, + }, + polls: { + theme: 'light', + hideAfterInactivity: false, + inactivityTime: 5000, + align: 'top', + }, + obswebsocket: { allowedIPs: [], password: '', port: '4455' }, + group: { + canvas: { + height: 1080, + width: 1920, + }, + items: [], + }, + wordcloud: { + fadeOutInterval: 10, + fadeOutIntervalType: 'minutes', + wordFont: { + family: 'PT Sans', + weight: '500', + color: '#ffffff', + }, + }, + reference: { + overlayId: null, + }, + chat: { + type: 'vertical', + hideMessageAfter: 600000, + showTimestamp: true, + showBadges: true, + showCommandMessages: false, + useCustomLineHeight: false, + customLineHeight: 14, + useCustomBadgeSize: false, + customBadgeSize: 14, + useCustomEmoteSize: false, + customEmoteSize: 14, + useCustomSpaceBetweenMessages: false, + useGeneratedColors: true, + useCustomUsernameColor: false, + customSpaceBetweenMessages: 4, + messagePadding: 0, + reverseOrder: false, + font: { + family: 'PT Sans', + size: 20, + borderPx: 1, + borderColor: '#000000', + weight: '500', + color: '#ffffff', + shadow: [], + }, + separatorFont: null, + usernameFont: null, + separator: ': ', + messageBackgroundColor: '#ffffff00', + }, + carousel: { + images: [], + }, + plugin: { + pluginId: '', + overlayId: '', + }, + goal: { + display: { + type: 'fade', + durationMs: 30000, + animationInMs: 1000, + animationOutMs: 1000, + spaceBetweenGoalsInPx: 1, + }, + campaigns: [], + }, + hypetrain: null, + randomizer: null, + stats: null, +} as const; + +function setDefaultOpts(opts: any, type: T): Overlay['items'][number]['opts'] { + return { + ...defaultsDeep(opts, values[type]), + typeId: type, + }; +} + +function defaultValues(overlay: Overlay) { + for (const item of overlay.items) { + if (Object.keys(values).includes(item.opts.typeId)) { + item.opts = { + ...setDefaultOpts(item.opts, item.opts.typeId), + typeId: item.opts.typeId, + } as any; + } + } + + return overlay; +} + +export default defaultValues; +export { setDefaultOpts }; \ No newline at end of file diff --git a/backend/src/helpers/panel.ts b/backend/src/helpers/panel.ts new file mode 100644 index 000000000..42a14c10a --- /dev/null +++ b/backend/src/helpers/panel.ts @@ -0,0 +1,90 @@ +import { constants } from 'crypto'; +import fs from 'fs'; +import http, { Server } from 'http'; +import https from 'https'; +import { normalize } from 'path'; + +import express from 'express'; +import { Server as io } from 'socket.io'; +import { DefaultEventsMap } from 'socket.io/dist/typed-events'; + +import type { Module } from '../_interface.js'; + +import type { ClientToServerEventsWithNamespace } from '~/../d.ts/src/helpers/socket.js'; +import { info } from '~/helpers/log.js'; + +export type MenuItem = { + id: string; + category?: string; + name: string; +}; + +export const menu: (MenuItem & { this: Module | null })[] = []; +export const menuPublic: { name: string; id: string }[] = []; + +menu.push({ + category: 'main', name: 'dashboard', id: '', this: null, +}); + +export let ioServer: io | null = null; +export let app: express.Application | null = null; +export let server: Server; +export let serverSecure: Server; + +export const addMenu = (menuArg: typeof menu[number]) => { + if (!menu.find(o => o.id === menuArg.id)) { + menu.push(menuArg); + } +}; + +export const addMenuPublic = (menuArg: typeof menuPublic[number]) => { + if (!menuPublic.find(o => o.id === menuArg.id)) { + menuPublic.push(menuArg); + } +}; + +export const setApp = (_app: express.Application) => { + app = _app; +}; + +export const setServer = () => { + if (app) { + server = http.createServer(app); + if (process.env.CORS) { + ioServer = new io(server, { + cors: { + origin: process.env.CORS, + methods: ['GET', 'POST'], + }, + }); + } else { + ioServer = new io(server); + } + ioServer.sockets.setMaxListeners(200); + + if (process.env.CA_CERT && process.env.CA_KEY && process.env.NODE_EXTRA_CA_CERTS) { + info(`Using ${process.env.CA_CERT} certificate for HTTPS with ${process.env.NODE_EXTRA_CA_CERTS} CA Bundle`); + serverSecure = https.createServer({ + key: fs.readFileSync(normalize(process.env.CA_KEY)), + cert: fs.readFileSync(normalize(process.env.CA_CERT)), + ca: fs.readFileSync(normalize(process.env.NODE_EXTRA_CA_CERTS)), + secureOptions: constants.SSL_OP_NO_TLSv1 | constants.SSL_OP_NO_TLSv1_1, + }, app); + if (ioServer) { + ioServer.attach(serverSecure); + } + } else if (process.env.CA_CERT && process.env.CA_KEY) { + info(`Using ${process.env.CA_CERT} certificate for HTTPS`); + serverSecure = https.createServer({ + key: fs.readFileSync(normalize(process.env.CA_KEY)), + cert: fs.readFileSync(normalize(process.env.CA_CERT)), + secureOptions: constants.SSL_OP_NO_TLSv1 | constants.SSL_OP_NO_TLSv1_1, + }, app); + if (ioServer) { + ioServer.attach(serverSecure); + } + } else { + info(`No certificates were provided, serving only HTTP.`); + } + } +}; diff --git a/backend/src/helpers/panel/alerts.ts b/backend/src/helpers/panel/alerts.ts new file mode 100644 index 000000000..f02096f58 --- /dev/null +++ b/backend/src/helpers/panel/alerts.ts @@ -0,0 +1,16 @@ +import type { UIError } from '~/../d.ts/src/helpers/panel/alerts.js'; + +const warns: UIError[] = []; +const errors: UIError[] = []; + +function addUIWarn (warn: UIError) { + warns.push(warn); +} + +function addUIError (error: UIError) { + errors.push(error); +} + +export { + warns, addUIWarn, errors, addUIError, +}; \ No newline at end of file diff --git a/backend/src/helpers/panel/index.ts b/backend/src/helpers/panel/index.ts new file mode 100644 index 000000000..f6ab4bac4 --- /dev/null +++ b/backend/src/helpers/panel/index.ts @@ -0,0 +1,2 @@ +export * from './alerts.js'; +export * from './socketsConnected.js'; \ No newline at end of file diff --git a/backend/src/helpers/panel/socketsConnected.ts b/backend/src/helpers/panel/socketsConnected.ts new file mode 100644 index 000000000..ac69b8e17 --- /dev/null +++ b/backend/src/helpers/panel/socketsConnected.ts @@ -0,0 +1,13 @@ +let socketsConnected = 0; + +function socketsConnectedDec() { + socketsConnected--; +} + +function socketsConnectedInc() { + socketsConnected++; +} + +export { + socketsConnected, socketsConnectedInc, socketsConnectedDec, +}; \ No newline at end of file diff --git a/backend/src/helpers/parameterError.ts b/backend/src/helpers/parameterError.ts new file mode 100644 index 000000000..2ffaba735 --- /dev/null +++ b/backend/src/helpers/parameterError.ts @@ -0,0 +1,15 @@ +class ParameterError extends Error { + constructor(message: string) { + // Pass remaining arguments (including vendor specific ones) to parent constructor + super(message); + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ParameterError); + } + + this.name = 'ParameterError'; + } +} + +export { ParameterError }; diff --git a/backend/src/helpers/parseTextWithEmotes.ts b/backend/src/helpers/parseTextWithEmotes.ts new file mode 100644 index 000000000..d125f3e9a --- /dev/null +++ b/backend/src/helpers/parseTextWithEmotes.ts @@ -0,0 +1,18 @@ +export const parseTextWithEmotes = async (text: string | undefined, size: 1 | 2 | 3 = 1) =>{ + const Emotes = (await import('../emotes.js')).default; + if (typeof text === 'undefined' || text.length === 0) { + return ''; + } + + // checking emotes + for (const emote of Emotes.cache) { + const split: string[] = (text as string).split(' '); + for (let i = 0; i < split.length; i++) { + if (split[i] === emote.code) { + split[i] = `${emote.code}`; + } + } + text = split.join(' '); + } + return text; +}; \ No newline at end of file diff --git a/backend/src/helpers/parser.ts b/backend/src/helpers/parser.ts new file mode 100644 index 000000000..a3f18b6bd --- /dev/null +++ b/backend/src/helpers/parser.ts @@ -0,0 +1,19 @@ +import * as constants from '@sogebot/ui-helpers/constants.js'; + +export let linesParsed = 0; +export const linesParsedIncrement = () => { + linesParsed++; +}; + +export const status = { + TMI: constants.DISCONNECTED, + API: constants.DISCONNECTED, + MOD: false, +}; + +export function setStatus(key: 'TMI' | 'API', value: 0 | 1 | 2 | 3): void; +export function setStatus(key: 'MOD', value: boolean): void; +export function setStatus(key: 'RES', value: number): void; +export function setStatus(key: any, value: any): void { + (status as any)[key] = value; +} diff --git a/backend/src/helpers/parser/emitter.ts b/backend/src/helpers/parser/emitter.ts new file mode 100644 index 000000000..c3fd79721 --- /dev/null +++ b/backend/src/helpers/parser/emitter.ts @@ -0,0 +1,23 @@ +import { TypedEmitter } from 'tiny-typed-emitter'; + +interface Events { + 'process': ( + opts: { + sender: { userName: string; userId: string }; + message: string, + skip: boolean, + quiet: boolean + }, + callback: (responses: CommandResponse[]) => void) => void; + 'fireAndForget': ( + opts: { + this: any, + fnc: any, + opts: ParserOptions, + }) => void; +} + +class _ParserEmitter extends TypedEmitter {} +const parserEmitter = new _ParserEmitter(); + +export { parserEmitter }; \ No newline at end of file diff --git a/backend/src/helpers/parser/index.ts b/backend/src/helpers/parser/index.ts new file mode 100644 index 000000000..133032850 --- /dev/null +++ b/backend/src/helpers/parser/index.ts @@ -0,0 +1 @@ +export * from './emitter.js'; \ No newline at end of file diff --git a/backend/src/helpers/permissions/check.ts b/backend/src/helpers/permissions/check.ts new file mode 100644 index 000000000..ba88a332a --- /dev/null +++ b/backend/src/helpers/permissions/check.ts @@ -0,0 +1,116 @@ +import { Permissions } from '@entity/permissions.js'; +import _ from 'lodash-es'; +import { LessThan } from 'typeorm'; + +import { defaultPermissions } from './defaultPermissions.js'; +import { areDecoratorsLoaded } from '../../decorators.js'; +import { + debug, error, warning, +} from '../log.js'; +import * as changelog from '../user/changelog.js'; +import { + isOwner, isSubscriber, isVIP, +} from '../user/index.js'; +import { isBot } from '../user/isBot.js'; +import { isBroadcaster } from '../user/isBroadcaster.js'; +import { isModerator } from '../user/isModerator.js'; + +import type { checkReturnType } from '~/../d.ts/src/helpers/permissions/check.js'; +import { filters } from '~/helpers/permissions/filters.js'; +import { variables } from '~/watchers.js'; + +let isWarnedAboutCasters = false; + +async function check(userId: string, permId: string, partial = false): Promise { + if (!areDecoratorsLoaded) { + await new Promise((resolve) => { + const _check = () => { + // wait for all data to be loaded + if (areDecoratorsLoaded) { + resolve(); + } else { + setTimeout(() => _check(), 10); + } + }; + _check(); + }); + } + + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const generalOwners = variables.get('services.twitch.generalOwners') as string[]; + + if (generalOwners.filter(o => typeof o === 'string' && o.trim().length > 0).length === 0 && broadcasterUsername === '' && !isWarnedAboutCasters) { + isWarnedAboutCasters = true; + warning('Owners or broadcaster oauth is not set, all users are treated as CASTERS!!!'); + const pItem = await Permissions.findOneBy({ id: defaultPermissions.CASTERS }); + return { access: true, permission: pItem }; + } + + const user = await changelog.get(userId); + const pItem = await Permissions.findOne({ + where: { id: permId }, + }); + try { + if (!user) { + return { access: permId === defaultPermissions.VIEWERS, permission: pItem }; + } + if (!pItem) { + throw Error(`Permissions ${permId} doesn't exist`); + } + + // if userId is part of excludeUserIds => false + if (pItem.excludeUserIds.includes(String(userId))) { + return { access: false, permission: pItem }; + } + + // if userId is part of userIds => true + if (pItem.userIds.includes(String(userId))) { + return { access: true, permission: pItem }; + } + + // get all higher permissions to check if not partial check only + if (!partial && pItem.isWaterfallAllowed) { + const partialPermission = await Permissions.find({ where: { order: LessThan(pItem.order) } }); + for (const p of _.orderBy(partialPermission, 'order', 'asc')) { + const partialCheck = await check(userId, p.id, true); + if (partialCheck.access) { + return { access: true, permission: p }; // we don't need to continue, user have already access with higher permission + } + } + } + + let shouldProceed = false; + switch (pItem.automation) { + case 'viewers': + shouldProceed = true; + break; + case 'casters': + if (generalOwners.filter(o => typeof o === 'string').length === 0 && broadcasterUsername === '') { + shouldProceed = true; + } else { + shouldProceed = isBot(user) || isBroadcaster(user) || isOwner(user); + } + break; + case 'moderators': + shouldProceed = isModerator(user); + break; + case 'subscribers': + shouldProceed = isSubscriber(user); + break; + case 'vip': + shouldProceed = isVIP(user); + break; + default: + shouldProceed = false; // we don't have any automation + break; + } + const access = shouldProceed && await filters(user, pItem.filters); + debug('permissions.check', JSON.stringify({ userId, access, permission: pItem })); + return { access, permission: pItem }; + } catch (e: any) { + error(e.stack); + return { access: false, permission: pItem }; + } +} + +export { check }; \ No newline at end of file diff --git a/backend/src/helpers/permissions/defaultPermissions.ts b/backend/src/helpers/permissions/defaultPermissions.ts new file mode 100644 index 000000000..e6e9e3042 --- /dev/null +++ b/backend/src/helpers/permissions/defaultPermissions.ts @@ -0,0 +1,10 @@ +const defaultPermissions = { + CASTERS: '4300ed23-dca0-4ed9-8014-f5f2f7af55a9', + MODERATORS: 'b38c5adb-e912-47e3-937a-89fabd12393a', + SUBSCRIBERS: 'e3b557e7-c26a-433c-a183-e56c11003ab7', + VIP: 'e8490e6e-81ea-400a-b93f-57f55aad8e31', + VIEWERS: '0efd7b1c-e460-4167-8e06-8aaf2c170311', +}; + +export default defaultPermissions; +export { defaultPermissions }; \ No newline at end of file diff --git a/backend/src/helpers/permissions/filters.ts b/backend/src/helpers/permissions/filters.ts new file mode 100644 index 000000000..8d72f4a7b --- /dev/null +++ b/backend/src/helpers/permissions/filters.ts @@ -0,0 +1,102 @@ +import { + UserBit, UserInterface, UserTip, +} from '@entity/user.js'; + +import type { default as currencyType } from '../../currency.js'; +import type { default as levelType } from '../../systems/levels.js'; +import type { default as ranksType } from '../../systems/ranks.js'; +import { mainCurrency } from '../currency/index.js'; + +import { Permissions } from '~/database/entity/permissions.js'; +import { AppDataSource } from '~/database.js'; +import exchange from '~/helpers/currency/exchange.js'; + +let levels: typeof levelType; +let ranks: typeof ranksType; +let currency: typeof currencyType; + +async function _filters( + user: Required, + filters: Permissions['filters'] = [], +): Promise { + for (const f of filters) { + let amount = 0; + switch (f.type) { + case 'ranks': { + if (!ranks) { + ranks = (await import('../../systems/ranks.js')).default; + } + const rank = await ranks.get(user); + // we can return immediately + return rank.current === f.value; + } + case 'level': + if (!levels) { + levels = (await import('../../systems/levels.js')).default; + } + amount = levels.getLevelOf(user); + break; + case 'bits': { + const bits = await AppDataSource.getRepository(UserBit).find({ where: { userId: user.userId } }); + amount = bits.reduce((a, b) => (a + b.amount), 0); + break; + } + case 'messages': + amount = user.messages; + break; + case 'points': + amount = user.points; + break; + case 'subcumulativemonths': + amount = user.subscribeCumulativeMonths; + break; + case 'substreakmonths': + amount = user.subscribeStreak; + break; + case 'subtier': + amount = user.subscribeTier === 'Prime' ? 0 : Number(user.subscribeTier); + break; + case 'tips': { + const tips = await AppDataSource.getRepository(UserTip).find({ where: { userId: user.userId } }); + if (!currency) { + currency = (await import('../../currency.js')).default; + } + amount = tips.reduce((a, b) => (a + exchange(b.amount, b.currency, mainCurrency.value)), 0); + break; + } + case 'watched': + amount = user.watchedTime / (60 * 60 * 1000 /*hours*/); + } + + switch (f.comparator) { + case '<': + if (!(amount < Number(f.value))) { + return false; + } + break; + case '<=': + if (!(amount <= Number(f.value))) { + return false; + } + break; + case '==': + if (Number(amount) !== Number(f.value)) { + return false; + } + break; + case '>': + if (!(amount > Number(f.value))) { + return false; + } + break; + case '>=': + if (!(amount >= Number(f.value))) { + return false; + } + break; + } + } + return true; +} + +export { _filters as filters }; \ No newline at end of file diff --git a/backend/src/helpers/permissions/get.ts b/backend/src/helpers/permissions/get.ts new file mode 100644 index 000000000..f0e5a2a14 --- /dev/null +++ b/backend/src/helpers/permissions/get.ts @@ -0,0 +1,15 @@ +import { Permissions } from '@entity/permissions.js'; + +async function get(identifier: string): Promise { + const uuidRegex = /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/; + if (identifier.search(uuidRegex) >= 0) { + return Permissions.findOneBy({ id: identifier }); + } else { + // get first name-like + return (await Permissions.find()).find((o) => { + return o.name.toLowerCase() === identifier.toLowerCase(); + }) ?? null; + } +} + +export { get }; \ No newline at end of file diff --git a/backend/src/helpers/permissions/getCommandPermission.ts b/backend/src/helpers/permissions/getCommandPermission.ts new file mode 100644 index 000000000..ae0cbf74e --- /dev/null +++ b/backend/src/helpers/permissions/getCommandPermission.ts @@ -0,0 +1,12 @@ +import { PermissionCommands } from '@entity/permissions.js'; + +async function getCommandPermission(commandArg: string): Promise { + const cItem = await PermissionCommands.findOneBy({ name: commandArg }); + if (cItem) { + return cItem.permission; + } else { + return undefined; + } +} + +export { getCommandPermission }; \ No newline at end of file diff --git a/backend/src/helpers/permissions/getUserHighestPermission.ts b/backend/src/helpers/permissions/getUserHighestPermission.ts new file mode 100644 index 000000000..73690b700 --- /dev/null +++ b/backend/src/helpers/permissions/getUserHighestPermission.ts @@ -0,0 +1,17 @@ +import { Permissions } from '@entity/permissions.js'; + +import { check } from '~/helpers/permissions/check.js'; + +async function getUserHighestPermission(userId: string): Promise { + const permissions = await Permissions.find({ + order: { order: 'ASC' }, + }); + for (const p of permissions) { + if ((await check(userId, p.id, true)).access) { + return p.id; + } + } + throw new Error('Unknown permission for user ' + userId); +} + +export { getUserHighestPermission }; \ No newline at end of file diff --git a/backend/src/helpers/permissions/getUserPermissionsList.ts b/backend/src/helpers/permissions/getUserPermissionsList.ts new file mode 100644 index 000000000..04ce3bffb --- /dev/null +++ b/backend/src/helpers/permissions/getUserPermissionsList.ts @@ -0,0 +1,18 @@ +import { Permissions } from '@entity/permissions.js'; + +import { check } from '~/helpers/permissions/check.js'; + +async function getUserPermissionsList(userId: string, noCache = false): Promise { + const list: string[] = []; + const permissions = await Permissions.find({ + order: { order: 'ASC' }, + }); + for (const p of permissions) { + if ((await check(userId, p.id, true)).access) { + list.push(p.id); + } + } + return list; +} + +export { getUserPermissionsList }; \ No newline at end of file diff --git a/backend/src/helpers/points/getPointsName.ts b/backend/src/helpers/points/getPointsName.ts new file mode 100644 index 000000000..bed6805e6 --- /dev/null +++ b/backend/src/helpers/points/getPointsName.ts @@ -0,0 +1,9 @@ +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; + +import { name } from './name.js'; + +function getPointsName (points: number): string { + return getLocalizedName(points, name.value); +} + +export { getPointsName }; \ No newline at end of file diff --git a/backend/src/helpers/points/index.ts b/backend/src/helpers/points/index.ts new file mode 100644 index 000000000..15f2f5662 --- /dev/null +++ b/backend/src/helpers/points/index.ts @@ -0,0 +1,2 @@ +export * from './getPointsName.js'; +export * from './name.js'; \ No newline at end of file diff --git a/backend/src/helpers/points/name.ts b/backend/src/helpers/points/name.ts new file mode 100644 index 000000000..a37fd0044 --- /dev/null +++ b/backend/src/helpers/points/name.ts @@ -0,0 +1,12 @@ +let _value = ''; + +const name = { + set value(value: typeof _value) { + _value = value; + }, + get value() { + return _value; + }, +}; + +export { name }; \ No newline at end of file diff --git a/backend/src/helpers/profiler.ts b/backend/src/helpers/profiler.ts new file mode 100644 index 000000000..ac884c16b --- /dev/null +++ b/backend/src/helpers/profiler.ts @@ -0,0 +1,14 @@ +export const avgTime = new Map(); +const NS_PER_SEC = 1e9; + +export function logAvgTime(functionName: string, time: [ seconds: number, nanoseconds: number ]) { + const data = avgTime.get(functionName) ?? []; + const nanoseconds = time[0] * NS_PER_SEC + time[1]; + data.push(nanoseconds / 1000000); + if (data.length > 250) { + data.reverse(); + data.length = 250; + data.reverse(); + } + avgTime.set(functionName, data); +} \ No newline at end of file diff --git a/backend/src/helpers/register.ts b/backend/src/helpers/register.ts new file mode 100644 index 000000000..0ea347c4a --- /dev/null +++ b/backend/src/helpers/register.ts @@ -0,0 +1,56 @@ +import { error, warning } from '~/helpers/log.js'; + +export const systems = { + core: [], + systems: [], + integrations: [], + games: [], + widgets: [], + registries: [], + overlays: [], + stats: [], + services: [], +} as { + core: import('../_interface.js').Module[], + systems: import('../_interface.js').Module[], + integrations: import('../_interface.js').Module[], + games: import('../_interface.js').Module[], + widgets: import('../_interface.js').Module[], + registries: import('../_interface.js').Module[], + overlays: import('../_interface.js').Module[], + stats: import('../_interface.js').Module[], + services: import('../_interface.js').Module[], +}; + +export const register = (type: keyof typeof systems, system: import('../_interface.js').Module) => { + systems[type].push(system); +}; + +export const list = (type?: keyof typeof systems) => { + if (!type) { + const _list: import('../_interface.js').Module[] = []; + for (const key of Object.keys(systems) as (keyof typeof systems)[]) { + for (const mod of systems[key]) { + _list.push(mod); + } + } + return _list; + } + return systems[type]; +}; + +export const find = (type: keyof typeof systems, name: string) => { + return list(type).find(m => { + try { + if (typeof m.__moduleName__ === 'undefined') { + throw new Error('Module name undefined'); + } + if (m.__moduleName__ === null) { + warning(`Some modules are not loaded yet`); + } + return String(m.__moduleName__).toLowerCase() === name.toLowerCase(); + } catch (e: any) { + error(e); + } + }); +}; \ No newline at end of file diff --git a/backend/src/helpers/setImmediateAwait.ts b/backend/src/helpers/setImmediateAwait.ts new file mode 100644 index 000000000..47ba23319 --- /dev/null +++ b/backend/src/helpers/setImmediateAwait.ts @@ -0,0 +1,9 @@ +export const setImmediateAwait = () => { + return new Promise(resolve => { + if (process.env.BUILD === 'web') { + setTimeout(() => resolve(true), 1); + } else { + setImmediate(() => resolve(true)); + } + }); +}; \ No newline at end of file diff --git a/backend/src/helpers/socket.ts b/backend/src/helpers/socket.ts new file mode 100644 index 000000000..0c0dd7c1a --- /dev/null +++ b/backend/src/helpers/socket.ts @@ -0,0 +1,38 @@ +import { Socket } from 'socket.io'; + +import type { Fn, ClientToServerEventsWithNamespace, NestedFnParams } from '~/../d.ts/src/helpers/socket.js'; + +const endpoints: { + type: 'admin' | 'viewer' | 'public'; + on: any; + nsp: any; + callback: any; +}[] = []; + +function adminEndpoint> = ClientToServerEventsWithNamespace>(nsp: K0, on: K1, callback: (...args: NestedFnParams) => void): void { + if (!endpoints.find(o => o.type === 'admin' && o.nsp === nsp && o.on === on)) { + endpoints.push({ + nsp, on, callback, type: 'admin', + }); + } +} + +const viewerEndpoint = (nsp: string, on: string, callback: (opts: any, cb: (error: Error | string | null, ...response: any) => void) => void, socket?: Socket) => { + if (!endpoints.find(o => o.type === 'viewer' && o.nsp === nsp && o.on === on)) { + endpoints.push({ + nsp, on, callback, type: 'viewer', + }); + } +}; + +function publicEndpoint (nsp: string, on: string, callback: (opts: any, cb: (error: Error | string | null | unknown, ...response: any) => void) => void, socket?: Socket) { + if (!endpoints.find(o => o.type === 'public' && o.nsp === nsp && o.on === on)) { + endpoints.push({ + nsp, on, callback, type: 'public', + }); + } +} + +export { + endpoints, adminEndpoint, viewerEndpoint, publicEndpoint, +}; \ No newline at end of file diff --git a/backend/src/helpers/sql.ts b/backend/src/helpers/sql.ts new file mode 100644 index 000000000..980006f38 --- /dev/null +++ b/backend/src/helpers/sql.ts @@ -0,0 +1,26 @@ +import { AppDataSource } from '~/database.js'; + +import { isDbConnected } from '~/helpers/database.js'; +import { debug } from '~/helpers/log.js'; + +// TODO: dynamic way to determinate limit of SQL variables +export let SQLVariableLimit = 999; // sqlite have default limit of 999 +if (['mysql', 'mariadb'].includes(process.env.TYPEORM_CONNECTION ?? 'better-sqlite3')) { + // set default and then check current value + SQLVariableLimit = 16382; // per https://mariadb.com/kb/en/server-system-variables/#max_prepared_stmt_count + new Promise(() => { + const updateSQLVariableLimit = async () => { + if (!isDbConnected) { + setTimeout(() => updateSQLVariableLimit(), 10); + return; + } + const query = await AppDataSource.query(`show variables like 'max_prepared_stmt_count'`); + SQLVariableLimit = Number(query[0].Value); + debug('sql', `Variable limit for MySQL/MariaDB set dynamically to ${SQLVariableLimit}`); + }; + updateSQLVariableLimit(); + }); +} +if (['postgres'].includes(process.env.TYPEORM_CONNECTION ?? 'better-sqlite3')) { + SQLVariableLimit = 32767; // per https://stackoverflow.com/a/42251312 +} diff --git a/backend/src/helpers/tmi/emitter.ts b/backend/src/helpers/tmi/emitter.ts new file mode 100644 index 000000000..31da44757 --- /dev/null +++ b/backend/src/helpers/tmi/emitter.ts @@ -0,0 +1,17 @@ +import { TypedEmitter } from 'tiny-typed-emitter'; + +interface Events { + 'say': (channel: string, message: string, opts?: { replyTo: string | undefined }) => void, + 'whisper': (username: string, message: string, opts?: { replyTo: string | undefined }) => void, + 'ban': (username: string) => void, + 'timeout': (username: string, seconds: number, is: { mod: boolean }, reason?: string) => void, + 'delete': (msgId: string) => void, + 'join': (type: 'bot' | 'broadcaster') => void, + 'reconnect': (type: 'bot' | 'broadcaster') => void, + 'part': (type: 'bot' | 'broadcaster') => void, +} + +class _TMIEmitter extends TypedEmitter {} +const tmiEmitter = new _TMIEmitter(); + +export { tmiEmitter }; \ No newline at end of file diff --git a/backend/src/helpers/tmi/ignoreList.ts b/backend/src/helpers/tmi/ignoreList.ts new file mode 100644 index 000000000..ec217915e --- /dev/null +++ b/backend/src/helpers/tmi/ignoreList.ts @@ -0,0 +1,18 @@ +import { cloneDeep } from 'lodash-es'; + +let _ignorelist: any[] = []; + +const isIgnoredCache = new Map(); + +const ignorelist = { + set value(value: typeof _ignorelist) { + _ignorelist = cloneDeep(value); + isIgnoredCache.clear(); + }, + get value() { + return _ignorelist; + }, +}; +export { + ignorelist, isIgnoredCache, +}; \ No newline at end of file diff --git a/backend/src/helpers/tmi/index.ts b/backend/src/helpers/tmi/index.ts new file mode 100644 index 000000000..54bcc4c9b --- /dev/null +++ b/backend/src/helpers/tmi/index.ts @@ -0,0 +1,6 @@ +export * from './ignoreList.js'; +export * from './message.js'; +export * from './muteStatus.js'; +export * from './sendWithMe.js'; +export * from './showWithAt.js'; +export * from './emitter.js'; \ No newline at end of file diff --git a/backend/src/helpers/tmi/message.ts b/backend/src/helpers/tmi/message.ts new file mode 100644 index 000000000..5611bad99 --- /dev/null +++ b/backend/src/helpers/tmi/message.ts @@ -0,0 +1,48 @@ +import { capitalize } from 'lodash-es'; +import XRegExp from 'xregexp'; + +import { error } from '../log.js'; + +import { isDebugEnabled } from '~/helpers/debug.js'; +import { tmiEmitter } from '~/helpers/tmi/index.js'; +import { variables } from '~/watchers.js'; + +export async function message(type: 'say' | 'whisper' | 'me', username: string | undefined | null, messageToSend: string, messageId?: string, retry = true) { + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const botUsername = variables.get('services.twitch.botUsername') as string; + const sendAsReply = variables.get('services.twitch.sendAsReply') as string; + try { + if (username === null || typeof username === 'undefined') { + username = botUsername; + } + if (broadcasterUsername === '') { + error('TMI: channel is not defined, message cannot be sent'); + } else { + if (isDebugEnabled('tmi.message')) { + return; + } + if (type === 'me') { + tmiEmitter.emit('say', broadcasterUsername, `/me ${messageToSend}`); + } else { + // strip username if username is bot or is reply + if ((sendAsReply && messageId) || username === botUsername) { + if (messageToSend.startsWith(username) || messageToSend.startsWith('@' + username)) { + const regexp = XRegExp(`^@?${username}\\s?\\P{L}?`); + messageToSend = capitalize(messageToSend.replace(regexp, '').trim()); + } + tmiEmitter.emit('say', broadcasterUsername, `${messageToSend}`, { replyTo: messageId }); + } else { + tmiEmitter.emit(type as any, broadcasterUsername, `${messageToSend}`); + } + } + } + } catch (e: any) { + if (retry) { + setTimeout(() => message(type, username, messageToSend, messageId, false), 5000); + } else { + error(JSON.stringify({ + e: e.stack, type, username, messageToSend, messageId, retry, + }, null, 2)); + } + } +} diff --git a/backend/src/helpers/tmi/muteStatus.ts b/backend/src/helpers/tmi/muteStatus.ts new file mode 100644 index 000000000..a2c083d8e --- /dev/null +++ b/backend/src/helpers/tmi/muteStatus.ts @@ -0,0 +1,11 @@ +let _mute = false; + +function setMuteStatus(status: boolean) { + _mute = status; +} + +function getMuteStatus() { + return _mute; +} + +export { setMuteStatus, getMuteStatus }; \ No newline at end of file diff --git a/backend/src/helpers/tmi/sendWithMe.ts b/backend/src/helpers/tmi/sendWithMe.ts new file mode 100644 index 000000000..9439b935e --- /dev/null +++ b/backend/src/helpers/tmi/sendWithMe.ts @@ -0,0 +1,12 @@ +let _value = false; + +const sendWithMe = { + set value(value: typeof _value) { + _value = value; + }, + get value() { + return _value; + }, +}; + +export { sendWithMe }; \ No newline at end of file diff --git a/backend/src/helpers/tmi/showWithAt.ts b/backend/src/helpers/tmi/showWithAt.ts new file mode 100644 index 000000000..1568b2b03 --- /dev/null +++ b/backend/src/helpers/tmi/showWithAt.ts @@ -0,0 +1,12 @@ +let _value = false; + +const showWithAt = { + set value(value: typeof _value) { + _value = value; + }, + get value() { + return _value; + }, +}; + +export { showWithAt }; \ No newline at end of file diff --git a/backend/src/helpers/toBoolean.ts b/backend/src/helpers/toBoolean.ts new file mode 100644 index 000000000..822e51902 --- /dev/null +++ b/backend/src/helpers/toBoolean.ts @@ -0,0 +1,9 @@ +export const toBoolean = (value: string | boolean | number): boolean => { + if (typeof value === 'string') { + return value.toLowerCase() === 'true'; + } else if (typeof value === 'number') { + return Boolean(value); + } else { + return value; + } +}; diff --git a/backend/src/helpers/type.ts b/backend/src/helpers/type.ts new file mode 100644 index 000000000..2e64ec29a --- /dev/null +++ b/backend/src/helpers/type.ts @@ -0,0 +1,44 @@ +// https://stackoverflow.com/questions/61323821/alternative-to-math-max-and-math-min-for-bigint-type-in-javascript + +export const bigIntMax = (...args: bigint[]): bigint => args.reduce((m, e) => e > m ? e : m); +export const bigIntMin = (...args: bigint[]): bigint => args.reduce((m, e) => e < m ? e : m); + +// https://stackoverflow.com/questions/29085197/how-do-you-json-stringify-an-es6-map +// Courtesy of https://stackoverflow.com/users/696535/pawel +// https://stackoverflow.com/a/56150320 + +export const serialize = (toSerialize: bigint | Map): string => { + return JSON.stringify(toSerialize, function (key, value) { + const originalObject = this[key]; + if(originalObject instanceof Map) { + return { + dataType: 'Map', + value: Array.from(originalObject.entries()), // or with spread: value: [...originalObject] + }; + } if (typeof originalObject === 'bigint') { + return { + dataType: 'BigInt', + value: String(value), // or with spread: value: [...originalObject] + }; + } else { + return value; + } + }); +}; + +export function unserialize(serializedMap: string | undefined): K | undefined { + if (!serializedMap) { + return undefined; + } + return JSON.parse(serializedMap, function (key, value) { + if(typeof value === 'object' && value !== null) { + if (value.dataType === 'Map') { + return new Map(value.value); + } + if (value.dataType === 'BigInt') { + return BigInt(value.value); + } + } + return value; + }); +} \ No newline at end of file diff --git a/backend/src/helpers/ui/domain.ts b/backend/src/helpers/ui/domain.ts new file mode 100644 index 000000000..c851791a9 --- /dev/null +++ b/backend/src/helpers/ui/domain.ts @@ -0,0 +1,12 @@ +let _value = 'localhost'; + +const domain = { + set value(value: typeof _value) { + _value = value; + }, + get value() { + return _value; + }, +}; + +export { domain }; \ No newline at end of file diff --git a/backend/src/helpers/ui/index.ts b/backend/src/helpers/ui/index.ts new file mode 100644 index 000000000..91fd182d6 --- /dev/null +++ b/backend/src/helpers/ui/index.ts @@ -0,0 +1 @@ +export * from './domain.js'; \ No newline at end of file diff --git a/backend/src/helpers/user/changelog.ts b/backend/src/helpers/user/changelog.ts new file mode 100644 index 000000000..a86a4824c --- /dev/null +++ b/backend/src/helpers/user/changelog.ts @@ -0,0 +1,217 @@ +import { User, UserInterface } from '@entity/user.js'; +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { + get as _get, cloneDeep, merge, set, +} from 'lodash-es'; +import { v4 } from 'uuid'; + +import { timer } from '../../decorators.js'; +import { flatten } from '../flatten.js'; +import { debug, error } from '../log.js'; + +import { AppDataSource } from '~/database.js'; + +const changelog: (Partial & { userId: string, changelogType: 'set' | 'increment' })[] = []; +const lock = new Map(); + +const defaultData: Readonly> = { + userId: '', + userName: '', + watchedTime: 0, + points: 0, + messages: 0, + subscribeTier: '0', + subscribeStreak: 0, + pointsByMessageGivenAt: 0, + pointsOfflineGivenAt: 0, + pointsOnlineGivenAt: 0, + profileImageUrl: '', + rank: '', + subscribeCumulativeMonths: 0, + seenAt: null, + subscribedAt: null, + createdAt: null, + giftedSubscribes: 0, + haveCustomRank: false, + haveSubscribedAtLock: false, + haveSubscriberLock: false, + isModerator: false, + isOnline: false, + isSubscriber: false, + isVIP: false, + chatTimeOffline: 0, + chatTimeOnline: 0, + displayname: '', + extra: {}, +}; + +export function update(userId: string, data: Partial) { + changelog.push({ + ...cloneDeep(data), userId, changelogType: 'set', + }); +} +export function increment(userId: string, data: Partial) { + changelog.push({ + ...cloneDeep(data), userId, changelogType: 'increment', + }); +} + +export async function getOrFail(userId: string): Promise>> { + const data = await get(userId); + if (!data) { + throw new Error('User not found'); + } + return data; +} + +function checkLock(userId: string, resolve: (value: unknown) => void) { + if (!lock.get(userId)) { + resolve(true); + } else { + setImmediate(() => checkLock(userId, resolve)); + } +} + +class Changelog { + @timer() + async get(userId: string): Promise> | null> { + await new Promise((resolve) => { + checkLock(userId, resolve); + }); + + const user = await AppDataSource.getRepository(User).findOneBy({ userId }); + const data = cloneDeep(defaultData); + merge(data, { userId }, user ?? {}); + + for (const { changelogType, ...change } of changelog.filter(o => o.userId === userId)) { + if (changelogType === 'set') { + merge(data, change); + } else if (changelogType === 'increment') { + for (const path of Object.keys(flatten(change))) { + if (path === 'userId') { + continue; + } + + const value = _get(data, path, 0) + _get(change, path, 0); + if (path === 'points' && value < 0) { + set(data, path, 0); + } else { + set(data, path, value); + } + } + } + } + + if (!user && changelog.filter(o => o.userId === userId).length === 0) { + return null; + } + return data; + } +} +const self = new Changelog(); + +export async function get(userId: string): Promise> | null> { + return self.get(userId); +} + +function checkQueue(id: string, resolve: (value: unknown) => void, reject: (reason?: any) => void) { + debug('flush', `queue: ${flushQueue.join(', ')}`); + if (changelog.length === 0) { + // nothing to do, just reject, no point to wait + flushQueue.splice(flushQueue.indexOf(id) ,1); + reject(); + } else { + debug('flush', `checking if ${id} should run`); + // this flush should start + if (flushQueue[0] === id) { + resolve(true); + } else { + setImmediate(() => checkQueue(id, resolve, reject)); + } + } +} + +const flushQueue: string[] = []; +export async function flush() { + debug('flush', `queued - ${flushQueue.length}`); + if (changelog.length === 0) { + // don't event start + debug('flush', 'empty'); + return; + } + const id = v4(); + flushQueue.push(id); + debug('flush', `start - ${id}`); + try { + await new Promise((resolve, reject) => { + checkQueue(id, resolve, reject); + }); + } catch (e) { + debug('flush', `skip - ${id}`); + return; + } + + debug('flush', `progress - ${id} - changes: ${changelog.length}`); + + // prepare changes + const length = changelog.length; + + const users = new Map>(); + for (let i = 0; i < length; i++) { + const shift = changelog.shift() as typeof changelog[number]; + const { changelogType, ...change } = shift; + + // set lock for this userId + lock.set(change.userId, true); + + if (!users.has(change.userId)) { + // initial values + const user = await AppDataSource.getRepository(User).findOneBy({ userId: change.userId }); + const data = cloneDeep(defaultData); + merge(data, { userId: change.userId }, user ?? {}); + users.set(change.userId, data); + } + + if (changelogType === 'set') { + users.set(change.userId, { + ...users.get(change.userId) ?? {}, + ...change, + userId: change.userId, + }); + } else if (changelogType === 'increment') { + const data = users.get(change.userId) ?? { userId: change.userId }; + for (const path of Object.keys(flatten(change))) { + if (path === 'userId') { + continue; + } + + const value = _get(data, path, 0) + _get(change, path, 0); + if (path === 'points' && value < 0) { + set(data, path, 0); + } else { + set(data, path, value); + } + } + users.set(change.userId, data); + } + } + + for (const user of users.values()) { + try { + await AppDataSource.getRepository(User).save(user); + } catch (e) { + if (e instanceof Error) { + error(e.stack); + } + } + } + lock.clear(); + + flushQueue.splice(flushQueue.indexOf(id) ,1); + debug('flush', `done - ${id}`); +} + +(async function flushInterval() { + await flush(); + setTimeout(() => flushInterval(), MINUTE); +})(); \ No newline at end of file diff --git a/backend/src/helpers/user/getBotId.ts b/backend/src/helpers/user/getBotId.ts new file mode 100644 index 000000000..4a1b19a53 --- /dev/null +++ b/backend/src/helpers/user/getBotId.ts @@ -0,0 +1,5 @@ +import { variables } from '~/watchers.js'; + +export default function getBotId () { + return variables.get('services.twitch.botId') as string; +} \ No newline at end of file diff --git a/backend/src/helpers/user/getBotUserName.ts b/backend/src/helpers/user/getBotUserName.ts new file mode 100644 index 000000000..2eef24616 --- /dev/null +++ b/backend/src/helpers/user/getBotUserName.ts @@ -0,0 +1,5 @@ +import { variables } from '~/watchers.js'; + +export default function getBotUserName () { + return variables.get('services.twitch.botUsername') as string; +} \ No newline at end of file diff --git a/backend/src/helpers/user/getBroadcasterId.ts b/backend/src/helpers/user/getBroadcasterId.ts new file mode 100644 index 000000000..53a759277 --- /dev/null +++ b/backend/src/helpers/user/getBroadcasterId.ts @@ -0,0 +1,5 @@ +import { variables } from '~/watchers.js'; + +export default function getBroadcasterId () { + return variables.get('services.twitch.broadcasterId') as string; +} \ No newline at end of file diff --git a/backend/src/helpers/user/getBroadcasterUserName.ts b/backend/src/helpers/user/getBroadcasterUserName.ts new file mode 100644 index 000000000..31dd98f92 --- /dev/null +++ b/backend/src/helpers/user/getBroadcasterUserName.ts @@ -0,0 +1,5 @@ +import { variables } from '~/watchers.js'; + +export default function getBroadcasterUserName () { + return variables.get('services.twitch.broadcasterUsername') as string; +} \ No newline at end of file diff --git a/backend/src/helpers/user/getNameById.ts b/backend/src/helpers/user/getNameById.ts new file mode 100644 index 000000000..acfaab928 --- /dev/null +++ b/backend/src/helpers/user/getNameById.ts @@ -0,0 +1,16 @@ +import * as changelog from '~/helpers/user/changelog.js'; +import twitch from '~/services/twitch.js'; + +export default async function getNameById (userId: string): Promise { + const user = await changelog.get(userId); + if (!user) { + const getUserById = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUserById(userId)); + if (getUserById) { + changelog.update(userId, { userName: getUserById.name }); + return getUserById.name; + } else { + throw new Error('Cannot get username for userId ' + userId); + } + } + return user.userName; +} \ No newline at end of file diff --git a/backend/src/helpers/user/index.ts b/backend/src/helpers/user/index.ts new file mode 100644 index 000000000..f20bdf26d --- /dev/null +++ b/backend/src/helpers/user/index.ts @@ -0,0 +1,7 @@ +export * from './isBot.js'; +export * from './isBroadcaster.js'; +export * from './isIgnored.js'; +export * from './isModerator.js'; +export * from './isOwner.js'; +export * from './isSubscriber.js'; +export * from './isVIP.js'; \ No newline at end of file diff --git a/backend/src/helpers/user/isBot.ts b/backend/src/helpers/user/isBot.ts new file mode 100644 index 000000000..1574ac7e1 --- /dev/null +++ b/backend/src/helpers/user/isBot.ts @@ -0,0 +1,41 @@ +import type { UserInterface } from '@entity/user.js'; + +import { variables } from '~/watchers.js'; + +export function isBot(user: string | CommandOptions['sender'] | UserInterface | UserStateTags) { + try { + const botUsername = variables.get('services.twitch.botUsername') as string; + if (botUsername) { + return botUsername.toLowerCase().trim() === (typeof user === 'string' ? user : user.userName).toLowerCase().trim(); + } else { + return false; + } + } catch (e: any) { + return true; // we can expect, if user is null -> bot or admin + } +} + +export function isBotId(userId: string | undefined) { + try { + const botId = variables.get('services.twitch.botId') as string; + if (botId.length > 0) { + return botId === userId; + } else { + return false; + } + } catch (e: any) { + return true; // we can expect, if user is null -> bot or admin + } +} + +let _isBotSubscriber = false; +function isBotSubscriber (value: boolean): boolean; +function isBotSubscriber (): boolean; +function isBotSubscriber(value?: boolean) { + if (typeof value !== 'undefined') { + _isBotSubscriber = value; + } + return _isBotSubscriber; +} + +export { isBotSubscriber }; diff --git a/backend/src/helpers/user/isBroadcaster.ts b/backend/src/helpers/user/isBroadcaster.ts new file mode 100644 index 000000000..f91c8bc18 --- /dev/null +++ b/backend/src/helpers/user/isBroadcaster.ts @@ -0,0 +1,19 @@ +import { variables } from '~/watchers.js'; + +export function isBroadcaster(user: string | CommandOptions['sender'] | { username: string | null; userId?: number | string } | UserStateTags) { + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + try { + return broadcasterUsername.toLowerCase().trim() === (typeof user === 'string' ? user : user.userName?.toLowerCase().trim()); + } catch (e: any) { + return false; + } +} + +export function isBroadcasterId(userId: string) { + try { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + return broadcasterId === userId; + } catch (e: any) { + return false; + } +} \ No newline at end of file diff --git a/backend/src/helpers/user/isIgnored.ts b/backend/src/helpers/user/isIgnored.ts new file mode 100644 index 000000000..a9062f16c --- /dev/null +++ b/backend/src/helpers/user/isIgnored.ts @@ -0,0 +1,90 @@ +import { readFileSync } from 'fs'; + +import { HOUR } from '@sogebot/ui-helpers/constants.js'; +import { cloneDeep, isEqual } from 'lodash-es'; +import fetch from 'node-fetch'; + +import { isBroadcaster } from './isBroadcaster.js'; +import { timer } from '../../decorators.js'; +import { info } from '../log.js'; +import { + ignorelist, isIgnoredCache, +} from '../tmi/ignoreList.js'; + +let globalIgnoreList = JSON.parse(readFileSync('./assets/globalIgnoreList.json', 'utf8')); + +class HelpersUserIsIgnored { + @timer() + isIgnored(sender: { userName: string | null; userId?: string }) { + if (sender.userName === null) { + return false; // null can be bot from dashboard or event + } + + if (sender.userId && isIgnoredCache.has(sender.userId)) { + return isIgnoredCache.get(sender.userId); + } + + if (isIgnoredCache.has(sender.userName)) { + return isIgnoredCache.get(sender.userName); + } + + const isInIgnoreList = getIgnoreList().includes(sender.userName) || getIgnoreList().includes(sender.userId); + const isIgnoredCheck = (isInGlobalIgnoreList(sender) || isInIgnoreList) && !isBroadcaster(sender); + + if (sender.userId) { + isIgnoredCache.set(sender.userId, isIgnoredCheck); + } + isIgnoredCache.set(sender.userName, isIgnoredCheck); + return isIgnoredCheck; + } +} +const cl = new HelpersUserIsIgnored(); + +export function isInGlobalIgnoreList (sender: { userName: string | null; userId?: string }) { + return typeof getGlobalIgnoreList().find(data => { + return data.id === sender.userId || data.known_aliases.includes((sender.userName || '').toLowerCase()); + }) !== 'undefined'; +} + +export function isIgnoredSafe(sender: { userName: string | null; userId?: string }) { + const found = getGlobalIgnoreList().find(data => { + return data.id === sender.userId || data.known_aliases.includes((sender.userName || '').toLowerCase()); + }); + + return found ? !!found.safe : false; +} + +export function isIgnored(sender: { userName: string | null; userId?: string }) { + return cl.isIgnored(sender); +} + +export function getIgnoreList() { + return ignorelist.value.map((o) => { + return typeof o === 'string' ? o.trim().toLowerCase() : o; + }); +} + +setInterval(() => { + update(); +}, HOUR); + +const update = async () => { + const response = await fetch(`https://raw.githubusercontent.com/sogehige/sogeBot/master/assets/globalIgnoreList.json`); + + if (response.ok) { + const data = await response.json(); + if (!isEqual(data, globalIgnoreList)) { + globalIgnoreList = cloneDeep(data); + info('IGNORELIST: updated ignorelist from github'); + isIgnoredCache.clear(); + } + } +}; +update(); + +export function getGlobalIgnoreList() { + return Object.keys(globalIgnoreList) + .map(id => { + return { id, ...globalIgnoreList[id as unknown as keyof typeof globalIgnoreList] }; + }); +} \ No newline at end of file diff --git a/backend/src/helpers/user/isModerator.ts b/backend/src/helpers/user/isModerator.ts new file mode 100644 index 000000000..4f9093838 --- /dev/null +++ b/backend/src/helpers/user/isModerator.ts @@ -0,0 +1,8 @@ +import type { UserInterface } from '@entity/user.js'; + +export function isModerator(user: UserInterface | Omit): boolean { + if ('isMod' in user) { + return user.isMod; + } + return user.isModerator ?? false; +} \ No newline at end of file diff --git a/backend/src/helpers/user/isOwner.ts b/backend/src/helpers/user/isOwner.ts new file mode 100644 index 000000000..9091bb5b7 --- /dev/null +++ b/backend/src/helpers/user/isOwner.ts @@ -0,0 +1,17 @@ +import { UserInterface } from '@entity/user.js'; + +import { variables } from '~/watchers.js'; + +export function isOwner(user: string | CommandOptions['sender'] | UserInterface | UserStateTags) { + try { + const generalOwners = variables.get('services.twitch.generalOwners') as string[]; + if (generalOwners) { + const owners = generalOwners.filter(o => typeof o === 'string').map(o => o.trim().toLowerCase()); + return owners.includes(typeof user === 'string' ? user : user.userName.toLowerCase().trim()); + } else { + return false; + } + } catch (e: any) { + return true; // we can expect, if user is null -> bot or admin + } +} \ No newline at end of file diff --git a/backend/src/helpers/user/isSubscriber.ts b/backend/src/helpers/user/isSubscriber.ts new file mode 100644 index 000000000..01e953dc9 --- /dev/null +++ b/backend/src/helpers/user/isSubscriber.ts @@ -0,0 +1,5 @@ +import { UserInterface } from '@entity/user.js'; + +export function isSubscriber(user: UserInterface): boolean { + return user.isSubscriber ?? false; +} \ No newline at end of file diff --git a/backend/src/helpers/user/isVIP.ts b/backend/src/helpers/user/isVIP.ts new file mode 100644 index 000000000..f9d6cc264 --- /dev/null +++ b/backend/src/helpers/user/isVIP.ts @@ -0,0 +1,5 @@ +import { UserInterface } from '@entity/user.js'; + +export function isVIP(user: UserInterface): boolean { + return user.isVIP ?? false; +} \ No newline at end of file diff --git a/backend/src/helpers/user/random.ts b/backend/src/helpers/user/random.ts new file mode 100644 index 000000000..a2c775a4b --- /dev/null +++ b/backend/src/helpers/user/random.ts @@ -0,0 +1,70 @@ +import { User } from '@entity/user.js'; + +import { AppDataSource } from '~/database.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { variables } from '~/watchers.js'; + +async function getRandOrder() { + if (AppDataSource.options.type === 'better-sqlite3' || AppDataSource.options.type === 'postgres') { + return 'RANDOM()'; + } else { + return 'RAND()'; + } +} + +async function getRandomViewer() { + await changelog.flush(); + const botUsername = variables.get('services.twitch.botUsername') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + return AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .orderBy(await getRandOrder()) + .limit(1) + .getOne(); +} + +async function getRandomSubscriber() { + await changelog.flush(); + const botUsername = variables.get('services.twitch.botUsername') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + return AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .andWhere('user.isSubscriber = :isSubscriber', { isSubscriber: true }) + .orderBy(await getRandOrder()) + .limit(1) + .getOne(); +} + +async function getRandomOnlineViewer() { + await changelog.flush(); + const botUsername = variables.get('services.twitch.botUsername') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + return AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .andWhere('user.isOnline = :isOnline', { isOnline: true }) + .orderBy(await getRandOrder()) + .limit(1) + .getOne(); +} + +async function getRandomOnlineSubscriber() { + await changelog.flush(); + const botUsername = variables.get('services.twitch.botUsername') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + return AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .andWhere('user.isOnline = :isOnline', { isOnline: true }) + .andWhere('user.isSubscriber = :isSubscriber', { isSubscriber: true }) + .orderBy(await getRandOrder()) + .limit(1) + .getOne(); +} + +export { + getRandomViewer, getRandomOnlineViewer, + getRandomSubscriber, getRandomOnlineSubscriber, +}; \ No newline at end of file diff --git a/backend/src/integrations/_interface.ts b/backend/src/integrations/_interface.ts new file mode 100644 index 000000000..7c8adc80b --- /dev/null +++ b/backend/src/integrations/_interface.ts @@ -0,0 +1,9 @@ +import Module from '../_interface.js'; + +class Integration extends Module { + constructor() { + super('integrations', false); + } +} + +export default Integration; diff --git a/backend/src/integrations/discord.ts b/backend/src/integrations/discord.ts new file mode 100644 index 000000000..bec71e78e --- /dev/null +++ b/backend/src/integrations/discord.ts @@ -0,0 +1,806 @@ +import { DiscordLink } from '@entity/discord.js'; +import { Events } from '@entity/event.js'; +import { Permissions as PermissionsEntity } from '@entity/permissions.js'; +import { User } from '@entity/user.js'; +import { HOUR, MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { dayjs, timezone } from '@sogebot/ui-helpers/dayjsHelper.js'; +import chalk from 'chalk'; +import * as DiscordJs from 'discord.js'; +import { ChannelType, GatewayIntentBits } from 'discord.js'; +import { get } from 'lodash-es'; +import { IsNull, LessThan, Not } from 'typeorm'; +import { v5 as uuidv5 } from 'uuid'; + +import Integration from './_interface.js'; +import { + onChange, onStartup, onStreamEnd, onStreamStart, +} from '../decorators/on.js'; +import { + command, persistent, settings, +} from '../decorators.js'; +import events from '../events.js'; +import { Expects } from '../expects.js'; +import { Message } from '../message.js'; +import { Parser } from '../parser.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline, stats } from '~/helpers/api/index.js'; +import { attributesReplace } from '~/helpers/attributesReplace.js'; +import { + announceTypes, getOwner, getUserSender, isUUID, prepare, +} from '~/helpers/commons/index.js'; +import { isBotStarted, isDbConnected } from '~/helpers/database.js'; +import { debounce } from '~/helpers/debounce.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { + chatIn, chatOut, debug, error, info, warning, whisperOut, +} from '~/helpers/log.js'; +import { check } from '~/helpers/permissions/check.js'; +import { get as getPermission } from '~/helpers/permissions/get.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { getIdFromTwitch } from '~/services/twitch/calls/getIdFromTwitch.js'; +import { variables } from '~/watchers.js'; + +class Discord extends Integration { + client: DiscordJs.Client | null = null; + + @persistent() + embedStartedAt = ''; + @persistent() + embedMessageId = ''; + + @settings('general') + clientId = ''; + + @settings('general') + token = ''; + + @settings('bot') + guild = ''; + + @settings('bot') + listenAtChannels: string | string[] = ''; + + @settings('bot') + sendOnlineAnnounceToChannel = ''; + + @settings('bot') + onlineAnnounceMessage = ''; + + @settings('bot') + sendAnnouncesToChannel: { [key in typeof announceTypes[number]]: string } = { + bets: '', + duel: '', + general: '', + heist: '', + polls: '', + raffles: '', + scrim: '', + songs: '', + timers: '', + }; + + @settings('bot') + fields: string[] = ['$game', '$title', '$tags', '$startedAt', '$viewers', '$followers', '$subscribers']; + + @settings('bot') + fieldsDisabled: string[] = ['']; + + @settings('bot') + ignorelist: string[] = []; + + @settings('status') + onlinePresenceStatusDefault: 'online' | 'idle' | 'invisible' | 'dnd' = 'online'; + + @settings('status') + onlinePresenceStatusDefaultName = ''; + + @settings('status') + onlinePresenceStatusOnStream: 'streaming' | 'online' | 'idle' | 'invisible' | 'dnd' = 'online'; + + @settings('status') + onlinePresenceStatusOnStreamName = '$title'; + + @settings('mapping') + rolesMapping: { [permissionId: string]: string } = {}; + + @settings('bot') + deleteMessagesAfterWhile = false; + + generateEmbed(isOnline: boolean) { + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const profileImageUrl = variables.get('services.twitch.profileImageUrl') as string; + + const color = isOnline ? 0x00ff00 : 0xff0000; + const description = isOnline + ? `${broadcasterUsername.charAt(0).toUpperCase() + broadcasterUsername.slice(1)} started stream! Check it out!` + : `${broadcasterUsername.charAt(0).toUpperCase() + broadcasterUsername.slice(1)} is not streaming anymore! Check it next time!`; + + return new DiscordJs.EmbedBuilder() + .setURL('https://twitch.tv/' + broadcasterUsername) + .addFields( + this.fields + .filter((o) => this.filterFields(o, isOnline)) + .map((o) => this.prepareFields(o, isOnline))) + // Set the title of the field + .setTitle('https://twitch.tv/' + broadcasterUsername) + // Set the color of the embed + .setColor(color) + // Set the main content of the embed + .setDescription(description) + .setImage(isOnline ? `https://static-cdn.jtvnw.net/previews-ttv/live_user_${broadcasterUsername}-1920x1080.jpg?${Date.now()}`: null) + .setThumbnail(isOnline ? profileImageUrl : null) + .setFooter({ text: prepare('integrations.discord.announced-by') + ' - https://www.sogebot.xyz' }); + } + + @onStartup() + onStartup() { + this.addEvent(); + + // embed updater + setInterval(async () => { + if (isStreamOnline.value && this.client && this.embedMessageId.length > 0) { + this.changeClientOnlinePresence(); + const channel = this.client.guilds.cache.get(this.guild)?.channels.cache.get(this.sendOnlineAnnounceToChannel); + if (channel) { + const message = await (channel as DiscordJs.TextChannel).messages.fetch(this.embedMessageId); + + debug('discord.embed', `Trying to update message ${this.embedMessageId}.`); + if (message) { + debug('discord.embed', `Updating message ${this.embedMessageId}.`); + message.edit({ embeds: [this.generateEmbed(true)] }) + .then(() => debug('discord.embed', `Message ${this.embedMessageId} was updated.`)) + .catch((e) => debug('discord.embed', e)); + } else { + debug('discord.embed', `Error during update of ${this.embedMessageId}. Message not found. ${JSON.stringify({ message })}`); + } + } + } + }, MINUTE * 10); + + setInterval(() => this.updateRolesOfLinkedUsers(), HOUR); + } + + async updateRolesOfLinkedUsers() { + if (!isDbConnected || !this.client) { + return; + } + + // go through mappings and delete zombies + for (const mapped of Object.keys(this.rolesMapping)) { + const doesPermissionExist = typeof (await getPermission(mapped)) !== 'undefined'; + if (!doesPermissionExist || this.rolesMapping[mapped] === '') { + // delete permission if doesn't exist anymore + delete this.rolesMapping[mapped]; + continue; + } + } + + const linkedUsers = await AppDataSource.getRepository(DiscordLink).find(); + for (const user of linkedUsers) { + if (!user.userId) { + continue; + } + const guild = this.client.guilds.cache.get(this.guild); + if (!guild) { + return warning('No servers found for discord'); + } + + let discordUser: DiscordJs.GuildMember; + try { + discordUser = await guild.members.fetch(user.discordId); + } catch (e) { + await AppDataSource.getRepository(DiscordLink).delete({ userId: user.userId }); + warning(`Discord user ${user.tag}@${user.discordId} not found - removed from link table`); + continue; + } + + const botPermissionsSortedByPriority = await PermissionsEntity.find({ + order: { order: 'ASC' }, + }); + + const alreadyAssignedRoles: string[] = []; + for (const botPermission of botPermissionsSortedByPriority) { + if (!this.rolesMapping[botPermission.id]) { + debug('discord.roles', `Permission ${botPermission.name}#${botPermission.id} is not mapped.`); + // we don't have mapping set for this permission + continue; + } + + // role was already assigned by higher permission (we don't want to remove it) + // e.g. Ee have same role for Subscriber and VIP + // User is subscriber but not VIP -> should have role + if (alreadyAssignedRoles.includes(this.rolesMapping[botPermission.id])) { + debug('discord.roles', `Role ${this.rolesMapping[botPermission.id]} is already mapped for user ${user.userId}`); + continue; + } + + const haveUserAnAccess = (await check(user.userId, botPermission.id, true)).access; + const role = await guild.roles.fetch(this.rolesMapping[botPermission.id]); + if (!role) { + warning(`Role with ID ${this.rolesMapping[botPermission.id]} not found on your Discord Server`); + continue; + } + debug('discord.roles', `User ${user.userId} - permission ${botPermission.id} - role ${role.name} - ${haveUserAnAccess}`); + + if (haveUserAnAccess) { + // add role to user + alreadyAssignedRoles.push(role.id); + if (discordUser.roles.cache.has(role.id)) { + debug('discord.roles', `User ${user.userId} already have role ${role.name}`); + } else { + discordUser.roles.add(role).catch(roleError => { + warning(`Cannot add role '${role.name}' to user ${user.userId}, check permission for bot (bot cannot set role above his own)`); + warning(roleError); + }).then(member => { + debug('discord.roles', `User ${user.userId} have new role ${role.name}`); + }); + } + } else { + // remove role from user + if (!discordUser.roles.cache.has(role.id)) { + debug('discord.roles', `User ${user.userId} already doesn't have role ${role.name}`); + } else { + discordUser.roles.remove(role).catch(roleError => { + warning('Cannot remove role to user, check permission for bot (bot cannot set role above his own)'); + warning(roleError); + }).then(member => { + debug('discord.roles', `User ${user.userId} have removed role ${role.name}`); + }); + } + } + } + } + } + + @onStartup() + @onChange('enabled') + @onChange('token') + async onStateChange(key: string, value: boolean) { + if (await debounce(uuidv5('onStateChange', this.uuid), 1000)) { + if (this.enabled && this.token.length > 0) { + this.initClient(); + if (this.client) { + this.client.login(this.token).catch((reason) => { + error(chalk.bgRedBright('DISCORD') + ': ' + reason); + }); + } + } else { + if (this.client) { + this.client.destroy(); + } + } + } + } + + async removeExpiredLinks() { + // remove expired links + await AppDataSource.getRepository(DiscordLink).delete({ userId: IsNull(), createdAt: LessThan(Date.now() - (MINUTE * 10)) }); + } + + @command('!unlink') + async unlinkAccounts(opts: CommandOptions) { + this.removeExpiredLinks(); + await AppDataSource.getRepository(DiscordLink).delete({ userId: opts.sender.userId }); + return [{ response: prepare('integrations.discord.all-your-links-were-deleted-with-sender', { sender: opts.sender }), ...opts }]; + } + + @command('!link') + async linkAccounts(opts: CommandOptions) { + enum errors { NOT_UUID } + this.removeExpiredLinks(); + + try { + const [ uuid ] = new Expects(opts.parameters).everything({ name: 'uuid' }).toArray(); + if (!isUUID(uuid)) { + throw new Error(String(errors.NOT_UUID)); + } + + const link = await AppDataSource.getRepository(DiscordLink).findOneByOrFail({ id: uuid, userId: IsNull() }); + // link user + await AppDataSource.getRepository(DiscordLink).save({ ...link, userId: opts.sender.userId }); + return [{ response: prepare('integrations.discord.this-account-was-linked-with', { sender: opts.sender, discordTag: link.tag }), ...opts }]; + } catch (e: any) { + if (e.message.includes('Expected parameter')) { + return [ + { response: prepare('integrations.discord.help-message', { sender: opts.sender, command: this.getCommand('!link') }), ...opts }, + ]; + } else { + if (e.message !== String(errors.NOT_UUID)) { + warning(e.stack); + } + return [{ response: prepare('integrations.discord.invalid-or-expired-token', { sender: opts.sender }), ...opts }]; + } + } + } + + @onStreamEnd() + async updateStreamStartAnnounce() { + this.changeClientOnlinePresence(); + const channel = this.client?.guilds.cache.get(this.guild)?.channels.cache.get(this.sendOnlineAnnounceToChannel); + if (channel && this.embedMessageId !== '') { + try { + const message = await (channel as DiscordJs.TextChannel).messages.fetch(this.embedMessageId); + + debug('discord.embed', `Trying to update message ${this.embedMessageId}.`); + if (message) { + debug('discord.embed', `Updating message ${this.embedMessageId}.`); + message.edit({ embeds: [this.generateEmbed(false)] }) + .then(() => debug('discord.embed', `Message ${this.embedMessageId} was updated.`)) + .catch((e) => debug('discord.embed', e)); + } else { + debug('discord.embed', `Error during update of ${this.embedMessageId}. Message not found. ${JSON.stringify({ message })}`); + } + } catch (e: any) { + warning(`Discord embed couldn't be changed to offline - ${e.message}`); + } + } + this.embedMessageId = ''; + } + + filterFields(o: string, isOnline: boolean) { + const broadcasterType = variables.get('services.twitch.broadcasterType') as string; + + if (this.fieldsDisabled.includes(o)) { + return false; + } + + if (!isOnline) { + if (['$viewers', '$followers', '$subscribers'].includes(o)) { + return false; + } + } + + if (o === '$subscribers' && broadcasterType !== '') { + return false; + } + + if (o === '$tags' && (stats.value.currentTags ?? []).length === 0) { + // don't show empty tags + return false; + } + return true; + } + + prepareFields(o: string, isOnline: boolean) { + if (o === '$game') { + return { name: prepare('webpanel.responses.variable.game'), value: stats.value.currentGame ?? '' }; + } + if (o === '$title') { + return { name: prepare('webpanel.responses.variable.title'), value: stats.value.currentTitle ?? '' }; + } + if (o === '$tags') { + return { name: prepare('webpanel.responses.variable.tags'), value: `${(stats.value.currentTags ?? []).map(tag => `${tag}`).join(', ')}` ?? '' }; + } + if (o === '$startedAt') { + if (isOnline) { + return { name: prepare('integrations.discord.started-at'), value: this.embedStartedAt, inline: true }; + } else { + return { name: prepare('integrations.discord.streamed-at'), value: `${this.embedStartedAt} - ${dayjs().tz(timezone).format('LLL')}`, inline: true }; + } + } + if (o === '$viewers') { + return { name: prepare('webpanel.viewers'), value: String(stats.value.currentViewers), inline: true }; + } + if (o === '$followers') { + return { name: prepare('webpanel.followers'), value: String(stats.value.currentFollowers), inline: true }; + } + if (o === '$subscribers') { + return { name: prepare('webpanel.subscribers'), value: String(stats.value.currentSubscribers), inline: true }; + } + return { name: o, value: 'unknown field' }; + } + + @onStreamStart() + async sendStreamStartAnnounce() { + this.changeClientOnlinePresence(); + try { + if (this.client && this.sendOnlineAnnounceToChannel.length > 0 && this.guild.length > 0) { + const channel = this.client.guilds.cache.get(this.guild)?.channels.cache.get(this.sendOnlineAnnounceToChannel); + if (!channel) { + throw new Error(`Channel ${this.sendOnlineAnnounceToChannel} not found on your discord server`); + } + this.embedStartedAt = dayjs().tz(timezone).format('LLL'); + // Send the embed to the same channel as the message + const message = await (channel as DiscordJs.TextChannel).send({ + content: this.onlineAnnounceMessage.length > 0 ? this.onlineAnnounceMessage : undefined, + embeds: [this.generateEmbed(true)], + }); + this.embedMessageId = message.id; + chatOut(`#${(channel as DiscordJs.TextChannel).name}: [[online announce embed]] [${this.client.user?.tag}]`); + } + } catch (e: any) { + warning(e.stack); + } + } + + @onChange('onlinePresenceStatusOnStreamName') + @onChange('onlinePresenceStatusDefaultName') + @onChange('onlinePresenceStatusOnStream') + @onChange('onlinePresenceStatusDefault') + async changeClientOnlinePresence() { + if (!isBotStarted) { + setTimeout(() => { + this.changeClientOnlinePresence(); + }, 1000); + return; + } + try { + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + if (isStreamOnline.value) { + const activityString = await new Message(this.onlinePresenceStatusOnStreamName).parse(); + if (this.onlinePresenceStatusOnStream === 'streaming') { + this.client?.user?.setStatus('online'); + this.client?.user?.setPresence({ + status: 'online', + activities: [{ + name: activityString, type: DiscordJs.ActivityType.Streaming, url: `https://twitch.tv/${broadcasterUsername}`, + }], + }); + } else { + this.client?.user?.setStatus(this.onlinePresenceStatusOnStream); + if (activityString !== '') { + this.client?.user?.setActivity(''); + } else { + this.client?.user?.setPresence({ status: this.onlinePresenceStatusOnStream, activities: [{ name: activityString }] }); + } + } + } else { + const activityString = await new Message(this.onlinePresenceStatusDefaultName).parse(); + if (activityString !== ''){ + this.client?.user?.setStatus(this.onlinePresenceStatusDefault); + this.client?.user?.setPresence({ status: this.onlinePresenceStatusDefault, activities: [{ name: activityString }] }); + } else { + this.client?.user?.setActivity(''); + this.client?.user?.setStatus(this.onlinePresenceStatusDefault); + } + } + } catch (e: any) { + warning(e.stack); + } + } + + public addEvent(){ + if (typeof events === 'undefined') { + setTimeout(() => this.addEvent(), 1000); + } else { + events.supportedOperationsList.push( + { + id: 'send-discord-message', definitions: { channel: '', messageToSend: '' }, fire: this.fireSendDiscordMessage, + }, + ); + } + } + + /* note: as we are using event, we need to use self as pointer to discord class */ + public async fireSendDiscordMessage(operation: Events.OperationDefinitions, attributes: Events.Attributes): Promise { + const dMchannel = String(operation.channel); + try { + if (self.client === null) { + throw new Error('Discord integration is not connected'); + } + const userName = attributes.username === null || typeof attributes.username === 'undefined' ? getOwner() : attributes.username; + await changelog.flush(); + const userObj = await AppDataSource.getRepository(User).findOneBy({ userName }); + if (!attributes.test) { + if (!userObj) { + changelog.update(await getIdFromTwitch(userName), { userName }); + return self.fireSendDiscordMessage(operation, { ...attributes, userName }); + } + } + + const message = attributesReplace(attributes, String(operation.messageToSend)); + const messageContent = await self.replaceLinkedUsernameInMessage(await new Message(message).parse()); + const channel = await self.client.guilds.cache.get(self.guild)?.channels.cache.get(dMchannel); + await (channel as DiscordJs.TextChannel).send(messageContent); + chatOut(`#${(channel as DiscordJs.TextChannel).name}: ${messageContent} [${self.client.user?.tag}]`); + } catch (e: any) { + warning(e.stack); + } + } + + async replaceLinkedUsernameInMessage(message: string) { + // search linked users and change to @ + let match; + const usernameRegexp = /@(?[A-Za-z0-9_]{3,15})\b/g; + while ((match = usernameRegexp.exec(message)) !== null) { + if (match) { + const username = match.groups?.username as string; + const userId = await users.getIdByName(username); + const link = await AppDataSource.getRepository(DiscordLink).findOneBy({ userId }); + if (link) { + message = message.replace(`@${username}`, `<@${link.discordId}>`); + } + } + } + return message; + } + + initClient() { + if (!this.client) { + this.client = new DiscordJs.Client({ + intents: [ + GatewayIntentBits.GuildMessages, + GatewayIntentBits.Guilds, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: [ + DiscordJs.Partials.Reaction, + DiscordJs.Partials.Message, + DiscordJs.Partials.Channel, + ], + }); + this.client.on('ready', () => { + if (this.client) { + info(chalk.yellow('DISCORD: ') + `Logged in as ${get(this.client, 'user.tag', 'unknown')}!`); + this.changeClientOnlinePresence(); + this.updateRolesOfLinkedUsers(); + } + }); + this.client.on('error', (err) => { + error(`DISCORD: ${err.stack || err.message}`); + }); + + this.client.on('messageCreate', async (msg) => { + if (this.client && this.guild) { + + const isSelf = msg.author.tag === get(this.client, 'user.tag', null); + const isDM = msg.channel.type === ChannelType.DM; + const isDifferentGuild = msg.guild?.id !== this.guild; + const isInIgnoreList + = this.ignorelist.includes(msg.author.tag) + || this.ignorelist.includes(msg.author.id) + || this.ignorelist.includes(msg.author.username); + if (isSelf || isDM || isDifferentGuild || isInIgnoreList) { + return; + } + + if (msg.channel.type === ChannelType.GuildText) { + const listenAtChannels = [ + ...Array.isArray(this.listenAtChannels) ? this.listenAtChannels : [this.listenAtChannels], + ].filter(o => o !== ''); + if (listenAtChannels.includes(msg.channel.id)) { + this.message(msg.content, msg.channel, msg.author, msg); + } + } + } + }); + } + } + + async message(content: string, channel: DiscordJsTextChannel, author: DiscordJsUser, msg?: DiscordJs.Message) { + chatIn(`#${channel.name}: ${content} [${author.tag}]`); + if (msg) { + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + if (content === this.getCommand('!_debug')) { + info('======= COPY DISCORD DEBUG MESSAGE FROM HERE ======='); + info('Content: '); + info(content); + info('Author: '); + info(JSON.stringify(author, null, 2)); + info('Message: '); + info(JSON.stringify(msg, null, 2)); + info('Channel: '); + info(JSON.stringify(channel, null, 2)); + info('======= END OF DISCORD DEBUG MESSAGE ======='); + + if (this.deleteMessagesAfterWhile) { + setTimeout(() => { + msg.delete(); + }, 10000); + } + return; + } + if (content === this.getCommand('!link')) { + this.removeExpiredLinks(); + const link = await AppDataSource.getRepository(DiscordLink).save({ + userId: null, + tag: author.tag, + discordId: author.id, + createdAt: Date.now(), + }); + const message = prepare('integrations.discord.link-whisper', { + tag: author.tag, + broadcaster: broadcasterUsername, + id: link.id, + command: this.getCommand('!link'), + }); + try { + await author.send(message); + whisperOut(`${author.tag}: ${message}`); + } catch (e) { + const reply = await msg.reply(`@${author.tag}, Cannot send whisper to you. Please enable it in your Discord settings.`); + chatOut(`#${channel.name}: @${author.tag}, Cannot send whisper to you. Please enable it in your Discord settings. [${author.tag}]`); + if (this.deleteMessagesAfterWhile) { + setTimeout(() => { + msg.delete(); + reply.delete(); + }, 10000); + } + } + + const reply = await msg.reply(prepare('integrations.discord.check-your-dm')); + chatOut(`#${channel.name}: @${author.tag}, ${prepare('integrations.discord.check-your-dm')} [${author.tag}]`); + if (this.deleteMessagesAfterWhile) { + setTimeout(() => { + msg.delete(); + reply.delete(); + }, 10000); + } + return; + } else if (content === this.getCommand('!unlink')) { + await AppDataSource.getRepository(DiscordLink).delete({ discordId: author.id }); + const reply = await msg.reply(prepare('integrations.discord.all-your-links-were-deleted')); + chatOut(`#${channel.name}: @${author.tag}, ${prepare('integrations.discord.all-your-links-were-deleted')} [${author.tag}]`); + if (this.deleteMessagesAfterWhile) { + setTimeout(() => { + msg.delete(); + reply.delete(); + }, 10000); + } + return; + } + } + try { + // get linked account + const link = await AppDataSource.getRepository(DiscordLink).findOneByOrFail({ discordId: author.id, userId: Not(IsNull()) }); + if (link.userId) { + const user = await changelog.getOrFail(link.userId); + const parser = new Parser(); + parser.started_at = (msg || { createdTimestamp: Date.now() }).createdTimestamp; + parser.discord = { author, channel }; + parser.sender = getUserSender(user.userId, user.userName); + + eventEmitter.emit('keyword-send-x-times', { + userName: user.userName, message: content, source: 'discord', + }); + if (content.startsWith('!')) { + eventEmitter.emit('command-send-x-times', { + userName: user.userName, message: content, source: 'discord', + }); + } + + parser.message = content; + parser.process().then(responses => { + if (responses) { + for (let i = 0; i < responses.length; i++) { + setTimeout(async () => { + if (channel.type === ChannelType.GuildText) { + const messageToSend = await new Message(await responses[i].response).parse({ + ...responses[i].attr, + forceWithoutAt: true, // we dont need @ + sender: { ...responses[i].sender }, + discord: { author, channel }, + }) as string; + const reply = await channel.send(messageToSend); + chatOut(`#${channel.name}: ${messageToSend} [${author.tag}]`); + if (this.deleteMessagesAfterWhile) { + setTimeout(() => { + reply.delete(); + }, 10000); + } + } + }, 1000 * i); + } + } + if (this.deleteMessagesAfterWhile) { + if (msg) { + setTimeout(() => { + msg.delete(); + }, 10000); + } + } + }); + } + } catch (e: any) { + const message = prepare('integrations.discord.your-account-is-not-linked', { command: this.getCommand('!link') }); + if (msg) { + const reply = await msg.reply(message); + chatOut(`#${channel.name}: @${author.tag}, ${message} [${author.tag}]`); + if (this.deleteMessagesAfterWhile) { + setTimeout(() => { + msg.delete(); + reply.delete(); + }, 10000); + } + } + } + } + + sockets() { + adminEndpoint('/integrations/discord', 'discord::getRoles', async (cb) => { + try { + if (this.client && this.guild) { + return cb(null, this.client.guilds.cache.get(this.guild)?.roles.cache + .sort((a, b) => { + const nameA = a.name.toUpperCase(); // ignore upper and lowercase + const nameB = b.name.toUpperCase(); // ignore upper and lowercase + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + // names must be equal + return 0; + }) + .map(o => ({ text: `${o.name} ${o.id}`, value: o.id })) || [], + ); + } else { + cb(null, []); + } + } catch (e: any) { + cb(e.message, []); + } + }); + adminEndpoint('/integrations/discord', 'discord::getGuilds', async (cb) => { + try { + if (this.client) { + await this.client.guilds.fetch(); + return cb(null, this.client.guilds.cache + .sort((a, b) => { + const nameA = a.name.toUpperCase(); // ignore upper and lowercase + const nameB = b.name.toUpperCase(); // ignore upper and lowercase + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + // names must be equal + return 0; + }) + .map(o => ({ text: `${o.name} ${o.id}`, value: o.id }))); + } else { + cb(null, []); + } + } catch (e: any) { + cb(e.message, []); + } + }); + adminEndpoint('/integrations/discord', 'discord::getChannels', async (cb) => { + try { + if (this.client && this.guild) { + cb(null, this.client.guilds.cache.get(this.guild)?.channels.cache + .filter(o => o.type === ChannelType.GuildText) + .sort((a, b) => { + const nameA = (a as DiscordJs.TextChannel).name.toUpperCase(); // ignore upper and lowercase + const nameB = (b as DiscordJs.TextChannel).name.toUpperCase(); // ignore upper and lowercase + if (nameA < nameB) { + return -1; + } + if (nameA > nameB) { + return 1; + } + // names must be equal + return 0; + }) + .map(o => ({ text: `#${(o as DiscordJs.TextChannel).name} ${o.id}`, value: o.id })) || [], + ); + } else { + cb(null, []); + } + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/integrations/discord', 'discord::authorize', async (cb) => { + if (this.token === '' || this.clientId === '') { + cb('Cannot authorize! Missing clientId or token. Please save changes before authorizing.', null); + } else { + try { + cb(null, { do: 'redirect', opts: [`https://discordapp.com/oauth2/authorize?&scope=bot&permissions=8&client_id=${this.clientId}`] }); + } catch (e: any) { + error(e.stack); + cb(e.stack, null); + } + } + }); + } +} + +const self = new Discord(); +export default self; diff --git a/backend/src/integrations/donatello.ts b/backend/src/integrations/donatello.ts new file mode 100644 index 000000000..1dd20f10b --- /dev/null +++ b/backend/src/integrations/donatello.ts @@ -0,0 +1,197 @@ +import { Currency, UserTip, UserTipInterface } from '@entity/user.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import axios from 'axios'; + +import Integration from './_interface.js'; +import { onStartup } from '../decorators/on.js'; +import { persistent, settings } from '../decorators.js'; +import eventlist from '../overlays/eventlist.js'; +import alerts from '../registries/alerts.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline, stats } from '~/helpers/api/index.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import rates from '~/helpers/currency/rates.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { triggerInterfaceOnTip } from '~/helpers/interface/triggers.js'; +import { + error, tip, +} from '~/helpers/log.js'; + +type DonatelloResponse = { + content: { + pubId: string; + clientName: string; + message: string; + amount: string; + currency: Currency; + goal: string; + isPublished: boolean; + createdAt: string; // Eastern European Standard Time (EET) + }[], + page: number; + size: number; + pages: number; + first: boolean; + last: boolean; + total: number; +}; + +const DONATES_URL = 'https://donatello.to/api/v1/donates' as const; + +function getTips (page: number, jwtToken: string, lastPubId: string): Promise<[isProcessed: boolean, tips: DonatelloResponse['content']]> { + return new Promise((resolve, reject) => { + axios.get(`${DONATES_URL}?size=100&page=${page}`, { + headers: { + Accept: 'application/json', + 'X-Token': jwtToken, + }, + }).then(response => { + const data = response.data; + const isLastPage = data.last; + const lastIdx = data.content.findIndex(o => o.pubId === lastPubId); + let tips: DonatelloResponse['content'] = data.content; + if (lastIdx !== -1) { + tips = data.content.slice(0, lastIdx); + } + resolve([isLastPage || lastIdx !== -1, tips]); + }).catch(e => reject(e)); + }); +} + +class Donatello extends Integration { + @persistent() + lastPubId = ''; + + @settings() + token = ''; + + @onStartup() + interval() { + setInterval(async () => { + if (this.token.length === 0 || !this.enabled) { + return; + } + + try { + let page = 0; + const aggregatedTips: DonatelloResponse['content'] = []; + let isProcessed = false; + while(!isProcessed) { + const data = await getTips(page, this.token, this.lastPubId); + isProcessed = data[0]; + const tips = data[1]; + + if (isProcessed) { + aggregatedTips.push(...tips); + break; + } + + page++; + } + + if (aggregatedTips.length > 0) { + this.lastPubId = aggregatedTips[0].pubId; + } + for (const item of aggregatedTips) { + this.parse(item); + } + } catch (e) { + error('DONATELLO: Something wrong during tips fetch.'); + error(e); + } + }, constants.MINUTE); + } + + async parse(data: DonatelloResponse['content'][number], isAnonymous = false): Promise { + const timestamp = Date.now(); + + const username = data.clientName; + const amount = Number(data.amount); + + isAnonymous = isAnonymous || username === '' || username === null ; + + if (!isAnonymous) { + try { + const user = await users.getUserByUsername(username); + tip(`${username.toLowerCase()}${user.userId ? '#' + user.userId : ''}, amount: ${amount.toFixed(2)}${data.currency}, message: ${data.message}`); + + eventlist.add({ + event: 'tip', + amount: amount, + currency: data.currency, + userId: String(await users.getIdByName(username.toLowerCase()) ?? '0'), + message: data.message, + timestamp, + }); + + eventEmitter.emit('tip', { + isAnonymous: false, + userName: username.toLowerCase(), + amount: amount.toFixed(2), + currency: data.currency, + amountInBotCurrency: Number(exchange(amount, data.currency, mainCurrency.value)).toFixed(2), + currencyInBot: mainCurrency.value, + message: data.message, + }); + + alerts.trigger({ + event: 'tip', + service: 'donationalerts', + name: username.toLowerCase(), + amount: Number(amount.toFixed(2)), + tier: null, + currency: data.currency, + monthsName: '', + message: data.message, + }); + + const newTip: UserTipInterface = { + amount: Number(amount), + currency: data.currency, + sortAmount: exchange(Number(amount), data.currency, mainCurrency.value), + message: data.message, + tippedAt: timestamp, + exchangeRates: rates, + userId: user.userId, + }; + AppDataSource.getRepository(UserTip).save(newTip); + } catch { + return this.parse(data, true); + } + } else { + tip(`${username}#__anonymous__, amount: ${Number(amount).toFixed(2)}${data.currency}, message: ${data.message}`); + alerts.trigger({ + event: 'tip', + name: username, + amount: Number(amount.toFixed(2)), + tier: null, + currency: data.currency, + monthsName: '', + message: data.message, + }); + eventlist.add({ + event: 'tip', + amount: amount, + currency: data.currency, + userId: `${username}#__anonymous__`, + message: data.message, + timestamp, + }); + } + triggerInterfaceOnTip({ + userName: username.toLowerCase(), + amount: amount, + message: data.message, + currency: data.currency, + timestamp, + }); + if (isStreamOnline.value) { + stats.value.currentTips = stats.value.currentTips + Number(exchange(amount, data.currency, mainCurrency.value)); + } + } +} + +export default new Donatello(); diff --git a/backend/src/integrations/donationalerts.ts b/backend/src/integrations/donationalerts.ts new file mode 100644 index 000000000..6558a4bf1 --- /dev/null +++ b/backend/src/integrations/donationalerts.ts @@ -0,0 +1,296 @@ +import { Currency, UserTip, UserTipInterface } from '@entity/user.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import axios from 'axios'; +import chalk from 'chalk'; + +import Integration from './_interface.js'; +import { onStartup } from '../decorators/on.js'; +import { persistent, settings } from '../decorators.js'; +import eventlist from '../overlays/eventlist.js'; +import alerts from '../registries/alerts.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline, stats } from '~/helpers/api/index.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import rates from '~/helpers/currency/rates.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { triggerInterfaceOnTip } from '~/helpers/interface/triggers.js'; +import { + error, info, tip, +} from '~/helpers/log.js'; +import { adminEndpoint } from '~/helpers/socket.js'; + +const parsedTips: number[] = []; + +type DonationAlertsResponse = { + 'data': { + 'id': number, + 'name': string, + 'username': string, + 'message': string, + 'amount': number, + 'currency': Currency, + 'is_shown': number, + 'created_at': string /* 2019-09-29 09:00:00 */, + 'shown_at': null + }[], + 'links': { + 'first': string, + 'last': string, + 'prev': null | string, + 'next': null | string + }, + 'meta': { + 'current_page': number, + 'from': number, + 'last_page': number, + 'path': 'https://www.donationalerts.com/api/v1/alerts/donations', + 'per_page': number, + 'to': number, + 'total': number, + } +}; + +function getTips (page: number, jwtToken: string, afterDate: number): Promise<[last_page: number, tips: DonationAlertsResponse['data']]> { + return new Promise((resolve, reject) => { + axios.get(`https://www.donationalerts.com/api/v1/alerts/donations?page=${page}`, { + headers: { + Accept: 'application/json', + Authorization: 'Bearer ' + jwtToken, + }, + }).then(response => { + const data = response.data; + // check if we are at afterDate + const tips = data.data.filter(o => new Date(o.created_at).getTime() > afterDate); + if (tips.length < data.data.length) { + resolve([1, tips]); + } else { + resolve([Number(data.meta.last_page), data.data]); + } + }).catch(e => reject(e)); + }); +} + +class Donationalerts extends Integration { + @persistent() + afterDate = 0; + + isRefreshing = false; + + @settings() + channel = ''; + + @settings() + access_token = ''; + + @settings() + refresh_token = ''; + + @onStartup() + interval() { + setInterval(async () => { + if (this.channel.length === 0 || !this.enabled) { + return; + } + + try { + // get initial data + let [last_page, tips] = await getTips(1, this.access_token, this.afterDate); + + if (last_page > 1) { + for(let page = 2; page <= last_page; page++) { + const data = await getTips(page, this.access_token, this.afterDate); + last_page = data[0]; + tips = [...tips, ...data[1]]; + } + } + for (const item of tips) { + this.parse(item); + } + + if (tips.length > 0) { + this.afterDate = new Date(tips[0].created_at).getTime(); + } + } catch (e) { + this.refresh(); + } + }, 10 * constants.SECOND); + } + + refresh() { + if (this.isRefreshing) { + return; + } + if (this.refresh_token.length > 0) { + this.isRefreshing = true; + // get new refresh and access token + axios.request<{ + 'token_type': 'Bearer', + 'access_token': string, + 'expires_in': number, + 'refresh_token': string, + }>({ + url: 'https://credentials.sogebot.xyz/donationalerts', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: { refreshToken: this.refresh_token }, + }).then((response) => { + this.refresh_token = response.data.refresh_token; + this.access_token = response.data.access_token; + info(chalk.yellow('DONATIONALERTS:') + ' Access token refreshed.'); + }).catch((e) => { + error(chalk.yellow('DONATIONALERTS:') + ' Bot was unable to refresh access token. Please recreate your tokens.'); + error(e.stack); + this.channel = ''; + this.access_token = ''; + this.refresh_token = ''; + }).finally(() => { + this.isRefreshing = false; + }); + } else { + error(chalk.yellow('DONATIONALERTS:') + ' Bot was unable to refresh access token. Please recreate your tokens.'); + error(chalk.yellow('DONATIONALERTS:') + ' No refresh token'); + this.channel = ''; + this.access_token = ''; + this.refresh_token = ''; + } + } + + sockets() { + adminEndpoint('/integrations/donationalerts', 'donationalerts::validate', (token, cb) => { + axios('https://www.donationalerts.com/api/v1/alerts/donations', { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: 'Bearer ' + token, + }, + }).then(() => { + cb(null); + }).catch((e: unknown) => cb(e as Error)); + }); + adminEndpoint('/integrations/donationalerts', 'donationalerts::revoke', async (cb) => { + self.channel = ''; + self.refresh_token = ''; + self.access_token = ''; + info(`DONATIONALERTS: User access revoked.`); + cb(null); + }); + adminEndpoint('/integrations/donationalerts', 'donationalerts::token', async (tokens, cb) => { + self.access_token = tokens.accessToken; + self.refresh_token = tokens.refreshToken; + await this.connect(); + cb(null); + }); + } + + @onStartup() + async connect () { + if (this.access_token.trim() === '') { + this.channel = ''; + return; + } + + try { + const request = await axios.get('https://www.donationalerts.com/api/v1/user/oauth', { headers: { 'Authorization': `Bearer ${this.access_token}` } }); + this.channel = request.data.data.name, + info(chalk.yellow('DONATIONALERTS:') + ` Access token check OK. Using channel ${this.channel}`); + } catch (e) { + error(chalk.yellow('DONATIONALERTS:') + ' Access token is not valid.'); + this.channel = ''; + return; + } + } + + async parse(data: DonationAlertsResponse['data'][number]) { + // we will save id to not parse it twice (websocket shenanigans may happen) + if (parsedTips.includes(data.id)) { + return; + } else { + parsedTips.unshift(data.id); + parsedTips.length = 20; + } + + const timestamp = Date.now(); + const isAnonymous = data.username === '' || data.username === null ; + + if (!isAnonymous) { + const user = await users.getUserByUsername(data.username); + tip(`${data.username.toLowerCase()}${user.userId ? '#' + user.userId : ''}, amount: ${Number(data.amount).toFixed(2)}${data.currency}, message: ${data.message}`); + + eventlist.add({ + event: 'tip', + amount: data.amount, + currency: data.currency, + userId: String(await users.getIdByName(data.username.toLowerCase()) ?? '0'), + message: data.message, + timestamp, + }); + + eventEmitter.emit('tip', { + isAnonymous: false, + userName: data.username.toLowerCase(), + amount: data.amount.toFixed(2), + currency: data.currency, + amountInBotCurrency: Number(exchange(Number(data.amount), data.currency, mainCurrency.value)).toFixed(2), + currencyInBot: mainCurrency.value, + message: data.message, + }); + + alerts.trigger({ + event: 'tip', + service: 'donationalerts', + name: data.username.toLowerCase(), + amount: Number(data.amount.toFixed(2)), + tier: null, + currency: data.currency, + monthsName: '', + message: data.message, + }); + + const newTip: UserTipInterface = { + amount: Number(data.amount), + currency: data.currency, + sortAmount: exchange(Number(data.amount), data.currency, mainCurrency.value), + message: data.message, + tippedAt: timestamp, + exchangeRates: rates, + userId: user.userId, + }; + AppDataSource.getRepository(UserTip).save(newTip); + } else { + tip(`anonymous#__anonymous__, amount: ${Number(data.amount).toFixed(2)}${data.currency}, message: ${data.message}`); + alerts.trigger({ + event: 'tip', + name: 'anonymous', + amount: Number(data.amount.toFixed(2)), + tier: null, + currency: data.currency, + monthsName: '', + message: data.message, + }); + eventlist.add({ + event: 'tip', + amount: data.amount, + currency: data.currency, + userId: `anonymous#__anonymous__`, + message: data.message, + timestamp, + }); + } + triggerInterfaceOnTip({ + userName: isAnonymous ? 'anonymous' : data.username.toLowerCase(), + amount: data.amount, + message: data.message, + currency: data.currency, + timestamp, + }); + if (isStreamOnline.value) { + stats.value.currentTips = stats.value.currentTips + Number(exchange(data.amount, data.currency, mainCurrency.value)); + } + } +} + +const self = new Donationalerts(); +export default self; diff --git a/backend/src/integrations/kofi.ts b/backend/src/integrations/kofi.ts new file mode 100644 index 000000000..16fe90c25 --- /dev/null +++ b/backend/src/integrations/kofi.ts @@ -0,0 +1,126 @@ +import { Currency } from '@entity/user.js'; + +import Integration from './_interface.js'; +import { onStartup } from '../decorators/on.js'; +import { settings } from '../decorators.js'; +import eventlist from '../overlays/eventlist.js'; +import alerts from '../registries/alerts.js'; +import users from '../users.js'; + +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/mainCurrency.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { triggerInterfaceOnTip } from '~/helpers/interface/triggers.js'; +import { + error, tip, info, +} from '~/helpers/log.js'; +import { app } from '~/helpers/panel.js'; + +type KoFiData = { + 'message_id':string, + 'timestamp':string, + 'type': 'Donation' | 'Subscription' | 'Shop Order' | 'Commission', + 'is_public':boolean, + 'from_name':string, + 'message':string, + 'amount':string, // 3.00 + 'url':string, + 'email':string, + 'currency':Currency, + 'is_subscription_payment':boolean, + 'is_first_subscription_payment':boolean, + 'kofi_transaction_id':string, + 'verification_token':string, + 'shop_items':null, + 'tier_name':null +}; + +class Kofi extends Integration { + @settings() + verification_token = ''; + + @onStartup() + onStartup() { + if (app) { + info('KO-FI: webhooks endpoint registered'); + app.post('/webhooks/kofi', async (req, res) => { + if (!this.enabled) { + return; + } + try { + const data: KoFiData = JSON.parse(req.body.data); + + if (data.type !== 'Donation' || !data.is_public) { + return; // we are parsing only public donation events + } + + if (data.verification_token !== this.verification_token) { + throw new Error(`Verification token doesn't match!`); + } + + // let's get userId only from database + const userId = await new Promise(resolve => { + users.getIdByName(data.from_name.toLowerCase()) + .then(r => resolve(r)) + .catch(() => resolve(`${data.from_name.toLowerCase()}#__anonymous__`)); + }); + + const isAnonymous = userId.includes('__anonymous__'); + + if (isAnonymous) { + tip(`${userId}, amount: ${Number(data.amount).toFixed(2)}${data.currency}, message: ${data.message}`); + } else { + tip(`${data.from_name.toLowerCase()}#${userId}, amount: ${Number(data.amount).toFixed(2)}${data.currency}, message: ${data.message}`); + } + + eventlist.add({ + event: 'tip', + amount: Number(data.amount), + currency: data.currency, + userId: userId, + message: data.message, + timestamp: new Date(data.timestamp).getTime(), + }); + + eventEmitter.emit('tip', { + isAnonymous: isAnonymous, + userName: data.from_name.toLowerCase(), + amount: Number(data.amount).toFixed(2), + currency: data.currency, + amountInBotCurrency: Number(exchange(Number(data.amount), data.currency, mainCurrency.value)).toFixed(2), + currencyInBot: mainCurrency.value, + message: data.message, + }); + alerts.trigger({ + event: 'tip', + service: 'kofi', + name: data.from_name, + amount: Number(Number(data.amount).toFixed(2)), + tier: null, + currency: data.currency, + monthsName: '', + message: data.message, + }); + + triggerInterfaceOnTip({ + userName: isAnonymous ? 'anonymous' : data.from_name.toLowerCase(), + amount: Number(data.amount), + message: data.message, + currency: data.currency, + timestamp: new Date(data.timestamp).getTime(), + }); + + res.status(200).send(); // send 200 to ko-fi to accept that we have this parsed + } catch (e) { + if (e instanceof Error) { + error(e.stack ?? e.message); + } + } + }); + } else { + setTimeout(() => this.onStartup(), 1000); + } + } +} + +export default new Kofi(); diff --git a/backend/src/integrations/lastfm.ts b/backend/src/integrations/lastfm.ts new file mode 100644 index 000000000..ea0930cb9 --- /dev/null +++ b/backend/src/integrations/lastfm.ts @@ -0,0 +1,90 @@ +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import axios from 'axios'; + +import Integration from './_interface.js'; +import { onChange, onStartup } from '../decorators/on.js'; +import { settings } from '../decorators.js'; + +import { isStreamOnline } from '~/helpers/api/index.js'; +import { announce, prepare } from '~/helpers/commons/index.js'; +import { error } from '~/helpers/log.js'; + +let canSendRequests = true; + +enum NOTIFY { + disabled, all, online, +} + +class LastFM extends Integration { + @settings() + apiKey = ''; + + @settings() + username = ''; + + @settings() + notify = NOTIFY.all; + + currentSong: null | string = null; + + @onStartup() + onStartup() { + setInterval(() => { + this.fetchData(); + }, 5000); + } + + @onChange('username') + @onChange('apiKey') + reEnableAfterFail() { + canSendRequests = true; + } + + async notifySong (song: string) { + announce(prepare('integrations.lastfm.current-song-changed', { name: song }), 'songs'); + } + + async fetchData() { + if (this.enabled && canSendRequests) { + try { + const response = await axios.get(`http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${this.username}&api_key=${this.apiKey}&format=json`); + const tracks = response.data.recenttracks.track; + + for (const track of tracks) { + if (track['@attr'] && track['@attr'].nowplaying === 'true') { + const song = `${track.name} - ${track.artist['#text']}`; + if (this.currentSong !== song) { + if (this.notify != NOTIFY.disabled + && (this.notify == NOTIFY.all + || (this.notify == NOTIFY.online && isStreamOnline.value))) { + this.notifySong(song); + } + } + this.currentSong = song; + } + } + } catch(e: any) { + if (e.isAxiosError) { + if (e.response.data.error === 10) { + error('LAST.FM: Invalid API key - You must be granted a valid key by last.fm'); + canSendRequests = false; + } else if (e.response.data.error === 26) { + error('LAST.FM: Suspended API key - Access for your account has been suspended, please contact Last.fm'); + canSendRequests = false; + } else if (e.response.data.error === 29) { + error('LAST.FM: Rate limit exceeded, waiting 5 minutes'); + canSendRequests = false; + setTimeout(() => canSendRequests = true, 5 * MINUTE); + } else { + error('LAST.FM: ' + e.response.data.message + ' | Error no. ' + e.response.data.error); + } + } else { + error('LAST.FM: ' + e.stack); + } + } + } + } +} + +const self = new LastFM(); +export default self; diff --git a/backend/src/integrations/obswebsocket.ts b/backend/src/integrations/obswebsocket.ts new file mode 100644 index 000000000..58c4af4e2 --- /dev/null +++ b/backend/src/integrations/obswebsocket.ts @@ -0,0 +1,168 @@ +import { Events } from '@entity/event.js'; +import { OBSWebsocket as OBSWebsocketEntity } from '@entity/obswebsocket.js'; +import { EntityNotFoundError } from 'typeorm'; + +import Integration from './_interface.js'; +import { onStartup } from '../decorators/on.js'; +import { + command, default_permission, +} from '../decorators.js'; +import events from '../events.js'; +import { Expects } from '../expects.js'; + +import { AppDataSource } from '~/database.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { + error, info, +} from '~/helpers/log.js'; +import { app, ioServer } from '~/helpers/panel.js'; +import { ParameterError } from '~/helpers/parameterError.js'; +import { defaultPermissions } from '~/helpers/permissions/defaultPermissions.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; +import { translate } from '~/translate.js'; + +class OBSWebsocket extends Integration { + @onStartup() + addEvent() { + if (typeof events === 'undefined') { + setTimeout(() => this.addEvent(), 1000); + } else { + events.supportedEventsList.push({ + id: 'obs-scene-changed', + variables: [ 'sceneName' ], + definitions: { linkFilter: '' }, + check: this.eventIsProperlyFiltered, + }); + events.supportedEventsList.push({ + id: 'obs-input-mute-state-changed', + variables: [ 'inputName', 'inputMuted' ], + definitions: { linkFilter: '' }, + check: this.eventIsProperlyFiltered, + }); + events.supportedOperationsList.push({ + id: 'run-obswebsocket-command', definitions: { taskId: '' }, fire: this.runObswebsocketCommand, + }); + } + } + + async runObswebsocketCommand(operation: Events.OperationDefinitions, attributes: Events.Attributes): Promise { + const task = await AppDataSource.getRepository(OBSWebsocketEntity).findOneByOrFail({ id: String(operation.taskId) }); + + info(`OBSWEBSOCKETS: Task ${task.id} triggered by operation`); + await obsws.triggerTask(task.code, attributes); + } + + protected async eventIsProperlyFiltered(event: any, attributes: Events.Attributes): Promise { + const isTriggeredByCorrectOverlay = (function triggeredByCorrectOverlayCheck () { + const match = new RegExp('[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}').exec(attributes.linkFilter); + if (match) { + return match[0] === event.definitions.linkFilter + || attributes.linkFilter === event.definitions.linkFilter; + } else { + return false; + } + })(); + return isTriggeredByCorrectOverlay; + } + + @onStartup() + initialize() { + this.addMenu({ + category: 'registry', name: 'obswebsocket', id: 'registry/obswebsocket', this: null, + }); + } + + @onStartup() + initEndpoint() { + if (!app) { + setTimeout(() => this.initEndpoint(), 1000); + return; + } + + app.post('/integrations/obswebsocket/log', (req, res) => { + let message = req.body.message; + if (typeof req.body.message !== 'string') { + message = JSON.stringify(req.body.message, null, 2); + } + ioServer?.of('/').emit('integration::obswebsocket::log', message || ''); + res.status(200).send(); + }); + } + + sockets() { + adminEndpoint('/', 'integration::obswebsocket::trigger', (data, cb) => { + this.triggerTask(data.code, data.attributes) + .then(() => cb(null)) + .catch(e => cb(e)); + }); + adminEndpoint('/', 'integration::obswebsocket::generic::save', async (item, cb) => { + try { + cb(null, await AppDataSource.getRepository(OBSWebsocketEntity).save(item)); + } catch (e) { + if (e instanceof Error) { + cb(e.message, undefined); + } + } + }); + adminEndpoint('/', 'integration::obswebsocket::generic::getOne', async (id, cb) => { + cb(null, await AppDataSource.getRepository(OBSWebsocketEntity).findOneBy({ id })); + }); + adminEndpoint('/', 'integration::obswebsocket::generic::deleteById', async (id, cb) => { + await AppDataSource.getRepository(OBSWebsocketEntity).delete({ id }); + cb(null); + }); + adminEndpoint('/', 'integration::obswebsocket::generic::getAll', async (cb) => { + cb(null, await AppDataSource.getRepository(OBSWebsocketEntity).find()); + }); + publicEndpoint('/', 'integration::obswebsocket::event', (opts) => { + const { type, location, ...data } = opts; + eventEmitter.emit(type, { + linkFilter: location, + ...data, + }); + }); + } + + async triggerTask(code: string, attributes?: Events.Attributes) { + await new Promise((resolve, reject) => { + // we need to send on all sockets on / + const sockets = ioServer?.of('/').sockets; + if (sockets) { + for (const socket of sockets.values()) { + socket.emit('integration::obswebsocket::trigger', { code, attributes }, () => resolve(true)); + } + } + setTimeout(() => reject('Test timed out. Please check if your overlay is opened.'), 10000); + }); + } + + @command('!obsws run') + @default_permission(defaultPermissions.CASTERS) + async runTask(opts: CommandOptions) { + try { + const [ taskId ] = new Expects(opts.parameters).string().toArray(); + const task = await AppDataSource.getRepository(OBSWebsocketEntity).findOneOrFail({ where: { id: taskId } }); + + info(`OBSWEBSOCKETS: User ${opts.sender.userName}#${opts.sender.userId} triggered task ${task.id}`); + await this.triggerTask(task.code); + + return []; + } catch (err: any) { + const isEntityNotFound = (err instanceof EntityNotFoundError); + const isParameterError = (err instanceof ParameterError); + + if (isEntityNotFound) { + const match = new RegExp('matching: "(.*)"').exec(err.message); + return [{ response: translate('integrations.obswebsocket.runTask.EntityNotFound').replace('$id', match ? match[1] : 'n/a'), ...opts }]; + } else if (isParameterError) { + return [{ response: translate('integrations.obswebsocket.runTask.ParameterError'), ...opts }]; + } else { + error(err.stack ?? err); + return [{ response: translate('integrations.obswebsocket.runTask.UnknownError'), ...opts }]; + } + } + } +} + +const obsws = new OBSWebsocket(); +export default obsws; diff --git a/backend/src/integrations/protondb.ts b/backend/src/integrations/protondb.ts new file mode 100644 index 000000000..c03a12e78 --- /dev/null +++ b/backend/src/integrations/protondb.ts @@ -0,0 +1,244 @@ +import { capitalize, noop } from 'lodash-es'; +import fetch from 'node-fetch'; +import trigramSimilarity from 'trigram-similarity'; + +import System from './_interface.js'; +import { command, default_permission } from '../decorators.js'; +import { Expects } from '../expects.js'; + +import { + stats, +} from '~/helpers/api/index.js'; +import { prepare } from '~/helpers/commons/index.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; + +const cache = new Map(); + +class ProtonDB extends System { + @command('!pdb') + @default_permission(defaultPermissions.CASTERS) + async getGameInfo(opts: CommandOptions): Promise { + let [gameInput] = new Expects(opts.parameters) + .everything({ optional: true }) + .toArray(); + + if (!gameInput) { + if (!stats.value.currentGame) { + return []; // skip if we don't have game + } else { + gameInput = stats.value.currentGame; + } + } + + let id: string | null = null; + if (cache.has(gameInput.toLowerCase())) { + id = cache.get(gameInput.toLowerCase()) as string; + } else { + const request = await new Promise((resolve, reject) => { + fetch('http://api.steampowered.com/ISteamApps/GetAppList/v0002/') + .then(response => response.json()) + .then(json => resolve(json)) + .catch(e => reject(e)); + }); + + const apps = new Map(); + (request as any).applist.apps.forEach((o: any) => { + const similarity = trigramSimilarity(o.name.toLowerCase(), gameInput.toLowerCase()); + if (similarity >= 0.75) { + apps.set(similarity, o); + } + }); + + // select most similar game + const key = [...apps.keys()].sort().reverse()[0]; + const app = apps.get(key); + + if (app) { + id = app.appid as string; + cache.set(gameInput.toLowerCase(), id); + } + } + + if (!id) { + return [{ + response: prepare('integrations.protondb.responseNotFound', { + game: gameInput.toUpperCase(), + }), + ...opts, + }]; + } + + try { + type response = { + bestReportedTier: string; + tier: string; + score: number; + confidence: string; + total: number; + trendingTier: string; + }; + const reqProtonSummary = await new Promise((resolve, reject) => { + fetch(`https://www.protondb.com/api/v1/reports/summaries/${id}.json`) + .then(response => response.json() as Promise) + .then(json => resolve(json)) + .catch(e => reject(e)); + }); + + // to get platforms + type response2 = { + [x: string]: { + data: { + type: string + name: string + steam_appid: number + required_age: string + is_free: boolean + controller_support: string + dlc: Array + detailed_description: string + about_the_game: string + short_description: string + supported_languages: string + header_image: string + capsule_image: string + capsule_imagev5: string + website: string + pc_requirements: { + minimum: string + recommended: string + } + mac_requirements: Array + linux_requirements: Array + legal_notice: string + developers: Array + publishers: Array + price_overview: { + currency: string + initial: number + final: number + discount_percent: number + initial_formatted: string + final_formatted: string + } + packages: Array + package_groups: Array<{ + name: string + title: string + description: string + selection_text: string + save_text: string + display_type: number + is_recurring_subscription: string + subs: Array<{ + packageid: number + percent_savings_text: string + percent_savings: number + option_text: string + option_description: string + can_get_free_license: string + is_free_license: boolean + price_in_cents_with_discount: number + }> + }> + platforms: { + windows: boolean + mac: boolean + linux: boolean + } + metacritic: { + score: number + url: string + } + categories: Array<{ + id: number + description: string + }> + genres: Array<{ + id: string + description: string + }> + screenshots: Array<{ + id: number + path_thumbnail: string + path_full: string + }> + movies: Array<{ + id: number + name: string + thumbnail: string + webm: { + '480': string + max: string + } + mp4: { + '480': string + max: string + } + highlight: boolean + }> + recommendations: { + total: number + } + achievements: { + total: number + highlighted: Array<{ + name: string + path: string + }> + } + release_date: { + coming_soon: boolean + date: string + } + support_info: { + url: string + email: string + } + background: string + background_raw: string + content_descriptors: { + ids: Array + notes: any + } + } + } + }; + const reqProtonDetail = await new Promise((resolve, reject) => { + fetch(`https://www.protondb.com/proxy/steam/api/appdetails/?appids=${id}`) + .then(response => response.json() as Promise) + .then(json => resolve(json)) + .catch(e => reject(e)); + }); + + let rating = capitalize(reqProtonSummary.tier); + const native: string[] = []; + + reqProtonDetail[id].data.platforms.linux ? native.push('Linux') : noop(); + reqProtonDetail[id].data.platforms.mac ? native.push('Mac') : noop(); + reqProtonDetail[id].data.platforms.windows ? native.push('Windows') : noop(); + + if (native.length === 3) { + rating = 'Native'; + } + + return [{ + response: prepare('integrations.protondb.responseOk', { + game: reqProtonDetail[id].data.name.toUpperCase(), + rating, + native: native.join(', '), + url: `https://www.protondb.com/app/${id}`, + }), + ...opts, + }]; + } catch (e) { + return [{ + response: prepare('integrations.protondb.responseNg', { + game: gameInput.toUpperCase(), + }), + ...opts, + }]; + } + } +} + +export default new ProtonDB(); diff --git a/backend/src/integrations/pubg.ts b/backend/src/integrations/pubg.ts new file mode 100644 index 000000000..f7c574449 --- /dev/null +++ b/backend/src/integrations/pubg.ts @@ -0,0 +1,235 @@ +// bot libraries + +import { HOUR, MINUTE } from '@sogebot/ui-helpers/constants.js'; +import axios from 'axios'; +import { escapeRegExp } from 'lodash-es'; + +import Integration from './_interface.js'; +import { onChange, onStartup } from '../decorators/on.js'; +import { + command, persistent, settings, ui, +} from '../decorators.js'; +import { Expects } from '../expects.js'; +import { Message } from '../message.js'; + +import { prepare } from '~/helpers/commons/index.js'; +import { flatten } from '~/helpers/flatten.js'; +import { error, info } from '~/helpers/log.js'; +import { adminEndpoint } from '~/helpers/socket.js'; + +class PUBG extends Integration { + @settings() + @ui({ type: 'text-input', secret: true }) + apiKey = ''; + + @settings('player') + @ui({ type: 'selector', values: ['steam', 'console', 'kakao', 'psn', 'stadia', 'xbox'] }) + platform: 'steam' | 'console' | 'kakao' | 'psn' | 'stadia' | 'xbox' = 'steam'; + @settings('player') + playerName = ''; + @settings('player') + @ui({ type: 'pubg-player-id' }, 'player') + playerId = ''; + seasonId = ''; + @persistent() + _lastSeasonIdFetch = 0; + + @settings('customization') + rankedGameModeStatsCustomization = 'Rank: $currentTier.tier $currentTier.subTier ($currentRankPoint) | Wins: $wins ((toPercent|1|$winRatio)%) | Top 10: (toPercent|1|$top10Ratio)% | Avg. Rank: (toFloat|1|$avgRank) | KDA: (toFloat|1|$kda)'; + @settings('customization') + gameModeStatsCustomization = 'Wins: $wins | Top 10: $top10s'; + + @settings('stats') + rankedGameModeStats: { [x: string]: any } = {}; + @persistent() + _lastRankedGameModeStats = 0; + + @settings('stats') + gameModeStats: { [x: string]: any } = {}; + @persistent() + _lastGameModeStats = 0; + + @onStartup() + onStartup() { + setInterval(() => { + if (this._lastSeasonIdFetch > HOUR) { + this.fetchSeasonId(); + } + if (this._lastRankedGameModeStats > 10 * MINUTE) { + this.fetchUserStats(true); + } + if (this._lastGameModeStats > 10 * MINUTE) { + this.fetchUserStats(false); + } + }, MINUTE); + } + + @onChange('apiKey') + @onChange('enabled') + @onChange('platform') + @onStartup() + async fetchSeasonId() { + if (this.apiKey.length > 0) { + this._lastSeasonIdFetch = Date.now(); + const request = await axios.get( + `https://api.pubg.com/shards/${this.platform}/seasons`, + { + headers: { + Authorization: `Bearer ${this.apiKey}`, + Accept: 'application/vnd.api+json', + }, + }, + ); + for (const season of request.data.data) { + if (season.attributes.isCurrentSeason) { + this.seasonId = season.id; + if (this.seasonId !== season.id) { + info(`PUBG: current season set automatically to ${season.id}`); + this.fetchUserStats(true); + this.fetchUserStats(false); + } + } + } + } + } + + async fetchUserStats(ranked = false) { + if (this.apiKey.length > 0 && this.seasonId.length > 0 && this.playerId.length > 0) { + if (ranked) { + this._lastRankedGameModeStats = Date.now(); + } else { + this._lastGameModeStats = Date.now(); + } + const request = await axios.get( + ranked ? `https://api.pubg.com/shards/${this.platform}/players/${this.playerId}/seasons/${this.seasonId}/ranked` : `https://api.pubg.com/shards/${this.platform}/players/${this.playerId}/seasons/${this.seasonId}`, + { + headers: { + Authorization: `Bearer ${this.apiKey}`, + Accept: 'application/vnd.api+json', + }, + }, + ); + if (ranked) { + this.rankedGameModeStats = request.data.data.attributes.rankedGameModeStats; + } else { + this.gameModeStats = request.data.data.attributes.gameModeStats; + } + } + } + + sockets() { + adminEndpoint('/integrations/pubg', 'pubg::searchForseasonId', async ({ apiKey, platform }, cb) => { + try { + const request = await axios.get( + `https://api.pubg.com/shards/${platform}/seasons`, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/vnd.api+json', + }, + }, + ); + for (const season of request.data.data) { + if (season.attributes.isCurrentSeason) { + cb(null, { data: [season] }); + } + } + throw new Error('No current season found.'); + } catch (e: any) { + cb(e.message, null); + } + }); + adminEndpoint('/integrations/pubg', 'pubg::getUserStats', async ({ apiKey, platform, playerId, seasonId, ranked }, cb) => { + try { + const request = await axios.get( + ranked ? `https://api.pubg.com/shards/${platform}/players/${playerId}/seasons/${seasonId}/ranked` : `https://api.pubg.com/shards/${platform}/players/${playerId}/seasons/${seasonId}`, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/vnd.api+json', + }, + }, + ); + if (ranked) { + this.rankedGameModeStats = request.data.data.attributes.rankedGameModeStats; + } else { + this.gameModeStats = request.data.data.attributes.gameModeStats; + } + cb(null, request.data.data.attributes[ranked ? 'rankedGameModeStats' : 'gameModeStats']); + } catch (e: any) { + cb(e.message, null); + } + }); + adminEndpoint('/integrations/pubg', 'pubg::searchForPlayerId', async ({ apiKey, platform, playerName }, cb) => { + try { + const request = await axios.get( + `https://api.pubg.com/shards/${platform}/players?filter[playerNames]=${playerName}`, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/vnd.api+json', + }, + }, + ); + cb(null, request.data); + } catch (e: any) { + cb(e.message, null); + } + }); + adminEndpoint('/integrations/pubg', 'pubg::exampleParse', async ({ text }, cb) => { + try { + const messageToSend = await new Message(text).parse() as string; + cb(null, messageToSend); + } catch (e: any) { + cb(e.message, null); + } + }); + } + + @command('!pubg normal') + async showGameModeStats(opts: CommandOptions): Promise { + try { + const gameType = new Expects(opts.parameters).everything().toArray()[0] as string; + if (typeof this.gameModeStats[gameType] === 'undefined') { + throw new Error('Expected parameter'); + } + let text = this.gameModeStatsCustomization; + for (const key of Object.keys(flatten(this.gameModeStats[gameType]))) { + text = text.replace(new RegExp(escapeRegExp(`$${key}`), 'gi'), flatten(this.gameModeStats[gameType])[key]); + } + return [{ response: await new Message(`$sender, ${text}`).parse(), ...opts }]; + } catch (e: any) { + if (e.message.includes('Expected parameter')) { + return [{ response: prepare('integrations.pubg.expected_one_of_these_parameters', { list: Object.keys(this.gameModeStats).join(', ') }), ...opts }]; + } else { + error(e.stack); + return []; + } + } + } + + @command('!pubg ranked') + async showRankedGameModeStats(opts: CommandOptions): Promise { + try { + const gameType = new Expects(opts.parameters).everything().toArray()[0] as string; + if (typeof this.rankedGameModeStats[gameType] === 'undefined') { + throw new Error('Expected parameter'); + } + let text = this.rankedGameModeStatsCustomization; + for (const key of Object.keys(flatten(this.rankedGameModeStats[gameType]))) { + text = text.replace(new RegExp(escapeRegExp(`$${key}`), 'gi'), flatten(this.rankedGameModeStats[gameType])[key]); + } + return [{ response: await new Message(`$sender, ${text}`).parse(), ...opts }]; + } catch (e: any) { + if (e.message.includes('Expected parameter')) { + return [{ response: prepare('integrations.pubg.expected_one_of_these_parameters', { list: Object.keys(this.rankedGameModeStats).join(', ') }), ...opts }]; + } else { + error(e.stack); + return []; + } + } + } +} + +const self = new PUBG(); +export default self; diff --git a/backend/src/integrations/qiwi.ts b/backend/src/integrations/qiwi.ts new file mode 100644 index 000000000..7885e0a63 --- /dev/null +++ b/backend/src/integrations/qiwi.ts @@ -0,0 +1,137 @@ +import { UserTip, UserTipInterface } from '@entity/user.js'; +import axios from 'axios'; + +import Integration from './_interface.js'; +import { onChange, onStartup } from '../decorators/on.js'; +import { settings } from '../decorators.js'; +import eventlist from '../overlays/eventlist.js'; +import alerts from '../registries/alerts.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline, stats } from '~/helpers/api/index.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import rates from '~/helpers/currency/rates.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { triggerInterfaceOnTip } from '~/helpers/interface/triggers.js'; +import { error, tip } from '~/helpers/log.js'; + +class Qiwi extends Integration { + interval: any = null; + + @settings() + secretToken = ''; + + @onStartup() + @onChange('enabled') + onEnabledChange (key: string, val: boolean) { + if (val) { + this.start(); + } else { + clearInterval(this.interval); + } + } + + @onChange('secretToken') + onTokenChange (key: string, val: string) { + if (val) { + this.start(); + } else { + clearInterval(this.interval); + } + } + + async start () { + clearInterval(this.interval); + if (this.secretToken.trim() === '' || !this.enabled) { + return; + } else { + this.interval = setInterval(() => this.check(), 3000); + } + } + async check () { + let request: any; + try { + request = await axios(`https://donate.qiwi.com/api/stream/v1/widgets/${this.secretToken}/events?limit=50`); + } catch (e: any) { + error(`Qiwi: error on api request: ${e.message}`); + return; + } + const data = request.data; + if (data.events.length === 0) { + return; + } + for (const event of data.events) { + const { DONATION_SENDER, DONATION_AMOUNT, DONATION_CURRENCY, DONATION_MESSAGE } = event.attributes; + const username: string | null = DONATION_SENDER ? DONATION_SENDER.toLowerCase() : null; + const message = DONATION_MESSAGE ? DONATION_MESSAGE : ''; + const amount = Number(DONATION_AMOUNT); + + let id: string | null = null; + const timestamp = Date.now(); + if (username) { + const user = await users.getUserByUsername(username); + id = user.userId; + const newTip: UserTipInterface = { + amount: Number(amount), + currency: DONATION_CURRENCY, + sortAmount: exchange(Number(amount), DONATION_CURRENCY, mainCurrency.value), + message: message, + tippedAt: timestamp, + exchangeRates: rates, + userId: user.userId, + }; + AppDataSource.getRepository(UserTip).save(newTip); + } + + if (isStreamOnline.value) { + stats.value.currentTips = stats.value.currentTips + exchange(amount, DONATION_CURRENCY, mainCurrency.value); + } + + eventlist.add({ + event: 'tip', + amount, + currency: DONATION_CURRENCY, + userId: String(username ? await users.getIdByName(username.toLowerCase()) ?? '0' : '0'), + message, + timestamp, + }); + + tip(`${username ? username : 'Anonymous'}${id ? '#' + id : ''}, amount: ${Number(amount).toFixed(2)}${DONATION_CURRENCY}, ${message ? 'message: ' + message : ''}`); + + eventEmitter.emit('tip', { + isAnonymous: username ? false : true, + userName: username || 'Anonymous', + amount: String(amount), + currency: DONATION_CURRENCY, + amountInBotCurrency: Number(exchange(amount, DONATION_CURRENCY, mainCurrency.value)).toFixed(2), + currencyInBot: mainCurrency.value, + message, + }); + + alerts.trigger({ + event: 'tip', + name: username || 'Anonymous', + service: 'qiwi', + amount, + tier: null, + currency: DONATION_CURRENCY, + monthsName: '', + message, + }); + + triggerInterfaceOnTip({ + userName: username || 'Anonymous', + amount, + message, + currency: DONATION_CURRENCY, + timestamp, + }); + + } + + } +} + +export default new Qiwi(); diff --git a/backend/src/integrations/spotify.ts b/backend/src/integrations/spotify.ts new file mode 100644 index 000000000..afd95b268 --- /dev/null +++ b/backend/src/integrations/spotify.ts @@ -0,0 +1,825 @@ +import crypto from 'crypto'; +import { setTimeout } from 'timers/promises'; + +import { SpotifySongBan } from '@entity/spotify.js'; +import { HOUR, SECOND } from '@sogebot/ui-helpers/constants.js'; +import chalk from 'chalk'; +import _ from 'lodash-es'; +import SpotifyWebApi from 'spotify-web-api-node'; + +import Integration from './_interface.js'; +import { + onChange, onLoad, onStartup, +} from '../decorators/on.js'; +import { + command, default_permission, persistent, settings, +} from '../decorators.js'; +import { Expects } from '../expects.js'; + +import { isStreamOnline } from '~/helpers/api/index.js'; +import { CommandError } from '~/helpers/commandError.js'; +import { announce, prepare } from '~/helpers/commons/index.js'; +import { debug, error, info, warning } from '~/helpers/log.js'; +import { addUIError } from '~/helpers/panel/index.js'; +import { ioServer } from '~/helpers/panel.js'; +import { adminEndpoint } from '~/helpers/socket.js'; + +/* + * How to integrate: + * 1. Create app in https://beta.developer.spotify.com/dashboard/applications + * 1a. Set redirect URI as http://whereYouAccessDashboard.com/oauth/spotify + * 2. Update your clientId, clientSecret, redirectURI in Integrations UI + * 3. Authorize your user through UI + */ + +let currentSongHash = ''; +let isTemporarilyUnavailable = false; + +class Spotify extends Integration { + client: null | SpotifyWebApi = null; + retry: { IRefreshToken: number } = { IRefreshToken: 0 }; + state: any = null; + + isUnauthorized = true; + userId: string | null = null; + + lastActiveDeviceId = ''; + @settings('connection') + manualDeviceId = ''; + + @persistent() + songsHistory: string[] = []; + currentSong = JSON.stringify(null as null | { + started_at: number; song: string; artist: string; artists: string, uri: string; is_playing: boolean; is_enabled: boolean; + }); + + @settings() + _accessToken: string | null = null; + @settings() + _refreshToken: string | null = null; + @settings() + songRequests = true; + @settings() + fetchCurrentSongWhenOffline = false; + @settings() + queueWhenOffline = false; + @settings() + notify = false; + @settings() + allowApprovedArtistsOnly = false; + @settings() + approvedArtists = []; // uris or names + + @settings('customization') + format = '$song - $artist'; + + @settings('connection') + clientId = ''; + @settings('connection') + clientSecret = ''; + @settings('connection') + redirectURI = 'http://localhost:20000/credentials/oauth/spotify'; // TODO: remove after old ui is removed + @settings('connection') + username = ''; + + scopes: string[] = [ + 'user-read-currently-playing', + 'user-read-playback-state', + 'user-read-private', + 'user-read-email', + 'playlist-modify-public', + 'playlist-modify', + 'playlist-read-collaborative', + 'playlist-modify-private', + 'playlist-read-private', + 'user-modify-playback-state', + ]; + + @onStartup() + onStartup() { + this.addMenu({ + category: 'manage', name: 'spotifybannedsongs', id: 'manage/spotify/bannedsongs', this: this, + }); + + setInterval(() => this.IRefreshToken(), HOUR); + setInterval(() => this.ICurrentSong(), 10000); + setInterval(() => { + this.getMe(); + this.getActiveDevice(); + }, 30000); + } + + @onLoad('songsHistory') + onSongsHistoryLoad() { + setInterval(() => { + const currentSong = JSON.parse(this.currentSong); + if (currentSong !== null) { + // we need to exclude is_playing and is_enabled from currentSong + const currentSongWithoutAttributes = JSON.stringify({ + started_at: currentSong.started_at, + song: currentSong.song, + artist: currentSong.artist, + artists: currentSong.artists, + uri: currentSong.uri, + }); + + if (currentSongHash !== currentSongWithoutAttributes) { + currentSongHash = currentSongWithoutAttributes; + const message = prepare('integrations.spotify.song-notify', { name: currentSong.song, artist: currentSong.artist }); + debug('spotify.song', message); + if (this.notify) { + announce(message, 'songs'); + } + } + + if (!this.songsHistory.includes(currentSongWithoutAttributes)) { + this.songsHistory.push(currentSongWithoutAttributes); + } + // keep only 10 latest songs + 1 current + if (this.songsHistory.length > 11) { + this.songsHistory.splice(0, 1); + } + } else { + currentSongHash = ''; + } + }, 5 * SECOND); + } + + @onChange('connection.username') + onUsernameChange (key: string, value: string) { + if (value.length > 0) { + info(chalk.yellow('SPOTIFY: ') + `Access to account ${value} granted`); + } + } + + @onChange('redirectURI') + @onChange('clientId') + @onChange('clientSecret') + onConnectionVariablesChange () { + this.currentSong = JSON.stringify(null); + this.disconnect(); + if (this.enabled) { + this.isUnauthorized = false; + this.connect(); + this.getMe(); + } + } + + @onStartup() + @onChange('enabled') + onStateChange (key: string, value: boolean) { + this.currentSong = JSON.stringify(null); + if (value) { + this.connect(); + this.getMe(); + } else { + this.disconnect(); + } + } + + @command('!spotify history') + async cHistory(opts: CommandOptions): Promise { + try { + if (this.songsHistory.length <= 1) { + // we are expecting more than 1 song (current) + throw new CommandError('no-songs-found-in-history'); + } + const numOfSongs = new Expects(opts.parameters).number({ optional: true }).toArray()[0]; + if (!numOfSongs || numOfSongs <= 1) { + const latestSong: any = JSON.parse(this.songsHistory[this.songsHistory.length - 2]); + return [{ + response: prepare('integrations.spotify.return-one-song-from-history', { + artists: latestSong.artists, artist: latestSong.artist, uri: latestSong.uri, name: latestSong.song, + }), ...opts, + }]; + } else { + // return songs in desc order (excl. current song) + const actualNumOfSongs = Math.min(this.songsHistory.length - 1, numOfSongs, 10); + const responses = [ + { response: prepare('integrations.spotify.return-multiple-song-from-history', { count: actualNumOfSongs }), ...opts }, + ]; + const lowestIndex = Math.max(0, this.songsHistory.length - actualNumOfSongs); + for (let index = this.songsHistory.length - 1; index >= lowestIndex ; index--) { + if (index - 1 < 0) { + break; + } + + const song: any = JSON.parse(this.songsHistory[index - 1]); + responses.push({ + response: prepare('integrations.spotify.return-multiple-song-from-history-item', { + index: responses.length, + artists: song.artists, artist: song.artist, uri: song.uri, name: song.song, + }), ...opts, + }); + } + return responses; + } + } catch (e: any) { + if (e instanceof CommandError) { + return [{ response: prepare('integrations.spotify.' + e.message), ...opts }]; + } else { + error(e.stack); + } + } + return []; + } + + get deviceId () { + if (this.manualDeviceId.length > 0) { + return this.manualDeviceId; + } + + if (this.lastActiveDeviceId.length > 0) { + return this.lastActiveDeviceId; + } + + return undefined; + } + + @command('!spotify skip') + @default_permission(null) + async cSkipSong() { + if (this.client) { + this.client.skipToNext({ device_id: this.deviceId }); + ioServer?.emit('api.stats', { + method: 'POST', data: 'n/a', timestamp: Date.now(), call: 'spotify::skip', api: 'other', endpoint: 'n/a', code: 200, + }); + } + return []; + } + + async getActiveDevice() { + try { + if (this.enabled && !this.isUnauthorized) { + const request = await this.client?.getMyDevices(); + if (request) { + const activeDevice = request.body.devices.find(o => o.is_active); + if (activeDevice && this.lastActiveDeviceId !== activeDevice.id) { + info(`SPOTIFY: new active device found, set to ${activeDevice.id}`); + this.lastActiveDeviceId = activeDevice.id ?? 'n/a'; + } + } + } + } catch (e: any) { + if (String(e.statusCode).startsWith('5')) { + // skip all 5xx errors + return; + } + error('SPOTIFY: cannot get active device, please reauthenticate to include scope user-read-playback-state'); + error(e.stack); + } + } + + async getMe () { + try { + if ((this.enabled) && !_.isNil(this.client) && !this.isUnauthorized) { + const data = await this.client.getMe(); + + this.username = data.body.display_name ? data.body.display_name : data.body.id; + if (this.userId !== data.body.id) { + info(chalk.yellow('SPOTIFY: ') + `Logged in as ${this.username}#${data.body.id}`); + } + this.userId = data.body.id; + + isTemporarilyUnavailable = false; + } + } catch (e: any) { + if (String(e.statusCode).startsWith('5')) { + // skip all 5xx errors + return; + } + if (e.message.includes('The access token expired.') || e.message.includes('No token provided.')) { + debug('spotify.user', 'Get of user failed, incorrect or missing access token. Refreshing token and retrying.'); + this.IRefreshToken(); + } else if (e.message.includes('temporarily_unavailable')) { + if (!isTemporarilyUnavailable) { + isTemporarilyUnavailable = true; + info(chalk.yellow('SPOTIFY: ') + 'Spotify is temporarily unavailable'); + } + return; + } else if (e.message !== 'Unauthorized') { + if (!this.isUnauthorized) { + this.isUnauthorized = true; + info(chalk.yellow('SPOTIFY: ') + 'Get of user failed, check your credentials'); + debug('spotify.user', e.stack); + } + } + this.username = ''; + this.userId = null; + } + } + + async ICurrentSong () { + try { + if (!this.fetchCurrentSongWhenOffline && !(isStreamOnline.value)) { + throw Error('Stream is offline'); + } + if (this.client === null) { + throw Error('Spotify Web Api not connected'); + } + const data = await this.client.getMyCurrentPlayingTrack(); + if (!data.body.item || data.body.item.type === 'episode') { + throw Error('No song was received from spotify'); + } + + let currentSong = JSON.parse(this.currentSong); + if (currentSong === null || currentSong.song !== data.body.item.name) { + currentSong = { + started_at: Date.now(), // important for song history + song: data.body.item.name, + artist: data.body.item.artists[0].name, + artists: data.body.item.artists.map(o => o.name).join(', '), + uri: data.body.item.uri, + is_playing: data.body.is_playing, + is_enabled: this.enabled, + }; + } + currentSong.is_playing = data.body.is_playing; + currentSong.is_enabled = this.enabled; + this.currentSong = JSON.stringify(currentSong); + } catch (e: any) { + if (e instanceof Error) { + debug('spotify.song', e.stack || e.message); + } + this.currentSong = JSON.stringify(null); + } + } + + async IRefreshToken () { + if (this.retry.IRefreshToken < 5) { + try { + if (!_.isNil(this.client) && this._refreshToken) { + const data = await this.client.refreshAccessToken(); + this.client.setAccessToken(data.body.access_token); + this.isUnauthorized = false; + + this.retry.IRefreshToken = 0; + isTemporarilyUnavailable = false; + ioServer?.emit('api.stats', { + method: 'GET', data: data.body, timestamp: Date.now(), call: 'spotify::refreshToken', api: 'other', endpoint: 'n/a', code: 200, + }); + } + } catch (e: any) { + if (e.message.includes('temporarily_unavailable')) { + if (!isTemporarilyUnavailable) { + isTemporarilyUnavailable = true; + info(chalk.yellow('SPOTIFY: ') + 'Spotify is temporarily unavailable'); + } + setTimeout(10000).then(() => { + this.IRefreshToken(); + }); + return; + } + this.retry.IRefreshToken++; + ioServer?.emit('api.stats', { + method: 'GET', data: e.message, timestamp: Date.now(), call: 'spotify::refreshToken', api: 'other', endpoint: 'n/a', code: 500, + }); + info(chalk.yellow('SPOTIFY: ') + 'Refreshing access token failed ' + (this.retry.IRefreshToken > 0 ? 'retrying #' + this.retry.IRefreshToken : '')); + info(e.stack); + setTimeout(10000).then(() => { + this.IRefreshToken(); + }); + } + } + + if (this.retry.IRefreshToken >= 5) { + addUIError({ name: 'SPOTIFY', message: 'Refreshing access token failed. Revoking access.' }); + this.userId = null; + this._accessToken = null; + this._refreshToken = null; + this.username = ''; + this.currentSong = JSON.stringify(null); + } + } + + sockets () { + adminEndpoint('/integrations/spotify', 'spotify::state', async (callback) => { + callback(null, this.state); + }); + adminEndpoint('/integrations/spotify', 'spotify::skip', async (callback) => { + this.cSkipSong(); + callback(null); + }); + adminEndpoint('/integrations/spotify', 'spotify::addBan', async (spotifyUri, cb) => { + try { + if (!this.client) { + addUIError({ name: 'Spotify Ban Import', message: 'You are not connected to spotify API, authorize your user' }); + throw Error('client'); + } + let id = ''; + if (spotifyUri.startsWith('spotify:')) { + id = spotifyUri.replace('spotify:track:', ''); + } else { + const regex = new RegExp('\\S+open\\.spotify\\.com\\/track\\/(\\w+)(.*)?', 'gi'); + const exec = regex.exec(spotifyUri as unknown as string); + if (exec) { + id = exec[1]; + } else { + throw Error('ID was not found in ' + spotifyUri); + } + } + + const response = await this.client.getTrack(id); + + ioServer?.emit('api.stats', { + method: 'GET', data: response.body, timestamp: Date.now(), call: 'spotify::addBan', api: 'other', endpoint: 'n/a', code: 200, + }); + + const track = response.body; + const songBan = SpotifySongBan.create({ + artists: track.artists.map(o => o.name), spotifyUri: track.uri, title: track.name, + }); + await songBan.save(); + } catch (e: any) { + if (e.message !== 'client') { + if (cb) { + cb(e); + } + addUIError({ name: 'Spotify Ban Import', message: 'Something went wrong with banning song. Check your spotifyURI.' }); + } + ioServer?.emit('api.stats', { + method: 'GET', data: e.response, timestamp: Date.now(), call: 'spotify::addBan', api: 'other', endpoint: 'n/a', code: 'n/a', + }); + if (cb) { + cb(e); + } + } + if (cb) { + cb(null); + } + }); + adminEndpoint('/integrations/spotify', 'spotify::deleteBan', async (where, cb) => { + where = where || {}; + if (cb) { + await SpotifySongBan.delete(where); + cb(null); + } + }); + adminEndpoint('/integrations/spotify', 'spotify::getAllBanned', async (where, cb) => { + where = where || {}; + if (cb) { + cb(null, await SpotifySongBan.find(where)); + } + }); + const setCode = async (token: string, cb: any) => { + const waitForUsername = () => { + return new Promise((resolve) => { + const check = async () => { + if (this.client) { + this.client.getMe() + .then((data) => { + this.username = data.body.display_name ? data.body.display_name : data.body.id; + resolve(true); + }) + .catch(() => { + setTimeout(10000).then(() => { + check(); + }); + }); + } else { + resolve(true); + } + }; + check(); + }); + }; + + this.currentSong = JSON.stringify(null); + this.connect({ token }); + await waitForUsername(); + setTimeout(10000).then(() => this.isUnauthorized = false); + cb(null, true); + }; + adminEndpoint('/integrations/spotify', 'code', async (token, cb) => { + this.redirectURI = 'https://dash.sogebot.xyz/credentials/spotify'; + setCode(token, cb); + }); + adminEndpoint('/integrations/spotify', 'spotify::code', async (token, cb) => { + setCode(token, cb); + }); + adminEndpoint('/integrations/spotify', 'spotify::revoke', async (cb) => { + clearTimeout(this.timeouts.IRefreshToken); + try { + if (this.client !== null) { + this.client.resetAccessToken(); + this.client.resetRefreshToken(); + } + + const username = this.username; + this.userId = null; + this._accessToken = null; + this._refreshToken = null; + this.username = ''; + this.currentSong = JSON.stringify(null); + + info(chalk.yellow('SPOTIFY: ') + `Access to account ${username} is revoked`); + + cb(null, { do: 'refresh' }); + } catch (e: any) { + cb(e.stack); + } finally { + this.timeouts.IRefreshToken = global.setTimeout(() => this.IRefreshToken(), 60000); + } + }); + adminEndpoint('/integrations/spotify', 'spotify::authorize', async (cb) => { + if ( + this.clientId === '' + || this.clientSecret === '' + ) { + cb('Cannot authorize! Missing clientId or clientSecret. Please save before authorizing.', null); + } else { + try { + const authorizeURI = this.authorizeURI(); + if (!authorizeURI) { + error('Integration must be enabled to authorize'); + cb('Integration must enabled to authorize'); + } else { + cb(null, { do: 'redirect', opts: [authorizeURI] }); + } + } catch (e: any) { + error(e.stack); + cb(e.stack, null); + } + } + }); + } + + connect (opts: { token?: string } = {}) { + const isNewConnection = this.client === null; + try { + const err: string[] = []; + if (this.clientId.trim().length === 0) { + err.push('clientId'); + } + if (this.clientSecret.trim().length === 0) { + err.push('clientSecret'); + } + if (this.redirectURI.trim().length === 0) { + err.push('redirectURI'); + } + if (err.length > 0) { + throw new Error(err.join(', ') + ' missing'); + } + + this.client = new SpotifyWebApi({ + clientId: this.clientId, + clientSecret: this.clientSecret, + redirectUri: this.redirectURI, + }); + + if (this._refreshToken) { + this.client.setRefreshToken(this._refreshToken); + this.IRefreshToken(); + this.retry.IRefreshToken = 0; + } + + try { + if (opts.token && !_.isNil(this.client)) { + this.client.authorizationCodeGrant(opts.token) + .then((data) => { + this._accessToken = data.body.access_token; + this._refreshToken = data.body.refresh_token; + + if (this.client) { + this.client.setAccessToken(this._accessToken); + this.client.setRefreshToken(this._refreshToken); + } + this.retry.IRefreshToken = 0; + }, (authorizationError) => { + if (authorizationError) { + addUIError({ name: 'SPOTIFY', message: 'Getting of accessToken and refreshToken failed.' }); + info(chalk.yellow('SPOTIFY: ') + 'Getting of accessToken and refreshToken failed'); + } + }); + } + if (isNewConnection) { + info(chalk.yellow('SPOTIFY: ') + 'Client connected to service'); + } + } catch (e: any) { + error(e.stack); + addUIError({ name: 'SPOTIFY', message: 'Client connection failed.' }); + info(chalk.yellow('SPOTIFY: ') + 'Client connection failed'); + } + } catch (e: any) { + info(chalk.yellow('SPOTIFY: ') + e.message); + } + } + + disconnect () { + this.client = null; + info(chalk.yellow('SPOTIFY: ') + 'Client disconnected from service'); + } + + authorizeURI () { + if (_.isNil(this.client)) { + return null; + } + const state = crypto.createHash('md5').update(Math.random().toString()).digest('hex'); + this.state = state; + return this.client.createAuthorizeURL(this.scopes, state) + '&show_dialog=true'; + } + + @command('!spotify unban') + @default_permission(null) + async unban (opts: CommandOptions): Promise { + try { + const songToUnban = await SpotifySongBan.findOneOrFail({ where: { spotifyUri: opts.parameters } }); + await SpotifySongBan.delete({ spotifyUri: opts.parameters }); + return [{ + response: prepare('integrations.spotify.song-unbanned', { + artist: songToUnban.artists[0], uri: songToUnban.spotifyUri, name: songToUnban.title, + }), ...opts, + }]; + } catch (e: any) { + return [{ response: prepare('integrations.spotify.song-not-found-in-banlist', { uri: opts.parameters }), ...opts }]; + } + } + + @command('!spotify ban') + @default_permission(null) + async ban (opts: CommandOptions): Promise { + if (!this.client) { + error(`${chalk.bgRed('SPOTIFY')}: you are not connected to spotify API, authorize your user.`); + return []; + } + + // ban current playing song only + const currentSong: any = JSON.parse(this.currentSong); + if (Object.keys(currentSong).length === 0) { + return [{ response: prepare('integrations.spotify.not-banned-song-not-playing'), ...opts }]; + } else { + const songBan = SpotifySongBan.create({ + artists: currentSong.artists.split(', '), spotifyUri: currentSong.uri, title: currentSong.song, + }); + await songBan.save(); + this.cSkipSong(); + return [{ + response: prepare('integrations.spotify.song-banned', { + artists: currentSong.artists, artist: currentSong.artist, uri: currentSong.uri, name: currentSong.song, + }), ...opts, + }]; + } + } + + @command('!spotify') + @default_permission(null) + async main (opts: CommandOptions): Promise { + if (!isStreamOnline.value && !this.queueWhenOffline) { + error(`${chalk.bgRed('SPOTIFY')}: stream is offline and you have disabled queue when offline.`); + return []; + } // don't do anything on offline stream*/ + if (!this.songRequests) { + error(`${chalk.bgRed('SPOTIFY')}: song requests are disabled.`); + return []; + } + if (!this.client) { + error(`${chalk.bgRed('SPOTIFY')}: you are not connected to spotify API, authorize your user.`); + return []; + } + + try { + const [spotifyId] = new Expects(opts.parameters) + .everything() + .toArray(); + + if (spotifyId.startsWith('spotify:') || spotifyId.startsWith('https://open.spotify.com/track/')) { + let id = ''; + if (spotifyId.startsWith('spotify:')) { + id = spotifyId.replace('spotify:track:', ''); + } else { + const regex = new RegExp('\\S+open\\.spotify\\.com\\/track\\/(\\w+)(.*)?', 'gi'); + const exec = regex.exec(spotifyId); + if (exec) { + id = exec[1]; + } else { + throw Error('ID was not found in ' + spotifyId); + } + } + debug('spotify.request', `Searching song with id ${id}`); + + const response = await Promise.race([ + new Promise>>((resolve) => { + if (this.client) { + this.client.getTrack(id).then(data => resolve(data)); + } + }), + new Promise((resolve) => { + setTimeout(10 * SECOND).then(() => resolve(null)); + }), + ]); + + if (response === null) { + warning('Spotify didn\'t get track in time. Reconnecting client and retrying request.'); + await this.connect(); + return this.main(opts); + } + + debug('spotify.request', `Response => ${JSON.stringify({ response }, null, 2)}`); + ioServer?.emit('api.stats', { + method: 'GET', data: response.body, timestamp: Date.now(), call: 'spotify::search', api: 'other', endpoint: 'n/a', code: response.statusCode, + }); + + const track = response.body; + + if (this.allowApprovedArtistsOnly && this.approvedArtists.find((item) => { + return track.artists.find(artist => artist.name === item || artist.uri === item); + }) === undefined) { + return [{ response: prepare('integrations.spotify.cannot-request-song-from-unapproved-artist', { name: track.name, artist: track.artists[0].name }), ...opts }]; + } + + if(await this.requestSongByAPI(track.uri)) { + return [{ + response: prepare('integrations.spotify.song-requested', { + name: track.name, artist: track.artists[0].name, artists: track.artists.map(o => o.name).join(', '), + }), ...opts, + }]; + } else { + return [{ + response: prepare('integrations.spotify.cannot-request-song-is-banned', { + name: track.name, artist: track.artists[0].name, artists: track.artists.map(o => o.name).join(', '), + }), ...opts, + }]; + } + } else { + const response = await Promise.race([ + new Promise>>((resolve) => { + if (this.client) { + this.client.searchTracks(spotifyId).then(data => resolve(data)); + } + }), + new Promise((resolve) => { + setTimeout(10 * SECOND).then(() => resolve(null)); + }), + ]); + + if (response === null) { + warning('Spotify didn\'t get track in time. Reconnecting client and retrying request.'); + await this.connect(); + return this.main(opts); + } + ioServer?.emit('api.stats', { + method: 'GET', data: response.body, timestamp: Date.now(), call: 'spotify::search', api: 'other', endpoint: 'n/a', code: response.statusCode, + }); + + if (!response.body.tracks || response.body.tracks.items.length === 0) { + throw new Error('Song not found'); + } + + const track = response.body.tracks.items[0]; + if (this.allowApprovedArtistsOnly && this.approvedArtists.find((item) => { + return track.artists.find(artist => artist.name === item || artist.uri === item); + }) === undefined) { + return [{ response: prepare('integrations.spotify.cannot-request-song-from-unapproved-artist', { name: track.name, artist: track.artists[0].name }), ...opts }]; + } + + if(await this.requestSongByAPI(track.uri)) { + return [{ response: prepare('integrations.spotify.song-requested', { name: track.name, artist: track.artists[0].name }), ...opts }]; + } else { + return [{ response: prepare('integrations.spotify.cannot-request-song-is-banned', { name: track.name, artist: track.artists[0].name }), ...opts }]; + } + } + } catch (e: any) { + debug('spotify.request', e.stack); + if (e.message === 'PREMIUM_REQUIRED') { + error('Spotify Premium is required to request a song.'); + } else if (e.message !== 'Song not found') { + throw e; + } + return [{ response: prepare('integrations.spotify.song-not-found'), ...opts }]; + } + } + + async requestSongByAPI(uri: string) { + if (this.client) { + try { + const isSongBanned = (await SpotifySongBan.count({ where: { spotifyUri: uri } })) > 0; + if (isSongBanned) { + return false; + } + + const queueResponse = await this.client.addToQueue(uri, { device_id: this.deviceId }); + ioServer?.emit('api.stats', { + method: 'POST', data: queueResponse.body, timestamp: Date.now(), call: 'spotify::queue', api: 'other', endpoint: 'https://api.spotify.com/v1/me/player/queue?uri=' + uri, code: queueResponse.statusCode, + }); + return true; + } catch (e: any) { + if (e.stack.includes('WebapiPlayerError')) { + if (e.message.includes('NO_ACTIVE_DEVICE')) { + throw new Error('NO_ACTIVE_DEVICE'); + } + if (e.message.includes('PREMIUM_REQUIRED')) { + throw new Error('PREMIUM_REQUIRED'); + } + error(e.message); + return false; + } else { + // rethrow error + throw(e); + } + } + } + } +} + +const _spotify = new Spotify(); +export default _spotify; \ No newline at end of file diff --git a/backend/src/integrations/streamelements.ts b/backend/src/integrations/streamelements.ts new file mode 100644 index 000000000..e0b2a8581 --- /dev/null +++ b/backend/src/integrations/streamelements.ts @@ -0,0 +1,225 @@ +import { Currency, UserTip, UserTipInterface } from '@entity/user.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import Axios from 'axios'; +import chalk from 'chalk'; + +import Integration from './_interface.js'; +import { onChange, onStartup } from '../decorators/on.js'; +import { persistent, settings } from '../decorators.js'; +import eventlist from '../overlays/eventlist.js'; +import alerts from '../registries/alerts.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline, stats } from '~/helpers/api/index.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import rates from '~/helpers/currency/rates.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { triggerInterfaceOnTip } from '~/helpers/interface/triggers.js'; +import { + error, info, tip, +} from '~/helpers/log.js'; + +type StreamElementsEvent = { + donation: { + user: { + username: string, + geo: null, + email: string, + }, + message: string, + amount: number, + currency: Currency + }, + provider: string, + status: string, + deleted: boolean, + _id: string, + channel: string, + transactionId: string, + approved: string, + createdAt: string, + updatedAt: string +}; + +/* example payload (eventData) +{ + _id: '5d967959cd89a10ce12818ad', + channel: '5afbafb0c3a79ebedde18249', + event: 'tip', + provider: 'twitch', + createdAt: '2019-10-03T22:42:33.023Z', + data: { + tipId: '5d967959531876d2589dd772', + username: 'qwe', + amount: 12, + currency: 'RUB', + message: 'saaaaaaa', + items: [], + avatar: 'https://static-cdn.jtvnw.net/user-default-pictures-uv/ebe4cd89-b4f4-4cd9-adac-2f30151b4209-profile_image-300x300.png' + }, + updatedAt: '2019-10-03T22:42:33.023Z' +} */ + +function getTips (offset: number, channel: string, jwtToken: string, beforeDate: number, afterDate: number): Promise<[total: number, tips: any[]]> { + return new Promise((resolve) => { + Axios(`https://api.streamelements.com/kappa/v2/tips/${channel}?limit=100&before=${beforeDate}&after=${afterDate}&offset=${offset}`, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: 'Bearer ' + jwtToken, + }, + }).then((response: any) => { + const data = response.data; + resolve([Number(data.total), data.docs]); + }); + }); +} + +class StreamElements extends Integration { + channel = ''; + + @persistent() + afterDate = Date.now(); + + @settings() + jwtToken = ''; + + @onStartup() + interval() { + setInterval(async () => { + if (this.channel.length === 0) { + return; + } + const beforeDate = Date.now(); + + // get initial data + let [total, tips] = await getTips(0, this.channel, this.jwtToken, beforeDate, this.afterDate); + + while (tips.length < total) { + tips = [...tips, ...(await getTips(tips.length, this.channel, this.jwtToken, beforeDate, this.afterDate))[1]]; + } + + for (const item of tips.filter(o => new Date(o.createdAt).getTime() >= this.afterDate)) { + this.parse(item); + } + + this.afterDate = beforeDate; + + }, constants.MINUTE); + } + + @onStartup() + @onChange('enabled') + onStateChange (key: string, val: boolean) { + if (val) { + this.connect(); + } else { + this.channel = ''; + } + } + + @onChange('jwtToken') + async connect () { + if (this.jwtToken.trim() === '' || !this.enabled) { + return; + } + + // validate token + try { + const request = await Axios('https://api.streamelements.com/kappa/v2/channels/me', { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: 'Bearer ' + this.jwtToken, + }, + }) as any; + this.channel = request.data._id; + info(chalk.yellow('STREAMELEMENTS:') + ` JWT token check OK. Using channel ${request.data.username}@${request.data._id}`); + } catch (e: any) { + error(chalk.yellow('STREAMELEMENTS:') + ' JWT token is not valid.'); + return; + } + } + + async parse(eventData: StreamElementsEvent) { + const username = eventData.donation.user.username; + const amount = eventData.donation.amount; + const message = eventData.donation.message; + const DONATION_CURRENCY = eventData.donation.currency; + + if (isStreamOnline.value) { + stats.value.currentTips = stats.value.currentTips + exchange(amount, DONATION_CURRENCY, mainCurrency.value); + } + + let isAnonymous = false; + const timestamp = Date.now(); + users.getUserByUsername(username) + .then(async(user) => { + const newTip: UserTipInterface = { + amount: Number(amount), + currency: DONATION_CURRENCY, + sortAmount: exchange(Number(amount), DONATION_CURRENCY, mainCurrency.value), + message, + tippedAt: timestamp, + exchangeRates: rates, + userId: user.userId, + }; + AppDataSource.getRepository(UserTip).save(newTip); + tip(`${username.toLowerCase()}${user.userId ? '#' + user.userId : ''}, amount: ${Number(amount).toFixed(2)}${DONATION_CURRENCY}, message: ${message}`); + + eventlist.add({ + event: 'tip', + amount, + currency: DONATION_CURRENCY, + userId: user.userId, + message, + timestamp, + }); + }) + .catch(() => { + // user not found on Twitch + tip(`${username.toLowerCase()}#__anonymous__, amount: ${Number(amount).toFixed(2)}${DONATION_CURRENCY}, message: ${message}`); + eventlist.add({ + event: 'tip', + amount, + currency: DONATION_CURRENCY, + userId: `${username}#__anonymous__`, + message, + timestamp, + }); + isAnonymous = true; + }).finally(() => { + eventEmitter.emit('tip', { + userName: username.toLowerCase(), + amount: Number(amount).toFixed(2), + currency: DONATION_CURRENCY, + amountInBotCurrency: Number(exchange(amount, DONATION_CURRENCY, mainCurrency.value)).toFixed(2), + currencyInBot: mainCurrency.value, + message, + isAnonymous, + }); + alerts.trigger({ + event: 'tip', + service: 'streamelements', + name: username.toLowerCase(), + amount: Number(Number(amount).toFixed(2)), + tier: null, + currency: DONATION_CURRENCY, + monthsName: '', + message, + }); + + triggerInterfaceOnTip({ + userName: username.toLowerCase(), + amount, + message, + currency: DONATION_CURRENCY, + timestamp, + }); + }); + } +} + +export default new StreamElements(); diff --git a/backend/src/integrations/streamlabs.ts b/backend/src/integrations/streamlabs.ts new file mode 100644 index 000000000..ef31bf1ef --- /dev/null +++ b/backend/src/integrations/streamlabs.ts @@ -0,0 +1,324 @@ +import { Currency, UserTip, UserTipInterface } from '@entity/user.js'; +import axios from 'axios'; +import chalk from 'chalk'; +import { io, Socket } from 'socket.io-client'; + +import Integration from './_interface.js'; +import { onChange, onStartup } from '../decorators/on.js'; +import { persistent, settings } from '../decorators.js'; +import eventlist from '../overlays/eventlist.js'; +import alerts from '../registries/alerts.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline, stats } from '~/helpers/api/index.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import rates from '~/helpers/currency/rates.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { triggerInterfaceOnTip } from '~/helpers/interface/triggers.js'; +import { + debug, error, info, tip, warning, +} from '~/helpers/log.js'; +import { ioServer } from '~/helpers/panel.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import { variables } from '~/watchers.js'; + +namespace StreamlabsEvent { + export type Donation = { + type: 'donation'; + message: { + id: number; + name: string; + amount: string; + formatted_amount: string; + formattedAmount: string; + message: string; + currency: Currency; + emotes: null; + iconClassName: string; + to: { + name: string; + }; + from: string; + from_user_id: null; + _id: string; + isTest?: boolean; // our own variable + created_at: number; // our own variable + }[]; + event_id: string; + }; +} + +class Streamlabs extends Integration { + socketToStreamlabs: Socket | null = null; + + // save last donationId which rest api had + @persistent() + afterDonationId = ''; + + @settings() + accessToken = ''; + + accessTokenBtn = null; + + @settings() + socketToken = ''; + + @settings() + userName = ''; + + @onStartup() + onStartup() { + setInterval(() => { + if (this.onStartupTriggered) { + this.restApiInterval(); + } + }, 30000); + } + + @onChange('accessToken') + async onAccessTokenChange () { + await this.getMe(); + await this.getSocketToken(); + } + + @onStartup() + @onChange('enabled') + async onStateChange (key: string, val: boolean) { + if (val) { + await this.getMe(); + await this.getSocketToken(); + this.connect(); + } else { + this.disconnect(); + } + } + + async disconnect () { + if (this.socketToStreamlabs !== null) { + this.socketToStreamlabs.offAny(); + this.socketToStreamlabs.disconnect(); + } + } + + async restApiInterval () { + if (this.enabled && this.accessToken.length > 0) { + this.getMe(); + const after = String(this.afterDonationId).length > 0 ? `&after=${this.afterDonationId}` : ''; + const url = 'https://streamlabs.com/api/v1.0/donations?access_token=' + this.accessToken + after; + try { + const result = (await axios.get(url)).data; + debug('streamlabs', url); + debug('streamlabs', result); + ioServer?.emit('api.stats', { + method: 'GET', data: result, timestamp: Date.now(), call: 'streamlabs', api: 'other', endpoint: url, code: 200, + }); + let donationIdSet = false; + for (const item of result.data) { + if (!donationIdSet) { + this.afterDonationId = item.donation_id; + donationIdSet = true; + } + + const { name, currency: currency2, amount, message, created_at } = item; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + this.parse({ + type: 'donation', + message: [{ + formatted_amount: `${currency2}${amount}`, + formattedAmount: `${currency2}${amount}`, + amount: String(amount), + message: decodeURI(message), + from: name, + isTest: false, + created_at: Number(created_at), + // filling up + _id: '', + currency: currency2, + emotes: null, + from_user_id: null, + iconClassName: 'user', + id: 0, + name, + to: { name: broadcasterUsername }, + }], + event_id: '', + }); + } + } catch (e: any) { + if (e.isAxiosError) { + ioServer?.emit('api.stats', { + method: 'GET', data: e.message, timestamp: Date.now(), call: 'streamlabs', api: 'other', endpoint: url, code: e.response?.status ?? 'n/a', + }); + } else { + ioServer?.emit('api.stats', { + method: 'GET', data: e.stack, timestamp: Date.now(), call: 'streamlabs', api: 'other', endpoint: url, code: 0, + }); + } + if (e.message.includes('ETIMEDOUT')) { + error('Streamlabs connection timed out, will retry later.'); + } else { + error(e.stack); + } + } + } + } + + async getSocketToken() { + if (this.enabled && this.accessToken.length > 0 ) { + try { + const url = 'https://streamlabs.com/api/v1.0/socket/token?access_token=' + this.accessToken; + const result = (await axios.get(url)).data; + this.socketToken = result.socket_token; + } catch (e) { + if (this.socketToken === '') { + warning('STREAMLABS: Couldn\'t fetch socket token. Will use only REST API polling.'); + } + } + } + } + + async getMe() { + if (this.enabled && this.accessToken.length > 0) { + const url = 'https://streamlabs.com/api/v1.0/user?access_token=' + this.accessToken; + const result = (await axios.get(url)).data; + if (this.userName !== result.streamlabs.username) { + info('STREAMLABS: Connected as ' + result.streamlabs.username); + } + this.userName = result.streamlabs.username; + } else { + this.userName = ''; + } + } + + sockets() { + adminEndpoint('/integrations/streamlabs', 'revoke', async (cb) => { + this.socketToken = ''; + this.userName = ''; + this.accessToken = ''; + this.disconnect(); + info(`STREAMLABS: User access revoked.`); + cb(null); + }); + adminEndpoint('/integrations/streamlabs', 'token', async (tokens, cb) => { + this.accessToken = tokens.accessToken; + await this.connect(); + cb(null); + }); + } + + @onChange('socketToken') + async connect () { + this.disconnect(); + + if (this.socketToken.trim() === '' || !this.enabled) { + return; + } + + this.socketToStreamlabs = io('https://sockets.streamlabs.com?token=' + this.socketToken); + + this.socketToStreamlabs.on('reconnect_attempt', () => { + info(chalk.yellow('STREAMLABS:') + ' Trying to reconnect to service'); + }); + + this.socketToStreamlabs.on('connect', () => { + info(chalk.yellow('STREAMLABS:') + ' Successfully connected socket to service'); + }); + + this.socketToStreamlabs.on('disconnect', () => { + info(chalk.yellow('STREAMLABS:') + ' Socket disconnected from service'); + if (this.socketToStreamlabs) { + this.socketToStreamlabs.open(); + } + }); + + this.socketToStreamlabs.on('event', async (eventData: StreamlabsEvent.Donation) => { + this.parse(eventData); + }); + } + + async parse(eventData: StreamlabsEvent.Donation) { + if (eventData.type === 'donation') { + for (const event of eventData.message) { + const timestamp = (event.created_at * 1000) || Date.now(); + + debug('streamlabs', event); + if (!event.isTest) { + const user = await users.getUserByUsername(event.from.toLowerCase()); + const tips = await AppDataSource.getRepository(UserTip).find({ where: { userId: user.userId } }); + + // workaround for https://github.com/sogebot/sogeBot/issues/3338 + // incorrect currency on event rerun + const parsedCurrency = (event.formatted_amount as string).match(/(?[A-Z$]{3}|\$)/); + if (parsedCurrency && parsedCurrency.groups) { + event.currency = (parsedCurrency.groups.currency === '$' ? 'USD' : parsedCurrency.groups.currency) as Currency; + } + + // check if it is new tip (by message and by tippedAt time interval) + if (tips.find(item => { + return item.message === event.message + && (item.tippedAt || 0) - 30000 < timestamp + && (item.tippedAt || 0) + 30000 > timestamp; + })) { + return; // we already have this one + } + + const newTip: UserTipInterface = { + amount: Number(event.amount), + currency: event.currency, + sortAmount: exchange(Number(event.amount), event.currency, mainCurrency.value), + message: event.message, + tippedAt: timestamp, + exchangeRates: rates, + userId: user.userId, + }; + AppDataSource.getRepository(UserTip).save(newTip); + + if (isStreamOnline.value) { + stats.value.currentTips = stats.value.currentTips + Number(exchange(Number(event.amount), event.currency, mainCurrency.value)); + } + tip(`${event.from.toLowerCase()}${user.userId ? '#' + user.userId : ''}, amount: ${Number(event.amount).toFixed(2)}${event.currency}, message: ${event.message}`); + } + eventlist.add({ + event: 'tip', + amount: Number(event.amount), + currency: event.currency, + userId: String(await users.getIdByName(event.from.toLowerCase())), + message: event.message, + timestamp, + isTest: event.isTest, + }); + eventEmitter.emit('tip', { + isAnonymous: false, + userName: event.from.toLowerCase(), + amount: parseFloat(event.amount).toFixed(2), + currency: event.currency, + amountInBotCurrency: Number(exchange(Number(event.amount), event.currency, mainCurrency.value)).toFixed(2), + currencyInBot: mainCurrency.value, + message: event.message, + }); + alerts.trigger({ + event: 'tip', + name: event.from.toLowerCase(), + service: 'streamlabs', + tier: null, + amount: Number(parseFloat(event.amount).toFixed(2)), + currency: event.currency, + monthsName: '', + message: event.message, + }); + + triggerInterfaceOnTip({ + userName: event.from.toLowerCase(), + amount: Number(event.amount), + message: event.message, + currency: event.currency, + timestamp, + }); + } + } + } +} + +export default new Streamlabs(); \ No newline at end of file diff --git a/backend/src/integrations/tiltify.ts b/backend/src/integrations/tiltify.ts new file mode 100644 index 000000000..7c1df76d9 --- /dev/null +++ b/backend/src/integrations/tiltify.ts @@ -0,0 +1,215 @@ +import { Mutex } from 'async-mutex'; +import fetch from 'node-fetch'; + +import Integration from './_interface.js'; +import { command, persistent, settings } from '../decorators.js'; + +import { onStartup } from '~/decorators/on.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { triggerInterfaceOnTip } from '~/helpers/interface/index.js'; +import { getLang } from '~/helpers/locales.js'; +import { error, info, tip } from '~/helpers/log.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; +import eventlist from '~/overlays/eventlist.js'; +import alerts from '~/registries/alerts.js'; + +const mutex = new Mutex(); + +class Tiltify extends Integration { + @settings() + access_token = ''; + @settings() + userName = ''; + @settings() + userId = ''; + + @persistent() + lastCheckAt = Date.now(); + + campaigns: { + id: number, + name: string, + slug: string, + startsAt: number, + endsAt: null | number, + description: string, + causeId: number, + originalFundraiserGoal: number, + fundraiserGoalAmount: number, + supportingAmountRaised: number, + amountRaised: number, + supportable: boolean, + status: 'published', + type: 'Event', + avatar: { + src: string, + alt: string, + width: number, + height: number, + }, + livestream: { + type: 'twitch', + channel: string, + } | null, + causeCurrency: 'USD', + totalAmountRaised: 0, + user: { + id: number, + username: string, + slug: string, + url: string, + avatar: { + src: string, + alt: string, + width: number, + height: number, + }, + }, + regionId: null, + metadata: Record, + }[] = []; + donations: Record = {}; + + @onStartup() + onStartup() { + if (this.access_token.length > 0 && this.userName.length > 0 && String(this.userId).length > 0) { + info(`TILTIFY: Logged in as ${this.userName}#${this.userId}.`); + } else { + info(`TILTIFY: Not logged in.`); + } + setInterval(async () => { + if (this.enabled) { + return; + } + if (this.access_token.length > 0 && this.userName.length > 0 && String(this.userId).length > 0) { + const release = await mutex.acquire(); + try { + await this.getCampaigns(); + await this.getDonations(); + } catch(e) { + error(e); + } + release(); + } + }, 30000); + } + + async getCampaigns() { + const response = await fetch(`https://tiltify.com/api/v3/users/${this.userId}/campaigns`, { + headers: { + 'Authorization': `Bearer ${this.access_token}`, + }, + }); + this.campaigns = (await response.json() as any).data; + } + + async getDonations() { + for (const campaign of this.campaigns) { + const response = await fetch(`https://tiltify.com/api/v3/campaigns/${campaign.id}/donations`, { + headers: { + 'Authorization': `Bearer ${this.access_token}`, + }, + }); + const data = (await response.json() as any).data as { + 'id': number, + 'amount': number, + 'name': string, + 'comment': string, + 'completedAt': number, + 'rewardId': number, + }[]; + + for (const donate of data) { + if (this.lastCheckAt < donate.completedAt) { + tip(`${donate.name} for ${campaign.name}, amount: ${Number(donate.amount).toFixed(2)}${campaign.causeCurrency}, message: ${donate.comment}`); + alerts.trigger({ + event: 'tip', + service: 'tiltify', + name: donate.name, + amount: Number(donate.amount.toFixed(2)), + tier: null, + currency: campaign.causeCurrency, + monthsName: '', + message: donate.comment, + }); + eventlist.add({ + event: 'tip', + amount: donate.amount, + currency: campaign.causeCurrency, + userId: `${donate.name}#__anonymous__`, + message: donate.comment, + timestamp: donate.completedAt, + charityCampaignName: campaign.name, + }); + triggerInterfaceOnTip({ + userName: donate.name, + amount: donate.amount, + message: donate.comment, + currency: campaign.causeCurrency, + timestamp: donate.completedAt, + }); + } + } + } + } + + sockets () { + publicEndpoint('/integrations/tiltify', 'tiltify::campaigns', async (cb) => { + cb(this.campaigns); + }); + adminEndpoint('/integrations/tiltify', 'tiltify::revoke', async (cb) => { + self.access_token = ''; + self.userName = ''; + self.userId = ''; + info(`TILTIFY: User access revoked.`); + cb(null); + }); + adminEndpoint('/integrations/tiltify', 'tiltify::code', async (token, cb) => { + // check if token is working ok + const response = await fetch(`https://tiltify.com/api/v3/user`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (response.ok) { + const user = await response.json() as any; + this.userName = user.data.username; + this.userId = user.data.id; + info(`TILTIFY: Logged in as ${this.userName}#${this.userId}.`); + self.access_token = token; + } else { + error(`TILTIFY: Something went wrong during setting access token. Please retry.`); + } + + cb(null); + }); + } + + @command('!charity') + commandCharity(opts: CommandOptions) { + const responses: string[] = []; + for (const campaign of this.campaigns) { + responses.push(`${responses.length+1}. ${campaign.name} - ${campaign.amountRaised.toLocaleString(getLang(), { style: 'currency', currency: campaign.causeCurrency })} of ${campaign.fundraiserGoalAmount.toLocaleString(getLang(), { style: 'currency', currency: campaign.causeCurrency })} => ${campaign.user.url}/${campaign.slug}`); + } + if (responses.length > 0) { + return [prepare('integrations.tiltify.active_campaigns'), ...responses].map(response => ({ + response, ...opts, + })); + } else { + return [ + { response: prepare('integrations.tiltify.no_active_campaigns'), ...opts }, + ]; + } + } +} + +const self = new Tiltify(); diff --git a/backend/src/integrations/tipeeestream.ts b/backend/src/integrations/tipeeestream.ts new file mode 100644 index 000000000..975873908 --- /dev/null +++ b/backend/src/integrations/tipeeestream.ts @@ -0,0 +1,218 @@ +import { Currency, UserTip, UserTipInterface } from '@entity/user.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import fetch from 'node-fetch'; + +import Integration from './_interface.js'; +import { persistent, settings } from '../decorators.js'; +import { onStartup } from '../decorators/on.js'; +import eventlist from '../overlays/eventlist.js'; +import alerts from '../registries/alerts.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline } from '~/helpers/api/index.js'; +import { stats } from '~/helpers/api/stats.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import rates from '~/helpers/currency/rates.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { triggerInterfaceOnTip } from '~/helpers/interface/triggers.js'; +import { error, tip } from '~/helpers/log.js'; + +type TipeeestreamEvent = { + message: string, + datas: { + items: [ + { + id: number, + type: 'donation', + user: { + locked: boolean, + hash_id: string, + hash: string, + avatar: { + id: number, + is_converted: boolean + }, + hasPayment: string, + currency: { + code: Currency, + symbol: string, + label: string, + available: boolean + }, + country: string, + campaignActivation: number, + id: number, + providers: { + connectedAt: string, + code: 'twitch' | 'youtube', + id: number, + username: string, + master: boolean, + token: string, + followers: number, + last_follow_update: string, + created_at: string, + channel: string, + }[] + , + username: string, + pseudo: string, + email_confirmation_at: string, + created_at: string, + session_at: string, + parameters: [] + }, + ref: string, + created_at: string, + inserted_at: string, + display: boolean, + parameters: { + amount: number, + campaignId: number, + currency: Currency, + fees: number, + identifier: string, + formattedMessage: string, + message?: string, + username: string + }, + formattedAmount: string, + 'parameters.amount': number + } + ], + total_count: number + } +}; + +class TipeeeStream extends Integration { + @persistent() + afterDate = 0; + + @settings() + apiKey = ''; + + @onStartup() + interval() { + setInterval(async () => { + if (this.apiKey.length === 0 || !this.enabled) { + return; + } + try { + const beforeDate = Date.now(); + const response = await fetch(`https://api.tipeeestream.com/v1.0/events.json?apiKey=${this.apiKey}&type[]=donation&limit=100000&end=${(new Date(beforeDate)).toISOString()}&start=${(new Date(this.afterDate)).toISOString()}`); + + if (response.ok) { + const data = await response.json() as TipeeestreamEvent; + + if (data.message !== 'success') { + throw new Error(data.message); + } + + for (const item of data.datas.items) { + this.parse(item); + } + + this.afterDate = beforeDate; + } else { + if (response.status === 401) { + setTimeout(() => this.status({ state: false }), 1000); + throw new Error('Unauthorized access, please check your apiKey. Disabling Tipeeestream integration.'); + } else { + throw new Error(response.statusText); + } + } + } catch (e) { + if (e instanceof Error) { + error(`TIPEEESTREAM: ${e.stack}`); + } + } + + }, constants.MINUTE); + } + + async parse(data: TipeeestreamEvent['datas']['items'][number]) { + try { + const amount = data.parameters.amount; + const message = data.parameters.message ?? ''; + const userName = data.parameters.username.toLowerCase(); + const donationCurrency = data.parameters.currency; + + if (isStreamOnline.value) { + stats.value.currentTips = stats.value.currentTips + Number(exchange(amount, donationCurrency, mainCurrency.value)); + } + + let isAnonymous = false; + const timestamp = Date.now(); + users.getUserByUsername(userName) + .then(async(user) => { + const newTip: UserTipInterface = { + amount, + currency: donationCurrency, + sortAmount: exchange(Number(amount), donationCurrency, mainCurrency.value), + message, + exchangeRates: rates, + tippedAt: timestamp, + userId: user.userId, + }; + AppDataSource.getRepository(UserTip).save(newTip); + tip(`${userName.toLowerCase()}${user.userId ? '#' + user.userId : ''}, amount: ${Number(amount).toFixed(2)}${donationCurrency}, message: ${message}`); + eventlist.add({ + event: 'tip', + amount, + currency: donationCurrency, + userId: user.userId, + message, + timestamp, + }); + }) + .catch(() => { + // user not found on Twitch + tip(`${userName.toLowerCase()}#__anonymous__, amount: ${Number(amount).toFixed(2)}${donationCurrency}, message: ${message}`); + eventlist.add({ + event: 'tip', + amount, + currency: donationCurrency, + userId: `${userName}#__anonymous__`, + message, + timestamp, + }); + isAnonymous = true; + }).finally(() => { + eventEmitter.emit('tip', { + userName: userName.toLowerCase(), + amount: Number(amount).toFixed(2), + currency: donationCurrency, + amountInBotCurrency: Number(exchange(amount, donationCurrency, mainCurrency.value)).toFixed(2), + currencyInBot: mainCurrency.value, + message, + isAnonymous, + }); + alerts.trigger({ + event: 'tip', + service: 'tipeeestream', + name: userName.toLowerCase(), + amount: Number(Number(amount).toFixed(2)), + tier: null, + currency: donationCurrency, + monthsName: '', + message, + }); + + triggerInterfaceOnTip({ + userName: userName.toLowerCase(), + amount, + message, + currency: donationCurrency, + timestamp, + }); + }); + } catch (e: any) { + error(`TIPEESTREAM: Error in parsing event: ${JSON.stringify(data)})`); + error(e); + } + } +} + +export default new TipeeeStream(); diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 000000000..9979ec683 --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,142 @@ +Error.stackTraceLimit = Infinity; + +import 'reflect-metadata'; + +import 'dotenv/config'; + +import { existsSync, readFileSync, unlinkSync } from 'fs'; +import { normalize } from 'path'; +import util from 'util'; + +import blocked from 'blocked-at'; +import figlet from 'figlet'; +import gitCommitInfo from 'git-commit-info'; +import { get } from 'lodash-es'; + +import { autoLoad } from './helpers/autoLoad.js'; +import { isDebugEnabled, setDEBUG } from './helpers/debug.js'; +import { startWatcher } from './watchers.js'; + +import { AppDataSource } from '~/database.js'; +import { setIsBotStarted, setIsDbConnected } from '~/helpers/database.js'; +import { + error, info, warning, +} from '~/helpers/log.js'; + +const connect = async function () { + const type = process.env.TYPEORM_CONNECTION; + if (!type) { + error('Set your db in .env or as ENVIROMNENT VARIABLES'); + process.exit(1); + } + + await AppDataSource.initialize(); + await AppDataSource.runMigrations(); + + const typeToLog = { + 'better-sqlite3': 'SQLite3', + mariadb: 'MySQL/MariaDB', + mysql: 'MySQL/MariaDB', + postgres: 'PostgreSQL', + }; + info(`Initialized ${typeToLog[type as keyof typeof typeToLog]} database (${normalize(String(AppDataSource.options.database))})`); + setIsDbConnected(); +}; + +async function main () { + try { + const version = get(process, 'env.npm_package_version', 'x.y.z'); + const commitFile = existsSync('./.commit') ? readFileSync('./.commit').toString() : null; + if (!existsSync('~/restart.pid')) { + const versionString = version.replace('SNAPSHOT', commitFile && commitFile.length > 0 ? commitFile : gitCommitInfo().shortHash || 'SNAPSHOT'); + process.stdout.write(figlet.textSync('sogeBot ' + versionString, { + font: 'ANSI Shadow', + horizontalLayout: 'default', + verticalLayout: 'default', + })); + process.stdout.write('\n\n\n'); + info(`Bot is starting up (Bot version: ${versionString.replace('\n', '')}, NodeJS: ${process.versions.node})`); + if (process.env.DEBUG) { + setDEBUG(process.env.DEBUG); + } + } + await connect(); + } catch (e: any) { + error('Bot was unable to connect to database, check your database configuration'); + error(e); + error('Exiting bot.'); + process.exit(1); + } + try { + // Initialize all core singletons + setTimeout(async () => { + const translate = (await import('./translate.js')).default; + + translate._load().then(async () => { + await import('./general.js'); + await import('./socket.js'); + await import('./ui.js'); + await import('./currency.js'); + await import('./stats.js'); + await import('./users.js'); + await import('./events.js'); + await import('./plugins.js'); + await import('./customvariables.js'); + await import('./permissions.js'); + await import('./dashboard.js'); + await import('./tts.js'); + await import('./emotes.js'); + await import('./panel.js'); + await autoLoad('./dest/stats/'); + await autoLoad('./dest/registries/'); + await autoLoad('./dest/systems/'); + await autoLoad('./dest/widgets/'); + await autoLoad('./dest/overlays/'); + await autoLoad('./dest/games/'); + await autoLoad('./dest/integrations/'); + await autoLoad('./dest/services/'); + + setTimeout(() => { + if (existsSync('~/restart.pid')) { + unlinkSync('~/restart.pid'); + } + setIsBotStarted(); + startWatcher(); + + if (isDebugEnabled('eventloop')) { + warning('EVENTLOOP BLOCK DETECTION ENABLED! This may cause some performance issues.'); + blocked((time: any, stack: any) => { + error(`EVENTLOOP BLOCK !!! Blocked for ${time}ms, operation started here:`); + error(stack); + }, { threshold: 1000 }); + } + }, 30000); + }); + }, 5000); + } catch (e: any) { + error(e); + process.exit(); + } +} + +main(); + +process.on('unhandledRejection', function (reason, p) { + error(`Possibly Unhandled Rejection at: ${util.inspect(p)} reason: ${reason}`); +}); + +process.on('uncaughtException', (err: Error) => { + const date = new Date().toISOString(); + process.report?.writeReport(`uncaughtException-${date}`, err); + error(util.inspect(err)); + if (err.message.includes('[TwitchJS] Parse error encountered [Chat]')) { + // workaround for https://github.com/sogebot/sogeBot/issues/3762 + return; + } + error(''); + error('BOT HAS UNEXPECTEDLY CRASHED'); + error('PLEASE CHECK https://github.com/sogebot/sogeBot/wiki/How-to-report-an-issue'); + error(`AND ADD ${process.cwd()}/logs/uncaughtException-${date}.json file to your report`); + error(''); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/src/message.ts b/backend/src/message.ts new file mode 100644 index 000000000..50ffc3a5f --- /dev/null +++ b/backend/src/message.ts @@ -0,0 +1,264 @@ +import axios from 'axios'; +import _ from 'lodash-es'; +import { v4 } from 'uuid'; + +import { + operation, command, count, custom, evaluate, ifp, info, list, math, online, param, price, qs, random, ResponseFilter, stream, youtube, +} from './filters/index.js'; +import getBotId from './helpers/user/getBotId.js'; +import getBotUserName from './helpers/user/getBotUserName.js'; + +import { timer } from '~/decorators.js'; +import { getGlobalVariables } from '~/helpers/checkFilter.js'; +import { getUserSender } from '~/helpers/commons/index.js'; +import { app } from '~/helpers/panel.js'; +import twitch from '~/services/twitch.js'; +import { adminMiddleware } from '~/socket.js'; +import { translate } from '~/translate.js'; + +(function initializeMessageParserAPI() { + if (!app) { + setTimeout(() => initializeMessageParserAPI(), 100); + return; + } + + app.post('/api/core/parse', adminMiddleware, async (req, res) => { + try { + const text = await new Message(req.body.message).parse({ sender: getUserSender(req.body.user.id, req.body.user.username), discord: undefined }) as string; + res.send({ data: text }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); +})(); + +export class Message { + message = ''; + id = v4(); + + constructor (message: string) { + this.message = message; + } + + @timer() + async global (opts: { escape?: string, sender?: CommandOptions['sender'], discord?: CommandOptions['discord'] }) { + if (!this.message.includes('$')) { + // message doesn't have any variables + return this.message; + } + + const variables = await getGlobalVariables(this.message, opts); + for (const variable of Object.keys(variables)) { + this.message = this.message.replaceAll(variable, String(variables[variable as keyof typeof variables] ?? '')); + } + + return this.message; + } + + @timer() + async parse (attr: { [name: string]: any, isFilter?: boolean, sender: CommandOptions['sender'], discord: CommandOptions['discord'], forceWithoutAt?: boolean } = { sender: getUserSender(getBotId(), getBotUserName()), discord: undefined, isFilter: false }) { + this.message = await this.message; // if is promise + + if (!attr.isFilter) { + await this.global({ sender: attr.sender, discord: attr.discord }); + // local replaces + if (!_.isNil(attr)) { + for (let [key, value] of Object.entries(attr)) { + if (key === 'sender') { + if (typeof value.userName !== 'undefined') { + value = twitch.showWithAt && attr.forceWithoutAt !== true ? `@${value.userName}` : value.userName; + } else { + value = twitch.showWithAt && attr.forceWithoutAt !== true ? `@${value}` : value; + } + } + this.message = this.message.replace(new RegExp('[$]' + key, 'g'), value); + } + } + await this.parseMessageEach(param, attr, true); + } + + await this.parseMessageEach(price, attr); + await this.parseMessageEach(info, attr); + await this.parseMessageEach(youtube, attr); + await this.parseMessageEach(random, attr); + await this.parseMessageEach(ifp, attr, false); + if (attr.replaceCustomVariables || typeof attr.replaceCustomVariables === 'undefined') { + await this.parseMessageVariables(custom, attr); + } + await this.parseMessageEach(math, attr); + await this.parseMessageOnline(online, attr); + await this.parseMessageCommand(command, attr); + await this.parseMessageEach(qs, attr, false); + await this.parseMessageEach(list, attr); + await this.parseMessageEach(stream, attr, true, '$\\@\\w0-9'); + await this.parseMessageEach(count, attr); + await this.parseMessageEach(operation, attr, false); + await this.parseMessageEval(evaluate, attr); + await this.parseMessageApi(); + + return this.message; + } + + @timer() + async parseMessageApi () { + if (this.message.trim().length === 0) { + return; + } + + const rMessage = this.message.match(/\(api\|(http\S+)\)/i); + if (!_.isNil(rMessage) && !_.isNil(rMessage[1])) { + this.message = this.message.replace(rMessage[0], '').trim(); // remove api command from message + const url = rMessage[1].replace(/&/g, '&'); + const response = await axios.get(url); + if (response.status !== 200) { + return translate('core.api.error'); + } + + // search for api datas in this.message + const rData = this.message.match(/\(api\.(?!_response)(\S*?)\)/gi); + if (_.isNil(rData)) { + if (_.isObject(response.data)) { + // Stringify object + this.message = this.message.replace('(api._response)', JSON.stringify(response.data)); + } else { + this.message = this.message.replace('(api._response)', response.data.toString().replace(/^"(.*)"/, '$1')); + } + } else { + if (_.isBuffer(response.data)) { + response.data = JSON.parse(response.data.toString()); + } + for (const tag of rData) { + let path = response.data; + const ids = tag.replace('(api.', '').replace(')', '').split('.'); + _.each(ids, function (id) { + const isArray = id.match(/(\S+)\[(\d+)\]/i); + if (isArray) { + path = path[isArray[1]][isArray[2]]; + } else { + path = path[id]; + } + }); + this.message = this.message.replace(tag, !_.isNil(path) ? path : translate('core.api.not-available')); + } + } + } + } + + @timer() + async parseMessageCommand (filters: ResponseFilter, attr: Parameters[1]) { + if (this.message.trim().length === 0) { + return; + } + for (const key in filters) { + const fnc = filters[key]; + let regexp = _.escapeRegExp(key); + + // we want to handle # as \w - number in regexp + regexp = regexp.replace(/#/g, '.*?'); + const rMessage = this.message.match((new RegExp('(' + regexp + ')', 'g'))); + if (rMessage !== null) { + for (const bkey in rMessage) { + this.message = this.message.replace(rMessage[bkey], await fnc(rMessage[bkey], { ..._.cloneDeep(attr), sender: attr.sender })).trim(); + } + } + } + } + + @timer() + async parseMessageOnline (filters: ResponseFilter, attr: Parameters[1]) { + if (this.message.trim().length === 0) { + return; + } + for (const key in filters) { + const fnc = filters[key]; + let regexp = _.escapeRegExp(key); + + // we want to handle # as \w - number in regexp + regexp = regexp.replace(/#/g, '(\\S+)'); + const rMessage = this.message.match((new RegExp('(' + regexp + ')', 'g'))); + if (rMessage !== null) { + for (const bkey in rMessage) { + if (!(await fnc(rMessage[bkey], { ..._.cloneDeep(attr), sender: attr.sender }))) { + this.message = ''; + } else { + this.message = this.message.replace(rMessage[bkey], '').trim(); + } + } + } + } + } + + @timer() + async parseMessageEval (filters: ResponseFilter, attr: Parameters[1]) { + if (this.message.trim().length === 0) { + return; + } + for (const key in filters) { + const fnc = filters[key]; + let regexp = _.escapeRegExp(key); + + // we want to handle # as \w - number in regexp + regexp = regexp.replace(/#/g, '([\\S ]+)'); + const rMessage = this.message.match((new RegExp('(' + regexp + ')', 'g'))); + if (rMessage !== null) { + for (const bkey in rMessage) { + const newString = await fnc(rMessage[bkey], { ..._.cloneDeep(attr), sender: attr.sender }); + if (_.isUndefined(newString) || newString.length === 0) { + this.message = ''; + } + this.message = this.message.replace(rMessage[bkey], newString).trim(); + } + } + } + } + + @timer() + async parseMessageVariables (filters: ResponseFilter, attr: Parameters[1], removeWhenEmpty = true) { + if (this.message.trim().length === 0) { + return; + } + for (const key in filters) { + const fnc = filters[key]; + let regexp = _.escapeRegExp(key); + + regexp = regexp.replace(/#/g, '([a-zA-Z0-9_]+)'); + const rMessage = this.message.match((new RegExp('(' + regexp + ')', 'g'))); + if (rMessage !== null) { + for (const bkey in rMessage) { + const newString = await fnc(rMessage[bkey], { ..._.cloneDeep(attr), sender: attr.sender }); + if ((_.isNil(newString) || newString.length === 0) && removeWhenEmpty) { + this.message = ''; + } + this.message = this.message.replace(rMessage[bkey], newString).trim(); + } + } + } + } + + @timer() + async parseMessageEach (filters: ResponseFilter, attr: Parameters[1], removeWhenEmpty = true, regexpChar = '\\S') { + if (this.message.trim().length === 0) { + return; + } + for (const key in filters) { + const fnc = filters[key]; + let regexp = _.escapeRegExp(key); + + if (key.startsWith('$')) { + regexp = regexp.replace(/#/g, '(.+?)'); + } else { + regexp = regexp.replace(/#/g, '([' + regexpChar + ' ]+?)'); // default behavior for if + } + const rMessage = this.message.match((new RegExp('(' + regexp + ')', 'g'))); + if (rMessage !== null) { + for (const bkey in rMessage) { + const newString = await fnc(rMessage[bkey], { ..._.cloneDeep(attr), sender: attr.sender }); + if ((_.isNil(newString) || newString.length === 0) && removeWhenEmpty) { + this.message = ''; + } + this.message = String(this.message.replace(rMessage[bkey], newString)).trim(); + } + } + } + } +} diff --git a/backend/src/overlays/_interface.ts b/backend/src/overlays/_interface.ts new file mode 100644 index 000000000..61c2404e6 --- /dev/null +++ b/backend/src/overlays/_interface.ts @@ -0,0 +1,9 @@ +import Module from '../_interface.js'; + +class Overlay extends Module { + constructor() { + super('overlays', true); + } +} + +export default Overlay; diff --git a/backend/src/overlays/bets.ts b/backend/src/overlays/bets.ts new file mode 100644 index 000000000..ccc1c8931 --- /dev/null +++ b/backend/src/overlays/bets.ts @@ -0,0 +1,24 @@ +import Overlay from './_interface.js'; + +import * as channelPrediction from '~/helpers/api/channelPrediction.js'; +import { publicEndpoint } from '~/helpers/socket.js'; + +class Bets extends Overlay { + public sockets() { + publicEndpoint(this.nsp, 'data', async (callback) => { + const data = channelPrediction.status(); + callback(data ? { + id: data.id, + title: data.title, + autoLockAfter: 'autoLockAfter' in data ? data.autoLockAfter : null, + creationDate: data.creationDate ? new Date(data.creationDate).toISOString() : null, + lockDate: data.lockDate ? new Date(data.lockDate).toISOString() : null, + outcomes: data.outcomes, + winningOutcomeId: 'winningOutcomeId' in data ? data.winningOutcomeId : null, + winningOutcome: 'winningOutcome' in data ? data.winningOutcome : null, + } : null); + }); + } +} + +export default new Bets(); diff --git a/backend/src/overlays/chat.ts b/backend/src/overlays/chat.ts new file mode 100644 index 000000000..15a83a079 --- /dev/null +++ b/backend/src/overlays/chat.ts @@ -0,0 +1,66 @@ +import { v4 } from 'uuid'; + +import Overlay from './_interface.js'; +import { badgesCache } from '../services/twitch/calls/getChannelChatBadges.js'; + +import { onMessage } from '~/decorators/on.js'; +import { timer } from '~/decorators.js'; +import { ioServer } from '~/helpers/panel.js'; +import { parseTextWithEmotes } from '~/helpers/parseTextWithEmotes.js'; +import { adminEndpoint } from '~/helpers/socket.js'; + +class Chat extends Overlay { + showInUI = false; + + @timer() + async withEmotes (text: string | undefined) { + return parseTextWithEmotes(text, 3); + } + + @onMessage() + message(message: onEventMessage) { + this.withEmotes(message.message).then(data => { + if (!message.sender) { + return; + } + const badgeImages: {url: string }[] = []; + for (const messageBadgeId of message.sender.badges.keys()) { + const badge = badgesCache.find(o => o.id === messageBadgeId); + if (badge) { + const badgeImage = badge.getVersion(message.sender.badges.get(messageBadgeId) as string)?.getImageUrl(4); + if (badgeImage) { + badgeImages.push({ url: badgeImage }); + } + } + } + ioServer?.of('/overlays/chat').emit('message', { + id: v4(), + timestamp: message.timestamp, + displayName: message.sender.displayName.toLowerCase() === message.sender.userName ? message.sender.displayName : `${message.sender.displayName} (${message.sender.userName})`, + userName: message.sender.userName, + message: data, + show: false, + badges: badgeImages, + color: message.sender.color, + }); + }); + } + + sockets() { + adminEndpoint('/overlays/chat', 'test', (data) => { + this.withEmotes(data.message).then(message => { + ioServer?.of('/overlays/chat').emit('message', { + id: v4(), + timestamp: Date.now(), + displayName: data.username, + userName: data.username, + message, + show: false, + badges: [], + }); + }); + }); + } +} + +export default new Chat(); diff --git a/backend/src/overlays/clips.ts b/backend/src/overlays/clips.ts new file mode 100644 index 000000000..27224cb5b --- /dev/null +++ b/backend/src/overlays/clips.ts @@ -0,0 +1,26 @@ +import Overlay from './_interface.js'; + +import { ioServer } from '~/helpers/panel.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import twitch from '~/services/twitch.js'; + +class Clips extends Overlay { + sockets() { + adminEndpoint('/overlays/clips', 'test', clipURL => { + this.showClip(clipURL); + }); + } + + async showClip (clipId: string) { + const getClipById = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.clips.getClipById(clipId)); + + if (getClipById) { + ioServer?.of('/' + this._name + '/' + this.__moduleName__.toLowerCase()) + .emit('clips', { clips: [ + { ...getClipById, mp4: getClipById.thumbnailUrl.replace('-preview-480x272.jpg', '.mp4') }, + ] }); + } + } +} + +export default new Clips(); diff --git a/backend/src/overlays/clipscarousel.ts b/backend/src/overlays/clipscarousel.ts new file mode 100644 index 000000000..58b821e44 --- /dev/null +++ b/backend/src/overlays/clipscarousel.ts @@ -0,0 +1,19 @@ +import type { ClipsCarousel as CC } from '@entity/overlay.js'; + +import Overlay from './_interface.js'; + +import { publicEndpoint } from '~/helpers/socket.js'; +import { getTopClips } from '~/services/twitch/calls/getTopClips.js'; + +class ClipsCarousel extends Overlay { + sockets () { + publicEndpoint(this.nsp, 'clips', async (data: NonNullable, cb) => { + const clips = await getTopClips({ + period: 'custom', days: data.customPeriod, first: data.numOfClips, + }); + cb(null, { clips }); + }); + } +} + +export default new ClipsCarousel(); diff --git a/backend/src/overlays/countdown.ts b/backend/src/overlays/countdown.ts new file mode 100644 index 000000000..732ec752f --- /dev/null +++ b/backend/src/overlays/countdown.ts @@ -0,0 +1,129 @@ +import { Overlay as OverlayEntity } from '@entity/overlay.js'; +import { MINUTE, SECOND } from '@sogebot/ui-helpers/constants.js'; + +import Overlay from './_interface.js'; + +import { AppDataSource } from '~/database.js'; +import { app } from '~/helpers/panel.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; +import { adminMiddleware } from '~/socket.js'; + +const checks = new Map(); +const statusUpdate = new Map(); + +setInterval(() => { + // remove all checks and statusUpdate if last data were 10 minutes long + for (const key of checks.keys()) { + if (Date.now() - (checks.get(key)?.timestamp ?? 0) > 10 * MINUTE) { + checks.delete(key); + } + } + for (const key of statusUpdate.keys()) { + if (Date.now() - (statusUpdate.get(key)?.timestamp ?? 0) > 10 * MINUTE) { + statusUpdate.delete(key); + } + } +}, 30 * SECOND); + +class Countdown extends Overlay { + sockets () { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.post('/api/overlays/countdown/:id/:operation', adminMiddleware, async (req, res) => { + const check = checks.get(req.params.id); + const operationEnableList = { + stop: false, + start: true, + toggle: !check?.isEnabled, + }; + + let time: null | number = null; + + if (req.params.operation === 'set') { + time = !isNaN(Number(req.body.time)) ? Number(req.body.time) : null; + if (!time) { + res.status(400).send(`Invalid time value, expected number, got ${typeof req.body.time}.`); + return; + } + const overlays = await AppDataSource.getRepository(OverlayEntity).find(); + for (const overlay of overlays) { + const item = overlay.items.find(o => o.id === req.params.id); + if (item && item.opts.typeId === 'countdown') { + item.opts.time = time; + item.opts.currentTime = time; + await overlay.save(); + } + } + } + + statusUpdate.set(req.params.id, { + isEnabled: operationEnableList[req.params.operation as keyof typeof operationEnableList] ?? null, + time: req.params.operation === 'set' && time ? time: check?.time ?? 0, + timestamp: Date.now(), + }); + + res.status(204).send(); + }); + + publicEndpoint('/overlays/countdown', 'countdown::update', async (data: { id: string, isEnabled: boolean, time: number }, cb) => { + const update = { + timestamp: Date.now(), + isEnabled: data.isEnabled, + time: data.time, + }; + + const update2 = statusUpdate.get(data.id); + if (update2) { + if (update2.isEnabled !== null) { + update.isEnabled = update2.isEnabled; + } + if (update2.time !== null) { + update.time = update2.time; + } + } + + checks.set(data.id, update); + cb(null, statusUpdate.get(data.id)); + statusUpdate.delete(data.id); + + // we need to check if persistent + const overlays = await AppDataSource.getRepository(OverlayEntity).find(); + for (const overlay of overlays) { + const item = overlay.items.find(o => o.id === data.id); + if (item && item.opts.typeId === 'countdown' && item.opts.isPersistent) { + item.opts.currentTime = data.time; + await overlay.save(); + } + } + }); + adminEndpoint('/overlays/countdown', 'countdown::check', async (countdownId: string, cb) => { + const update = checks.get(countdownId); + if (update) { + const update2 = statusUpdate.get(countdownId); + if (update2) { + if (update2.isEnabled !== null) { + update.isEnabled = update2.isEnabled; + } + if (update2.time !== null) { + update.time = update2.time; + } + } + cb(null, update); + } else { + cb(null, undefined); + } + }); + adminEndpoint('/overlays/countdown', 'countdown::update::set', async (data: { id: string, isEnabled: boolean | null, time: number | null }) => { + statusUpdate.set(data.id, { + isEnabled: data.isEnabled, + time: data.time, + timestamp: Date.now(), + }); + }); + } +} + +export default new Countdown(); diff --git a/backend/src/overlays/credits.ts b/backend/src/overlays/credits.ts new file mode 100644 index 000000000..216040922 --- /dev/null +++ b/backend/src/overlays/credits.ts @@ -0,0 +1,98 @@ +import { EventList, EventListInterface } from '@entity/eventList.js'; +import _ from 'lodash-es'; +import { MoreThanOrEqual } from 'typeorm'; + +import Overlay from './_interface.js'; +import users from '../users.js'; + +import type { Currency } from '~/database/entity/user.js'; +import { AppDataSource } from '~/database.js'; +import { + isStreamOnline, stats, streamStatusChangeSince, +} from '~/helpers/api/index.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import { publicEndpoint } from '~/helpers/socket.js'; +import { getTopClips } from '~/services/twitch/calls/getTopClips.js'; +import { variables } from '~/watchers.js'; + +export type Event = (EventListInterface & { username?: string, values?: { + currency: Currency; + amount: number; + fromId?: string; + fromUsername?: string; + subCumulativeMonths?: number; + subCumulativeMonthsName?: string; + bits?: number; + count?: number; + viewers?: number; + titleOfReward?: string; +} & Event}); + +class Credits extends Overlay { + sockets () { + publicEndpoint(this.nsp, 'getClips', async(opts, cb) => { + if (opts.show) { + const clips = await getTopClips({ + period: opts.period, days: opts.periodValue, first: opts.numOfClips, + }); + cb(await Promise.all( + clips.map(async (o) => { + return { + creatorDisplayName: o.creatorDisplayName, + title: o.title, + duration: o.duration, + game: o.game, + mp4: o.mp4, + }; + }), + )); + } else { + cb([]); + } + }); + publicEndpoint(this.nsp, 'load', async (cb) => { + const when = isStreamOnline.value ? streamStatusChangeSince.value : Date.now() - 50000000000; + const timestamp = new Date(when).getTime(); + const events: Event[] = await AppDataSource.getRepository(EventList).find({ + order: { timestamp: 'DESC' }, + where: { timestamp: MoreThanOrEqual(timestamp), isHidden: false }, + }); + + // we need to map usernames + const userIds = events.map(o => o.userId); + const fromIds = events.filter(o => 'fromId' in (o.values ?? {})).map(o => o.values!.fromId!); + const mapping = await users.getUsernamesFromIds(Array.from(new Set([ + ...userIds, + ...fromIds, + ]))); + for (const event of events) { + event.username = mapping.get(event.userId) ?? 'n/a'; + } + + // change tips if neccessary for aggregated events (need same currency) + for (const event of events) { + event.values = JSON.parse(event.values_json); + if (event.values) { + if (event.values.fromId) { + event.values.fromUsername = mapping.get(event.values.fromId) ?? 'n/a'; + } + if (!_.isNil(event.values.amount) && !_.isNil(event.values.currency)) { + event.values.amount = exchange(event.values.amount, event.values.currency, mainCurrency.value); + event.values.currency = mainCurrency.value; + } + } + } + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + + cb(null, { + streamer: broadcasterUsername, + game: stats.value.currentGame, + title: stats.value.currentTitle, + events, + }); + }); + } +} + +export default new Credits(); diff --git a/backend/src/overlays/eventlist.ts b/backend/src/overlays/eventlist.ts new file mode 100644 index 000000000..bf4f67815 --- /dev/null +++ b/backend/src/overlays/eventlist.ts @@ -0,0 +1,146 @@ +import crypto from 'crypto'; + +import { EventList as EventListEntity } from '@entity/eventList.js'; +import * as _ from 'lodash-es'; +import { In, Not } from 'typeorm'; + +import Overlay from './_interface.js'; +import eventlist from '../widgets/eventlist.js'; + +import { AppDataSource } from '~/database.js'; +import { warning } from '~/helpers/log.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; +import getNameById from '~/helpers/user/getNameById.js'; +import { isBotId } from '~/helpers/user/isBot.js'; +import twitch from '~/services/twitch.js'; + +class EventList extends Overlay { + showInUI = false; + + sockets () { + adminEndpoint('/overlays/eventlist', 'eventlist::getUserEvents', async (userId, cb) => { + const eventsByUserId = await AppDataSource.getRepository(EventListEntity).findBy({ userId: userId }); + // we also need subgifts by giver + const eventsByRecipientId + = (await AppDataSource.getRepository(EventListEntity).findBy({ event: 'subgift' })) + .filter(o => JSON.parse(o.values_json).fromId === userId); + const events = _.orderBy([ ...eventsByRecipientId, ...eventsByUserId ], 'timestamp', 'desc'); + // we need to change userId => username and fromId => fromId username for eventlist compatibility + const mapping = new Map() as Map; + for (const event of events) { + const values = JSON.parse(event.values_json); + if (values.fromId && values.fromId != '0') { + if (!mapping.has(values.fromId)) { + mapping.set(values.fromId, await getNameById(values.fromId)); + } + } + if (!mapping.has(event.userId)) { + mapping.set(event.userId, await getNameById(event.userId)); + } + } + cb(null, events.map(event => { + const values = JSON.parse(event.values_json); + if (values.fromId && values.fromId != '0') { + values.fromId = mapping.get(values.fromId); + } + return { + ...event, + username: mapping.get(event.userId), + values_json: JSON.stringify(values), + }; + })); + }); + publicEndpoint('/overlays/eventlist', 'getEvents', async (opts: { ignore: string[]; limit: number }, cb) => { + let events = await AppDataSource.getRepository(EventListEntity) + .find({ + where: { + isHidden: false, + event: Not(In(opts.ignore.map(value => value.trim()))), + }, + order: { + timestamp: 'DESC', + }, + take: opts.limit, + }); + if (events) { + events = _.uniqBy(events, o => + (o.userId + (['cheer', 'rewardredeem'].includes(o.event) ? crypto.randomBytes(64).toString('hex') : o.event)), + ); + } + + // we need to change userId => username and from => from username for eventlist compatibility + const mapping = new Map() as Map; + for (const event of events) { + try { + const values = JSON.parse(event.values_json); + if (values.from && values.from != '0') { + if (!mapping.has(values.from)) { + mapping.set(values.from, await getNameById(values.from)); + } + } + if (!mapping.has(event.userId)) { + mapping.set(event.userId, await getNameById(event.userId)); + } + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('Cannot get username')) { + event.isHidden = true; // hide event if cannot get username + await AppDataSource.getRepository(EventListEntity).save(event); + continue; + } + } + console.error(e); + } + } + + cb(null, events.map(event => { + const values = JSON.parse(event.values_json); + if (values.from && values.from != '0') { + values.from = mapping.get(values.from); + } + return { + ...event, + username: mapping.get(event.userId), + values_json: JSON.stringify(values), + }; + })); + }); + } + + async add (data: EventList.Event) { + if (!data.userId.includes('__anonymous__') && isBotId(data.userId)) { + warning(`Event ${data.event} won't be saved in eventlist, coming from bot account.`); + return; + } // don't save event from a bot + + if (!data.userId.includes('__anonymous__')) { + getNameById(data.userId).then((username) => { + let description = username; + if (data.event === 'tip') { + description = `${data.amount} ${data.currency}`; + } + twitch.addEventToMarker(data.event, description); + }); + } + + await AppDataSource.getRepository(EventListEntity).save({ + event: data.event, + userId: data.userId, + timestamp: Date.now(), + isTest: data.isTest ?? false, + values_json: JSON.stringify( + Object.keys(data) + .filter(key => !['event', 'userId', 'timestamp', 'isTest'].includes(key)) + .reduce((obj, key) => { + return { + ...obj, + [key]: (data as any)[key], + }; + }, {}), + ), + }); + eventlist.askForGet(); + } +} + +export default new EventList(); \ No newline at end of file diff --git a/backend/src/overlays/gallery.ts b/backend/src/overlays/gallery.ts new file mode 100644 index 000000000..232c26385 --- /dev/null +++ b/backend/src/overlays/gallery.ts @@ -0,0 +1,142 @@ +import { fileURLToPath } from 'node:url'; +import path, { dirname } from 'path'; + +import { Gallery as GalleryEntity } from '@entity/gallery.js'; + +import Overlay from './_interface.js'; + +import { AppDataSource } from '~/database.js'; +import { debug } from '~/helpers/log.js'; +import { app } from '~/helpers/panel.js'; +import { adminEndpoint } from '~/helpers/socket.js'; + +// __dirname is not available in ES6 module +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +class Gallery extends Overlay { + showInUI = false; + + constructor () { + super(); + this.addMenu({ + category: 'registry', name: 'gallery', id: 'registry/gallery', this: null, + }); + + const init = (retry = 0) => { + if (retry === 10000) { + throw new Error('Gallery endpoint failed.'); + } else if (!app) { + setTimeout(() => init(retry++), 100); + } else { + debug('ui', 'Gallery endpoint OK.'); + app.get('/gallery/:id', async (req, res) => { + if (req.params.id === '_default_image') { + res.sendFile(path.join(__dirname, '..', '..', 'assets', 'alerts', 'default.gif')); + return; + } + if (req.params.id === '_default_audio') { + res.sendFile(path.join(__dirname, '..', '..', 'assets', 'alerts', 'default.mp3')); + return; + } + const request = await AppDataSource.getRepository(GalleryEntity).createQueryBuilder('gallery').select('sum(length(gallery.data))', 'size').where('id=:id', { id: req.params.id }).getRawOne(); + if (!request.size) { + res.sendStatus(404); + return; + } + if (req.headers['if-none-match'] === req.params.id + '-' + request.size) { + res.sendStatus(304); + return; + } + + const file = await AppDataSource.getRepository(GalleryEntity).findOneBy({ id: req.params.id }); + if (file) { + const data = Buffer.from(file.data.split(',')[1], 'base64'); + res.writeHead(200, { + 'Content-Type': file.type, + 'Content-Length': data.length, + 'Cache-Control': 'public, max-age=31536000', + 'Accept-Ranges': 'bytes', + 'ETag': req.params.id + '-' + request.size, + }); + res.end(data); + } + }); + } + }; + init(); + } + + sockets () { + adminEndpoint('/overlays/gallery', 'generic::getOne', async (id, cb) => { + try { + const item = await AppDataSource.getRepository(GalleryEntity).findOne({ + where: { id }, + select: ['id', 'name', 'type', 'folder'], + }); + cb(null, item); + } catch (e: any) { + cb(e.stack, null); + } + }); + adminEndpoint('/overlays/gallery', 'generic::getAll', async (cb) => { + try { + const items = await AppDataSource.getRepository(GalleryEntity).find({ select: ['id', 'name', 'type', 'folder'] }); + cb(null, items); + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/overlays/gallery', 'generic::deleteById', async (id, cb) => { + try { + await AppDataSource.getRepository(GalleryEntity).delete({ id: String(id) }); + cb(null); + } catch (e: any) { + cb(e.stack); + } + }); + adminEndpoint('/overlays/gallery', 'generic::setById', async (opts, cb) => { + try { + cb(null, await AppDataSource.getRepository(GalleryEntity).save({ + ...(await AppDataSource.getRepository(GalleryEntity).findOneBy({ id: String(opts.id) })), + ...opts.item, + })); + cb(null); + } catch (e: any) { + cb(e.stack); + } + }); + adminEndpoint('/overlays/gallery', 'gallery::upload', async (data, cb) => { + try { + const filename = data[0]; + const filedata = data[1] as { id: string, b64data: string, folder: string }; + const matches = filedata.b64data.match(/^data:([0-9A-Za-z-+/]+);base64,(.+)$/); + if (!matches) { + // update entity + const item = await AppDataSource.getRepository(GalleryEntity).findOneByOrFail({ id: filedata.id }); + await AppDataSource.getRepository(GalleryEntity).save({ + id: item.id, + type: item.type, + data: item.data + filedata.b64data, + folder: filedata.folder, + name: item.name, + }); + } else { + // new entity + const type = matches[1]; + await AppDataSource.getRepository(GalleryEntity).save({ + id: filedata.id, type, data: filedata.b64data, name: filename, folder: filedata.folder, + }); + } + if (cb) { + cb(null); + } + } catch (e: any) { + if (cb) { + cb(e.stack); + } + } + }); + } +} + +export default new Gallery(); diff --git a/backend/src/overlays/goals.ts b/backend/src/overlays/goals.ts new file mode 100644 index 000000000..f5cf8a4f9 --- /dev/null +++ b/backend/src/overlays/goals.ts @@ -0,0 +1,112 @@ +import { Goal, Overlay as OverlayEntity } from '@entity/overlay.js'; + +import { + onBit, onFollow, onSub, onTip, +} from '../decorators/on.js'; +import Overlay from '../overlays/_interface.js'; + +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import { recountIntervals } from '~/helpers/goals/recountIntervals.js'; + +class Goals extends Overlay { + @onBit() + public async onBit(bit: onEventBit) { + const overlays = await OverlayEntity.find(); + for (const overlay of overlays) { + let isChanged = false; + { + const goals = overlay.items.filter(o => o.opts.typeId === 'goal'); + for (const goal of goals) { + goal.opts = goal.opts as Goal; + for (const campaign of goal.opts.campaigns.filter(o => o.type === 'bits')) { + if (new Date(campaign.endAfter).getTime() >= new Date().getTime() || campaign.endAfterIgnore) { + campaign.currentAmount = (campaign.currentAmount ?? 0) + bit.amount; + isChanged = true; + } + } + } + } + + { + // tips with tracking bits + const goals = overlay.items.filter(o => o.opts.typeId === 'goal'); + for (const goal of goals) { + goal.opts = goal.opts as Goal; + for (const campaign of goal.opts.campaigns.filter(o => o.type === 'tips' && o.countBitsAsTips)) { + if (new Date(campaign.endAfter).getTime() >= new Date().getTime() || campaign.endAfterIgnore) { + const amount = Number(exchange(bit.amount / 100, 'USD', mainCurrency.value)); + campaign.currentAmount = (campaign.currentAmount ?? 0) + amount; + isChanged = true; + } + } + } + } + isChanged && await overlay.save(); + } + recountIntervals(); + } + + @onTip() + public async onTip(tip: onEventTip) { + const overlays = await OverlayEntity.find(); + for (const overlay of overlays) { + let isChanged = false; + const goals = overlay.items.filter(o => o.opts.typeId === 'goal'); + for (const goal of goals) { + goal.opts = goal.opts as Goal; + for (const campaign of goal.opts.campaigns.filter(o => o.type === 'tips')) { + if (new Date(campaign.endAfter).getTime() >= new Date().getTime() || campaign.endAfterIgnore) { + const amount = Number(exchange(tip.amount, tip.currency, mainCurrency.value)); + campaign.currentAmount = (campaign.currentAmount ?? 0) + amount; + isChanged = true; + } + } + } + isChanged && await overlay.save(); + } + recountIntervals(); + } + + @onFollow() + public async onFollow() { + const overlays = await OverlayEntity.find(); + for (const overlay of overlays) { + let isChanged = false; + const goals = overlay.items.filter(o => o.opts.typeId === 'goal'); + for (const goal of goals) { + goal.opts = goal.opts as Goal; + for (const campaign of goal.opts.campaigns.filter(o => o.type === 'followers')) { + if (new Date(campaign.endAfter).getTime() >= new Date().getTime() || campaign.endAfterIgnore) { + campaign.currentAmount = (campaign.currentAmount ?? 0) + 1; + isChanged = true; + } + } + isChanged && await overlay.save(); + } + } + recountIntervals(); + } + + @onSub() + public async onSub() { + const overlays = await OverlayEntity.find(); + for (const overlay of overlays) { + let isChanged = false; + const goals = overlay.items.filter(o => o.opts.typeId === 'goal'); + for (const goal of goals) { + goal.opts = goal.opts as Goal; + for (const campaign of goal.opts.campaigns.filter(o => o.type === 'subscribers')) { + if (new Date(campaign.endAfter).getTime() >= new Date().getTime() || campaign.endAfterIgnore) { + campaign.currentAmount = (campaign.currentAmount ?? 0) + 1; + isChanged = true; + } + } + isChanged && await overlay.save(); + } + } + recountIntervals(); + } +} + +export default new Goals(); diff --git a/backend/src/overlays/marathon.ts b/backend/src/overlays/marathon.ts new file mode 100644 index 000000000..084f7121a --- /dev/null +++ b/backend/src/overlays/marathon.ts @@ -0,0 +1,193 @@ +import { Marathon as MarathonItem, Overlay as OverlayEntity } from '@entity/overlay.js'; + +import Overlay from './_interface.js'; +import { onStartup } from '../decorators/on.js'; + +import { eventEmitter } from '~/helpers/events/emitter.js'; +import { error } from '~/helpers/log.js'; +import { addUIError } from '~/helpers/panel/alerts.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; + +const cachedOverlays = new Map(); + +class Marathon extends Overlay { + @onStartup() + events() { + eventEmitter.on('subscription', async (data) => { + await this.updateCache(); + for (const [id, value] of cachedOverlays.entries()) { + if (value.endTime < Date.now() + && !value.disableWhenReachedZero + && (!value.maxEndTime || Date.now() < value.maxEndTime)) { + value.endTime = Date.now(); // reset endTime + } else if (value.endTime < Date.now()) { + return; + } + + let tier = Number(data.tier); + if (isNaN(tier)) { + tier = 1; + } + const timeToAdd = value.values.sub[`tier${tier}` as keyof MarathonItem['values']['sub']] * 1000; + if (value.maxEndTime !== null) { + value.endTime = Math.min(value.endTime + timeToAdd, value.maxEndTime); + } else { + value.endTime += timeToAdd; + } + cachedOverlays.set(id, value); + } + await this.flushCache(); + }); + eventEmitter.on('resub', async (data) => { + await this.updateCache(); + for (const [id, value] of cachedOverlays.entries()) { + if (value.endTime < Date.now() + && !value.disableWhenReachedZero + && (!value.maxEndTime || Date.now() < value.maxEndTime)) { + value.endTime = Date.now(); // reset endTime + } else if (value.endTime < Date.now()) { + return; + } + + let tier = Number(data.tier); + if (isNaN(tier)) { + tier = 1; + } + const timeToAdd = value.values.resub[`tier${tier}` as keyof MarathonItem['values']['resub']] * 1000; + + if (value.maxEndTime !== null) { + value.endTime = Math.min(value.endTime + timeToAdd, value.maxEndTime); + } else { + value.endTime += timeToAdd; + } + cachedOverlays.set(id, value); + } + await this.flushCache(); + }); + eventEmitter.on('cheer', async (data) => { + await this.updateCache(); + for (const [id, value] of cachedOverlays.entries()) { + if (value.endTime < Date.now() + && !value.disableWhenReachedZero + && (!value.maxEndTime || Date.now() < value.maxEndTime)) { + value.endTime = Date.now(); // reset endTime + } else if (value.endTime < Date.now()) { + return; + } + + // how much time to add + let multiplier = data.bits / value.values.bits.bits; + if (!value.values.bits.addFraction) { + multiplier = Math.floor(multiplier); + } + const timeToAdd = value.values.bits.time * multiplier * 1000; + + if (value.maxEndTime !== null) { + value.endTime = Math.min(value.endTime + timeToAdd, value.maxEndTime); + } else { + value.endTime += timeToAdd; + } + cachedOverlays.set(id, value); + } + await this.flushCache(); + }); + eventEmitter.on('tip', async (data) => { + await this.updateCache(); + for (const [id, value] of cachedOverlays.entries()) { + if (value.endTime < Date.now() + && !value.disableWhenReachedZero + && (!value.maxEndTime || Date.now() < value.maxEndTime)) { + value.endTime = Date.now(); // reset endTime + } else if (value.endTime < Date.now()) { + return; + } + + // how much time to add + let multiplier = Number(data.amountInBotCurrency) / value.values.tips.tips; + if (!value.values.tips.addFraction) { + multiplier = Math.floor(multiplier); + } + const timeToAdd = value.values.tips.time * multiplier * 1000; + + if (value.maxEndTime !== null) { + value.endTime = Math.min(value.endTime + timeToAdd, value.maxEndTime); + } else { + value.endTime += timeToAdd; + } + cachedOverlays.set(id, value); + } + await this.flushCache(); + }); + } + + async updateCache() { + const ids = []; + const overlays = await OverlayEntity.find(); + for (const overlay of overlays) { + const groupId = overlay.id; + const items = overlay.items.filter(o => o.opts.typeId === 'marathon'); + for (const item of items) { + if (!cachedOverlays.has(`${groupId}|${item.id}`)) { + cachedOverlays.set(`${groupId}|${item.id}`, item.opts as MarathonItem); + } + ids.push(`${groupId}|${item.id}`); + } + } + + // cleanup ids which are not longer valid + for (const id of cachedOverlays.keys()) { + if (!ids.includes(id)) { + cachedOverlays.delete(id); + } + } + } + + async flushCache() { + for (const [key, value] of cachedOverlays.entries()) { + const [groupId, itemId] = key.split('|'); + const overlay = await OverlayEntity.findOneBy({ id: groupId }); + if (overlay) { + const item = overlay.items.find(o => o.id === itemId); + if (item) { + item.opts = value; + } + await overlay.save(); + } + } + } + + sockets () { + publicEndpoint('/overlays/marathon', 'marathon::public', async (marathonId: string, cb) => { + // no updateCache + const key = Array.from(cachedOverlays.keys()).find(id => id.includes(marathonId)); + cb(null, cachedOverlays.get(key ?? '')); + }); + adminEndpoint('/overlays/marathon', 'marathon::check', async (marathonId: string, cb) => { + await this.updateCache(); + const key = Array.from(cachedOverlays.keys()).find(id => id.includes(marathonId)); + cb(null, cachedOverlays.get(key ?? '')); + }); + adminEndpoint('/overlays/marathon', 'marathon::update::set', async (data: { time: number, id: string }) => { + await this.updateCache(); + const key = Array.from(cachedOverlays.keys()).find(id => id.includes(data.id)); + const item = cachedOverlays.get(key ?? ''); + if (item) { + if (isNaN(item.endTime) || item.endTime < Date.now()) { + item.endTime = Date.now(); + } + item.endTime += data.time; + if (item.maxEndTime !== null && item.endTime) { + error('MARATHON: cannot set end time bigger than maximum end time'); + addUIError({ name: 'MARATHON', message: 'Cannot set end time bigger than maximum end time.' }); + item.endTime = item.maxEndTime; + } + cachedOverlays.set(key ?? '', item); + } + await this.flushCache(); + }); + } +} + +const marathon = new Marathon(); +export { cachedOverlays, marathon }; +export default marathon; diff --git a/backend/src/overlays/polls.ts b/backend/src/overlays/polls.ts new file mode 100644 index 000000000..93c2573b9 --- /dev/null +++ b/backend/src/overlays/polls.ts @@ -0,0 +1,29 @@ +import Overlay from './_interface.js'; + +import * as channelPoll from '~/helpers/api/channelPoll.js'; +import { publicEndpoint } from '~/helpers/socket.js'; + +class Polls extends Overlay { + public sockets() { + publicEndpoint(this.nsp, 'data', async (callback) => { + const event = channelPoll.event; + if (event) { + callback({ + id: event.id, + title: event.title, + choices: event.choices.map(choice => ({ + id: choice.id, + title: choice.title, + totalVotes: 'votes' in choice ? choice.votes : 0, + })), + startDate: event.started_at, + endDate: 'ended_at' in event ? event.ended_at : null, + }); + } else { + callback(null); + } + }); + } +} + +export default new Polls(); diff --git a/backend/src/overlays/stats.ts b/backend/src/overlays/stats.ts new file mode 100644 index 000000000..868fd818e --- /dev/null +++ b/backend/src/overlays/stats.ts @@ -0,0 +1,26 @@ +import { getTime } from '@sogebot/ui-helpers/getTime.js'; + +import Overlay from './_interface.js'; + +import { + isStreamOnline, stats, streamStatusChangeSince, +} from '~/helpers/api/index.js'; +import { publicEndpoint } from '~/helpers/socket.js'; + +class Stats extends Overlay { + showInUI = false; + + sockets () { + publicEndpoint(this.nsp, 'get', async (cb) => { + cb({ + uptime: getTime(isStreamOnline.value ? streamStatusChangeSince.value : 0, false), + viewers: stats.value.currentViewers, + followers: stats.value.currentFollowers, + subscribers: stats.value.currentSubscribers, + bits: stats.value.currentBits, + }); + }); + } +} + +export default new Stats(); diff --git a/backend/src/overlays/stopwatch.ts b/backend/src/overlays/stopwatch.ts new file mode 100644 index 000000000..393e357e5 --- /dev/null +++ b/backend/src/overlays/stopwatch.ts @@ -0,0 +1,113 @@ +import { Overlay as OverlayEntity } from '@entity/overlay.js'; +import { MINUTE, SECOND } from '@sogebot/ui-helpers/constants.js'; + +import Overlay from './_interface.js'; + +import { AppDataSource } from '~/database.js'; +import { app } from '~/helpers/panel.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; +import { adminMiddleware } from '~/socket.js'; + +const checks = new Map(); +const statusUpdate = new Map(); + +setInterval(() => { + // remove all checks and statusUpdate if last data were 10 minutes long + for (const key of checks.keys()) { + if (Date.now() - (checks.get(key)?.timestamp ?? 0) > 10 * MINUTE) { + checks.delete(key); + } + } + for (const key of statusUpdate.keys()) { + if (Date.now() - (statusUpdate.get(key)?.timestamp ?? 0) > 10 * MINUTE) { + statusUpdate.delete(key); + } + } +}, 30 * SECOND); + +class Stopwatch extends Overlay { + sockets () { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.post('/api/overlays/stopwatch/:id/:operation', adminMiddleware, async (req, res) => { + const check = checks.get(req.params.id); + const operationEnableList = { + stop: false, + start: true, + toggle: !check?.isEnabled, + resetAndStop: false, + }; + + statusUpdate.set(req.params.id, { + isEnabled: operationEnableList[req.params.operation as keyof typeof operationEnableList] ?? null, + time: req.params.operation.includes('reset') ? 0 : check?.time ?? 0, + timestamp: Date.now(), + }); + + res.status(204).send(); + }); + + publicEndpoint('/overlays/stopwatch', 'stopwatch::update', async (data: { groupId: string, id: string, isEnabled: boolean, time: number }, cb) => { + const update = { + timestamp: Date.now(), + isEnabled: data.isEnabled, + time: data.time, + }; + + const update2 = statusUpdate.get(data.id); + if (update2) { + if (update2.isEnabled !== null) { + update.isEnabled = update2.isEnabled; + } + if (update2.time !== null) { + update.time = update2.time; + } + } + + checks.set(data.id, update); + cb(null, statusUpdate.get(data.id)); + statusUpdate.delete(data.id); + + // we need to check if persistent + const overlay = await AppDataSource.getRepository(OverlayEntity).findOneBy({ id: data.groupId }); + if (overlay) { + const item = overlay.items.find(o => o.id === data.id); + if (item && item.opts.typeId === 'stopwatch') { + if (item.opts && item.opts.isPersistent) { + item.opts.currentTime = data.time; + await overlay.save(); + } + } + } + }); + adminEndpoint('/overlays/stopwatch', 'stopwatch::check', async (stopwatchId: string, cb) => { + const update = checks.get(stopwatchId); + if (update) { + const update2 = statusUpdate.get(stopwatchId); + if (update2) { + if (update2.isEnabled !== null) { + update.isEnabled = update2.isEnabled; + } + if (update2.time !== null) { + update.time = update2.time; + } + } + cb(null, update); + } else { + cb(null, undefined); + } + }); + adminEndpoint('/overlays/stopwatch', 'stopwatch::update::set', async (data: { id: string, isEnabled: boolean | null, time: number | null }) => { + statusUpdate.set(data.id, { + isEnabled: data.isEnabled, + time: data.time, + timestamp: Date.now(), + }); + }); + } +} + +export default new Stopwatch(); diff --git a/backend/src/overlays/texttospeech.ts b/backend/src/overlays/texttospeech.ts new file mode 100644 index 000000000..ba17151eb --- /dev/null +++ b/backend/src/overlays/texttospeech.ts @@ -0,0 +1,50 @@ +import { v4 } from 'uuid'; + +import Overlay from './_interface.js'; +import { + command, default_permission, +} from '../decorators.js'; +import { warning } from '../helpers/log.js'; + +import { onStartup } from '~/decorators/on.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; + +class TextToSpeech extends Overlay { + @onStartup() + onStartup() { + eventEmitter.on('highlight', (opts) => { + this.textToSpeech({ + parameters: opts.message, + isHighlight: true, + } as any); + }); + } + + @command('!tts') + @default_permission(defaultPermissions.CASTERS) + async textToSpeech(opts: CommandOptions): Promise { + const { default: tts, services } = await import ('../tts.js'); + if (tts.ready) { + let key = v4(); + if (tts.service === services.RESPONSIVEVOICE) { + key = tts.responsiveVoiceKey; + } + if (tts.service === services.GOOGLE) { + tts.addSecureKey(key); + } + + this.emit('speak', { + text: opts.parameters, + highlight: opts.isHighlight, + service: tts.service, + key, + }); + } else { + warning('!tts command cannot be executed. TTS is not properly set in a bot.'); + } + return []; + } +} + +export default new TextToSpeech(); diff --git a/backend/src/overlays/wordcloud.ts b/backend/src/overlays/wordcloud.ts new file mode 100644 index 000000000..d2dccee4f --- /dev/null +++ b/backend/src/overlays/wordcloud.ts @@ -0,0 +1,13 @@ +import Overlay from './_interface.js'; +import { parser } from '../decorators.js'; + +const re = new RegExp('\\p{L}*', 'gmu'); +class WordCloud extends Overlay { + @parser({ fireAndForget: true }) + sendWords(opts: ParserOptions) { + this.socket?.emit('wordcloud:word', opts.message.match(re)?.filter(o => o.length > 0).map(o => o.toLowerCase())); + } +} + +const wordCloud = new WordCloud(); +export default wordCloud; diff --git a/backend/src/panel.ts b/backend/src/panel.ts new file mode 100644 index 000000000..d9ae60de0 --- /dev/null +++ b/backend/src/panel.ts @@ -0,0 +1,546 @@ +import fs, { existsSync, readFileSync } from 'fs'; +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import cors from 'cors'; +import express from 'express'; +import RateLimit from 'express-rate-limit'; +import gitCommitInfo from 'git-commit-info'; +import _ from 'lodash-es'; +import sanitize from 'sanitize-filename'; + +import { getDEBUG, setDEBUG } from './helpers/debug.js'; +import { broadcasterMissingScopes } from './services/twitch/eventSubWebsocket.js'; +import { possibleLists } from '../d.ts/src/helpers/socket.js'; + +import Core from '~/_interface.js'; +import { CacheGames, CacheGamesInterface } from '~/database/entity/cacheGames.js'; +import { CacheTitles } from '~/database/entity/cacheTitles.js'; +import { Translation } from '~/database/entity/translation.js'; +import { User } from '~/database/entity/user.js'; +import { AppDataSource } from '~/database.js'; +import { onStartup } from '~/decorators/on.js'; +import { getOwnerAsSender } from '~/helpers/commons/getOwnerAsSender.js'; +import { + getURL, getValueOf, isVariableSet, postURL, +} from '~/helpers/customvariables/index.js'; +import { getIsBotStarted } from '~/helpers/database.js'; +import { flatten } from '~/helpers/flatten.js'; +import { setValue } from '~/helpers/general/index.js'; +import { getLang } from '~/helpers/locales.js'; +import { + info, +} from '~/helpers/log.js'; +import { errors, warns } from '~/helpers/panel/alerts.js'; +import { socketsConnectedDec, socketsConnectedInc } from '~/helpers/panel/index.js'; +import { + app, ioServer, server, serverSecure, setApp, setServer, +} from '~/helpers/panel.js'; +import { status as statusObj } from '~/helpers/parser.js'; +import { list } from '~/helpers/register.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import { tmiEmitter } from '~/helpers/tmi/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { Parser } from '~/parser.js'; +import { getGameThumbnailFromName } from '~/services/twitch/calls/getGameThumbnailFromName.js'; +import { sendGameFromTwitch } from '~/services/twitch/calls/sendGameFromTwitch.js'; +import { updateChannelInfo } from '~/services/twitch/calls/updateChannelInfo.js'; +import { processAuth, default as socketSystem } from '~/socket.js'; +import highlights from '~/systems/highlights.js'; +import translateLib, { translate } from '~/translate.js'; +import { variables } from '~/watchers.js'; + +// __dirname is not available in ES6 module +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const port = Number(process.env.PORT ?? 20000); +const secureport = Number(process.env.SECUREPORT ?? 20443); + +const limiter = RateLimit({ + windowMs: 60 * 1000, + max: 1000, + skip: (req) => { + return req.url.includes('/socket/refresh'); + }, + message: 'Too many requests from this IP, please try again after several minutes.', + keyGenerator: (req) => { + return req.ip + req.url; + }, +}); + +class Panel extends Core { + @onStartup() + onStartup() { + this.init(); + this.expose(); + } + + expose () { + server.listen(port, '::'); + server.listen(port, '0.0.0.0', () => { + info(`WebPanel is available at http://localhost:${port}`); + info(`New dashboard is available at https://dash.sogebot.xyz/?server=http://localhost:${port}`); + }); + serverSecure?.listen(secureport, '0.0.0.0', () => { + info(`WebPanel is available at https://localhost:${secureport}`); + info(`New dashboard is available at https://dash.sogebot.xyz/?server=https://localhost:${port}`); + + }); + } + + init () { + setApp(express()); + app?.use(processAuth); + app?.use(limiter); + app?.use(cors()); + app?.use(express.json({ + limit: '500mb', + verify: (req, _res, buf) =>{ + // Small modification to the JSON bodyParser to expose the raw body in the request object + // The raw body is required at signature verification + (req as any).rawBody = buf; + }, + })); + + app?.use(express.urlencoded({ extended: true, limit: '500mb' })); + app?.use(express.raw()); + + setServer(); + + // highlights system + app?.get('/highlights/:id', (req, res) => { + highlights.url(req, res); + }); + + app?.get('/health', (req, res) => { + if (getIsBotStarted()) { + const version = _.get(process, 'env.npm_package_version', 'x.y.z'); + const commitFile = existsSync('./.commit') ? readFileSync('./.commit').toString() : null; + res.status(200).send(version.replace('SNAPSHOT', commitFile && commitFile.length > 0 ? commitFile : gitCommitInfo().shortHash || 'SNAPSHOT')); + } else { + res.status(503).send('Not OK'); + } + }); + + // customvariables system + app?.get('/customvariables/:id', (req, res) => { + getURL(req, res); + }); + app?.post('/customvariables/:id', (req, res) => { + postURL(req, res); + }); + + // static routing + app?.use('/dist', express.static(path.join(__dirname, '..', 'public', 'dist'))); + + const nuxtCache = new Map(); + app?.get(['/_static/*', '/credentials/_static/*'], (req, res) => { + if (!nuxtCache.get(req.url)) { + // search through node_modules to find correct nuxt file + const paths = [ + path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-oauth', 'dist', '_static'), + path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-public', 'dist', '_static'), + path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-admin', 'dist', '_static'), + path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-overlay', 'dist', '_static'), + ]; + for (const dir of paths) { + const pathToFile = path.join(dir, req.url.replace('_static', '')); + if (fs.existsSync(pathToFile)) { // lgtm [js/path-injection] + nuxtCache.set(req.url, pathToFile); + } + } + } + + const filepath = path.join(nuxtCache.get(req.url) ?? '') as string; + if (fs.existsSync(filepath) && nuxtCache.has(req.url)) { // lgtm [js/path-injection] + res.sendFile(filepath); + } else { + res.sendStatus(404); + } + }); + app?.get(['/public/_next/*'], (req, res) => { + if (!nuxtCache.get(req.url)) { + // search through node_modules to find correct nuxt file + const paths = [ + path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-public', 'out', '_next'), + ]; + for (const dir of paths) { + const url = req.url.replace('public', '').replace('_next', ''); + const pathToFile = path.join(dir, url); + if (fs.existsSync(pathToFile)) { // lgtm [js/path-injection] + nuxtCache.set(req.url, pathToFile); + } + } + } + const filepath = path.join(nuxtCache.get(req.url) ?? ''); + if (fs.existsSync(filepath) && nuxtCache.has(req.url)) { // lgtm [js/path-injection] + res.sendFile(filepath); + } else { + nuxtCache.delete(req.url); + res.sendStatus(404); + } + }); + app?.get(['/_nuxt/*', '/credentials/_nuxt/*', '/overlays/_nuxt/*'], (req, res) => { + if (!nuxtCache.get(req.url)) { + // search through node_modules to find correct nuxt file + const paths = [ + path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-oauth', 'dist', '_nuxt'), + path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-admin', 'dist', '_nuxt'), + path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-overlay', 'dist', '_nuxt'), + ]; + for (const dir of paths) { + const pathToFile = path.join(dir, req.url.replace('_nuxt', '').replace('credentials', '').replace('overlays', '')); + if (fs.existsSync(pathToFile)) { // lgtm [js/path-injection] + nuxtCache.set(req.url, pathToFile); + } + } + } + const filepath = path.join(nuxtCache.get(req.url) ?? ''); + if (fs.existsSync(filepath) && nuxtCache.has(req.url)) { // lgtm [js/path-injection] + res.sendFile(filepath); + } else { + nuxtCache.delete(req.url); + res.sendStatus(404); + } + }); + app?.get('/webhooks/callback', function (req, res) { + res.status(200).send('OK'); + }); + app?.get('/popout/', function (req, res) { + res.sendFile(path.join(__dirname, '..', 'public', 'popout.html')); + }); + app?.get('/assets/:asset/:file?', function (req, res) { + if (req.params.file) { + res.sendFile(path.join(__dirname, '..', 'assets', sanitize(req.params.asset), sanitize(req.params.file))); + } else { + res.sendFile(path.join(__dirname, '..', 'assets', sanitize(req.params.asset))); + } + }); + app?.get('/credentials/oauth/:page?', function (req, res) { + res.sendFile(path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-oauth', 'dist', 'oauth', 'index.html')); + }); + app?.get('/credentials/login', function (req, res) { + res.sendFile(path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-oauth', 'dist', 'login', 'index.html')); + }); + app?.get('/fonts', function (req, res) { + res.sendFile(path.join(__dirname, '..', 'fonts.json')); + }); + app?.get('/favicon.ico', function (req, res) { + res.sendFile(path.join(__dirname, '..', 'favicon.ico')); + }); + app?.get('/:page?', function (req, res) { + const indexPath = path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-admin', 'dist', 'index.html'); + if (fs.existsSync(indexPath)) { + res.sendFile(path.join(__dirname, '..', 'node_modules', '@sogebot', 'ui-admin', 'dist', 'index.html')); + } else { + res.sendFile(path.join(__dirname, '..', 'assets', 'updating.html')); + } + }); + + ioServer?.use(socketSystem.authorize as any); + + ioServer?.on('connect', async (socket) => { + socket.on('disconnect', () => { + socketsConnectedDec(); + }); + socketsConnectedInc(); + + // twitch game and title change + socket.on('getGameFromTwitch', function (game: string, cb) { + sendGameFromTwitch(game).then((data) => cb(data)); + }); + socket.on('getUserTwitchGames', async (cb) => { + let titles = await AppDataSource.getRepository(CacheTitles).find(); + const cachedGames = await AppDataSource.getRepository(CacheGames).find(); + + // we need to cleanup titles if game is not in cache + for (const title of titles) { + if (!cachedGames.map(o => o.name).includes(title.game)) { + await AppDataSource.getRepository(CacheTitles).delete({ game: title.game }); + } + } + + const games: CacheGamesInterface[] = []; + for (const game of cachedGames) { + games.push({ + ...game, + thumbnail: await getGameThumbnailFromName(game.name) || '', + }); + } + titles = await AppDataSource.getRepository(CacheTitles).find(); + cb(titles, games); + }); + socket.on('cleanupGameAndTitle', async () => { + // remove empty titles + await AppDataSource + .createQueryBuilder() + .delete() + .from(CacheTitles, 'titles') + .where('title = :title', { title: '' }) + .execute(); + + // remove duplicates + const allTitles = await AppDataSource.getRepository(CacheTitles).find(); + for (const t of allTitles) { + const titles = allTitles.filter(o => o.game === t.game && o.title === t.title); + if (titles.length > 1) { + // remove title if we have more than one title + await AppDataSource + .createQueryBuilder() + .delete() + .from(CacheTitles, 'titles') + .where('id = :id', { id: t.id }) + .execute(); + } + } + }); + socket.on('updateGameAndTitle', async (data: { game: string, title: string, tags: string[], contentClassificationLabels: string[] }, cb: (status: boolean | null) => void) => { + const status = await updateChannelInfo(data); + + if (!status) { // twitch refused update + cb(true); + } + + data.title = data.title.trim(); + data.game = data.game.trim(); + + const item = await AppDataSource.getRepository(CacheTitles).findOneBy({ + game: data.game, + title: data.title, + }); + + if (!item) { + await AppDataSource + .createQueryBuilder() + .insert() + .into(CacheTitles) + .values([ + { + game: data.game, title: data.title, timestamp: Date.now(), tags: data.tags, content_classification_labels: data.contentClassificationLabels, + }, + ]) + .execute(); + } else { + // update timestamp + await AppDataSource.getRepository(CacheTitles).save({ ...item, timestamp: Date.now(), tags: data.tags, content_classification_labels: data.contentClassificationLabels }); + } + cb(null); + }); + socket.on('joinBot', async () => { + tmiEmitter.emit('join', 'bot'); + }); + socket.on('leaveBot', async () => { + tmiEmitter.emit('part', 'bot'); + // force all users offline + await changelog.flush(); + await AppDataSource.getRepository(User).update({}, { isOnline: false }); + }); + + // custom var + socket.on('custom.variable.value', async (_variable: string, cb: (error: string | null, value: string) => void) => { + let value = translate('webpanel.not-available'); + const isVarSet = await isVariableSet(_variable); + if (isVarSet) { + value = await getValueOf(_variable); + } + cb(null, value); + }); + + socket.on('responses.get', async function (at: string | null, callback: (responses: Record) => void) { + const responses = flatten(!_.isNil(at) ? translateLib.translations[getLang()][at] : translateLib.translations[getLang()]); + _.each(responses, function (value, key) { + const _at = !_.isNil(at) ? at + '.' + key : key; + responses[key] = {}; // remap to obj + responses[key].default = translate(_at, true); + responses[key].current = translate(_at); + }); + callback(responses); + }); + socket.on('responses.set', function (data: { key: string }) { + _.remove(translateLib.custom, function (o: any) { + return o.key === data.key; + }); + translateLib.custom.push(data); + translateLib._save(); + + const lang = {}; + _.merge( + lang, + translate({ root: 'webpanel' }), + translate({ root: 'ui' }), // add ui root -> slowly refactoring to new name + ); + socket.emit('lang', lang); + }); + socket.on('responses.revert', async function (data: { name: string }, callback: (translation: string) => void) { + _.remove(translateLib.custom, function (o: any) { + return o.name === data.name; + }); + await AppDataSource.getRepository(Translation).delete({ name: data.name }); + callback(translate(data.name)); + }); + + adminEndpoint('/', 'debug::get', (cb) => { + cb(null, getDEBUG()); + }); + + adminEndpoint('/', 'debug::set', (data) => { + setDEBUG(data); + }); + + adminEndpoint('/', 'token::broadcaster-missing-scopes', (cb) => { + cb(broadcasterMissingScopes); + }); + + adminEndpoint('/', 'panel::alerts', (cb) => { + const toShow: { errors: typeof errors, warns: typeof warns } = { errors: [], warns: [] }; + do { + const err = errors.shift(); + if (!err) { + break; + } + + if (!toShow.errors.find((o) => { + return o.name === err.name && o.message === err.message; + })) { + toShow.errors.push(err); + } + } while (errors.length > 0); + do { + const warn = warns.shift(); + if (!warn) { + break; + } + + if (!toShow.warns.find((o) => { + return o.name === warn.name && o.message === warn.message; + })) { + toShow.warns.push(warn); + } + } while (warns.length > 0); + cb(null, toShow); + }); + + socket.on('connection_status', (cb: (status: typeof statusObj) => void) => { + cb(statusObj); + }); + socket.on('saveConfiguration', function (data: any) { + _.each(data, async function (index, value) { + if (value.startsWith('_')) { + return true; + } + setValue({ + sender: getOwnerAsSender(), createdAt: 0, command: '', parameters: value + ' ' + index, attr: { quiet: data._quiet }, isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, + }); + }); + }); + + socket.on('populateListOf', async (type: possibleLists, cb: (err: string | null, toEmit: any) => void) => { + const toEmit: any[] = []; + if (type === 'systems') { + for (const system of list('systems')) { + toEmit.push({ + name: system.__moduleName__.toLowerCase(), + enabled: system.enabled, + areDependenciesEnabled: await system.areDependenciesEnabled, + isDisabledByEnv: system.isDisabledByEnv, + type: 'systems', + }); + } + } else if (type === 'services') { + for (const system of list('services')) { + toEmit.push({ + name: system.__moduleName__.toLowerCase(), + type: 'services', + }); + } + } else if (type === 'core') { + for (const system of ['dashboard', 'currency', 'ui', 'general', 'twitch', 'socket', 'eventsub', 'tts', 'emotes']) { + toEmit.push({ name: system.toLowerCase(), type: 'core' }); + } + } else if (type === 'integrations') { + for (const system of list('integrations')) { + if (!system.showInUI) { + continue; + } + toEmit.push({ + name: system.__moduleName__.toLowerCase(), + enabled: system.enabled, + areDependenciesEnabled: await system.areDependenciesEnabled, + isDisabledByEnv: system.isDisabledByEnv, + type: 'integrations', + }); + } + } else if (type === 'overlays') { + for (const system of list('overlays')) { + if (!system.showInUI) { + continue; + } + toEmit.push({ name: system.__moduleName__.toLowerCase(), type: 'overlays' }); + } + } else if (type === 'games') { + for (const system of list('games')) { + if (!system.showInUI) { + continue; + } + toEmit.push({ + name: system.__moduleName__.toLowerCase(), + enabled: system.enabled, + areDependenciesEnabled: await system.areDependenciesEnabled, + isDisabledByEnv: system.isDisabledByEnv, + type: 'games', + }); + } + } + + cb(null, toEmit); + }); + + socket.on('name', function (cb: (botUsername: string) => void) { + cb(variables.get('services.twitch.botUsername') as string); + }); + socket.on('channelName', function (cb: (broadcasterUsername: string) => void) { + cb(variables.get('services.twitch.broadcasterUsername') as string); + }); + socket.on('version', function (cb: (version: string) => void) { + const version = _.get(process, 'env.npm_package_version', 'x.y.z'); + const commitFile = existsSync('./.commit') ? readFileSync('./.commit').toString() : null; + cb(version.replace('SNAPSHOT', commitFile && commitFile.length > 0 ? commitFile : gitCommitInfo().shortHash || 'SNAPSHOT')); + }); + + socket.on('parser.isRegistered', function (data: { emit: string, command: string }) { + socket.emit(data.emit, { isRegistered: new Parser().find(data.command) }); + }); + + socket.on('translations', (cb: (lang: Record) => void) => { + const lang = {}; + _.merge( + lang, + translate({ root: 'webpanel' }), + translate({ root: 'ui' }), // add ui root -> slowly refactoring to new name + { bot: translate({ root: 'core' }) }, + ); + cb(lang); + }); + + // send webpanel translations + const lang = {}; + _.merge( + lang, + translate({ root: 'webpanel' }), + translate({ root: 'ui' }), // add ui root -> slowly refactoring to new name, + { bot: translate({ root: 'core' }) }, + ); + socket.emit('lang', lang); + }); + } +} + +export const getServer = function () { + return server; +}; + +export const getApp = function () { + return app; +}; + +export default new Panel(); \ No newline at end of file diff --git a/backend/src/parser.ts b/backend/src/parser.ts new file mode 100644 index 000000000..fec940e44 --- /dev/null +++ b/backend/src/parser.ts @@ -0,0 +1,354 @@ +import * as constants from '@sogebot/ui-helpers/constants.js'; +import { flatMap, sortBy, isFunction, isNil, orderBy } from 'lodash-es'; +import { v4 as uuid } from 'uuid'; + +import { getUserSender } from './helpers/commons/index.js'; +import { list } from './helpers/register.js'; +import getBotId from './helpers/user/getBotId.js'; +import getBotUserName from './helpers/user/getBotUserName.js'; + +import { PermissionCommands } from '~/database/entity/permissions.js'; +import { timer } from '~/decorators.js'; +import { incrementCountOfCommandUsage } from '~/helpers/commands/count.js'; +import { + debug, error, info, warning, +} from '~/helpers/log.js'; +import { parserEmitter } from '~/helpers/parser/emitter.js'; +import { check } from '~/helpers/permissions/check.js'; +import { getCommandPermission } from '~/helpers/permissions/getCommandPermission.js'; +import { translate } from '~/translate.js'; + +parserEmitter.on('process', async (opts, cb) => { + cb(await (new Parser(opts)).process()); +}); + +parserEmitter.on('fireAndForget', async (opts) => { + setImmediate(() => { + opts.fnc.apply(opts.this, [opts.opts]); + }); +}); + +export class Parser { + id = uuid(); + started_at = Date.now(); + message = ''; + isAction = false; + isHighlight = false; + isFirstTimeMessage = false; + sender: CommandOptions['sender'] | null = null; + discord: CommandOptions['discord'] = undefined; + emotesOffsets = new Map(); + skip = false; + quiet = false; + successfullParserRuns: any[] = []; + + constructor (opts: any = {}) { + this.message = opts.message || ''; + this.id = opts.id || ''; + this.sender = opts.sender || null; + this.discord = opts.discord || undefined; + this.emotesOffsets = opts.emotesOffsets || new Map(); + this.skip = opts.skip || false; + this.isAction = opts.isAction || false; + this.isFirstTimeMessage = opts.isFirstTimeMessage || false; + this.isHighlight = opts.isHighlight || false; + this.quiet = opts.quiet || false; + this.successfullParserRuns = []; + } + + get isCommand() { + return this.message.startsWith('!'); + } + + time () { + return Date.now() - this.started_at; + } + + @timer() + async isModerated () { + debug('parser.process', 'ISMODERATED START of "' + this.message + '"'); + if (this.skip) { + return false; + } + + const parsers = await this.parsers(); + for (const parser of parsers) { + const time = Date.now(); + if (parser.priority !== constants.MODERATION) { + continue; + } // skip non-moderation parsers + debug('parser.process', 'Processing ' + parser.name); + const text = this.message.trim().replace(/^(!\w+)/i, ''); + const opts: ParserOptions = { + isParserOptions: true, + id: this.id, + emotesOffsets: this.emotesOffsets, + isAction: this.isAction, + isHighlight: this.isHighlight, + isFirstTimeMessage: this.isFirstTimeMessage, + sender: this.sender, + discord: this.discord ?? undefined, + message: this.message.trim(), + parameters: text.trim(), + skip: this.skip, + parser: this, + }; + const isOk = await parser.fnc.apply(parser.this, [opts]); + + debug('parser.time', 'Processed ' + parser.name + ' took ' + ((Date.now() - time) / 1000)); + if (!isOk) { + debug('parser.process', 'Moderation failed ' + parser.name); + return true; + } + } + return false; // no parser failed + } + + @timer() + async process (): Promise { + debug('parser.process', 'PROCESS START of "' + this.message + '"'); + + const parsers = await this.parsers(); + + const text = this.message.trim().replace(/^(!\w+)/i, ''); + const opts: ParserOptions = { + isParserOptions: true, + id: this.id, + sender: this.sender, + discord: this.discord ?? undefined, + emotesOffsets: this.emotesOffsets, + isAction: this.isAction, + isHighlight: this.isHighlight, + isFirstTimeMessage: this.isFirstTimeMessage, + message: this.message.trim(), + parameters: text.trim(), + skip: this.skip, + parser: this, + }; + + for (const parser of parsers.filter(o => !o.fireAndForget && o.priority !== constants.MODERATION)) { + if ( + !(this.skip && parser.skippable) // parser is not fully skippable + && (isNil(this.sender) // if user is null -> we are running command through a bot + || this.skip + || (await check(this.sender.userId, parser.permission, false)).access) + ) { + debug('parser.process', 'Processing ' + parser.name); + + const time = Date.now(); + const status = await parser.fnc.apply(parser.this, [opts]); + debug('parser.process', 'Status ' + JSON.stringify({ status })); + if (!status) { + const rollbacks = await this.rollbacks(); + for (const r of rollbacks) { + // rollback is needed (parser ran successfully) + if (this.successfullParserRuns.find((o) => { + const parserSystem = o.name.split('.')[0]; + const rollbackSystem = r.name.split('.')[0]; + return parserSystem === rollbackSystem; + })) { + debug('parser.process', 'Rollbacking ' + r.name); + await r.fnc.apply(r.this, [opts]); + } else { + debug('parser.process', 'Rollback skipped for ' + r.name); + } + } + return []; + } else { + this.successfullParserRuns.push({ name: parser.name, opts }); // need to save opts for permission rollback + } + debug('parser.time', 'Processed ' + parser.name + ' took ' + ((Date.now() - time) / 1000)); + } else { + debug('parser.process', 'Skipped ' + parser.name); + } + } + + setTimeout(() => { + // run fire and forget after regular parsers + for (const parser of parsers.filter(o => o.fireAndForget)) { + if (this.skip && parser.skippable) { + debug('parser.process', 'Skipped ' + parser.name); + } else { + parserEmitter.emit('fireAndForget', { + this: parser.this, + fnc: parser.fnc, + opts, + }); + } + } + }, 0); + + if (this.isCommand) { + const output = this.command(this.sender, this.message.trim()); + return output; + } + return []; + } + + /** + * Return all parsers + * @constructor + * @returns object or empty list + */ + @timer() + async parsers () { + let parsers: any[] = []; + for (let i = 0, length = list().length; i < length; i++) { + if (isFunction(list()[i].parsers)) { + parsers.push(list()[i].parsers()); + } + } + parsers = orderBy(flatMap(await Promise.all(parsers)), 'priority', 'asc'); + return parsers; + } + + /** + * Return all rollbacks + * @constructor + * @returns object or empty list + */ + @timer() + async rollbacks () { + const rollbacks: any[] = []; + for (let i = 0, length = list().length; i < length; i++) { + if (isFunction(list()[i].rollbacks)) { + rollbacks.push(list()[i].rollbacks()); + } + } + return flatMap(await Promise.all(rollbacks)); + } + + /** + * Find first command called by message + * @constructor + * @param {string} message - Message from chat + * @param {string[] | null} cmdlist - Set of commands to check, if null all registered commands are checked + * @returns object or null if empty + */ + @timer() + async find (message: string, cmdlist: { + this: any; fnc: (opts: CommandOptions) => CommandResponse[]; command: string; id: string; permission: string | null; _fncName: string; + }[] | null = null) { + debug('parser.find', JSON.stringify({ message, cmdlist })); + + if (cmdlist === null) { + cmdlist = await this.getCommandsList(); + } + for (const item of cmdlist) { + const onlyParams = message.trim().toLowerCase().replace(item.command, ''); + const isStartingWith = message.trim().toLowerCase().startsWith(item.command); + + debug('parser.find', JSON.stringify({ command: item.command, isStartingWith })); + + if (isStartingWith && (onlyParams.length === 0 || (onlyParams.length > 0 && onlyParams[0] === ' '))) { + const customPermission = await getCommandPermission(item.id); + if (typeof customPermission !== 'undefined') { + item.permission = customPermission; + } + return item; + } + } + return null; + } + + @timer() + async getCommandsList () { + let commands: any[] = []; + for (let i = 0, length = list().length; i < length; i++) { + if (isFunction(list()[i].commands)) { + commands.push(list()[i].commands()); + } + } + commands = sortBy(flatMap(await Promise.all(commands)), (o => -o.command.length)); + for (const command of commands) { + const permission = await PermissionCommands.findOneBy({ name: command.id }); + if (permission) { + command.permission = permission.permission; // change to custom permission + debug('parser.command', `Checking permission for ${command.id} - custom ${permission.name}`); + } else { + debug('parser.command', `Checking permission for ${command.id} - original`); + } + } + return commands; + } + + @timer() + async command (sender: CommandOptions['sender'] | null, message: string, disablePermissionCheck = false): Promise { + debug('parser.command', { sender, message }); + if (!message.startsWith('!')) { + return []; + } // do nothing, this is not a command or user is ignored + const command = await this.find(message, null); + debug('parser.command', { command }); + if (isNil(command)) { + return []; + } // command not found, do nothing + if (command.permission === null) { + warning(`Command ${command.command} is disabled!`); + return []; + } // command is disabled + + if ( + isNil(this.sender) // if user is null -> we are running command through a bot + || disablePermissionCheck + || this.skip + || (await check(this.sender.userId, command.permission, false)).access + ) { + const text = message.trim().replace(new RegExp('^(' + command.command + ')', 'i'), '').trim(); + const opts: CommandOptions = { + sender: sender || getUserSender(getBotId(), getBotUserName()), + discord: this.discord ?? undefined, + emotesOffsets: this.emotesOffsets, + isAction: this.isAction, + isHighlight: this.isHighlight, + isFirstTimeMessage: this.isFirstTimeMessage, + command: command.command, + parameters: text.trim(), + createdAt: this.started_at, + attr: { + skip: this.skip, + quiet: this.quiet, + }, + }; + + if (isNil(command.id)) { + throw Error(`command id is missing from ${command.fnc}`); + } + + if (typeof command.fnc === 'function' && !isNil(command.id)) { + incrementCountOfCommandUsage(command.command); + debug('parser.command', 'Running ' + command.command); + const responses = command.fnc.apply(command.this, [opts]) as CommandResponse[]; + return responses; + } else { + error(command.command + ' have wrong undefined function ' + command._fncName + '() registered!'); + return []; + } + } else { + info(`User ${this.sender.userName}#${this.sender.userId} doesn't have permissions to use ${command.command}`); + // do all rollbacks when permission failed + const rollbacks = await this.rollbacks(); + for (const r of rollbacks) { + const runnedRollback = this.successfullParserRuns.find((o) => { + const parserSystem = o.name.split('.')[0]; + const rollbackSystem = r.name.split('.')[0]; + return parserSystem === rollbackSystem; + }); + if (runnedRollback) { + debug('parser.process', 'Rollbacking ' + r.name); + await r.fnc.apply(r.this, [runnedRollback.opts]); + } else { + debug('parser.process', 'Rollback skipped for ' + r.name); + } + } + + // user doesn't have permissions for command + if (sender) { + return[{ + response: translate('permissions.without-permission').replace(/\$command/g, message), sender, attr: { isWhisper: true }, discord: this.discord, + }]; + } + return []; + } + } +} \ No newline at end of file diff --git a/backend/src/permissions.ts b/backend/src/permissions.ts new file mode 100644 index 000000000..d6efd2690 --- /dev/null +++ b/backend/src/permissions.ts @@ -0,0 +1,269 @@ +import Core from '~/_interface.js'; +import { Permissions as PermissionsEntity } from '~/database/entity/permissions.js'; +import { User } from '~/database/entity/user.js'; +import { AppDataSource } from '~/database.js'; +import { onStartup } from '~/decorators/on.js'; +import { command, default_permission } from '~/decorators.js'; +import { Expects } from '~/expects.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { error } from '~/helpers/log.js'; +import { check } from '~/helpers/permissions/check.js'; +import { defaultPermissions } from '~/helpers/permissions/defaultPermissions.js'; +import { get } from '~/helpers/permissions/get.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import users from '~/users.js'; + +class Permissions extends Core { + @onStartup() + onStartup() { + this.addMenu({ + category: 'settings', name: 'permissions', id: 'settings/permissions', this: null, + }); + this.ensurePreservedPermissionsInDb(); + } + + public sockets() { + adminEndpoint('/core/permissions', 'generic::getAll', async (cb) => { + cb(null, await PermissionsEntity.find({ + order: { order: 'ASC' }, + })); + }); + adminEndpoint('/core/permissions', 'permission::save', async (data, cb) => { + // we need to remove missing permissions + const permissionsFromDB = await PermissionsEntity.find(); + for (const permissionFromDB of permissionsFromDB) { + if (!data.find(o => o.id === permissionFromDB.id)) { + await PermissionsEntity.remove(permissionFromDB); + } + } + // then save new data + await PermissionsEntity.save(data); + if (cb) { + cb(null); + } + }); + adminEndpoint('/core/permissions', 'generic::deleteById', async (id, cb) => { + await PermissionsEntity.delete({ id: String(id) }); + if (cb) { + cb(null); + } + }); + adminEndpoint('/core/permissions', 'test.user', async (opts, cb) => { + if (!(await PermissionsEntity.findOneBy({ id: String(opts.pid) }))) { + cb('permissionNotFoundInDatabase'); + return; + } + if (typeof opts.value === 'string') { + await changelog.flush(); + const userByName = await AppDataSource.getRepository(User).findOneBy({ userName: opts.value }); + if (userByName) { + const status = await check(userByName.userId, opts.pid); + const partial = await check(userByName.userId, opts.pid, true); + cb(null, { + status, + partial, + state: opts.state, + }); + return; + } + } else if(isFinite(opts.value)) { + const userById = await changelog.get(String(opts.value)); + if (userById) { + const status = await check(userById.userId, opts.pid); + const partial = await check(userById.userId, opts.pid, true); + cb(null, { + status, + partial, + state: opts.state, + }); + return; + } + } + cb(null, { + status: { access: 2 }, + partial: { access: 2 }, + state: opts.state, + }); + }); + } + + /** + * !permission exclude-add -p SongRequest -u soge + */ + @command('!permission exclude-add') + @default_permission(defaultPermissions.CASTERS) + async excludeAdd(opts: CommandOptions): Promise { + try { + const [userlevel, username] = new Expects(opts.parameters) + .permission() + .argument({ name: 'u', type: 'username' }) + .toArray(); + + const userId = await users.getIdByName(username); + if (!userId) { + throw new Error(prepare('permissions.userNotFound', { username })); + } + + const pItem = await get(userlevel); + if (!pItem) { + throw Error(prepare('permissions.permissionNotFound', { userlevel })); + } + if (pItem.isCorePermission) { + throw Error(prepare('permissions.cannotIgnoreForCorePermission', { userlevel: pItem.name })); + } + + pItem.excludeUserIds = [ String(userId), ...pItem.excludeUserIds ]; + await pItem.save(); + + return [{ + response: prepare('permissions.excludeAddSuccessful', { + username, + permissionName: pItem.name, + }), + ...opts, + }]; + } catch (e: any) { + error(e.stack); + return [{ response: e.message, ...opts }]; + } + } + + /** + * !permission exclude-rm -p SongRequest -u soge + */ + @command('!permission exclude-rm') + @default_permission(defaultPermissions.CASTERS) + async excludeRm(opts: CommandOptions): Promise { + try { + const [userlevel, username] = new Expects(opts.parameters) + .permission() + .argument({ name: 'u', type: 'username' }) + .toArray(); + + const userId = await users.getIdByName(username); + if (!userId) { + throw new Error(prepare('permissions.userNotFound', { username })); + } + + const pItem = await get(userlevel); + if (!pItem) { + throw Error(prepare('permissions.permissionNotFound', { userlevel })); + } + + pItem.excludeUserIds = [ ...pItem.excludeUserIds.filter(id => id !== String(userId))]; + await pItem.save(); + + return [{ + response: prepare('permissions.excludeRmSuccessful', { + username, + permissionName: pItem.name, + }), + ...opts, + }]; + } catch (e: any) { + return [{ response: e.message, ...opts }]; + } + } + + @command('!permission list') + @default_permission(defaultPermissions.CASTERS) + protected async list(opts: CommandOptions): Promise { + const permissions = await PermissionsEntity.find({ order: { order: 'ASC' } }); + const responses: CommandResponse[] = []; + responses.push({ response: prepare('core.permissions.list'), ...opts }); + for (let i = 0; i < permissions.length; i++) { + const symbol = permissions[i].isWaterfallAllowed ? '≥' : '='; + responses.push({ response: `${symbol} | ${permissions[i].name} | ${permissions[i].id}`, ...opts }); + } + return responses; + } + + public async ensurePreservedPermissionsInDb(): Promise { + let p; + try { + p = await PermissionsEntity.find(); + } catch (e: any) { + setTimeout(() => this.ensurePreservedPermissionsInDb(), 1000); + return; + } + let addedCount = 0; + + if (!p.find((o) => o.isCorePermission && o.automation === 'casters')) { + await PermissionsEntity.insert({ + id: defaultPermissions.CASTERS, + name: 'Casters', + automation: 'casters', + isCorePermission: true, + isWaterfallAllowed: true, + order: p.length + addedCount, + userIds: [], + excludeUserIds: [], + filters: [], + }); + addedCount++; + } + + if (!p.find((o) => o.isCorePermission && o.automation === 'moderators')) { + await PermissionsEntity.insert({ + id: defaultPermissions.MODERATORS, + name: 'Moderators', + automation: 'moderators', + isCorePermission: true, + isWaterfallAllowed: true, + order: p.length + addedCount, + userIds: [], + excludeUserIds: [], + filters: [], + }); + addedCount++; + } + + if (!p.find((o) => o.isCorePermission && o.automation === 'subscribers')) { + await PermissionsEntity.insert({ + id: defaultPermissions.SUBSCRIBERS, + name: 'Subscribers', + automation: 'subscribers', + isCorePermission: true, + isWaterfallAllowed: true, + order: p.length + addedCount, + userIds: [], + excludeUserIds: [], + filters: [], + }); + addedCount++; + } + + if (!p.find((o) => o.isCorePermission && o.automation === 'vip')) { + await PermissionsEntity.insert({ + id: defaultPermissions.VIP, + name: 'VIP', + automation: 'vip', + isCorePermission: true, + isWaterfallAllowed: true, + order: p.length + addedCount, + userIds: [], + excludeUserIds: [], + filters: [], + }); + addedCount++; + } + + if (!p.find((o) => o.isCorePermission && o.automation === 'viewers')) { + await PermissionsEntity.insert({ + id: defaultPermissions.VIEWERS, + name: 'Viewers', + automation: 'viewers', + isCorePermission: true, + isWaterfallAllowed: true, + order: p.length + addedCount, + userIds: [], + excludeUserIds: [], + filters: [], + }); + addedCount++; + } + } +} + +export default new Permissions(); diff --git a/backend/src/plugins.ts b/backend/src/plugins.ts new file mode 100644 index 000000000..e436b54cd --- /dev/null +++ b/backend/src/plugins.ts @@ -0,0 +1,358 @@ +import { SECOND } from '@sogebot/ui-helpers/constants.js'; +import { validateOrReject } from 'class-validator'; +import { merge } from 'lodash-es'; + +import { Plugin, PluginVariable } from './database/entity/plugins.js'; +import { isValidationError } from './helpers/errors.js'; +import { eventEmitter } from './helpers/events/index.js'; +import { debug, error } from './helpers/log.js'; +import { app } from './helpers/panel.js'; +import { setImmediateAwait } from './helpers/setImmediateAwait.js'; +import { adminEndpoint, publicEndpoint } from './helpers/socket.js'; +import { Types } from './plugins/ListenTo.js'; +import { runScriptInSandbox, transpiledFiles } from './plugins/Sandbox.js'; + +import Core from '~/_interface.js'; +import { onStartup } from '~/decorators/on.js'; + +const plugins: Plugin[] = []; + +class Plugins extends Core { + @onStartup() + onStartup() { + this.addMenu({ + category: 'registry', name: 'plugins', id: 'registry/plugins', this: null, + }); + + this.updateCache().then(() => { + this.process(Types.Started); + }); + setInterval(() => { + this.triggerCrons(); + }, SECOND); + + eventEmitter.on(Types.onChannelCharityCampaignStart, async (args) => { + this.process(Types.onChannelCharityCampaignStart, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelCharityCampaignProgress, async (args) => { + this.process(Types.onChannelCharityCampaignProgress, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelCharityCampaignStop, async (args) => { + this.process(Types.onChannelCharityCampaignStop, undefined, undefined, { args }); + }); + + eventEmitter.on(Types.onChannelCharityDonation, async (args) => { + this.process(Types.onChannelCharityDonation, undefined, undefined, { args }); + }); + + eventEmitter.on(Types.onChannelGoalBegin, async (args) => { + this.process(Types.onChannelGoalBegin, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelGoalProgress, async (args) => { + this.process(Types.onChannelGoalProgress, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelGoalEnd, async (args) => { + this.process(Types.onChannelGoalEnd, undefined, undefined, { args }); + }); + + eventEmitter.on(Types.onChannelModeratorAdd, async (args) => { + this.process(Types.onChannelModeratorAdd, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelModeratorRemove, async (args) => { + this.process(Types.onChannelModeratorRemove, undefined, undefined, { args }); + }); + + eventEmitter.on(Types.onChannelRewardAdd, async (args) => { + this.process(Types.onChannelRewardAdd, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelRewardUpdate, async (args) => { + this.process(Types.onChannelRewardUpdate, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelRewardRemove, async (args) => { + this.process(Types.onChannelRewardRemove, undefined, undefined, { args }); + }); + + eventEmitter.on(Types.onChannelShieldModeBegin, async (args) => { + this.process(Types.onChannelShieldModeBegin, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelShieldModeEnd, async (args) => { + this.process(Types.onChannelShieldModeEnd, undefined, undefined, { args }); + }); + + eventEmitter.on(Types.onChannelShoutoutCreate, async (args) => { + this.process(Types.onChannelShoutoutCreate, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelShoutoutReceive, async (args) => { + this.process(Types.onChannelShoutoutReceive, undefined, undefined, { args }); + }); + + eventEmitter.on(Types.onChannelUpdate, async (args) => { + this.process(Types.onChannelUpdate, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onUserUpdate, async (args) => { + this.process(Types.onUserUpdate, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelRaidFrom, async (args) => { + this.process(Types.onChannelRaidFrom, undefined, undefined, { args }); + }); + eventEmitter.on(Types.onChannelRedemptionUpdate, async (args) => { + this.process(Types.onChannelRedemptionUpdate, undefined, undefined, { args }); + }); + + eventEmitter.on(Types.CustomVariableOnChange, async (variableName, cur, prev) => { + this.process(Types.CustomVariableOnChange, undefined, undefined, { variableName, cur, prev }); + }); + + eventEmitter.on('clearchat', async () => { + this.process(Types.TwitchClearChat); + }); + + eventEmitter.on('cheer', async (data) => { + const user = { + userName: data.userName, + userId: data.userId, + }; + this.process(Types.TwitchCheer, data.message, user, { + amount: data.bits, + }); + }); + + eventEmitter.on('tip', async (data) => { + const users = (await import('./users.js')).default; + const user = { + userName: data.userName, + userId: !data.isAnonymous ? await users.getIdByName(data.userName) : '0', + }; + this.process(Types.GenericTip, data.message, user, { + isAnonymous: data.isAnonymous, + amount: data.amount, + botAmount: data.amountInBotCurrency, + currency: data.currency, + botCurrency: data.currencyInBot, + }); + }); + + eventEmitter.on('game-changed', async (data) => { + this.process(Types.TwitchGameChanged, undefined, undefined, { category: data.game, oldCategory: data.oldGame }); + }); + + eventEmitter.on('stream-started', async () => { + this.process(Types.TwitchStreamStarted); + }); + + eventEmitter.on('stream-stopped', async () => { + this.process(Types.TwitchStreamStopped); + }); + + const commonHandler = async (event: Types, data: T) => { + const users = (await import('./users.js')).default; + const { userName, ...parameters } = data; + const user = { + userName, + userId: await users.getIdByName(userName), + }; + + this.process(event, '', user, parameters); + }; + + eventEmitter.on('subscription', async (data) => { + commonHandler(Types.TwitchSubscription, data); + }); + + eventEmitter.on('subgift', async (data) => { + commonHandler(Types.TwitchSubgift, data); + }); + + eventEmitter.on('subcommunitygift', async (data) => { + commonHandler(Types.TwitchSubcommunitygift, data); + }); + + eventEmitter.on('resub', async (data) => { + commonHandler(Types.TwitchResub, data); + }); + + eventEmitter.on('reward-redeemed', async (data) => { + commonHandler(Types.TwitchRewardRedeem, data); + }); + + eventEmitter.on('follow', async (data) => { + this.process(Types.TwitchFollow, '', data); + }); + + eventEmitter.on('raid', async (data) => { + commonHandler(Types.TwitchRaid, data); + }); + } + + async updateCache () { + const _plugins = await Plugin.find(); + while (plugins.length > 0) { + plugins.shift(); + } + for (const plugin of _plugins) { + plugins.push(plugin); + } + } + + async triggerCrons() { + for (const plugin of plugins) { + if (!plugin.enabled) { + continue; + } + try { + const workflow = JSON.parse(plugin.workflow); + if (!Array.isArray(workflow.code)) { + continue; + } + + for (const file of workflow.code) { + if (!file.source.includes('ListenTo.Cron')) { + continue; + } + + this.process(Types.Cron); + break; // we found at least one cron to run + } + } catch { + continue; + } + } + } + + sockets() { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + app.get('/overlays/plugin/:pid/:id', async (req, res) => { + try { + const plugin = plugins.find(o => o.id === req.params.pid); + if (!plugin) { + return res.status(404).send(); + } + + const files = JSON.parse(plugin.workflow); + const overlay = files.overlay.find((o: any) => o.id === req.params.id); + if (!overlay) { + return res.status(404).send(); + } + + const source = overlay.source.replace('', ` + + + + `); + res.send(source); + } catch (e) { + error(e); + return res.status(500).send(); + } + }); + adminEndpoint('/core/plugins', 'generic::getAll', async (cb) => { + cb(null, plugins); + }); + publicEndpoint('/core/plugins', 'generic::getOne', async (id, cb) => { + cb(null, plugins.find(o => o.id === id)); + }); + adminEndpoint('/core/plugins', 'generic::deleteById', async (id, cb) => { + await Plugin.delete({ id }); + await PluginVariable.delete({ pluginId: id }); + await this.updateCache(); + transpiledFiles.clear(); + cb(null); + }); + adminEndpoint('/core/plugins', 'generic::validate', async (data, cb) => { + try { + const item = new Plugin(); + merge(item, data); + await validateOrReject(item); + cb(null); + } catch (e) { + if (e instanceof Error) { + cb(e.message); + } + if (isValidationError(e)) { + cb(e); + } + } + }); + adminEndpoint('/core/plugins', 'generic::save', async (item, cb) => { + try { + const itemToSave = new Plugin(); + merge(itemToSave, item); + await validateOrReject(itemToSave); + await itemToSave.save(); + await this.updateCache(); + transpiledFiles.clear(); + cb(null, itemToSave); + } catch (e) { + if (e instanceof Error) { + cb(e.message, undefined); + } + if (isValidationError(e)) { + cb(e, undefined); + } + } + }); + } + + async process(type: Types, message = '', userstate: { userName: string, userId: string } | null = null, params?: Record) { + debug('plugins', `Processing plugin: ${JSON.stringify({ type, message, userstate, params })}`); + const pluginsEnabled = plugins.filter(o => o.enabled); + for (const plugin of pluginsEnabled) { + await setImmediateAwait(); + // explore drawflow + const __________workflow__________: { + code: { name: string, source: string, id: string}[], + overflow: { name: string, source: string, id: string}[] + } = ( + JSON.parse(plugin.workflow) + ); + if (!Array.isArray(__________workflow__________.code)) { + continue; // skip legacy plugins + } + + for (const ___code___ of __________workflow__________.code) { + await setImmediateAwait(); + try { + runScriptInSandbox(plugin, userstate, message, type, ___code___, params, { + socket: this.socket, + }); + } catch (e) { + error(`PLUGINS#${plugin.id}:./${___code___.name}: ${e}`); + } + } + } + } + + /* TODO: replace with event emitter */ + async trigger(type: 'message', message: string, userstate: { userName: string, userId: string }): Promise { + this.process(message.startsWith('!') ? Types.TwitchCommand : Types.TwitchMessage, message, userstate); + } +} + +export default new Plugins(); diff --git a/backend/src/plugins/CustomVariable.ts b/backend/src/plugins/CustomVariable.ts new file mode 100644 index 000000000..16c5b6950 --- /dev/null +++ b/backend/src/plugins/CustomVariable.ts @@ -0,0 +1,11 @@ +import { getValueOf } from '~/helpers/customvariables/getValueOf.js'; +import { setValueOf } from '~/helpers/customvariables/setValueOf.js'; + +export const CustomVariableGenerator = (pluginId: string) => ({ + async set(variableName: string, value: any) { + await setValueOf(String(variableName), value, {}); + }, + async get(variableName: string) { + return getValueOf(String(variableName)); + }, +}); \ No newline at end of file diff --git a/backend/src/plugins/ListenTo.ts b/backend/src/plugins/ListenTo.ts new file mode 100644 index 000000000..1d6ea634d --- /dev/null +++ b/backend/src/plugins/ListenTo.ts @@ -0,0 +1,261 @@ +import cronparser from 'cron-parser'; +import { escapeRegExp } from 'lodash-es'; + +import { Events } from '~/helpers/events/emitter'; +import { debug } from '~/helpers/log.js'; + +export enum Types { + 'Started', + 'Cron', + 'TwitchCommand', + 'TwitchMessage', + 'TwitchSubscription', + 'TwitchClearChat', + 'TwitchCheer', + 'TwitchGameChanged', + 'TwitchStreamStarted', + 'TwitchStreamStopped', + 'TwitchResub', + 'TwitchFollow', + 'TwitchRaid', + 'TwitchRewardRedeem', + 'TwitchSubgift', + 'TwitchSubcommunitygift', + 'GenericTip', + 'CustomVariableOnChange', + 'onChannelCharityCampaignProgress', + 'onChannelCharityCampaignStart', + 'onChannelCharityCampaignStop', + 'onChannelCharityDonation', + 'onChannelGoalBegin', + 'onChannelGoalEnd', + 'onChannelGoalProgress', + 'onChannelModeratorAdd', + 'onChannelModeratorRemove', + 'onChannelRewardAdd', + 'onChannelRewardRemove', + 'onChannelRewardUpdate', + 'onChannelShieldModeBegin', + 'onChannelShieldModeEnd', + 'onChannelShoutoutCreate', + 'onChannelShoutoutReceive', + 'onChannelUpdate', + 'onUserUpdate', + 'onChannelRaidFrom', + 'onChannelRedemptionUpdate', +} + +export const ListenToGenerator = (pluginId: string, type: Types, message: string, userstate: { userName: string, userId: string } | null, params?: Record) => ({ + Bot: { + started(callback: () => void) { + if (type === Types.Started) { + callback(); + } + }, + }, + Cron(cron: string, callback: () => void) { + if (type === Types.Cron) { + const cronParsed = cronparser.parseExpression(cron); + const cronDate = cronParsed.prev(); + const timestamp = Math.floor(cronDate.getTime() / 1000); + const currentTimestamp = Math.floor(Date.now() / 1000); + if (timestamp === currentTimestamp) { + callback(); + } + } + }, + Twitch: { + onChannelCharityCampaignStart: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelCharityCampaignStart) { + params && callback(params as Parameters[0]); + } + }, + onChannelCharityCampaignProgress: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelCharityCampaignProgress) { + params && callback(params as Parameters[0]); + } + }, + onChannelCharityCampaignStop: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelCharityCampaignStop) { + params && callback(params as Parameters[0]); + } + }, + onChannelCharityDonation: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelCharityDonation) { + params && callback(params as Parameters[0]); + } + }, + onChannelGoalBegin: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelGoalBegin) { + params && callback(params as Parameters[0]); + } + }, + onChannelGoalEnd: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelGoalEnd) { + params && callback(params as Parameters[0]); + } + }, + onChannelGoalProgress: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelGoalProgress) { + params && callback(params as Parameters[0]); + } + }, + onChannelModeratorAdd: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelModeratorAdd) { + params && callback(params as Parameters[0]); + } + }, + onChannelModeratorRemove: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelModeratorRemove) { + params && callback(params as Parameters[0]); + } + }, + onChannelRewardAdd: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelRewardAdd) { + params && callback(params as Parameters[0]); + } + }, + onChannelRewardRemove: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelRewardRemove) { + params && callback(params as Parameters[0]); + } + }, + onChannelRewardUpdate: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelRewardUpdate) { + params && callback(params as Parameters[0]); + } + }, + onChannelShieldModeBegin: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelShieldModeBegin) { + params && callback(params as Parameters[0]); + } + }, + onChannelShieldModeEnd: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelShieldModeEnd) { + params && callback(params as Parameters[0]); + } + }, + onChannelShoutoutCreate: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelShoutoutCreate) { + params && callback(params as Parameters[0]); + } + }, + onChannelShoutoutReceive: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelShoutoutReceive) { + params && callback(params as Parameters[0]); + } + }, + onChannelUpdate: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelUpdate) { + params && callback(params as Parameters[0]); + } + }, + onUserUpdate: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onUserUpdate) { + params && callback(params as Parameters[0]); + } + }, + onChannelRaidFrom: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelRaidFrom) { + params && callback(params as Parameters[0]); + } + }, + onChannelRedemptionUpdate: (callback: (args: Parameters[0]) => void) => { + if (type === Types.onChannelRedemptionUpdate) { + params && callback(params as Parameters[0]); + } + }, + onStreamStart: (callback: () => void) => { + if (type === Types.TwitchStreamStarted) { + callback(); + } + }, + onStreamStop: (callback: () => void) => { + if (type === Types.TwitchStreamStopped) { + callback(); + } + }, + onCategoryChange: (callback: (category: string, oldCategory: string) => void) => { + if (type === Types.TwitchGameChanged) { + callback(params?.category || '', params?.oldCategory || ''); + } + }, + onChatClear: (callback: () => void) => { + if (type === Types.TwitchClearChat) { + callback(); + } + }, + onCommand: (opts: { command: string }, callback: any) => { + if (type === Types.TwitchCommand) { + if (message.toLowerCase().startsWith(opts.command.toLowerCase())) { + debug('plugins', `PLUGINS#${pluginId}: Twitch command executed`); + const regexp = new RegExp(escapeRegExp(opts.command), 'i'); + callback(userstate, ...message.replace(regexp, '').trim().split(' ').filter(Boolean)); + } + } + }, + onCheer: (callback: any) => { + if (type === Types.TwitchCheer) { + callback(userstate, params?.amount ?? 0, message); + } + }, + onMessage: (callback: any) => { + if (type === Types.TwitchMessage) { + debug('plugins', `PLUGINS#${pluginId}: Twitch message executed`); + callback(userstate, message); + } + }, + onFollow: (callback: any) => { + if (type === Types.TwitchFollow) { + callback(userstate); + } + }, + onRaid: (callback: any) => { + if (type === Types.TwitchRaid) { + callback(userstate, params); + } + }, + onRewardRedeem: (callback: any) => { + if (type === Types.TwitchRewardRedeem) { + callback(userstate, params); + } + }, + onResub: (callback: any) => { + if (type === Types.TwitchResub) { + callback(userstate, params); + } + }, + onSubscription: (callback: any) => { + if (type === Types.TwitchSubscription) { + callback(userstate, params); + } + }, + onSubGift: (callback: any) => { + if (type === Types.TwitchSubgift) { + callback(userstate, params); + } + }, + onSubCommunityGift: (callback: any) => { + if (type === Types.TwitchSubcommunitygift) { + callback(userstate, params); + } + }, + }, + CustomVariable: { + onChange: (variableName: string, callback: any) => { + if (type === Types.CustomVariableOnChange) { + if (variableName === params?.variableName) { + debug('plugins', `PLUGINS#${pluginId}: CustomVariable:onChange executed`); + callback(params?.cur, params?.prev); + } + } + }, + }, + Generic: { + onTip: (callback: any) => { + if (type === Types.GenericTip) { + callback(userstate, message, params); + } + }, + }, +}); \ No newline at end of file diff --git a/backend/src/plugins/Log.ts b/backend/src/plugins/Log.ts new file mode 100644 index 000000000..5d7ead7c5 --- /dev/null +++ b/backend/src/plugins/Log.ts @@ -0,0 +1,10 @@ +import { info, warning } from '~/helpers/log.js'; + +export const LogGenerator = (pluginId: string, fileName: string) => ({ + info: async (message: string) => { + info(`PLUGINS#${pluginId}:./${fileName}: ${message}`); + }, + warning: async (message: string) => { + warning(`PLUGINS#${pluginId}:./${fileName}: ${message}`); + }, +}); \ No newline at end of file diff --git a/backend/src/plugins/Permission.ts b/backend/src/plugins/Permission.ts new file mode 100644 index 000000000..bc67b8fac --- /dev/null +++ b/backend/src/plugins/Permission.ts @@ -0,0 +1,9 @@ +import { debug } from '~/helpers/log.js'; +import { check } from '~/helpers/permissions/check.js'; + +export const PermissionGenerator = (pluginId: string) => ({ + accessTo: async (userId: string, permId: string) => { + debug('plugins', `PLUGINS#${pluginId}: accessTo ${permId}`); + return (await check(userId, permId, false)).access; + }, +}); \ No newline at end of file diff --git a/backend/src/plugins/Sandbox.ts b/backend/src/plugins/Sandbox.ts new file mode 100644 index 000000000..9bad4b67f --- /dev/null +++ b/backend/src/plugins/Sandbox.ts @@ -0,0 +1,171 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { getTime } from '@sogebot/ui-helpers/getTime.js'; +import axios, { AxiosRequestConfig } from 'axios'; +import ts from 'typescript'; + +import { CustomVariableGenerator } from './CustomVariable.js'; +import { ListenToGenerator, Types } from './ListenTo.js'; +import { LogGenerator } from './Log.js'; +import { PermissionGenerator } from './Permission.js'; +import { TwitchGenerator } from './Twitch.js'; +import { VariableGenerator } from './Variable.js'; + +import type { EmitData } from '~/database/entity/alert.js'; +import { Plugin } from '~/database/entity/plugins.js'; +import { chatMessagesAtStart, isStreamOnline, stats } from '~/helpers/api/index.js'; +import { streamStatusChangeSince } from '~/helpers/api/streamStatusChangeSince.js'; +import { getUserSender } from '~/helpers/commons/index.js'; +import { mainCurrency, symbol } from '~/helpers/currency/index.js'; +import emitter from '~/helpers/interfaceEmitter.js'; +import { debug, info } from '~/helpers/log.js'; +import { linesParsed } from '~/helpers/parser.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import getBotId from '~/helpers/user/getBotId.js'; +import getBotUserName from '~/helpers/user/getBotUserName.js'; +import { getRandomOnlineSubscriber, getRandomSubscriber, getRandomOnlineViewer, getRandomViewer } from '~/helpers/user/random.js'; +import tts from '~/overlays/texttospeech.js'; +import alerts from '~/registries/alerts.js'; +import points from '~/systems/points.js'; +import users from '~/users.js'; + +export const transpiledFiles = new Map(); + +export const runScriptInSandbox = (plugin: Plugin, + userstate: { userName: string, userId: string } | null, + message: string, + type: Types, + ___code___: { + name: string; + source: string; + id: string; + }, + params: any, + ______opts: { + socket: any + }) => { + // @ts-expect-error TS6133 + const ListenTo = ListenToGenerator(plugin.id, type, message, userstate, params); + // @ts-expect-error TS6133 + const Twitch = TwitchGenerator(plugin.id, userstate); + // @ts-expect-error TS6133 + const Permission = PermissionGenerator(plugin.id); + // @ts-expect-error TS6133 + const permission = defaultPermissions; + // @ts-expect-error TS6133 + const Log = LogGenerator(plugin.id, ___code___.name); + // @ts-expect-error TS6133 + const Variable = VariableGenerator(plugin.id); + // @ts-expect-error TS6133 + const CustomVariable = CustomVariableGenerator(plugin.id); + // @ts-expect-error TS6133 + const Alerts = { + async trigger(uuid: string, name?: string, msg?: string, customOptions?: EmitData['customOptions']) { + if (customOptions) { + info(`PLUGINS#${plugin.id}: Triggering alert ${uuid} with custom options ${JSON.stringify(customOptions)}`); + } else { + info(`PLUGINS#${plugin.id}: Triggering alert ${uuid}`); + } + await alerts.trigger({ + amount: 0, + currency: 'CZK', + event: 'custom', + alertId: uuid, + message: msg || '', + monthsName: '', + name: name ?? '', + tier: null, + recipient: userstate?.userName ?? '', + customOptions, + }); + }, + }; + // @ts-expect-error TS6133 + const User = { + async getByUserId(userId: string) { + return users.getUserByUserId(userId); + }, + async getByUserName(userName: string) { + return users.getUserByUsername(userName); + }, + getRandom: { + subscriber(onlineOnly: boolean) { + return onlineOnly ? getRandomOnlineSubscriber() : getRandomSubscriber(); + }, + viewer(onlineOnly: boolean) { + return onlineOnly ? getRandomOnlineViewer() : getRandomViewer(); + }, + }, + }; + // @ts-expect-error TS6133 + const Points = { + async increment(userName: string, value: number) { + await points.increment({ userName }, Math.abs(Number(value))); + }, + async decrement(userName: string, value: number) { + await points.decrement({ userName }, Math.abs(Number(value))); + }, + }; + // @ts-expect-error TS6133 + const Overlay = { + emoteExplosion(emotes: string[]) { + emitter.emit('services::twitch::emotes', 'explode', emotes); + }, + emoteFirework(emotes: string[]) { + emitter.emit('services::twitch::emotes', 'firework', emotes); + }, + runFunction(functionName: string, args: any[], overlayId?: string) { + ______opts.socket?.emit('trigger::function', functionName, args, overlayId); + }, + triggerTTSOverlay(parameters: string) { + tts.textToSpeech({ + discord: undefined, + createdAt: Date.now(), + emotesOffsets: new Map(), + isFirstTimeMessage: false, + isHighlight: false, + isAction: false, + sender: getUserSender(getBotId(), getBotUserName()), + parameters, + attr: { + highlight: false, + }, + command: '!tts', + }); + }, + }; + // @ts-expect-error TS6133 + const fetch = async (uri: string, config: AxiosRequestConfig) => { + return (await axios(uri, config)); + }; + // @ts-expect-error TS6133 + const stream = { + uptime: getTime(isStreamOnline.value ? streamStatusChangeSince.value : null, false), + currentViewers: stats.value.currentViewers, + currentSubscribers: stats.value.currentSubscribers, + currentBits: stats.value.currentBits, + currentTips: stats.value.currentTips, + currency: symbol(mainCurrency.value), + chatMessages: (isStreamOnline.value) ? linesParsed - chatMessagesAtStart.value : 0, + currentFollowers: stats.value.currentFollowers, + maxViewers: stats.value.maxViewers, + newChatters: stats.value.newChatters, + game: stats.value.currentGame, + status: stats.value.currentTitle, + currentWatched: stats.value.currentWatchedTime, + channelDisplayName: stats.value.channelDisplayName, + channelUserName: stats.value.channelUserName, + }; + + eval(getTranspiledCode(___code___.id, ___code___.source)); +}; + +const getTranspiledCode = (codeId: string, source: string) => { + if (transpiledFiles.has(codeId)) { + debug('plugins', `Using cached code ${codeId}`); + return transpiledFiles.get(codeId)!; + } else { + debug('plugins', `Transpiling code ${codeId}`); + transpiledFiles.set(codeId, ts.transpile(source)); + return transpiledFiles.get(codeId)!; + } +}; \ No newline at end of file diff --git a/backend/src/plugins/Twitch.ts b/backend/src/plugins/Twitch.ts new file mode 100644 index 000000000..b7b4a09e0 --- /dev/null +++ b/backend/src/plugins/Twitch.ts @@ -0,0 +1,28 @@ +import { getUserSender } from '~/helpers/commons/index.js'; +import { sendMessage } from '~/helpers/commons/sendMessage.js'; +import { info } from '~/helpers/log.js'; +import getBotId from '~/helpers/user/getBotId.js'; +import getBotUserName from '~/helpers/user/getBotUserName.js'; +import banUser from '~/services/twitch/calls/banUser.js'; + +export const TwitchGenerator = (pluginId: string, userstate: { userName: string, userId: string } | null) => ({ + sendMessage: (message:string) => { + if (userstate) { + sendMessage(message, getUserSender(userstate.userId, userstate.userName)); + } else { + sendMessage(message, getUserSender(getBotId(), getBotUserName())); + } + }, + timeout: async (userId: string, timeout: number, reason?: string) => { + info(reason + ? `PLUGINS#${pluginId}: Timeouting user ${userId} for ${timeout}s with reason: ${reason}` + : `PLUGINS#${pluginId}: Timeouting user ${userId} for ${timeout}s`); + banUser(userId, reason, Number(timeout)); + }, + ban: async (userId: string, reason?: string) => { + info(reason + ? `PLUGINS#${pluginId}: Banning user ${userId} with reason: ${reason}` + : `PLUGINS#${pluginId}: Banning user ${userId}`); + banUser(userId, reason); + }, +}); \ No newline at end of file diff --git a/backend/src/plugins/Variable.ts b/backend/src/plugins/Variable.ts new file mode 100644 index 000000000..f4a76c6bf --- /dev/null +++ b/backend/src/plugins/Variable.ts @@ -0,0 +1,18 @@ +import { PluginVariable } from '~/database/entity/plugins.js'; + +export const VariableGenerator = (pluginId: string) => ({ + async loadFromDatabase(variableName: string) { + const variable = await PluginVariable.findOneBy({ pluginId, variableName }); + if (variable) { + return JSON.parse(variable.value); + } + return null; + }, + async saveToDatabase(variableName: string, value: any) { + const variable = new PluginVariable(); + variable.variableName = variableName; + variable.pluginId = pluginId; + variable.value = JSON.stringify(value); + await variable.save(); + }, +}); \ No newline at end of file diff --git a/backend/src/plugins/template.ts b/backend/src/plugins/template.ts new file mode 100644 index 000000000..12aef7702 --- /dev/null +++ b/backend/src/plugins/template.ts @@ -0,0 +1,61 @@ +import { getTime } from '@sogebot/ui-helpers/getTime.js'; + +import { + chatMessagesAtStart, isStreamOnline, stats, streamStatusChangeSince, +} from '~/helpers/api/index.js'; +import { getGlobalVariables } from '~/helpers/checkFilter.js'; +import { mainCurrency, symbol } from '~/helpers/currency/index.js'; +import { flatten } from '~/helpers/flatten.js'; +import { linesParsed } from '~/helpers/parser.js'; +import { showWithAt } from '~/helpers/tmi/index.js'; + +export async function template(message: string, params: Record, userstate?: { userName: string; userId: string } | null) { + if (userstate === null) { + userstate = undefined; + } + + params = flatten({ + ...params, + stream: { + uptime: getTime(isStreamOnline.value ? streamStatusChangeSince.value : null, false), + currentViewers: stats.value.currentViewers, + currentSubscribers: stats.value.currentSubscribers, + currentBits: stats.value.currentBits, + currentTips: stats.value.currentTips, + currency: symbol(mainCurrency.value), + chatMessages: (isStreamOnline.value) ? linesParsed - chatMessagesAtStart.value : 0, + currentFollowers: stats.value.currentFollowers, + maxViewers: stats.value.maxViewers, + newChatters: stats.value.newChatters, + game: stats.value.currentGame, + tags: stats.value.currentTags ?? [], + status: stats.value.currentTitle, + currentWatched: stats.value.currentWatchedTime, + }, + sender: { + userName: userstate?.userName, + userId: userstate?.userId, + }, + }); + const regexp = new RegExp(`{ *?(?[a-zA-Z0-9._]+) *?}`, 'g'); + const match = message.matchAll(regexp); + for (const item of match) { + message = message.replace(item[0], params[item[1]]); + } + + // global variables replacer + if (!message.includes('$')) { + // message doesn't have any variables + return message; + } + + const variables = await getGlobalVariables(message, { sender: userstate }); + for (const variable of Object.keys(variables)) { + message = message.replaceAll(variable, String(variables[variable as keyof typeof variables] ?? '')); + } + + if (userstate) { + message = message.replace(/\$sender/g, showWithAt ? `@${userstate.userName}` : userstate.userName); + } + return message; +} \ No newline at end of file diff --git a/backend/src/registries/_interface.ts b/backend/src/registries/_interface.ts new file mode 100644 index 000000000..5f580a1d6 --- /dev/null +++ b/backend/src/registries/_interface.ts @@ -0,0 +1,9 @@ +import Module from '../_interface.js'; + +class Registry extends Module { + constructor() { + super('registries', true); + } +} + +export default Registry; diff --git a/backend/src/registries/alerts.ts b/backend/src/registries/alerts.ts new file mode 100644 index 000000000..17bbd567c --- /dev/null +++ b/backend/src/registries/alerts.ts @@ -0,0 +1,305 @@ +import { + Alert, EmitData, +} from '@entity/alert.js'; +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import { v4 } from 'uuid'; + +import Registry from './_interface.js'; +import { command, default_permission, example, persistent, settings } from '../decorators.js'; + +import { parserReply } from '~/commons.js'; +import { User, UserInterface } from '~/database/entity/user.js'; +import { AppDataSource } from '~/database.js'; +import { Expects } from '~/expects.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { eventEmitter } from '~/helpers/events/emitter.js'; +import { error, debug, info } from '~/helpers/log.js'; +import { app, ioServer } from '~/helpers/panel.js'; +import { defaultPermissions } from '~/helpers/permissions/defaultPermissions.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { Types } from '~/plugins/ListenTo.js'; +import twitch from '~/services/twitch.js'; +import { adminMiddleware } from '~/socket.js'; +import { translate } from '~/translate.js'; +import { variables } from '~/watchers.js'; + +/* secureKeys are used to authenticate use of public overlay endpoint */ +const secureKeys = new Set(); + +const fetchUserForAlert = (opts: EmitData, type: 'recipient' | 'name'): Promise> & { game?: string } | null> => { + return new Promise> & { game?: string } | null>((resolve) => { + if ((opts.event === 'rewardredeem' || opts.event === 'custom') && type === 'name') { + return resolve(null); // we don't have user on reward redeems + } + + const value = opts[type]; + if (value && value.length > 0) { + Promise.all([ + AppDataSource.getRepository(User).findOneBy({ userName: value }), + twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUserByName(value)), + ]).then(async ([user_db, response]) => { + if (response) { + changelog.update(response.id, { + userId: response.id, + userName: response.name, + displayname: response.displayName, + profileImageUrl: response.profilePictureUrl, + }); + } + const id = user_db?.userId || response?.id; + if (id) { + if (opts.event === 'promo') { + const user = await changelog.get(id); + const channel = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.channels.getChannelInfoById(id)); + if (user && channel) { + resolve({ + ...user, + game: channel.gameName, + }); + } else { + resolve(null); + } + } else { + resolve(changelog.get(id)); + } + } else { + resolve(null); + } + }).catch((e) => { + if (e instanceof Error) { + error(e.stack || e.message); + } + resolve(null); + }); + } else { + resolve(null); + } + }); +}; + +class Alerts extends Registry { + @persistent() + areAlertsMuted = false; + @persistent() + isTTSMuted = false; + @persistent() + isSoundMuted = false; + + constructor() { + super(); + this.addMenu({ + category: 'registry', name: 'alerts', id: 'registry/alerts/', this: null, + }); + } + + sockets () { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + eventEmitter.on(Types.onChannelShoutoutCreate, (opts) => { + this.trigger({ + event: 'promo', + message: '', + name: opts.shoutedOutBroadcasterDisplayName, + tier: '1', + amount: opts.viewerCount, + currency: '', + monthsName: '', + }); + }); + + app.get('/api/registries/alerts', adminMiddleware, async (req, res) => { + res.send(await Alert.find()); + }); + + app.get('/api/registries/alerts/:id', async (req, res) => { + try { + res.send(await Alert.findOneByOrFail({ id: req.params.id })); + } catch { + res.status(404).send(); + } + }); + + app.delete('/api/registries/alerts/:id', adminMiddleware, async (req, res) => { + await Alert.delete({ id: req.params.id }); + res.status(404).send(); + }); + + app.post('/api/registries/alerts', adminMiddleware, async (req, res) => { + try { + const itemToSave = Alert.create(req.body); + await itemToSave.validateAndSave(); + res.send(itemToSave); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + + publicEndpoint('/registries/alerts', 'speak', async (opts, cb) => { + if (secureKeys.has(opts.key)) { + secureKeys.delete(opts.key); + + const { default: tts, services } = await import ('../tts.js'); + if (!tts.ready) { + cb(new Error('TTS is not properly set and ready.')); + return; + } + + if (tts.service === services.GOOGLE) { + try { + const audioContent = await tts.googleSpeak(opts); + cb(null, audioContent); + } catch (e) { + cb(e); + } + } + } else { + cb(new Error('Invalid auth.')); + } + }); + publicEndpoint('/registries/alerts', 'isAlertUpdated', async ({ updatedAt, id }, cb) => { + try { + const alert = await Alert.findOneBy({ id }); + if (alert) { + cb(null, updatedAt < (alert.updatedAt || 0), alert.updatedAt || 0); + } else { + cb(null, false, 0); + } + } catch (e: any) { + cb(e.stack, false, 0); + } + }); + adminEndpoint('/registries/alerts', 'alerts::settings', async (data, cb) => { + if (data) { + this.areAlertsMuted = data.areAlertsMuted; + this.isSoundMuted = data.isSoundMuted; + this.isTTSMuted = data.isTTSMuted; + } + + cb({ + areAlertsMuted: this.areAlertsMuted, + isSoundMuted: this.isSoundMuted, + isTTSMuted: this.isTTSMuted, + }); + }); + adminEndpoint('/registries/alerts', 'test', async (data: EmitData) => { + this.trigger({ + ...data, + monthsName: getLocalizedName(data.amount, translate('core.months')), + }, true); + }); + + publicEndpoint('/registries/alerts', 'speak', async (opts, cb) => { + if (secureKeys.has(opts.key)) { + secureKeys.delete(opts.key); + + const { default: tts, services } = await import ('../tts.js'); + if (!tts.ready) { + cb(new Error('TTS is not properly set and ready.')); + return; + } + + if (tts.service === services.GOOGLE) { + try { + const audioContent = await tts.googleSpeak(opts); + cb(null, audioContent); + } catch (e) { + cb(e); + } + } + } else { + cb(new Error('Invalid auth.')); + } + }); + } + + async trigger(opts: EmitData, isTest = false) { + debug('alerts.trigger', JSON.stringify(opts, null, 2)); + const { default: tts, services } = await import ('../tts.js'); + if (!this.areAlertsMuted || isTest) { + let key = v4(); + if (tts.service === services.RESPONSIVEVOICE) { + key = tts.responsiveVoiceKey; + } + if (tts.service === services.GOOGLE) { + // add secureKey + secureKeys.add(key); + setTimeout(() => { + secureKeys.delete(key); + }, 10 * MINUTE); + } + + secureKeys.add(key); + + const [ user, recipient ] = await Promise.all([ + fetchUserForAlert(opts, 'name'), + fetchUserForAlert(opts, 'recipient'), + ]); + + // search for user triggering alert + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + const caster = await AppDataSource.getRepository(User).findOneBy({ userId: broadcasterId }) ?? null; + + const data = { + ...opts, isTTSMuted: !tts.ready || this.isTTSMuted, isSoundMuted: this.isSoundMuted, TTSService: tts.service, TTSKey: key, user, game: user?.game, caster, recipientUser: recipient, id: v4(), + }; + + info(`Triggering alert send: ${JSON.stringify(data)}`); + ioServer?.of('/registries/alerts').emit('alert', data); + } + } + + skip() { + ioServer?.of('/registries/alerts').emit('skip'); + } + @settings() + ['!promo-shoutoutMessage'] = 'Shoutout to $userName! Lastly seen playing (stream|$userName|game). $customMessage'; + @settings() + ['!promo-enableShoutoutMessage'] = true; + @command('!promo') + @example([ + [ + '?!promo ', + ], + [ + '+!promo soge', + { if: 'enableShoutoutMessage', message: '-{shoutoutMessage}', replace: { $userName: 'soge', $customMessage: '' } }, + ], + [ + '+!promo soge Hey! Give him a follow!', + { if: 'enableShoutoutMessage', message: '-{shoutoutMessage}', replace: { $userName: 'soge', $customMessage: 'Hey! Give him a follow!' } }, + ], + ]) + @default_permission(defaultPermissions.MODERATORS) + async promo(opts: CommandOptions): Promise { + try { + const [ userName, customMessage ] = new Expects(opts.parameters) + .username() + .everything({ optional: true }) + .toArray(); + + const message = await prepare(this['!promo-shoutoutMessage'], { + userName, customMessage: customMessage ?? '', + }, false); + this['!promo-enableShoutoutMessage'] && parserReply(message, { sender: opts.sender, discord: opts.discord, attr: opts.attr, id: '', forbidReply: true }); + this.trigger({ + event: 'promo', + message: customMessage, + name: userName, + tier: '1', + amount: 0, + currency: '', + monthsName: '', + }); + } catch (err) { + console.log({ err }); + } + return []; + } +} + +export default new Alerts(); diff --git a/backend/src/registries/overlays.ts b/backend/src/registries/overlays.ts new file mode 100644 index 000000000..eb2e8c984 --- /dev/null +++ b/backend/src/registries/overlays.ts @@ -0,0 +1,111 @@ +import { SECOND } from '@sogebot/ui-helpers/constants.js'; + +import Registry from './_interface.js'; +import { Message } from '../message.js'; + +import { Goal, Overlay } from '~/database/entity/overlay.js'; +import { AppDataSource } from '~/database.js'; +import { stats } from '~/helpers/api/index.js'; +import { executeVariablesInText } from '~/helpers/customvariables/executeVariablesInText.js'; +import { isBotStarted } from '~/helpers/database.js'; +import defaultValues from '~/helpers/overlaysDefaultValues.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; + +const ticks: string[] = []; + +setInterval(async () => { + if (!isBotStarted) { + return; + } + + while(ticks.length > 0) { + let id = ticks.shift() as string; + let groupId = ''; + let time: number | string = 1000; + if (id.includes('|')) { + [groupId, id, time] = id.split('|'); + } + // check if it is without group + const overlay = await AppDataSource.getRepository(Overlay).findOneBy({ id: groupId }); + if (overlay) { + const item = overlay.items.find(o => o.id === id); + if (item?.opts.typeId === 'countdown' || item?.opts.typeId === 'stopwatch') { + item.opts.currentTime = Number(time); + overlay.save(); + } + } + } +}, SECOND * 1); + +const updateGoalValues = (output: Overlay) => { + // we need to set up current values for goals current + for (const item_ of output.items) { + if (item_.opts.typeId === 'goal') { + for (const campaign of (item_.opts as Goal).campaigns) { + if (campaign.type === 'currentFollowers') { + campaign.currentAmount = stats.value.currentFollowers; + } + if (campaign.type === 'currentSubscribers') { + campaign.currentAmount = stats.value.currentSubscribers; + } + } + } + } + return output.items; +}; + +class Overlays extends Registry { + constructor() { + super(); + this.addMenu({ + category: 'registry', name: 'overlays', id: 'registry/overlays', this: null, + }); + } + + sockets() { + adminEndpoint('/registries/overlays', 'generic::deleteById', async (id, cb) => { + await AppDataSource.getRepository(Overlay).delete(id); + cb(null); + }); + adminEndpoint('/registries/overlays', 'generic::save', async (opts, cb) => { + const data = await AppDataSource.getRepository(Overlay).save(opts); + cb(null, data); + }); + + publicEndpoint('/registries/overlays', 'parse', async (text, cb) => { + try { + cb(null, await new Message(await executeVariablesInText(text, null)).parse()); + } catch (e) { + cb(e, ''); + } + }); + publicEndpoint('/registries/overlays', 'generic::getAll', async (cb) => { + const items = await AppDataSource.getRepository(Overlay).find(); + cb(null, items.map(defaultValues) as Overlay[]); + }); + publicEndpoint('/registries/overlays', 'generic::getOne', async (id, cb) => { + const item = await AppDataSource.getRepository(Overlay).findOneBy({ id }); + if (item) { + const output = defaultValues(item); + output.items = updateGoalValues(output); + cb(null, output); + } else { + // try to find if id is part of group + const items = await Overlay.find(); + for (const it of items) { + if (it.items.map(o => o.id).includes(id)) { + const output = defaultValues(it); + output.items = updateGoalValues(output); + return cb(null, output); + } + } + cb(null, undefined); + } + }); + publicEndpoint('/registry/overlays', 'overlays::tick', (opts) => { + ticks.push(`${opts.groupId}|${opts.id}|${opts.millis}`); + }); + } +} + +export default new Overlays(); diff --git a/backend/src/registries/randomizer.ts b/backend/src/registries/randomizer.ts new file mode 100644 index 000000000..2b96ef0a6 --- /dev/null +++ b/backend/src/registries/randomizer.ts @@ -0,0 +1,133 @@ +import { Randomizer as RandomizerEntity } from '@entity/randomizer.js'; +import { LOW } from '@sogebot/ui-helpers/constants.js'; +import { validateOrReject } from 'class-validator'; +import { merge } from 'lodash-es'; + +import { AppDataSource } from '~/database.js'; + +import { v4 } from 'uuid'; + +import { app } from '~/helpers/panel.js'; +import { check } from '~/helpers/permissions/check.js'; +import { adminMiddleware } from '~/socket.js'; + +import Registry from './_interface.js'; +import { parser } from '../decorators.js'; + +class Randomizer extends Registry { + constructor() { + super(); + this.addMenu({ + category: 'registry', name: 'randomizer', id: 'registry/randomizer/', this: null, + }); + } + + sockets () { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/registries/randomizer', adminMiddleware, async (req, res) => { + res.send({ + data: await RandomizerEntity.find(), + }); + }); + app.get('/api/registries/randomizer/visible', async (req, res) => { + res.send({ + data: await RandomizerEntity.findOneBy({ isShown: true }), + }); + }); + app.get('/api/registries/randomizer/:id', adminMiddleware, async (req, res) => { + res.send({ + data: await RandomizerEntity.findOneBy({ id: req.params.id }), + }); + }); + app.post('/api/registries/randomizer/hide', adminMiddleware, async (req, res) => { + await AppDataSource.getRepository(RandomizerEntity).update({}, { isShown: false }); + res.status(204).send(); + }); + app.post('/api/registries/randomizer/:id/show', adminMiddleware, async (req, res) => { + await AppDataSource.getRepository(RandomizerEntity).update({}, { isShown: false }); + await AppDataSource.getRepository(RandomizerEntity).update({ id: String(req.params.id) }, { isShown: true }); + res.status(204).send(); + }); + app.post('/api/registries/randomizer/:id/spin', adminMiddleware, async (req, res) => { + const { default: tts, services } = await import ('../tts.js'); + let key = v4(); + if (tts.ready) { + if (tts.service === services.RESPONSIVEVOICE) { + key = tts.responsiveVoiceKey; + } + if (tts.service === services.GOOGLE) { + tts.addSecureKey(key); + } + } + this.socket?.emit('spin', { + service: tts.service, + key, + }); + res.status(204).send(); + }); + app.delete('/api/registries/randomizer/:id', adminMiddleware, async (req, res) => { + await RandomizerEntity.delete({ id: req.params.id }); + res.status(404).send(); + }); + app.post('/api/registries/randomizer', adminMiddleware, async (req, res) => { + try { + const itemToSave = new RandomizerEntity(); + merge(itemToSave, req.body); + await validateOrReject(itemToSave); + await itemToSave.save(); + res.send({ data: itemToSave }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + } + + /** + * Check if command is in randomizer (priority: low, fireAndForget) + * + * ! - hide/show randomizer + * + * ! go - spin up randomizer + */ + @parser({ priority: LOW, fireAndForget: true }) + async run (opts: ParserOptions) { + if (!opts.sender || !opts.message.startsWith('!')) { + return true; + } // do nothing if it is not a command + + const [command, subcommand] = opts.message.split(' '); + + const randomizer = await AppDataSource.getRepository(RandomizerEntity).findOneBy({ command }); + if (!randomizer) { + return true; + } + + // user doesn't have permision to use command + if (!(await check(opts.sender.userId, randomizer.permissionId, false)).access) { + return true; + } + + if (!subcommand) { + await AppDataSource.getRepository(RandomizerEntity).update({}, { isShown: false }); + await AppDataSource.getRepository(RandomizerEntity).update({ id: randomizer.id }, { isShown: !randomizer.isShown }); + } else if (subcommand === 'go') { + if (!randomizer.isShown) { + await AppDataSource.getRepository(RandomizerEntity).update({}, { isShown: false }); + await AppDataSource.getRepository(RandomizerEntity).update({ id: randomizer.id }, { isShown: !randomizer.isShown }); + setTimeout(() => { + this.socket?.emit('spin'); + }, 5000); + } else { + this.socket?.emit('spin'); + } + } + + return true; + } +} + +export default new Randomizer(); diff --git a/backend/src/services/_interface.ts b/backend/src/services/_interface.ts new file mode 100644 index 000000000..a741d1260 --- /dev/null +++ b/backend/src/services/_interface.ts @@ -0,0 +1,9 @@ +import Module from '../_interface.js'; + +class Service extends Module { + constructor() { + super('services', true); + } +} + +export default Service; diff --git a/backend/src/services/google.ts b/backend/src/services/google.ts new file mode 100644 index 000000000..b2aa4a84b --- /dev/null +++ b/backend/src/services/google.ts @@ -0,0 +1,376 @@ +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { getTime } from '@sogebot/ui-helpers/getTime.js'; +import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client'; +import { google, youtube_v3 } from 'googleapis'; + +import Service from './_interface.js'; + +import { GooglePrivateKeys } from '~/database/entity/google.js'; +import { AppDataSource } from '~/database.js'; +import { onChange, onStartup, onStreamEnd, onStreamStart } from '~/decorators/on.js'; +import { persistent, settings } from '~/decorators.js'; +import { + isStreamOnline, + stats, + streamStatusChangeSince, +} from '~/helpers/api/index.js'; +import { getLang } from '~/helpers/locales.js'; +import { error, info, debug } from '~/helpers/log.js'; +import { app } from '~/helpers/panel.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import { adminMiddleware } from '~/socket.js'; + +class Google extends Service { + clientId = '225380804535-gjd77dplfkbe4d3ct173d8qm0j83f8tr.apps.googleusercontent.com'; + @persistent() + refreshToken = ''; + @settings() + channel = ''; + @settings() + streamId = ''; + + @settings() + onStreamEndTitle = 'Archive | $gamesList | $date'; + @settings() + onStreamEndTitleEnabled = false; + + @settings() + onStreamEndDescription = 'Streamed at https://twitch.tv/changeme\nTitle: $title\n\n=========\n$chapters\n========\n\nDate: $date'; + @settings() + onStreamEndDescriptionEnabled = false; + + @settings() + onStreamEndPrivacyStatus: 'private' | 'public' | 'unlisted' = 'private'; + @settings() + onStreamEndPrivacyStatusEnabled = false; + + expiryDate: null | number = null; + accessToken: null | string = null; + client: OAuth2Client | null = null; + + broadcastId: string | null = null; + gamesPlayedOnStream: { game: string, timeMark: string }[] = []; + broadcastStartedAt: string = new Date().toLocaleDateString(getLang()); + + @onStreamStart() + onStreamStart() { + this.gamesPlayedOnStream = stats.value.currentGame ? [{ game: stats.value.currentGame, timeMark: '00:00:00' }] : []; + this.broadcastStartedAt = new Date().toLocaleDateString(getLang()); + } + + @onStreamEnd() + async onStreamEnd() { + if (this.client && this.broadcastId) { + setTimeout(() => this.prepareBroadcast, 10000); + const youtube = google.youtube({ + auth: this.client, + version: 'v3', + }); + + // load broadcast + const list = await youtube.liveBroadcasts.list({ + part: ['id','snippet','contentDetails','status'], + id: [this.broadcastId], + }); + + let broadcast: youtube_v3.Schema$LiveBroadcast; + if (list.data.items && list.data.items.length > 0) { + broadcast = list.data.items[0]; + } else { + // broadcast was not found + return; + } + + // get active broadcasts + youtube.liveBroadcasts.update({ + part: ['id','snippet','contentDetails','status'], + requestBody: { + ...broadcast, + id: this.broadcastId, + snippet: { + ...broadcast.snippet, + title: this.onStreamEndTitleEnabled + ? this.onStreamEndTitle + .replace('$gamesList', Array.from(new Set(this.gamesPlayedOnStream.map(item => item.game))).join(', ')) + .replace('$title', stats.value.currentTitle || '') + .replace('$date', this.broadcastStartedAt) + : broadcast.snippet?.title, + description: this.onStreamEndDescriptionEnabled + ? this.onStreamEndDescription + .replace('$chapters', this.gamesPlayedOnStream + .map(item => `${item.timeMark} ${item.game}`) + .join('\n')) + .replace('$title', broadcast.snippet?.title || stats.value.currentTitle || '') + .replace('$date', this.broadcastStartedAt) + : broadcast.snippet?.description, + }, + status: { + ...broadcast.status, + privacyStatus: this.onStreamEndPrivacyStatusEnabled + ? this.onStreamEndPrivacyStatus + : broadcast.status?.privacyStatus, + }, + }, + }); + } + } + + @onStartup() + startIntervals() { + setInterval(async () => { + const stream = await this.getBroadcast(); + + if (stream && stream.snippet) { + const currentTitle = stats.value.currentTitle || 'n/a'; + if (stream.snippet.title !== currentTitle && isStreamOnline.value) { + info(`YOUTUBE: Title is not matching current title, changing by bot to "${currentTitle}"`); + await this.updateTitle(stream, currentTitle); + } + } + + // add game to list + if (stats.value.currentGame + && (this.gamesPlayedOnStream.length > 0 && this.gamesPlayedOnStream[this.gamesPlayedOnStream.length - 1].game !== stats.value.currentGame)) { + info(`YOTUBE: Game changed to ${stats.value.currentGame} at ${Date.now() - streamStatusChangeSince.value}`); + this.gamesPlayedOnStream.push({ + game: stats.value.currentGame, + timeMark: getTime(streamStatusChangeSince.value, false) as string, + }); + } + }, MINUTE); + + setInterval(async () => { + const broadcast = await this.getBroadcast(); + + if (!broadcast) { + this.prepareBroadcast(); + } + }, 15 * MINUTE); + } + + @onChange('refreshToken') + @onStartup() + async onStartup() { + debug('google', `Refresh token changed to: ${this.refreshToken}`); + if (this.refreshToken.length === 0) { + return; + } + + if (this.client) { + this.client = null; + } + + // configure a JWT auth client + this.client = new google.auth.OAuth2({ + clientId: this.clientId, + }); + this.client.setCredentials({ + access_token: this.accessToken, + refresh_token: this.refreshToken, + expiry_date: this.expiryDate, + }); + + this.client.on('tokens', (tokens) => { + if (tokens.refresh_token) { + this.refreshToken = tokens.refresh_token; + } + if (tokens.access_token) { + this.accessToken = tokens.access_token; + } + if (tokens.expiry_date) { + this.expiryDate = tokens.expiry_date; + } + }); + + const youtube = google.youtube({ + auth: this.client, + version: 'v3', + }); + + const channel = await youtube.channels.list({ + part: ['snippet,contentDetails'], + mine: true, + }); + if (channel.data.items && channel.data.items.length > 0) { + const item = channel.data.items[0].snippet!; + this.channel = [channel.data.items[0].id, item.title, item.customUrl].filter(String).join(' | '); + info(`YOUTUBE: Authentication to Google Service successful as ${this.channel}.`); + } else { + error(`'YOUTUBE: Couldn't get channel informations.`); + } + } + + async updateTitle(stream: youtube_v3.Schema$LiveBroadcast, title: string) { + if (this.client) { + const youtube = google.youtube({ + auth: this.client, + version: 'v3', + }); + + // get active broadcasts + await youtube.liveBroadcasts.update({ + part: ['id','snippet','contentDetails','status'], + requestBody: { + ...stream, + snippet: { + ...stream.snippet, + title, + }, + }, + }); + } + } + + async getBroadcast() { + if (this.client) { + const youtube = google.youtube({ + auth: this.client, + version: 'v3', + }); + + // get active broadcasts + const list = await youtube.liveBroadcasts.list({ + part: ['id','snippet','contentDetails','status'], + broadcastStatus: 'active', + }); + + if (list.data.items && list.data.items.length > 0) { + const broadcast = list.data.items[0]; + this.broadcastId = broadcast.id ?? null; + return broadcast; + } + } + return null; + } + + async prepareBroadcast() { + if (isStreamOnline.value || this.refreshToken === '') { + return; // do nothing if already streaming + } + // we want to create new stream, private for now for archive purpose + if (this.client) { + const youtube = google.youtube({ + auth: this.client, + version: 'v3', + }); + + // get active broadcasts + const list = await youtube.liveBroadcasts.list({ + part: ['id','snippet','contentDetails','status'], + broadcastStatus: 'upcoming', + }); + + if (list.data.items && list.data.items.length > 0) { + const broadcast = list.data.items[0]; + + this.broadcastId = broadcast.id ?? null; + + if (this.streamId.length > 0 && broadcast.id) { + await youtube.liveBroadcasts.bind({ + part: ['id'], + streamId: this.streamId, + id: broadcast.id, + }); + } + + // if have broadcast, update scheduledStartTime + return youtube.liveBroadcasts.update({ + part: ['id','snippet','contentDetails','status'], + requestBody: { + ...broadcast, + snippet: { + ...broadcast.snippet, + title: stats.value.currentTitle || 'n/a', + scheduledStartTime: new Date(Date.now() + (15 * 60000)).toISOString(), + }, + }, + }); + } + + youtube.liveBroadcasts.insert({ + part: ['id','snippet','contentDetails','status'], + requestBody: { + snippet: { + title: stats.value.currentTitle || 'n/a', + scheduledStartTime: new Date(Date.now() + (15 * 60000)).toISOString(), + }, + status: { + privacyStatus: 'private', + selfDeclaredMadeForKids: true, + }, + contentDetails: { + enableAutoStart: true, + enableAutoStop: true, + }, + }, + }) + .then(liveBroadcastResponse => { + if (this.streamId.length > 0 && liveBroadcastResponse.data.id) { + youtube.liveBroadcasts.bind({ + part: ['id'], + streamId: this.streamId, + id: liveBroadcastResponse.data.id, + }); + } + info(`YOUTUBE: Created new private broadcast ${liveBroadcastResponse.data.id}`); + }) + .catch(e => { + error(`YOUTUBE: Something went wrong:\n${e}`); + }); + } + } + + sockets() { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + adminEndpoint('/services/google', 'google::revoke', async (cb) => { + self.channel = ''; + self.refreshToken = ''; + info(`YOUTUBE: User access revoked.`); + cb(null); + }); + adminEndpoint('/services/google', 'google::token', async (tokens, cb) => { + self.refreshToken = tokens.refreshToken; + cb(null); + }); + + app.get('/api/services/google/privatekeys', adminMiddleware, async (req, res) => { + res.send({ + data: await AppDataSource.getRepository(GooglePrivateKeys).find(), + }); + }); + + app.post('/api/services/google/privatekeys', adminMiddleware, async (req, res) => { + const data = req.body; + await AppDataSource.getRepository(GooglePrivateKeys).save(data); + res.send({ data }); + }); + + app.delete('/api/services/google/privatekeys/:id', adminMiddleware, async (req, res) => { + await AppDataSource.getRepository(GooglePrivateKeys).delete({ id: req.params.id }); + res.status(404).send(); + }); + + app.get('/api/services/google/streams', adminMiddleware, async (req, res) => { + if (this.client) { + const youtube = google.youtube({ + auth: this.client, + version: 'v3', + }); + + const rmtps = await youtube.liveStreams.list({ + part: ['id', 'snippet', 'cdn', 'status'], + mine: true, + }); + res.send({ data: rmtps.data.items }); + } else { + res.send({ data: [] }); + } + }); + } +} +const self = new Google(); +export default self; diff --git a/backend/src/services/twitch.ts b/backend/src/services/twitch.ts new file mode 100644 index 000000000..a5c522158 --- /dev/null +++ b/backend/src/services/twitch.ts @@ -0,0 +1,599 @@ +import { EventList } from '@entity/eventList.js'; +import { User } from '@entity/user.js'; +import { SECOND } from '@sogebot/ui-helpers/constants.js'; +import { dayjs, timezone } from '@sogebot/ui-helpers/dayjsHelper.js'; +import { getTime } from '@sogebot/ui-helpers/getTime.js'; +import { ApiClient } from '@twurple/api'; +import { capitalize } from 'lodash-es'; + +import Service from './_interface.js'; +import { init } from './twitch/api/interval.js'; +import { createClip } from './twitch/calls/createClip.js'; +import { createMarker } from './twitch/calls/createMarker.js'; +import { updateBroadcasterType } from './twitch/calls/updateBroadcasterType.js'; +import Chat from './twitch/chat.js'; +import EventSubLongPolling from './twitch/eventSubLongPolling.js'; +import EventSubWebsocket from './twitch/eventSubWebsocket.js'; +import { CustomAuthProvider } from './twitch/token/CustomAuthProvider.js'; +import { onChange, onLoad, onStreamStart } from '../decorators/on.js'; +import { + command, default_permission, example, persistent, settings, +} from '../decorators.js'; +import { Expects } from '../expects.js'; +import { debug, error, info } from '../helpers/log.js'; + +import { AppDataSource } from '~/database.js'; +import { + isStreamOnline, stats, streamStatusChangeSince, +} from '~/helpers/api/index.js'; +import { prepare } from '~/helpers/commons/prepare.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import { + ignorelist, sendWithMe, setMuteStatus, showWithAt, +} from '~/helpers/tmi/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import getNameById from '~/helpers/user/getNameById.js'; +import { isIgnored } from '~/helpers/user/isIgnored.js'; +import { sendGameFromTwitch } from '~/services/twitch/calls/sendGameFromTwitch.js'; +import { updateChannelInfo } from '~/services/twitch/calls/updateChannelInfo.js'; +import { translate } from '~/translate.js'; +import { variables } from '~/watchers.js'; + +const urls = { + 'SogeBot Token Generator v2': 'https://credentials.sogebot.xyz/twitch/refresh/', +}; +const markerEvents = new Set(); +const loadedKeys = new Set(); + +class Twitch extends Service { + tmi: Chat | null = null; + eventSubLongPolling: EventSubLongPolling | null = null; + eventSubWebsocket: EventSubWebsocket | null = null; + + authProvider: CustomAuthProvider | null = null; + apiClient: ApiClient | null = null; + + @persistent() + uptime = 0; + + @persistent() // needs to be persistent as we are using it with variables.get + botTokenValid = false; + @persistent() // needs to be persistent as we are using it with variables.get + broadcasterTokenValid = false; + + @settings('chat') + sendWithMe = false; + @settings('chat') + sendAsReply = false; + @settings('chat') + ignorelist: any[] = []; + @settings('chat') + showWithAt = true; + @settings('chat') + mute = false; + @settings('chat') + whisperListener = false; + + @settings('general') + tokenService: keyof typeof urls | 'Own Twitch App' = 'SogeBot Token Generator v2'; + @settings('general') + tokenServiceCustomClientId = ''; + @settings('general') + tokenServiceCustomClientSecret = ''; + @settings('general') + generalOwners: string[] = []; + @settings('general') + createMarkerOnEvent = true; + + @settings('broadcaster') + broadcasterRefreshToken = ''; + @settings('broadcaster') + broadcasterId = ''; + @settings('broadcaster') + broadcasterUsername = ''; + @settings('broadcaster') + broadcasterCurrentScopes: string[] = []; + @persistent() + broadcasterType: string | null = null; + + @settings('bot') + botRefreshToken = ''; + @settings('bot') + botId = ''; + @settings('bot') + botUsername = ''; + @settings('bot') + botCurrentScopes: string[] = []; + + @onChange('botCurrentScopes') + onChangeBotScopes() { + if (this.botCurrentScopes.length > 0) { + info('TWITCH: Bot scopes ' + this.botCurrentScopes.join(', ')); + } + } + + @onChange('broadcasterCurrentScopes') + onChangeBroadcasterScopes() { + if (this.broadcasterCurrentScopes.length > 0) { + info('TWITCH: Broadcaster scopes ' + this.broadcasterCurrentScopes.join(', ')); + } + } + + @onLoad(['tokenService', 'tokenServiceCustomClientId', 'tokenServiceCustomClientSecret']) + @onChange(['tokenService', 'tokenServiceCustomClientId', 'tokenServiceCustomClientSecret']) + onTokenServiceChange() { + let clientId; + switch (this.tokenService) { + case 'SogeBot Token Generator v2': + clientId = '89k6demxtifvq0vzgjpvr1mykxaqmf'; + break; + default: + clientId = this.tokenServiceCustomClientId; + } + this.authProvider = new CustomAuthProvider({ + clientId, + clientSecret: this.tokenServiceCustomClientSecret, // we don't care if we have generator + }); + this.apiClient = new ApiClient({ authProvider: this.authProvider }); + } + + @onLoad(['broadcasterRefreshToken', 'botRefreshToken', 'tokenService', 'tokenServiceCustomClientId', 'tokenServiceCustomClientSecret']) + async onChangeRefreshTokens(key: string) { + this.botTokenValid = false; + this.broadcasterTokenValid = false; + + loadedKeys.add(key); + if (loadedKeys.size < 5 || !this.authProvider || !this.apiClient) { + debug('twitch.onChangeRefreshTokens', 'Not yet loaded'); + return; + } + debug('twitch.onChangeRefreshTokens', 'Adding tokens to authProvider'); + if (this.botRefreshToken.length > 0) { + const userId = await this.authProvider.addUserForToken({ + expiresIn: 0, + refreshToken: this.botRefreshToken, + obtainmentTimestamp: 0, + }); + this.authProvider.addIntentsToUser(userId, ['bot', 'chat']); + const tokenInfo = await this.apiClient.asUser(userId, ctx => ctx.getTokenInfo()); + this.botId = userId; + this.botUsername = tokenInfo.userName ?? ''; + this.botCurrentScopes = tokenInfo.scopes; + this.botTokenValid = true; + info(`TWITCH: Bot token initialized OK for ${this.botUsername}#${this.botId} with scopes: ${this.botCurrentScopes.join(', ')}`); + } + if (this.broadcasterRefreshToken.length > 0) { + const userId = await this.authProvider.addUserForToken({ + expiresIn: 0, + refreshToken: this.broadcasterRefreshToken, + obtainmentTimestamp: 0, + }); + + this.authProvider.addIntentsToUser(userId, ['broadcaster']); + const tokenInfo = await this.apiClient.asUser(userId, ctx => ctx.getTokenInfo()); + this.broadcasterId = userId; + this.broadcasterUsername = tokenInfo.userName ?? ''; + this.broadcasterCurrentScopes = tokenInfo.scopes; + this.broadcasterTokenValid = true; + setTimeout(() => updateBroadcasterType(), 5000); + info(`TWITCH: Broadcaster token initialized OK for ${this.broadcasterUsername}#${this.broadcasterId} (type: ${this.broadcasterType}) with scopes: ${this.broadcasterCurrentScopes.join(', ')}`); + } + this.onTokenValidChange(); + } + + onTokenValidChange() { + debug('twitch.onTokenValidChange', 'onTokenValidChange()'); + + if (!this.broadcasterTokenValid) { + debug('twitch.eventsub', 'onTokenValidChange() listener stop()'); + } + if (this.broadcasterTokenValid && this.botTokenValid) { + setTimeout(() => { + if (!this.authProvider || !this.apiClient) { + return; + } + this.tmi = new Chat(this.authProvider); + this.eventSubLongPolling = new EventSubLongPolling(); + this.eventSubWebsocket = new EventSubWebsocket(this.apiClient); + + if (this.broadcasterId === this.botId) { + error(`You have set bot and broadcaster oauth for same user ${this.broadcasterUsername}#${this.broadcasterId}. This is *NOT RECOMMENDED*. Please use *SEPARATE* account for bot.`); + } + }, 2000); + } else { + this.tmi = null; + this.eventSubLongPolling = null; + this.eventSubWebsocket = null; + } + } + + constructor() { + super(); + + this.botTokenValid = false; + this.broadcasterTokenValid = false; + + setTimeout(() => { + init(); // start up intervals for api + }, 30000); + + setInterval(() => { + if (markerEvents.size > 0) { + const description: string[] = []; + const events = Array.from(markerEvents.values()); + + // group events if there are more then five in second + if (events.length > 5) { + // we need to group everything, as description can be max of 140 chars + for (const event of ['follow', 'subs/resubs/gifts', 'tip', 'rewardredeem', 'raids', 'cheer']) { + const length = events.filter(o => { + if (event === 'subs/resubs/gifts') { + return o.startsWith('subgift') || o.startsWith('resub') || o.startsWith('sub') || o.startsWith('subcommunitygift'); + } + if (event === 'raids') { + return o.startsWith('raid'); + } + return o.startsWith(event); + }).length; + if (length > 0) { + description.push(`${event}: ${length}`); + } + } + if (isStreamOnline.value) { + createMarker(description.join(', ')); + } + } else { + if (isStreamOnline.value) { + for (const event of events) { + createMarker(event); + } + } + } + markerEvents.clear(); + } + + if (isStreamOnline.value) { + this.uptime += SECOND; + } + }, SECOND); + } + + addEventToMarker(event: EventList.Event['event'], username: string) { + if (this.createMarkerOnEvent) { + markerEvents.add(`${event} ${username}`); + } + } + + @onChange('broadcasterUsername') + public async onChangeBroadcasterUsername(key: string, value: any) { + if (!this.generalOwners.includes(value)) { + this.generalOwners.push(value); + } + } + + @onStreamStart() + async reconnectOnStreamStart() { + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + + this.uptime = 0; + await this.tmi?.part('bot'); + await this.tmi?.join('bot', broadcasterUsername); + await this.tmi?.part('broadcaster'); + await this.tmi?.join('broadcaster', broadcasterUsername); + } + + @onChange('showWithAt') + @onLoad('showWithAt') + setShowWithAt() { + showWithAt.value = this.showWithAt; + } + + @onChange('sendWithMe') + @onLoad('sendWithMe') + setSendWithMe() { + sendWithMe.value = this.sendWithMe; + } + + @onChange('ignorelist') + @onLoad('ignorelist') + setIgnoreList() { + ignorelist.value = this.ignorelist; + } + + @onChange('mute') + @onLoad('mute') + setMuteStatus() { + setMuteStatus(this.mute); + } + + sockets() { + adminEndpoint('/services/twitch', 'broadcaster', (cb) => { + try { + cb(null, (this.broadcasterUsername).toLowerCase()); + } catch (e: any) { + cb(e.stack, ''); + } + }); + adminEndpoint('/services/twitch', 'twitch::revoke', async ({ accountType }, cb) => { + if (accountType === 'bot') { + this.botRefreshToken = ''; + this.botCurrentScopes = []; + this.botId = ''; + this.botTokenValid = false; + this.botUsername = ''; + } else { + this.broadcasterRefreshToken = ''; + this.broadcasterCurrentScopes = []; + this.broadcasterId = ''; + this.broadcasterTokenValid = false; + this.broadcasterUsername = ''; + } + info(`TWITCH: ${capitalize(accountType)} access revoked.`); + cb(null); + }); + adminEndpoint('/services/twitch', 'twitch::token', async ({ accessToken, refreshToken, accountType }, cb) => { + this.tokenService = 'SogeBot Token Generator v2'; + this[`${accountType}RefreshToken`] = refreshToken; + // waiting a while for variable propagation + setTimeout(() => { + this.onChangeRefreshTokens(`${accountType}RefreshToken`); + setTimeout(async () => { + cb(null); + }, 1000); + }, 250); + }); + adminEndpoint('/services/twitch', 'twitch::token::ownApp', async ({ accessToken, refreshToken, accountType, clientId, clientSecret }, cb) => { + this.tokenService ='Own Twitch App'; + this[`${accountType}RefreshToken`] = refreshToken; + this.tokenServiceCustomClientId = clientId; + this.tokenServiceCustomClientSecret = clientSecret; + // waiting a while for variable propagation + setTimeout(() => { + this.onChangeRefreshTokens(`${accountType}RefreshToken`); + setTimeout(async () => { + cb(null); + }, 1000); + }, 250); + }); + } + + @command('!clip') + @default_permission(defaultPermissions.CASTERS) + async clip (opts: CommandOptions) { + const cid = await createClip({ createAfterDelay: false }); + if (cid) { + return [{ + response: prepare('api.clips.created', { link: `https://clips.twitch.tv/${cid}` }), + ...opts, + }]; + } else { + return [{ + response: await translate(isStreamOnline.value ? 'clip.notCreated' : 'clip.offline'), + ...opts, + }]; + } + } + + @command('!replay') + @default_permission(defaultPermissions.CASTERS) + async replay (opts: CommandOptions) { + const cid = await createClip({ createAfterDelay: false }); + if (cid) { + (await import('~/overlays/clips.js')).default.showClip(cid); + return [{ + response: prepare('api.clips.created', { link: `https://clips.twitch.tv/${cid}` }), + ...opts, + }]; + } else { + return [{ + response: await translate(isStreamOnline.value ? 'clip.notCreated' : 'clip.offline'), + ...opts, + }]; + } + } + + @command('!uptime') + async uptimeCmd (opts: CommandOptions) { + const time = getTime(streamStatusChangeSince.value, true) as any; + return [ + { + response: await translate(isStreamOnline.value ? 'uptime.online' : 'uptime.offline') + .replace(/\$days/g, time.days) + .replace(/\$hours/g, time.hours) + .replace(/\$minutes/g, time.minutes) + .replace(/\$seconds/g, time.seconds), + ...opts, + }, + ]; + } + + @command('!ignore add') + @default_permission(defaultPermissions.CASTERS) + async ignoreAdd (opts: CommandOptions) { + try { + const username = new Expects(opts.parameters).username().toArray()[0].toLowerCase(); + this.ignorelist = [ + ...new Set([ + ...this.ignorelist, + username, + ], + )]; + // update ignore list + + return [{ response: prepare('ignore.user.is.added', { username }), ...opts }]; + } catch (e: any) { + error(e.stack); + } + return []; + } + + @command('!ignore remove') + @default_permission(defaultPermissions.CASTERS) + async ignoreRm (opts: CommandOptions) { + try { + const username = new Expects(opts.parameters).username().toArray()[0].toLowerCase(); + this.ignorelist = this.ignorelist.filter(o => o !== username); + // update ignore list + return [{ response: prepare('ignore.user.is.removed', { username }), ...opts }]; + } catch (e: any) { + error(e.stack); + } + return []; + } + + @command('!ignore check') + @default_permission(defaultPermissions.CASTERS) + async ignoreCheck (opts: CommandOptions) { + try { + const username = new Expects(opts.parameters).username().toArray()[0].toLowerCase(); + const isUserIgnored = isIgnored({ userName: username }); + return [{ response: prepare(isUserIgnored ? 'ignore.user.is.ignored' : 'ignore.user.is.not.ignored', { username }), ...opts }]; + } catch (e: any) { + error(e.stack); + } + return []; + } + + @command('!time') + async time (opts: CommandOptions) { + return [ { response: prepare('time', { time: dayjs().tz(timezone).format('LTS') }), ...opts }]; + } + + @command('!followers') + async followers (opts: CommandOptions) { + const events = await AppDataSource.getRepository(EventList) + .createQueryBuilder('events') + .select('events') + .orderBy('events.timestamp', 'DESC') + .where('events.event = :event', { event: 'follow' }) + .getMany(); + + let lastFollowAgo = ''; + let lastFollowUsername = 'n/a'; + if (events.length > 0) { + lastFollowUsername = await getNameById(events[0].userId); + lastFollowAgo = dayjs(events[0].timestamp).fromNow(); + } + + const response = prepare('followers', { + lastFollowAgo: lastFollowAgo, + lastFollowUsername: lastFollowUsername, + }); + return [ { response, ...opts }]; + } + + @command('!subs') + async subs (opts: CommandOptions) { + const events = await AppDataSource.getRepository(EventList) + .createQueryBuilder('events') + .select('events') + .orderBy('events.timestamp', 'DESC') + .where('events.event = :event', { event: 'sub' }) + .orWhere('events.event = :event2', { event2: 'resub' }) + .orWhere('events.event = :event3', { event3: 'subgift' }) + .getMany(); + await changelog.flush(); + const onlineSubscribers = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: this.botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: this.broadcasterUsername.toLowerCase() }) + .andWhere('user.isSubscriber = :isSubscriber', { isSubscriber: true }) + .andWhere('user.isOnline = :isOnline', { isOnline: true }) + .getMany()).filter(o => { + return !isIgnored({ userName: o.userName, userId: o.userId }); + }); + + let lastSubAgo = ''; + let lastSubUsername = 'n/a'; + if (events.length > 0) { + lastSubUsername = await getNameById(events[0].userId); + lastSubAgo = dayjs(events[0].timestamp).fromNow(); + } + + const response = prepare('subs', { + lastSubAgo: lastSubAgo, + lastSubUsername: lastSubUsername, + onlineSubCount: onlineSubscribers.length, + }); + return [ { response, ...opts }]; + } + + @command('!title') + async getTitle (opts: CommandOptions) { + return [ { response: translate('title.current').replace(/\$title/g, stats.value.currentTitle || 'n/a'), ...opts }]; + } + + @command('!title set') + @default_permission(defaultPermissions.CASTERS) + async setTitle (opts: CommandOptions) { + if (opts.parameters.length === 0) { + return [ { response: await translate('title.current').replace(/\$title/g, stats.value.currentTitle || 'n/a'), ...opts }]; + } + const status = await updateChannelInfo({ title: opts.parameters }); + return status ? [ { response: status.response, ...opts } ] : []; + } + + @command('!game') + async getGame (opts: CommandOptions) { + return [ { response: translate('game.current').replace(/\$title/g, stats.value.currentGame || 'n/a'), ...opts }]; + } + + @command('!marker') + @example([ + [ + '?!marker ', + ], + [ + '+!marker Something amazing just happened!', + { message: '-Stream marker has been created at 00:10:05.', replace: {} }, + ], + [ + '+!marker', + { message: '-Stream marker has been created at 00:10:06.', replace: {} }, + ], + ]) + @default_permission(defaultPermissions.MODERATORS) + async createMarker (opts: CommandOptions) { + const description = opts.parameters.trim().length === 0 + ? 'Created by ' + opts.sender.userName + : opts.parameters + ' by ' + opts.sender.userName; + const marker = await createMarker(description); + if (marker) { + return [{ response: translate('marker').replace(/\$time/g, getTime(marker.positionInSeconds, false) || '???'), ...opts }]; + } else { + return []; + } + } + + @command('!game set') + @default_permission(defaultPermissions.CASTERS) + async setGame (opts: CommandOptions) { + if (opts.parameters.length === 0) { + return [ { response: translate('game.current').replace(/\$title/g, stats.value.currentGame || 'n/a'), ...opts }]; + } + const games = await sendGameFromTwitch(opts.parameters); + if (Array.isArray(games) && games.length > 0) { + const exactMatchIdx = games.findIndex(name => name.toLowerCase() === opts.parameters.toLowerCase()); + const status = await updateChannelInfo({ game: games[exactMatchIdx !== -1 ? exactMatchIdx : 0] }); + return status ? [ { response: status.response, ...opts } ] : []; + } + return [{ response: translate('game.current').replace(/\$title/g, stats.value.currentGame || 'n/a'), ...opts }]; + } + + @default_permission(defaultPermissions.CASTERS) + @command('!reconnect') + async reconnect() { + if (this.tmi) { + info('TMI: Triggering reconnect from chat'); + this.tmi.shouldConnect = true; + this.tmi.reconnect('bot'); + this.tmi.reconnect('broadcaster'); + } else { + error('TMI: Not initialized'); + } + return []; + } + +} + +export default new Twitch(); diff --git a/backend/src/services/twitch/api/interval.ts b/backend/src/services/twitch/api/interval.ts new file mode 100644 index 000000000..4a9cd3d61 --- /dev/null +++ b/backend/src/services/twitch/api/interval.ts @@ -0,0 +1,153 @@ +import * as constants from '@sogebot/ui-helpers/constants.js'; +import chalk from 'chalk'; + +import { getChannelChatBadges } from '../calls/getChannelChatBadges.js'; +import { getChannelFollowers } from '../calls/getChannelFollowers.js'; + +import { + debug, error, warning, +} from '~/helpers/log.js'; +import { logAvgTime } from '~/helpers/profiler.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import { checkClips } from '~/services/twitch/calls/checkClips.js'; +import { getChannelChatters } from '~/services/twitch/calls/getChannelChatters.js'; +import { getChannelInformation } from '~/services/twitch/calls/getChannelInformation.js'; +import { getChannelSubscribers } from '~/services/twitch/calls/getChannelSubscribers.js'; +import { getCurrentStream } from '~/services/twitch/calls/getCurrentStream.js'; +import { getModerators } from '~/services/twitch/calls/getModerators.js'; +import { updateBroadcasterType } from '~/services/twitch/calls/updateBroadcasterType.js'; +import { variables } from '~/watchers.js'; + +const intervals = new Map; +}>(); + +const addInterval = (fnc: keyof typeof functions, intervalId: number) => { + intervals.set(fnc, { + interval: intervalId, lastRunAt: 0, opts: {}, isDisabled: false, + }); +}; + +const functions = { + getCurrentStream: getCurrentStream, + getChannelInformation: getChannelInformation, + updateBroadcasterType: updateBroadcasterType, + getChannelSubscribers: getChannelSubscribers, + getChannelChatters: getChannelChatters, + getChannelChatBadges: getChannelChatBadges, + checkClips: checkClips, + getModerators: getModerators, + getChannelFollowers: getChannelFollowers, +} as const; + +export const init = () => { + addInterval('getChannelFollowers', constants.MINUTE); + addInterval('getCurrentStream', constants.MINUTE); + addInterval('updateBroadcasterType', constants.HOUR); + addInterval('getChannelSubscribers', 2 * constants.MINUTE); + addInterval('getChannelChatters', 5 * constants.MINUTE); + addInterval('getChannelChatBadges', 5 * constants.MINUTE); + addInterval('getChannelInformation', constants.MINUTE); + addInterval('checkClips', constants.MINUTE); + addInterval('getModerators', 10 * constants.MINUTE); +}; + +export const stop = () => { + intervals.clear(); +}; + +let isBlocking: boolean | string = false; + +const check = async () => { + if (isBlocking) { + debug('api.interval', chalk.yellow(isBlocking + '() ') + 'still in progress.'); + return; + } + for (const fnc of intervals.keys()) { + await setImmediateAwait(); + + const botTokenValid = variables.get('services.twitch.botTokenValid') as string; + const broadcasterTokenValid = variables.get('services.twitch.broadcasterTokenValid') as string; + if (!botTokenValid || !broadcasterTokenValid) { + debug('api.interval', 'Tokens not valid.'); + return; + } + + debug('api.interval', chalk.yellow(fnc + '() ') + 'check'); + let interval = intervals.get(fnc); + if (!interval) { + error(`Interval ${fnc} not found.`); + continue; + } + if (interval.isDisabled) { + debug('api.interval', chalk.yellow(fnc + '() ') + 'disabled'); + continue; + } + if (Date.now() - interval.lastRunAt >= interval.interval) { + isBlocking = fnc; + debug('api.interval', chalk.yellow(fnc + '() ') + 'start'); + const time = process.hrtime(); + const time2 = Date.now(); + try { + const value = await Promise.race>([ + new Promise((resolve, reject) => { + functions[fnc](interval?.opts) + .then((data: any) => resolve(data)) + .catch((e: any) => reject(e)); + }), + new Promise((_resolve, reject) => setTimeout(() => reject(), 10 * constants.MINUTE)), + ]); + logAvgTime(`api.${fnc}()`, process.hrtime(time)); + debug('api.interval', chalk.yellow(fnc + '(time: ' + (Date.now() - time2 + ') ') + JSON.stringify(value))); + intervals.set(fnc, { + ...interval, + lastRunAt: Date.now(), + }); + if (value.disable) { + intervals.set(fnc, { + ...interval, + isDisabled: true, + }); + debug('api.interval', chalk.yellow(fnc + '() ') + 'disabled'); + continue; + } + debug('api.interval', chalk.yellow(fnc + '() ') + 'done, value:' + JSON.stringify(value)); + + interval = intervals.get(fnc); // refresh data + if (!interval) { + error(`Interval ${fnc} not found.`); + continue; + } + + if (value.state) { // if is ok, update opts and run unlock after a while + intervals.set(fnc, { + ...interval, + opts: value.opts ?? {}, + }); + } else { // else run next tick + intervals.set(fnc, { + ...interval, + opts: value.opts ?? {}, + lastRunAt: 0, + }); + } + } catch (e) { + if (e instanceof Error) { + error(e.stack ?? e.message); + } + warning(`API call for ${fnc} is probably frozen (took more than 10minutes), forcefully unblocking`); + debug('api.interval', chalk.yellow(fnc + '() ') + e); + continue; + } finally { + debug('api.interval', chalk.yellow(fnc + '() ') + 'unblocked.'); + isBlocking = false; + } + } else { + debug('api.interval', chalk.yellow(fnc + '() ') + `skip run, lastRunAt: ${interval.lastRunAt}` ); + } + } +}; +setInterval(check, 10000); \ No newline at end of file diff --git a/backend/src/services/twitch/calls/addModerator.ts b/backend/src/services/twitch/calls/addModerator.ts new file mode 100644 index 000000000..486ea30f6 --- /dev/null +++ b/backend/src/services/twitch/calls/addModerator.ts @@ -0,0 +1,6 @@ +import getBroadcasterId from '~/helpers/user/getBroadcasterId.js'; +import twitch from '~/services/twitch.js'; + +export default function addModerator(userId: string) { + return twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.moderation.addModerator(getBroadcasterId(), userId)); +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/addVip.ts b/backend/src/services/twitch/calls/addVip.ts new file mode 100644 index 000000000..5fe22cf85 --- /dev/null +++ b/backend/src/services/twitch/calls/addVip.ts @@ -0,0 +1,6 @@ +import getBroadcasterId from '~/helpers/user/getBroadcasterId.js'; +import twitch from '~/services/twitch.js'; + +export default function addVip(userId: string) { + return twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.channels.addVip(getBroadcasterId(), userId)); +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/banUser.ts b/backend/src/services/twitch/calls/banUser.ts new file mode 100644 index 000000000..07662f2f5 --- /dev/null +++ b/backend/src/services/twitch/calls/banUser.ts @@ -0,0 +1,19 @@ +import getBroadcasterId from '~/helpers/user/getBroadcasterId.js'; +import twitch from '~/services/twitch.js'; + +export default function banUser(userId: string, reason?: string, duration?: number, type: 'bot' | 'broadcaster' = 'bot') { + twitch.apiClient?.asIntent([type], ctx => ctx.moderation.banUser(getBroadcasterId(), { + user: { + id: userId, + }, + duration, + reason: reason ?? '', + })).catch((e) => { + if (type === 'bot') { + // try again with broadcaster + banUser(userId, reason, duration, 'broadcaster'); + } else { + throw e; // rethrow on second try + } + }); +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/checkClips.ts b/backend/src/services/twitch/calls/checkClips.ts new file mode 100644 index 000000000..2d57b2408 --- /dev/null +++ b/backend/src/services/twitch/calls/checkClips.ts @@ -0,0 +1,43 @@ +import { debug } from 'console'; + +import { TwitchClips } from '../../../database/entity/twitch.js'; +import { error, warning } from '../../../helpers/log.js'; + +import { AppDataSource } from '~/database.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import twitch from '~/services/twitch.js'; + +export async function checkClips () { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + try { + let notCheckedClips = (await AppDataSource.getRepository(TwitchClips).findBy({ isChecked: false })); + + // remove clips which failed + for (const clip of notCheckedClips.filter((o) => new Date(o.shouldBeCheckedAt).getTime() < new Date().getTime())) { + await AppDataSource.getRepository(TwitchClips).remove(clip); + } + notCheckedClips = notCheckedClips.filter((o) => new Date(o.shouldBeCheckedAt).getTime() >= new Date().getTime()); + if (notCheckedClips.length === 0) { // nothing to do + return { state: true }; + } + + const getClipsByIds = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.clips.getClipsByIds(notCheckedClips.map((o) => o.clipId))); + for (const clip of getClipsByIds ?? []) { + // clip found in twitch api + await AppDataSource.getRepository(TwitchClips).update({ clipId: clip.id }, { isChecked: true }); + } + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + return { state: false }; // ignore etimedout error + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + } + return { state: true }; +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/createClip.ts b/backend/src/services/twitch/calls/createClip.ts new file mode 100644 index 000000000..0285ad44e --- /dev/null +++ b/backend/src/services/twitch/calls/createClip.ts @@ -0,0 +1,60 @@ +import { defaults } from 'lodash-es'; + +import { TwitchClips } from '../../../database/entity/twitch.js'; +import { debug, error, warning } from '../../../helpers/log.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline } from '~/helpers/api/index.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +export async function createClip (opts: { createAfterDelay: boolean }): Promise { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + if (!(isStreamOnline.value)) { + return null; + } // do nothing if stream is offline + + const isClipChecked = async function (id: string) { + return new Promise((resolve: (value: boolean) => void) => { + const check = async () => { + const clip = await AppDataSource.getRepository(TwitchClips).findOneBy({ clipId: id }); + if (!clip) { + resolve(false); + } else if (clip.isChecked) { + resolve(true); + } else { + // not checked yet + setTimeout(() => check(), 100); + } + }; + check(); + }); + }; + + defaults(opts, { createAfterDelay: true }); + + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + try { + const clipId = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.clips.createClip({ ...opts, channel: broadcasterId })); + if (!clipId) { + return null; + } + await AppDataSource.getRepository(TwitchClips).save({ + clipId: clipId, isChecked: false, shouldBeCheckedAt: Date.now() + 120 * 1000, + }); + return (await isClipChecked(clipId)) ? clipId : null; + } catch (e: unknown) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + return null; + } + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + return null; +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/createMarker.ts b/backend/src/services/twitch/calls/createMarker.ts new file mode 100644 index 000000000..ef4d9dc7e --- /dev/null +++ b/backend/src/services/twitch/calls/createMarker.ts @@ -0,0 +1,31 @@ +import { HelixStreamMarker } from '@twurple/api/lib'; + +import { debug, error, warning } from '../../../helpers/log.js'; + +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +export async function createMarker (description = 'Marked from sogeBot'): Promise { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + + try { + return await twitch.apiClient?.asIntent(['bot'], ctx => ctx.streams.createStreamMarker(broadcasterId, description)) ?? null; + } catch (e: unknown) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return createMarker(description); + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + } + return null; +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/deleteChatMessages.ts b/backend/src/services/twitch/calls/deleteChatMessages.ts new file mode 100644 index 000000000..bfcd15da4 --- /dev/null +++ b/backend/src/services/twitch/calls/deleteChatMessages.ts @@ -0,0 +1,6 @@ +import getBroadcasterId from '~/helpers/user/getBroadcasterId.js'; +import twitch from '~/services/twitch.js'; + +export default function deleteChatMessages(messageId: string) { + return twitch.apiClient?.asIntent(['bot'], ctx => ctx.moderation.deleteChatMessages(getBroadcasterId(), messageId)); +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getChannelChatBadges.ts b/backend/src/services/twitch/calls/getChannelChatBadges.ts new file mode 100644 index 000000000..df134bb06 --- /dev/null +++ b/backend/src/services/twitch/calls/getChannelChatBadges.ts @@ -0,0 +1,36 @@ +import { HelixChatBadgeSet } from '@twurple/api/lib'; + +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import { debug, error, warning } from '~/helpers/log.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +export let badgesCache: HelixChatBadgeSet[] = []; + +export async function getChannelChatBadges() { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + try { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + if (!twitch.apiClient) { + return { state: false }; + } + + badgesCache = [ + ...await twitch.apiClient.asIntent(['broadcaster'], ctx => ctx.chat.getChannelBadges(broadcasterId)), + ...await twitch.apiClient.asIntent(['broadcaster'], ctx => ctx.chat.getGlobalBadges()), + ]; + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + return { state: false }; // ignore etimedout error + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + } + return { state: true }; +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getChannelChatters.ts b/backend/src/services/twitch/calls/getChannelChatters.ts new file mode 100644 index 000000000..57d8d12fb --- /dev/null +++ b/backend/src/services/twitch/calls/getChannelChatters.ts @@ -0,0 +1,156 @@ +import { User } from '@entity/user.js'; +import { HelixChatChatter, HelixForwardPagination } from '@twurple/api/lib'; +import { HttpStatusCodeError } from '@twurple/api-call'; +import { + capitalize, + chunk, includes, +} from 'lodash-es'; + +import { AppDataSource } from '~/database.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { getAllOnline } from '~/helpers/getAllOnlineUsernames.js'; +import { + debug, error, warning, +} from '~/helpers/log.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import { SQLVariableLimit } from '~/helpers/sql.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { isIgnored } from '~/helpers/user/isIgnored.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; +import joinpart from '~/widgets/joinpart.js'; + +const getChannelChattersAll = async (chatters: HelixChatChatter[] = [], after?: HelixForwardPagination['after']): Promise => { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + + try { + const response = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.getChatters(broadcasterId, { after, limit: 100 })); + if (!response) { + return []; + } + chatters.push(...response.data); + + if (response.total === chatters.length) { + return chatters; + } + + return getChannelChattersAll(chatters, response.cursor); + } catch (e) { + if (e instanceof Error) { + if (e instanceof HttpStatusCodeError) { + if (e.statusCode === 403) { + error(`No chatters found. ${capitalize(JSON.parse(e.body).message)}`); + } else { + error(`getChannelChattersAll => ${e.statusCode} - ${JSON.parse(e.body).message}`); + } + } else { + error(`getChannelChattersAll => ${e.stack ?? e.message}`); + } + } + return []; + } +}; + +export const getChannelChatters = async (opts: any) => { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + try { + const botId = variables.get('services.twitch.botId') as string; + + const [ + chatters, + allOnlineUsers, + ] = await Promise.all([ + new Promise(resolve => { + getChannelChattersAll().then(response => resolve(response.filter(data => { + // exclude global ignore list + const shouldExclude = isIgnored({ userName: data.userName, userId: data.userId }); + debug('api.getChannelChatter', `${data.userName} - shouldExclude: ${shouldExclude}`); + return !shouldExclude; + }))); + }), + getAllOnline(), + ]); + + const partedUsers: string[] = []; + for (const user of allOnlineUsers) { + if (!includes(chatters.map(o => o.userId), user.userId) && user.userId !== botId) { + // user is no longer in channel + await AppDataSource.getRepository(User).update({ userId: user.userId }, { isOnline: false, displayname: chatters.find(o => o.userId === user.userId)?.userDisplayName || chatters.find(o => o.userId === user.userId)?.userName, userName: chatters.find(o => o.userId === user.userId)?.userName }); + partedUsers.push(user.userName); + } + } + + const joinedUsers: HelixChatChatter[] = []; + for (const chatter of chatters) { + if (!includes(allOnlineUsers.map(o => o.userId), chatter.userId) && chatter.userId !== botId) { + joinedUsers.push(chatter); + } + } + + // insert joined online users + const usersToFetch: string[] = []; + if (joinedUsers.length > 0) { + for (const joinedUser of joinedUsers) { + const user = await AppDataSource.getRepository(User).findOne({ where: { userId: joinedUser.userId } }); + if (user) { + await AppDataSource.getRepository(User).save({ ...user, isOnline: true }); + if (!user.createdAt) { + // run this after we save new user + const getUserById = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUserById(joinedUser.userId)); + if (getUserById) { + changelog.update(getUserById.id, { createdAt: new Date(getUserById.creationDate).toISOString() }); + } + } + } else { + usersToFetch.push(joinedUser.userId); + } + } + } + + for (const userIdBatch of chunk(usersToFetch, 100)) { + twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUsersByIds(userIdBatch).then(users => { + if (users) { + AppDataSource.getRepository(User).save( + users.map(user => { + return { + userId: user.id, + userName: user.name, + displayname: user.displayName, + profileImageUrl: user.profilePictureUrl, + }; + }), + { chunk: Math.floor(SQLVariableLimit / 4) }, + ).catch(() => { + // ignore + return; + }); + } + })); + } + + joinpart.send({ users: partedUsers, type: 'part' }); + for (const username of partedUsers) { + await setImmediateAwait(); + eventEmitter.emit('user-parted-channel', { userName: username }); + } + + joinpart.send({ users: joinedUsers.map(o => o.userDisplayName), type: 'join' }); + for (const user of joinedUsers) { + await setImmediateAwait(); + eventEmitter.emit('user-joined-channel', { userName: user.userName }); + } + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`getChannelChattersAll => Connection to Twitch timed out. Will retry request.`); + } else { + error(`getChannelChattersAll => ${e.stack ?? e.message}`); + } + } + return { state: false, opts }; + } + return { state: true, opts }; +}; \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getChannelFollowers.ts b/backend/src/services/twitch/calls/getChannelFollowers.ts new file mode 100644 index 000000000..85be1dd7b --- /dev/null +++ b/backend/src/services/twitch/calls/getChannelFollowers.ts @@ -0,0 +1,22 @@ +import { + stats as apiStats, +} from '~/helpers/api/index.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { debug } from '~/helpers/log.js'; +import getBroadcasterId from '~/helpers/user/getBroadcasterId.js'; +import twitch from '~/services/twitch.js'; + +export async function getChannelFollowers() { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + + const response = await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.channels.getChannelFollowers(getBroadcasterId(), getBroadcasterId())); + if (!response) { + return { state: false }; + } + + apiStats.value.currentFollowers = response.total; + + return { state: true }; +} diff --git a/backend/src/services/twitch/calls/getChannelInformation.ts b/backend/src/services/twitch/calls/getChannelInformation.ts new file mode 100644 index 000000000..4350081ae --- /dev/null +++ b/backend/src/services/twitch/calls/getChannelInformation.ts @@ -0,0 +1,100 @@ +import { isEqual } from 'lodash-es'; + +import { CacheTitles } from '~/database/entity/cacheTitles.js'; +import { AppDataSource } from '~/database.js'; +import { currentStreamTags, gameCache, gameOrTitleChangedManually, rawStatus, tagsCache } from '~/helpers/api/index.js'; +import { + stats as apiStats, +} from '~/helpers/api/index.js'; +import { parseTitle } from '~/helpers/api/parseTitle.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import { debug, error, info, warning } from '~/helpers/log.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +let retries = 0; + +export async function getChannelInformation (opts: any) { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + try { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + const getChannelInfo = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.channels.getChannelInfoById(broadcasterId)); + if (!getChannelInfo) { + throw new Error(`Channel ${broadcasterId} not found on Twitch`); + } + + while (currentStreamTags.length) { + currentStreamTags.pop(); + } + for (const tag of getChannelInfo.tags) { + currentStreamTags.push(tag); + } + + if (!gameOrTitleChangedManually.value) { + // Just polling update + let _rawStatus = rawStatus.value; + + const title = await parseTitle(null); + const game = gameCache.value; + const tags = JSON.parse(tagsCache.value) as string[]; + + const titleEquals = getChannelInfo.title === title; + const gameEquals = getChannelInfo.gameName.toLowerCase() === game.toLowerCase(); + const tagsEquals = isEqual(getChannelInfo.tags.sort(), tags.sort()); + const isChanged = !titleEquals || !gameEquals || !tagsEquals; + + if (gameEquals && game !== getChannelInfo.gameName) { + gameCache.value = getChannelInfo.gameName; + await AppDataSource.getRepository(CacheTitles).update({ game }, { game: getChannelInfo.gameName }); + } + + if (isChanged && retries === -1) { + return { state: true, opts }; + } else if (isChanged && !opts.forceUpdate) { + // check if title is same as updated title + const numOfRetries = 1; + if (retries >= numOfRetries) { + retries = 0; + info(`Title/game changed outside of a bot => ${getChannelInfo.gameName} | ${getChannelInfo.title} ${getChannelInfo.tags.map(o => `#${o}`).join(' ')}`); + retries = -1; + _rawStatus = getChannelInfo.title; + } else { + retries++; + return { state: false, opts }; + } + } else { + retries = 0; + } + + apiStats.value.language = getChannelInfo.language; + apiStats.value.currentTags = getChannelInfo.tags; + apiStats.value.contentClasificationLabels = getChannelInfo.contentClassificationLabels; + apiStats.value.currentGame = getChannelInfo.gameName; + apiStats.value.currentTitle = getChannelInfo.title; + apiStats.value.channelDisplayName = getChannelInfo.displayName; + apiStats.value.channelUserName = getChannelInfo.name; + + gameCache.value = getChannelInfo.gameName; + rawStatus.value = _rawStatus; + tagsCache.value = JSON.stringify(getChannelInfo.tags); + } else { + gameOrTitleChangedManually.value = false; + } + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + return { state: false, opts }; // ignore etimedout error + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + return { state: false, opts }; + } + + retries = 0; + return { state: true, opts }; +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getChannelSubscribers.ts b/backend/src/services/twitch/calls/getChannelSubscribers.ts new file mode 100644 index 000000000..03578141d --- /dev/null +++ b/backend/src/services/twitch/calls/getChannelSubscribers.ts @@ -0,0 +1,92 @@ +import { HelixSubscription } from '@twurple/api/lib'; + +import { User } from '~/database/entity/user.js'; +import { AppDataSource } from '~/database.js'; +import { + stats as apiStats, +} from '~/helpers/api/index.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import { debug, error, warning } from '~/helpers/log.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { isBotId, isBotSubscriber } from '~/helpers/user/index.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +export async function getChannelSubscribers (opts: T): Promise<{ state: boolean; opts: T }> { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + opts = opts || {}; + + try { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + const broadcasterType = variables.get('services.twitch.broadcasterType') as string; + + if (broadcasterType !== 'partner' && broadcasterType !== 'affiliate') { + if (!opts.noAffiliateOrPartnerWarningSent) { + warning('Broadcaster is not affiliate/partner, will not check subs'); + apiStats.value.currentSubscribers = 0; + } + return { state: false, opts: { ...opts, noAffiliateOrPartnerWarningSent: true } }; + } + const getSubscriptionsPaginated = await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.subscriptions.getSubscriptionsPaginated(broadcasterId).getAll()); + if (!getSubscriptionsPaginated) { + return { state: false, opts }; + } + apiStats.value.currentSubscribers = getSubscriptionsPaginated.length - 1; // exclude owner + setSubscribers(getSubscriptionsPaginated.filter(o => !isBotId(o.userId))); + if (getSubscriptionsPaginated.find(o => isBotId(o.userId))) { + isBotSubscriber(true); + } else { + isBotSubscriber(false); + } + + // reset warning after correct calls (user may have affiliate or have correct oauth) + opts.noAffiliateOrPartnerWarningSent = false; + opts.notCorrectOauthWarningSent = false; + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + return { state: false, opts }; // ignore etimedout error + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + } + return { state: true, opts }; +} + +async function setSubscribers (subscribers: HelixSubscription[]) { + await changelog.flush(); + const currentSubscribers = await AppDataSource.getRepository(User).find({ where: { isSubscriber: true } }); + + // check if current subscribers are still subs + for (const user of currentSubscribers) { + if (!user.haveSubscriberLock && !subscribers + .map((o) => String(o.userId)) + .includes(String(user.userId))) { + // subscriber is not sub anymore -> unsub and set subStreak to 0 + changelog.update(user.userId, { + ...user, + isSubscriber: false, + subscribeStreak: 0, + }); + } + } + + // update subscribers tier and set them active + for (const user of subscribers) { + const current = currentSubscribers.find(o => o.userId === user.userId); + const isNotCurrentSubscriber = !current; + const valuesNotMatch = current && (current.subscribeTier !== String(Number(user.tier) / 1000) || current.isSubscriber === false); + if (isNotCurrentSubscriber || valuesNotMatch) { + changelog.update(user.userId, { + userName: user.userName.toLowerCase(), + isSubscriber: true, + subscribeTier: String(Number(user.tier) / 1000), + }); + } + } +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getCurrentStream.ts b/backend/src/services/twitch/calls/getCurrentStream.ts new file mode 100644 index 000000000..946225fde --- /dev/null +++ b/backend/src/services/twitch/calls/getCurrentStream.ts @@ -0,0 +1,76 @@ +import { dayjs } from '@sogebot/ui-helpers/dayjsHelper.js'; + +import { isStreamOnline, streamId, streamStatusChangeSince, streamType } from '~/helpers/api/index.js'; +import { + stats as apiStats, chatMessagesAtStart, +} from '~/helpers/api/index.js'; +import * as stream from '~/helpers/core/stream.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import { debug, error, warning } from '~/helpers/log.js'; +import { linesParsed } from '~/helpers/parser.js'; +import twitch from '~/services/twitch.js'; +import stats from '~/stats.js'; +import { variables } from '~/watchers.js'; + +export async function getCurrentStream (opts: any) { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + const cid = variables.get('services.twitch.broadcasterId') as string; + + try { + const getStreamByUserId = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.streams.getStreamByUserId(cid)); + debug('api.stream', 'API: ' + JSON.stringify({ getStreamByUserId })); + + if (getStreamByUserId) { + if (isStreamOnline.value) { + eventEmitter.emit('every-x-minutes-of-stream', { reset: false } ); + } + + if (dayjs(getStreamByUserId.startDate).valueOf() >= dayjs(streamStatusChangeSince.value).valueOf()) { + streamStatusChangeSince.value = (new Date(getStreamByUserId.startDate)).getTime(); + } + if (!isStreamOnline.value || streamType.value !== getStreamByUserId.type) { + if (Number(streamId.value) !== Number(getStreamByUserId.id)) { + stream.end(); + stream.start(getStreamByUserId); + } + } + + apiStats.value.currentViewers = getStreamByUserId.viewers; + + if (apiStats.value.maxViewers < getStreamByUserId.viewers) { + apiStats.value.maxViewers = getStreamByUserId.viewers; + } + + stats.save({ + timestamp: new Date().getTime(), + whenOnline: isStreamOnline.value ? streamStatusChangeSince.value : Date.now(), + currentViewers: apiStats.value.currentViewers, + currentSubscribers: apiStats.value.currentSubscribers, + currentFollowers: apiStats.value.currentFollowers, + currentBits: apiStats.value.currentBits, + currentTips: apiStats.value.currentTips, + chatMessages: linesParsed - chatMessagesAtStart.value, + maxViewers: apiStats.value.maxViewers, + newChatters: apiStats.value.newChatters, + currentWatched: apiStats.value.currentWatchedTime, + }); + } else { + stream.end(); + } + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + return { state: false, opts }; // ignore etimedout error + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + return { state: false, opts }; + } + return { state: true, opts }; +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getCustomRewards.ts b/backend/src/services/twitch/calls/getCustomRewards.ts new file mode 100644 index 000000000..259fd7009 --- /dev/null +++ b/backend/src/services/twitch/calls/getCustomRewards.ts @@ -0,0 +1,38 @@ +import { HelixCustomReward } from '@twurple/api/lib'; +import { HttpStatusCodeError } from '@twurple/api-call'; +import { capitalize } from 'lodash-es'; + +import { isDebugEnabled } from '~/helpers/debug.js'; +import { debug, error, info, warning } from '~/helpers/log.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +export const getCustomRewards = async (): Promise => { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + try { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + return await twitch.apiClient?.asIntent(['broadcaster'], ctx=> ctx.channelPoints.getCustomRewards(broadcasterId)) ?? []; + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`getCustomRewards => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return getCustomRewards(); + } else { + if (e instanceof HttpStatusCodeError) { + if (e.statusCode === 403) { + info(`No channel custom rewards found. ${capitalize(JSON.parse(e.body).message)}`); + } else { + error(`getCustomRewards => ${e.statusCode} - ${JSON.parse(e.body).message}`); + } + } else { + error(`getCustomRewards => ${e.stack ?? e.message}`); + } + } + } + return []; + } +}; \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getGameIdFromName.ts b/backend/src/services/twitch/calls/getGameIdFromName.ts new file mode 100644 index 000000000..3ff37cc73 --- /dev/null +++ b/backend/src/services/twitch/calls/getGameIdFromName.ts @@ -0,0 +1,43 @@ +import { CacheGames } from '@entity/cacheGames.js'; + +import { AppDataSource } from '~/database.js'; +import { stats } from '~/helpers/api/index.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { debug, warning } from '~/helpers/log.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; + +async function getGameIdFromName (name: string): Promise { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + const gameFromDb = await AppDataSource.getRepository(CacheGames).findOneBy({ name }); + // check if name is cached + if (gameFromDb) { + return String(gameFromDb.id); + } + + try { + const getGameByName = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.games.getGameByName(name)); + if (!getGameByName) { + throw new Error(`Game ${name} not found on Twitch - fallback to ${stats.value.currentGame}.`); + } + // add id->game to cache + const id = Number(getGameByName.id); + await AppDataSource.getRepository(CacheGames).save({ id, name, thumbnail: getGameByName.boxArtUrl }); + return String(id); + } catch (e: unknown) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`getGameIdFromName => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return getGameIdFromName(name); + } else { + warning(`getGameIdFromName => ${e.stack ?? e.message}`); + } + } + return undefined; + } +} + +export { getGameIdFromName }; \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getGameNameFromId.ts b/backend/src/services/twitch/calls/getGameNameFromId.ts new file mode 100644 index 000000000..c47c087e9 --- /dev/null +++ b/backend/src/services/twitch/calls/getGameNameFromId.ts @@ -0,0 +1,46 @@ +import { CacheGames } from '@entity/cacheGames.js'; + +import { AppDataSource } from '~/database.js'; +import { stats } from '~/helpers/api/index.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { debug, warning } from '~/helpers/log.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; + +async function getGameNameFromId (id: number): Promise { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + if (id.toString().trim().length === 0 || id === 0) { + return ''; + } // return empty game if gid is empty + + const gameFromDb = await AppDataSource.getRepository(CacheGames).findOneBy({ id }); + + // check if id is cached + if (gameFromDb) { + return gameFromDb.name; + } + + try { + const getGameById = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.games.getGameById(String(id))); + if (!getGameById) { + throw new Error(`Couldn't find name of game for gid ${id} - fallback to ${stats.value.currentGame}`); + } + await AppDataSource.getRepository(CacheGames).save({ id, name: getGameById.name, thumbnail: getGameById.boxArtUrl }); + return getGameById.name; + } catch (e: unknown) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`getGameIdFromName => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return getGameNameFromId(id); + } else { + warning(`getGameNameFromId => ${e.stack ?? e.message}`); + } + } + return stats.value.currentGame as string; + } +} + +export { getGameNameFromId }; \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getGameThumbnailFromName.ts b/backend/src/services/twitch/calls/getGameThumbnailFromName.ts new file mode 100644 index 000000000..987bb4771 --- /dev/null +++ b/backend/src/services/twitch/calls/getGameThumbnailFromName.ts @@ -0,0 +1,42 @@ +import { CacheGames } from '@entity/cacheGames.js'; + +import { AppDataSource } from '~/database.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { debug, warning } from '~/helpers/log.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; + +async function getGameThumbnailFromName (name: string): Promise { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + const gameFromDb = await AppDataSource.getRepository(CacheGames).findOneBy({ name }); + // check if name is cached + if (gameFromDb && gameFromDb.thumbnail) { + return String(gameFromDb.thumbnail); + } + + try { + const getGameByName = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.games.getGameByName(name)); + if (!getGameByName) { + return undefined; + } + // add id->game to cache + const id = Number(getGameByName.id); + await AppDataSource.getRepository(CacheGames).save({ id, name, thumbnail: getGameByName.boxArtUrl }); + return String(id); + } catch (e: unknown) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`getGameThumbnailFromName => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return getGameThumbnailFromName(name); + } else { + warning(`getGameThumbnailFromName => ${e.stack ?? e.message}`); + } + } + return undefined; + } +} + +export { getGameThumbnailFromName }; \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getIdFromTwitch.ts b/backend/src/services/twitch/calls/getIdFromTwitch.ts new file mode 100644 index 000000000..aa27b1419 --- /dev/null +++ b/backend/src/services/twitch/calls/getIdFromTwitch.ts @@ -0,0 +1,33 @@ +import { isDebugEnabled } from '~/helpers/debug.js'; +import { debug, error, warning } from '~/helpers/log.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; + +async function getIdFromTwitch (userName: string): Promise { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + try { + const getUserByName = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUserByName(userName)); + if (getUserByName) { + return getUserByName.id; + } else { + throw new Error(`User ${userName} not found on Twitch.`); + } + } catch (e: unknown) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`getIdFromTwitch => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return getIdFromTwitch(userName); + } else if(e.message.includes('not found on Twitch')) { + warning(`${e.message}`); + } else { + error(`getIdFromTwitch => ${e.stack ?? e.message}`); + } + } + throw(e); + } +} + +export { getIdFromTwitch }; \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getModerators.ts b/backend/src/services/twitch/calls/getModerators.ts new file mode 100644 index 000000000..747fdf9e2 --- /dev/null +++ b/backend/src/services/twitch/calls/getModerators.ts @@ -0,0 +1,53 @@ +import { In, Not } from 'typeorm'; + +import { User } from '~/database/entity/user.js'; +import { AppDataSource } from '~/database.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import { debug, error, warning } from '~/helpers/log.js'; +import { addUIError } from '~/helpers/panel/index.js'; +import { setStatus } from '~/helpers/parser.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +export async function getModerators(opts: { isWarned: boolean }) { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + try { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + const botId = variables.get('services.twitch.botId') as string; + const broadcasterCurrentScopes = variables.get('services.twitch.broadcasterCurrentScopes') as string[]; + + if (!broadcasterCurrentScopes.includes('moderation:read')) { + if (!opts.isWarned) { + opts.isWarned = true; + warning('Missing Broadcaster oAuth scope moderation:read to read channel moderators.'); + addUIError({ name: 'OAUTH', message: 'Missing Broadcaster oAuth scope moderation:read to read channel moderators.' }); + } + return { state: false, opts }; + } + + const getModeratorsPaginated = await twitch.apiClient?.moderation.getModeratorsPaginated(broadcasterId).getAll(); + if (!getModeratorsPaginated) { + return { state: false }; + } + + await changelog.flush(); + await AppDataSource.getRepository(User).update({ userId: Not(In(getModeratorsPaginated.map(o => o.userId))) }, { isModerator: false }); + await AppDataSource.getRepository(User).update({ userId: In(getModeratorsPaginated.map(o => o.userId)) }, { isModerator: true }); + + setStatus('MOD', getModeratorsPaginated.map(o => o.userId).includes(botId)); + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + return { state: false }; // ignore etimedout error + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + } + return { state: true }; +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getTopClips.ts b/backend/src/services/twitch/calls/getTopClips.ts new file mode 100644 index 000000000..58a396c8d --- /dev/null +++ b/backend/src/services/twitch/calls/getTopClips.ts @@ -0,0 +1,50 @@ +import { shuffle } from '@sogebot/ui-helpers/array.js'; +import { DAY } from '@sogebot/ui-helpers/constants.js'; +import { HelixClip } from '@twurple/api/lib'; + +import { getGameNameFromId } from './getGameNameFromId.js'; + +import { streamStatusChangeSince } from '~/helpers/api/index.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import { debug, error, warning } from '~/helpers/log.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +export async function getTopClips (opts: any): Promise<(Partial & { mp4: string; game: string | null })[]> { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + try { + const period = { + startDate: opts.period === 'stream' + ? (new Date(streamStatusChangeSince.value)).toISOString() + : new Date(Date.now() - opts.days * DAY).toISOString(), + endDate: (new Date()).toISOString(), + }; + + const getClipsForBroadcaster = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.clips.getClipsForBroadcasterPaginated(broadcasterId, { ...period }).getAll()) as unknown as (HelixClip & { mp4: string; game: string | null })[]; + + // get mp4 from thumbnail + const clips: (Partial & { mp4: string; game: string | null })[] = []; + for (const c of getClipsForBroadcaster ?? []) { + c.mp4 = c.thumbnailUrl.replace('-preview-480x272.jpg', '.mp4'); + c.game = await getGameNameFromId(Number(c.gameId)); + clips.push(c); + } + return shuffle(clips).slice(0, opts.first); + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return getTopClips(opts); + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + } + return []; +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/getUserByName.ts b/backend/src/services/twitch/calls/getUserByName.ts new file mode 100644 index 000000000..9197afa94 --- /dev/null +++ b/backend/src/services/twitch/calls/getUserByName.ts @@ -0,0 +1,5 @@ +import twitch from '~/services/twitch.js'; + +export default function getUserByName(userName: string) { + return twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUserByName(userName)); +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/isFollowerUpdate.ts b/backend/src/services/twitch/calls/isFollowerUpdate.ts new file mode 100644 index 000000000..0aee8eaa0 --- /dev/null +++ b/backend/src/services/twitch/calls/isFollowerUpdate.ts @@ -0,0 +1,34 @@ +import { debug, error, warning } from '../../../helpers/log.js'; + +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +export async function isFollowerUpdate (id: string): Promise { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + + try { + const helixFollow = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.channels.getChannelFollowers(broadcasterId, id)); + + if ((helixFollow?.total ?? 0) > 0) { + return new Date(helixFollow!.data[0]!.followDate!).toISOString(); + } + } catch (e: any) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return isFollowerUpdate(id); + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + } + return false; +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/searchCategoriesPaginated.ts b/backend/src/services/twitch/calls/searchCategoriesPaginated.ts new file mode 100644 index 000000000..49029e484 --- /dev/null +++ b/backend/src/services/twitch/calls/searchCategoriesPaginated.ts @@ -0,0 +1,29 @@ +import { HelixGame } from '@twurple/api/lib'; + +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import { debug, error, warning } from '~/helpers/log.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; + +async function searchCategoriesPaginated (game: string): Promise { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + try { + return await twitch.apiClient?.asIntent(['bot'], ctx => ctx.search.searchCategoriesPaginated(game).getAll()) ?? []; + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return searchCategoriesPaginated(game); + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + return []; + } +} + +export { searchCategoriesPaginated }; \ No newline at end of file diff --git a/backend/src/services/twitch/calls/sendGameFromTwitch.ts b/backend/src/services/twitch/calls/sendGameFromTwitch.ts new file mode 100644 index 000000000..edac11f18 --- /dev/null +++ b/backend/src/services/twitch/calls/sendGameFromTwitch.ts @@ -0,0 +1,30 @@ +import { isDebugEnabled } from '~/helpers/debug.js'; +import { debug, error, warning } from '~/helpers/log.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; + +async function sendGameFromTwitch (game: string): Promise { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + try { + const searchCategories = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.search.searchCategoriesPaginated(game).getAll()) ?? []; + return searchCategories.map(o => o.name); + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`sendGameFromTwitch => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return sendGameFromTwitch(game); + } + if (e.message.includes('Cannot initialize Twitch API, bot token invalid')) { + return []; + } else { + error(`sendGameFromTwitch => ${e.stack ?? e.message}`); + } + } + return []; + } +} + +export { sendGameFromTwitch }; \ No newline at end of file diff --git a/backend/src/services/twitch/calls/sendWhisper.ts b/backend/src/services/twitch/calls/sendWhisper.ts new file mode 100644 index 000000000..62091468a --- /dev/null +++ b/backend/src/services/twitch/calls/sendWhisper.ts @@ -0,0 +1,6 @@ +import getBotId from '~/helpers/user/getBotId.js'; +import twitch from '~/services/twitch.js'; + +export default function sendWhisper(userId: string, message: string) { + return twitch.apiClient?.asIntent(['bot'], ctx => ctx.whispers.sendWhisper(getBotId(), userId, message)); +} \ No newline at end of file diff --git a/backend/src/services/twitch/calls/updateBroadcasterType.ts b/backend/src/services/twitch/calls/updateBroadcasterType.ts new file mode 100644 index 000000000..1a2d6bf9b --- /dev/null +++ b/backend/src/services/twitch/calls/updateBroadcasterType.ts @@ -0,0 +1,33 @@ +import { isDebugEnabled } from '~/helpers/debug.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import emitter from '~/helpers/interfaceEmitter.js'; +import { debug, error, warning } from '~/helpers/log.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +async function updateBroadcasterType () { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + try { + const cid = variables.get('services.twitch.broadcasterId') as string; + const getUserById = await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.users.getUserById(cid)); + + if (getUserById) { + emitter.emit('set', '/services/twitch', 'profileImageUrl', getUserById.profilePictureUrl); + emitter.emit('set', '/services/twitch', 'broadcasterType', getUserById.broadcasterType); + } + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + return { state: false }; // ignore etimedout error + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + } + return { state: true }; +} + +export { updateBroadcasterType }; \ No newline at end of file diff --git a/backend/src/services/twitch/calls/updateChannelInfo.ts b/backend/src/services/twitch/calls/updateChannelInfo.ts new file mode 100644 index 000000000..006d94c2a --- /dev/null +++ b/backend/src/services/twitch/calls/updateChannelInfo.ts @@ -0,0 +1,128 @@ +import { error } from 'console'; + +import { defaults, isNil } from 'lodash-es'; + +import { getChannelInformation } from './getChannelInformation.js'; +import { getGameIdFromName } from './getGameIdFromName.js'; + +import { + gameCache, gameOrTitleChangedManually, rawStatus, stats, tagsCache, +} from '~/helpers/api/index.js'; +import { parseTitle } from '~/helpers/api/parseTitle.js'; +import { CONTENT_CLASSIFICATION_LABELS } from '~/helpers/constants.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { eventEmitter } from '~/helpers/events/emitter.js'; +import { getFunctionName } from '~/helpers/getFunctionName.js'; +import { debug, warning } from '~/helpers/log.js'; +import { addUIError } from '~/helpers/panel/index.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import twitch from '~/services/twitch.js'; +import { translate } from '~/translate.js'; +import { variables } from '~/watchers.js'; + +async function updateChannelInfo (args: { title?: string | null; game?: string | null, tags?: string[], contentClassificationLabels?: string[] }): Promise<{ response: string; status: boolean } | null> { + if (isDebugEnabled('api.calls')) { + debug('api.calls', new Error().stack); + } + args = defaults(args, { title: null }, { game: null }); + const cid = variables.get('services.twitch.broadcasterId') as string; + const broadcasterCurrentScopes = variables.get('services.twitch.broadcasterCurrentScopes') as string[]; + + if (!broadcasterCurrentScopes.includes('channel_editor')) { + warning('Missing Broadcaster oAuth scope channel_editor to change game or title. This mean you can have inconsistent game set across Twitch: https://github.com/twitchdev/issues/issues/224'); + addUIError({ name: 'OAUTH', message: 'Missing Broadcaster oAuth scope channel_editor to change game or title. This mean you can have inconsistent game set across Twitch: Twitch Issue # 224' }); + } + if (!broadcasterCurrentScopes.includes('user:edit:broadcast')) { + warning('Missing Broadcaster oAuth scope user:edit:broadcast to change game or title'); + addUIError({ name: 'OAUTH', message: 'Missing Broadcaster oAuth scope user:edit:broadcast to change game or title' }); + return { response: '', status: false }; + } + + let title: string; + let game; + let tags: string[]; + + try { + if (!isNil(args.title)) { + rawStatus.value = args.title; // save raw status to cache, if changing title + } + title = await parseTitle(rawStatus.value); + + if (!isNil(args.game)) { + game = args.game; + gameCache.value = args.game; // save game to cache, if changing game + } else { + game = gameCache.value; + } // we are not setting game -> load last game + + if (!isNil(args.tags)) { + tags = args.tags; + tagsCache.value = JSON.stringify(args.tags); // save tags to cache, if changing tags + } else { + tags = JSON.parse(tagsCache.value) as string[]; + } // we are not setting game -> load last game + + if (!isNil(args.tags)) { + tags = args.tags; + tagsCache.value = JSON.stringify(args.tags); // save tags to cache, if changing tags + } else { + tags = JSON.parse(tagsCache.value) as string[]; + } // we are not setting game -> load last game + + const gameId = await getGameIdFromName(game); + + let contentClassificationLabels: {id: string, is_enabled: boolean}[] | undefined = undefined; + // if content classification is present, do a change, otherwise we are not changing anything + if (args.contentClassificationLabels) { + contentClassificationLabels = []; + for (const id of Object.keys(CONTENT_CLASSIFICATION_LABELS)) { + if (id === 'MatureGame') { + continue; // set automatically + } + contentClassificationLabels.push({ id, is_enabled: args.contentClassificationLabels.includes(id) }); + } + } + + await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.channels.updateChannelInfo(cid, { + title: title ? title : undefined, gameId, tags, contentClassificationLabels, + })); + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('ETIMEDOUT')) { + warning(`${getFunctionName()} => Connection to Twitch timed out. Will retry request.`); + await setImmediateAwait(); + return updateChannelInfo(args); + } else { + error(`${getFunctionName()} => ${e.stack ?? e.message}`); + } + } + return { response: '', status: false }; + } + + const responses: { response: string; status: boolean } = { response: '', status: false }; + + if (!isNil(args.game)) { + responses.response = translate('game.change.success').replace(/\$game/g, args.game); + responses.status = true; + if (stats.value.currentGame !== args.game) { + eventEmitter.emit('game-changed', { oldGame: stats.value.currentGame ?? 'n/a', game: args.game }); + } + stats.value.currentGame = args.game; + } + + if (!isNil(args.title)) { + responses.response = translate('title.change.success').replace(/\$title/g, args.title); + responses.status = true; + stats.value.currentTitle = args.title; + } + + if (!isNil(args.tags)) { + stats.value.currentTags = args.tags; + } + gameOrTitleChangedManually.value = true; + + await getChannelInformation({}); + return responses; +} + +export { updateChannelInfo }; \ No newline at end of file diff --git a/backend/src/services/twitch/chat.ts b/backend/src/services/twitch/chat.ts new file mode 100644 index 000000000..f53a635c4 --- /dev/null +++ b/backend/src/services/twitch/chat.ts @@ -0,0 +1,757 @@ +import util from 'util'; + +import type { EmitData } from '@entity/alert.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import { dayjs } from '@sogebot/ui-helpers/dayjsHelper.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import { + ChatClient, ChatCommunitySubInfo, ChatSubGiftInfo, ChatSubInfo, ChatUser, +} from '@twurple/chat'; +import { isNil } from 'lodash-es'; + +import addModerator from './calls/addModerator.js'; +import banUser from './calls/banUser.js'; +import deleteChatMessages from './calls/deleteChatMessages.js'; +import getUserByName from './calls/getUserByName.js'; +import sendWhisper from './calls/sendWhisper.js'; +import { CustomAuthProvider } from './token/CustomAuthProvider.js'; + +import { + getFunctionList, +} from '~/decorators/on.js'; +import { timer } from '~/decorators.js'; +import * as hypeTrain from '~/helpers/api/hypeTrain.js'; +import { sendMessage } from '~/helpers/commons/sendMessage.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { subscription } from '~/helpers/events/subscription.js'; +import { + triggerInterfaceOnMessage, triggerInterfaceOnSub, +} from '~/helpers/interface/triggers.js'; +import emitter from '~/helpers/interfaceEmitter.js'; +import { warning } from '~/helpers/log.js'; +import { + chatIn, debug, error, info, resub, subcommunitygift, subgift, whisperIn, +} from '~/helpers/log.js'; +import { linesParsedIncrement, setStatus } from '~/helpers/parser.js'; +import { tmiEmitter } from '~/helpers/tmi/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import getNameById from '~/helpers/user/getNameById.js'; +import { isOwner } from '~/helpers/user/index.js'; +import { isBot, isBotId } from '~/helpers/user/isBot.js'; +import { isIgnored, isIgnoredSafe } from '~/helpers/user/isIgnored.js'; +import eventlist from '~/overlays/eventlist.js'; +import { Parser } from '~/parser.js'; +import alerts from '~/registries/alerts.js'; +import { translate } from '~/translate.js'; +import users from '~/users.js'; +import { variables } from '~/watchers.js'; +import joinpart from '~/widgets/joinpart.js'; + +let _connected_channel = ''; + +const ignoreGiftsFromUser = new Map(); +const commandRegexp = new RegExp(/^!\w+$/); +class Chat { + authProvider: CustomAuthProvider; + + shouldConnect = false; + + timeouts: Record = {}; + client: { + bot: ChatClient | null; + broadcaster: ChatClient | null; + } = { + bot: null, + broadcaster: null, + }; + broadcasterWarning = false; + botWarning = false; + + constructor(authProvider: CustomAuthProvider) { + this.emitter(); + this.authProvider = authProvider; + + this.initClient('bot'); + this.initClient('broadcaster'); + + setInterval(() => { + if (this.client.bot && !this.client.bot.isConnected) { + info(`TMI: Found bot disconnected from TMI, reconnecting.`); + this.client.bot.reconnect(); + } + if (this.client.broadcaster && !this.client.broadcaster.isConnected) { + info(`TMI: Found broadcaster disconnected from TMI, reconnecting.`); + this.client.broadcaster.reconnect(); + } + }, constants.MINUTE); + } + + emitter() { + if (!tmiEmitter) { + setTimeout(() => this.emitter(), 10); + return; + } + + tmiEmitter.on('timeout', async (username, duration, is, reason) => { + debug('emitter.timeout', JSON.stringify({ username, duration, is, reason })); + const userId = await users.getIdByName(username); + + banUser(userId, reason ?? '', duration); + + if (is.mod) { + info(`Bot will set mod status for ${username} after ${duration} seconds.`); + setTimeout(() => { + // we need to remod user + addModerator(userId); + }, (duration * 1000) + 1000); + } + }); + tmiEmitter.on('say', async (channel, message, opts) => { + if (typeof (global as any).it === 'function') { + return; + } + debug('emitter.say', JSON.stringify({ channel, message, opts })); + + if (this.client.bot) { + await this.client.bot.say(channel, message, opts); + } else { + throw new Error('Bot client is not available.'); + } + }); + tmiEmitter.on('whisper', async (username, message) => { + debug('emitter.whisper', JSON.stringify({ username, message })); + const userId = await users.getIdByName(username); + sendWhisper(userId, message); + }); + tmiEmitter.on('join', (type) => { + debug('emitter.join', JSON.stringify({ type })); + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + this.join(type, broadcasterUsername); + }); + tmiEmitter.on('reconnect', (type) => { + debug('emitter.reconnect', JSON.stringify({ type })); + this.reconnect(type); + }); + tmiEmitter.on('delete', (msgId) => { + debug('emitter.delete', JSON.stringify({ msgId })); + this.delete(msgId); + }); + tmiEmitter.on('part', (type) => { + debug('emitter.part', JSON.stringify({ type })); + this.part(type); + }); + } + + async initClient (type: 'bot' | 'broadcaster') { + if (typeof (global as any).it === 'function') { + // do nothing if tests + warning('initClient disabled due to mocha test run.'); + return; + } + clearTimeout(this.timeouts[`initClient.${type}`]); + + const isValidToken = type === 'bot' + ? variables.get(`services.twitch.botTokenValid`) + : variables.get(`services.twitch.broadcasterTokenValid`); + const channel = variables.get('services.twitch.broadcasterUsername') as string; + + // wait for initial validation + if (!isValidToken || channel.length === 0) { + this.timeouts[`initClient.${type}`] = setTimeout(() => this.initClient(type), 10 * constants.SECOND); + return; + } + + try { + const client = this.client[type]; + if (client) { + info(`TMI: ${type} quit on initClient`); + await this.client[type]?.quit(); + client.removeListener(); + this.client[type] = null; + } + this.client[type] = new ChatClient({ + rejoinChannelsOnReconnect: true, + authProvider: this.authProvider, + channels: [channel], + isAlwaysMod: true, + authIntents: [type], + logger: { + minLevel: isDebugEnabled('twitch.tmi') ? 'debug' : 'warning', + custom: (level, message) => { + info(`TMI[${type}:${level}]: ${message}`); + }, + }, + }); + + this.loadListeners(type); + await this.client[type]?.connect(); + setTimeout(() => { + this.join(type, channel); + }, 5 * constants.SECOND); + } catch (e: any) { + error(e.stack); + if (type === 'broadcaster' && !this.broadcasterWarning) { + error('Broadcaster oauth is not properly set - subscribers will not be loaded'); + this.broadcasterWarning = true; + } else if (!this.botWarning) { + error('Bot oauth is not properly set'); + this.botWarning = true; + } + } + } + + /* will connect/reconnect bot and broadcaster + * this is called from oauth when channel is changed or initialized + */ + async reconnect (type: 'bot' | 'broadcaster') { + try { + if (!this.shouldConnect) { + setTimeout(() => this.reconnect(type), 1000); + return; + } + const client = this.client[type]; + if (!client) { + throw Error('TMI: cannot reconnect, connection is not established'); + } + + const channel = variables.get('services.twitch.broadcasterUsername') as string; + + info(`TMI: ${type} is reconnecting`); + + client.removeListener(); + await client.part(channel); + await client.connect(); + this.loadListeners(type); + await this.join(type, channel); + } catch (e: any) { + this.initClient(type); // connect properly + } + } + + async join (type: 'bot' | 'broadcaster', channel: string) { + _connected_channel = channel; + const client = this.client[type]; + if (!client) { + info(`TMI: ${type} oauth is not properly set, cannot join`); + } else { + if (channel === '') { + info(`TMI: ${type} is not properly set, cannot join empty channel`); + if (type ==='bot') { + setStatus('TMI', constants.DISCONNECTED); + } + } else { + try { + await client.join(channel); + } catch (e: unknown) { + if (e instanceof Error) { + warning('TMI: ' + e.message + ' for ' + type); + setTimeout(() => this.initClient(type), constants.SECOND * 5); + return; + } + } + info(`TMI: ${type} joined channel ${channel}`); + if (type ==='bot') { + setStatus('TMI', constants.CONNECTED); + } + + emitter.emit('set', '/services/twitch', 'broadcasterUsername', channel); + } + } + } + + async part (type: 'bot' | 'broadcaster') { + if (_connected_channel.length === 0) { + return; + } + const client = this.client[type]; + if (!client) { + info(`TMI: ${type} is not connected in any channel`); + } else { + await client.part(_connected_channel); + info(`TMI: ${type} parted channel ${_connected_channel}`); + } + } + + loadListeners (type: 'bot' | 'broadcaster') { + const client = this.client[type]; + if (!client) { + error('Cannot init listeners for TMI ' + type + 'client'); + error(new Error().stack || ''); + return; + } + client.removeListener(); + + // common for bot and broadcaster + client.onPart((channel, user) => { + if (isBot(user)) { + info(`TMI: ${type} is disconnected from channel`); + setStatus('TMI', constants.DISCONNECTED); + for (const event of getFunctionList('partChannel')) { + (this as any)[event.fName](); + } + } + }); + + client.onAuthenticationFailure(message => { + info(`TMI: ${type} authentication failure, ${message}`); + }); + + client.irc.onDisconnect((manually, reason) => { + setStatus('TMI', constants.DISCONNECTED); + if (manually) { + reason = new Error('Disconnected manually by user'); + } + if (reason) { + info(`TMI: ${type} is disconnected, reason: ${reason}`); + } + }); + + client.irc.onConnect(() => { + setStatus('TMI', constants.CONNECTED); + info(`TMI: ${type} is connected`); + }); + + client.onJoin(async () => { + setStatus('TMI', constants.CONNECTED); + for (const event of getFunctionList('joinChannel')) { + (this as any)[event.fName](); + } + }); + + if (type === 'bot') { + client.onWhisper((_user, message, msg) => { + if (isBotId(msg.userInfo.userId)) { + return; + } + this.message({ + userstate: msg.userInfo, message, isWhisper: true, emotesOffsets: msg.emoteOffsets, isAction: false, isHighlight: false, + }); + linesParsedIncrement(); + }); + + client.onAction((channel, user, message, msg) => { + const userstate = msg.userInfo; + if (isBotId(userstate.userId)) { + return; + } + // strip message from ACTION + message = message.replace('\u0001ACTION ', '').replace('\u0001', ''); + this.message({ userstate, message, id: msg.id, emotesOffsets: msg.emoteOffsets, isAction: true }) + .then(() => { + linesParsedIncrement(); + triggerInterfaceOnMessage({ + sender: userstate, + message, + timestamp: Date.now(), + }); + }); + + eventEmitter.emit('action', { userName: userstate.userName?.toLowerCase() ?? '', source: 'twitch' }); + }); + + client.onMessage(async (_channel, user, message, msg) => { + const userstate = msg.userInfo; + if (isBotId(userstate.userId)) { + return; + } + + this.message({ + userstate, message, + id: msg.id, + emotesOffsets: msg.emoteOffsets, + isAction: false, + isFirstTimeMessage: msg.isFirst, + isHighlight: msg.isHighlight, + }).then(() => { + linesParsedIncrement(); + triggerInterfaceOnMessage({ + sender: userstate, + message, + timestamp: Date.now(), + }); + }); + }); + + client.onChatClear(() => { + eventEmitter.emit('clearchat'); + }); + } else if (type === 'broadcaster') { + client.onSub((_channel, username, subInfo, msg) => { + subscription(username, subInfo, msg.userInfo); + }); + + client.onResub((_channel, username, subInfo, msg) => { + this.resub(username, subInfo, msg.userInfo); + }); + + client.onSubGift((_channel, username, subInfo, msg) => { + this.subgift(username, subInfo, msg.userInfo); + }); + + client.onCommunitySub((_channel, username, subInfo, msg) => { + this.subscriptionGiftCommunity(username, subInfo, msg.userInfo); + }); + } else { + throw Error(`This ${type} is not supported`); + } + } + + @timer() + async resub (username: string, subInfo: ChatSubInfo, userstate: ChatUser) { + try { + const amount = subInfo.months; + const subStreakShareEnabled = typeof subInfo.streak !== 'undefined'; + const streakMonths = subInfo.streak ?? 0; + const tier = (subInfo.isPrime ? 'Prime' : String(Number(subInfo.plan ?? 1000) / 1000)) as EmitData['tier']; + const message = subInfo.message ?? ''; + + if (isIgnored({ userName: username, userId: userstate.userId })) { + return; + } + + const subStreak = subStreakShareEnabled ? streakMonths : 0; + + const user = await changelog.get(userstate.userId); + if (!user) { + changelog.update(userstate.userId, { userName: username }); + this.resub(username, subInfo, userstate); + return; + } + + let profileImageUrl = null; + if (user.profileImageUrl.length === 0) { + const res = await getUserByName(user.userName); + if (res) { + profileImageUrl = res.profilePictureUrl; + } + } + + changelog.update(user.userId, { + ...user, + isSubscriber: true, + subscribedAt: new Date(Number(dayjs().subtract(streakMonths, 'month').unix()) * 1000).toISOString(), + subscribeTier: String(tier), + subscribeCumulativeMonths: amount, + subscribeStreak: subStreak, + profileImageUrl: profileImageUrl ? profileImageUrl : user.profileImageUrl, + }); + + hypeTrain.addSub({ + username: user.userName, + profileImageUrl: profileImageUrl ? profileImageUrl : user.profileImageUrl, + }); + + eventlist.add({ + event: 'resub', + tier: String(tier), + userId: String(userstate.userId), + subStreakShareEnabled, + subStreak, + subStreakName: getLocalizedName(subStreak, translate('core.months')), + subCumulativeMonths: amount, + subCumulativeMonthsName: getLocalizedName(amount, translate('core.months')), + message, + timestamp: Date.now(), + }); + resub(`${username}#${userstate.userId}, streak share: ${subStreakShareEnabled}, streak: ${subStreak}, months: ${amount}, message: ${message}, tier: ${tier}`); + eventEmitter.emit('resub', { + userName: username, + tier: String(tier), + subStreakShareEnabled, + subStreak, + subStreakName: getLocalizedName(subStreak, translate('core.months')), + subCumulativeMonths: amount, + subCumulativeMonthsName: getLocalizedName(amount, translate('core.months')), + message, + }); + alerts.trigger({ + event: 'resub', + name: username, + amount: Number(amount), + tier, + currency: '', + monthsName: getLocalizedName(amount, translate('core.months')), + message, + }); + } catch (e: any) { + error('Error parsing resub event'); + error(util.inspect(userstate)); + error(e.stack); + } + } + + @timer() + async subscriptionGiftCommunity (username: string, subInfo: ChatCommunitySubInfo, userstate: ChatUser) { + try { + const userId = subInfo.gifterUserId ?? ''; + const count = subInfo.count; + + changelog.increment(userId, { giftedSubscribes: Number(count) }); + + const ignoreGifts = ignoreGiftsFromUser.get(userId) ?? 0; + ignoreGiftsFromUser.set(userId, ignoreGifts + count); + + if (isIgnored({ userName: username, userId })) { + return; + } + + eventlist.add({ + event: 'subcommunitygift', + userId: userId, + count, + timestamp: Date.now(), + }); + eventEmitter.emit('subcommunitygift', { userName: username, count }); + subcommunitygift(`${username}#${userId}, to ${count} viewers`); + alerts.trigger({ + event: 'subcommunitygift', + name: username, + amount: Number(count), + tier: null, + currency: '', + monthsName: '', + message: '', + }); + } catch (e: any) { + error('Error parsing subscriptionGiftCommunity event'); + error(util.inspect({ userstate, subInfo })); + error(e.stack); + } + } + + @timer() + async subgift (recipient: string | null, subInfo: ChatSubGiftInfo, userstate: ChatUser) { + try { + const username = subInfo.gifter ?? ''; + const userId = subInfo.gifterUserId ?? '0'; + const amount = subInfo.months; + const recipientId = subInfo.userId; + const tier = (subInfo.isPrime ? 1 : (Number(subInfo.plan ?? 1000) / 1000)); + + const ignoreGifts = (ignoreGiftsFromUser.get(userId) ?? 0); + let isGiftIgnored = false; + + if (!recipient) { + recipient = await getNameById(recipientId); + } + changelog.update(recipientId, { userId: recipientId, userName: recipient }); + const user = await changelog.get(recipientId); + + if (!user) { + this.subgift(recipient, subInfo, userstate); + return; + } + + if (ignoreGifts > 0) { + isGiftIgnored = true; + ignoreGiftsFromUser.set(userId, ignoreGifts - 1); + } + + if (!isGiftIgnored) { + debug('tmi.subgift', `Triggered: ${username}#${userId} -> ${recipient}#${recipientId}`); + alerts.trigger({ + event: 'subgift', + name: username, + recipient, + amount: amount, + tier: null, + currency: '', + monthsName: getLocalizedName(amount, translate('core.months')), + message: '', + }); + eventEmitter.emit('subgift', { + userName: username, recipient: recipient, tier, + }); + triggerInterfaceOnSub({ + userName: recipient, + userId: recipientId, + subCumulativeMonths: 0, + }); + } else { + debug('tmi.subgift', `Ignored: ${username}#${userId} -> ${recipient}#${recipientId}`); + } + if (isIgnored({ userName: username, userId: recipientId })) { + return; + } + + changelog.update(user.userId, { + ...user, + isSubscriber: true, + subscribedAt: new Date().toISOString(), + subscribeTier: String(tier), + subscribeCumulativeMonths: amount, + subscribeStreak: user.subscribeStreak + 1, + }); + + eventlist.add({ + event: 'subgift', + userId: recipientId, + fromId: userId, + monthsName: getLocalizedName(amount, translate('core.months')), + months: amount, + timestamp: Date.now(), + }); + subgift(`${recipient}#${recipientId}, from: ${username}#${userId}, months: ${amount}`); + + // also set subgift count to gifter + if (!(isIgnored({ userName: username, userId })) && !isGiftIgnored) { + changelog.increment(userId, { giftedSubscribes: 1 }); + } + } catch (e: any) { + error('Error parsing subgift event'); + error(util.inspect(userstate)); + error(e.stack); + } + } + + delete (msgId: string): void { + deleteChatMessages(msgId); + } + + @timer() + async message (data: { skip?: boolean, quiet?: boolean, message: string, userstate: ChatUser, id?: string, isHighlight?: boolean, isWhisper?: boolean, emotesOffsets?: Map, isAction: boolean, isFirstTimeMessage?: boolean }) { + data.emotesOffsets ??= new Map(); + data.isAction ??= false; + data.isFirstTimeMessage ??= false; + + let userId = data.userstate.userId as string | undefined; + const userName = data.userstate.userName as string | undefined; + const userstate = data.userstate; + const message = data.message; + const skip = data.skip ?? false; + const quiet = data.quiet; + + if (!userId && userName) { + // this can happen if we are sending commands from dashboards etc. + userId = String(await users.getIdByName(userName)); + } + + const parse = new Parser({ + sender: userstate, + message: message, + skip: skip, + quiet: quiet, + id: data.id, + emotesOffsets: data.emotesOffsets, + isAction: data.isAction, + isFirstTimeMessage: data.isFirstTimeMessage, + isHighlight: data.isHighlight, + }); + + const whisperListener = variables.get('services.twitch.whisperListener') as boolean; + let additionalInfo = '+'; + if (userstate.isVip) { + additionalInfo += 'V'; + } + if (userstate.isMod) { + additionalInfo += 'M'; + } + if (userstate.isArtist) { + additionalInfo += 'A'; + } + if (userstate.isBroadcaster) { + additionalInfo += 'B'; + } + if (userstate.isFounder) { + additionalInfo += 'F'; + } + if (userstate.isSubscriber) { + additionalInfo += 'S'; + } + + if (!skip + && data.isWhisper + && (whisperListener || isOwner(userstate))) { + whisperIn(`${message} [${userName}${additionalInfo.length > 1 ? additionalInfo : ''}]`); + } else if (!skip && !isBotId(userId)) { + if (data.isHighlight) { + if (userId) { + eventEmitter.emit('highlight', { userId, message }); + } + chatIn(`**${message}** [${userName}${additionalInfo.length > 1 ? additionalInfo : ''}]`); + } else { + chatIn(`${message} [${userName}${additionalInfo.length > 1 ? additionalInfo : ''}]`); + } + } + + if (commandRegexp.test(message)) { + // check only if ignored if it is just simple command + if (await isIgnored({ userName: userName ?? '', userId: userId })) { + return; + } + } else { + const isIgnoredSafeCheck = await isIgnoredSafe({ userName: userName ?? '', userId: userId }); + if (isIgnoredSafeCheck) { + return; + } + // we need to moderate ignored users as well + const [isModerated, isIgnoredCheck] = await Promise.all( + [parse.isModerated(), isIgnored({ userName: userName ?? '', userId: userId })], + ); + if (isModerated || isIgnoredCheck) { + return; + } + } + + // trigger plugins + (await import('../../plugins.js')).default.trigger('message', message, userstate); + + if (!skip && !isNil(userName)) { + const user = await changelog.get(userstate.userId); + if (user) { + if (!user.isOnline) { + joinpart.send({ users: [userName], type: 'join' }); + eventEmitter.emit('user-joined-channel', { userName: userName }); + } + + changelog.update(user.userId, { + ...user, + userName: userName, + userId: userstate.userId, + isOnline: true, + isVIP: userstate.isVip, + isModerator: userstate.isMod, + isSubscriber: user.haveSubscriberLock ? user.isSubscriber : userstate.isSubscriber || userstate.isFounder, + messages: user.messages ?? 0, + seenAt: new Date().toISOString(), + }); + } else { + joinpart.send({ users: [userName], type: 'join' }); + eventEmitter.emit('user-joined-channel', { userName: userName }); + changelog.update(userstate.userId, { + userName: userName, + userId: userstate.userId, + isOnline: true, + isVIP: userstate.isVip, + isModerator: userstate.isMod, + isSubscriber: userstate.isSubscriber || userstate.isFounder, + seenAt: new Date().toISOString(), + }); + } + + eventEmitter.emit('keyword-send-x-times', { + userName: userName, message: message, source: 'twitch', + }); + if (message.startsWith('!')) { + eventEmitter.emit('command-send-x-times', { + userName: userName, message: message, source: 'twitch', + }); + } else if (!message.startsWith('!')) { + changelog.increment(userstate.userId, { messages: 1 }); + } + } + + if (data.isFirstTimeMessage) { + eventEmitter.emit('chatter-first-message', { + userName: userName ?? '', message: message, source: 'twitch', + }); + } + const responses = await parse.process(); + for (let i = 0; i < responses.length; i++) { + await sendMessage(responses[i].response, responses[i].sender, { ...responses[i].attr }, parse.id); + } + } +} + +export default Chat; diff --git a/backend/src/services/twitch/eventSubLongPolling.ts b/backend/src/services/twitch/eventSubLongPolling.ts new file mode 100644 index 000000000..4e9f9ac07 --- /dev/null +++ b/backend/src/services/twitch/eventSubLongPolling.ts @@ -0,0 +1,602 @@ +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { EventSubChannelBanEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelBanEvent.external'; +import { EventSubChannelCharityCampaignProgressEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelCharityCampaignProgressEvent.external.js'; +import { EventSubChannelCharityCampaignStartEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelCharityCampaignStartEvent.external.js'; +import { EventSubChannelCharityCampaignStopEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelCharityCampaignStopEvent.external.js'; +import { EventSubChannelCharityDonationEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelCharityDonationEvent.external.js'; +import { EventSubChannelCheerEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelCheerEvent.external'; +import { EventSubChannelFollowEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelFollowEvent.external'; +import { EventSubChannelGoalBeginEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelGoalBeginEvent.external.js'; +import { EventSubChannelGoalEndEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelGoalEndEvent.external.js'; +import { EventSubChannelGoalProgressEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelGoalProgressEvent.external.js'; +import { EventSubChannelHypeTrainBeginEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelHypeTrainBeginEvent.external'; +import { EventSubChannelHypeTrainEndEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelHypeTrainEndEvent.external'; +import { EventSubChannelHypeTrainProgressEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelHypeTrainProgressEvent.external'; +import { EventSubChannelModeratorEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelModeratorEvent.external'; +import { EventSubChannelPollBeginEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPollBeginEvent.external'; +import { EventSubChannelPollEndEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPollEndEvent.external'; +import { EventSubChannelPollProgressEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPollProgressEvent.external'; +import { EventSubChannelPredictionBeginEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPredictionBeginEvent.external'; +import { EventSubChannelPredictionEndEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPredictionEndEvent.external'; +import { EventSubChannelPredictionLockEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPredictionLockEvent.external'; +import { EventSubChannelPredictionProgressEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelPredictionProgressEvent.external'; +import { EventSubChannelRaidEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelRaidEvent.external'; +import { EventSubChannelRedemptionAddEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelRedemptionAddEvent.external'; +import { EventSubChannelRedemptionUpdateEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelRedemptionUpdateEvent.external.js'; +import { EventSubChannelRewardEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelRewardEvent.external.js'; +import { EventSubChannelShieldModeBeginEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelShieldModeBeginEvent.external.js'; +import { EventSubChannelShoutoutCreateEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelShoutoutCreateEvent.external.js'; +import { EventSubChannelUnbanEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelUnbanEvent.external'; +import { EventSubChannelUpdateEventData } from '@twurple/eventsub-base/lib/events/EventSubChannelUpdateEvent.external.js'; +import { EventSubUserUpdateEventData } from '@twurple/eventsub-base/lib/events/EventSubUserUpdateEvent.external.js'; +import { Mutex } from 'async-mutex'; +import axios from 'axios'; + +import { isAlreadyProcessed } from './eventsub/events.js'; + +import * as channelPoll from '~/helpers/api/channelPoll.js'; +import * as channelPrediction from '~/helpers/api/channelPrediction.js'; +import * as hypeTrain from '~/helpers/api/hypeTrain.js'; +import { dayjs } from '~/helpers/dayjsHelper.js'; +import { cheer } from '~/helpers/events/cheer.js'; +import { follow } from '~/helpers/events/follow.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { raid } from '~/helpers/events/raid.js'; +import { ban, error, info, redeem, timeout, unban, warning } from '~/helpers/log.js'; +import { ioServer } from '~/helpers/panel.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import getBroadcasterId from '~/helpers/user/getBroadcasterId.js'; +import eventlist from '~/overlays/eventlist.js'; +import { Types } from '~/plugins/ListenTo.js'; +import alerts from '~/registries/alerts.js'; +import { variables } from '~/watchers.js'; + +const mutex = new Mutex(); + +let lastMessage = ''; +class EventSubLongPolling { + constructor() { + info('EVENTSUB: Long-polling initiated.'); + warning('--------------------------------------------------------------------------------------------- !'); + warning('EVENTSUB: If you updated a bot, you need to login into https://dash.sogebot.xyz at least once !'); + warning(' to have eventsub long-polling working correctly! !'); + warning('--------------------------------------------------------------------------------------------- !'); + + setInterval(async () => { + // polling + const tokenValid = variables.get(`services.twitch.broadcasterTokenValid`); + if (!mutex.isLocked() && tokenValid) { + const release = await mutex.acquire(); + try { + const response = await axios.get('https://eventsub.sogebot.xyz/user', { + timeout: 2 * MINUTE, + headers: { + 'sogebot-event-userid': getBroadcasterId(), + }, + }); + + if (response.status === 200) { + if (isAlreadyProcessed(response.data.event)) { + release(); + return; + } + + info(`EVENTSUB: Received event ${response.data.subscription.type}.`); + const availableEvents = { + 'channel.channel_points_custom_reward_redemption.add': this.onChannelRedemptionAdd, + 'channel.channel_points_custom_reward_redemption.update': this.onChannelRedemptionUpdate, + 'channel.follow': this.onChannelFollow, + 'channel.cheer': this.onChannelCheer, + 'channel.raid': this.onChannelRaid, + 'channel.ban': this.onChannelBan, + 'channel.unban': this.onChannelUnban, + 'channel.prediction.begin': this.onChannelPredictionBegin, + 'channel.prediction.progress': this.onChannelPredictionProgress, + 'channel.prediction.lock': this.onChannelPredictionLock, + 'channel.prediction.end': this.onChannelPredictionEnd, + 'channel.poll.begin': this.onChannelPollBegin, + 'channel.poll.progress': this.onChannelPollProgress, + 'channel.poll.end': this.onChannelPollEnd, + 'channel.hype_train.begin': this.onChannelHypeTrainBegin, + 'channel.hype_train.progress': this.onChannelHypeTrainProgress, + 'channel.hype_train.end': this.onChannelHypeTrainEnd, + 'channel.charity_campaign.donate': this.onChannelCharityCampaignDonate, + 'channel.charity_campaign.start': this.onChannelCharityCampaignStart, + 'channel.charity_campaign.progress': this.onChannelCharityCampaignProgress, + 'channel.charity_campaign.stop': this.onChannelCharityCampaignStop, + 'channel.goal.begin': this.onChannelGoalBegin, + 'channel.goal.progress': this.onChannelGoalProgress, + 'channel.goal.end': this.onChannelGoalEnd, + 'channel.moderator.add': this.onChannelModeratorAdd, + 'channel.moderator.remove': this.onChannelModeratorRemove, + 'channel.channel_points_custom_reward.add': this.onChannelRewardAdd, + 'channel.channel_points_custom_reward.update': this.onChannelRewardUpdate, + 'channel.channel_points_custom_reward.remove': this.onChannelRewardRemove, + 'channel.shield_mode.begin': this.onChannelShieldModeBegin, + 'channel.shield_mode.end': this.onChannelShieldModeEnd, + 'channel.shoutout.create': this.onChannelShoutoutCreate, + 'channel.shoutout.receive': this.onChannelShoutoutReceive, + 'channel.update': this.onChannelUpdate, + 'user.update': this.onUserUpdate, + } as const; + if (availableEvents[response.data.subscription.type as keyof typeof availableEvents]) { + availableEvents[response.data.subscription.type as keyof typeof availableEvents](response.data.event); + } + } else if (response.status === 204) { + if (process.env.NODE_ENV === 'development') { + info(`EVENTSUB: No event received during long-polling.`); + } + } else { + throw new Error('Unexpected status received: ' + response.status); + } + lastMessage = ''; + } catch (e) { + if (e instanceof Error) { + if (e.message !== lastMessage) { + error(`EVENTSUB: ${e}`); + lastMessage = e.message; + } + } + } + release(); + } + }, 200); + } + + onChannelRedemptionAdd(event: EventSubChannelRedemptionAddEventData) { + // trigger reward-redeemed event + if (event.user_input.length > 0) { + redeem(`${ event.user_login }#${ event.user_id } redeemed ${ event.reward.title }: ${ event.user_input }`); + } else { + redeem(`${ event.user_login }#${ event.user_id } redeemed ${ event.reward.title }`); + } + + changelog.update(event.user_id, { + userId: event.user_id, userName: event.user_login, displayname: event.user_name, + }); + + eventlist.add({ + event: 'rewardredeem', + userId: String(event.user_id), + message: event.user_input, + timestamp: Date.now(), + titleOfReward: event.reward.title, + rewardId: event.reward.id, + }); + alerts.trigger({ + event: 'rewardredeem', + name: event.reward.title, + rewardId: event.reward.id, + amount: 0, + tier: null, + currency: '', + monthsName: '', + message: event.user_input, + recipient: event.user_name, + }); + eventEmitter.emit('reward-redeemed', { + userId: event.user_id, + userName: event.user_name, + rewardId: event.reward.id, + userInput: event.user_input, + }); + } + + onChannelFollow(event: EventSubChannelFollowEventData) { + follow(event.user_id, event.user_login, new Date(event.followed_at).toISOString()); + } + + onChannelCheer(event: EventSubChannelCheerEventData) { + cheer(event); + } + + onChannelRaid(event: EventSubChannelRaidEventData) { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + + if (event.from_broadcaster_user_id === broadcasterId) { + // raiding + eventEmitter.emit(Types.onChannelRaidFrom, { + raidedBroadcasterDisplayName: event.to_broadcaster_user_name, + raidedBroadcasterName: event.to_broadcaster_user_login, + raidedBroadcasterId: event.to_broadcaster_user_id, + raidingBroadcasterDisplayName: event.from_broadcaster_user_name, + raidingBroadcasterName: event.from_broadcaster_user_name, + raidingBroadcasterId: event.from_broadcaster_user_id, + viewers: event.viewers, + }); + } else { + // getting raided + raid(event); + } + } + + onChannelBan(event: EventSubChannelBanEventData) { + const userName = event.user_login; + const userId = event.user_id; + const createdBy = event.moderator_user_login; + const createdById = event.moderator_user_id; + const reason = event.reason; + const ends_at = event.ends_at ? dayjs(event.ends_at) : null; + if (ends_at) { + const duration = dayjs.duration(ends_at.diff(dayjs(event.banned_at))); + timeout(`${ userName }#${ userId } by ${ createdBy }#${ createdById } for ${ duration.asSeconds() } seconds`); + eventEmitter.emit('timeout', { userName, duration: duration.asSeconds() }); + } else { + ban(`${ userName }#${ userId } by ${ createdBy }: ${ reason ? reason : '' }`); + eventEmitter.emit('ban', { userName, reason: reason ? reason : '' }); + } + } + + onChannelUnban(event: EventSubChannelUnbanEventData) { + unban(`${ event.user_login }#${ event.user_id } by ${ event.moderator_user_login }#${ event.moderator_user_id }`); + } + + onChannelPredictionBegin(event: EventSubChannelPredictionBeginEventData) { + channelPrediction.start(event); + } + + onChannelPredictionProgress(event: EventSubChannelPredictionProgressEventData) { + channelPrediction.progress(event); + } + + onChannelPredictionLock(event: EventSubChannelPredictionLockEventData) { + channelPrediction.lock(event); + } + + onChannelPredictionEnd(event: EventSubChannelPredictionEndEventData) { + channelPrediction.end(event); + } + + onChannelPollBegin(event: EventSubChannelPollBeginEventData) { + channelPoll.setData(event); + channelPoll.triggerPollStart(); + } + + onChannelPollProgress(event: EventSubChannelPollProgressEventData) { + channelPoll.setData(event); + } + + onChannelPollEnd(event: EventSubChannelPollEndEventData) { + channelPoll.setData(event); + channelPoll.triggerPollEnd(); + } + + onChannelHypeTrainBegin(event: EventSubChannelHypeTrainBeginEventData) { + hypeTrain.setIsStarted(true); + hypeTrain.setCurrentLevel(1); + eventEmitter.emit('hypetrain-started'); + } + + onChannelHypeTrainProgress(event: EventSubChannelHypeTrainProgressEventData) { + hypeTrain.setIsStarted(true); + hypeTrain.setTotal(event.total); + hypeTrain.setGoal(event.goal); + for (const top of (event.top_contributions ?? [])) { + if (top.type === 'other') { + continue; + } + hypeTrain.setTopContributions(top.type, top.total, top.user_id, top.user_login); + } + + if (event.last_contribution.type !== 'other') { + hypeTrain.setLastContribution(event.last_contribution.total, event.last_contribution.type, event.last_contribution.user_id, event.last_contribution.user_login); + } + hypeTrain.setCurrentLevel(event.level); + + // update overlay + ioServer?.of('/services/twitch').emit('hypetrain-update', { + id: event.id, total: event.total, goal: event.goal, level: event.level, subs: Object.fromEntries(hypeTrain.subs), + }); + } + + onChannelHypeTrainEnd(event: EventSubChannelHypeTrainEndEventData) { + hypeTrain.triggerHypetrainEnd().then(() => { + hypeTrain.setTotal(0); + hypeTrain.setGoal(0); + hypeTrain.setLastContribution(0, 'bits', null, null); + hypeTrain.setTopContributions('bits', 0, null, null); + hypeTrain.setTopContributions('subscription', 0, null, null); + hypeTrain.setCurrentLevel(1); + ioServer?.of('/services/twitch').emit('hypetrain-end'); + }); + } + + onChannelCharityCampaignDonate(event: EventSubChannelCharityDonationEventData) { + eventEmitter.emit(Types.onChannelCharityDonation, { + broadcasterDisplayName: event.broadcaster_name, + broadcasterId: event.broadcaster_id, + broadcasterName: event.broadcaster_login, + charityDescription: event.charity_description, + charityLogo: event.charity_logo, + charityName: event.charity_name, + charityWebsite: event.charity_website, + campaignId: event.campaign_id, + donorDisplayName: event.user_name, + donorId: event.user_id, + donorName: event.user_login, + amount: event.amount.value * event.amount.decimal_places, + amountCurrency: event.amount.currency, + }); + } + + onChannelCharityCampaignStart(event: EventSubChannelCharityCampaignStartEventData) { + eventEmitter.emit(Types.onChannelCharityCampaignStart, { + broadcasterDisplayName: event.broadcaster_name, + broadcasterId: event.broadcaster_id, + broadcasterName: event.broadcaster_login, + charityDescription: event.charity_description, + charityLogo: event.charity_logo, + charityName: event.charity_name, + charityWebsite: event.charity_website, + currentAmount: event.current_amount.value * event.current_amount.decimal_places, + currentAmountCurrency: event.current_amount.currency, + targetAmount: event.target_amount.value * event.target_amount.decimal_places, + targetAmountCurrency: event.target_amount.currency, + startDate: new Date(event.started_at).toISOString(), + }); + } + + onChannelCharityCampaignProgress(event: EventSubChannelCharityCampaignProgressEventData) { + eventEmitter.emit(Types.onChannelCharityCampaignProgress, { + broadcasterDisplayName: event.broadcaster_name, + broadcasterId: event.broadcaster_id, + broadcasterName: event.broadcaster_login, + charityDescription: event.charity_description, + charityLogo: event.charity_logo, + charityName: event.charity_name, + charityWebsite: event.charity_website, + currentAmount: event.current_amount.value * event.current_amount.decimal_places, + currentAmountCurrency: event.current_amount.currency, + targetAmount: event.target_amount.value * event.target_amount.decimal_places, + targetAmountCurrency: event.target_amount.currency, + }); + } + + onChannelCharityCampaignStop(event: EventSubChannelCharityCampaignStopEventData) { + eventEmitter.emit(Types.onChannelCharityCampaignStop, { + broadcasterDisplayName: event.broadcaster_name, + broadcasterId: event.broadcaster_id, + broadcasterName: event.broadcaster_login, + charityDescription: event.charity_description, + charityLogo: event.charity_logo, + charityName: event.charity_name, + charityWebsite: event.charity_website, + currentAmount: event.current_amount.value * event.current_amount.decimal_places, + currentAmountCurrency: event.current_amount.currency, + targetAmount: event.target_amount.value * event.target_amount.decimal_places, + targetAmountCurrency: event.target_amount.currency, + endDate: new Date(event.stopped_at).toISOString(), + }); + } + + onChannelGoalBegin(event: EventSubChannelGoalBeginEventData) { + eventEmitter.emit(Types.onChannelGoalBegin, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + currentAmount: event.current_amount, + description: event.description, + startDate: new Date(event.started_at).toISOString(), + targetAmount: event.target_amount, + type: event.type, + }); + } + + onChannelGoalProgress(event: EventSubChannelGoalProgressEventData) { + eventEmitter.emit(Types.onChannelGoalProgress, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + currentAmount: event.current_amount, + description: event.description, + startDate: new Date(event.started_at).toISOString(), + targetAmount: event.target_amount, + type: event.type, + }); + } + + onChannelGoalEnd(event: EventSubChannelGoalEndEventData) { + eventEmitter.emit(Types.onChannelGoalEnd, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + currentAmount: event.current_amount, + description: event.description, + startDate: new Date(event.started_at).toISOString(), + endDate: new Date(event.ended_at).toISOString(), + targetAmount: event.target_amount, + type: event.type, + isAchieved: event.is_achieved, + }); + } + + onChannelModeratorAdd(event: EventSubChannelModeratorEventData) { + eventEmitter.emit(Types.onChannelModeratorAdd, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + userDisplayName: event.user_name, + userId: event.user_id, + userName: event.user_login, + }); + } + + onChannelModeratorRemove(event: EventSubChannelModeratorEventData) { + eventEmitter.emit(Types.onChannelModeratorRemove, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + userDisplayName: event.user_name, + userId: event.user_id, + userName: event.user_login, + }); + } + + onChannelRewardAdd(event: EventSubChannelRewardEventData) { + eventEmitter.emit(Types.onChannelRewardAdd, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + autoApproved: event.should_redemptions_skip_request_queue, + backgroundColor: event.background_color, + cooldownExpiryDate: event.cooldown_expires_at ? new Date(event.cooldown_expires_at).toISOString() : null, + cost: event.cost, + globalCooldown: event.global_cooldown.is_enabled ? event.global_cooldown.seconds : null, + id: event.id, + isEnabled: event.is_enabled, + isInStock: event.is_in_stock, + isPaused: event.is_paused, + maxRedemptionsPerStream: event.max_per_stream.is_enabled ? event.max_per_stream.value : null, + maxRedemptionsPerUserPerStream: event.max_per_user_per_stream.is_enabled ? event.max_per_user_per_stream.value : null, + prompt: event.prompt, + redemptionsThisStream: event.redemptions_redeemed_current_stream, + title: event.title, + userInputRequired: event.is_user_input_required, + }); + } + + onChannelRewardUpdate(event: EventSubChannelRewardEventData) { + eventEmitter.emit(Types.onChannelRewardUpdate, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + autoApproved: event.should_redemptions_skip_request_queue, + backgroundColor: event.background_color, + cooldownExpiryDate: event.cooldown_expires_at ? new Date(event.cooldown_expires_at).toISOString() : null, + cost: event.cost, + globalCooldown: event.global_cooldown.is_enabled ? event.global_cooldown.seconds : null, + id: event.id, + isEnabled: event.is_enabled, + isInStock: event.is_in_stock, + isPaused: event.is_paused, + maxRedemptionsPerStream: event.max_per_stream.is_enabled ? event.max_per_stream.value : null, + maxRedemptionsPerUserPerStream: event.max_per_user_per_stream.is_enabled ? event.max_per_user_per_stream.value : null, + prompt: event.prompt, + redemptionsThisStream: event.redemptions_redeemed_current_stream, + title: event.title, + userInputRequired: event.is_user_input_required, + }); + } + + onChannelRewardRemove(event: EventSubChannelRewardEventData) { + eventEmitter.emit(Types.onChannelRewardRemove, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + autoApproved: event.should_redemptions_skip_request_queue, + backgroundColor: event.background_color, + cooldownExpiryDate: event.cooldown_expires_at ? new Date(event.cooldown_expires_at).toISOString() : null, + cost: event.cost, + globalCooldown: event.global_cooldown.is_enabled ? event.global_cooldown.seconds : null, + id: event.id, + isEnabled: event.is_enabled, + isInStock: event.is_in_stock, + isPaused: event.is_paused, + maxRedemptionsPerStream: event.max_per_stream.is_enabled ? event.max_per_stream.value : null, + maxRedemptionsPerUserPerStream: event.max_per_user_per_stream.is_enabled ? event.max_per_user_per_stream.value : null, + prompt: event.prompt, + redemptionsThisStream: event.redemptions_redeemed_current_stream, + title: event.title, + userInputRequired: event.is_user_input_required, + }); + } + + onChannelShieldModeBegin(event: EventSubChannelShieldModeBeginEventData) { + eventEmitter.emit(Types.onChannelShieldModeBegin, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + moderatorDisplayName: event.moderator_user_name, + moderatorId: event.moderator_user_id, + moderatorName: event.moderator_user_login, + }); + } + + onChannelShieldModeEnd(event: EventSubChannelShieldModeBeginEventData) { + eventEmitter.emit(Types.onChannelShieldModeEnd, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + moderatorDisplayName: event.moderator_user_name, + moderatorId: event.moderator_user_id, + moderatorName: event.moderator_user_login, + endDate: new Date(event.started_at).toISOString(), + }); + } + + onChannelShoutoutCreate(event: EventSubChannelShoutoutCreateEventData) { + eventEmitter.emit(Types.onChannelShoutoutCreate, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + moderatorDisplayName: event.moderator_user_name, + moderatorId: event.moderator_user_id, + moderatorName: event.moderator_user_login, + cooldownEndDate: new Date(event.cooldown_ends_at).toISOString(), + startDate: new Date(event.started_at).toISOString(), + viewerCount: event.viewer_count, + shoutedOutBroadcasterDisplayName: event.to_broadcaster_user_name, + shoutedOutBroadcasterId: event.to_broadcaster_user_id, + shoutedOutBroadcasterName: event.to_broadcaster_user_login, + }); + } + + onChannelShoutoutReceive(event: EventSubChannelShoutoutCreateEventData) { + eventEmitter.emit(Types.onChannelShoutoutReceive, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + startDate: new Date(event.started_at).toISOString(), + viewerCount: event.viewer_count, + shoutingOutBroadcasterDisplayName: event.to_broadcaster_user_name, + shoutingOutBroadcasterId: event.to_broadcaster_user_id, + shoutingOutBroadcasterName: event.to_broadcaster_user_login, + }); + } + + onChannelUpdate(event: EventSubChannelUpdateEventData) { + eventEmitter.emit(Types.onChannelUpdate, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + categoryId: event.category_id, + categoryName: event.category_name, + isMature: event.is_mature, + streamLanguage: event.language, + streamTitle: event.title, + }); + } + + onUserUpdate(event: EventSubUserUpdateEventData) { + eventEmitter.emit(Types.onUserUpdate, { + userDescription: event.description, + userDisplayName: event.user_name, + userId: event.user_id, + userEmail: event.email ?? null, + userName: event.user_login, + userEmailIsVerified: event.email_verified, + }); + } + + onChannelRedemptionUpdate(event: EventSubChannelRedemptionUpdateEventData) { + eventEmitter.emit(Types.onChannelRedemptionUpdate, { + broadcasterDisplayName: event.broadcaster_user_name, + broadcasterId: event.broadcaster_user_id, + broadcasterName: event.broadcaster_user_login, + id: event.id, + input: event.user_input, + redemptionDate: new Date(event.redeemed_at).toISOString(), + rewardCost: event.reward.cost, + rewardId: event.reward.id, + rewardPrompt: event.reward.prompt, + rewardTitle: event.reward.title, + status: event.status, + userDisplayName: event.user_name, + userId: event.user_id, + userName: event.user_login, + + }); + } +} + +export default EventSubLongPolling; \ No newline at end of file diff --git a/backend/src/services/twitch/eventSubWebsocket.ts b/backend/src/services/twitch/eventSubWebsocket.ts new file mode 100644 index 000000000..82842f39b --- /dev/null +++ b/backend/src/services/twitch/eventSubWebsocket.ts @@ -0,0 +1,720 @@ +import { ApiClient } from '@twurple/api'; +import { rawDataSymbol } from '@twurple/common'; +import { EventSubWsListener } from '@twurple/eventsub-ws'; + +import { isAlreadyProcessed } from './eventsub/events.js'; + +import * as channelPoll from '~/helpers/api/channelPoll.js'; +import * as channelPrediction from '~/helpers/api/channelPrediction.js'; +import * as hypeTrain from '~/helpers/api/hypeTrain.js'; +import { dayjs } from '~/helpers/dayjsHelper.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { cheer } from '~/helpers/events/cheer.js'; +import { follow } from '~/helpers/events/follow.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { raid } from '~/helpers/events/raid.js'; +import { ban, error, info, redeem, timeout, unban, warning } from '~/helpers/log.js'; +import { ioServer } from '~/helpers/panel.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import eventlist from '~/overlays/eventlist.js'; +import { Types } from '~/plugins/ListenTo.js'; +import alerts from '~/registries/alerts.js'; +import { variables } from '~/watchers.js'; + +let keepAliveCount: null | number = null; + +setInterval(() => { + if (keepAliveCount !== null) { + keepAliveCount--; + } +}, 10000); + +export const broadcasterMissingScopes: string[] = []; +const CHANNEL_READ_CHARITY = 'channel:read:charity' as const; +const CHANNEL_READ_GOALS = 'channel:read:goals' as const; +const MODERATOR_READ_SHIELD_MODE = 'moderator:read:shield_mode' as const; +const MODERATOR_READ_SHOUTOUTS = 'moderator:read:shoutouts' as const; + +const runIfScopeIsApproved = (scopes: string[], scope: string, callback: () => void) => { + if (scopes.includes(scope)) { + if (broadcasterMissingScopes.includes(scope)) { + broadcasterMissingScopes.splice(broadcasterMissingScopes.findIndex(o => o === scope), 1); + } + callback(); + } else { + if (!broadcasterMissingScopes.includes(scope)) { + broadcasterMissingScopes.push(scope); + } + } +}; + +class EventSubWebsocket { + listener: EventSubWsListener; + listenerBroadcasterId?: string; + reconnection = false; + + constructor(apiClient: ApiClient) { + this.listener = new EventSubWsListener({ + apiClient, + logger: { + minLevel: 'trace', + custom: (level, message) => { + if (message.includes('"message_type":"session_keepalive"')) { + keepAliveCount = 0; + } else { + if (isDebugEnabled('twitch.eventsub')) { + info(`EVENTSUB-WS[${level}]: ${message}`); + } + } + }, + }, + }); + + setInterval(() => { + // check if we have keepAliveCount around 0 + if (!keepAliveCount) { + return; + } + if (keepAliveCount < -2) { + // we didn't get keepAlive for 20 seconds -> reconnecting + keepAliveCount = null; + // set as reconnection + this.reconnection = true; + this.listener.stop(); + this.listener.start(); + } + }, 10000); + + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + + this.listener.onSubscriptionDeleteSuccess((ev) => { + info(`EVENTSUB-WS: Subscription ${ev.id} removed.`); + }); + this.listener.onSubscriptionCreateSuccess((ev) => { + if (!this.reconnection || isDebugEnabled('twitch.eventsub')) { + info(`EVENTSUB-WS: Subscription ${ev.id} added.`); + } + }); + this.listener.onSubscriptionCreateFailure((ev, err) => { + error(`EVENTSUB-WS: Subscription create failure: ${err}`); + }); + this.listener.onSubscriptionDeleteFailure((ev, err) => { + error(`EVENTSUB-WS: Subscription delete failure: ${err}`); + }); + this.listener.onUserSocketConnect(() => { + if (!this.reconnection) { + info(`EVENTSUB-WS: Service initialized for ${broadcasterUsername}#${broadcasterId}`); + } + keepAliveCount = 0; // reset keepAliveCount + }); + this.listener.onUserSocketDisconnect(async (_, err) => { + if (err) { + error(`EVENTSUB-WS: ${err}`); + } + }); + + try { + const broadcasterScopes = variables.get('services.twitch.broadcasterCurrentScopes') as string[]; + + runIfScopeIsApproved(broadcasterScopes, CHANNEL_READ_CHARITY, () => { + this.listener.onChannelCharityCampaignProgress(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelCharityCampaignProgress, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + charityDescription: event.charityDescription, + charityLogo: event.charityLogo, + charityName: event.charityName, + charityWebsite: event.charityWebsite, + currentAmount: event.currentAmount.localizedValue, + currentAmountCurrency: event.currentAmount.currency, + targetAmount: event.targetAmount.localizedValue, + targetAmountCurrency: event.targetAmount.currency, + }); + }); + this.listener.onChannelCharityCampaignStart(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelCharityCampaignStart, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + charityDescription: event.charityDescription, + charityLogo: event.charityLogo, + charityName: event.charityName, + charityWebsite: event.charityWebsite, + currentAmount: event.currentAmount.localizedValue, + currentAmountCurrency: event.currentAmount.currency, + targetAmount: event.targetAmount.localizedValue, + targetAmountCurrency: event.targetAmount.currency, + startDate: event.startDate.toISOString(), + }); + }); + this.listener.onChannelCharityCampaignStop(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelCharityCampaignStop, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + charityDescription: event.charityDescription, + charityLogo: event.charityLogo, + charityName: event.charityName, + charityWebsite: event.charityWebsite, + currentAmount: event.currentAmount.localizedValue, + currentAmountCurrency: event.currentAmount.currency, + targetAmount: event.targetAmount.localizedValue, + targetAmountCurrency: event.targetAmount.currency, + endDate: event.endDate.toISOString(), + }); + }); + this.listener.onChannelCharityDonation(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelCharityDonation, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + charityDescription: event.charityDescription, + charityLogo: event.charityLogo, + charityName: event.charityName, + charityWebsite: event.charityWebsite, + campaignId: event.campaignId, + donorDisplayName: event.donorDisplayName, + donorId: event.donorId, + donorName: event.donorName, + amount: event.amount.localizedValue, + amountCurrency: event.amount.currency, + }); + }); + }); + + // GOAL + runIfScopeIsApproved(broadcasterScopes, CHANNEL_READ_GOALS, () => { + this.listener.onChannelGoalBegin(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelGoalBegin, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + currentAmount: event.currentAmount, + description: event.description, + startDate: event.startDate.toISOString(), + targetAmount: event.targetAmount, + type: event.type, + }); + }); + this.listener.onChannelGoalEnd(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelGoalEnd, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + currentAmount: event.currentAmount, + description: event.description, + startDate: event.startDate.toISOString(), + endDate: event.endDate.toISOString(), + targetAmount: event.targetAmount, + type: event.type, + isAchieved: event.isAchieved, + }); + }); + this.listener.onChannelGoalProgress(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelGoalProgress, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + currentAmount: event.currentAmount, + description: event.description, + startDate: event.startDate.toISOString(), + targetAmount: event.targetAmount, + type: event.type, + }); + }); + }); + + // MODERATOR + this.listener.onChannelModeratorAdd(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelModeratorAdd, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + userDisplayName: event.userDisplayName, + userId: event.userId, + userName: event.userName, + }); + }); + this.listener.onChannelModeratorRemove(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelModeratorRemove, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + userDisplayName: event.userDisplayName, + userId: event.userId, + userName: event.userName, + }); + }); + + // REWARD + this.listener.onChannelRewardAdd(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelRewardAdd, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + autoApproved: event.autoApproved, + backgroundColor: event.backgroundColor, + cooldownExpiryDate: event.cooldownExpiryDate?.toISOString() ?? null, + cost: event.cost, + globalCooldown: event.globalCooldown, + id: event.id, + isEnabled: event.isEnabled, + isInStock: event.isInStock, + isPaused: event.isPaused, + maxRedemptionsPerStream: event.maxRedemptionsPerStream, + maxRedemptionsPerUserPerStream: event.maxRedemptionsPerUserPerStream, + prompt: event.prompt, + redemptionsThisStream: event.redemptionsThisStream, + title: event.title, + userInputRequired: event.userInputRequired, + }); + }); + this.listener.onChannelRewardRemove(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelRewardRemove, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + autoApproved: event.autoApproved, + backgroundColor: event.backgroundColor, + cooldownExpiryDate: event.cooldownExpiryDate?.toISOString() ?? null, + cost: event.cost, + globalCooldown: event.globalCooldown, + id: event.id, + isEnabled: event.isEnabled, + isInStock: event.isInStock, + isPaused: event.isPaused, + maxRedemptionsPerStream: event.maxRedemptionsPerStream, + maxRedemptionsPerUserPerStream: event.maxRedemptionsPerUserPerStream, + prompt: event.prompt, + redemptionsThisStream: event.redemptionsThisStream, + title: event.title, + userInputRequired: event.userInputRequired, + }); + }); + this.listener.onChannelRewardUpdate(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelRewardUpdate, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + autoApproved: event.autoApproved, + backgroundColor: event.backgroundColor, + cooldownExpiryDate: event.cooldownExpiryDate?.toISOString() ?? null, + cost: event.cost, + globalCooldown: event.globalCooldown, + id: event.id, + isEnabled: event.isEnabled, + isInStock: event.isInStock, + isPaused: event.isPaused, + maxRedemptionsPerStream: event.maxRedemptionsPerStream, + maxRedemptionsPerUserPerStream: event.maxRedemptionsPerUserPerStream, + prompt: event.prompt, + redemptionsThisStream: event.redemptionsThisStream, + title: event.title, + userInputRequired: event.userInputRequired, + }); + }); + + // SHIELD + runIfScopeIsApproved(broadcasterScopes, MODERATOR_READ_SHIELD_MODE, () => { + this.listener.onChannelShieldModeBegin(broadcasterId, broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelShieldModeBegin, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + moderatorDisplayName: event.moderatorDisplayName, + moderatorId: event.moderatorId, + moderatorName: event.moderatorName, + }); + }); + this.listener.onChannelShieldModeEnd(broadcasterId, broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelShieldModeEnd, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + moderatorDisplayName: event.moderatorDisplayName, + moderatorId: event.moderatorId, + moderatorName: event.moderatorName, + endDate: event.endDate.toISOString(), + }); + }); + }); + + // SHOUTOUT + runIfScopeIsApproved(broadcasterScopes, MODERATOR_READ_SHOUTOUTS, () => { + this.listener.onChannelShoutoutCreate(broadcasterId, broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelShoutoutCreate, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + moderatorDisplayName: event.moderatorDisplayName, + moderatorId: event.moderatorId, + moderatorName: event.moderatorName, + cooldownEndDate: event.cooldownEndDate.toISOString(), + shoutedOutBroadcasterDisplayName: event.shoutedOutBroadcasterDisplayName, + shoutedOutBroadcasterId: event.shoutedOutBroadcasterId, + shoutedOutBroadcasterName: event.shoutedOutBroadcasterName, + startDate: event.startDate.toISOString(), + viewerCount: event.viewerCount, + }); + }); + this.listener.onChannelShoutoutReceive(broadcasterId, broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelShoutoutReceive, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + startDate: event.startDate.toISOString(), + viewerCount: event.viewerCount, + shoutingOutBroadcasterDisplayName: event.shoutingOutBroadcasterDisplayName, + shoutingOutBroadcasterId: event.shoutingOutBroadcasterId, + shoutingOutBroadcasterName: event.shoutingOutBroadcasterName, + }); + }); + }); + + // SUBSCRIPTION + // We are currently not using this event, because it is missing if subscription is with Prime or not + // revise after https://twitch.uservoice.com/forums/310213-developers/suggestions/42012043-add-is-prime-to-subscription-events + // this.listener.onChannelSubscription(broadcasterId, async (event) => {}); + // this.listener.onChannelSubscriptionEnd(broadcasterId, event => {}); + // this.listener.onChannelSubscriptionGift(broadcasterId, event => {}); + // this.listener.onChannelSubscriptionMessage(broadcasterId, event => {}); + + // CHANNEL UPDATE + this.listener.onChannelUpdate(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelUpdate, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + categoryId: event.categoryId, + categoryName: event.categoryName, + isMature: event.isMature, + streamLanguage: event.streamLanguage, + streamTitle: event.streamTitle, + }); + }); + + // STREAM + // We are currently not using this event, because we have own API polling logic at getCurrentStream + // Will need to be revised eventually + // this.listener.onStreamOnline(broadcasterId, event => {}); + // this.listener.onStreamOffline(broadcasterId, event => {}); + + // USER UPDATE + this.listener.onUserUpdate(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onUserUpdate, { + userDescription: event.userDescription, + userDisplayName: event.userDisplayName, + userId: event.userId, + userEmail: event.userEmail, + userEmailIsVerified: event.userEmailIsVerified, + userName: event.userName, + }); + }); + + // FOLLOW + this.listener.onChannelFollow(broadcasterId, broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + follow(event.userId, event.userName, new Date(event.followDate).toISOString()); + }); + + // CHEER + this.listener.onChannelCheer(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + cheer(event[rawDataSymbol]); + }); + + // RAID + this.listener.onChannelRaidFrom(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + eventEmitter.emit(Types.onChannelRaidFrom, { + raidedBroadcasterDisplayName: event.raidedBroadcasterDisplayName, + raidedBroadcasterName: event.raidedBroadcasterName, + raidedBroadcasterId: event.raidedBroadcasterId, + raidingBroadcasterDisplayName: event.raidingBroadcasterDisplayName, + raidingBroadcasterName: event.raidingBroadcasterName, + raidingBroadcasterId: event.raidingBroadcasterId, + viewers: event.viewers, + }); + }); + this.listener.onChannelRaidTo(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + raid(event[rawDataSymbol]); + }); + + // HYPE TRAIN + this.listener.onChannelHypeTrainBegin(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + hypeTrain.setIsStarted(true); + hypeTrain.setCurrentLevel(1); + eventEmitter.emit('hypetrain-started'); + }); + this.listener.onChannelHypeTrainProgress(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + hypeTrain.setIsStarted(true); + hypeTrain.setTotal(event.total); + hypeTrain.setGoal(event.goal); + for (const top of event.topContributors) { + if (top.type === 'other') { + continue; + } + hypeTrain.setTopContributions(top.type, top.total, top.userId, top.userName); + } + + if (event.lastContribution.type !== 'other') { + hypeTrain.setLastContribution(event.lastContribution.total, event.lastContribution.type, event.lastContribution.userId, event.lastContribution.userName); + } + hypeTrain.setCurrentLevel(event.level); + + // update overlay + ioServer?.of('/services/twitch').emit('hypetrain-update', { + id: event.id, total: event.total, goal: event.goal, level: event.level, subs: Object.fromEntries(hypeTrain.subs), + }); + }); + this.listener.onChannelHypeTrainEnd(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + hypeTrain.triggerHypetrainEnd().then(() => { + hypeTrain.setTotal(0); + hypeTrain.setGoal(0); + hypeTrain.setLastContribution(0, 'bits', null, null); + hypeTrain.setTopContributions('bits', 0, null, null); + hypeTrain.setTopContributions('subscription', 0, null, null); + hypeTrain.setCurrentLevel(1); + ioServer?.of('/services/twitch').emit('hypetrain-end'); + }); + }); + + // POLLS + this.listener.onChannelPollBegin(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + channelPoll.setData(event[rawDataSymbol]); + channelPoll.triggerPollStart(); + }); + this.listener.onChannelPollProgress(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + channelPoll.setData(event[rawDataSymbol]); + }); + this.listener.onChannelPollEnd(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + channelPoll.setData(event[rawDataSymbol]); + channelPoll.triggerPollEnd(); + }); + + // PREDICTION + this.listener.onChannelPredictionBegin(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + channelPrediction.start(event[rawDataSymbol]); + }); + this.listener.onChannelPredictionProgress(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + channelPrediction.progress(event[rawDataSymbol]); + }); + this.listener.onChannelPredictionLock(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + channelPrediction.lock(event[rawDataSymbol]); + }); + this.listener.onChannelPredictionEnd(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + channelPrediction.end(event[rawDataSymbol]); + }); + + // MOD + this.listener.onChannelBan(broadcasterId, (event) => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + const userName = event.userName; + const userId = event.userId; + const createdBy = event.moderatorName; + const createdById = event.moderatorId; + const reason = event.reason; + const ends_at = dayjs(event.endDate); + if (ends_at) { + const duration = dayjs.duration(ends_at.diff(dayjs(event.startDate))); + timeout(`${ userName }#${ userId } by ${ createdBy }#${ createdById } for ${ duration.asSeconds() } seconds`); + eventEmitter.emit('timeout', { userName, duration: duration.asSeconds() }); + } else { + ban(`${ userName }#${ userId } by ${ createdBy }: ${ reason ? reason : '' }`); + eventEmitter.emit('ban', { userName, reason: reason ? reason : '' }); + } + }); + this.listener.onChannelUnban(broadcasterId, (event) => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + unban(`${ event.userName }#${ event.userId } by ${ event.moderatorName }`); + }); + + // REDEMPTION + this.listener.onChannelRedemptionAdd(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + + // trigger reward-redeemed event + if (event.input.length > 0) { + redeem(`${ event.userName }#${ event.userId } redeemed ${ event.rewardTitle }: ${ event.input }`); + } else { + redeem(`${ event.userName }#${ event.userId } redeemed ${ event.rewardTitle }`); + } + + changelog.update(event.userId, { + userId: event.userId, userName: event.userName, displayname: event.userDisplayName, + }); + + eventlist.add({ + event: 'rewardredeem', + userId: String(event.userId), + message: event.input, + timestamp: Date.now(), + titleOfReward: event.rewardTitle, + rewardId: event.rewardId, + }); + alerts.trigger({ + event: 'rewardredeem', + name: event.rewardTitle, + rewardId: event.rewardId, + amount: 0, + tier: null, + currency: '', + monthsName: '', + message: event.input, + recipient: event.userName, + }); + eventEmitter.emit('reward-redeemed', { + userId: event.userId, + userName: event.userName, + rewardId: event.rewardId, + userInput: event.input, + }); + }); + this.listener.onChannelRedemptionUpdate(broadcasterId, event => { + if (isAlreadyProcessed(event[rawDataSymbol])) { + return; + } + + eventEmitter.emit(Types.onChannelRedemptionUpdate, { + broadcasterDisplayName: event.broadcasterDisplayName, + broadcasterId: event.broadcasterId, + broadcasterName: event.broadcasterName, + id: event.id, + input: event.input, + redemptionDate: event.redemptionDate.toISOString(), + rewardCost: event.rewardCost, + rewardId: event.rewardId, + rewardPrompt: event.rewardPrompt, + rewardTitle: event.rewardTitle, + status: event.status, + userDisplayName: event.userDisplayName, + userId: event.userId, + userName: event.userName, + }); + }); + + this.listenerBroadcasterId = broadcasterId; + } catch (e) { + if (e instanceof Error) { + error('EVENTSUB-WS: ' + e.message); + } + error('EVENTSUB-WS: Unknown error durring initialization. ' + e); + } finally { + if (broadcasterMissingScopes.length > 0) { + warning('TWITCH: Broadcaster token is missing the following scopes: ' + broadcasterMissingScopes.join(', ') + '. Please re-authenticate your account.'); + } + } + + if (process.env.ENV === 'production' || process.env.NODE_ENV === 'production') { + this.listener.stop(); + setTimeout(() => { + this.listener.start(); + }, 5000); + } else { + info('EVENTSUB-WS: Eventsub events disabled on dev-mode.'); + } + } +} + +export default EventSubWebsocket; \ No newline at end of file diff --git a/backend/src/services/twitch/eventsub/events.ts b/backend/src/services/twitch/eventsub/events.ts new file mode 100644 index 000000000..84996fc3e --- /dev/null +++ b/backend/src/services/twitch/eventsub/events.ts @@ -0,0 +1,18 @@ +import { isEqual } from 'lodash-es'; + +import { debug } from '~/helpers/log.js'; + +const events: Record[] = []; +export function isAlreadyProcessed(event: Record) { + for (const processed of events) { + if (isEqual(event, processed)) { + debug('twitch.eventsub', `Event ${JSON.stringify(event)} was already processed.`); + return true; + } + } + events.push(event); + if (events.length > 20) { + events.shift(); + } + return false; +} \ No newline at end of file diff --git a/backend/src/services/twitch/token/CustomAuthProvider.ts b/backend/src/services/twitch/token/CustomAuthProvider.ts new file mode 100644 index 000000000..c240fcf7c --- /dev/null +++ b/backend/src/services/twitch/token/CustomAuthProvider.ts @@ -0,0 +1,121 @@ +import { MakeOptional } from '@d-fischer/shared-utils'; +import { UserIdResolvable, extractUserId } from '@twurple/api'; +import { RefreshingAuthProvider, AccessTokenWithUserId, AccessToken, refreshUserToken, accessTokenIsExpired, getTokenInfo, InvalidTokenError, InvalidTokenTypeError, TokenInfo } from '@twurple/auth'; +import axios from 'axios'; + +import { debug } from '~/helpers/log.js'; +import { variables } from '~/watchers.js'; + +const urls = { + 'SogeBot Token Generator v2': 'https://credentials.sogebot.xyz/twitch/refresh/', +}; + +function createAccessTokenFromData(data: any): AccessToken { + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || null, + scope: data.scope ?? [], + expiresIn: data.expires_in ?? null, + obtainmentTimestamp: Date.now(), + }; +} + +export class CustomAuthProvider extends RefreshingAuthProvider { + async refreshUserToken(refreshToken: string) { + let tokenData: AccessToken; + + const tokenService = variables.get('services.twitch.tokenService') as keyof typeof urls; + const url = urls[tokenService]; + if (!url) { + // we have custom app so we are using original code + const tokenServiceCustomClientId = variables.get('services.twitch.tokenServiceCustomClientId') as string; + const tokenServiceCustomClientSecret = variables.get('services.twitch.tokenServiceCustomClientSecret') as string; + tokenData = await refreshUserToken(tokenServiceCustomClientId, tokenServiceCustomClientSecret, refreshToken); + } else { + // we are using own generator + const generalOwners = variables.get('services.twitch.generalOwners') as string[]; + const channel = variables.get('services.twitch.broadcasterUsername') as string; + const response = await axios.post(url + encodeURIComponent(refreshToken.trim()), undefined, { + headers: { + 'SogeBot-Channel': channel, + 'SogeBot-Owners': generalOwners.join(', '), + }, + timeout: 120000, + }); + tokenData = createAccessTokenFromData(response.data); + } + + debug('twitch.token', JSON.stringify({ tokenData })); + return tokenData; + } + + async addUserForToken( + initialToken: MakeOptional, + intents?: string[], + ): Promise { + let tokenWithInfo: [MakeOptional, TokenInfo] | null = null; + if (initialToken.accessToken && !accessTokenIsExpired(initialToken)) { + try { + const tokenInfo = await getTokenInfo(initialToken.accessToken); + tokenWithInfo = [initialToken, tokenInfo]; + } catch (e) { + if (!(e instanceof InvalidTokenError)) { + throw e; + } + } + } + + if (!tokenWithInfo) { + if (!initialToken.refreshToken) { + throw new InvalidTokenError(); + } + + const refreshedToken = await this.refreshUserToken( + initialToken.refreshToken, + ); + + const tokenInfo = await getTokenInfo(refreshedToken.accessToken); + this.emit(this.onRefresh, tokenInfo.userId!, refreshedToken); + tokenWithInfo = [refreshedToken, tokenInfo]; + } + + const [tokenToAdd, tokenInfo] = tokenWithInfo; + + if (!tokenInfo.userId) { + throw new InvalidTokenTypeError( + 'Could not determine a user ID for your token; you might be trying to disguise an app token as a user token.', + ); + } + + const token = tokenToAdd.scope + ? tokenToAdd + : { + ...tokenToAdd, + scope: tokenInfo.scopes, + }; + + this.addUser(tokenInfo.userId, token, intents); + + return tokenInfo.userId; + } + async refreshAccessTokenForUser(user: UserIdResolvable): Promise { + const userId = extractUserId(user); + const previousTokenData = this._userAccessTokens.get(userId); + if (!previousTokenData) { + throw new Error('Trying to refresh token for user that was not added to the provider'); + } + + const tokenData = await this.refreshUserToken(previousTokenData.refreshToken!); + + this._userAccessTokens.set(userId, { + ...tokenData, + userId, + }); + + this.emit(this.onRefresh, userId, tokenData); + return { + ...tokenData, + userId, + }; + } +} \ No newline at end of file diff --git a/backend/src/socket.ts b/backend/src/socket.ts new file mode 100644 index 000000000..cc9ecc7ee --- /dev/null +++ b/backend/src/socket.ts @@ -0,0 +1,317 @@ +import { DAY } from '@sogebot/ui-helpers/constants.js'; +import axios from 'axios'; +import { NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { Socket as SocketIO } from 'socket.io'; +import { v4 as uuid } from 'uuid'; + +import Core from '~/_interface.js'; +import { onLoad } from '~/decorators/on.js'; +import { + persistent, settings, ui, +} from '~/decorators.js'; +import { debug, error } from '~/helpers/log.js'; +import { app, ioServer } from '~/helpers/panel.js'; +import { check } from '~/helpers/permissions/check.js'; +import { defaultPermissions } from '~/helpers/permissions/defaultPermissions.js'; +import { getUserHighestPermission } from '~/helpers/permissions/getUserHighestPermission.js'; +import { adminEndpoint, endpoints } from '~/helpers/socket.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { isModerator } from '~/helpers/user/isModerator.js'; + +enum Authorized { + inProgress, + NotAuthorized, + isAuthorized, +} + +type Unpacked = + T extends (infer U)[] ? U : + T extends (...args: any[]) => infer R ? R : + T extends Promise ? E : + T; + +const getPrivileges = async(type: 'admin' | 'viewer' | 'public', userId: string) => { + try { + const user = await changelog.getOrFail(userId); + return { + haveAdminPrivileges: type === 'admin' ? Authorized.isAuthorized : Authorized.NotAuthorized, + haveModPrivileges: isModerator(user) ? Authorized.isAuthorized : Authorized.NotAuthorized, + haveViewerPrivileges: Authorized.isAuthorized, + }; + } catch (e: any) { + return { + haveAdminPrivileges: Authorized.NotAuthorized, + haveModPrivileges: Authorized.NotAuthorized, + haveViewerPrivileges: Authorized.NotAuthorized, + }; + } +}; + +const initEndpoints = (socket: SocketIO, privileges: Unpacked>) => { + for (const key of [...new Set(endpoints.filter(o => o.nsp === socket.nsp.name).map(o => o.nsp + '||' + o.on))]) { + const [nsp, on] = key.split('||'); + const endpointsToInit = endpoints.filter(o => o.nsp === nsp && o.on === on); + socket.offAny(); // remove all listeners in case we call this twice + + socket.on(on, async (opts: any, cb: (error: Error | string | null, ...response: any) => void) => { + const adminEndpointInit = endpointsToInit.find(o => o.type === 'admin'); + const viewerEndpoint = endpointsToInit.find(o => o.type === 'viewer'); + const publicEndpoint = endpointsToInit.find(o => o.type === 'public'); + if (adminEndpointInit && privileges.haveAdminPrivileges) { + adminEndpointInit.callback(opts, cb ?? socket, socket); + return; + } else if (!viewerEndpoint && !publicEndpoint) { + debug('socket', `User dont have admin access to ${socket.nsp.name}`); + debug('socket', privileges); + cb('User doesn\'t have access to this endpoint', null); + return; + } + + if (viewerEndpoint && privileges.haveViewerPrivileges) { + viewerEndpoint.callback(opts, cb ?? socket, socket); + return; + } else if (!publicEndpoint) { + debug('socket', `User dont have viewer access to ${socket.nsp.name}`); + debug('socket', privileges); + cb('User doesn\'t have access to this endpoint', null); + return; + } + + publicEndpoint.callback(opts, cb ?? socket, socket); + }); + } +}; + +class Socket extends Core { + @persistent() + JWTKey = ''; + + @settings('connection') + accessTokenExpirationTime = DAY; + + @settings('connection') + refreshTokenExpirationTime = DAY * 31; + + @settings('connection') + @ui({ type: 'uuid-generator' }, 'connection') + socketToken = ''; + + @onLoad('JWTKey') + JWTKeyGenerator() { + if (this.JWTKey === '') { + this.JWTKey = uuid(); + } + } + + constructor() { + super(); + + const init = (retry = 0) => { + if (retry === 10000) { + throw new Error('Socket oauth validate endpoint failed.'); + } else if (!app) { + setTimeout(() => init(retry++), 100); + } else { + debug('ui', 'Socket oauth validate endpoint OK.'); + + app.get('/socket/validate', async (req, res) => { + const accessTokenHeader = req.headers['x-twitch-token'] as string | undefined; + const userId = req.headers['x-twitch-userid'] as string | undefined; + + debug('socket', 'Validation started'); + debug('socket', JSON.stringify({ accessTokenHeader, userId }, null, 2)); + try { + if (!accessTokenHeader || !userId) { + throw new Error('Insufficient data'); + } + const twitchValidation = await axios.get(`https://id.twitch.tv/oauth2/validate`, { headers: { 'Authorization': 'Bearer ' + accessTokenHeader } }); + if (userId !== twitchValidation.data.user_id) { + throw new Error('Not matching userId'); + } + const userName = twitchValidation.data.login; + const haveCasterPermission = (await check(userId, defaultPermissions.CASTERS, true)).access; + const user = await changelog.get(userId); + changelog.update(userId, { + ...user, + userId, + userName, + }); + + const accessToken = jwt.sign({ + userId, + userName, + privileges: await getPrivileges(haveCasterPermission ? 'admin' : 'viewer', userId), + }, this.JWTKey, { expiresIn: `${this.accessTokenExpirationTime}s` }); + const refreshToken = jwt.sign({ + userId, + userName, + }, this.JWTKey, { expiresIn: `${this.refreshTokenExpirationTime}s` }); + res.status(200).send({ + accessToken, refreshToken, userType: haveCasterPermission ? 'admin' : 'viewer', + }); + } catch(e: any) { + error(e.stack); + res.status(400).send('You don\'t have access to this server.'); + } + }); + app.get('/socket/refresh', async (req, res) => { + const refreshTokenHeader = req.headers['x-twitch-token'] as string | undefined; + + try { + if (!refreshTokenHeader) { + throw new Error('Insufficient data'); + } + const data = jwt.verify(refreshTokenHeader, this.JWTKey) as { + userId: string; username: string; + }; + const userPermission = await getUserHighestPermission(data.userId); + const user = await changelog.get(data.userId); + changelog.update(data.userId, { + ...user, + ...data, + }); + + const accessToken = jwt.sign({ + userId: data.userId, + username: data.username, + privileges: await getPrivileges(userPermission === defaultPermissions.CASTERS ? 'admin' : 'viewer', data.userId), + }, this.JWTKey, { expiresIn: `${this.accessTokenExpirationTime}s` }); + const refreshToken = jwt.sign({ + userId: data.userId, + username: data.username, + }, this.JWTKey, { expiresIn: `${this.refreshTokenExpirationTime}s` }); + const payload = { + accessToken, refreshToken, userType: userPermission === defaultPermissions.CASTERS ? 'admin' : 'viewer', + }; + debug('socket', '/socket/refresh ->'); + debug('socket', JSON.stringify(payload, null, 2)); + res.status(200).send(payload); + } catch(e: any) { + debug('socket', e.stack); + res.status(400).send('You don\'t have access to this server.'); + } + }); + } + }; + init(); + } + + async authorize(socket: SocketIO, next: NextFunction) { + const authToken = (socket.handshake.auth as any).token; + // first check if token is socketToken + if (authToken === this.socketToken) { + initEndpoints(socket, { + haveAdminPrivileges: Authorized.isAuthorized, haveModPrivileges: Authorized.isAuthorized, haveViewerPrivileges: Authorized.isAuthorized, + }); + } else { + if (authToken !== '' && authToken !== null) { + try { + const token = jwt.verify(authToken, _self.JWTKey) as { + userId: string; username: string; privileges: Unpacked>; + }; + debug('socket', JSON.stringify(token, null, 4)); + initEndpoints(socket, token.privileges); + } catch (e: any) { + if (e instanceof jwt.JsonWebTokenError) { + error('Used token for authorization is malformed or invalid: ' + e.message); + debug('socket', e.stack); + } else if (e instanceof Error) { + debug('socket', e.stack); + } + next(Error(e)); + return; + } + } else { + initEndpoints(socket, { + haveAdminPrivileges: Authorized.NotAuthorized, haveModPrivileges: Authorized.NotAuthorized, haveViewerPrivileges: Authorized.NotAuthorized, + }); + debug('socket', `Authorize endpoint failed with token '${authToken}'`); + setTimeout(() => socket.emit('forceDisconnect'), 1000); // force disconnect if we must be logged in + } + } + setTimeout(() => next(), 100); + } + + sockets () { + adminEndpoint('/core/socket', 'purgeAllConnections', (cb, socket) => { + this.JWTKey = uuid(); + ioServer?.emit('forceDisconnect'); + if (socket) { + initEndpoints(socket, { + haveAdminPrivileges: Authorized.NotAuthorized, haveModPrivileges: Authorized.NotAuthorized, haveViewerPrivileges: Authorized.NotAuthorized, + }); + } + cb(null); + }); + } + + @onLoad('socketToken') + generateSocketTokenIfNeeded() { + if (this.socketToken === '') { + this.socketToken = uuid(); + } + } +} + +const processAuth = (req: { headers: { [x: string]: any; }; }, res: { sendStatus: (arg0: number) => any; }, next: () => void) => { + req.headers.adminAccess = false; + + try { + const authHeader = req.headers.authorization; + const authToken = authHeader && authHeader.split(' ')[1]; + + if (authToken === _self.socketToken) { + req.headers.adminAccess = true; + return next(); + } + + if (authToken == null) { + return next(); + } + + const token = jwt.verify(authToken, _self.JWTKey) as { + userId: string; username: string; privileges: Unpacked>; + }; + + if (token.privileges.haveAdminPrivileges === Authorized.isAuthorized) { + req.headers.adminAccess = true; + return next(); + } + + } catch { + null; + } + next(); +}; + +const adminMiddleware = (req: { headers: { [x: string]: any; }; }, res: { sendStatus: (arg0: number) => any; }, next: () => void) => { + try { + const authHeader = req.headers.authorization; + const authToken = authHeader && authHeader.split(' ')[1]; + + if (authToken === _self.socketToken) { + return next(); + } + + if (authToken == null) { + return res.sendStatus(401); + } + + const token = jwt.verify(authToken, _self.JWTKey) as { + userId: string; username: string; privileges: Unpacked>; + }; + + if (token.privileges.haveAdminPrivileges !== Authorized.isAuthorized) { + return res.sendStatus(401); + } + + next(); + } catch (e) { + return res.sendStatus(401); + } +}; + +const _self = new Socket(); +export default _self; +export { Socket, getPrivileges, adminMiddleware, processAuth }; \ No newline at end of file diff --git a/backend/src/stats.ts b/backend/src/stats.ts new file mode 100644 index 000000000..50574f5cf --- /dev/null +++ b/backend/src/stats.ts @@ -0,0 +1,176 @@ +'use strict'; + +import { error } from 'console'; + +import { DAY, MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { get, isNil } from 'lodash-es'; +import { LessThan } from 'typeorm'; + +import Core from '~/_interface.js'; +import { TwitchStats, TwitchStatsInterface } from '~/database/entity/twitch.js'; +import { AppDataSource } from '~/database.js'; +import { onStreamStart } from '~/decorators/on.js'; +import { persistent } from '~/decorators.js'; +import { + chatMessagesAtStart, isStreamOnline, rawStatus, stats, streamStatusChangeSince, +} from '~/helpers/api/index.js'; +import { debug } from '~/helpers/log.js'; +import { app } from '~/helpers/panel.js'; +import { linesParsed } from '~/helpers/parser.js'; +import lastfm from '~/integrations/lastfm.js'; +import spotify from '~/integrations/spotify.js'; +import { adminMiddleware } from '~/socket.js'; +import songs from '~/systems/songs.js'; +import translateLib, { translate } from '~/translate.js'; +import { variables } from '~/watchers.js'; + +class Stats extends Core { + @persistent() + currentFollowers = 0; + @persistent() + currentSubscribers = 0; + + showInUI = false; + latestTimestamp = 0; + + @onStreamStart() + async setInitialValues() { + this.currentFollowers = stats.value.currentFollowers; + this.currentSubscribers = stats.value.currentSubscribers; + debug('stats', JSON.stringify({ + currentFollowers: this.currentFollowers, currentSubscribers: this.currentSubscribers, + })); + } + + sockets() { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/stats/current', adminMiddleware, async (__, res) => { + try { + if (!translateLib.isLoaded) { + throw new Error('Translation not yet loaded'); + } + + const ytCurrentSong = Object.values(songs.isPlaying).find(o => o) ? get(JSON.parse(songs.currentSong), 'title', null) : null; + const spotifySongParsed = JSON.parse(spotify.currentSong); + let spotifyCurrentSong: null | string = null; + if (spotifySongParsed && spotifySongParsed.is_playing) { + spotifyCurrentSong = `${spotifySongParsed.song} - ${spotifySongParsed.artist}`; + } + + const broadcasterType = variables.get('services.twitch.broadcasterType') as string; + const data = { + broadcasterType: broadcasterType, + uptime: isStreamOnline.value ? streamStatusChangeSince.value : null, + currentViewers: stats.value.currentViewers, + currentSubscribers: stats.value.currentSubscribers, + currentBits: stats.value.currentBits, + currentTips: stats.value.currentTips, + chatMessages: isStreamOnline.value ? linesParsed - chatMessagesAtStart.value : 0, + currentFollowers: stats.value.currentFollowers, + maxViewers: stats.value.maxViewers, + newChatters: stats.value.newChatters, + game: stats.value.currentGame, + status: stats.value.currentTitle, + rawStatus: rawStatus.value, + currentSong: lastfm.currentSong || ytCurrentSong || spotifyCurrentSong || translate('songs.not-playing'), + currentWatched: stats.value.currentWatchedTime, + tags: stats.value.currentTags ?? [], + contentClassificationLabels: stats.value.contentClasificationLabels ?? [], + }; + res.send(data); + } catch (e: any) { + if (e instanceof Error) { + if (e.message !== 'Translation not yet loaded') { + error(e); + res.status(500).send(e.message); + } + } + } + }); + + app.get('/api/stats/latest', adminMiddleware, async (__, res) => { + try { + // cleanup + AppDataSource.getRepository(TwitchStats).delete({ 'whenOnline': LessThan(Date.now() - (DAY * 31)) }); + + const statsFromDb = await AppDataSource.getRepository(TwitchStats) + .createQueryBuilder('stats') + .offset(1) + .cache(true) + .limit(Number.MAX_SAFE_INTEGER) + .where('stats.whenOnline > :whenOnline', { whenOnline: Date.now() - (DAY * 31) }) + .orderBy('stats.whenOnline', 'DESC') + .getMany(); + const statsToReturn = { + currentViewers: 0, + currentSubscribers: 0, + currentBits: 0, + currentTips: 0, + chatMessages: 0, + currentFollowers: 0, + maxViewers: 0, + newChatters: 0, + currentWatched: 0, + }; + if (statsFromDb.length > 0) { + for (const stat of statsFromDb) { + statsToReturn.currentViewers += _self.parseStat(stat.currentViewers); + statsToReturn.currentBits += _self.parseStat(stat.currentBits); + statsToReturn.currentTips += _self.parseStat(stat.currentTips); + statsToReturn.chatMessages += _self.parseStat(stat.chatMessages); + statsToReturn.maxViewers += _self.parseStat(stat.maxViewers); + statsToReturn.newChatters += _self.parseStat(stat.newChatters); + statsToReturn.currentWatched += _self.parseStat(stat.currentWatched); + } + statsToReturn.currentViewers = Number(Number(statsToReturn.currentViewers / statsFromDb.length).toFixed(0)); + statsToReturn.currentBits = Number(Number(statsToReturn.currentBits / statsFromDb.length).toFixed(0)); + statsToReturn.currentTips = Number(Number(statsToReturn.currentTips / statsFromDb.length).toFixed(2)); + statsToReturn.chatMessages = Number(Number(statsToReturn.chatMessages / statsFromDb.length).toFixed(0)); + statsToReturn.maxViewers = Number(Number(statsToReturn.maxViewers / statsFromDb.length).toFixed(0)); + statsToReturn.newChatters = Number(Number(statsToReturn.newChatters / statsFromDb.length).toFixed(0)); + statsToReturn.currentWatched = Number(Number(statsToReturn.currentWatched / statsFromDb.length).toFixed(0)); + res.send({ + ...statsToReturn, currentFollowers: _self.currentFollowers, currentSubscribers: _self.currentSubscribers, + }); + } else { + res.status(204).send(); + } + } catch (e: any) { + error(e); + res.status(500).send(); + } + }); + } + + async save(data: Required & { timestamp: number }) { + if (data.timestamp - this.latestTimestamp >= MINUTE * 15) { + const whenOnline = new Date(data.whenOnline).getTime(); + const statsFromDB = await AppDataSource.getRepository(TwitchStats).findOneBy({ 'whenOnline': whenOnline }); + await AppDataSource.getRepository(TwitchStats).save({ + currentViewers: statsFromDB ? Math.round((data.currentViewers + statsFromDB.currentViewers) / 2) : data.currentViewers, + whenOnline: statsFromDB ? statsFromDB.whenOnline : Date.now(), + currentSubscribers: data.currentSubscribers, + currentBits: data.currentBits, + currentTips: data.currentTips, + chatMessages: data.chatMessages, + currentFollowers: data.currentFollowers, + maxViewers: data.maxViewers, + newChatters: data.newChatters, + currentWatched: data.currentWatched, + }); + + this.latestTimestamp = data.timestamp; + } + } + + parseStat(value: null | string | number) { + return parseFloat(isNil(value) || isNaN(parseFloat(String(value))) ? String(0) : String(value)); + } +} + +const _self = new Stats(); +export default _self; diff --git a/backend/src/stats/_interface.ts b/backend/src/stats/_interface.ts new file mode 100644 index 000000000..461b017c6 --- /dev/null +++ b/backend/src/stats/_interface.ts @@ -0,0 +1,9 @@ +import Module from '../_interface.js'; + +class Overlay extends Module { + constructor() { + super('stats', true); + } +} + +export default Overlay; diff --git a/backend/src/stats/bits.ts b/backend/src/stats/bits.ts new file mode 100644 index 000000000..6665e11a7 --- /dev/null +++ b/backend/src/stats/bits.ts @@ -0,0 +1,41 @@ +import { UserBit } from '@entity/user.js'; + +import Stats from './_interface.js'; + +import { AppDataSource } from '~/database.js'; +import { error } from '~/helpers/log.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import getNameById from '~/helpers/user/getNameById.js'; + +class Bits extends Stats { + constructor() { + super(); + this.addMenu({ + category: 'stats', name: 'bits', id: 'stats/bits', this: null, + }); + } + + sockets() { + adminEndpoint('/stats/bits', 'generic::getAll', async (cb) => { + try { + const items = await AppDataSource.getRepository(UserBit).find(); + cb(null, await Promise.all(items.map(async (item) => { + let username = 'NotExisting'; + try { + username = await getNameById(item.userId); + } catch(e) { + error(`STATS: userId ${item.userId} is not found on Twitch`); + } + return { + ...item, + username, + }; + }))); + } catch (e: any) { + cb(e, []); + } + }); + } +} + +export default new Bits(); diff --git a/backend/src/stats/commandcount.ts b/backend/src/stats/commandcount.ts new file mode 100644 index 000000000..6d6a461a2 --- /dev/null +++ b/backend/src/stats/commandcount.ts @@ -0,0 +1,27 @@ +import { CommandsCount } from '@entity/commands.js'; +import { AppDataSource } from '~/database.js'; + +import Stats from './_interface.js'; + +import { adminEndpoint } from '~/helpers/socket.js'; + +class CommandCount extends Stats { + constructor() { + super(); + this.addMenu({ + category: 'stats', name: 'commandcount', id: 'stats/commandcount', this: null, + }); + } + + sockets() { + adminEndpoint('/stats/commandcount', 'commands::count', async (cb) => { + try { + cb(null, await AppDataSource.getRepository(CommandsCount).find()); + } catch (e: any) { + cb(e.stack, []); + } + }); + } +} + +export default new CommandCount(); diff --git a/backend/src/stats/profiler.ts b/backend/src/stats/profiler.ts new file mode 100644 index 000000000..39857850f --- /dev/null +++ b/backend/src/stats/profiler.ts @@ -0,0 +1,20 @@ +import Stats from './_interface.js'; + +import { avgTime } from '~/helpers/profiler.js'; +import { adminEndpoint } from '~/helpers/socket.js'; + +class Profiler extends Stats { + constructor() { + super(); + this.addMenu({ + category: 'stats', name: 'profiler', id: 'stats/profiler', this: null, + }); + } + public sockets() { + adminEndpoint('/stats/profiler', 'profiler::load', async (cb) => { + cb(null, Array.from(avgTime.entries())); + }); + } +} + +export default new Profiler(); diff --git a/backend/src/stats/tips.ts b/backend/src/stats/tips.ts new file mode 100644 index 000000000..eba8726ff --- /dev/null +++ b/backend/src/stats/tips.ts @@ -0,0 +1,41 @@ +import { UserTip } from '@entity/user.js'; + +import Stats from './_interface.js'; + +import { AppDataSource } from '~/database.js'; +import { error } from '~/helpers/log.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import getNameById from '~/helpers/user/getNameById.js'; + +class Tips extends Stats { + constructor() { + super(); + this.addMenu({ + category: 'stats', name: 'tips', id: 'stats/tips', this: null, + }); + } + + sockets() { + adminEndpoint('/stats/tips', 'generic::getAll', async (cb) => { + try { + const items = await AppDataSource.getRepository(UserTip).find(); + cb(null, await Promise.all(items.map(async (item) => { + let username = 'NotExisting'; + try { + username = await getNameById(item.userId); + } catch(e) { + error(`STATS: userId ${item.userId} is not found on Twitch`); + } + return { + ...item, + username, + }; + }))); + } catch (e: any) { + cb(e.stack, []); + } + }); + } +} + +export default new Tips(); diff --git a/backend/src/systems/_interface.ts b/backend/src/systems/_interface.ts new file mode 100644 index 000000000..65463dea1 --- /dev/null +++ b/backend/src/systems/_interface.ts @@ -0,0 +1,9 @@ +import Module from '../_interface.js'; + +class System extends Module { + constructor() { + super('systems'); + } +} + +export default System; diff --git a/backend/src/systems/alias.ts b/backend/src/systems/alias.ts new file mode 100644 index 000000000..1ea1a2ed6 --- /dev/null +++ b/backend/src/systems/alias.ts @@ -0,0 +1,501 @@ +import { Alias as AliasEntity, AliasGroup } from '@entity/alias.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import { validateOrReject } from 'class-validator'; +import * as _ from 'lodash-es'; +import { merge } from 'lodash-es'; + +import System from './_interface.js'; +import { parserReply } from '../commons.js'; +import { + command, default_permission, parser, timer, +} from '../decorators.js'; +import { Expects } from '../expects.js'; +import { isValidationError } from '../helpers/errors.js'; +import { Parser } from '../parser.js'; + +import { AppDataSource } from '~/database.js'; +import { checkFilter } from '~/helpers/checkFilter.js'; +import { incrementCountOfCommandUsage } from '~/helpers/commands/count.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { executeVariablesInText } from '~/helpers/customvariables/index.js'; +import { + debug, error, info, warning, +} from '~/helpers/log.js'; +import { check } from '~/helpers/permissions/check.js'; +import { defaultPermissions } from '~/helpers/permissions/defaultPermissions.js'; +import { get } from '~/helpers/permissions/get.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import customCommands from '~/systems/customcommands.js'; +import { translate } from '~/translate.js'; + +/* + * !alias - gets an info about alias usage + * !alias group -set [group] -a ![alias] - add alias to group + * !alias group -unset ![alias] - unset alias from group + * !alias group -list - list alias groups + * !alias group -list [group] - list alias group by name + * !alias group -enable [group] - enable alias group by name + * !alias group -disable [group] - disable alias group by name + * !alias add (-p [uuid|name]) -a ![alias] -c ![cmd] - add alias for specified command + * !alias edit (-p [uuid|name]) -a ![alias] -c ![cmd] - add alias for specified command + * !alias remove ![alias] - remove specified alias + * !alias toggle ![alias] - enable/disable specified alias + * !alias toggle-visibility ![alias] - enable/disable specified alias + * !alias list - get alias list + */ + +class Alias extends System { + constructor () { + super(); + + this.addMenu({ + category: 'commands', name: 'alias', id: 'commands/alias', this: this, + }); + } + + sockets() { + adminEndpoint('/systems/alias', 'generic::groups::deleteById', async (name, cb) => { + try { + const group = await AliasGroup.findOneBy({ name }); + if (!group) { + throw new Error(`Group ${name} not found`); + } + await group.remove(); + cb(null); + } catch (e) { + cb(e as Error); + } + }); + adminEndpoint('/systems/alias', 'generic::groups::save', async (item, cb) => { + try { + const itemToSave = new AliasGroup(); + merge(itemToSave, item); + await itemToSave.save(); + cb(null, itemToSave); + } catch (e) { + if (e instanceof Error) { + cb(e.message, undefined); + } + } + }); + adminEndpoint('/systems/alias', 'generic::groups::getAll', async (cb) => { + let groupsList = await AliasGroup.find(); + for (const item of await AliasEntity.find()) { + if (item.group && !groupsList.find(o => o.name === item.group)) { + // we dont have any group options -> create temporary group + const group = new AliasGroup(); + group.name = item.group; + group.options = { + filter: null, + permission: null, + }; + groupsList = [ + ...groupsList, + group, + ]; + } + } + cb(null, groupsList); + }); + adminEndpoint('/systems/alias', 'generic::getAll', async (cb) => { + cb(null, await AliasEntity.find()); + }); + adminEndpoint('/systems/alias', 'generic::getOne', async (id, cb) => { + cb(null, await AliasEntity.findOneBy({ id })); + }); + adminEndpoint('/systems/alias', 'generic::deleteById', async (id, cb) => { + try { + const alias = await AliasEntity.findOneBy({ id }); + if (!alias) { + throw new Error(`Alias ${id} not found`); + } + await alias.remove(); + cb(null); + } catch (e) { + cb(e as Error); + } + }); + adminEndpoint('/systems/alias', 'generic::save', async (item, cb) => { + try { + const itemToSave = new AliasEntity(); + merge(itemToSave, item); + await validateOrReject(itemToSave); + await itemToSave.save(); + cb(null, itemToSave); + } catch (e) { + if (e instanceof Error) { + cb(e.message, undefined); + } + if (isValidationError(e)) { + cb(e, undefined); + } + } + }); + } + + async search(opts: ParserOptions): Promise<[Readonly> | null, string[]]> { + let alias: Readonly> | undefined; + const cmdArray = opts.message.toLowerCase().split(' '); + + // is it an command? + if (!opts.message.startsWith('!')) { + return [null, cmdArray]; + } + + const length = opts.message.toLowerCase().split(' ').length; + const aliases = await AliasEntity.find(); + for (let i = 0; i < length; i++) { + alias = aliases.find(o => o.alias === cmdArray.join(' ') && o.enabled); + if (alias) { + return [alias, cmdArray]; + } + cmdArray.pop(); // remove last array item if not found + } + return [null, cmdArray]; + } + + @timer() + @parser({ priority: constants.HIGH, fireAndForget: true }) + async run (opts: ParserOptions): Promise { + const alias = (await this.search(opts))[0]; + if (!alias || !opts.sender) { + return true; + } // no alias was found - return + + const replace = new RegExp(`${alias.alias}`, 'i'); + const cmdArray = opts.message.replace(replace, `${alias.command}`).split(' '); + let tryingToBypass = false; + + const length = opts.message.toLowerCase().split(' ').length; + for (let i = 0; i < length; i++) { // search for correct alias + if (cmdArray.length === alias.command.split(' ').length) { + break; + } // command is correct (have same number of parameters as command) + + const parsedCmd = await (opts.parser || new Parser()).find(cmdArray.join(' '), null); + const isRegistered = !_.isNil(parsedCmd) && parsedCmd.command.split(' ').length === cmdArray.length; + + if (isRegistered) { + tryingToBypass = true; + break; + } + cmdArray.pop(); // remove last array item if not found + } + if (!tryingToBypass) { + // Don't run alias if its same as command e.g. alias !me -> command !me + if (alias.command === alias.alias) { + warning(`Cannot run alias ${alias.alias}, because it exec ${alias.command}`); + return false; + } else { + let permission = alias.permission; + // load alias group if any + if (alias.group) { + const group = await AliasGroup.findOneBy({ name: alias.group }); + if (group) { + if (group.options.filter && !(await checkFilter(opts, group.options.filter))) { + warning(`Alias ${alias.alias}#${alias.id} didn't pass group filter.`); + return true; + } + if (permission === null) { + permission = group.options.permission; + } + } + } + + // show warning if null permission + if (!permission) { + permission = defaultPermissions.CASTERS; + warning(`Alias ${alias.alias}#${alias.id} doesn't have any permission set, treating as CASTERS permission.`); + } + + if (opts.skip || (await check(opts.sender.userId, permission, false)).access) { + // process custom variables + const response = await executeVariablesInText( + opts.message.replace(replace, alias.command), { + sender: { + userId: opts.sender.userId, + username: opts.sender.userName, + source: typeof opts.discord === 'undefined' ? 'twitch' : 'discord', + }, + }); + debug('alias.process', response); + const responses = await (opts.parser || new Parser()).command(opts.sender, response, true); + debug('alias.process', responses); + for (let i = 0; i < responses.length; i++) { + await parserReply(responses[i].response, { sender: responses[i].sender, discord: responses[i].discord, attr: responses[i].attr, id: opts.id }); + } + // go through custom commands + if (response.startsWith('!')) { + customCommands.run({ ...opts, message: response }); + } + + incrementCountOfCommandUsage(alias.alias); + } else { + info(`User ${opts.sender.userName}#${opts.sender.userId} doesn't have permissions to use ${alias.alias}`); + return false; + } + } + } + return true; + } + + @command('!alias') + @default_permission(defaultPermissions.CASTERS) + main (opts: CommandOptions): CommandResponse[] { + let url = 'http://sogebot.github.io/sogeBot/#/systems/alias'; + if ((process.env?.npm_package_version ?? 'x.y.z-SNAPSHOT').includes('SNAPSHOT')) { + url = 'http://sogebot.github.io/sogeBot/#/_master/systems/alias'; + } + return [{ response: translate('core.usage') + ' => ' + url, ...opts }]; + } + + @command('!alias group') + @default_permission(defaultPermissions.CASTERS) + async group (opts: CommandOptions): Promise { + try { + if (opts.parameters.includes('-set')) { + const [alias, group] = new Expects(opts.parameters) + .argument({ + name: 'a', type: String, multi: true, delimiter: '', + }) // set as multi as alias can contain spaces + .argument({ + name: 'set', type: String, multi: true, delimiter: '', + }) // set as multi as group can contain spaces + .toArray(); + const item = await AliasEntity.findOneBy({ alias }); + if (!item) { + const response = prepare('alias.alias-was-not-found', { alias }); + return [{ response, ...opts }]; + } + await AppDataSource.getRepository(AliasEntity).save({ ...item, group }); + const response = prepare('alias.alias-group-set', { ...item, group }); + return [{ response, ...opts }]; + } else if (opts.parameters.includes('-unset')) { + const [alias] = new Expects(opts.parameters) + .argument({ + name: 'unset', type: String, multi: true, delimiter: '', + }) // set as multi as alias can contain spaces + .toArray(); + const item = await AliasEntity.findOneBy({ alias }); + if (!item) { + const response = prepare('alias.alias-was-not-found', { alias }); + return [{ response, ...opts }]; + } + await AppDataSource.getRepository(AliasEntity).save({ ...item, group: null }); + const response = prepare('alias.alias-group-unset', item); + return [{ response, ...opts }]; + } else if (opts.parameters.includes('-list')) { + const [group] = new Expects(opts.parameters) + .argument({ + name: 'list', type: String, optional: true, multi: true, delimiter: '', + }) // set as multi as group can contain spaces + .toArray(); + if (group) { + const items = await AliasEntity.findBy({ visible: true, enabled: true, group }); + const response = prepare('alias.alias-group-list-aliases', { group, list: items.length > 0 ? items.map(o => o.alias).sort().join(', ') : `<${translate('core.empty')}>` }); + return [{ response, ...opts }]; + } else { + const aliases = await AliasEntity.find(); + const _groups = [...new Set(aliases.map(o => o.group).filter(o => !!o).sort())]; + const response = prepare('alias.alias-group-list', { list: _groups.length > 0 ? _groups.join(', ') : `<${translate('core.empty')}>` }); + return [{ response, ...opts }]; + } + } else if (opts.parameters.includes('-enable')) { + const [group] = new Expects(opts.parameters) + .argument({ + name: 'enable', type: String, multi: true, delimiter: '', + }) // set as multi as group can contain spaces + .toArray(); + await AppDataSource.getRepository(AliasEntity).update({ group }, { enabled: true }); + const response = prepare('alias.alias-group-list-enabled', { group }); + return [{ response, ...opts }]; + } else if (opts.parameters.includes('-disable')) { + const [group] = new Expects(opts.parameters) + .argument({ + name: 'disable', type: String, multi: true, delimiter: '', + }) // set as multi as group can contain spaces + .toArray(); + await AppDataSource.getRepository(AliasEntity).update({ group }, { enabled: false }); + const response = prepare('alias.alias-group-list-disabled', { group }); + return [{ response, ...opts }]; + } else { + throw new Error('-set, -unset, -enable, -disable or -list not found in command.'); + } + } catch (e: any) { + error(e.stack); + return [{ response: prepare('alias.alias-parse-failed'), ...opts }]; + } + + } + + @command('!alias edit') + @default_permission(defaultPermissions.CASTERS) + async edit (opts: CommandOptions) { + try { + const [perm, alias, cmd] = new Expects(opts.parameters) + .permission({ optional: true, default: defaultPermissions.VIEWERS }) + .argument({ + name: 'a', type: String, multi: true, delimiter: '', + }) // set as multi as alias can contain spaces + .argument({ + name: 'c', type: String, multi: true, delimiter: '', + }) // set as multi as command can contain spaces + .toArray(); + + if (!alias.startsWith('!') || !(cmd.startsWith('!') || cmd.startsWith('$_'))) { + throw Error('Alias/Command doesn\'t start with ! or command is not custom variable'); + } + + const pItem = await get(perm); + if (!pItem) { + throw Error('Permission ' + perm + ' not found.'); + } + + const item = await AliasEntity.findOneBy({ alias }); + if (!item) { + const response = prepare('alias.alias-was-not-found', { alias }); + return [{ response, ...opts }]; + } + item.command = cmd; + item.permission = pItem.id ?? defaultPermissions.VIEWERS; + await item.save(); + + const response = prepare('alias.alias-was-edited', { alias, command: cmd }); + return [{ response, ...opts }]; + } catch (e: any) { + return [{ response: prepare('alias.alias-parse-failed'), ...opts }]; + } + } + + @command('!alias add') + @default_permission(defaultPermissions.CASTERS) + async add (opts: CommandOptions) { + try { + const [perm, alias, cmd] = new Expects(opts.parameters) + .permission({ optional: true, default: defaultPermissions.VIEWERS }) + .argument({ + name: 'a', type: String, multi: true, delimiter: '', + }) // set as multi as alias can contain spaces + .argument({ + name: 'c', type: String, multi: true, delimiter: '', + }) // set as multi as command can contain spaces + .toArray(); + + if (!alias.startsWith('!') || !(cmd.startsWith('!') || cmd.startsWith('$_'))) { + throw Error('Alias/Command doesn\'t start with ! or command is not custom variable'); + } + + const pItem = await get(perm); + if (!pItem) { + throw Error('Permission ' + perm + ' not found.'); + } + + const newAlias = new AliasEntity(); + newAlias.alias = alias; + newAlias.command = cmd; + newAlias.enabled = true; + newAlias.visible = true; + newAlias.permission = pItem.id ?? defaultPermissions.VIEWERS; + await newAlias.save(); + const response = prepare('alias.alias-was-added', newAlias); + return [{ response, ...opts }]; + } catch (e: any) { + return [{ response: prepare('alias.alias-parse-failed'), ...opts }]; + } + } + + @command('!alias list') + @default_permission(defaultPermissions.CASTERS) + async list (opts: CommandOptions) { + const alias = await AliasEntity.findBy({ visible: true, enabled: true }); + const response + = (alias.length === 0 + ? translate('alias.list-is-empty') + : translate('alias.list-is-not-empty')) + .replace(/\$list/g, _.orderBy(alias, 'alias').map(o => o.alias).join(', ')); + return [{ response, ...opts }]; + } + + @command('!alias toggle') + @default_permission(defaultPermissions.CASTERS) + async toggle (opts: CommandOptions) { + try { + const [alias] = new Expects(opts.parameters) + .everything() + .toArray(); + + if (!alias.startsWith('!')) { + throw Error('Not starting with !'); + } + + const item = await AliasEntity.findOneBy({ alias }); + if (!item) { + const response = prepare('alias.alias-was-not-found', { alias }); + return [{ response, ...opts }]; + } + item.enabled = !item.enabled; + await item.save(); + const response = prepare(item.enabled ? 'alias.alias-was-enabled' : 'alias.alias-was-disabled', item); + return [{ response, ...opts }]; + } catch (e: any) { + console.log({ e }); + const response = prepare('alias.alias-parse-failed'); + return [{ response, ...opts }]; + } + } + + @command('!alias toggle-visibility') + @default_permission(defaultPermissions.CASTERS) + async toggleVisibility (opts: CommandOptions) { + try { + const [alias] = new Expects(opts.parameters) + .everything() + .toArray(); + + if (!alias.startsWith('!')) { + throw Error('Not starting with !'); + } + + const item = await AliasEntity.findOneBy({ alias }); + if (!item) { + const response = prepare('alias.alias-was-not-found', { alias }); + return [{ response, ...opts }]; + } + item.visible = !item.visible; + await item.save(); + const response = prepare(item.visible ? 'alias.alias-was-exposed' : 'alias.alias-was-concealed', item); + return [{ response, ...opts }]; + } catch (e: any) { + const response = prepare('alias.alias-parse-failed'); + return [{ response, ...opts }]; + } + } + + @command('!alias remove') + @default_permission(defaultPermissions.CASTERS) + async remove (opts: CommandOptions) { + try { + const [alias] = new Expects(opts.parameters) + .everything() + .toArray(); + + if (!alias.startsWith('!')) { + throw Error('Not starting with !'); + } + + const item = await AliasEntity.findOneBy({ alias }); + if (!item) { + const response = prepare('alias.alias-was-not-found', { alias }); + return [{ response, ...opts }]; + } + await AppDataSource.getRepository(AliasEntity).remove(item); + const response = prepare('alias.alias-was-removed', { alias }); + return [{ response, ...opts }]; + } catch (e: any) { + const response = prepare('alias.alias-parse-failed'); + return [{ response, ...opts }]; + } + } +} + +export default new Alias(); diff --git a/backend/src/systems/antihateraid.ts b/backend/src/systems/antihateraid.ts new file mode 100644 index 000000000..fbc3ec839 --- /dev/null +++ b/backend/src/systems/antihateraid.ts @@ -0,0 +1,78 @@ +import System from './_interface.js'; +import { + command, default_permission, settings, +} from '../decorators.js'; + +import { prepare } from '~/helpers/commons/index.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import getBroadcasterId from '~/helpers/user/getBroadcasterId.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +enum modes { + 'SUBSONLY', 'FOLLOWONLY', 'EMOTESONLY' +} + +class AntiHateRaid extends System { + @settings() + clearChat = true; + @settings() + mode: modes = modes.SUBSONLY; + @settings() + minFollowTime = 10; + @settings() + customAnnounce = ''; + + @command('!antihateraidon') + @default_permission(defaultPermissions.MODERATORS) + async antihateraidon (opts: CommandOptions): Promise { + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + + if(this.clearChat) { + twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.moderation.deleteChatMessages(broadcasterId)); + } + if (this.mode === modes.SUBSONLY) { + twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.chat.updateSettings(broadcasterId, { + subscriberOnlyModeEnabled: true, + })); + } + if (this.mode === modes.FOLLOWONLY) { + twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.chat.updateSettings(broadcasterId, { + followerOnlyModeEnabled: true, + })); + } + if (this.mode === modes.EMOTESONLY) { + twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.chat.updateSettings(broadcasterId, { + emoteOnlyModeEnabled: true, + })); + } + return [{ + response: prepare(this.customAnnounce.length > 0 ? this.customAnnounce : prepare('systems.antihateraid.announce'), { + username: opts.sender.userName, mode: prepare('systems.antihateraid.mode.' + this.mode), + }, false), ...opts, + }]; + } + + @command('!antihateraidoff') + @default_permission(defaultPermissions.MODERATORS) + async antihateraidoff () { + if (this.mode === modes.SUBSONLY) { + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + subscriberOnlyModeEnabled: false, + })); + } + if (this.mode === modes.FOLLOWONLY) { + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + followerOnlyModeEnabled: false, + })); + } + if (this.mode === modes.EMOTESONLY) { + twitch.apiClient?.asIntent(['bot'], ctx => ctx.chat.updateSettings(getBroadcasterId(), { + emoteOnlyModeEnabled: false, + })); + } + return []; + } +} + +export default new AntiHateRaid(); diff --git a/backend/src/systems/bets.ts b/backend/src/systems/bets.ts new file mode 100644 index 000000000..a9cbc1d9d --- /dev/null +++ b/backend/src/systems/bets.ts @@ -0,0 +1,141 @@ +import _ from 'lodash-es'; + +import System from './_interface.js'; +import { + command, default_permission, +} from '../decorators.js'; +import { Expects } from '../expects.js'; + +import { onStartup, onStreamStart } from '~/decorators/on.js'; +import * as channelPrediction from '~/helpers/api/channelPrediction.js'; +import { + prepare, +} from '~/helpers/commons/index.js'; +import { error } from '~/helpers/log.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import getBroadcasterId from '~/helpers/user/getBroadcasterId.js'; +import twitch from '~/services/twitch.js'; + +const ERROR_NOT_ENOUGH_OPTIONS = 'Expected more parameters'; +const ERROR_NOT_OPTION = '7'; +let retryTimeout: NodeJS.Timeout | undefined; + +/* + * !bet open [-timeout 5] -title "your bet title" option | option | option | ... - open a new bet with selected options + * - -timeout in seconds - optional: default 120 + * - -title - must be in "" - optional + * !bet close [option] - close a bet and select option as winner + * !bet reuse - reuse latest bet + * !bet lock - lock current bet + */ +class Bets extends System { + @onStartup() + @onStreamStart() + async onStartup() { + try { + // initial load of predictions + const predictions = await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.predictions.getPredictions(getBroadcasterId())); + if (predictions) { + const prediction = predictions?.data.find(o => o.status === 'ACTIVE' || o.status === 'LOCKED'); + if (prediction) { + channelPrediction.status(prediction); + } + } + } catch (e) { + if (e instanceof Error && e.message.includes('not found in auth provider')) { + clearTimeout(retryTimeout); + retryTimeout = setTimeout(() => this.onStartup(), 10000); + } else { + throw e; + } + } + } + + @command('!bet open') + @default_permission(defaultPermissions.MODERATORS) + public async open(opts: CommandOptions): Promise { + try { + const [timeout, title, options] = new Expects(opts.parameters) + .argument({ + name: 'timeout', optional: true, default: 120, type: Number, + }) + .argument({ + name: 'title', optional: false, multi: true, + }) + .list({ delimiter: '|' }) + .toArray() as [number, string, string[]]; + if (options.length < 2) { + throw new Error(ERROR_NOT_ENOUGH_OPTIONS); + } + + await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.predictions.createPrediction(getBroadcasterId(), { + title, + outcomes: options, + autoLockAfter: timeout, + })); + + return []; + } catch (e: any) { + switch (e.message) { + case ERROR_NOT_ENOUGH_OPTIONS: + return [{ response: prepare('bets.notEnoughOptions'), ...opts }]; + default: + throw(e); + } + } + } + + @command('!bet lock') + @default_permission(defaultPermissions.MODERATORS) + public async lock(opts: CommandOptions): Promise { + try { + await twitch.apiClient?.asIntent(['broadcaster'], + ctx => ctx.predictions.lockPrediction(getBroadcasterId(), channelPrediction.status()!.id)); + } catch (e: any) { + error(e); + } + return []; + } + + @command('!bet reuse') + @default_permission(defaultPermissions.MODERATORS) + public async reuse(opts: CommandOptions): Promise { + try { + const predictions = await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.predictions.getPredictions(getBroadcasterId())); + if (predictions) { + const prediction = predictions?.data[0]; + + await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.predictions.createPrediction(getBroadcasterId(), { + outcomes: prediction.outcomes.map(o => o.title), + autoLockAfter: prediction.autoLockAfter, + title: prediction.title, + })); + } + } catch (e) { + error(e); + } + return []; + } + + @command('!bet close') + @default_permission(defaultPermissions.MODERATORS) + public async close(opts: CommandOptions): Promise { + try { + const index = new Expects(opts.parameters).number().toArray()[0]; + if (channelPrediction.status()) { + const outcome = channelPrediction.status()?.outcomes[index]; + if (!outcome) { + throw new Error(ERROR_NOT_OPTION); + } + await twitch.apiClient?.asIntent(['broadcaster'], + ctx => ctx.predictions.resolvePrediction(getBroadcasterId(), channelPrediction.status()!.id, outcome.id)); + } + } catch (e: any) { + error(e); + } + return []; + } +} + +const bets = new Bets(); +export default bets; diff --git a/backend/src/systems/checklist.ts b/backend/src/systems/checklist.ts new file mode 100644 index 000000000..47c748074 --- /dev/null +++ b/backend/src/systems/checklist.ts @@ -0,0 +1,43 @@ +import { Checklist as ChecklistEntity } from '@entity/checklist.js'; + +import System from './_interface.js'; +import { onChange, onStreamEnd } from '../decorators/on.js'; +import { settings, ui } from '../decorators.js'; + +import { AppDataSource } from '~/database.js'; +import { adminEndpoint } from '~/helpers/socket.js'; + +class Checklist extends System { + @settings('customization') + @ui({ type: 'configurable-list' }) + itemsArray: any[] = []; + + sockets() { + adminEndpoint('/systems/checklist', 'generic::getAll', async (cb) => { + try { + const checkedItems = await AppDataSource.getRepository(ChecklistEntity).find(); + cb(null, this.itemsArray, checkedItems); + } catch(e: any) { + cb(e.stack, [], []); + } + }); + adminEndpoint('/systems/checklist', 'checklist::save', async (checklistItem, cb) => { + await AppDataSource.getRepository(ChecklistEntity).save(checklistItem); + if (cb) { + cb(null); + } + }); + } + + @onChange('itemsArray') + onChangeItemsArray() { + AppDataSource.getRepository(ChecklistEntity).clear(); + } + + @onStreamEnd() + public onStreamEnd() { + AppDataSource.getRepository(ChecklistEntity).update({}, { isCompleted: false }); + } +} + +export default new Checklist(); diff --git a/backend/src/systems/commercial.ts b/backend/src/systems/commercial.ts new file mode 100644 index 000000000..f5a879c68 --- /dev/null +++ b/backend/src/systems/commercial.ts @@ -0,0 +1,88 @@ +import * as _ from 'lodash-es'; + +import System from './_interface.js'; +import { + command, default_permission, helper, +} from '../decorators.js'; + +import { getOwnerAsSender } from '~/helpers/commons/index.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { error, warning } from '~/helpers/log.js'; +import { addUIError } from '~/helpers/panel/alerts.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import twitch from '~/services/twitch.js'; +import { variables } from '~/watchers.js'; + +/* + * !commercial - gets an info about alias usage + * !commercial [duration] [?message] - run commercial + */ + +class Commercial extends System { + sockets() { + adminEndpoint('/systems/commercial', 'commercial.run', (data) => { + commercial.main({ + parameters: data.seconds, + command: '!commercial', + sender: getOwnerAsSender(), + attr: {}, + createdAt: Date.now(), + emotesOffsets: new Map(), + isAction: false, + isHighlight: false, + isFirstTimeMessage: false, + discord: undefined, + }); + }); + } + + @command('!commercial') + @default_permission(defaultPermissions.CASTERS) + @helper() + async main (opts: CommandOptions) { + const parsed = opts.parameters.match(/^([\d]+)? ?(.*)?$/); + + if (!parsed) { + return [{ response: '$sender, something went wrong with !commercial', ...opts }]; + } + + const commercial = { + duration: !_.isNil(parsed[1]) ? parseInt(parsed[1], 10) : null, + message: !_.isNil(parsed[2]) ? parsed[2] : null, + }; + + if (_.isNil(commercial.duration)) { + return [{ response: `Usage: ${opts.command} [duration] [optional-message]`, ...opts }]; + } + + const broadcasterId = variables.get('services.twitch.broadcasterId') as string; + const broadcasterCurrentScopes = variables.get('services.twitch.broadcasterCurrentScopes') as string[]; + // check if duration is correct (30, 60, 90, 120, 150, 180) + if ([30, 60, 90, 120, 150, 180].includes(commercial.duration ?? 0)) { + if (!broadcasterCurrentScopes.includes('channel:edit:commercial')) { + warning('Missing Broadcaster oAuth scope channel:edit:commercial to start commercial'); + addUIError({ name: 'OAUTH', message: 'Missing Broadcaster oAuth scope channel:edit:commercial to start commercial' }); + return; + } + + try { + await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.channels.startChannelCommercial(broadcasterId, commercial.duration as 30 | 60 | 90 | 120 | 150 | 180)); + eventEmitter.emit('commercial', { duration: commercial.duration ?? 30 }); + if (!_.isNil(commercial.message)) { + return [{ response: commercial.message, ...opts }]; + } + } catch (e: unknown) { + if (e instanceof Error) { + error(e.stack ?? e.message); + } + } + return []; + } else { + return [{ response: '$sender, available commercial duration are: 30, 60, 90, 120, 150 and 180', ...opts }]; + } + } +} + +const commercial = new Commercial(); +export default commercial; diff --git a/backend/src/systems/cooldown.ts b/backend/src/systems/cooldown.ts new file mode 100644 index 000000000..a914e782b --- /dev/null +++ b/backend/src/systems/cooldown.ts @@ -0,0 +1,520 @@ +import { + Cooldown as CooldownEntity, +} from '@entity/cooldown.js'; +import { Keyword } from '@entity/keyword.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import { validateOrReject } from 'class-validator'; +import _, { merge } from 'lodash-es'; +import { In } from 'typeorm'; + +import System from './_interface.js'; +import { parserReply } from '../commons.js'; +import { onChange } from '../decorators/on.js'; +import { + command, default_permission, parser, permission_settings, rollback, settings, +} from '../decorators.js'; +import { Expects } from '../expects.js'; +import { Parser } from '../parser.js'; + +import { AppDataSource } from '~/database.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { debug, error, info } from '~/helpers/log.js'; +import { app } from '~/helpers/panel.js'; +import { ParameterError } from '~/helpers/parameterError.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { getUserHighestPermission } from '~/helpers/permissions/getUserHighestPermission.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { isOwner } from '~/helpers/user/index.js'; +import { adminMiddleware } from '~/socket.js'; +import alias from '~/systems/alias.js'; +import customCommands from '~/systems/customcommands.js'; +import { translate } from '~/translate.js'; + +const cache: { id: string; cooldowns: CooldownEntity[] }[] = []; +const defaultCooldowns: { name: string; lastRunAt: number, permId: string }[] = []; +let viewers: {userId: string, timestamp: number, cooldownId: string}[] = []; + +setInterval(async () => { + for (const cooldown of await CooldownEntity.find()) { + viewers = viewers.filter(o => (Date.now() + o.timestamp < cooldown.miliseconds && o.cooldownId === cooldown.id) || o.cooldownId !== cooldown.id); + } +}, constants.HOUR); + +/* + * !cooldown set [keyword|!command|g:group] [global|user] [seconds] [true/false] - set cooldown for keyword or !command, true/false set quiet mode + * !cooldown unset [keyword|!command|g:group] - unset cooldown for keyword or !command, true/false set quiet mode + * !cooldown toggle moderators [keyword|!command|g:group] [global|user] - enable/disable specified keyword or !command cooldown for moderators + * !cooldown toggle owners [keyword|!command|g:group] [global|user] - enable/disable specified keyword or !command cooldown for owners + * !cooldown toggle subscribers [keyword|!command|g:group] [global|user] - enable/disable specified keyword or !command cooldown for owners + * !cooldown toggle enabled [keyword|!command|g:group] [global|user] - enable/disable specified keyword or !command cooldown + */ + +class Cooldown extends System { + @permission_settings('default', [ defaultPermissions.CASTERS ]) + defaultCooldownOfCommandsInSeconds = 0; + @permission_settings('default', [ defaultPermissions.CASTERS ]) + defaultCooldownOfKeywordsInSeconds = 0; + + @settings() + cooldownNotifyAsWhisper = false; + + @settings() + cooldownNotifyAsChat = true; + + @onChange('defaultCooldownOfKeywordsInSeconds') + resetDefaultCooldownsKeyword() { + let idx: number; + while ((idx = defaultCooldowns.findIndex(o => !o.name.startsWith('!'))) !== -1) { + defaultCooldowns.splice(idx, 1); + } + } + + @onChange('defaultCooldownOfCommandsInSeconds') + resetCooldownOfCommandsInSeconds(val: number) { + let idx: number; + while ((idx = defaultCooldowns.findIndex(o => o.name.startsWith('!'))) !== -1) { + defaultCooldowns.splice(idx, 1); + } + } + + constructor () { + super(); + this.addMenu({ + category: 'commands', name: 'cooldowns', id: 'commands/cooldowns', this: this, + }); + } + + sockets() { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/systems/cooldown', adminMiddleware, async (req, res) => { + res.send({ + data: await CooldownEntity.find(), + }); + }); + app.get('/api/systems/cooldown/:id', adminMiddleware, async (req, res) => { + res.send({ + data: await CooldownEntity.findOneBy({ id: req.params.id }), + }); + }); + app.delete('/api/systems/cooldown/:id', adminMiddleware, async (req, res) => { + await CooldownEntity.delete({ id: req.params.id }); + res.status(404).send(); + }); + app.post('/api/systems/cooldown', adminMiddleware, async (req, res) => { + try { + const itemToSave = new CooldownEntity(); + merge(itemToSave, req.body); + await validateOrReject(itemToSave); + await itemToSave.save(); + res.send({ data: itemToSave }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + } + + async help (opts: CommandOptions): Promise { + let url = 'http://sogebot.github.io/sogeBot/#/systems/cooldowns'; + if ((process.env?.npm_package_version ?? 'x.y.z-SNAPSHOT').includes('SNAPSHOT')) { + url = 'http://sogebot.github.io/sogeBot/#/_master/systems/cooldowns'; + } + return [{ response: translate('core.usage') + ' => ' + url, ...opts }]; + } + + @command('!cooldown set') + @default_permission(defaultPermissions.CASTERS) + async main (opts: CommandOptions): Promise { + try { + let [name, type, seconds, quiet] = new Expects(opts.parameters) + .string({ additionalChars: ':!', withSpaces: true }) + .oneOf({ values: ['global', 'user'], name: 'type' }) + .number() + .oneOf({ values: ['true'], optional: true }) + .toArray(); + + if (name.includes('\'')) { + name = name.replace(/'/g, ''); + } + + const cooldownToSave = new CooldownEntity(); + merge(cooldownToSave, { + name, + miliseconds: parseInt(seconds, 10) * 1000, + type, + timestamp: new Date(0).toISOString(), + isErrorMsgQuiet: quiet !== null, + isEnabled: true, + isOwnerAffected: false, + isModeratorAffected: false, + isSubscriberAffected: true, + }, await CooldownEntity.findOne({ + where: { + name, + type, + }, + })); + await cooldownToSave.save(); + + return [{ + response: prepare('cooldowns.cooldown-was-set', { + seconds, type, command: name, + }), ...opts, + }]; + } catch (e: any) { + error(`${opts.command} ${opts.parameters} [${opts.sender.userName}#${opts.sender.userId}]`); + error(e.stack); + if (e instanceof ParameterError) { + return this.help(opts); + } else { + return []; + } + } + } + + @command('!cooldown unset') + @default_permission(defaultPermissions.CASTERS) + async unset (opts: CommandOptions) { + try { + const [ commandOrKeyword ] = new Expects(opts.parameters).everything().toArray(); + await AppDataSource.getRepository(CooldownEntity).delete({ name: commandOrKeyword }); + return [{ response: prepare('cooldowns.cooldown-was-unset', { command: commandOrKeyword }), ...opts }]; + } catch (e: any) { + return this.help(opts); + } + } + + @parser({ priority: constants.HIGH, skippable: true }) + async check (opts: ParserOptions): Promise { + try { + if (!opts.sender) { + return true; + } + let data: (CooldownEntity | { type: 'default'; canBeRunAt: number; isEnabled: true; name: string; permId: string })[] = []; + let timestamp, now; + const [cmd, subcommand] = new Expects(opts.message) + .command({ optional: true }) + .string({ optional: true }) + .toArray(); + + if (!_.isNil(cmd)) { // command + let name = subcommand ? `${cmd} ${subcommand}` : cmd; + let isFound = false; + + const parsed = await ((opts.parser || new Parser()).find(subcommand ? `${cmd} ${subcommand}` : cmd, null)); + if (parsed) { + debug('cooldown.check', `Command found ${parsed.command}`); + name = parsed.command; + isFound = true; + } else { + // search in custom commands as well + if (customCommands.enabled) { + const foundCommands = await customCommands.find(subcommand ? `${cmd} ${subcommand}` : cmd); + if (foundCommands.length > 0) { + name = foundCommands[0].command.command; + isFound = true; + } + } + } + + if (!isFound) { + debug('cooldown.check', `'${name}' not found, reverting to simple '${cmd}'`); + name = cmd; // revert to basic command if nothing was found + } + + // get alias group + const groupName = []; + if (opts.message.startsWith('!')) { + const [parsedAlias] = await alias.search(opts); + if (parsedAlias && parsedAlias.group) { + debug('cooldown.check', `Will be searching for group '${parsedAlias.group}' as well.`); + groupName.push(`g:${parsedAlias.group}`); + } else { + const commands = await customCommands.find(opts.message); + for (const item of commands) { + if (item.command.group) { + debug('cooldown.check', `Will be searching for group '${item.command.group}' as well.`); + groupName.push(`g:${item.command.group}`); + } + } + } + } + + const cooldown = await AppDataSource.getRepository(CooldownEntity).findOne({ where: [{ name }, { name: In(groupName) }] }); + if (!cooldown) { + const defaultValue = await this.getPermissionBasedSettingsValue('defaultCooldownOfCommandsInSeconds'); + const permId = await getUserHighestPermission(opts.sender.userId); + + // user group have some default cooldown + if (defaultValue[permId] > 0) { + const canBeRunAt = (defaultCooldowns.find(o => + o.permId === permId + && o.name === cmd, + )?.lastRunAt ?? 0) + defaultValue[permId] * 1000; + data.push({ + isEnabled: true, + name: cmd, + type: 'default', + canBeRunAt, + permId, + }); + } else { + return true; + } + } else { + data = [cooldown]; + } + } else { // text + let [keywords, cooldowns] = await Promise.all([ + AppDataSource.getRepository(Keyword).find(), + AppDataSource.getRepository(CooldownEntity).find(), + ]); + + keywords = keywords.filter(o => { + return opts.message.toLowerCase().search(new RegExp('^(?!\\!)(?:^|\\s).*(' + _.escapeRegExp(o.keyword.toLowerCase()) + ')(?=\\s|$|\\?|\\!|\\.|\\,)', 'gi')) >= 0; + }); + + data = []; + for (const keyword of keywords) { + const cooldown = cooldowns.find((o) => o.name.toLowerCase() === keyword.keyword.toLowerCase()); + if (keyword.enabled) { + if (cooldown) { + data.push(cooldown); + } else { + const defaultValue = await this.getPermissionBasedSettingsValue('defaultCooldownOfKeywordsInSeconds'); + const permId = await getUserHighestPermission(opts.sender.userId); + // user group have some default cooldown + if (defaultValue[permId] > 0) { + const canBeRunAt = (defaultCooldowns.find(o => + o.permId === permId + && o.name === keyword.keyword, + )?.lastRunAt ?? 0) + defaultValue[permId] * 1000; + data.push({ + isEnabled: true, + name: keyword.keyword, + type: 'default', + permId, + canBeRunAt, + }); + } + } + } + } + } + if (!_.some(data, { isEnabled: true })) { // parse ok if all cooldowns are disabled + return true; + } + + const user = await changelog.get(opts.sender.userId); + if (!user) { + return true; + } + let result = false; + + const affectedCooldowns: CooldownEntity[] = []; + + for (const cooldown of data) { + debug('cooldown.check', `Checking cooldown entity: ${JSON.stringify(cooldown)}`); + if (cooldown.type === 'default') { + debug('cooldown.check', `Checking default cooldown ${cooldown.name} (${cooldown.permId}) ${cooldown.canBeRunAt}`); + if (cooldown.canBeRunAt >= Date.now()) { + debug('cooldown.check', `${opts.sender.userName}#${opts.sender.userId} have ${cooldown.name} on global default cooldown, remaining ${Math.ceil((cooldown.canBeRunAt - Date.now()) / 1000)}s`); + result = false; + } else { + const savedCooldown = defaultCooldowns.find(o => + o.permId === cooldown.permId + && o.name === cooldown.name, + ); + if (savedCooldown) { + savedCooldown.lastRunAt = Date.now(); + } else { + defaultCooldowns.push({ + lastRunAt: Date.now(), + name: cooldown.name, + permId: cooldown.permId, + }); + } + result = true; + } + continue; + } + debug('cooldown.check', `isOwner: ${isOwner(opts.sender)} isModerator: ${user.isModerator} isSubscriber: ${user.isSubscriber}`); + debug('cooldown.check', `isOwnerAffected: ${cooldown.isOwnerAffected} isModeratorAffected: ${cooldown.isModeratorAffected} isSubscriberAffected: ${cooldown.isSubscriberAffected}`); + + if ((isOwner(opts.sender) && !cooldown.isOwnerAffected) || (user.isModerator && !cooldown.isModeratorAffected) || (user.isSubscriber && !cooldown.isSubscriberAffected)) { + debug('cooldown.check', `User is not affected by this cooldown entity`); + result = true; + continue; + } + + const viewer = viewers.find(o => o.cooldownId === cooldown.id && o.userId === opts.sender?.userId); + debug('cooldown.db', viewer ?? `${opts.sender.userName}#${opts.sender.userId} not found in cooldown list`); + if (cooldown.type === 'global') { + timestamp = cooldown.timestamp ?? new Date(0).toISOString(); + } else { + timestamp = new Date(viewer?.timestamp || 0).toISOString(); + } + now = Date.now(); + + if (now - new Date(timestamp).getTime() >= cooldown.miliseconds) { + if (cooldown.type === 'global') { + cooldown.timestamp = new Date().toISOString(); + await cooldown.save(); + } else { + debug('cooldown.check', `${opts.sender.userName}#${opts.sender.userId} added to cooldown list.`); + viewers = [...viewers.filter(o => !(o.cooldownId === cooldown.id && o.userId === opts.sender?.userId)), { + timestamp: Date.now(), + cooldownId: cooldown.id, + userId: opts.sender.userId, + }]; + } + + merge(cooldown, { timestamp: new Date().toISOString() }); + affectedCooldowns.push(cooldown); + result = true; + continue; + } else { + if (!cooldown.isErrorMsgQuiet) { + if (this.cooldownNotifyAsWhisper) { + const response = prepare('cooldowns.cooldown-triggered', { command: opts.message, seconds: Math.ceil((cooldown.miliseconds - now + new Date(timestamp).getTime()) / 1000) }); + parserReply(response, opts, 'whisper'); // we want to whisp cooldown message + } + if (this.cooldownNotifyAsChat) { + const response = prepare('cooldowns.cooldown-triggered', { command: opts.message, seconds: Math.ceil((cooldown.miliseconds - now + new Date(timestamp).getTime()) / 1000) }); + parserReply(response, opts, 'chat'); + } + } + info(`${opts.sender.userName}#${opts.sender.userId} have ${cooldown.name} on cooldown, remaining ${Math.ceil((cooldown.miliseconds - now + new Date(timestamp).getTime()) / 1000)}s`); + result = false; + break; // disable _.each and updateQueue with false + } + } + + // cache cooldowns - keep only latest 50 + cache.push({ id: opts.id, cooldowns: affectedCooldowns }); + while(cache.length > 50) { + cache.shift(); + } + debug('cooldown.check', `User ${opts.sender.userName}#${opts.sender.userId} have ${result ? 'no' : 'some'} cooldowns`); + return result; + } catch (e: any) { + error(`Something went wrong during cooldown check: ${e.stack}`); + return false; + } + } + + @rollback() + async cooldownRollback (opts: ParserOptions): Promise { + if (!opts.sender) { + return true; + } + const cached = cache.find(o => o.id === opts.id); + if (cached) { + for (const cooldown of cached.cooldowns) { + if (cooldown.type === 'global') { + cooldown.timestamp = new Date(0).toISOString(); // we just revert to 0 as user were able to run it + } else { + viewers = viewers.filter(o => o.userId !== opts.sender?.userId && o.cooldownId !== cooldown.id); + } + // rollback timestamp + await AppDataSource.getRepository(CooldownEntity).save(cooldown); + } + } + cache.splice(cache.findIndex(o => o.id === opts.id), 1); + return true; + } + + async toggle (opts: CommandOptions, type: 'isEnabled' | 'isModeratorAffected' | 'isOwnerAffected' | 'isSubscriberAffected' | 'isErrorMsgQuiet' | 'type'): Promise { + try { + const [name, typeParameter] = new Expects(opts.parameters) + .string({ additionalChars: ':!' }) + .oneOf({ values: ['global', 'user'], name: 'type' }) + .toArray(); + + const cooldown = await AppDataSource.getRepository(CooldownEntity).findOne({ + where: { + name, + type: typeParameter, + }, + }); + if (!cooldown) { + return [{ response: prepare('cooldowns.cooldown-not-found', { command: name }), ...opts }]; + } + + if (type === 'type') { + await AppDataSource.getRepository(CooldownEntity).save({ + ...cooldown, + [type]: cooldown[type] === 'global' ? 'user' : 'global', + }); + } else { + await AppDataSource.getRepository(CooldownEntity).save({ + ...cooldown, + [type]: !cooldown[type], + }); + } + + let path = ''; + const status = !cooldown[type] ? 'enabled' : 'disabled'; + + if (type === 'isModeratorAffected') { + path = '-for-moderators'; + } + if (type === 'isOwnerAffected') { + path = '-for-owners'; + } + if (type === 'isSubscriberAffected') { + path = '-for-subscribers'; + } + if (type === 'isErrorMsgQuiet' || type === 'type') { + return []; + } // those two are setable only from dashboard + + return [{ response: prepare(`cooldowns.cooldown-was-${status}${path}`, { command: cooldown.name }), ...opts }]; + } catch (e: any) { + + error(`${opts.command} ${opts.parameters} [${opts.sender.userName}#${opts.sender.userId}]`); + error(e.stack); + if (e instanceof ParameterError) { + return this.help(opts); + } else { + return []; + } + } + } + + @command('!cooldown toggle enabled') + @default_permission(defaultPermissions.CASTERS) + async toggleEnabled (opts: CommandOptions) { + return this.toggle(opts, 'isEnabled'); + } + + @command('!cooldown toggle moderators') + @default_permission(defaultPermissions.CASTERS) + async toggleModerators (opts: CommandOptions) { + return this.toggle(opts, 'isModeratorAffected'); + } + + @command('!cooldown toggle owners') + @default_permission(defaultPermissions.CASTERS) + async toggleOwners (opts: CommandOptions) { + return this.toggle(opts, 'isOwnerAffected'); + } + + @command('!cooldown toggle subscribers') + @default_permission(defaultPermissions.CASTERS) + async toggleSubscribers (opts: CommandOptions) { + return this.toggle(opts, 'isSubscriberAffected'); + } + + async toggleNotify (opts: CommandOptions) { + return this.toggle(opts, 'isErrorMsgQuiet'); + } + async toggleType (opts: CommandOptions) { + return this.toggle(opts, 'type'); + } +} + +export default new Cooldown(); diff --git a/backend/src/systems/customcommands.ts b/backend/src/systems/customcommands.ts new file mode 100644 index 000000000..0367910d9 --- /dev/null +++ b/backend/src/systems/customcommands.ts @@ -0,0 +1,464 @@ +import { + Commands, CommandsGroup, +} from '@entity/commands.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import { validateOrReject } from 'class-validator'; +import { cloneDeep, merge, orderBy, shuffle } from 'lodash-es'; +import { v4 } from 'uuid'; + +import System from './_interface.js'; +import { parserReply } from '../commons.js'; +import { + command, default_permission, helper, + parser, + timer, +} from '../decorators.js'; +import { Expects } from '../expects.js'; + +import { checkFilter } from '~/helpers/checkFilter.js'; +import { + getAllCountOfCommandUsage, getCountOfCommandUsage, incrementCountOfCommandUsage, resetCountOfCommandUsage, +} from '~/helpers/commands/count.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { info, warning } from '~/helpers/log.js'; +import { app } from '~/helpers/panel.js'; +import { check } from '~/helpers/permissions/check.js'; +import { defaultPermissions } from '~/helpers/permissions/defaultPermissions.js'; +import { get } from '~/helpers/permissions/get.js'; +import { adminMiddleware } from '~/socket.js'; +import { translate } from '~/translate.js'; + +/* + * !command - gets an info about command usage + * !command add (-p [uuid|name]) ?-s true|false -c ![cmd] -r [response] - add command with specified response + * !command edit (-p [uuid|name]) ?-s true|false -c ![cmd] -rid [number] -r [response] - edit command with specified response + * !command remove -c ![cmd] - remove specified command + * !command remove -c ![cmd] -rid [number] - remove specified response of command + * !command toggle ![cmd] - enable/disable specified command + * !command toggle-visibility ![cmd] - enable/disable specified command + * !command list - get commands list + * !command list ![cmd] - get responses of command + */ + +class CustomCommands extends System { + constructor () { + super(); + this.addMenu({ + category: 'commands', name: 'customcommands', id: 'commands/customcommands', this: this, + }); + } + + sockets () { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/systems/customcommands', adminMiddleware, async (req, res) => { + res.send({ + data: await Commands.find(), + count: await getAllCountOfCommandUsage(), + }); + }); + app.get('/api/systems/customcommands/groups/', adminMiddleware, async (req, res) => { + let groupsList = await CommandsGroup.find(); + for (const item of await Commands.find()) { + if (item.group && !groupsList.find(o => o.name === item.group)) { + // we dont have any group options -> create temporary group + const group = new CommandsGroup(); + group.name = item.group; + group.options = { + filter: null, + permission: null, + }; + groupsList = [ + ...groupsList, + group, + ]; + } + } + res.send({ + data: groupsList, + }); + }); + app.get('/api/systems/customcommands/:id', adminMiddleware, async (req, res) => { + const cmd = await Commands.findOneBy({ id: req.params.id }); + res.send({ + data: cmd, + count: cmd ? await getCountOfCommandUsage(cmd.command) : 0, + }); + }); + app.delete('/api/systems/customcommands/groups/:name', adminMiddleware, async (req, res) => { + const group = await CommandsGroup.findOneBy({ name: req.params.name }); + if (group) { + await group.remove(); + } + res.status(404).send(); + }); + app.delete('/api/systems/customcommands/:id', adminMiddleware, async (req, res) => { + const cmd = await Commands.findOneBy({ id: req.params.id }); + if (cmd) { + await cmd.remove(); + } + res.status(404).send(); + }); + app.post('/api/systems/customcommands/group', adminMiddleware, async (req, res) => { + try { + const itemToSave = new CommandsGroup(); + merge(itemToSave, req.body); + await validateOrReject(itemToSave); + await itemToSave.save(); + res.send({ data: itemToSave }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + app.post('/api/systems/customcommands', adminMiddleware, async (req, res) => { + try { + const itemToSave = new Commands(); + const { count, ...data } = req.body; + merge(itemToSave, data); + await validateOrReject(itemToSave); + await itemToSave.save(); + + if (count === 0) { + await resetCountOfCommandUsage(itemToSave.command); + } + + res.send({ data: itemToSave }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + } + + @command('!command') + @default_permission(defaultPermissions.CASTERS) + @helper() + main (opts: CommandOptions) { + let url = 'http://sogebot.github.io/sogeBot/#/systems/custom-commands'; + if ((process.env?.npm_package_version ?? 'x.y.z-SNAPSHOT').includes('SNAPSHOT')) { + url = 'http://sogebot.github.io/sogeBot/#/_master/systems/custom-commands'; + } + return [{ response: translate('core.usage') + ' => ' + url, ...opts }]; + } + + @command('!command edit') + @default_permission(defaultPermissions.CASTERS) + async edit (opts: CommandOptions) { + try { + const [userlevel, stopIfExecuted, cmd, rId, response] = new Expects(opts.parameters) + .permission({ optional: true, default: defaultPermissions.VIEWERS }) + .argument({ + optional: true, name: 's', default: null, type: Boolean, + }) + .argument({ + name: 'c', type: String, multi: true, delimiter: '', + }) + .argument({ name: 'rid', type: Number }) + .argument({ + name: 'r', type: String, multi: true, delimiter: '', + }) + .toArray(); + + if (!cmd.startsWith('!')) { + throw Error('Command should start with !'); + } + + const cDb = await Commands.findOneBy({ command: cmd }); + if (!cDb) { + return [{ response: prepare('customcmds.command-was-not-found', { command: cmd }), ...opts }]; + } + + const responseDb = cDb.responses.find(o => o.order === (rId - 1)); + if (!responseDb) { + return [{ response: prepare('customcmds.response-was-not-found', { command: cmd, response: rId }), ...opts }]; + } + + const pItem = await get(userlevel); + if (!pItem) { + throw Error('Permission ' + userlevel + ' not found.'); + } + + responseDb.response = response; + responseDb.permission = pItem.id ?? defaultPermissions.VIEWERS; + if (stopIfExecuted) { + responseDb.stopIfExecuted = stopIfExecuted; + } + + await cDb.save(); + return [{ response: prepare('customcmds.command-was-edited', { command: cmd, response }), ...opts }]; + } catch (e: any) { + return [{ response: prepare('customcmds.commands-parse-failed', { command: this.getCommand('!command') }), ...opts }]; + } + } + + @command('!command add') + @default_permission(defaultPermissions.CASTERS) + async add (opts: CommandOptions): Promise { + try { + const [userlevel, stopIfExecuted, cmd, response] = new Expects(opts.parameters) + .permission({ optional: true, default: defaultPermissions.VIEWERS }) + .argument({ + optional: true, name: 's', default: false, type: Boolean, + }) + .argument({ + name: 'c', type: String, multi: true, delimiter: '', + }) + .argument({ + name: 'r', type: String, multi: true, delimiter: '', + }) + .toArray(); + + if (!cmd.startsWith('!')) { + throw Error('Command should start with !'); + } + + const cDb = await Commands.findOneBy({ command: cmd }); + if (!cDb) { + const newCommand = new Commands(); + newCommand.command = cmd; + newCommand.enabled = true; + newCommand.visible = true; + await newCommand.save(); + return this.add(opts); + } + + const pItem = await get(userlevel); + if (!pItem) { + throw Error('Permission ' + userlevel + ' not found.'); + } + + cDb.responses.push({ + id: v4(), + order: cDb.responses.length, + permission: pItem.id ?? defaultPermissions.VIEWERS, + stopIfExecuted: stopIfExecuted, + response: response, + filter: '', + }); + await cDb.save(); + return [{ response: prepare('customcmds.command-was-added', { command: cmd }), ...opts }]; + } catch (e: any) { + return [{ response: prepare('customcmds.commands-parse-failed', { command: this.getCommand('!command') }), ...opts }]; + } + } + + async find(search: string) { + const commandsSearchProgress: { + command: Commands; + cmdArray: string[]; + }[] = []; + const cmdArray = search.toLowerCase().split(' '); + for (let i = 0, len = search.toLowerCase().split(' ').length; i < len; i++) { + const db_commands = (await Commands.find()).filter(o => o.command === cmdArray.join(' ')); + for (const cmd of db_commands) { + commandsSearchProgress.push({ + cmdArray: cloneDeep(cmdArray), + command: cmd, + }); + } + cmdArray.pop(); // remove last array item if not found + } + return commandsSearchProgress; + } + + @timer() + @parser({ priority: constants.HIGHEST, fireAndForget: true }) + async run (opts: ParserOptions & { quiet?: boolean, processedCommands?: string[] }): Promise { + if (!opts.message.startsWith('!') || !opts.sender) { + return true; + } // do nothing if it is not a command + + const _commands = await this.find(opts.message); + if (_commands.length === 0) { + return true; + } // no command was found - return + + // go through all commands + let atLeastOnePermissionOk = false; + for (const cmd of _commands) { + if (!cmd.command.enabled) { + atLeastOnePermissionOk = true; // continue if command is disabled + warning(`Custom command ${cmd.command.command} (${cmd.command.id}) is disabled!`); + continue; + } + const _responses: Commands['responses'] = []; + // remove found command from message to get param + const param = opts.message.replace(new RegExp('^(' + cmd.cmdArray.join(' ') + ')', 'i'), '').trim(); + incrementCountOfCommandUsage(cmd.command.command); + + // check group filter first + let group: CommandsGroup | null; + let groupPermission: null | string = null; + if (cmd.command.group) { + group = await CommandsGroup.findOneBy({ name: cmd.command.group }); + if (group) { + if (group.options.filter && !(await checkFilter(opts, group.options.filter))) { + warning(`Custom command ${cmd.command.command}#${cmd.command.id} didn't pass group filter.`); + continue; + } + groupPermission = group.options.permission; + } + } + + const responses = cmd.command.areResponsesRandomized ? shuffle(cmd.command.responses) : orderBy(cmd.command.responses, 'order', 'asc'); + for (const r of responses) { + let permission = r.permission ?? groupPermission; + // show warning if null permission + if (!permission) { + permission = defaultPermissions.CASTERS; + warning(`Custom command ${cmd.command.command}#${cmd.command.id}|${r.order} doesn't have any permission set, treating as CASTERS permission.`); + } + + if ((opts.skip || (await check(opts.sender.userId, permission, false)).access) + && (r.filter.length === 0 || (r.filter.length > 0 && await checkFilter(opts, r.filter)))) { + _responses.push(r); + atLeastOnePermissionOk = true; + if (r.stopIfExecuted) { + break; + } + } + + if (!atLeastOnePermissionOk) { + info(`User ${opts.sender.userName}#${opts.sender.userId} doesn't have permissions or filter to use custom command ${cmd.command.command}#${cmd.command.id}`); + } + } + + if (!opts.quiet) { + this.sendResponse(cloneDeep(_responses), { + param, sender: opts.sender, command: cmd.command.command, processedCommands: opts.processedCommands, discord: opts.discord, id: opts.id, + }); + } + } + return atLeastOnePermissionOk; + } + + async sendResponse(responses: Commands['responses'], opts: { param: string; sender: CommandOptions['sender'], discord: CommandOptions['discord'], command: string, processedCommands?: string[], id: string, }) { + for (let i = 0; i < responses.length; i++) { + await parserReply(responses[i].response, opts); + } + } + + @command('!command list') + @default_permission(defaultPermissions.CASTERS) + async list (opts: CommandOptions) { + const cmd = new Expects(opts.parameters).command({ optional: true }).toArray()[0]; + + const commands = await Commands.find(); + if (!cmd) { + // print commands + const _commands = commands.filter(o => o.visible && o.enabled); + const response = (_commands.length === 0 ? translate('customcmds.list-is-empty') : translate('customcmds.list-is-not-empty').replace(/\$list/g, orderBy(_commands, 'command').map(o => o.command).join(', '))); + return [{ response, ...opts }]; + } else { + // print responses + const command_with_responses = commands.find(o => o.command === cmd); + + if (!command_with_responses || command_with_responses.responses.length === 0) { + return [{ response: prepare('customcmds.list-of-responses-is-empty', { command: cmd }), ...opts }]; + } + return Promise.all(orderBy(command_with_responses.responses, 'order', 'asc').map(async(r) => { + const perm = r.permission ? await get(r.permission) : { name: '-- unset --' }; + const response = prepare('customcmds.response', { + command: cmd, index: ++r.order, response: r.response, after: r.stopIfExecuted ? '_' : 'v', permission: perm?.name ?? 'n/a', + }); + return { response, ...opts }; + })); + } + } + + @command('!command toggle') + @default_permission(defaultPermissions.CASTERS) + async toggle (opts: CommandOptions): Promise { + try { + const [cmdInput, subcommand] = new Expects(opts.parameters) + .command() + .string({ optional: true }) + .toArray(); + + const cmd = await Commands.findOneBy({ command: (cmdInput + ' ' + subcommand).trim() }); + if (!cmd) { + const response = prepare('customcmds.command-was-not-found', { command: (cmdInput + ' ' + subcommand).trim() }); + return [{ response, ...opts }]; + } + cmd.enabled = !cmd.enabled; + await cmd.save(); + return [{ response: prepare(cmd.enabled ? 'customcmds.command-was-enabled' : 'customcmds.command-was-disabled', { command: cmd.command }), ...opts }]; + } catch (e: any) { + const response = prepare('customcmds.commands-parse-failed', { command: this.getCommand('!command') }); + return [{ response, ...opts }]; + } + } + + @command('!command toggle-visibility') + @default_permission(defaultPermissions.CASTERS) + async toggleVisibility (opts: CommandOptions): Promise { + try { + const [cmdInput, subcommand] = new Expects(opts.parameters) + .command() + .string({ optional: true }) + .toArray(); + + const cmd = await Commands.findOneBy({ command: (cmdInput + ' ' + subcommand).trim() }); + if (!cmd) { + const response = prepare('customcmds.command-was-not-found', { command: (cmdInput + ' ' + subcommand).trim() }); + return [{ response, ...opts }]; + } + cmd.visible = !cmd.visible; + await cmd.save(); + + const response = prepare(cmd.visible ? 'customcmds.command-was-exposed' : 'customcmds.command-was-concealed', { command: cmd.command }); + return [{ response, ...opts }]; + + } catch (e: any) { + const response = prepare('customcmds.commands-parse-failed', { command: this.getCommand('!command') }); + return [{ response, ...opts }]; + } + } + + @command('!command remove') + @default_permission(defaultPermissions.CASTERS) + async remove (opts: CommandOptions) { + try { + const [cmd, rId] = new Expects(opts.parameters) + .argument({ + name: 'c', type: String, multi: true, delimiter: '', + }) + .argument({ + name: 'rid', type: Number, optional: true, default: 0, + }) + .toArray(); + + if (!cmd.startsWith('!')) { + throw Error('Command should start with !'); + } + + const command_db = await Commands.findOneBy({ command: cmd }); + if (!command_db) { + return [{ response: prepare('customcmds.command-was-not-found', { command: cmd }), ...opts }]; + } else { + let response = prepare('customcmds.command-was-removed', { command: cmd }); + if (rId >= 1) { + const responseDb = command_db.responses.filter(o => o.order !== (rId - 1)); + + // reorder + responseDb.forEach((item, index) => { + item.order = index; + }); + + await command_db.save(); + + response = prepare('customcmds.response-was-removed', { command: cmd, response: rId }); + } else { + await command_db.remove(); + } + return [{ response, ...opts }]; + } + } catch (e: any) { + return [{ response: prepare('customcmds.commands-parse-failed', { command: this.getCommand('!command') }), ...opts }]; + } + } +} + +export default new CustomCommands(); diff --git a/backend/src/systems/emotescombo.ts b/backend/src/systems/emotescombo.ts new file mode 100644 index 000000000..d8d35fc25 --- /dev/null +++ b/backend/src/systems/emotescombo.ts @@ -0,0 +1,147 @@ +import * as constants from '@sogebot/ui-helpers/constants.js'; + +import System from './_interface.js'; +import { parserReply } from '../commons.js'; +import { parser, settings } from '../decorators.js'; + +import { onStreamStart } from '~/decorators/on.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { ioServer } from '~/helpers/panel.js'; +import { translate } from '~/translate.js'; + +class EmotesCombo extends System { + @settings() + comboCooldown = 0; + @settings() + comboMessageMinThreshold = 3; + @settings() + hypeMessagesEnabled = true; + @settings() + hypeMessages = [ + { messagesCount: 5, message: translate('ui.overlays.emotes.hype.5') }, + { messagesCount: 15, message: translate('ui.overlays.emotes.hype.15') }, + ]; + @settings() + comboMessages = [ + { messagesCount: 3, message: translate('ui.overlays.emotes.message.3') }, + { messagesCount: 5, message: translate('ui.overlays.emotes.message.5') }, + { messagesCount: 10, message: translate('ui.overlays.emotes.message.10') }, + { messagesCount: 15, message: translate('ui.overlays.emotes.message.15') }, + { messagesCount: 20, message: translate('ui.overlays.emotes.message.20') }, + ]; + comboEmote = ''; + comboEmoteCount = 0; + comboLastBreak = 0; + + @onStreamStart() + reset() { + this.comboEmote = ''; + this.comboEmoteCount = 0; + this.comboLastBreak = 0; + } + + @parser({ priority: constants.LOW, fireAndForget: true }) + async containsEmotes (opts: ParserOptions) { + if (!opts.sender || !this.enabled) { + return true; + } + + const Emotes = (await import('../emotes.js')).default; + + const parsed: string[] = []; + const usedEmotes: { [code: string]: typeof Emotes.cache[number]} = {}; + + if (opts.emotesOffsets) { + // add emotes from twitch which are not maybe in cache (other partner emotes etc) + for (const emoteId of opts.emotesOffsets.keys()) { + // if emote is already in cache, continue + const firstEmoteOffset = opts.emotesOffsets.get(emoteId)?.shift(); + if (!firstEmoteOffset) { + continue; + } + const emoteCode = opts.message.slice(Number(firstEmoteOffset.split('-')[0]), Number(firstEmoteOffset.split('-')[1])+1); + const emoteFromCache = Emotes.cache.find(o => o.code === emoteCode); + if (!emoteFromCache) { + const data = { + type: 'twitch', + code: emoteCode, + urls: { + '1': 'https://static-cdn.jtvnw.net/emoticons/v1/' + emoteId + '/1.0', + '2': 'https://static-cdn.jtvnw.net/emoticons/v1/' + emoteId + '/2.0', + '3': 'https://static-cdn.jtvnw.net/emoticons/v1/' + emoteId + '/3.0', + }, + } as const; + + // update emotes in cache + Emotes.cache.push(data); + } + } + } + + for (const potentialEmoteCode of opts.message.split(' ')) { + if (parsed.includes(potentialEmoteCode)) { + continue; + } // this emote was already parsed + parsed.push(potentialEmoteCode); + + const emoteFromCache = Emotes.cache.find(o => o.code === potentialEmoteCode); + if (emoteFromCache) { + for (let i = 0; i < opts.message.split(' ').filter(word => word === potentialEmoteCode).length; i++) { + usedEmotes[potentialEmoteCode] = emoteFromCache; + } + } + } + + if (Date.now() - this.comboLastBreak > this.comboCooldown * constants.SECOND) { + const uniqueEmotes = Object.keys(usedEmotes); + // we want to count only messages with emotes (skip text only) + if (uniqueEmotes.length !== 0) { + if (uniqueEmotes.length > 1 || (uniqueEmotes[0] !== this.comboEmote && this.comboEmote !== '')) { + // combo breaker + if (this.comboMessageMinThreshold <= this.comboEmoteCount) { + this.comboLastBreak = Date.now(); + const message = this.comboMessages + .sort((a, b) => a.messagesCount - b.messagesCount) + .filter(o => o.messagesCount <= this.comboEmoteCount) + .pop(); + if (message) { + // send message about combo break + parserReply( + prepare(message.message, { + emote: this.comboEmote, + amount: this.comboEmoteCount, + }, false), + { ...opts, forbidReply: true }, + ); + } + } + this.comboEmoteCount = 1; + this.comboEmote = uniqueEmotes[0]; + ioServer?.of('/systems/emotescombo').emit('combo', { count: this.comboEmoteCount, url: null }); + } else { + this.comboEmoteCount++; + this.comboEmote = uniqueEmotes[0]; + + if (this.hypeMessagesEnabled) { + const message = this.hypeMessages + .sort((a, b) => a.messagesCount - b.messagesCount) + .find(o => o.messagesCount === this.comboEmoteCount); + if (message) { + parserReply( + prepare(message.message, { + emote: this.comboEmote, + amount: this.comboEmoteCount, + }, false), + { ...opts, forbidReply: true }, + ); + } + } + ioServer?.of('/systems/emotescombo').emit('combo', { count: this.comboEmoteCount, url: usedEmotes[this.comboEmote].urls['3'] }); + } + } + } + return true; + } +} + +export default new EmotesCombo(); \ No newline at end of file diff --git a/backend/src/systems/highlights.ts b/backend/src/systems/highlights.ts new file mode 100644 index 000000000..8b2856e6f --- /dev/null +++ b/backend/src/systems/highlights.ts @@ -0,0 +1,196 @@ +import { Highlight } from '@entity/highlight.js'; +import { dayjs } from '@sogebot/ui-helpers/dayjsHelper.js'; +import { timestampToObject } from '@sogebot/ui-helpers/getTime.js'; +import { Request, Response } from 'express'; +import { isNil } from 'lodash-es'; + +import System from './_interface.js'; +import { + command, default_permission, settings, ui, +} from '../decorators.js'; +import { createClip } from '../services/twitch/calls/createClip.js'; + +import { + isStreamOnline, stats, streamStatusChangeSince, +} from '~/helpers/api/index.js'; +import { getUserSender } from '~/helpers/commons/index.js'; +import { error } from '~/helpers/log.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import getBotId from '~/helpers/user/getBotId.js'; +import getBotUserName from '~/helpers/user/getBotUserName.js'; +import getBroadcasterId from '~/helpers/user/getBroadcasterId.js'; +import { createMarker } from '~/services/twitch/calls/createMarker.js'; +import twitch from '~/services/twitch.js'; +import { translate } from '~/translate.js'; + +const ERROR_STREAM_NOT_ONLINE = '1'; +const ERROR_MISSING_TOKEN = '2'; + +/* + * !highlight - save highlight with optional description + * !highlight list - get list of highlights in current running or latest stream + */ + +class Highlights extends System { + @settings('urls') + @ui({ type: 'highlights-url-generator' }) + urls: { url: string; clip: boolean; highlight: boolean }[] = []; + + constructor() { + super(); + this.addMenu({ + category: 'manage', name: 'highlights', id: 'manage/highlights', this: this, + }); + } + + public sockets() { + adminEndpoint('/systems/highlights', 'highlight', () => { + this.main({ + parameters: '', sender: getUserSender(getBotId(), getBotUserName()), attr: {}, command: '!highlight', createdAt: Date.now(), isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, + }); + }); + adminEndpoint('/systems/highlights', 'generic::getAll', async (cb) => { + (async function getAll(callback): Promise { + const highlightsToCheck = await Highlight.find({ order: { createdAt: 'DESC' }, where: { expired: false } }); + try { + const availableVideos = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.videos.getVideosByIds(highlightsToCheck.map(o => o.videoId))) ?? []; + + for (const highlight of highlightsToCheck) { + if (!availableVideos.find(o => o.id === highlight.videoId)) { + await Highlight.update(highlight.id, { expired: true }); + } + } + const highlights = await Highlight.find({ order: { createdAt: 'DESC' } }); + callback(null, highlights, availableVideos); + } catch (err: any) { + if (err._statusCode === 404) { + for (const highlight of highlightsToCheck) { + await Highlight.update(highlight.id, { expired: true }); + } + return getAll(callback); + } + callback(err.stack, [], []); + } + })(cb); + }); + adminEndpoint('/systems/highlights', 'generic::deleteById', async (id, cb) => { + try { + await Highlight.delete({ id }); + cb(null); + } catch (err: any) { + cb(err.message); + } + }); + } + + public async url(req: Request, res: Response) { + const url = this.urls.find((o) => { + const splitURL = o.url.split('/'); + const id = splitURL[splitURL.length - 1]; + return req.params.id === id; + }); + if (url) { + if (!this.enabled) { + return res.status(412).send({ error: 'Highlights system is disabled' }); + } else { + if (!(isStreamOnline.value)) { + return res.status(412).send({ error: 'Stream is offline' }); + } else { + if (url.clip) { + try { + const cid = await createClip({ createAfterDelay: false }); + if (!cid) { + throw new Error('Clip was not created!'); + } + } catch (e) { + if (e instanceof Error) { + error(e.stack ?? e.message); + } + return res.status(403).send({ error: 'Clip was not created!' }); + } + } + if (url.highlight) { + this.main({ + parameters: '', sender: getUserSender(getBotId(), getBotUserName()), attr: {}, command: '!highlight', createdAt: Date.now(), isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, + }); + } + return res.status(200).send({ ok: true }); + } + } + } else { + return res.status(404).send({ error: 'Unknown highlights link' }); + } + } + + @command('!highlight') + @default_permission(defaultPermissions.CASTERS) + public async main(opts: CommandOptions): Promise { + try { + if (!isStreamOnline.value) { + throw Error(ERROR_STREAM_NOT_ONLINE); + } + + const videos = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.videos.getVideosByUser(getBroadcasterId(), { type: 'archive', limit: 1 })); + if (!videos) { + throw new Error('Api is not ready'); + } + + const timestamp = timestampToObject(dayjs().valueOf() - dayjs(streamStatusChangeSince.value).valueOf()); + const highlight = Highlight.create({ + videoId: videos.data[0].id, + timestamp: { + hours: timestamp.hours, minutes: timestamp.minutes, seconds: timestamp.seconds, + }, + game: stats.value.currentGame || 'n/a', + title: stats.value.currentTitle || 'n/a', + expired: false, + }); + return this.add(highlight, timestamp, opts); + } catch (err: any) { + switch (err.message) { + case ERROR_STREAM_NOT_ONLINE: + error('Cannot highlight - stream offline'); + return [{ response: translate('highlights.offline'), ...opts }]; + case ERROR_MISSING_TOKEN: + error('Cannot highlight - missing token'); + break; + default: + error(err.stack); + } + return []; + } + } + + public async add(highlight: Highlight, timestamp: TimestampObject, opts: CommandOptions): Promise { + createMarker(); + Highlight.insert(highlight); + return [{ + response: translate('highlights.saved') + .replace(/\$hours/g, (timestamp.hours < 10) ? '0' + timestamp.hours : timestamp.hours) + .replace(/\$minutes/g, (timestamp.minutes < 10) ? '0' + timestamp.minutes : timestamp.minutes) + .replace(/\$seconds/g, (timestamp.seconds < 10) ? '0' + timestamp.seconds : timestamp.seconds), ...opts, + }]; + } + + @command('!highlight list') + @default_permission(defaultPermissions.CASTERS) + public async list(opts: CommandOptions): Promise { + const sortedHighlights = await Highlight.find({ order: { createdAt: 'DESC' } }); + const latestStreamId = sortedHighlights.length > 0 ? sortedHighlights[0].videoId : null; + + if (isNil(latestStreamId)) { + return [{ response: translate('highlights.list.empty'), ...opts }]; + } + const list: string[] = []; + + for (const highlight of sortedHighlights.filter((o) => o.videoId === latestStreamId)) { + list.push(highlight.timestamp.hours + 'h' + + highlight.timestamp.minutes + 'm' + + highlight.timestamp.seconds + 's'); + } + return [{ response: translate(list.length > 0 ? 'highlights.list.items' : 'highlights.list.empty').replace(/\$items/g, list.join(', ')), ...opts }]; + } +} + +export default new Highlights(); diff --git a/backend/src/systems/howlongtobeat.ts b/backend/src/systems/howlongtobeat.ts new file mode 100644 index 000000000..e9a72b26f --- /dev/null +++ b/backend/src/systems/howlongtobeat.ts @@ -0,0 +1,280 @@ +import { CacheGames } from '@entity/cacheGames.js'; +import { HowLongToBeatGame } from '@entity/howLongToBeatGame.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import { HowLongToBeatService } from 'howlongtobeat'; +import { EntityNotFoundError } from 'typeorm'; + +import System from './_interface.js'; +import { onStartup } from '../decorators/on.js'; +import { command, default_permission } from '../decorators.js'; +import { Expects } from '../expects.js'; + +import { AppDataSource } from '~/database.js'; +import { + isStreamOnline, stats, streamStatusChangeSince, +} from '~/helpers/api/index.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { + debug, error, +} from '~/helpers/log.js'; +import { app } from '~/helpers/panel.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { adminMiddleware } from '~/socket.js'; + +class HowLongToBeat extends System { + interval: number = constants.MINUTE; + hltbService = new HowLongToBeatService(); + + @onStartup() + onStartup() { + this.addMenu({ + category: 'manage', name: 'howlongtobeat', id: 'manage/howlongtobeat', this: this, + }); + + setInterval(() => { + this.updateGameplayTimes(); + }, constants.HOUR); + + let lastDbgMessage = ''; + setInterval(async () => { + const dbgMessage = `streamOnline: ${isStreamOnline.value}, enabled: ${this.enabled}, currentGame: ${ stats.value.currentGame}`; + if (lastDbgMessage !== dbgMessage) { + lastDbgMessage = dbgMessage; + debug('hltb', dbgMessage); + } + if (isStreamOnline.value && this.enabled) { + this.addToGameTimestamp(); + } + }, this.interval); + } + + async updateGameplayTimes() { + const games = await HowLongToBeatGame.find(); + + for (const game of games) { + try { + if (Date.now() - new Date(game.updatedAt!).getTime() < constants.DAY) { + throw new Error('Updated recently'); + } + + if (['irl', 'always on', 'software and game development'].includes(game.game.toLowerCase())) { + throw new Error('Ignored game'); + } + + const gameFromHltb = (await this.hltbService.search(game.game))[0]; + if (!gameFromHltb) { + throw new Error('Game not found'); + } + + game.gameplayMain = gameFromHltb.gameplayMain; + game.gameplayMainExtra = gameFromHltb.gameplayMainExtra; + game.gameplayCompletionist = gameFromHltb.gameplayCompletionist; + await game.save(); + } catch (e) { + continue; + } + } + } + + sockets() { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/systems/hltb', adminMiddleware, async (req, res) => { + res.send({ + data: await HowLongToBeatGame.find(), + thumbnails: await AppDataSource.getRepository(CacheGames).find(), + }); + }); + app.post('/api/systems/hltb/:id', async (req, res) => { + try { + delete req.body.streams; // remove streams to not change this + + let game = await HowLongToBeatGame.findOne({ where: { id: req.params.id } }); + if (!game) { + game = HowLongToBeatGame.create(req.body); + } else { + for (const key of Object.keys(req.body)) { + (game as any)[key as any] = req.body[key]; + } + } + res.send({ + data: await game!.validateAndSave(), + }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + app.get('/api/systems/hltb/:id', async (req, res) => { + res.send({ + data: await HowLongToBeatGame.findOne({ where: { id: req.params.id } }), + }); + }); + app.delete('/api/systems/hltb/:id', adminMiddleware, async (req, res) => { + const item = await HowLongToBeatGame.findOne({ where: { id: req.params.id } }); + await item?.remove(); + res.status(404).send(); + }); + app.post('/api/systems/hltb', adminMiddleware, async (req, res) => { + try { + if (req.query.search) { + const search = await this.hltbService.search(req.query.search as string); + const games = await HowLongToBeatGame.find(); + + res.send({ + data: search + .filter((o: any) => { + // we need to filter already added gaems + return !games.map(a => a.game.toLowerCase()).includes(o.name.toLowerCase()); + }) + .map((o: any) => o.name), + }); + } else { + const game = HowLongToBeatGame.create({ + game: req.body.game, + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + gameplayMain: 0, + gameplayMainExtra: 0, + gameplayCompletionist: 0, + }); + await game.validateAndSave(); + res.send({ + data: game, + }); + } + } catch (e: any) { + res.status(400).send({ errors: e }); + } + }); + } + + async addToGameTimestamp() { + if (!stats.value.currentGame) { + debug('hltb', 'No game being played on stream.'); + return; // skip if we don't have game + } + + if (stats.value.currentGame.trim().length === 0) { + debug('hltb', 'Empty game is being played on stream'); + return; // skip if we have empty game + } + + try { + const game = await HowLongToBeatGame.findOneOrFail({ where: { game: stats.value.currentGame } }); + const stream = game.streams.find(o => o.createdAt === new Date(streamStatusChangeSince.value).toISOString()); + if (stream) { + debug('hltb', 'Another 15s entry of this stream for ' + stats.value.currentGame); + stream.timestamp += this.interval; + } else { + debug('hltb', 'First entry of this stream for ' + stats.value.currentGame); + game.streams.push({ + createdAt: new Date(streamStatusChangeSince.value).toISOString(), + timestamp: this.interval, + offset: 0, + isMainCounted: false, + isCompletionistCounted: false, + isExtraCounted: false, + }); + } + await game.save(); + } catch (e: any) { + if (e instanceof EntityNotFoundError) { + try { + if (['irl', 'always on', 'software and game development'].includes(stats.value.currentGame.toLowerCase())) { + throw new Error('Ignored game'); + } + + const gameFromHltb = (await this.hltbService.search(stats.value.currentGame))[0]; + if (!gameFromHltb) { + throw new Error('Game not found'); + } + // we don't care if MP game or not (user might want to track his gameplay time) + const game = HowLongToBeatGame.create({ + game: stats.value.currentGame, + gameplayMain: gameFromHltb.gameplayMain, + gameplayMainExtra: gameFromHltb.gameplayMainExtra, + gameplayCompletionist: gameFromHltb.gameplayCompletionist, + }); + await game.save(); + } catch { + const game = HowLongToBeatGame.create({ + game: stats.value.currentGame, + gameplayMain: 0, + gameplayMainExtra: 0, + gameplayCompletionist: 0, + }); + await game.save(); + } + } else { + error(e.stack); + } + } + } + + @command('!hltb') + @default_permission(defaultPermissions.CASTERS) + async currentGameInfo(opts: CommandOptions, retry = false): Promise { + let [gameInput] = new Expects(opts.parameters) + .everything({ optional: true }) + .toArray(); + + if (!gameInput) { + if (!stats.value.currentGame) { + return []; // skip if we don't have game + } else { + gameInput = stats.value.currentGame; + } + } + const gameToShow = await HowLongToBeatGame.findOne({ where: { game: gameInput } }); + if (!gameToShow && !retry) { + if (!stats.value.currentGame) { + return this.currentGameInfo(opts, true); + } + + if (stats.value.currentGame.trim().length === 0 || stats.value.currentGame.trim() === 'IRL') { + return this.currentGameInfo(opts, true); + } + return this.currentGameInfo(opts, true); + } else if (!gameToShow) { + return [{ response: prepare('systems.howlongtobeat.error', { game: gameInput }), ...opts }]; + } + const timeToBeatMain = (gameToShow.streams.filter(o => o.isMainCounted).reduce((prev, cur) => prev += cur.timestamp + cur.offset , 0) + gameToShow.offset) / constants.HOUR; + const timeToBeatMainExtra = (gameToShow.streams.filter(o => o.isExtraCounted).reduce((prev, cur) => prev += cur.timestamp + cur.offset, 0) + gameToShow.offset) / constants.HOUR; + const timeToBeatCompletionist = (gameToShow.streams.filter(o => o.isCompletionistCounted).reduce((prev, cur) => prev += cur.timestamp + cur.offset, 0) + gameToShow.offset) / constants.HOUR; + + const gameplayMain = gameToShow.gameplayMain; + const gameplayMainExtra = gameToShow.gameplayMainExtra; + const gameplayCompletionist = gameToShow.gameplayCompletionist; + + if (gameplayMain === 0) { + return [{ + response: prepare('systems.howlongtobeat.multiplayer-game', { + game: gameInput, + currentMain: timeToBeatMain.toFixed(1), + currentMainExtra: timeToBeatMainExtra.toFixed(1), + currentCompletionist: timeToBeatCompletionist.toFixed(1), + }), ...opts, + }]; + } + + return [{ + response: prepare('systems.howlongtobeat.game', { + game: gameInput, + hltbMain: gameplayMain, + hltbCompletionist: gameplayCompletionist, + hltbMainExtra: gameplayMainExtra, + currentMain: timeToBeatMain.toFixed(1), + currentMainExtra: timeToBeatMainExtra.toFixed(1), + currentCompletionist: timeToBeatCompletionist.toFixed(1), + percentMain: Number((timeToBeatMain / gameplayMain) * 100).toFixed(2), + percentMainExtra: Number((timeToBeatMainExtra / gameplayMainExtra) * 100).toFixed(2), + percentCompletionist: Number((timeToBeatCompletionist / gameplayCompletionist) * 100).toFixed(2), + }), ...opts, + }]; + } +} + +export default new HowLongToBeat(); diff --git a/backend/src/systems/keywords.ts b/backend/src/systems/keywords.ts new file mode 100644 index 000000000..3fb03f5ae --- /dev/null +++ b/backend/src/systems/keywords.ts @@ -0,0 +1,466 @@ +import { + Keyword, KeywordGroup, KeywordResponses, +} from '@entity/keyword.js'; +import { validateOrReject } from 'class-validator'; +import _, { merge } from 'lodash-es'; +import XRegExp from 'xregexp'; + +import System from './_interface.js'; +import { parserReply } from '../commons.js'; +import { + command, default_permission, helper, parser, timer, +} from '../decorators.js'; +import { Expects } from '../expects.js'; + +import { AppDataSource } from '~/database.js'; +import { checkFilter } from '~/helpers/checkFilter.js'; +import { isUUID, prepare } from '~/helpers/commons/index.js'; +import { + debug, error, warning, +} from '~/helpers/log.js'; +import { app } from '~/helpers/panel.js'; +import { check } from '~/helpers/permissions/check.js'; +import { defaultPermissions } from '~/helpers/permissions/defaultPermissions.js'; +import { get } from '~/helpers/permissions/get.js'; +import { adminMiddleware } from '~/socket.js'; +import { translate } from '~/translate.js'; + +class Keywords extends System { + constructor() { + super(); + this.addMenu({ + category: 'commands', name: 'keywords', id: 'commands/keywords', this: this, + }); + } + + sockets () { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/systems/keywords', adminMiddleware, async (req, res) => { + res.send({ + data: await Keyword.find({ relations: ['responses'] }), + }); + }); + app.get('/api/systems/keywords/groups/', adminMiddleware, async (req, res) => { + let [ groupsList, items ] = await Promise.all([ + KeywordGroup.find(), Keyword.find(), + ]); + + for (const item of items) { + if (item.group && !groupsList.find(o => o.name === item.group)) { + // we dont have any group options -> create temporary group + const group = new KeywordGroup(); + group.name = item.group; + group.options = { + filter: null, + permission: null, + }; + groupsList = [ + ...groupsList, + group, + ]; + } + } + res.send({ + data: groupsList, + }); + }); + app.get('/api/systems/keywords/:id', adminMiddleware, async (req, res) => { + res.send({ + data: await Keyword.findOne({ where: { id: req.params.id }, relations: ['responses'] }), + }); + }); + app.delete('/api/systems/keywords/groups/:name', adminMiddleware, async (req, res) => { + await KeywordGroup.delete({ name: req.params.name }); + res.status(404).send(); + }); + app.delete('/api/systems/keywords/:id', adminMiddleware, async (req, res) => { + await Keyword.delete({ id: req.params.id }); + res.status(404).send(); + }); + app.post('/api/systems/keywords/group', adminMiddleware, async (req, res) => { + try { + const itemToSave = new KeywordGroup(); + merge(itemToSave, req.body); + await validateOrReject(itemToSave); + await itemToSave.save(); + res.send({ data: itemToSave }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + app.post('/api/systems/keywords', adminMiddleware, async (req, res) => { + try { + const itemToSave = new Keyword(); + merge(itemToSave, req.body); + await validateOrReject(itemToSave); + await itemToSave.save(); + + await AppDataSource.getRepository(KeywordResponses).delete({ keyword: { id: itemToSave.id } }); + const responses = req.body.responses; + for (const response of responses) { + const resToSave = new KeywordResponses(); + merge(resToSave, response); + resToSave.keyword = itemToSave; + await resToSave.save(); + } + res.send({ data: itemToSave }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + } + + @command('!keyword') + @default_permission(defaultPermissions.CASTERS) + @helper() + public main(opts: CommandOptions): CommandResponse[] { + let url = 'http://sogebot.github.io/sogeBot/#/systems/keywords'; + if ((process.env?.npm_package_version ?? 'x.y.z-SNAPSHOT').includes('SNAPSHOT')) { + url = 'http://sogebot.github.io/sogeBot/#/_master/systems/keywords'; + } + return [{ response: translate('core.usage') + ' => ' + url, ...opts }]; + } + + /** + * Add new keyword + * + * format: !keyword add -k [regexp] -r [response] + * @param {CommandOptions} opts - options + * @return {Promise} + */ + @command('!keyword add') + @default_permission(defaultPermissions.CASTERS) + public async add(opts: CommandOptions): Promise<(CommandResponse & { id: string | null })[]> { + try { + const [userlevel, stopIfExecuted, keywordRegex, response] = new Expects(opts.parameters) + .permission({ optional: true, default: defaultPermissions.VIEWERS }) + .argument({ + optional: true, name: 's', default: false, type: Boolean, + }) + .argument({ + name: 'k', type: String, multi: true, delimiter: '', + }) + .argument({ + name: 'r', type: String, multi: true, delimiter: '', + }) + .toArray(); + + let kDb = await Keyword.findOne({ + relations: ['responses'], + where: { keyword: keywordRegex }, + }); + if (!kDb) { + kDb = new Keyword(); + kDb.keyword = keywordRegex; + kDb.enabled = true; + await kDb.save(); + return this.add(opts); + } + + const pItem = await get(userlevel); + if (!pItem) { + throw Error('Permission ' + userlevel + ' not found.'); + } + + const newResponse = new KeywordResponses(); + newResponse.keyword = kDb; + newResponse.order = kDb.responses.length; + newResponse.permission = pItem.id ?? defaultPermissions.VIEWERS; + newResponse.stopIfExecuted = stopIfExecuted; + newResponse.response = response; + newResponse.filter = ''; + await newResponse.save(); + return [{ + response: prepare('keywords.keyword-was-added', kDb), ...opts, id: kDb.id, + }]; + } catch (e: any) { + error(e.stack); + return [{ + response: prepare('keywords.keyword-parse-failed'), ...opts, id: null, + }]; + } + } + + /** + * Edit keyword + * + * format: !keyword edit -k [uuid|regexp] -r [response] + * @param {CommandOptions} opts - options + * @return {Promise} + */ + @command('!keyword edit') + @default_permission(defaultPermissions.CASTERS) + public async edit(opts: CommandOptions): Promise { + try { + const [userlevel, stopIfExecuted, keywordRegexOrUUID, rId, response] = new Expects(opts.parameters) + .permission({ optional: true, default: defaultPermissions.VIEWERS }) + .argument({ + optional: true, name: 's', default: null, type: Boolean, + }) + .argument({ + name: 'k', type: String, multi: true, delimiter: '', + }) + .argument({ name: 'rid', type: Number }) + .argument({ + name: 'r', type: String, multi: true, delimiter: '', + }) + .toArray(); + + let keywords: Required[] = []; + if (isUUID(keywordRegexOrUUID)) { + keywords = await Keyword.find({ where: { id: keywordRegexOrUUID }, relations: ['responses'] }); + } else { + keywords = await Keyword.find({ where: { keyword: keywordRegexOrUUID }, relations: ['responses'] }); + } + + if (keywords.length === 0) { + return [{ response: prepare('keywords.keyword-was-not-found'), ...opts }]; + } else if (keywords.length > 1) { + return [{ response: prepare('keywords.keyword-is-ambiguous'), ...opts }]; + } else { + const keyword = keywords[0]; + const responseDb = keyword.responses.find(o => o.order === (rId - 1)); + if (!responseDb) { + return [{ response: prepare('keywords.response-was-not-found', { keyword: keyword.keyword, response: rId }), ...opts }]; + } + + const pItem = await get(userlevel); + if (!pItem) { + throw Error('Permission ' + userlevel + ' not found.'); + } + + responseDb.response = response; + responseDb.permission = pItem.id ?? defaultPermissions.VIEWERS; + if (stopIfExecuted) { + responseDb.stopIfExecuted = stopIfExecuted; + } + await responseDb.save(); + return [{ response: prepare('keywords.keyword-was-edited', { keyword: keyword.keyword, response }), ...opts }]; + } + } catch (e: any) { + error(e.stack); + return [{ response: prepare('keywords.keyword-parse-failed'), ...opts }]; + } + } + + /** + * Bot responds with list of keywords + * + * @param {CommandOptions} opts + * @returns {Promise} + */ + @command('!keyword list') + @default_permission(defaultPermissions.CASTERS) + public async list(opts: CommandOptions): Promise { + const keyword = new Expects(opts.parameters).everything({ optional: true }).toArray()[0]; + if (!keyword) { + // print keywords + const keywords = await Keyword.find({ where: { enabled: true } }); + const response = (keywords.length === 0 ? translate('keywords.list-is-empty') : translate('keywords.list-is-not-empty').replace(/\$list/g, _.orderBy(keywords, 'keyword').map(o => o.keyword).join(', '))); + return [{ response, ...opts }]; + } else { + // print responses + const keyword_with_responses + = await Keyword.findOne({ + relations: ['responses'], + where: isUUID(keyword) ? { id: keyword } : { keyword }, + }); + + if (!keyword_with_responses || keyword_with_responses.responses.length === 0) { + return [{ response: prepare('keywords.list-of-responses-is-empty', { keyword: keyword_with_responses?.keyword || keyword }), ...opts }]; + } + return Promise.all(_.orderBy(keyword_with_responses.responses, 'order', 'asc').map(async(r) => { + const perm = r.permission ? await get(r.permission) : { name: '-- unset --' }; + const response = prepare('keywords.response', { + keyword: keyword_with_responses.keyword, index: ++r.order, response: r.response, after: r.stopIfExecuted ? '_' : 'v', permission: perm?.name ?? 'n/a', + }); + return { response, ...opts }; + })); + } + } + + /** + * Remove keyword + * + * format: !keyword edit -k [uuid|regexp] + * @param {CommandOptions} opts - options + * @return {Promise} + */ + @command('!keyword remove') + @default_permission(defaultPermissions.CASTERS) + public async remove(opts: CommandOptions): Promise { + try { + const [keywordRegexOrUUID, rId] + = new Expects(opts.parameters) + .argument({ + name: 'k', optional: false, multi: true, delimiter: '', + }) + .argument({ + name: 'rid', optional: true, type: Number, + }) + .toArray(); + + let keywords: Required[] = []; + if (isUUID(keywordRegexOrUUID)) { + keywords = await Keyword.find({ where: { id: keywordRegexOrUUID } }); + } else { + keywords = await Keyword.find({ where: { keyword: keywordRegexOrUUID } }); + } + + if (keywords.length === 0) { + return [{ response: prepare('keywords.keyword-was-not-found'), ...opts }]; + } else if (keywords.length > 1) { + return [{ response: prepare('keywords.keyword-is-ambiguous'), ...opts }]; + } else { + const keyword = keywords[0]; + if (rId) { + const responseDb = keyword.responses.find(o => o.order === (rId - 1)); + if (!responseDb) { + return [{ response: prepare('keywords.response-was-not-found'), ...opts }]; + } + // remove and reorder + let count = 0; + for (let i = 0; i < keyword.responses.length; i++) { + const response = _.orderBy(keyword.responses, 'order', 'asc')[i]; + if (responseDb.id !== response.id) { + response.order = count; + count++; + await response.save(); + } else { + await response.remove(); + } + } + return [{ response: prepare('keywords.response-was-removed', keyword), ...opts }]; + } else { + await Keyword.remove(keyword); + return [{ response: prepare('keywords.keyword-was-removed', keyword), ...opts }]; + } + } + } catch (e: any) { + error(e.stack); + return [{ response: prepare('keywords.keyword-parse-failed'), ...opts }]; + } + } + + /** + * Enable/disable keyword + * + * format: !keyword toggle -k [uuid|regexp] + * @param {CommandOptions} opts - options + * @return {Promise} + */ + @command('!keyword toggle') + @default_permission(defaultPermissions.CASTERS) + public async toggle(opts: CommandOptions): Promise { + try { + const [keywordRegexOrUUID] + = new Expects(opts.parameters) + .argument({ + name: 'k', optional: false, multi: true, delimiter: '', + }) + .toArray(); + + let keywords: Required[] = []; + if (isUUID(keywordRegexOrUUID)) { + keywords = await Keyword.find({ where: { id: keywordRegexOrUUID } }); + } else { + keywords = await Keyword.find({ where: { keyword: keywordRegexOrUUID } }); + } + + if (keywords.length === 0) { + return [{ response: prepare('keywords.keyword-was-not-found'), ...opts }]; + } else if (keywords.length > 1) { + return [{ response: prepare('keywords.keyword-is-ambiguous'), ...opts }]; + } else { + keywords[0].enabled = !keywords[0].enabled; + await keywords[0].save(); // we have only one keyword + return [{ response: prepare(keywords[0].enabled ? 'keywords.keyword-was-enabled' : 'keywords.keyword-was-disabled', keywords[0]), ...opts }]; + } + } catch (e: any) { + error(e.stack); + return [{ response: prepare('keywords.keyword-parse-failed'), ...opts }]; + } + } + + /** + * Parses message for keywords + * + * @param {ParserOptions} opts + * @return true + */ + @timer() + @parser({ fireAndForget: true }) + public async run(opts: ParserOptions) { + if (!opts.sender || opts.message.trim().startsWith('!')) { + return true; + } + + const keywords = (await Keyword.find({ relations: ['responses'] })).filter((o) => { + const regexp = `([!"#$%&'()*+,-.\\/:;<=>?\\b\\s]${o.keyword}[!"#$%&'()*+,-.\\/:;<=>?\\b\\s])|(^${o.keyword}[!"#$%&'()*+,-.\\/:;<=>?\\b\\s])|([!"#$%&'()*+,-.\\/:;<=>?\\b\\s]${o.keyword}$)|(^${o.keyword}$)`; + const isFoundInMessage = XRegExp(regexp, 'giu').test(opts.message); + const isEnabled = o.enabled; + debug('keywords.run', `\n\t<\t${opts.message}\n\t?\t${o.keyword}\n\t-\tisFoundInMessage: ${isFoundInMessage}, isEnabled: ${isEnabled}\n\t-\t${regexp}`); + if (isFoundInMessage && !isEnabled) { + warning(`Keyword ${o.keyword} (${o.id}) is disabled!`); + } + return isFoundInMessage && isEnabled; + }); + + let atLeastOnePermissionOk = false; + for (const k of keywords) { + debug('keywords.run', JSON.stringify({ k })); + const _responses: KeywordResponses[] = []; + + // check group filter first + let group: Readonly> | null; + let groupPermission: null | string = null; + if (k.group) { + group = await KeywordGroup.findOneBy({ name: k.group }); + debug('keywords.run', JSON.stringify({ group })); + if (group) { + if (group.options.filter && !(await checkFilter(opts, group.options.filter))) { + warning(`Keyword ${k.keyword}#${k.id} didn't pass group filter.`); + continue; + } + groupPermission = group.options.permission; + } + } + + for (const r of _.orderBy(k.responses, 'order', 'asc')) { + let permission = r.permission ?? groupPermission; + // show warning if null permission + if (!permission) { + permission = defaultPermissions.CASTERS; + warning(`Keyword ${k.keyword}#${k.id}|${r.order} doesn't have any permission set, treating as CASTERS permission.`); + } + + if ((await check(opts.sender.userId, permission, false)).access + && (r.filter.length === 0 || (r.filter.length > 0 && await checkFilter(opts, r.filter)))) { + _responses.push(r); + atLeastOnePermissionOk = true; + if (r.stopIfExecuted) { + break; + } + } + } + + debug('keywords.run', JSON.stringify({ _responses })); + + this.sendResponse(_.cloneDeep(_responses), { sender: opts.sender, discord: opts.discord, id: opts.id }); + } + + return atLeastOnePermissionOk; + } + + async sendResponse(responses: (KeywordResponses)[], opts: { sender: CommandOptions['sender'], discord: CommandOptions['discord'], id: string }) { + for (let i = 0; i < responses.length; i++) { + await parserReply(responses[i].response, opts); + } + } +} + +export default new Keywords(); \ No newline at end of file diff --git a/backend/src/systems/levels.ts b/backend/src/systems/levels.ts new file mode 100644 index 000000000..d1e442d3f --- /dev/null +++ b/backend/src/systems/levels.ts @@ -0,0 +1,509 @@ +import { User, UserInterface } from '@entity/user.js'; +import { MINUTE, SECOND } from '@sogebot/ui-helpers/constants.js'; +import { format } from '@sogebot/ui-helpers/number.js'; +import { evaluate as mathJsEvaluate, round } from 'mathjs'; + +import System from './_interface.js'; +import { onStartup } from '../decorators/on.js'; +import { + command, default_permission, parser, permission_settings, settings, ui, +} from '../decorators.js'; +import { Expects } from '../expects.js'; +import general from '../general.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline } from '~/helpers/api/index.js'; +import { ResponseError } from '~/helpers/commandError.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { getAllOnlineIds } from '~/helpers/getAllOnlineUsernames.js'; +import { debug, error } from '~/helpers/log.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { getUserHighestPermission } from '~/helpers/permissions/getUserHighestPermission.js'; +import { getPointsName } from '~/helpers/points/index.js'; +import { setImmediateAwait } from '~/helpers/setImmediateAwait.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import { + bigIntMax, serialize, unserialize, +} from '~/helpers/type.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { isBotId } from '~/helpers/user/isBot.js'; +import { translate } from '~/translate.js'; + +let cachedLevelsHash = ''; +const cachedLevels: bigint[] = []; + +class Levels extends System { + @settings('conversion') + conversionRate = 10; + + @settings('levels') + firstLevelStartsAt = 100; + + @settings('levels') + nextLevelFormula = '$prevLevelXP + ($prevLevelXP * 1.5)'; + + @ui({ type: 'levels-showcase', emit: 'getLevelsExample' }, 'levels') + levelShowcase = null; + @ui({ type: 'helpbox' }, 'levels') + levelShowcaseHelp = null; + + @settings('xp') + xpName = 'XP'; + + @permission_settings('xp') + interval = 10; + + @permission_settings('xp') + perInterval = 10; + + @permission_settings('xp') + offlineInterval = 0; + + @permission_settings('xp') + perOfflineInterval = 0; + + @permission_settings('xp') + messageInterval = 5; + + @permission_settings('xp') + perMessageInterval = 1; + + @permission_settings('xp') + messageOfflineInterval = 0; + + @permission_settings('xp') + perMessageOfflineInterval = 0; + + sockets () { + adminEndpoint('/systems/levels', 'getLevelsExample', (data, cb) => { + try { + const firstLevelStartsAt = typeof data === 'function' ? this.firstLevelStartsAt : data.firstLevelStartsAt; + const nextLevelFormula = typeof data === 'function' ? this.nextLevelFormula : data.nextLevelFormula; + const xpName = typeof data === 'function' ? this.xpName : data.xpName; + const levels = []; + for (let i = 1; i <= 21; i++) { + levels.push(this.getLevelXP(i, BigInt(firstLevelStartsAt), nextLevelFormula, true)); + } + (typeof data === 'function' ? data : cb!)(null, levels.map(xp => `${Intl.NumberFormat(general.lang).format(xp)} ${xpName}`)); + } catch (e: any) { + (typeof data === 'function' ? data : cb!)(e, []); + } + }); + } + + @onStartup() + async update () { + if (!this.enabled) { + debug('levels.update', 'Disabled, next check in 5s'); + setTimeout(() => this.update(), 5 * SECOND); + return; + } + + const [interval, offlineInterval, perInterval, perOfflineInterval] = await Promise.all([ + this.getPermissionBasedSettingsValue('interval'), + this.getPermissionBasedSettingsValue('offlineInterval'), + this.getPermissionBasedSettingsValue('perInterval'), + this.getPermissionBasedSettingsValue('perOfflineInterval'), + ]); + + try { + debug('levels.update', `Started XP adding, isOnline: ${isStreamOnline.value}`); + let i = 0; + for (const userId of (await getAllOnlineIds())) { + if (isBotId(userId)) { + continue; + } + await this.process(userId, { + interval, offlineInterval, perInterval, perOfflineInterval, isOnline: isStreamOnline.value, + }); + if ( i % 10 === 0) { + await setImmediateAwait(); + } + i++; + } + } catch (e: any) { + error(e); + error(e.stack); + } finally { + debug('levels.update', 'Finished xp adding, triggering next check in 60s'); + setTimeout(() => this.update(), MINUTE); + } + } + + getLevelFromCache(levelFromCache: number) { + const hash = `${this.nextLevelFormula} + ${this.firstLevelStartsAt}`; + if (hash !== cachedLevelsHash) { + cachedLevelsHash = hash; + cachedLevels.length = 0; + // level 0 + cachedLevels.push(BigInt(0)); + } + + if (!cachedLevels[levelFromCache]) { + // recalculate from level (length is +1 as we start with level 0) + let level = cachedLevels.length; + + if (levelFromCache >= 1) { + for (; level <= levelFromCache; level++) { + const xp = this.getLevelXP(level, undefined, undefined, true); + debug('levels.update', `Recalculating level ${level} - ${xp} XP`); + cachedLevels.push(xp); + } + } + } + return cachedLevels[levelFromCache]; + } + + private async process(userId: string, opts: {interval: {[permissionId: string]: any}; offlineInterval: {[permissionId: string]: any}; perInterval: {[permissionId: string]: any}; perOfflineInterval: {[permissionId: string]: any}; isOnline: boolean}): Promise { + const user = await changelog.get(userId); + if (!user) { + // user is not existing in db, skipping + return; + } + + // get user max permission + const permId = await getUserHighestPermission(userId); + if (!permId) { + debug('levels.update', `User ${user.userName}#${userId} permId not found`); + return; // skip without id + } + + const interval_calculated = opts.isOnline ? opts.interval[permId] * 60 * 1000 : opts.offlineInterval[permId] * 60 * 1000; + const ptsPerInterval = opts.isOnline ? opts.perInterval[permId] : opts.perOfflineInterval[permId] ; + + const chat = await users.getChatOf(userId, true); + const chatOffline = await users.getChatOf(userId, false); + + // we need to save if extra.levels are not defined + if (typeof user.extra?.levels === 'undefined') { + debug('levels.update', `${user.userName}#${userId}[${permId}] -- initial data --`); + const levels: NonNullable['levels'] = { + xp: serialize(BigInt(0)), + xpOfflineGivenAt: chatOffline, + xpOfflineMessages: 0, + xpOnlineGivenAt: chat, + xpOnlineMessages: 0, + }; + changelog.update(user.userId, + { + extra: { + ...user.extra, + levels, + }, + }); + } + + if (interval_calculated !== 0 && ptsPerInterval[permId] !== 0) { + const givenAt = opts.isOnline + ? user.extra?.levels?.xpOnlineGivenAt ?? chat + : user.extra?.levels?.xpOfflineGivenAt ?? chat; + debug('levels.update', `${user.userName}#${userId}[${permId}] ${chat} | ${givenAt}`); + let modifier = 0; + let userTimeXP = givenAt + interval_calculated; + for (; userTimeXP <= chat; userTimeXP += interval_calculated) { + modifier++; + } + + if (modifier > 0) { + debug('levels.update', `${user.userName}#${userId}[${permId}] +${Math.floor(ptsPerInterval * modifier)}`); + const levels: NonNullable['levels'] = { + xp: serialize(BigInt(Math.floor(ptsPerInterval * modifier)) + (unserialize(user.extra?.levels?.xp) ?? BigInt(0))), + xpOfflineGivenAt: !opts.isOnline ? userTimeXP : user.extra?.levels?.xpOfflineGivenAt ?? chatOffline, + xpOfflineMessages: user.extra?.levels?.xpOfflineMessages ?? 0, + xpOnlineGivenAt: opts.isOnline ? userTimeXP : user.extra?.levels?.xpOnlineGivenAt ?? chat, + xpOnlineMessages: user.extra?.levels?.xpOnlineMessages ?? 0, + }; + changelog.update(user.userId, + { + extra: { + ...user.extra, + levels, + }, + }); + } + } else { + const levels: NonNullable['levels'] = { + xp: serialize(BigInt(ptsPerInterval) + (unserialize(user.extra?.levels?.xp) ?? BigInt(0))), + xpOfflineGivenAt: !opts.isOnline ? chat : user.extra?.levels?.xpOfflineGivenAt ?? chat, + xpOfflineMessages: user.extra?.levels?.xpOfflineMessages ?? 0, + xpOnlineGivenAt: opts.isOnline ? chat : user.extra?.levels?.xpOnlineGivenAt ?? chat, + xpOnlineMessages: user.extra?.levels?.xpOnlineMessages ?? 0, + }; + changelog.update(user.userId, + { + extra: { + ...user.extra, + levels, + }, + }); + debug('levels.update', `${user.userName}#${userId}[${permId}] levels disabled or interval is 0, settint levels time to chat`); + } + + } + + @parser({ fireAndForget: true }) + async messageXP (opts: ParserOptions) { + if (!opts.sender ||opts.skip || opts.message.startsWith('!')) { + return true; + } + + const [perMessageInterval, messageInterval, perMessageOfflineInterval, messageOfflineInterval] = await Promise.all([ + this.getPermissionBasedSettingsValue('perMessageInterval'), + this.getPermissionBasedSettingsValue('messageInterval'), + this.getPermissionBasedSettingsValue('perMessageOfflineInterval'), + this.getPermissionBasedSettingsValue('messageOfflineInterval'), + ]); + + // get user max permission + const permId = await getUserHighestPermission(opts.sender.userId); + if (!permId) { + return true; // skip without permission + } + + const interval_calculated = isStreamOnline.value ? messageInterval[permId] : messageOfflineInterval[permId]; + const ptsPerInterval = isStreamOnline.value ? perMessageInterval[permId] : perMessageOfflineInterval[permId]; + + if (interval_calculated === 0 || ptsPerInterval === 0) { + return true; + } + + const user = await changelog.get(opts.sender.userId); + if (!user) { + return true; + } + + // next message count (be it offline or online) + const messages = 1 + ((isStreamOnline.value + ? user.extra?.levels?.xpOnlineMessages + : user.extra?.levels?.xpOfflineMessages) ?? 0); + const chat = await users.getChatOf(user.userId, isStreamOnline.value); + + // default level object + const levels: NonNullable['levels'] = { + xp: serialize(unserialize(user.extra?.levels?.xp) ?? BigInt(0)), + xpOfflineGivenAt: user.extra?.levels?.xpOfflineGivenAt ?? chat, + xpOfflineMessages: !isStreamOnline.value + ? 0 + : user.extra?.levels?.xpOfflineMessages ?? 0, + xpOnlineGivenAt: user.extra?.levels?.xpOnlineGivenAt ?? chat, + xpOnlineMessages: isStreamOnline.value + ? 0 + : user.extra?.levels?.xpOnlineMessages ?? 0, + }; + + if (messages >= interval_calculated) { + // add xp and set offline/online messages to 0 + changelog.update(user.userId, + { + extra: { + ...user.extra, + levels: { + ...levels, + [isStreamOnline.value ? 'xpOnlineMessages' : 'xpOfflineMessages']: 0, + xp: serialize(BigInt(ptsPerInterval) + (unserialize(user.extra?.levels?.xp) ?? BigInt(0))), + }, + }, + }); + } else { + changelog.update(user.userId, + { + extra: { + ...user.extra, + levels: { + ...levels, + [isStreamOnline.value ? 'xpOnlineMessages' : 'xpOfflineMessages']: messages, + }, + }, + }); + } + return true; + } + + getLevelXP(level: number, firstLevelStartsAt = BigInt(this.firstLevelStartsAt), nextLevelFormula = this.nextLevelFormula, calculate = false) { + let prevLevelXP = firstLevelStartsAt; + + if (level === 0) { + return BigInt(0); + } + if (level === 1) { + return firstLevelStartsAt; + } + + for (let i = 1; i < level; i++) { + const expr = nextLevelFormula + .replace(/\$prevLevelXP/g, String(prevLevelXP)) + .replace(/\$prevLevel/g, String(i)); + const formula = !calculate + ? this.getLevelFromCache(i + 1) + : BigInt(round(mathJsEvaluate(expr))); + if (formula <= prevLevelXP && i > 1) { + error('Next level cannot be equal or less than previous level'); + return BigInt(0); + } + prevLevelXP = formula; + } + return bigIntMax(prevLevelXP, BigInt(0)); + } + + getLevelOf(user: UserInterface | null): number { + if (!user) { + return 0; + } + + const currentXP = unserialize(user.extra?.levels?.xp) ?? BigInt(0); + + if (currentXP < this.firstLevelStartsAt) { + return 0; + } + + let levelXP = BigInt(this.firstLevelStartsAt); + let level = 1; + for (; currentXP > 0; level++) { + if (level > 1) { + const formula = this.getLevelFromCache(level); + levelXP = formula; + if (formula === BigInt(0)) { + error('Formula of level calculation is returning 0, please adjust.'); + return 0; + } + } + if (BigInt(currentXP) < levelXP) { + level--; + break; + } + } + return level; + } + + @command('!level buy') + async buy (opts: CommandOptions): Promise { + const points = (await import('../systems/points.js')).default; + try { + if (!points.enabled) { + throw new Error('Point system disabled.'); + } + + const user = await changelog.getOrFail(opts.sender.userId); + const availablePoints = user.points; + const currentLevel = this.getLevelOf(user); + const xp = this.getLevelXP(currentLevel + 1); + const xpNeeded = xp - (unserialize(user.extra?.levels?.xp) ?? BigInt(0)); + const neededPoints = Number(xpNeeded * BigInt(this.conversionRate)); + + if (neededPoints >= availablePoints) { + throw new ResponseError( + prepare('systems.levels.notEnoughPointsToBuy', { + points: format(general.numberFormat, 0)(neededPoints), + pointsName: getPointsName(neededPoints), + amount: xpNeeded, + level: currentLevel + 1, + xpName: this.xpName, + }), + ); + } + + const chat = await users.getChatOf(user.userId, isStreamOnline.value); + const levels: NonNullable['levels'] = { + xp: serialize(xp), + xpOfflineGivenAt: user.extra?.levels?.xpOfflineGivenAt ?? chat, + xpOfflineMessages: user.extra?.levels?.xpOfflineMessages ?? 0, + xpOnlineGivenAt: user.extra?.levels?.xpOnlineGivenAt ?? chat, + xpOnlineMessages: user.extra?.levels?.xpOnlineMessages ?? 0, + }; + changelog.update(user.userId, + { + points: user.points - neededPoints, + extra: { + ...user.extra, + levels, + }, + }); + + const response = prepare('systems.levels.XPBoughtByPoints', { + points: format(general.numberFormat, 0)(neededPoints), + pointsName: getPointsName(neededPoints), + level: currentLevel + 1, + amount: xpNeeded, + xpName: this.xpName, + }); + return [{ response, ...opts }]; + } catch (e: any) { + if (e instanceof ResponseError) { + return [{ response: e.message, ...opts }]; + } else { + if (e.message === 'Point system disabled.') { + error(e.stack); + } + return [{ response: translate('systems.levels.somethingGetWrong').replace('$command', opts.command), ...opts }]; + } + } + } + + @command('!level change') + @default_permission(defaultPermissions.CASTERS) + async add (opts: CommandOptions): Promise { + try { + const [userName, xp] = new Expects(opts.parameters).username().number({ minus: true }).toArray(); + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneByOrFail({ userName }); + const chat = await users.getChatOf(user.userId, isStreamOnline.value); + + const levels: NonNullable['levels'] = { + xp: serialize(bigIntMax(BigInt(xp) + (unserialize(user.extra?.levels?.xp) ?? BigInt(0)), BigInt(0))), + xpOfflineGivenAt: user.extra?.levels?.xpOfflineGivenAt ?? chat, + xpOfflineMessages: user.extra?.levels?.xpOfflineMessages ?? 0, + xpOnlineGivenAt: user.extra?.levels?.xpOnlineGivenAt ?? chat, + xpOnlineMessages: user.extra?.levels?.xpOnlineMessages ?? 0, + }; + changelog.update(user.userId, + { + extra: { + ...user.extra, + levels, + }, + }); + + const response = prepare('systems.levels.changeXP', { + userName, + amount: xp, + xpName: this.xpName, + }); + return [{ response, ...opts }]; + } catch (e: any) { + return [{ response: translate('systems.levels.somethingGetWrong').replace('$command', opts.command), ...opts }]; + } + } + + @command('!level') + async main (opts: CommandOptions): Promise { + try { + const [userName] = new Expects(opts.parameters).username({ optional: true, default: opts.sender.userName }).toArray(); + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneByOrFail({ userName }); + + let currentLevel = this.firstLevelStartsAt === 0 ? 1 : 0; + let nextXP = await this.getLevelXP(currentLevel + 1); + let currentXP = BigInt(0); + + if (user.extra?.levels) { + currentXP = unserialize(user.extra?.levels.xp) ?? BigInt(0); + currentLevel = this.getLevelOf(user); + nextXP = await this.getLevelXP(currentLevel + 1); + } + + const response = prepare('systems.levels.currentLevel', { + userName, + currentLevel, + nextXP: bigIntMax(nextXP - currentXP, BigInt(0)), + currentXP, + xpName: this.xpName, + }); + return [{ response, ...opts }]; + } catch (e: any) { + return [{ response: translate('systems.levels.somethingGetWrong').replace('$command', opts.command), ...opts }]; + } + } +} + +export default new Levels(); diff --git a/backend/src/systems/moderation.ts b/backend/src/systems/moderation.ts new file mode 100644 index 000000000..0c2896a79 --- /dev/null +++ b/backend/src/systems/moderation.ts @@ -0,0 +1,743 @@ +// 3rdparty libraries + +import { Alias } from '@entity/alias.js'; +import { ModerationPermit, ModerationWarning } from '@entity/moderation.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import emojiRegex from 'emoji-regex'; +import { TLDs } from 'global-tld-list'; +import * as _ from 'lodash-es'; +import { LessThan } from 'typeorm'; +import XRegExp from 'xregexp'; + +import System from './_interface.js'; +import { parserReply } from '../commons.js'; +import { + command, default_permission, parser, permission_settings, settings, ui, +} from '../decorators.js'; +import { Expects } from '../expects.js'; +import spotify from '../integrations/spotify.js'; +import { Message } from '../message.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { + error, warning as warningLog, +} from '~/helpers/log.js'; +import { ParameterError } from '~/helpers/parameterError.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { getUserHighestPermission } from '~/helpers/permissions/getUserHighestPermission.js'; +import { getUserPermissionsList } from '~/helpers/permissions/getUserPermissionsList.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import { tmiEmitter } from '~/helpers/tmi/index.js'; +import banUser from '~/services/twitch/calls/banUser.js'; +import aliasSystem from '~/systems/alias.js'; +import songs from '~/systems/songs.js'; +import { translate } from '~/translate.js'; + +const tlds = [...TLDs.tlds.keys()]; + +const urlRegex = [ + new RegExp(`(www)? ??\\.? ?[a-zA-Z0-9]+([a-zA-Z0-9-]+) ??\\. ?(${tlds.join('|')})(?=\\P{L}|$)`, 'igu'), + new RegExp(`[a-zA-Z0-9]+([a-zA-Z0-9-]+)?\\.(${tlds.join('|')})(?=\\P{L}|$)`, 'igu'), +]; + +const timeoutType = ['links', 'symbols', 'caps', 'longmessage', 'spam', 'color', 'emotes', 'blacklist'] as const; +const ModerationMessageCooldown = new Map(); +const immuneUsers = new Map>([ + ['links', new Map()], + ['symbols', new Map()], + ['caps', new Map()], + ['longmessage', new Map()], + ['spam', new Map()], + ['color', new Map()], + ['emotes', new Map()], + ['blacklist', new Map()], +]); + +const messages: string[] = []; +messages.push = function (...args) { + if (this.length >= 2000) { + this.shift(); + } + return Array.prototype.push.apply(this,args); +}; + +setInterval(() => { + // cleanup map + for (const type of timeoutType) { + const map = immuneUsers.get(type); + if (map) { + for (const userId of map.keys()) { + const immuneExpiresIn = map.get(userId); + if(immuneExpiresIn && immuneExpiresIn < Date.now()) { + map.delete(userId); + } + } + } + } +}, 1000); + +class Moderation extends System { + @settings('lists') + autobanMessages: string[] = []; + @settings('lists') + cListsWhitelist: string[] = []; + @settings('lists') + @ui({ + type: 'textarea-from-array', + secret: true, + }) + cListsBlacklist: string[] = []; + @permission_settings('lists', [ defaultPermissions.CASTERS ], { [defaultPermissions.MODERATORS]: false }) + cListsEnabled = true; + @permission_settings('lists', [ defaultPermissions.CASTERS ]) + cListsTimeout = 120; + + @permission_settings('links_filter', [ defaultPermissions.CASTERS ], { [defaultPermissions.MODERATORS]: false }) + cLinksEnabled = true; + @permission_settings('links_filter', [ defaultPermissions.CASTERS ]) + cLinksIncludeSpaces = false; + @permission_settings('links_filter', [ defaultPermissions.CASTERS ]) + cLinksIncludeClips = true; + @permission_settings('links_filter', [ defaultPermissions.CASTERS ]) + cLinksTimeout = 120; + + @permission_settings('symbols_filter', [ defaultPermissions.CASTERS ], { [defaultPermissions.MODERATORS]: false }) + cSymbolsEnabled = true; + @permission_settings('symbols_filter', [ defaultPermissions.CASTERS ]) + cSymbolsTriggerLength = 15; + @permission_settings('symbols_filter', [ defaultPermissions.CASTERS ]) + cSymbolsMaxSymbolsConsecutively = 10; + @permission_settings('symbols_filter', [ defaultPermissions.CASTERS ]) + cSymbolsMaxSymbolsPercent = 50; + @permission_settings('symbols_filter', [ defaultPermissions.CASTERS ]) + cSymbolsTimeout = 120; + + @permission_settings('longMessage_filter', [ defaultPermissions.CASTERS ], { [defaultPermissions.MODERATORS]: false }) + cLongMessageEnabled = true; + @permission_settings('longMessage_filter', [ defaultPermissions.CASTERS ]) + cLongMessageTriggerLength = 300; + @permission_settings('longMessage_filter', [ defaultPermissions.CASTERS ]) + cLongMessageTimeout = 120; + + @permission_settings('caps_filter', [ defaultPermissions.CASTERS ], { [defaultPermissions.MODERATORS]: false }) + cCapsEnabled = true; + @permission_settings('caps_filter', [ defaultPermissions.CASTERS ]) + cCapsTriggerLength = 15; + @permission_settings('caps_filter', [ defaultPermissions.CASTERS ]) + cCapsMaxCapsPercent = 50; + @permission_settings('caps_filter', [ defaultPermissions.CASTERS ]) + cCapsTimeout = 120; + + @permission_settings('spam_filter', [ defaultPermissions.CASTERS ], { [defaultPermissions.MODERATORS]: false }) + cSpamEnabled = true; + @permission_settings('spam_filter', [ defaultPermissions.CASTERS ]) + cSpamTriggerLength = 15; + @permission_settings('spam_filter', [ defaultPermissions.CASTERS ]) + cSpamMaxLength = 50; + @permission_settings('spam_filter', [ defaultPermissions.CASTERS ]) + cSpamTimeout = 300; + + @permission_settings('color_filter', [ defaultPermissions.CASTERS ], { [defaultPermissions.MODERATORS]: false }) + cColorEnabled = true; + @permission_settings('color_filter', [ defaultPermissions.CASTERS ]) + cColorTimeout = 300; + + @permission_settings('emotes_filter', [ defaultPermissions.CASTERS ], { [defaultPermissions.MODERATORS]: false }) + cEmotesEnabled = true; + @permission_settings('emotes_filter', [ defaultPermissions.CASTERS ]) + cEmotesEmojisAreEmotes = true; + @permission_settings('emotes_filter', [ defaultPermissions.CASTERS ]) + cEmotesMaxCount = 15; + @permission_settings('emotes_filter', [ defaultPermissions.CASTERS ]) + cEmotesTimeout = 120; + + @settings('warnings') + cWarningsAllowedCount = 3; + @settings('warnings') + cWarningsAnnounceTimeouts = true; + @settings('warnings') + cWarningsShouldClearChat = true; + + sockets () { + adminEndpoint('/systems/moderation', 'lists.get', async (cb) => { + cb(null, { + blacklist: this.cListsBlacklist, + whitelist: this.cListsWhitelist, + }); + }); + adminEndpoint('/systems/moderation', 'lists.set', (data) => { + this.cListsBlacklist = data.blacklist.filter(entry => entry.trim() !== ''); + this.cListsWhitelist = data.whitelist.filter(entry => entry.trim() !== ''); + }); + } + + @command('!immune') + @default_permission(defaultPermissions.CASTERS) + public async immune(opts: CommandOptions): Promise { + try { + const [ username, type, time ] = new Expects(opts.parameters) + .username() + .oneOf({ values: timeoutType }) + .duration({}) + .toArray(); + + const userId = await users.getIdByName(username); + + const map = immuneUsers.get(type); + if (map) { + map.set(String(userId), Date.now() + Number(time)); + } + + return [{ + response: prepare('moderation.user-have-immunity', { + username, + type, + time: time / 1000, + }), ...opts, + }]; + } catch (err: any) { + const isParameterError = (err instanceof ParameterError); + + if (isParameterError) { + return [{ response: prepare('moderation.user-have-immunity-parameterError', { command: opts.command }), ...opts }, { response: '# - ' + timeoutType.join(', '), ...opts }, { response: '# - 5s, 10m, 12h, 1d', ...opts }]; + } else { + error(err.stack); + return [{ response: '$sender, unknown error, please check your logs', ...opts }]; + } + } + } + + async timeoutUser (sender: CommandOptions['sender'], text: string, warning: string, msg: string, time: number, type: typeof timeoutType[number], msgId: string ) { + // cleanup warnings + await AppDataSource.getRepository(ModerationWarning).delete({ timestamp: LessThan(Date.now() - 1000 * 60 * 60) }); + const warnings = await AppDataSource.getRepository(ModerationWarning).findBy({ userId: sender.userId }); + const silent = await this.isSilent(type); + + text = text.trim(); + + if (this.cWarningsAllowedCount === 0) { + tmiEmitter.emit('timeout', sender.userName, time, { + mod: sender.isMod, + }, text.length > 0 ? text : undefined); + return; + } + + const isWarningCountAboveThreshold = warnings.length >= this.cWarningsAllowedCount; + if (isWarningCountAboveThreshold) { + tmiEmitter.emit('timeout', sender.userName, time, { + mod: sender.isMod, + }, text.length > 0 ? text : undefined); + await AppDataSource.getRepository(ModerationWarning).delete({ userId: sender.userId }); + } else { + await AppDataSource.getRepository(ModerationWarning).insert({ userId: sender.userId, timestamp: Date.now() }); + const warningsLeft = this.cWarningsAllowedCount - warnings.length; + warning = await new Message(warning.replace(/\$count/g, String(warningsLeft < 0 ? 0 : warningsLeft))).parse(); + if (this.cWarningsShouldClearChat) { + tmiEmitter.emit('timeout', sender.userName, 1, { + mod: sender.isMod, + }, `warnings left ${warningsLeft < 0 ? 0 : warningsLeft} ${text.length > 0 ? ' | ' + text : ''}`); + } + + if (this.cWarningsAnnounceTimeouts) { + tmiEmitter.emit('delete', msgId); + if (!silent) { + parserReply('$sender, ' + warning, { sender, discord: undefined, id: '' }); + } else { + warningLog(`Moderation announce was not sent (another ${type} warning already sent in 60s): ${sender.userName}, ${warning}`); + } + } + } + } + + async whitelist (text: string, permId: string | null) { + let ytRegex, clipsRegex, spotifyRegex; + + // check if spotify -or- alias of spotify contain open.spotify.com link + if (spotify.enabled) { + const cmd = spotify.getCommand('!spotify'); + const alias = await AppDataSource.getRepository(Alias).findOne({ where: { command: cmd } }); + if (alias && alias.enabled && aliasSystem.enabled) { + spotifyRegex = new RegExp('^(' + cmd + '|' + alias.alias + ') \\S+open\\.spotify\\.com\\/track\\/(\\w+)(.*)?', 'gi'); + } else { + spotifyRegex = new RegExp('^(' + cmd + ') \\S+open\\.spotify\\.com\\/track\\/(\\w+)(.*)?', 'gi'); + } + text = text.replace(spotifyRegex, ''); + } + + // check if songrequest -or- alias of songrequest contain youtube link + if (songs.enabled) { + const cmd = songs.getCommand('!songrequest'); + const alias = await AppDataSource.getRepository(Alias).findOne({ where: { command: cmd } }); + if (alias && alias.enabled && aliasSystem.enabled) { + ytRegex = new RegExp('^(' + cmd + '|' + alias.alias + ') \\S+(?:youtu.be\\/|v\\/|e\\/|u\\/\\w+\\/|embed\\/|v=)([^#&?]*).*', 'gi'); + } else { + ytRegex = new RegExp('^(' + cmd + ') \\S+(?:youtu.be\\/|v\\/|e\\/|u\\/\\w+\\/|embed\\/|v=)([^#&?]*).*', 'gi'); + } + text = text.replace(ytRegex, ''); + } + + if (permId) { + const cLinksIncludeClips = (await this.getPermissionBasedSettingsValue('cLinksIncludeClips'))[permId]; + if (!cLinksIncludeClips) { + clipsRegex = /.*(clips.twitch.tv\/)(\w+)/g; + text = text.replace(clipsRegex, ''); + clipsRegex = /.*(www.twitch.tv\/\w+\/clip\/)(\w+)/g; + text = text.replace(clipsRegex, ''); + } + } + + text = ` ${text} `; + const whitelist = this.cListsWhitelist; + + for (const value of whitelist.map(o => o.trim().replace(/\*/g, '[\\pL0-9\\S]*').replace(/\+/g, '[\\pL0-9\\S]+'))) { + if (value.length > 0) { + let regexp; + if (value.startsWith('domain:')) { + regexp = XRegExp(` [\\S]*${XRegExp.escape(value.replace('domain:', ''))}[\\S]* `, 'gi'); + } else { // default regexp behavior + regexp = XRegExp(` [^\\s\\pL0-9\\w]?${value}[^\\s\\pL0-9\\w]? `, 'gi'); + } + // we need to change 'text' to ' text ' for regexp to correctly work + text = XRegExp.replace(` ${text} `, regexp, '').trim(); + } + } + return text.trim(); + } + + @command('!permit') + @default_permission(defaultPermissions.CASTERS) + async permitLink (opts: CommandOptions): Promise { + try { + const parsed = opts.parameters.match(/^@?([\S]+) ?(\d+)?$/); + if (!parsed) { + throw new Error('!permit command not parsed'); + } + let count = 1; + if (!_.isNil(parsed[2])) { + count = parseInt(parsed[2], 10); + } + + const userId = await users.getIdByName(parsed[1].toLowerCase()); + for (let i = 0; i < count; i++) { + await AppDataSource.getRepository(ModerationPermit).insert({ userId }); + } + + const response = prepare('moderation.user-have-link-permit', { + username: parsed[1].toLowerCase(), link: getLocalizedName(count, translate('core.links')), count: count, + }); + return [{ response, ...opts }]; + } catch (e: any) { + return [{ response: translate('moderation.permit-parse-failed'), ...opts }]; + } + } + + @command('!autoban') + @default_permission(defaultPermissions.MODERATORS) + async autoban(opts: CommandOptions) { + const username = new Expects(opts.parameters).username().toArray()[0].toLowerCase(); + // find last message of user + const message = messages.find(o => o.startsWith(`${username}|`))?.replace(`${username}|`, '').trim(); + if (message) { + warningLog(`AUTOBAN: Adding '${message}' to autoban message list.`); + this.autobanMessages.push(message); + } else { + warningLog('AUTOBAN: No message of user found, user will be just banned.'); + } + banUser(opts.sender.userId, 'AUTOBAN: Message of user found in message list. Banning user.'); + return []; + } + + @parser({ priority: constants.MODERATION }) + async saveMessageAndCheckAutoban(opts: ParserOptions) { + // remove all messages from user as we want to keep only last one + const idxs = messages.reduce(function(a, e, i) { + if (e.startsWith(opts.sender?.userName + '|')) { + a.push(i); + } + return a; + }, [] as number[]); + for (const idx of idxs.reverse()){ + messages.splice(idx, 1); + } + if (this.autobanMessages.includes(opts.message.trim())) { + warningLog('AUTOBAN: Message of user found in message list. Banning user.'); + + if (opts.sender) { + banUser(opts.sender.userId, 'AUTOBAN: Message of user found in message list. Banning user.'); + } + return false; + } + messages.push(`${opts.sender?.userName}|${opts.message}`); + return true; + } + + @parser({ priority: constants.MODERATION }) + async containsLink (opts: ParserOptions) { + if (!opts.sender || immuneUsers.get('links')?.has(String(opts.sender.userId))) { + return true; + } + + const enabled = await this.getPermissionBasedSettingsValue('cLinksEnabled'); + const cLinksIncludeSpaces = await this.getPermissionBasedSettingsValue('cLinksIncludeSpaces'); + const timeoutValues = await this.getPermissionBasedSettingsValue('cLinksTimeout'); + + const permId = await getUserHighestPermission(opts.sender.userId); + if (permId === defaultPermissions.CASTERS) { + return true; + } + + const permList = await getUserPermissionsList(opts.sender.userId); + for (const pid of permList) { + // if any of user permission have it allowed, allow + if (!enabled[pid]) { + return true; + } + } + + const whitelisted = await this.whitelist(opts.message, permId); + if (whitelisted.search(urlRegex[cLinksIncludeSpaces[permId] ? 0 : 1]) >= 0) { + const permit = await AppDataSource.getRepository(ModerationPermit).findOneBy({ userId: opts.sender.userId }); + if (permit) { + await AppDataSource.getRepository(ModerationPermit).remove(permit); + return true; + } else { + this.timeoutUser(opts.sender, whitelisted, + translate('moderation.user-is-warned-about-links'), + translate('moderation.user-have-timeout-for-links'), + timeoutValues[permId], 'links', opts.id); + return false; + } + } else { + return true; + } + } + + @parser({ priority: constants.MODERATION }) + async symbols (opts: ParserOptions) { + if (!opts.sender || immuneUsers.get('symbols')?.has(String(opts.sender.userId))) { + return true; + } + + const enabled = await this.getPermissionBasedSettingsValue('cSymbolsEnabled'); + const cSymbolsTriggerLength = await this.getPermissionBasedSettingsValue('cSymbolsTriggerLength'); + const cSymbolsMaxSymbolsConsecutively = await this.getPermissionBasedSettingsValue('cSymbolsMaxSymbolsConsecutively'); + const cSymbolsMaxSymbolsPercent = await this.getPermissionBasedSettingsValue('cSymbolsMaxSymbolsPercent'); + const timeoutValues = await this.getPermissionBasedSettingsValue('cSymbolsTimeout'); + + const permId = await getUserHighestPermission(opts.sender.userId); + if (permId === defaultPermissions.CASTERS) { + return true; + } + + const permList = await getUserPermissionsList(opts.sender.userId); + for (const pid of permList) { + // if any of user permission have it allowed, allow + if (!enabled[pid]) { + return true; + } + } + + const whitelisted = await this.whitelist(opts.message, permId); + const msgLength = whitelisted.trim().length; + let symbolsLength = 0; + + if (msgLength < cSymbolsTriggerLength[permId]) { + return true; + } + + const out = whitelisted.match(/([^\s\u0500-\u052F\u0400-\u04FF\w]+)/g); + for (const item in out) { + const symbols = out[Number(item)]; + if (symbols.length >= cSymbolsMaxSymbolsConsecutively[permId]) { + this.timeoutUser(opts.sender, opts.message, + translate('moderation.user-is-warned-about-symbols'), + translate('moderation.user-have-timeout-for-symbols'), + timeoutValues[permId], 'symbols', opts.id); + return false; + } + symbolsLength = symbolsLength + symbols.length; + } + if (Math.ceil(symbolsLength / (msgLength / 100)) >= cSymbolsMaxSymbolsPercent[permId]) { + this.timeoutUser(opts.sender, opts.message, translate('moderation.user-is-warned-about-symbols'), translate('moderation.symbols'), timeoutValues[permId], 'symbols', opts.id); + return false; + } + return true; + } + + @parser({ priority: constants.MODERATION }) + async longMessage (opts: ParserOptions) { + if (!opts.sender || immuneUsers.get('longmessage')?.has(String(opts.sender.userId))) { + return true; + } + + const enabled = await this.getPermissionBasedSettingsValue('cLongMessageEnabled'); + const cLongMessageTriggerLength = await this.getPermissionBasedSettingsValue('cLongMessageTriggerLength'); + const timeoutValues = await this.getPermissionBasedSettingsValue('cLongMessageTimeout'); + + const permId = await getUserHighestPermission(opts.sender.userId); + if (permId === defaultPermissions.CASTERS) { + return true; + } + + const permList = await getUserPermissionsList(opts.sender.userId); + for (const pid of permList) { + // if any of user permission have it allowed, allow + if (!enabled[pid]) { + return true; + } + } + + const whitelisted = await this.whitelist(opts.message, permId); + + const msgLength = whitelisted.trim().length; + if (msgLength < cLongMessageTriggerLength[permId]) { + return true; + } else { + this.timeoutUser(opts.sender, opts.message, + translate('moderation.user-is-warned-about-long-message'), + translate('moderation.user-have-timeout-for-long-message'), + timeoutValues[permId], 'longmessage', opts.id); + return false; + } + } + + @parser({ priority: constants.MODERATION }) + async caps (opts: ParserOptions) { + if (!opts.sender || immuneUsers.get('caps')?.has(String(opts.sender.userId))) { + return true; + } + + const enabled = await this.getPermissionBasedSettingsValue('cCapsEnabled'); + const cCapsTriggerLength = await this.getPermissionBasedSettingsValue('cCapsTriggerLength'); + const cCapsMaxCapsPercent = await this.getPermissionBasedSettingsValue('cCapsMaxCapsPercent'); + const timeoutValues = await this.getPermissionBasedSettingsValue('cCapsTimeout'); + + const permId = await getUserHighestPermission(opts.sender.userId); + if (permId === defaultPermissions.CASTERS) { + return true; + } + + const permList = await getUserPermissionsList(opts.sender.userId); + for (const pid of permList) { + // if any of user permission have it allowed, allow + if (!enabled[pid]) { + return true; + } + } + let whitelisted = await this.whitelist(opts.message, permId); + + const emotesCharList: number[] = []; + for (const emoteList of opts.emotesOffsets.values()) { + for(const emote of emoteList) { + for (const i of _.range(Number(emote.split('-')[0]), Number(emote.split('-')[1]) + 1)) { + emotesCharList.push(i); + } + } + } + + let msgLength = whitelisted.trim().length; + let capsLength = 0; + + // exclude emotes from caps check + whitelisted = whitelisted.replace(emojiRegex(), '').trim(); + + const regexp = /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~]/gi; + for (let i = 0; i < whitelisted.length; i++) { + // if is emote or symbol - continue + if (emotesCharList.includes(i) || whitelisted.charAt(i).match(regexp) !== null) { + msgLength--; + continue; + } else if (!_.isFinite(parseInt(whitelisted.charAt(i), 10)) && whitelisted.charAt(i).toUpperCase() === whitelisted.charAt(i) && whitelisted.charAt(i) !== ' ') { + capsLength += 1; + } + } + + if (msgLength < cCapsTriggerLength[permId]) { + return true; + } + if (Math.ceil(capsLength / (msgLength / 100)) >= cCapsMaxCapsPercent[permId]) { + this.timeoutUser(opts.sender, opts.message, + translate('moderation.user-is-warned-about-caps'), + translate('moderation.user-have-timeout-for-caps'), + timeoutValues[permId], 'caps', opts.id); + return false; + } + return true; + } + + @parser({ priority: constants.MODERATION }) + async spam (opts: ParserOptions) { + if (!opts.sender || immuneUsers.get('spam')?.has(String(opts.sender.userId))) { + return true; + } + + const enabled = await this.getPermissionBasedSettingsValue('cSpamEnabled'); + const cSpamTriggerLength = await this.getPermissionBasedSettingsValue('cSpamTriggerLength'); + const cSpamMaxLength = await this.getPermissionBasedSettingsValue('cSpamMaxLength'); + const timeoutValues = await this.getPermissionBasedSettingsValue('cSpamTimeout'); + + const permId = await getUserHighestPermission(opts.sender.userId); + if (permId === defaultPermissions.CASTERS) { + return true; + } + + const permList = await getUserPermissionsList(opts.sender.userId); + for (const pid of permList) { + // if any of user permission have it allowed, allow + if (!enabled[pid]) { + return true; + } + } + const whitelisted = await this.whitelist(opts.message,permId); + + const msgLength = whitelisted.trim().length; + + if (msgLength < cSpamTriggerLength[permId]) { + return true; + } + const out = whitelisted.match(/(.+)(\1+)/g); + for (const item in out) { + if (out[Number(item)].length >= cSpamMaxLength[permId]) { + this.timeoutUser(opts.sender, opts.message, + translate('moderation.user-have-timeout-for-spam'), + translate('moderation.user-is-warned-about-spam'), + timeoutValues[permId], 'spam', opts.id); + return false; + } + } + return true; + } + + @parser({ priority: constants.MODERATION }) + async color (opts: ParserOptions) { + if (!opts.sender || immuneUsers.get('color')?.has(String(opts.sender.userId))) { + return true; + } + + const enabled = await this.getPermissionBasedSettingsValue('cColorEnabled'); + const timeoutValues = await this.getPermissionBasedSettingsValue('cColorTimeout'); + + const permId = await getUserHighestPermission(opts.sender.userId); + if (permId === defaultPermissions.CASTERS) { + return true; + } + + const permList = await getUserPermissionsList(opts.sender.userId); + for (const pid of permList) { + // if any of user permission have it allowed, allow + if (!enabled[pid]) { + return true; + } + } + + if (opts.isAction) { + this.timeoutUser(opts.sender, opts.message, + translate('moderation.user-is-warned-about-color'), + translate('moderation.user-have-timeout-for-color'), + timeoutValues[permId], 'color', opts.id); + return false; + } else { + return true; + } + } + + @parser({ priority: constants.MODERATION }) + async emotes (opts: ParserOptions) { + if (!opts.sender || immuneUsers.get('emotes')?.has(String(opts.sender.userId))) { + return true; + } + + const enabled = await this.getPermissionBasedSettingsValue('cEmotesEnabled'); + const cEmotesEmojisAreEmotes = await this.getPermissionBasedSettingsValue('cEmotesEmojisAreEmotes'); + const cEmotesMaxCount = await this.getPermissionBasedSettingsValue('cEmotesMaxCount'); + const timeoutValues = await this.getPermissionBasedSettingsValue('cEmotesTimeout'); + + const permId = await getUserHighestPermission(opts.sender.userId); + if (permId === defaultPermissions.CASTERS) { + return true; + } + + const permList = await getUserPermissionsList(opts.sender.userId); + for (const pid of permList) { + // if any of user permission have it allowed, allow + if (!enabled[pid]) { + return true; + } + } + + let count = 0; + for (const offsets of opts.emotesOffsets.values()) { + count += offsets.length; + } + if (cEmotesEmojisAreEmotes[permId]) { + const regex = emojiRegex(); + while (regex.exec(opts.message)) { + count++; + } + } + + if (count > cEmotesMaxCount[permId]) { + this.timeoutUser(opts.sender, opts.message, + translate('moderation.user-is-warned-about-emotes'), + translate('moderation.user-have-timeout-for-emotes'), + timeoutValues[permId], 'emotes', opts.id); + return false; + } else { + return true; + } + } + + @parser({ priority: constants.MODERATION }) + async blacklist (opts: ParserOptions) { + if (!opts.sender || immuneUsers.get('blacklist')?.has(String(opts.sender.userId))) { + return true; + } + + const enabled = await this.getPermissionBasedSettingsValue('cListsEnabled'); + const timeoutValues = await this.getPermissionBasedSettingsValue('cListsTimeout'); + + const permId = await getUserHighestPermission(opts.sender.userId); + if (permId === defaultPermissions.CASTERS) { + return true; + } + + const permList = await getUserPermissionsList(opts.sender.userId); + for (const pid of permList) { + // if any of user permission have it allowed, allow + if (!enabled[pid]) { + return true; + } + } + + let isOK = true; + for (const value of this.cListsBlacklist.map(o => o.trim().replace(/\*/g, '[\\pL0-9]*').replace(/\+/g, '[\\pL0-9]+'))) { + if (value.length > 0) { + const regexp = XRegExp(` [^\\s\\pL0-9\\w]?${value}[^\\s\\pL0-9\\w]? `, 'gi'); + // we need to change 'text' to ' text ' for regexp to correctly work + if (XRegExp.exec(` ${opts.message} `, regexp)) { + isOK = false; + this.timeoutUser(opts.sender, opts.message, + translate('moderation.user-is-warned-about-forbidden-words'), + translate('moderation.user-have-timeout-for-forbidden-words'), + timeoutValues[permId], 'blacklist', opts.id); + break; + } + } + } + return isOK; + } + + async isSilent (name: typeof timeoutType[number]) { + const cooldown = ModerationMessageCooldown.get(name); + if (!cooldown || (Date.now() - cooldown) >= 60000) { + ModerationMessageCooldown.set(name, Date.now()); + return false; + } + return true; + } +} + +export default new Moderation(); diff --git a/backend/src/systems/points.ts b/backend/src/systems/points.ts new file mode 100644 index 000000000..3214b81e6 --- /dev/null +++ b/backend/src/systems/points.ts @@ -0,0 +1,655 @@ +import { PointsChangelog } from '@entity/points.js'; +import { User, UserInterface } from '@entity/user.js'; +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { format } from '@sogebot/ui-helpers/number.js'; +import cronparser from 'cron-parser'; +import { + LessThanOrEqual, FindOptionsWhere, +} from 'typeorm'; + +import System from './_interface.js'; +import { + onChange, onLoad, onStartup, +} from '../decorators/on.js'; +import { + command, default_permission, parser, permission_settings, persistent, settings, +} from '../decorators.js'; +import { Expects } from '../expects.js'; +import general from '../general.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline } from '~/helpers/api/index.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { getAllOnlineIds } from '~/helpers/getAllOnlineUsernames.js'; +import { + debug, error, warning, +} from '~/helpers/log.js'; +import { ParameterError } from '~/helpers/parameterError.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { getUserHighestPermission } from '~/helpers/permissions/getUserHighestPermission.js'; +import { getPointsName, name } from '~/helpers/points/index.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { isBot, isBotId } from '~/helpers/user/isBot.js'; +import { getIdFromTwitch } from '~/services/twitch/calls/getIdFromTwitch.js'; +import { translate } from '~/translate.js'; +import { variables } from '~/watchers.js'; + +class Points extends System { + cronTask: any = null; + isLoaded: string[] = []; + + @settings('reset') + isPointResetIntervalEnabled = false; + @settings('reset') + resetIntervalCron = '@monthly'; + @persistent() + lastCronRun = 0; + + @settings('customization') + name = 'point|points'; // default is | | in some languages can be set with custom || where x <= 10 + + @permission_settings('customization') + interval = 10; + + @permission_settings('customization') + perInterval = 1; + + @permission_settings('customization') + offlineInterval = 30; + + @permission_settings('customization') + perOfflineInterval = 1; + + @permission_settings('customization') + messageInterval = 5; + + @permission_settings('customization') + perMessageInterval = 1; + + @permission_settings('customization') + messageOfflineInterval = 5; + + @permission_settings('customization') + perMessageOfflineInterval = 0; + + @onLoad('name') + @onChange('name') + setPointsName() { + name.value = this.name; + } + + @onStartup() + onStartup() { + setInterval(() => { + try { + const interval = cronparser.parseExpression(this.resetIntervalCron); + const lastProbableRun = new Date(interval.prev().toISOString()).getTime(); + if (lastProbableRun > this.lastCronRun) { + if (this.isPointResetIntervalEnabled) { + warning('Points were reset by cron'); + changelog.flush().then(() => { + AppDataSource.getRepository(User).update({}, { points: 0 }); + }); + } else { + debug('points.cron', 'Cron would run, but it is disabled.'); + } + this.lastCronRun = Date.now(); + } + } catch (e: any) { + error(e); + } + }, MINUTE); + } + + @onChange('resetIntervalCron') + @onChange('isPointResetIntervalEnabled') + resetLastCronRun() { + this.lastCronRun = Date.now(); + } + + @onLoad('enabled') + async updatePoints () { + if (!this.enabled) { + debug('points.update', 'Disabled, next check in 5s'); + setTimeout(() => this.updatePoints(), 5000); + return; + } + + // cleanup all undoes (only 10minutes should be kept) + await AppDataSource.getRepository(PointsChangelog).delete({ updatedAt: LessThanOrEqual(Date.now() - (10 * MINUTE)) }); + + try { + const userPromises: Promise[] = []; + debug('points.update', `Started points adding, isStreamOnline: ${isStreamOnline.value}`); + for (const userId of (await getAllOnlineIds())) { + if (isBotId(userId)) { + continue; + } + userPromises.push(this.processPoints(userId)); + await Promise.all(userPromises); + } + } catch (e: any) { + error(e); + error(e.stack); + } finally { + debug('points.update', 'Finished points adding, triggering next check in 60s'); + setTimeout(() => this.updatePoints(), 60000); + } + } + + private async processPoints(userId: string): Promise { + const user = await changelog.get(userId); + if (!user) { + return; + } + + const [interval, offlineInterval, perInterval, perOfflineInterval] = await Promise.all([ + this.getPermissionBasedSettingsValue('interval'), + this.getPermissionBasedSettingsValue('offlineInterval'), + this.getPermissionBasedSettingsValue('perInterval'), + this.getPermissionBasedSettingsValue('perOfflineInterval'), + ]); + + // get user max permission + const permId = await getUserHighestPermission(userId); + if (!permId) { + debug('points.update', `User ${user.userName}#${userId} permId not found`); + return; // skip without id + } + + const interval_calculated = isStreamOnline.value ? interval[permId] * 60 * 1000 : offlineInterval[permId] * 60 * 1000; + const ptsPerInterval = isStreamOnline.value ? perInterval[permId] : perOfflineInterval[permId] ; + + const chat = await users.getChatOf(userId, isStreamOnline.value); + const userPointsKey = isStreamOnline.value ? 'pointsOnlineGivenAt' : 'pointsOfflineGivenAt'; + + if (interval_calculated !== 0 && ptsPerInterval !== 0) { + let givenAt = user[userPointsKey]; + debug('points.update', `${user.userName}#${userId}[${permId}] chat: ${chat} | givenAt: ${givenAt} | interval: ${interval_calculated}`); + + let modifier = 0; + while(givenAt < chat) { + modifier++; + givenAt += interval_calculated; + } + + if (modifier > 0) { + // add points to user[userPointsKey] + interval to user to not overcalculate (this should ensure recursive add points in time) + debug('points.update', `${user.userName}#${userId}[${permId}] +${Math.floor(ptsPerInterval * modifier)}`); + changelog.update(userId, { + ...user, + points: user.points + ptsPerInterval * modifier, + [userPointsKey]: givenAt, + }); + } + } else { + changelog.update(userId, { + ...user, + [userPointsKey]: chat, + }); + debug('points.update', `${user.userName}#${userId}[${permId}] points disabled or interval is 0, settint points time to chat`); + } + } + + @parser({ fireAndForget: true, skippable: true }) + async messagePoints (opts: ParserOptions) { + if (!opts.sender || opts.skip || opts.message.startsWith('!')) { + return true; + } + + const [perMessageInterval, messageInterval, perMessageOfflineInterval, messageOfflineInterval] = await Promise.all([ + this.getPermissionBasedSettingsValue('perMessageInterval'), + this.getPermissionBasedSettingsValue('messageInterval'), + this.getPermissionBasedSettingsValue('perMessageOfflineInterval'), + this.getPermissionBasedSettingsValue('messageOfflineInterval'), + ]); + + // get user max permission + const permId = await getUserHighestPermission(opts.sender.userId); + if (!permId) { + return true; // skip without permission + } + + const interval_calculated = isStreamOnline.value ? messageInterval[permId] : messageOfflineInterval[permId]; + const ptsPerInterval = isStreamOnline.value ? perMessageInterval[permId] : perMessageOfflineInterval[permId]; + + if (interval_calculated === 0 || ptsPerInterval === 0) { + return; + } + + const user = await changelog.get(opts.sender.userId); + if (!user) { + return true; + } + + if (user.pointsByMessageGivenAt + interval_calculated <= user.messages) { + changelog.update(opts.sender.userId, { + ...user, + points: user.points + ptsPerInterval, + pointsByMessageGivenAt: user.messages, + }); + } + return true; + } + + sockets () { + adminEndpoint('/systems/points', 'parseCron', (cron, cb) => { + try { + const interval = cronparser.parseExpression(cron); + // get 5 dates + const intervals: number[] = []; + for (let i = 0; i < 5; i++) { + intervals.push(new Date(interval.next().toISOString()).getTime()); + } + cb(null, intervals); + } catch (e: any) { + cb(e.message, []); + } + }); + + adminEndpoint('/systems/points', 'reset', async () => { + changelog.flush().then(() => { + AppDataSource.getRepository(PointsChangelog).clear(); + AppDataSource.getRepository(User).update({}, { points: 0 }); + }); + }); + } + + maxSafeInteger(number: number) { + return number <= Number.MAX_SAFE_INTEGER + ? number + : Number.MAX_SAFE_INTEGER; + } + + async getPointsOf(userId: string) { + const user = await changelog.get(userId); + + if (user) { + if (user.points < 0) { + changelog.update(userId, { + ...user, + points: 0, + }); + } + return user.points <= Number.MAX_SAFE_INTEGER + ? user.points + : Number.MAX_SAFE_INTEGER; + } else { + return 0; + } + } + + @command('!points undo') + @default_permission(defaultPermissions.CASTERS) + async undo(opts: CommandOptions) { + try { + const [username] = new Expects(opts.parameters).username().toArray(); + const userId = await users.getIdByName(username); + if (!userId) { + throw new Error(`User ${username} not found in database`); + } + + const undoOperation = await AppDataSource.getRepository(PointsChangelog).findOne({ + where: { userId }, + order: { updatedAt: 'DESC' }, + }); + if (!undoOperation) { + throw new Error(`No undo operation found for ` + username); + } + + await AppDataSource.getRepository(PointsChangelog).delete({ id: undoOperation.id }); + changelog.update(userId, { points: undoOperation.originalValue }); + + return [{ + response: prepare('points.success.undo', { + username, + command: undoOperation.command, + originalValue: format(general.numberFormat, 0)(undoOperation.originalValue), + originalValuePointsLocale: getPointsName(undoOperation.originalValue), + updatedValue: format(general.numberFormat, 0)(undoOperation.updatedValue), + updatedValuePointsLocale: getPointsName(undoOperation.updatedValue), + }), ...opts, + }]; + } catch (err: any) { + error(err); + return [{ response: translate('points.failed.undo').replace('$command', opts.command), ...opts }]; + } + } + + @command('!points set') + @default_permission(defaultPermissions.CASTERS) + async set (opts: CommandOptions): Promise { + try { + const [userName, points] = new Expects(opts.parameters).username().points({ all: false }).toArray(); + + await changelog.flush(); + const originalUser = await AppDataSource.getRepository(User).findOneBy({ userName }); + if (!originalUser) { + throw new Error(`User ${userName} not found in database.`); + } + changelog.update(originalUser.userId, { points }); + await AppDataSource.getRepository(PointsChangelog).insert({ + userId: originalUser.userId, + updatedAt: Date.now(), + command: 'set', + originalValue: originalUser.points, + updatedValue: points, + }); + + const response = prepare('points.success.set', { + amount: format(general.numberFormat, 0)(points), + username: userName, + pointsName: getPointsName(points), + }); + return [{ response, ...opts }]; + } catch (err: any) { + error(err); + return [{ response: translate('points.failed.set').replace('$command', opts.command), ...opts }]; + } + } + + @command('!points give') + async give (opts: CommandOptions): Promise { + try { + const [userName, points] = new Expects(opts.parameters).username().points({ all: true }).toArray(); + if (opts.sender.userName.toLowerCase() === userName.toLowerCase()) { + return []; + } + await changelog.flush(); + const guser = await AppDataSource.getRepository(User).findOneBy({ userName }); + const sender = await changelog.get(opts.sender.userId); + + if (!sender) { + throw new Error('Sender was not found in DB!'); + } + + if (!guser) { + changelog.update(await getIdFromTwitch(userName), { userName }); + return this.give(opts); + } + + const availablePoints = sender.points; + if (points === 0 || points === 'all' && availablePoints === 0) { + const response = prepare('points.failed.cannotGiveZeroPoints'.replace('$command', opts.command), { + amount: 0, + username: userName, + pointsName: getPointsName(0), + }); + return [{ response, ...opts }]; + } + + if (points !== 'all' && availablePoints < points) { + const response = prepare('points.failed.giveNotEnough'.replace('$command', opts.command), { + amount: format(general.numberFormat, 0)(points), + username: userName, + pointsName: getPointsName(points), + }); + return [{ response, ...opts }]; + } else if (points === 'all') { + changelog.increment(guser.userId, { points: availablePoints }); + changelog.update(sender.userId, { points: 0 }); + const response = prepare('points.success.give', { + amount: format(general.numberFormat, 0)(availablePoints), + userName, + pointsName: getPointsName(availablePoints), + }); + return [{ response, ...opts }]; + } else { + changelog.increment(guser.userId, { points: points }); + changelog.increment(sender.userId, { points: -points }); + const response = prepare('points.success.give', { + amount: format(general.numberFormat, 0)(points), + userName, + pointsName: getPointsName(points), + }); + return [{ response, ...opts }]; + } + } catch (err: any) { + return [{ response: translate('points.failed.give').replace('$command', opts.command), ...opts }]; + } + } + + @command('!points get') + @default_permission(defaultPermissions.CASTERS) + async get (opts: CommandOptions): Promise { + try { + const [userName] = new Expects(opts.parameters).username({ optional: true, default: opts.sender.userName }).toArray(); + + let user: Readonly> | null; + if (opts.sender.userName === userName) { + user = await changelog.get(opts.sender.userId); + } else { + await changelog.flush(); + user = await AppDataSource.getRepository(User).findOneBy({ userName }) ?? null; + } + + if (!user) { + const userId = await getIdFromTwitch(userName); + if (userId) { + changelog.update(userId, { userName }); + return this.get(opts); + } else { + throw new Error(`User ${userName} not found on twitch`); + } + } + + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const query = (type: typeof AppDataSource.options.type) => { + switch(type) { + case 'postgres': + case 'better-sqlite3': + return `SELECT COUNT(*) as "order" FROM "user" WHERE "points" > (SELECT "points" FROM "user" WHERE "userId"='${user?.userId}') AND "userName"!='${broadcasterUsername}'`; + case 'mysql': + case 'mariadb': + default: + return `SELECT COUNT(*) as \`order\` FROM \`user\` WHERE \`points\` > (SELECT \`points\` FROM \`user\` WHERE \`userId\`='${user?.userId}') AND "userName"!='${broadcasterUsername}'`; + } + }; + + await changelog.flush(); + const orderQuery = await AppDataSource.getRepository(User).query(query(AppDataSource.options.type)); + const count = await AppDataSource.getRepository(User).count(); + + let order: number | string = '?'; + if (orderQuery.length > 0) { + order = Number(orderQuery[0].order) + 1; + } + + if (user.userName === broadcasterUsername) { + order = '?'; // broadcaster is removed from ordering + } + + const response = prepare('points.defaults.pointsResponse', { + amount: format(general.numberFormat, 0)(this.maxSafeInteger(user.points)), + username: userName, + pointsName: getPointsName(this.maxSafeInteger(user.points)), + order, count, + }); + return [{ response, ...opts }]; + } catch (err: any) { + if (!(err instanceof ParameterError)) { + error(err.stack); + } + return [{ response: translate('points.failed.get').replace('$command', opts.command), ...opts }]; + } + } + + @command('!points online') + @default_permission(defaultPermissions.CASTERS) + async online (opts: CommandOptions): Promise { + try { + let points = new Expects(opts.parameters).points({ all: false, negative: true }).toArray()[0]; + + let response: string; + if (points >= 0) { + await changelog.flush(); + await AppDataSource.getRepository(User).increment({}, 'points', points); + response = prepare('points.success.online.positive', { + amount: format(general.numberFormat, 0)(points), + pointsName: getPointsName(points), + }); + } else { + points = Math.abs(points); + await this.decrement({}, points); + response = prepare('points.success.online.negative', { + amount: `-${format(general.numberFormat, 0)(points)}`, + pointsName: getPointsName(points), + }); + } + + return [{ response, ...opts }]; + } catch (err: any) { + return [{ response: translate('points.failed.online').replace('$command', opts.command), ...opts }]; + } + } + + @command('!points all') + @default_permission(defaultPermissions.CASTERS) + async all (opts: CommandOptions): Promise { + try { + let points: number = new Expects(opts.parameters).points({ all: false, negative: true }).toArray()[0]; + let response: string; + if (points >= 0) { + await changelog.flush(); + await AppDataSource.getRepository(User).increment({}, 'points', points); + response = prepare('points.success.all.positive', { + amount: format(general.numberFormat, 0)(points), + pointsName: getPointsName(points), + }); + } else { + points = Math.abs(points); + await this.decrement({}, points); + response = prepare('points.success.all.negative', { + amount: `-${format(general.numberFormat, 0)(points)}`, + pointsName: getPointsName(points), + }); + } + + return [{ response, ...opts }]; + } catch (err: any) { + return [{ response: translate('points.failed.all').replace('$command', opts.command), ...opts }]; + } + } + + @command('!makeitrain') + @default_permission(defaultPermissions.CASTERS) + async rain (opts: CommandOptions): Promise { + try { + const points = new Expects(opts.parameters).points({ all: false }).toArray()[0]; + await changelog.flush(); + + for (const user of (await AppDataSource.getRepository(User).findBy({ isOnline: true }))) { + if (isBot(user.userName)) { + continue; + } + + changelog.increment(user.userId, { points: Math.floor(Math.random() * points) }); + } + const response = prepare('points.success.rain', { + amount: format(general.numberFormat, 0)(points), + pointsName: getPointsName(points), + }); + return [{ response, ...opts }]; + } catch (err: any) { + return [{ response: translate('points.failed.rain').replace('$command', opts.command), ...opts }]; + } + } + + @command('!points add') + @default_permission(defaultPermissions.CASTERS) + async add (opts: CommandOptions): Promise { + try { + const [userName, points] = new Expects(opts.parameters).username().points({ all: false }).toArray(); + + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userName }); + + if (!user) { + changelog.update(await getIdFromTwitch(userName), { userName }); + return this.add(opts); + } else { + changelog.increment(user.userId, { points }); + } + + await AppDataSource.getRepository(PointsChangelog).insert({ + userId: user.userId, + command: 'add', + originalValue: user.points, + updatedValue: user.points + points, + updatedAt: Date.now(), + }); + + const response = prepare('points.success.add', { + amount: format(general.numberFormat, 0)(points), + username: userName, + pointsName: getPointsName(points), + }); + return [{ response, ...opts }]; + } catch (err: any) { + return [{ response: translate('points.failed.add').replace('$command', opts.command), ...opts }]; + } + } + + @command('!points remove') + @default_permission(defaultPermissions.CASTERS) + async remove (opts: CommandOptions): Promise { + try { + const [userName, points] = new Expects(opts.parameters).username().points({ all: true }).toArray(); + + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userName }); + if (!user) { + changelog.update(await getIdFromTwitch(userName), { userName }); + return this.remove(opts); + } + + if (points === 'all') { + changelog.update(user.userId, { points: 0 }); + } else { + changelog.update(user.userId, { points: Math.max(user.points - points, 0) }); + } + + await AppDataSource.getRepository(PointsChangelog).insert({ + userId: user.userId, + command: 'remove', + originalValue: user.points, + updatedValue: points === 'all' ? 0 : Math.max(user.points - points, 0), + updatedAt: Date.now(), + }); + + const response = prepare('points.success.remove', { + amount: format(general.numberFormat, 0)(points), + username: userName, + pointsName: getPointsName(points === 'all' ? 0 : points), + }); + return [{ response, ...opts }]; + } catch (err: any) { + error(err); + return [{ response: translate('points.failed.remove').replace('$command', opts.command), ...opts }]; + } + } + + @command('!points') + async main (opts: CommandOptions): Promise { + return this.get(opts); + } + + async increment(where: FindOptionsWhere>>, points: number) { + await changelog.flush(); + await AppDataSource.getRepository(User).increment(where, 'points', points); + } + + async decrement(where: FindOptionsWhere>>, points: number) { + await changelog.flush(); + await AppDataSource.getRepository(User).decrement(where, 'points', points); + await AppDataSource.getRepository(User).createQueryBuilder() + .update(User) + .set({ points: 0 }) + .where('points < 0') + .execute(); + } +} + +export default new Points(); diff --git a/backend/src/systems/polls.ts b/backend/src/systems/polls.ts new file mode 100644 index 000000000..20751773b --- /dev/null +++ b/backend/src/systems/polls.ts @@ -0,0 +1,119 @@ +import { rawDataSymbol } from '@twurple/common'; +import _ from 'lodash-es'; + +import System from './_interface.js'; +import { + onStartup, onStreamStart, +} from '../decorators/on.js'; +import { + command, default_permission, +} from '../decorators.js'; +import { Expects } from '../expects.js'; + +import * as channelPoll from '~/helpers/api/channelPoll.js'; +import { error } from '~/helpers/log.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import getBroadcasterId from '~/helpers/user/getBroadcasterId.js'; +import twitch from '~/services/twitch.js'; + +enum ERROR { + NOT_ENOUGH_OPTIONS, + NO_VOTING_IN_PROGRESS, + INVALID_VOTE_TYPE, + INVALID_VOTE, + ALREADY_OPENED, + ALREADY_CLOSED, +} +let retryTimeout: NodeJS.Timeout | undefined; + +/* + * !poll open [-tips/-bits/-points] -title "your vote title" option | option | option + * !poll close + * !poll reuse + */ +class Polls extends System { + @onStartup() + @onStreamStart() + async onStartup() { + try { + // initial load of polls + const polls = await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.polls.getPolls(getBroadcasterId())); + if (polls) { + const poll = polls?.data.find(o => o.status === 'ACTIVE'); + if (poll) { + channelPoll.setData(poll[rawDataSymbol]); + } + } + } catch (e) { + if (e instanceof Error && e.message.includes('not found in auth provider')) { + clearTimeout(retryTimeout); + retryTimeout = setTimeout(() => this.onStartup(), 10000); + } else { + throw e; + } + } + } + + @command('!poll close') + @default_permission(defaultPermissions.MODERATORS) + public async close(opts: CommandOptions): Promise { + try { + if (channelPoll.event) { + await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.polls.endPoll(getBroadcasterId(), channelPoll.event!.id, true)); + } + } catch (e) { + error(e); + } + return []; + } + + @command('!poll reuse') + @default_permission(defaultPermissions.MODERATORS) + public async reuse(opts: CommandOptions): Promise { + try { + const polls = await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.polls.getPolls(getBroadcasterId())); + if (polls) { + const poll = polls?.data[0]; + + await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.polls.createPoll(getBroadcasterId(), { + choices: poll.choices.map(o => o.title), + duration: poll.durationInSeconds, + title: poll.title, + })); + } + } catch (e) { + error(e); + } + return []; + } + + @command('!poll open') + @default_permission(defaultPermissions.MODERATORS) + public async open(opts: CommandOptions): Promise { + try { + const [duration, title, options] = new Expects(opts.parameters) + .switch({ + name: 'duration', values: ['1', '2', '3', '5', '10'], optional: true, default: '5', + }) + .argument({ + name: 'title', optional: false, multi: true, + }) + .list({ delimiter: '|' }) + .toArray(); + if (options.length < 2) { + throw new Error(String(ERROR.NOT_ENOUGH_OPTIONS)); + } + + await twitch.apiClient?.asIntent(['broadcaster'], ctx => ctx.polls.createPoll(getBroadcasterId(), { + choices: options, + duration: Number(duration) * 60, // in seconds + title, + })); + } catch (e) { + error(e); + } + return []; + } +} + +export default new Polls(); \ No newline at end of file diff --git a/backend/src/systems/price.ts b/backend/src/systems/price.ts new file mode 100644 index 000000000..c56f224ae --- /dev/null +++ b/backend/src/systems/price.ts @@ -0,0 +1,219 @@ +import { Price as PriceEntity } from '@entity/price.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import { format } from '@sogebot/ui-helpers/number.js'; +import { validateOrReject } from 'class-validator'; +import * as _ from 'lodash-es'; +import { merge } from 'lodash-es'; + +import System from './_interface.js'; +import { parserReply } from '../commons.js'; +import { + command, default_permission, rollback, +} from '../decorators.js'; +import { parser } from '../decorators.js'; +import general from '../general.js'; +import { Parser } from '../parser.js'; + +import { AppDataSource } from '~/database.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { app } from '~/helpers/panel.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { getPointsName } from '~/helpers/points/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { isBroadcaster, isOwner } from '~/helpers/user/index.js'; +import { adminMiddleware } from '~/socket.js'; +import { translate } from '~/translate.js'; + +/* + * !price - gets an info about price usage + * !price set [cmd] [price] - add notice with specified response + * !price unset [cmd] [price] - add notice with specified response + * !price list - get list of notices + * !price toggle [cmd] - remove notice by id + */ + +class Price extends System { + public dependsOn = [ 'systems.points' ]; + + constructor () { + super(); + this.addMenu({ + category: 'commands', name: 'price', id: 'commands/price', this: this, + }); + } + + sockets() { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/systems/price', adminMiddleware, async (req, res) => { + res.send({ + data: await PriceEntity.find({ order: { price: 'ASC' } }), + }); + }); + app.get('/api/systems/price/:id', adminMiddleware, async (req, res) => { + res.send({ + data: await PriceEntity.findOneBy({ id: req.params.id }), + }); + }); + app.delete('/api/systems/price/:id', adminMiddleware, async (req, res) => { + await PriceEntity.delete({ id: req.params.id }); + res.status(404).send(); + }); + app.post('/api/systems/price', adminMiddleware, async (req, res) => { + try { + const itemToSave = new PriceEntity(); + merge(itemToSave, req.body); + await validateOrReject(itemToSave); + await itemToSave.save(); + res.send({ data: itemToSave }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + } + + @command('!price') + @default_permission(defaultPermissions.CASTERS) + main (opts: CommandOptions): CommandResponse[] { + return [{ response: translate('core.usage') + ': !price set | !price unset | !price list | !price toggle ', ...opts }]; + } + + @command('!price set') + @default_permission(defaultPermissions.CASTERS) + async set (opts: CommandOptions): Promise { + const parsed = opts.parameters.match(/^(![\S]+) ([0-9]+)$/); + + if (_.isNil(parsed)) { + const response = prepare('price.price-parse-failed'); + return [{ response, ...opts }]; + } + + const [cmd, argPrice] = parsed.slice(1); + if (parseInt(argPrice, 10) === 0) { + return this.unset(opts); + } + + const price = await AppDataSource.getRepository(PriceEntity).save({ + ...(await AppDataSource.getRepository(PriceEntity).findOneBy({ command: cmd })), + command: cmd, price: parseInt(argPrice, 10), + }); + const response = prepare('price.price-was-set', { + command: cmd, amount: format(general.numberFormat, 0)(Number(argPrice)), pointsName: getPointsName(price.price), + }); + return [{ response, ...opts }]; + } + + @command('!price unset') + @default_permission(defaultPermissions.CASTERS) + async unset (opts: CommandOptions): Promise { + const parsed = opts.parameters.match(/^(![\S]+)$/); + + if (_.isNil(parsed)) { + const response = prepare('price.price-parse-failed'); + return [{ response, ...opts }]; + } + + const cmd = parsed[1]; + await AppDataSource.getRepository(PriceEntity).delete({ command: cmd }); + const response = prepare('price.price-was-unset', { command: cmd }); + return [{ response, ...opts }]; + } + + @command('!price toggle') + @default_permission(defaultPermissions.CASTERS) + async toggle (opts: CommandOptions): Promise { + const parsed = opts.parameters.match(/^(![\S]+)$/); + + if (_.isNil(parsed)) { + const response = prepare('price.price-parse-failed'); + return [{ response, ...opts }]; + } + + const cmd = parsed[1]; + const price = await AppDataSource.getRepository(PriceEntity).findOneBy({ command: cmd }); + if (!price) { + const response = prepare('price.price-was-not-found', { command: cmd }); + return [{ response, ...opts }]; + } + + await AppDataSource.getRepository(PriceEntity).save({ ...price, enabled: !price.enabled }); + const response = prepare(price.enabled ? 'price.price-was-enabled' : 'price.price-was-disabled', { command: cmd }); + return [{ response, ...opts }]; + } + + @command('!price list') + @default_permission(defaultPermissions.CASTERS) + async list (opts: CommandOptions): Promise { + const prices = await AppDataSource.getRepository(PriceEntity).find(); + const response = (prices.length === 0 ? translate('price.list-is-empty') : translate('price.list-is-not-empty').replace(/\$list/g, (_.orderBy(prices, 'command').map((o) => { + return `${o.command} - ${o.price}`; + })).join(', '))); + return [{ response, ...opts }]; + } + + @parser({ priority: constants.HIGH, skippable: true }) + async check (opts: ParserOptions): Promise { + const points = (await import('../systems/points.js')).default; + const parsed = opts.message.match(/^(![\S]+)/); + if (!opts.sender || !parsed || isBroadcaster(opts.sender?.userName)) { + return true; // skip if not command or user is broadcaster + } + const helpers = (await (opts.parser || new Parser()).getCommandsList()).filter(o => o.isHelper).map(o => o.command); + if (helpers.includes(opts.message)) { + return true; + } + + const price = await AppDataSource.getRepository(PriceEntity).findOneBy({ command: parsed[1], enabled: true }); + if (!price) { // no price set + return true; + } + + let translation = 'price.user-have-not-enough-points'; + if (price.price === 0 && price.priceBits > 0) { + translation = 'price.user-have-not-enough-bits'; + } + if (price.price > 0 && price.priceBits > 0) { + translation = 'price.user-have-not-enough-points-or-bits'; + } + + const availablePts = await points.getPointsOf(opts.sender.userId); + const removePts = price.price; + const haveEnoughPoints = price.price > 0 && availablePts >= removePts; + if (!haveEnoughPoints) { + const response = prepare(translation, { + bitsAmount: price.priceBits, amount: format(general.numberFormat, 0)(removePts), command: `${price.command}`, pointsName: getPointsName(removePts), + }); + parserReply(response, opts); + } else { + await points.decrement({ userId: opts.sender.userId }, removePts); + } + return haveEnoughPoints; + } + + @rollback() + async restorePointsRollback (opts: ParserOptions): Promise { + if (!opts.sender) { + return true; + } + const parsed = opts.message.match(/^(![\S]+)/); + const helpers = (await (opts.parser || new Parser()).getCommandsList()).filter(o => o.isHelper).map(o => o.command); + if ( + _.isNil(parsed) + || isOwner(opts.sender) + || helpers.includes(opts.message) + ) { + return true; + } + const price = await AppDataSource.getRepository(PriceEntity).findOneBy({ command: parsed[1], enabled: true }); + if (price) { // no price set + const removePts = price.price; + changelog.increment(opts.sender.userId, { points: removePts }); + } + return true; + } +} + +export default new Price(); \ No newline at end of file diff --git a/backend/src/systems/queue.ts b/backend/src/systems/queue.ts new file mode 100644 index 000000000..5b2f5949d --- /dev/null +++ b/backend/src/systems/queue.ts @@ -0,0 +1,274 @@ +import { Queue as QueueEntity, QueueInterface } from '@entity/queue.js'; + +import System from './_interface.js'; +import { + command, default_permission, settings, +} from '../decorators.js'; + +import { parserReply } from '~/commons.js'; +import { AppDataSource } from '~/database.js'; +import { getUserSender, prepare } from '~/helpers/commons/index.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import getBotId from '~/helpers/user/getBotId.js'; +import getBotUserName from '~/helpers/user/getBotUserName.js'; +import twitch from '~/services/twitch.js'; +import { translate } from '~/translate.js'; + +/* + * !queue - gets an info whether queue is opened or closed + * !queue open - open a queue + * !queue close - close a queue + * !queue pick [amount] - pick [amount] (optional) of users from queue + * !queue random [amount] - random [amount] (optional) of users from queue + * !queue join [optional-message] - join a queue + * !queue clear - clear a queue + * !queue list - current list of queue + */ +class Queue extends System { + locked = false; + + @settings('eligibility') + eligibilityAll = true; + @settings('eligibility') + eligibilitySubscribers = true; + + pickedUsers: QueueInterface[] = []; + + sockets () { + adminEndpoint('/systems/queue', 'queue::getAllPicked', async(cb) => { + try { + cb(null, this.pickedUsers); + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/systems/queue', 'generic::getAll', async(cb) => { + try { + cb( + null, + await AppDataSource.getRepository(QueueEntity).find(), + ); + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/systems/queue', 'queue::clear', async(cb) => { + try { + await AppDataSource.getRepository(QueueEntity).clear(), + cb(null); + } catch (e: any) { + cb(e.stack); + } + }); + adminEndpoint('/systems/queue', 'queue::pick', async (data, cb) => { + try { + if (data.username) { + const users: any[] = []; + if (typeof data.username === 'string') { + data.username = [data.username]; + } + for (const user of data.username) { + const entity = await AppDataSource.getRepository(QueueEntity).findOneBy({ username: user }); + if (entity) { + users.push(entity); + } + } + if (cb) { + const opts = { + sender: getUserSender(getBotId(), getBotUserName()), users, attr: {}, createdAt: Date.now(), command: '', parameters: '', isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, + }; + const picked = await this.pickUsers(opts, data.random); + for (let i = 0; i < picked.responses.length; i++) { + await parserReply(picked.responses[i].response, { sender: picked.responses[i].sender, discord: picked.responses[i].discord, attr: picked.responses[i].attr, id: '' }); + } + cb(null, picked.users); + } + } else { + if (cb) { + const opts = { + sender: getUserSender(getBotId(), getBotUserName()), attr: {}, createdAt: Date.now(), command: '', parameters: String(data.count), isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, + }; + const picked = await this.pickUsers(opts, data.random); + for (let i = 0; i < picked.responses.length; i++) { + await parserReply(picked.responses[i].response, { sender: picked.responses[i].sender, discord: picked.responses[i].discord, attr: picked.responses[i].attr, id: '' }); + } + cb(null, picked.users); + } + } + } catch (e: any) { + if (cb) { + cb(e.stack, []); + } + } + }); + } + + async getUsers (opts: { random: boolean, amount: number }): Promise { + opts = opts || { amount: 1 }; + let users = await AppDataSource.getRepository(QueueEntity).find(); + + if (opts.random) { + users = users.sort(() => Math.random()); + } else { + users = users.sort(o => -(new Date(o.createdAt).getTime())); + } + + const toReturn: QueueInterface[] = []; + let i = 0; + for (const user of users) { + const isNotSubscriberEligible = !user.isSubscriber && (this.eligibilitySubscribers); + if (isNotSubscriberEligible) { + continue; + } + + if (i < opts.amount) { + await AppDataSource.getRepository(QueueEntity).remove(user); + toReturn.push(user); + } else { + break; + } + i++; + } + return toReturn; + } + + @command('!queue') + main (opts: CommandOptions): CommandResponse[] { + return [{ response: translate(this.locked ? 'queue.info.closed' : 'queue.info.opened'), ...opts }]; + } + + @command('!queue open') + @default_permission(defaultPermissions.CASTERS) + open (opts: CommandOptions): CommandResponse[] { + this.locked = false; + return [{ response: translate('queue.open'), ...opts }]; + } + + @command('!queue close') + @default_permission(defaultPermissions.CASTERS) + close (opts: CommandOptions): CommandResponse[] { + this.locked = true; + return [{ response: translate('queue.close'), ...opts }]; + } + + @command('!queue join') + async join (opts: CommandOptions): Promise { + if (!(this.locked)) { + const user = await changelog.get(opts.sender.userId); + if (!user) { + changelog.update(opts.sender.userId, { userName: opts.sender.userName }); + return this.join(opts); + } + const [all, subscribers] = await Promise.all([this.eligibilityAll, this.eligibilitySubscribers]); + + // get message + const message = opts.parameters.length > 0 ? opts.parameters : null; + + let eligible = false; + if (!all) { + if (subscribers && user.isSubscriber) { + eligible = true; + } + } else { + eligible = true; + } + + if (eligible) { + await AppDataSource.getRepository(QueueEntity).save({ + ...(await AppDataSource.getRepository(QueueEntity).findOneBy({ username: opts.sender.userName })), + username: opts.sender.userName, + isSubscriber: user.isSubscriber, + isModerator: user.isModerator, + createdAt: Date.now(), + message, + + }); + return [{ response: translate('queue.join.opened'), ...opts }]; + } else { + return []; + } + } else { + return [{ response: translate('queue.join.closed'), ...opts }]; + } + } + + @command('!queue clear') + @default_permission(defaultPermissions.CASTERS) + clear (opts: CommandOptions): CommandResponse[] { + AppDataSource.getRepository(QueueEntity).delete({}); + this.pickedUsers = []; + return [{ response: translate('queue.clear'), ...opts }]; + } + + @command('!queue random') + @default_permission(defaultPermissions.CASTERS) + async random (opts: CommandOptions): Promise { + return (await this.pickUsers(opts, false)).responses; + } + + @command('!queue pick') + @default_permission(defaultPermissions.CASTERS) + async pick (opts: CommandOptions): Promise { + return (await this.pickUsers(opts, false)).responses; + } + + async pickUsers (opts: CommandOptions & { users?: QueueInterface[] }, random: boolean): Promise<{ users: QueueInterface[]; responses: CommandResponse[]}> { + let users: QueueInterface[] = []; + if (!opts.users) { + const match = opts.parameters.match(/^(\d+)?/); + if (match) { + const input = match[0]; + const amount = (input === '' ? 1 : parseInt(input, 10)); + users = await this.getUsers({ amount, random }); + } + } else { + users = opts.users; + for (const user of users) { + await AppDataSource.getRepository(QueueEntity).delete({ username: user.username }); + } + } + + this.pickedUsers = []; + for (const user of users) { + this.pickedUsers.push(user); + } + + const atUsername = twitch.showWithAt; + + let msg; + switch (users.length) { + case 0: + msg = translate('queue.picked.none'); + break; + case 1: + msg = translate('queue.picked.single'); + break; + default: + msg = translate('queue.picked.multi'); + } + + const response = msg.replace(/\$users/g, users.map(o => { + const user = o.message ? `${o.username} - ${o.message}` : o.username; + return atUsername ? `@${user}` : user; + }).join(', ')); + return { + users, + responses: [{ response, ...opts }], + }; + } + + @command('!queue list') + @default_permission(defaultPermissions.CASTERS) + async list (opts: CommandOptions): Promise { + const [atUsername, users] = await Promise.all([ + twitch.showWithAt, + AppDataSource.getRepository(QueueEntity).find(), + ]); + const queueList = users.map(o => atUsername ? `@${o.username}` : o).join(', '); + return [{ response: prepare('queue.list', { users: queueList }), ...opts }]; + } +} + +export default new Queue(); diff --git a/backend/src/systems/quotes.ts b/backend/src/systems/quotes.ts new file mode 100644 index 000000000..805eded55 --- /dev/null +++ b/backend/src/systems/quotes.ts @@ -0,0 +1,216 @@ +import { Quotes as QuotesEntity } from '@entity/quotes.js'; +import { sample } from '@sogebot/ui-helpers/array.js'; +import { validateOrReject } from 'class-validator'; +import * as _ from 'lodash-es'; +import { merge } from 'lodash-es'; + +import System from './_interface.js'; +import { command, default_permission } from '../decorators.js'; +import { Expects } from '../expects.js'; + +import { AppDataSource } from '~/database.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { app } from '~/helpers/panel.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { domain } from '~/helpers/ui/index.js'; +import getNameById from '~/helpers/user/getNameById.js'; +import { adminMiddleware } from '~/socket.js'; + +class Quotes extends System { + constructor () { + super(); + + this.addMenu({ + category: 'manage', name: 'quotes', id: 'manage/quotes', this: this, + }); + this.addMenuPublic({ id: 'quotes', name: 'quotes' }); + } + + sockets() { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/systems/quotes', async (req, res) => { + const quotes = await QuotesEntity.find(); + res.send({ + data: quotes, + users: await Promise.all(quotes.map(quote => new Promise<[string, string]>(resolve => getNameById(quote.quotedBy).then((username) => resolve([quote.quotedBy, username]))))), + }); + }); + app.get('/api/systems/quotes/:id', adminMiddleware, async (req, res) => { + const quote = await QuotesEntity.findOneBy({ id: Number(req.params.id) }); + res.send({ + data: await QuotesEntity.findOneBy({ id: Number(req.params.id) }), + user: quote ? await getNameById(quote.quotedBy) : null, + }); + }); + app.delete('/api/systems/quotes/:id', adminMiddleware, async (req, res) => { + await QuotesEntity.delete({ id: Number(req.params.id) }); + res.status(404).send(); + }); + app.post('/api/systems/quotes', adminMiddleware, async (req, res) => { + try { + const itemToSave = new QuotesEntity(); + merge(itemToSave, req.body); + await validateOrReject(itemToSave); + await itemToSave.save(); + res.send({ data: itemToSave }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + } + + @command('!quote add') + @default_permission(defaultPermissions.CASTERS) + async add (opts: CommandOptions): Promise<(CommandResponse & Partial)[]> { + try { + if (opts.parameters.length === 0) { + throw new Error(); + } + const [tags, quote] = new Expects(opts.parameters).argument({ + name: 'tags', optional: true, default: 'general', multi: true, delimiter: '', + }).argument({ + name: 'quote', multi: true, delimiter: '', + }).toArray() as [ string, string ]; + const tagsArray = tags.split(',').map((o) => o.trim()); + + const entity = new QuotesEntity(); + entity.tags = tagsArray; + entity.quote = quote; + entity.quotedBy = opts.sender.userId; + entity.createdAt = new Date().toISOString(), + await entity.save(); + const response = prepare('systems.quotes.add.ok', { + id: entity.id, quote, tags: tagsArray.join(', '), + }); + return [{ + response, ...opts, ...entity, + }]; + } catch (e: any) { + const response = prepare('systems.quotes.add.error', { command: opts.command }); + return [{ + response, ...opts, createdAt: new Date(0).toISOString(), attr: {}, quote: '', quotedBy: '0', tags: [], + }]; + } + } + + @command('!quote remove') + @default_permission(defaultPermissions.CASTERS) + async remove (opts: CommandOptions): Promise { + try { + if (opts.parameters.length === 0) { + throw new Error(); + } + const id = new Expects(opts.parameters).argument({ type: Number, name: 'id' }).toArray()[0]; + const item = await AppDataSource.getRepository(QuotesEntity).findOneBy({ id }); + + if (!item) { + const response = prepare('systems.quotes.remove.not-found', { id }); + return [{ response, ...opts }]; + } else { + await AppDataSource.getRepository(QuotesEntity).delete({ id }); + const response = prepare('systems.quotes.remove.ok', { id }); + return [{ response, ...opts }]; + } + } catch (e: any) { + const response = prepare('systems.quotes.remove.error'); + return [{ response, ...opts }]; + } + } + + @command('!quote set') + @default_permission(defaultPermissions.CASTERS) + async set (opts: CommandOptions): Promise { + try { + if (opts.parameters.length === 0) { + throw new Error(); + } + const [id, tag] = new Expects(opts.parameters).argument({ type: Number, name: 'id' }).argument({ + name: 'tag', multi: true, delimiter: '', + }).toArray() as [ number, string ]; + + const quote = await AppDataSource.getRepository(QuotesEntity).findOneBy({ id }); + if (quote) { + const tags = tag.split(',').map((o) => o.trim()); + await AppDataSource + .createQueryBuilder() + .update(QuotesEntity) + .where('id = :id', { id }) + .set({ tags }) + .execute(); + const response = prepare('systems.quotes.set.ok', { id, tags: tags.join(', ') }); + return [{ response, ...opts }]; + } else { + const response = prepare('systems.quotes.set.error.not-found-by-id', { id }); + return [{ response, ...opts }]; + } + } catch (e: any) { + const response = prepare('systems.quotes.set.error.no-parameters', { command: opts.command }); + return [{ response, ...opts }]; + } + } + + @command('!quote list') + async list (opts: CommandOptions): Promise { + const response = prepare( + (['localhost', '127.0.0.1'].includes(domain.value) ? 'systems.quotes.list.is-localhost' : 'systems.quotes.list.ok'), + { urlBase: domain.value }); + return [{ response, ...opts }]; + } + + @command('!quote') + async main (opts: CommandOptions): Promise { + const [id, tag] = new Expects(opts.parameters).argument({ + type: Number, name: 'id', optional: true, + }).argument({ + name: 'tag', optional: true, multi: true, delimiter: '', + }).toArray(); + if (_.isNil(id) && _.isNil(tag) || id === '-tag') { + const response = prepare('systems.quotes.show.error.no-parameters', { command: opts.command }); + return [{ response, ...opts }]; + } + + if (!_.isNil(id)) { + const quote = await AppDataSource.getRepository(QuotesEntity).findOneBy({ id }); + if (!_.isEmpty(quote) && typeof quote !== 'undefined') { + const quotedBy = await getNameById(quote.quotedBy); + const response = prepare('systems.quotes.show.ok', { + quote: quote.quote, id: quote.id, quotedBy, + }); + return [{ response, ...opts }]; + } else { + const response = prepare('systems.quotes.show.error.not-found-by-id', { id }); + return [{ response, ...opts }]; + } + } else { + const quotes = await AppDataSource.getRepository(QuotesEntity).find(); + const quotesWithTags: QuotesEntity[] = []; + for (const quote of quotes) { + if (quote.tags.includes(tag)) { + quotesWithTags.push(quote); + } + } + + if (quotesWithTags.length > 0) { + const quote = sample(quotesWithTags); + if (typeof quote !== 'undefined') { + const quotedBy = await getNameById(quote.quotedBy); + const response = prepare('systems.quotes.show.ok', { + quote: quote.quote, id: quote.id, quotedBy, + }); + return [{ response, ...opts }]; + } + const response = prepare('systems.quotes.show.error.not-found-by-tag', { tag }); + return [{ response, ...opts }]; + } else { + const response = prepare('systems.quotes.show.error.not-found-by-tag', { tag }); + return [{ response, ...opts }]; + } + } + } +} + +export default new Quotes(); diff --git a/backend/src/systems/raffles.ts b/backend/src/systems/raffles.ts new file mode 100644 index 000000000..9e9e48273 --- /dev/null +++ b/backend/src/systems/raffles.ts @@ -0,0 +1,564 @@ +import { + Raffle, RaffleParticipant, RaffleParticipantInterface, RaffleParticipantMessageInterface, +} from '@entity/raffle.js'; +import { User } from '@entity/user.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import * as _ from 'lodash-es'; +import { IsNull } from 'typeorm'; + +import System from './_interface.js'; +import { onStartup } from '../decorators/on.js'; +import { + command, default_permission, parser, settings, timer, +} from '../decorators.js'; + +import { AppDataSource } from '~/database.js'; +import { isStreamOnline } from '~/helpers/api/index.js'; +import { + announce, getOwnerAsSender, prepare, +} from '~/helpers/commons/index.js'; +import { isDbConnected } from '~/helpers/database.js'; +import { debug, warning } from '~/helpers/log.js'; +import { linesParsed } from '~/helpers/parser.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import { tmiEmitter } from '~/helpers/tmi/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import points from '~/systems/points.js'; +import { translate } from '~/translate.js'; + +const TYPE_NORMAL = 0; +const TYPE_TICKETS = 1; + +/* + * !raffle - gets an info about raffle + * !raffle open ![raffle-keyword] [-min #?] [-max #?] [-for subscribers?] + * - open a new raffle with selected keyword, + * - -min # - minimal of tickets to join, -max # - max of tickets to join -> ticket raffle + * - -for subscribers - who can join raffle, if empty -> everyone + * !raffle remove - remove raffle without winner + * !raffle pick - pick or repick a winner of raffle + * ![raffle-keyword] - join a raffle + */ + +let announceNewEntriesTime = 0; +let announceNewEntriesCount = 0; + +class Raffles extends System { + lastAnnounce = Date.now(); + lastAnnounceMessageCount = 0; + + @settings('luck') + subscribersPercent = 150; + + @settings() + raffleAnnounceInterval = 10; + @settings() + raffleAnnounceMessageInterval = 20; + @settings() + allowOverTicketing = false; + + @settings('join') + deleteRaffleJoinCommands = false; + @settings('join') + announceNewEntries = true; + @settings('join') + announceNewEntriesBatchTime = 15; + + @onStartup() + onStartup() { + this.announce(); + setInterval(() => { + if (this.announceNewEntries && announceNewEntriesTime !== 0 && announceNewEntriesTime <= Date.now()) { + this.announceEntries(); + } + }, 1000); + } + + sockets () { + adminEndpoint('/systems/raffles', 'raffle::getWinner', async (userName: string, cb) => { + try { + await changelog.flush(); + cb( + null, + await AppDataSource.getRepository(User).findOneBy({ userName }) as any, + ); + } catch (e: any) { + cb(e.stack); + } + }); + adminEndpoint('/systems/raffles', 'raffle::setEligibility', async ({ id, isEligible }, cb) => { + try { + await AppDataSource.getRepository(RaffleParticipant).update({ id }, { isEligible }); + cb(null); + } catch (e: any) { + cb(e.stack); + } + }); + adminEndpoint('/systems/raffles', 'raffle:getLatest', async (cb) => { + try { + cb( + null, + (await AppDataSource.getRepository(Raffle).find({ + relations: ['participants', 'participants.messages'], + order: { timestamp: 'DESC' }, + take: 1, + }))[0], + ); + } catch (e: any) { + cb (e); + } + }); + adminEndpoint('/systems/raffles', 'raffle::pick', async () => { + this.pick({ + attr: {}, command: '!raffle', createdAt: Date.now(), parameters: '', sender: getOwnerAsSender(), isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, + }); + }); + adminEndpoint('/systems/raffles', 'raffle::open', async (message) => { + // force close raffles + await AppDataSource.getRepository(Raffle).update({}, { isClosed: true }); + this.open({ + attr: {}, command: '!raffle open', createdAt: Date.now(), sender: getOwnerAsSender(), parameters: message, isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, + }); + }); + adminEndpoint('/systems/raffles', 'raffle::close', async () => { + await AppDataSource.getRepository(Raffle).update({ isClosed: false }, { isClosed: true }); + }); + } + + @parser({ fireAndForget: true }) + async messages (opts: ParserOptions) { + if (opts.skip || !opts.sender) { + return true; + } + + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + where: { isClosed: true }, + order: { timestamp: 'DESC' }, + }); + if (!raffle) { + return true; + } + + const isWinner = !_.isNil(raffle.winner) && raffle.winner === opts.sender.userName; + const isInFiveMinutesTreshold = Date.now() - raffle.timestamp <= 1000 * 60 * 5; + + if (isWinner && isInFiveMinutesTreshold) { + const winner = await AppDataSource.getRepository(RaffleParticipant).findOne({ + relations: ['messages'], + where: { + username: opts.sender.userName, + raffle: { + id: raffle.id, + }, + }, + }); + if (winner) { + const message: RaffleParticipantMessageInterface = { + timestamp: Date.now(), + text: opts.message, + }; + winner.messages.push(message); + await AppDataSource.getRepository(RaffleParticipant).save(winner); + } + } + return true; + } + + async announceEntries() { + try { + const raffle = await AppDataSource.getRepository(Raffle).findOneOrFail({ where: { winner: IsNull(), isClosed: false }, relations: ['participants'] }); + const eligibility: string[] = []; + if (raffle.forSubscribers === true) { + eligibility.push(prepare('raffles.eligibility-subscribers-item')); + } + if (_.isEmpty(eligibility)) { + eligibility.push(prepare('raffles.eligibility-everyone-item')); + } + + const message = prepare(raffle.type === TYPE_NORMAL ? 'raffles.added-entries' : 'raffles.added-ticket-entries', { + l10n_entries: getLocalizedName(announceNewEntriesCount, translate('core.entries')), + count: announceNewEntriesCount, + countTotal: raffle.participants.reduce((a, b) => { + a += b.tickets; + return a; + }, 0), + keyword: raffle.keyword, + min: raffle.minTickets, + max: raffle.maxTickets, + eligibility: eligibility.join(', '), + }); + + announce(message, 'raffles'); + } catch (e: any) { + warning('No active raffle found to announce added entries.'); + } + announceNewEntriesTime = 0; + announceNewEntriesCount = 0; + } + + async announce () { + clearTimeout(this.timeouts.raffleAnnounce); + if (!isDbConnected) { + this.timeouts.raffleAnnounce = global.setTimeout(() => this.announce(), 1000); + return; + } + + const raffle = await AppDataSource.getRepository(Raffle).findOne({ where: { winner: IsNull(), isClosed: false }, relations: ['participants'] }); + const isTimeToAnnounce = new Date().getTime() - new Date(this.lastAnnounce).getTime() >= (this.raffleAnnounceInterval * 60 * 1000); + const isMessageCountToAnnounce = linesParsed - this.lastAnnounceMessageCount >= this.raffleAnnounceMessageInterval; + if (!(isStreamOnline.value) || !raffle || !isTimeToAnnounce || !isMessageCountToAnnounce) { + this.timeouts.raffleAnnounce = global.setTimeout(() => this.announce(), 60000); + return; + } + this.lastAnnounce = Date.now(); + this.lastAnnounceMessageCount = linesParsed; + + let locale = 'raffles.announce-raffle'; + if (raffle.type === TYPE_TICKETS) { + locale = 'raffles.announce-ticket-raffle'; + } + + const eligibility: string[] = []; + if (raffle.forSubscribers === true) { + eligibility.push(prepare('raffles.eligibility-subscribers-item')); + } + if (_.isEmpty(eligibility)) { + eligibility.push(prepare('raffles.eligibility-everyone-item')); + } + + const count = raffle.participants.reduce((a, b) => { + a += b.tickets; + return a; + }, 0); + let message = prepare(locale, { + l10n_entries: getLocalizedName(count, translate('core.entries')), + count, + keyword: raffle.keyword, + min: raffle.minTickets, + max: raffle.maxTickets, + eligibility: eligibility.join(', '), + }); + if (this.deleteRaffleJoinCommands) { + message += ' ' + prepare('raffles.join-messages-will-be-deleted'); + } + announce(message, 'raffles'); + this.timeouts.raffleAnnounce = global.setTimeout(() => this.announce(), 60000); + } + + @command('!raffle remove') + @default_permission(defaultPermissions.CASTERS) + async remove (opts: CommandOptions): Promise { + const raffle = await AppDataSource.getRepository(Raffle).findOneBy({ winner: IsNull(), isClosed: false }); + if (raffle) { + await AppDataSource.getRepository(Raffle).remove(raffle); + } + return []; + } + + @command('!raffle open') + @default_permission(defaultPermissions.CASTERS) + async open (opts: CommandOptions): Promise { + const [subscribers] = [opts.parameters.indexOf('subscribers') >= 0]; + let type = (opts.parameters.indexOf('-min') >= 0 || opts.parameters.indexOf('-max') >= 0) ? TYPE_TICKETS : TYPE_NORMAL; + if (!points.enabled) { + type = TYPE_NORMAL; + } // force normal type if points are disabled + + let minTickets = 1; + let maxTickets = 100; + + if (type === TYPE_TICKETS) { + const matchMin = opts.parameters.match(/-min (\d+)/); + if (!_.isNil(matchMin)) { + minTickets = Math.max(Number(matchMin[1]), minTickets); + } + + const matchMax = opts.parameters.match(/-max (\d+)/); + if (!_.isNil(matchMax)) { + maxTickets = Number(matchMax[1]); + } + } + + const keywordMatch = opts.parameters.match(/(![\S]+)/); + if (_.isNil(keywordMatch)) { + const response = prepare('raffles.cannot-create-raffle-without-keyword'); + return [{ response, ...opts }]; + } + const keyword = keywordMatch[1]; + + // check if raffle running + const raffle = await AppDataSource.getRepository(Raffle).findOneBy({ winner: IsNull(), isClosed: false }); + if (raffle) { + const response = prepare('raffles.raffle-is-already-running', { keyword: raffle.keyword }); + return [{ response, ...opts }]; + } + + await AppDataSource.getRepository(Raffle).save({ + keyword: keyword, + forSubscribers: subscribers, + minTickets, + maxTickets, + type: type, + winner: null, + isClosed: false, + timestamp: Date.now(), + }); + + announceNewEntriesCount = 0; + announceNewEntriesTime = 0; + + const eligibility: string[] = []; + if (subscribers) { + eligibility.push(prepare('raffles.eligibility-subscribers-item')); + } + if (_.isEmpty(eligibility)) { + eligibility.push(prepare('raffles.eligibility-everyone-item')); + } + + let response = prepare(type === TYPE_NORMAL ? 'raffles.announce-raffle' : 'raffles.announce-ticket-raffle', { + l10n_entries: getLocalizedName(0, translate('core.entries')), + count: 0, + keyword: keyword, + eligibility: eligibility.join(', '), + min: minTickets, + max: maxTickets, + }); + + this.lastAnnounce = Date.now(); + if (this.deleteRaffleJoinCommands) { + response += ' ' + prepare('raffles.join-messages-will-be-deleted'); + } + announce(response, 'raffles'); // we are announcing raffle so it is send to all relevant channels + return []; + } + + @command('!raffle') + async main (opts: CommandOptions): Promise { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ where: { winner: IsNull(), isClosed: false }, relations: ['participants'] }); + + if (!raffle) { + const response = prepare('raffles.no-raffle-is-currently-running'); + return [{ response, ...opts }]; + } + + let locale = 'raffles.announce-raffle'; + if (raffle.type === TYPE_TICKETS) { + locale = 'raffles.announce-ticket-raffle'; + } + + const eligibility: string[] = []; + if (raffle.forSubscribers === true) { + eligibility.push(prepare('raffles.eligibility-subscribers-item')); + } + if (_.isEmpty(eligibility)) { + eligibility.push(prepare('raffles.eligibility-everyone-item')); + } + + const count = raffle.participants.reduce((a, b) => { + a += b.tickets; + return a; + }, 0); + let response = prepare(locale, { + l10n_entries: getLocalizedName(count, translate('core.entries')), + count, + keyword: raffle.keyword, + min: raffle.minTickets, + max: raffle.maxTickets, + eligibility: eligibility.join(', '), + }); + if (this.deleteRaffleJoinCommands) { + response += ' ' + prepare('raffles.join-messages-will-be-deleted'); + } + return [{ response, ...opts }]; + } + + @parser({ fireAndForget: true }) + @timer() + async participate (opts: ParserOptions): Promise { + if (opts.sender === null || _.isNil(opts.sender.userName) || !opts.message.match(/^(![\S]+)/)) { + return true; + } + + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + + if (!raffle) { + return true; + } + + const user = await changelog.get(opts.sender.userId); + if (!user) { + changelog.update(opts.sender.userId, { userName: opts.sender.userName }); + return this.participate(opts); + } + + const isStartingWithRaffleKeyword + = raffle.type === TYPE_TICKETS + ? opts.message.toLowerCase().startsWith(raffle.keyword.toLowerCase() + ' ') + : opts.message.toLowerCase().trim() === raffle.keyword.toLowerCase(); + if (!isStartingWithRaffleKeyword) { + return true; + } + if (this.deleteRaffleJoinCommands) { + tmiEmitter.emit('delete', opts.id); + } + + opts.message = opts.message.toString().replace(raffle.keyword, ''); + let tickets = opts.message.trim() === 'all' && !_.isNil(await points.getPointsOf(opts.sender.userId)) ? await points.getPointsOf(opts.sender.userId) : parseInt(opts.message.trim(), 10); + + if ((!_.isFinite(tickets) || tickets <= 0 || tickets < raffle.minTickets) && raffle.type === TYPE_TICKETS) { + return false; + } + if (!_.isFinite(tickets)) { + tickets = 0; + } + + const participant = raffle.participants.find(o => o.username === opts.sender?.userName); + let curTickets = 0; + if (participant) { + curTickets = participant.tickets; + } + let newTickets = curTickets + tickets; + + const userPoints = await points.getPointsOf(opts.sender.userId); + if (raffle.type === TYPE_TICKETS && userPoints < tickets) { + if (this.allowOverTicketing) { + newTickets = curTickets + userPoints; + } else { + return false; + } + } // user doesn't have enough points + + if (newTickets > raffle.maxTickets && raffle.type === TYPE_TICKETS) { + newTickets = raffle.maxTickets; + } + + const selectedParticipant = { + ...participant, + raffle, + isEligible: participant?.isEligible ?? true, + username: opts.sender.userName, + tickets: raffle.type === TYPE_NORMAL ? 1 : newTickets, + messages: [], + isSubscriber: user.isSubscriber, + }; + + if (raffle.forSubscribers && selectedParticipant.isEligible) { + selectedParticipant.isEligible = user.isSubscriber; + } + + if (selectedParticipant.isEligible && selectedParticipant.tickets > 0) { + if (announceNewEntriesTime === 0) { + announceNewEntriesTime = Date.now() + this.announceNewEntriesBatchTime * 1000; + } + if (raffle.type === TYPE_TICKETS) { + announceNewEntriesCount += newTickets - curTickets; + await points.decrement({ userId: opts.sender.userId }, newTickets - curTickets); + } else { + announceNewEntriesCount += 1; + } + if (!this.announceNewEntries) { + announceNewEntriesCount = 0; + announceNewEntriesTime = 0; + } + + debug('raffle', '------------------------------------------------------------------------------------------------'); + debug('raffle', `Eligible user ${opts.sender.userName}#${opts.sender.userId} for raffle ${raffle.id}`); + debug('raffle', opts.sender); + debug('raffle', selectedParticipant); + debug('raffle', user); + debug('raffle', '------------------------------------------------------------------------------------------------'); + await AppDataSource.getRepository(RaffleParticipant).save(selectedParticipant); + return true; + } else { + return false; + } + } + + @command('!raffle pick') + @default_permission(defaultPermissions.CASTERS) + async pick (opts: CommandOptions): Promise { + const raffle = (await AppDataSource.getRepository(Raffle).find({ + relations: ['participants'], + order: { timestamp: 'DESC' }, + take: 1, + }))[0]; + if (!raffle) { + return []; + } // no raffle ever + + if (raffle.participants.length === 0) { + const response = prepare('raffles.no-participants-to-pick-winner'); + // close raffle on pick + await AppDataSource.getRepository(Raffle).save({ + ...raffle, isClosed: true, timestamp: Date.now(), + }); + return [{ response, ...opts }]; + } + + let _total = 0; + const [sLuck] = await Promise.all([this.subscribersPercent]); + for (const participant of raffle.participants.filter((o) => o.isEligible)) { + if (participant.isSubscriber) { + _total = _total + ((participant.tickets / 100) * sLuck); + } else { + _total = _total + participant.tickets; + } + } + + let winNumber = _.random(0, _total - 1, false); + let winner: Readonly | null = null; + for (const participant of raffle.participants.filter((o) => o.isEligible)) { + let tickets = participant.tickets; + + if (participant.isSubscriber) { + tickets = ((participant.tickets / 100) * sLuck); + } + + winNumber = winNumber - tickets; + winner = participant; + if (winNumber <= 0) { + break; + } + } + + let tickets = 0; + if (winner) { + tickets = winner.tickets; + if (winner.isSubscriber) { + tickets = ((winner.tickets / 100) * sLuck); + } + } + + const probability = (tickets / _total * 100); + + // uneligible winner (don't want to pick second time same user if repick) + if (winner) { + await Promise.all([ + AppDataSource.getRepository(RaffleParticipant).save({ ...winner, isEligible: false }), + AppDataSource.getRepository(Raffle).save({ + ...raffle, winner: winner.username, isClosed: true, timestamp: Date.now(), + }), + ]); + + const response = prepare('raffles.raffle-winner-is', { + username: winner.username, + keyword: raffle.keyword, + probability: _.round(probability, 2), + }); + announce(response, 'raffles'); + } else { + // close raffle on pick + await AppDataSource.getRepository(Raffle).save({ + ...raffle, isClosed: true, timestamp: Date.now(), + }), + warning('No winner found in raffle'); + } + return []; + } +} + +export default new Raffles(); diff --git a/backend/src/systems/ranks.ts b/backend/src/systems/ranks.ts new file mode 100644 index 000000000..307675c3e --- /dev/null +++ b/backend/src/systems/ranks.ts @@ -0,0 +1,310 @@ +import { Rank } from '@entity/rank.js'; +import { User, UserInterface } from '@entity/user.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import * as _ from 'lodash-es'; + +import System from './_interface.js'; +import { command, default_permission } from '../decorators.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { prepare } from '~/helpers/commons/index.js'; +import { app } from '~/helpers/panel.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { adminMiddleware } from '~/socket.js'; +import { translate } from '~/translate.js'; + +/* + * !rank - show user rank + * !rank add - add for selected + * !rank add-sub - add for selected + * !rank rm - remove rank for selected + * !rank rm-sub - remove rank for selected of subscribers + * !rank list - show rank list + * !rank list-sub - show rank list for subcribers + * !rank edit - edit rank + * !rank edit-sub - edit rank for subcribers + * !rank set - set custom for + * !rank unset - unset custom rank for + */ + +class Ranks extends System { + constructor () { + super(); + this.addMenu({ + category: 'manage', name: 'ranks', id: 'manage/ranks', this: this, + }); + } + + sockets () { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/systems/ranks', adminMiddleware, async (req, res) => { + res.send({ + data: await Rank.find(), + }); + }); + app.get('/api/systems/ranks/:id', async (req, res) => { + res.send({ + data: await Rank.findOne({ where: { id: req.params.id } }), + }); + }); + app.delete('/api/systems/ranks/:id', adminMiddleware, async (req, res) => { + const poll = await Rank.findOne({ where: { id: req.params.id } }); + await poll?.remove(); + res.status(404).send(); + }); + app.post('/api/systems/ranks', adminMiddleware, async (req, res) => { + try { + const itemToSave = Rank.create(req.body); + res.send({ data: await itemToSave.validateAndSave() }); + } catch (e) { + if (e instanceof Error) { + res.status(400).send({ errors: e.message }); + } else { + res.status(400).send({ errors: e }); + } + } + }); + } + + @command('!rank add') + @default_permission(defaultPermissions.CASTERS) + async add (opts: CommandOptions, type: Rank['type'] = 'viewer'): Promise { + const parsed = opts.parameters.match(/^(\d+) ([\S].+)$/); + + if (_.isNil(parsed)) { + const response = prepare('ranks.rank-parse-failed'); + return [{ response, ...opts }]; + } + + const value = parseInt(parsed[1], 10); + const rank = await AppDataSource.getRepository(Rank).findOneBy({ value, type }); + if (!rank) { + await AppDataSource.getRepository(Rank).save({ + value, rank: parsed[2], type, + }); + } + + const response = prepare(!rank ? 'ranks.rank-was-added' : 'ranks.rank-already-exist', + { + rank: parsed[2], + hours: value, + type, + hlocale: getLocalizedName(value, translate(type === 'viewer' ? 'core.hours' : 'core.months')), + }); + return [{ response, ...opts }]; + } + + @command('!rank add-sub') + @default_permission(defaultPermissions.CASTERS) + async addsub (opts: CommandOptions): Promise { + return this.add(opts, 'subscriber'); + } + + @command('!rank edit') + @default_permission(defaultPermissions.CASTERS) + async edit (opts: CommandOptions, type: Rank['type'] = 'viewer'): Promise { + const parsed = opts.parameters.match(/^(\d+) ([\S].+)$/); + + if (_.isNil(parsed)) { + const response = prepare('ranks.rank-parse-failed'); + return [{ response, ...opts }]; + } + + const value = parsed[1]; + const rank = parsed[2]; + + const item = await AppDataSource.getRepository(Rank).findOneBy({ value: parseInt(value, 10), type }); + if (!item) { + const response = prepare('ranks.rank-was-not-found', { value: value }); + return [{ response, ...opts }]; + } + + await AppDataSource.getRepository(Rank).save({ ...item, rank }); + const response = prepare('ranks.rank-was-edited', + { + hours: parseInt(value, 10), + rank, + type, + hlocale: getLocalizedName(value, translate(type === 'viewer' ? 'core.hours' : 'core.months')), + }); + return [{ response, ...opts }]; + } + + @command('!rank edit-sub') + @default_permission(defaultPermissions.CASTERS) + async editsub (opts: CommandOptions) { + return this.edit(opts, 'subscriber'); + } + + @command('!rank set') + @default_permission(defaultPermissions.CASTERS) + async set (opts: CommandOptions): Promise { + const parsed = opts.parameters.match(/^([\S]+) ([\S ]+)$/); + + if (_.isNil(parsed)) { + const response = prepare('ranks.rank-parse-failed'); + return [{ response, ...opts }]; + } + + await changelog.flush(); + await AppDataSource.getRepository(User).update({ userName: parsed[1] }, { haveCustomRank: true, rank: parsed[2].trim() }); + const response = prepare('ranks.custom-rank-was-set-to-user', { rank: parsed[2].trim(), username: parsed[1] }); + return [{ response, ...opts }]; + } + + @command('!rank unset') + @default_permission(defaultPermissions.CASTERS) + async unset (opts: CommandOptions): Promise { + const parsed = opts.parameters.match(/^([\S]+)$/); + + if (_.isNil(parsed)) { + const response = prepare('ranks.rank-parse-failed'); + return [{ response, ...opts }]; + } + await changelog.flush(); + await AppDataSource.getRepository(User).update({ userName: parsed[1] }, { haveCustomRank: false, rank: '' }); + const response = prepare('ranks.custom-rank-was-unset-for-user', { username: parsed[1] }); + return [{ response, ...opts }]; + } + + @command('!rank help') + @default_permission(defaultPermissions.CASTERS) + help (opts: CommandOptions): CommandResponse[] { + let url = 'http://sogebot.github.io/sogeBot/#/systems/ranks'; + if ((process.env?.npm_package_version ?? 'x.y.z-SNAPSHOT').includes('SNAPSHOT')) { + url = 'http://sogebot.github.io/sogeBot/#/_master/systems/ranks'; + } + return [{ response: translate('core.usage') + ' => ' + url, ...opts }]; + } + + @command('!rank list') + @default_permission(defaultPermissions.CASTERS) + async list (opts: CommandOptions, type: Rank['type'] = 'viewer'): Promise { + const ranks = await AppDataSource.getRepository(Rank).findBy({ type }); + const response = prepare(ranks.length === 0 ? 'ranks.list-is-empty' : 'ranks.list-is-not-empty', { + list: _.orderBy(ranks, 'value', 'asc').map((l) => { + return l.value + 'h - ' + l.rank; + }).join(', '), + }); + return [{ response, ...opts }]; + } + + @command('!rank list-sub') + @default_permission(defaultPermissions.CASTERS) + async listsub (opts: CommandOptions) { + return this.list(opts, 'subscriber'); + } + + @command('!rank rm') + @default_permission(defaultPermissions.CASTERS) + async rm (opts: CommandOptions, type: Rank['type'] = 'viewer'): Promise { + const parsed = opts.parameters.match(/^(\d+)$/); + if (_.isNil(parsed)) { + const response = prepare('ranks.rank-parse-failed'); + return [{ response, ...opts }]; + } + + const value = parseInt(parsed[1], 10); + const removed = await AppDataSource.getRepository(Rank).delete({ value, type }); + + const response = prepare(removed ? 'ranks.rank-was-removed' : 'ranks.rank-was-not-found', + { + hours: value, + type, + hlocale: getLocalizedName(value, translate(type === 'viewer' ? 'core.hours' : 'core.months')), + }); + return [{ response, ...opts }]; + } + + @command('!rank rm-sub') + @default_permission(defaultPermissions.CASTERS) + async rmsub (opts: CommandOptions) { + return this.rm(opts, 'subscriber'); + } + + @command('!rank') + async main (opts: CommandOptions): Promise { + const user = await changelog.get(opts.sender.userId); + const watched = await users.getWatchedOf(opts.sender.userId); + const rank = await this.get(user); + + if (_.isNil(rank.current)) { + const response = prepare('ranks.user-dont-have-rank'); + return [{ response, ...opts }]; + } + + if (!_.isNil(rank.next) && typeof rank.current === 'object') { + if (rank.next.type === 'viewer') { + const toNextRank = rank.next.value - (rank.current.type === 'viewer' ? rank.current.value : 0); + const toNextRankWatched = watched / 1000 / 60 / 60 - (rank.current.type === 'viewer' ? rank.current.value : 0); + const toWatch = (toNextRank - toNextRankWatched); + const percentage = 100 - (((toWatch) / toNextRank) * 100); + const response = prepare('ranks.show-rank-with-next-rank', { rank: rank.current.rank, nextrank: `${rank.next.rank} ${percentage.toFixed(1)}% (${toWatch.toFixed(1)} ${getLocalizedName(toWatch.toFixed(1), translate('core.hours'))})` }); + return [{ response, ...opts }]; + } + if (rank.next.type === 'subscriber') { + const toNextRank = rank.next.value - (rank.current.type === 'subscriber' ? rank.current.value : 0); + const toNextRankSub = (user?.subscribeCumulativeMonths || 0) - (rank.current.type === 'subscriber' ? rank.current.value : 0); + const toWatch = (toNextRank - toNextRankSub); + const percentage = 100 - (((toWatch) / toNextRank) * 100); + const response = prepare('ranks.show-rank-with-next-rank', { rank: rank.current.rank, nextrank: `${rank.next.rank} ${percentage.toFixed(1)}% (${toWatch.toFixed(1)} ${getLocalizedName(toWatch.toFixed(1), translate('core.months'))})` }); + return [{ response, ...opts }]; + } + } + + const response = prepare('ranks.show-rank-without-next-rank', { rank: typeof rank.current === 'string' ? rank.current : rank.current.rank }); + return [{ response, ...opts }]; + } + + async get (user: Required | null): Promise<{current: null | string | Required; next: null | Required}> { + if (!user) { + return { current: null, next: null }; + } + + if (user.haveCustomRank) { + return { current: user.rank, next: null }; + } + + const ranks = await AppDataSource.getRepository(Rank).find({ order: { value: 'DESC' } }); + + let rankToReturn: null | Required = null; + let subNextRank: null | Required = null; + let nextRank: null | Required = null; + + if (user.isSubscriber) { + // search for sub rank + const subRanks = ranks.filter(o => o.type === 'subscriber'); + for (const rank of subRanks) { + if (user.subscribeCumulativeMonths >= rank.value) { + rankToReturn = rank; + break; + } else { + subNextRank = rank; + } + } + + if (rankToReturn) { + return { current: rankToReturn, next: subNextRank }; + } + } + + // watched time rank + for (const rank of ranks) { + if (user.watchedTime / 1000 / 60 / 60 >= rank.value) { + rankToReturn = rank; + break; + } else { + nextRank = rank; + } + } + return { current: rankToReturn, next: subNextRank || nextRank }; + } +} + +export default new Ranks(); diff --git a/backend/src/systems/scrim.ts b/backend/src/systems/scrim.ts new file mode 100644 index 000000000..284ded35f --- /dev/null +++ b/backend/src/systems/scrim.ts @@ -0,0 +1,220 @@ +import { ScrimMatchId } from '@entity/scrimMatchId.js'; +import * as constants from '@sogebot/ui-helpers/constants.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; + +import System from './_interface.js'; +import { onStartup } from '../decorators/on.js'; +import { + command, default_permission, settings, +} from '../decorators.js'; +import { Expects } from '../expects.js'; + +import { AppDataSource } from '~/database.js'; +import { announce } from '~/helpers/commons/announce.js'; +import { getUserSender } from '~/helpers/commons/index.js'; +import { prepare } from '~/helpers/commons/prepare.js'; +import { round5 } from '~/helpers/commons/round5.js'; +import { debug } from '~/helpers/log.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import getBotId from '~/helpers/user/getBotId.js'; +import getBotUserName from '~/helpers/user/getBotUserName.js'; +import twitch from '~/services/twitch.js'; +import { translate } from '~/translate.js'; + +enum ERROR { + ALREADY_OPENED, + CANNOT_BE_ZERO, +} + +/* + * !scrim - open scrim countdown + * !scrim match - if matchId add matchId to scrim, else list matches + * !scrim stop - stop scrim countdown + */ + +class Scrim extends System { + private cleanedUpOnStart = false; + + closingAt = 0; + type = ''; + lastRemindAt: number = Date.now(); + isCooldownOnly = false; + + @settings('customization') + waitForMatchIdsInSeconds = 60; + + @onStartup() + onStartup() { + this.reminder(); + setInterval(() => this.reminder(), 250); + } + + @command('!snipe') + @default_permission(defaultPermissions.CASTERS) + public async main(opts: CommandOptions): Promise { + try { + const [isCooldownOnly, type, minutes] = new Expects(opts.parameters) + .toggler({ name: 'c' }) + .string({ name: 'type' }) + .number({ name: 'minutes' }) + .toArray(); + if (this.closingAt !== 0) { + throw Error(String(ERROR.ALREADY_OPENED)); + } // ignore if its already opened + if (minutes === 0) { + throw Error(String(ERROR.CANNOT_BE_ZERO)); + } + + debug('scrim.main', `Opening new scrim cooldownOnly:${isCooldownOnly}, type:${type}, minutes:${minutes}`); + + const now = Date.now(); + + this.closingAt = now + (minutes * constants.MINUTE); + this.type = type; + this.isCooldownOnly = isCooldownOnly; + + this.lastRemindAt = now; + await AppDataSource.getRepository(ScrimMatchId).clear(); + announce(prepare('systems.scrim.countdown', { + type, + time: minutes, + unit: getLocalizedName(minutes, translate('core.minutes')), + }), 'scrim'); + return []; + } catch (e: any) { + if (isNaN(Number(e.message))) { + return [{ response: '$sender, cmd_error [' + opts.command + ']: ' + e.message, ...opts }]; + } + } + return []; + } + + @command('!snipe match') + public async match(opts: CommandOptions): Promise { + try { + if (opts.parameters.length === 0) { + return this.currentMatches(opts); + } else { + const [matchId] = new Expects(opts.parameters).everything({ name: 'matchId' }).toArray(); + const scrimMatchId = await AppDataSource.getRepository(ScrimMatchId).findOneBy({ username: opts.sender.userName }); + await AppDataSource.getRepository(ScrimMatchId).save({ + ...scrimMatchId, + username: opts.sender.userName, + matchId, + }); + } + } catch (e: any) { + if (isNaN(Number(e.message))) { + return [{ response: '$sender, cmd_error [' + opts.command + ']: ' + e.message, ...opts }]; + } + } + return []; + } + + @command('!scrim stop') + @default_permission(defaultPermissions.CASTERS) + public async stop(opts: CommandOptions): Promise { + this.closingAt = 0; + this.lastRemindAt = Date.now(); + return [{ response: prepare('systems.scrim.stopped'), ...opts }]; + } + + private reminder() { + if (!this.cleanedUpOnStart) { + this.cleanedUpOnStart = true; + this.closingAt = 0; + } else if (this.closingAt !== 0) { + const lastRemindAtDiffMs = -(this.lastRemindAt - Date.now()); + + const minutesToGo = (this.closingAt - Date.now()) / constants.MINUTE; + const secondsToGo = round5((this.closingAt - Date.now()) / constants.SECOND); + + if (minutesToGo > 1) { + // countdown every minute + if (lastRemindAtDiffMs >= constants.MINUTE) { + announce(prepare('systems.scrim.countdown', { + type: this.type, + time: minutesToGo.toFixed(), + unit: getLocalizedName(minutesToGo.toFixed(), translate('core.minutes')), + }), 'scrim'); + this.lastRemindAt = Date.now(); + } + } else if (secondsToGo <= 60 && secondsToGo > 0) { + // countdown every 15s + if (lastRemindAtDiffMs >= 15 * constants.SECOND) { + announce(prepare('systems.scrim.countdown', { + type: this.type, + time: String(secondsToGo === 60 ? 1 : secondsToGo), + unit: secondsToGo === 60 ? getLocalizedName(1, translate('core.minutes')) : getLocalizedName(secondsToGo, translate('core.seconds')), + }), 'scrim'); + this.lastRemindAt = Date.now(); + } + } else { + this.closingAt = 0; + this.countdown(); + } + } + } + + private async currentMatches(opts: CommandOptions): Promise { + const atUsername = twitch.showWithAt; + const matches: { + [x: string]: string[]; + } = {}; + const matchIdsFromDb = await AppDataSource.getRepository(ScrimMatchId).find(); + for (const d of matchIdsFromDb) { + const id = d.matchId; + if (typeof matches[id] === 'undefined') { + matches[id] = []; + } + matches[id].push((atUsername ? '@' : '') + d.username); + } + const output: string[] = []; + for (const id of Object.keys(matches).sort()) { + output.push(id + ' - ' + matches[id].sort().join(', ')); + } + return [{ response: prepare('systems.scrim.currentMatches', { matches: output.length === 0 ? '<' + translate('core.empty') + '>' : output.join(' | ') }), ...opts }]; + } + + async countdown() { + await Promise.all([...Array(4)].map((_, i) => { + return new Promise(resolve => { + setTimeout(() => { + announce( + prepare('systems.scrim.countdown', { + type: this.type, + time: (3 - i) + '.', + unit: '', + }), 'scrim'); + resolve(true); + }, (i + 1) * 1000); + }); + })); + + this.closingAt = 0; + announce(prepare('systems.scrim.go'), 'scrim'); + if (!this.isCooldownOnly) { + setTimeout(() => { + if (this.closingAt !== 0) { + return; // user restarted !snipe + } + announce( + prepare('systems.scrim.putMatchIdInChat', { command: this.getCommand('!snipe match') }), 'scrim', + ); + setTimeout(async () => { + if (this.closingAt !== 0) { + return; // user restarted !snipe + } + const currentMatches = await this.currentMatches({ + sender: getUserSender(getBotId(), getBotUserName()), parameters: '', createdAt: Date.now(), command: '', attr: {}, isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, + }); + for (const r of currentMatches) { + announce(await r.response, 'scrim'); + } + }, this.waitForMatchIdsInSeconds * constants.SECOND); + }, 15 * constants.SECOND); + } + } +} + +export default new Scrim(); diff --git a/backend/src/systems/songs.ts b/backend/src/systems/songs.ts new file mode 100644 index 000000000..0923c18bd --- /dev/null +++ b/backend/src/systems/songs.ts @@ -0,0 +1,837 @@ +import type { Filter } from '@devexpress/dx-react-grid'; +import { + currentSongType, + SongBan, SongPlaylist, SongRequest, +} from '@entity/song.js'; +import { User } from '@entity/user.js'; +import * as _ from 'lodash-es'; +import { nanoid } from 'nanoid'; +import io from 'socket.io'; +import { + Brackets, In, Like, +} from 'typeorm'; +import ytdl from 'ytdl-core'; +import ytpl from 'ytpl'; +import ytsr from 'ytsr'; + +import System from './_interface.js'; +import { onChange, onStartup } from '../decorators/on.js'; +import { + command, default_permission, persistent, settings, ui, +} from '../decorators.js'; + +import { AppDataSource } from '~/database.js'; +import { + announce, getUserSender, prepare, +} from '~/helpers/commons/index.js'; +import { error, info } from '~/helpers/log.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; +import { tmiEmitter } from '~/helpers/tmi/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import getBotId from '~/helpers/user/getBotId.js'; +import getBotUserName from '~/helpers/user/getBotUserName.js'; +import { isModerator } from '~/helpers/user/isModerator.js'; +import { translate } from '~/translate.js'; + +let importInProgress = false; +const cachedTags = new Set(); +let isCachedTagsValid = false; +const emptyCurrentSong = { + videoId: null, title: '', type: '', username: '', volume: 0, loudness: 0, forceVolume: false, startTime: 0, endTime: Number.MAX_SAFE_INTEGER, +}; + +class Songs extends System { + interval: { [id: string]: NodeJS.Timeout } = {}; + + meanLoudness = -15; + currentSong = JSON.stringify(emptyCurrentSong as currentSongType); + isPlaying: {[socketId: string]: boolean } = {}; + @persistent() + currentTag = 'general'; + + @settings() + @ui({ + type: 'number-input', + step: '1', + min: '0', + max: '100', + }) + volume = 25; + @settings() + duration = 10; + @settings() + shuffle = true; + @settings() + songrequest = true; + @settings() + allowRequestsOnlyFromPlaylist = false; + @settings() + playlist = true; + @settings() + notify = false; + @settings() + onlyMusicCategory = false; + @settings() + calculateVolumeByLoudness = true; + + @onStartup() + startup() { + this.getMeanLoudness(); + this.addMenu({ + category: 'manage', name: 'playlist', id: 'manage/songs/playlist', this: this, + }); + this.addMenu({ + category: 'manage', name: 'bannedsongs', id: 'manage/songs/bannedsongs', this: this, + }); + this.addMenuPublic({ id: 'songrequests', name: 'songs' }); + this.addMenuPublic({ id: 'playlist', name: 'playlist' }); + } + + async getTags() { + if (isCachedTagsValid) { + return [...cachedTags]; + } else { + cachedTags.clear(); + isCachedTagsValid = true; + for (const item of await SongPlaylist.find()) { + for (const tag of item.tags) { + cachedTags.add(tag); + } + } + return [...cachedTags]; + } + } + + sockets () { + if (this.socket === null) { + setTimeout(() => this.sockets(), 100); + return; + } + adminEndpoint('/systems/songs', 'songs::currentSong', async (cb) => { + cb(null, JSON.parse(this.currentSong)); + }); + adminEndpoint('/systems/songs', 'set.playlist.tag', async (tag) => { + if (this.currentTag !== tag) { + info(`SONGS: Playlist changed to ${tag}`); + } + this.currentTag = tag; + }); + publicEndpoint('/systems/songs', 'current.playlist.tag', async (cb) => { + cb(null, this.currentTag); + }); + adminEndpoint('/systems/songs', 'get.playlist.tags', async (cb) => { + try { + cb(null, await this.getTags()); + } catch (e: any) { + cb(e, []); + } + }); + publicEndpoint('/systems/songs', 'find.playlist', async (opts: { filters?: Filter[], page: number, search?: string, tag?: string | null, perPage: number}, cb) => { + opts.page = opts.page ?? 0; + opts.perPage = opts.perPage ?? 25; + + if (opts.perPage === -1) { + opts.perPage = Number.MAX_SAFE_INTEGER; + } + const query = SongPlaylist.createQueryBuilder('playlist') + .offset(opts.page * opts.perPage) + .limit(opts.perPage); + + // filter generator for new UI + for (const filter of opts.filters || []) { + const name = nanoid(); + + if (filter.operation === 'includes') { + query.andWhere(new Brackets(w => { + for (let i = 0; i < filter.value.length; i++) { + const name2 = nanoid(); + const value = filter.value[i]; + if (['postgres'].includes(AppDataSource.options.type.toLowerCase())) { + w[i === 0 ? 'where' : 'orWhere'](`"playlist"."${filter.columnName}" like :${name2}`, { [name2]: `%${value}%` }); + } else { + w[i === 0 ? 'where' : 'orWhere'](`playlist.${filter.columnName} like :${name2}`, { [name2]: `%${value}%` }); + } + } + })); + } + + if (filter.operation === 'contains') { + if (['postgres'].includes(AppDataSource.options.type.toLowerCase())) { + query.andWhere(`"playlist"."${filter.columnName}" like :${name}`, { [name]: `%${filter.value}%` }); + } else { + query.andWhere(`playlist.${filter.columnName} like :${name}`, { [name]: `%${filter.value}%` }); + } + } + if (filter.operation === 'equal') { + if (['postgres'].includes(AppDataSource.options.type.toLowerCase())) { + query.andWhere(`"playlist"."${filter.columnName}" = :${name}`, { [name]: `${filter.value}` }); + } else { + query.andWhere(`playlist.${filter.columnName} =:${name}`, { [name]: `${filter.value}` }); + } + } + if (filter.operation === 'notEqual') { + if (['postgres'].includes(AppDataSource.options.type.toLowerCase())) { + query.andWhere(`"playlist"."${filter.columnName}" != :${name}`, { [name]: `${filter.value}` }); + } else { + query.andWhere(`playlist.${filter.columnName} != :${name}`, { [name]: `${filter.value}` }); + } + } + } + + if (typeof opts.search !== 'undefined') { + query.andWhere(new Brackets(w => { + if (['postgres'].includes(AppDataSource.options.type.toLowerCase())) { + w.where('"playlist"."videoId" like :like', { like: `%${opts.search}%` }); + w.orWhere('"playlist"."title" like :like', { like: `%${opts.search}%` }); + } else { + w.where('playlist.videoId like :like', { like: `%${opts.search}%` }); + w.orWhere('playlist.title like :like', { like: `%${opts.search}%` }); + } + })); + } + + if (opts.tag) { + query.andWhere(new Brackets(w => { + if (['postgres'].includes(AppDataSource.options.type.toLowerCase())) { + w.where('"playlist"."tags" like :tag', { tag: `%${opts.tag}%` }); + } else { + w.where('playlist.tags like :tag', { tag: `%${opts.tag}%` }); + } + + })); + } + const [playlist, count] = await query.getManyAndCount(); + cb(null, await Promise.all(playlist.map(async (pl) => { + return { + ...pl, + volume: await this.getVolume(pl), + forceVolume: pl.forceVolume || false, + }; + })), count); + }); + adminEndpoint('/systems/songs', 'songs::save', async (item: SongPlaylist, cb) => { + isCachedTagsValid = false; + cb(null, await SongPlaylist.save(item)); + }); + adminEndpoint('/systems/songs', 'songs::getAllBanned', async (where, cb) => { + where ??= {}; + if (cb) { + cb(null, await SongBan.find(where)); + } + }); + adminEndpoint('/systems/songs', 'songs::removeRequest', async (id: string, cb) => { + await SongRequest.delete({ id }); + cb(null); + }); + publicEndpoint('/systems/songs', 'songs::getAllRequests', async (where, cb) => { + where = where || {}; + cb(null, await SongRequest.find({ + ...where, + order: { addedAt: 'ASC' }, + })); + }); + adminEndpoint('/systems/songs', 'delete.playlist', async (videoId, cb) => { + isCachedTagsValid = false; + await SongPlaylist.delete({ videoId }); + if (cb) { + cb(null); + } + }); + adminEndpoint('/systems/songs', 'delete.ban', async (videoId, cb) => { + await SongBan.delete({ videoId }); + if (cb) { + cb(null); + } + }); + adminEndpoint('/systems/songs', 'stop.import', () => { + importInProgress = false; + }); + adminEndpoint('/systems/songs', 'import.ban', async (url, cb) => { + try { + cb(null, await this.banSong({ + isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, parameters: this.getIdFromURL(url), sender: getUserSender(getBotId(), getBotUserName()), command: '', createdAt: Date.now(), attr: {}, discord: undefined, + })); + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/systems/songs', 'import.playlist', async ({ playlist, forcedTag }, cb) => { + try { + isCachedTagsValid = false; + cb(null, await this.importPlaylist({ + isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, parameters: playlist, sender: getUserSender(getBotId(), getBotUserName()), command: '', createdAt: Date.now(), attr: { forcedTag }, discord: undefined, + })); + } catch (e: any) { + cb(e.stack, null); + } + }); + adminEndpoint('/systems/songs', 'import.video', async ({ playlist, forcedTag }, cb) => { + try { + cb(null, await this.addSongToPlaylist({ + isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, parameters: playlist, sender: getUserSender(getBotId(), getBotUserName()), command: '', createdAt: Date.now(), attr: { forcedTag }, discord: undefined, + })); + } catch (e: any) { + cb(e.stack, null); + } + }); + adminEndpoint('/systems/songs', 'next', async () => { + this.sendNextSongID(); + }); + + this.socket.on('connection', (socket: io.Socket) => { + socket.on('disconnect', () => { + clearInterval(this.interval[socket.id]); + delete this.interval[socket.id]; + delete this.isPlaying[socket.id]; + }); + this.interval[socket.id] = setInterval(async () => { + socket.emit('isPlaying', (isPlaying: boolean) => this.isPlaying[socket.id] = isPlaying); + }, 1000); + }); + } + + getIdFromURL (url: string) { + const urlRegex = /^.*(?:youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)([^#&?]*).*/; + const match = url.match(urlRegex); + const videoID = (match && match[1].length === 11) ? match[1] : url; + return videoID; + } + + async getMeanLoudness () { + const playlist = await SongPlaylist.find(); + if (_.isEmpty(playlist)) { + this.meanLoudness = -15; + return -15; + } + + let loudness = 0; + for (const item of playlist) { + if (_.isNil(item.loudness)) { + loudness = loudness + -15; + } else { + loudness = loudness + item.loudness; + } + } + this.meanLoudness = loudness / playlist.length; + return loudness / playlist.length; + } + + async getVolume (item: SongPlaylist | currentSongType) { + if (!item.forceVolume && this.calculateVolumeByLoudness) { + item.loudness = !_.isNil(item.loudness) ? item.loudness : -15; + const volume = this.volume; + const correction = Math.ceil((volume / 100) * 3); + const loudnessDiff = this.meanLoudness - item.loudness; + return Math.round(volume + (correction * loudnessDiff)); + } else { + return item.volume; + } + } + + async getCurrentVolume (socket: io.Socket) { + let volume = 0; + if (this.calculateVolumeByLoudness) { + volume = await this.getVolume(JSON.parse(this.currentSong)); + } else { + volume = this.volume; + } + socket.emit('newVolume', volume); + } + + @command('!bansong') + @default_permission(defaultPermissions.CASTERS) + async banSong (opts: CommandOptions): Promise { + const videoID: string | null = opts.parameters.trim().length === 0 ? JSON.parse(this.currentSong).videoId : opts.parameters.trim(); + if (!videoID) { + throw new Error('Unknown videoId to ban song.'); + } + const videoTitle: string | null = (opts.parameters.trim().length === 0 ? JSON.parse(this.currentSong).title : (await this.getVideoDetails(videoID))?.videoDetails.title) ?? null; + if (!videoTitle) { + throw new Error('Cannot fetch video data, check your url or try again later.'); + } + + // send timeouts to all users who requested song + const request = (await SongRequest.findBy({ videoId: videoID })).map(o => o.username); + if (JSON.parse(this.currentSong).videoId === videoID) { + request.push(JSON.parse(this.currentSong).username); + } + await changelog.flush(); + const users = await AppDataSource.getRepository(User).findBy({ userName: In(request) }); + for (const username of request) { + const data = users.find(o => o.userName === username); + tmiEmitter.emit('timeout', username, 300, + { + mod: typeof data !== 'undefined' && isModerator(data), + }); + } + + const songBan = SongBan.create({ videoId: videoID, title: videoTitle }); + await Promise.all([ + songBan.save(), + SongPlaylist.delete({ videoId: videoID }), + SongRequest.delete({ videoId: videoID }), + ]); + + this.getMeanLoudness(); + this.sendNextSongID(); + this.refreshPlaylistVolume(); + + info(`Song ${videoTitle} (${videoID}) was added to banlist`); + const response = prepare('songs.song-was-banned', { name: videoTitle }); + return [{ response, ...opts }]; + } + + @onChange('calculateVolumeByLoudness') + async refreshPlaylistVolume () { + const playlist = await SongPlaylist.find(); + for (const item of playlist) { + item.volume = await this.getVolume(item); + await item.save(); + } + } + + async getVideoDetails (id: string): Promise { + return await new Promise((resolve: (value: ytdl.videoInfo) => any, reject) => { + let retry = 0; + const load = async () => { + try { + resolve(await ytdl.getInfo('https://www.youtube.com/watch?v=' + id)); + } catch (e: any) { + if (Number(retry ?? 0) < 5) { + setTimeout(() => { + retry++; + load(); + }, 500); + } else { + reject(e); + } + } + }; + load(); + }); + } + + @command('!unbansong') + @default_permission(defaultPermissions.CASTERS) + async unbanSong (opts: CommandOptions): Promise { + const removed = await SongBan.delete({ videoId: opts.parameters }); + if ((removed.affected || 0) > 0) { + return [{ response: translate('songs.song-was-unbanned'), ...opts }]; + } else { + return [{ response: translate('songs.song-was-not-banned'), ...opts }]; + } + } + + @command('!skipsong') + @default_permission(defaultPermissions.CASTERS) + async sendNextSongID (): Promise { + this.currentSong = JSON.stringify(emptyCurrentSong); + + // check if there are any requests + if (this.songrequest) { + const sr = await SongRequest.find({ order: { addedAt: 'ASC' }, take: 1 }); + if (sr[0]) { + const currentSong: any = sr[0]; + currentSong.volume = await this.getVolume(currentSong); + currentSong.type = 'songrequests'; + this.currentSong = JSON.stringify(currentSong); + + if (this.notify) { + this.notifySong(); + } + await SongRequest.delete({ videoId: sr[0].videoId }); + return []; + } + } + + // get song from playlist + if (this.playlist) { + if (!(await this.getTags()).includes(this.currentTag)) { + // tag is not in db + return []; + } + const order: any = this.shuffle ? { seed: 'ASC' } : { lastPlayedAt: 'ASC' }; + const pl = await SongPlaylist.find({ order, take: 1 }); + if (!pl[0]) { + return []; // don't do anything if no songs in playlist + } + + // shuffled song is played again + if (this.shuffle && pl[0].seed === 1) { + await this.createRandomSeeds(); + return this.sendNextSongID(); // retry with new seeds + } + + if (!pl[0].tags.includes(this.currentTag)) { + pl[0].seed = 1; + await pl[0].save(); + return this.sendNextSongID(); // get next song as this don't belong to tag + } + + pl[0].seed = 1; + pl[0].lastPlayedAt = new Date().toISOString(); + await pl[0].save(); + const currentSong = { + videoId: pl[0].videoId, + title: pl[0].title, + type: 'playlist', + username: getBotUserName(), + forceVolume: pl[0].forceVolume, + loudness: pl[0].loudness, + volume: await this.getVolume(pl[0]), + endTime: pl[0].endTime, + startTime: pl[0].startTime, + }; + this.currentSong = JSON.stringify(currentSong); + + if (this.notify) { + this.notifySong(); + } + return []; + } + return []; + } + + @command('!currentsong') + async getCurrentSong (opts: CommandOptions): Promise { + let translation = 'songs.no-song-is-currently-playing'; + const currentSong = JSON.parse(this.currentSong); + if (currentSong.videoId !== null) { + if (Object.values(this.isPlaying).find(o => o)) { + if (!_.isNil(currentSong.title)) { + if (currentSong.type === 'playlist') { + translation = 'songs.current-song-from-playlist'; + } else { + translation = 'songs.current-song-from-songrequest'; + } + } + } + } + + const response = prepare(translation, currentSong.videoId !== null ? { name: currentSong.title, username: currentSong.username } : {}); + return [{ response, ...opts }]; + } + + async notifySong () { + let translation; + const currentSong = JSON.parse(this.currentSong); + if (!_.isNil(currentSong.title)) { + if (currentSong.type === 'playlist') { + translation = 'songs.current-song-from-playlist'; + } else { + translation = 'songs.current-song-from-songrequest'; + } + } else { + return; + } + const message = prepare(translation, { name: currentSong.title, username: currentSong.username }); + announce(message, 'songs'); + } + + @command('!playlist steal') + @default_permission(defaultPermissions.CASTERS) + async stealSong (opts: CommandOptions): Promise { + try { + const currentSong = JSON.parse(this.currentSong); + + if (currentSong.videoId === null) { + throw new Error(); + } + + return this.addSongToPlaylist({ + ...opts, sender: getUserSender(getBotId(), getBotUserName()), parameters: currentSong.videoId, attr: {}, createdAt: Date.now(), command: '', + }); + } catch (err: any) { + return [{ response: translate('songs.no-song-is-currently-playing'), ...opts }]; + } + } + + async createRandomSeeds () { + const playlist = await SongPlaylist.find(); + for (const item of playlist) { + item.seed = Math.random(); + await item.save(); + } + } + + @command('!playlist') + @default_permission(defaultPermissions.CASTERS) + async playlistCurrent (opts: CommandOptions): Promise { + return [{ response: prepare('songs.playlist-current', { playlist: this.currentTag }), ...opts }]; + } + + @command('!playlist list') + @default_permission(defaultPermissions.CASTERS) + async playlistList (opts: CommandOptions): Promise { + return [{ response: prepare('songs.playlist-list', { list: (await this.getTags()).join(', ') }), ...opts }]; + } + + @command('!playlist set') + @default_permission(defaultPermissions.CASTERS) + async playlistSet (opts: CommandOptions): Promise { + try { + const tags = await this.getTags(); + if (!tags.includes(opts.parameters)) { + throw new Error(prepare('songs.playlist-not-exist', { playlist: opts.parameters })); + } + + this.currentTag = opts.parameters; + return [{ response: prepare('songs.playlist-set', { playlist: opts.parameters }), ...opts }]; + } catch (e: any) { + return [{ response: e.message, ...opts }]; + + } + } + + @command('!songrequest') + async addSongToQueue (opts: CommandOptions, retry = 0): Promise { + if (opts.parameters.length < 1 || !this.songrequest) { + if (this.songrequest) { + return [{ response: translate('core.usage') + ': !songrequest ', ...opts }]; + } else { + return [{ response: '$sender, ' + translate('songs.songrequest-disabled'), ...opts }]; + } + } + + const urlRegex = /^.*(?:youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)([^#&?]*).*/; + const idRegex = /^[a-zA-Z0-9-_]{11}$/; + const match = opts.parameters.match(urlRegex); + const videoID = (match && match[1].length === 11) ? match[1] : opts.parameters; + + if (_.isNil(videoID.match(idRegex))) { // not id or url] + try { + const search = await ytsr(opts.parameters, { limit: 1 }); + if (search.items.length > 0 && search.items[0].type === 'video') { + const videoId = /^\S+(?:youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)(?[^#&?]*).*/gi.exec(search.items[0].url)?.groups?.videoId; + if (!videoId) { + throw new Error('VideoID not parsed from ' + search.items[0].url); + } + opts.parameters = videoId; + return this.addSongToQueue(opts); + } + } catch (e: any) { + error(`SONGS: ${e.message}`); + return [{ response: translate('songs.youtube-is-not-responding-correctly'), ...opts }]; + } + } + + // is song banned? + const ban = await SongBan.findOneBy({ videoId: videoID }); + if (ban) { + return [{ response: translate('songs.song-is-banned'), ...opts }]; + } + + // check if song is in playlist + if (this.allowRequestsOnlyFromPlaylist) { + const inPlaylist = await SongPlaylist.count({ + where: { + videoId: videoID, + tags: Like(`%${this.currentTag}%`), + }, + }) > 0; + if (!inPlaylist) { + return [{ response: translate('songs.this-song-is-not-in-playlist'), ...opts }]; + } + } + + try { + const videoInfo = await ytdl.getInfo('https://www.youtube.com/watch?v=' + videoID); + if (Number(videoInfo.videoDetails.lengthSeconds) / 60 > this.duration) { + return [{ response: translate('songs.song-is-too-long'), ...opts }]; + } else if (videoInfo.videoDetails.category !== 'Music' && this.onlyMusicCategory) { + if (Number(retry ?? 0) < 5) { + // try once more to be sure + await new Promise((resolve) => { + setTimeout(() => resolve(true), 500); + }); + return this.addSongToQueue(opts, (retry ?? 0) + 1 ); + } + if (typeof (global as any).it === 'function') { + error('-- TEST ONLY ERROR --'); + error({ category: videoInfo.videoDetails.category }); + } + return [{ response: translate('songs.incorrect-category'), ...opts }]; + } else { + const songRequest = SongRequest.create({ + videoId: videoID, + title: videoInfo.videoDetails.title, + loudness: Number(videoInfo.loudness ?? -15), + length: Number(videoInfo.videoDetails.lengthSeconds), + username: opts.sender.userName, + }); + await songRequest.save(); + this.getMeanLoudness(); + const response = prepare('songs.song-was-added-to-queue', { name: videoInfo.videoDetails.title }); + return [{ response, ...opts }]; + } + } catch (e: any) { + if (Number(retry ?? 0) < 5) { + // try once more to be sure + await new Promise((resolve) => { + setTimeout(() => resolve(true), 500); + }); + return this.addSongToQueue(opts, (retry ?? 0) + 1 ); + } else { + error(e); + return [{ response: translate('songs.song-was-not-found'), ...opts }]; + } + } + } + + @command('!wrongsong') + async removeSongFromQueue (opts: CommandOptions): Promise { + const sr = await SongRequest.findOne({ + where: { username: opts.sender.userName }, + order: { addedAt: 'DESC' }, + }); + if (sr) { + SongRequest.remove(sr); + this.getMeanLoudness(); + const response = prepare('songs.song-was-removed-from-queue', { name: sr.title }); + return [{ response, ...opts }]; + } + return []; + } + + @command('!playlist add') + @default_permission(defaultPermissions.CASTERS) + async addSongToPlaylist (opts: CommandOptions): Promise { + if (_.isNil(opts.parameters)) { + return []; + } + + const urlRegex = /^.*(?:youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)([^#&?]*).*/; + const match = opts.parameters.match(urlRegex); + const id = (match && match[1].length === 11) ? match[1] : opts.parameters; + + const idsFromDB = (await SongPlaylist.find()).map(o => o.videoId); + const banFromDb = (await SongBan.find()).map(o => o.videoId); + + if (idsFromDB.includes(id)) { + info(`=> Skipped ${id} - Already in playlist`); + return [{ response: prepare('songs.song-is-already-in-playlist', { name: (await SongPlaylist.findOneByOrFail({ videoId: id })).title }), ...opts }]; + } else if (banFromDb.includes(id)) { + info(`=> Skipped ${id} - Song is banned`); + return [{ response: prepare('songs.song-is-banned', { name: (await SongPlaylist.findOneByOrFail({ videoId: id })).title }), ...opts }]; + } else { + const videoInfo = await ytdl.getInfo('https://www.youtube.com/watch?v=' + id); + if (videoInfo) { + info(`=> Imported ${id} - ${videoInfo.videoDetails.title}`); + const songPlaylist = SongPlaylist.create({ + videoId: id, + title: videoInfo.videoDetails.title, + loudness: Number(videoInfo.loudness ?? -15), + length: Number(videoInfo.videoDetails.lengthSeconds), + lastPlayedAt: new Date().toISOString(), + seed: 1, + volume: 20, + startTime: 0, + tags: [ opts.attr.forcedTag ? opts.attr.forcedTag : this.currentTag ], + endTime: Number(videoInfo.videoDetails.lengthSeconds), + }); + await songPlaylist.save(); + this.refreshPlaylistVolume(); + this.getMeanLoudness(); + isCachedTagsValid = false; + return [{ response: prepare('songs.song-was-added-to-playlist', { name: videoInfo.videoDetails.title }), ...opts }]; + } else { + return [{ response: translate('songs.youtube-is-not-responding-correctly'), ...opts }]; + } + } + } + + @command('!playlist remove') + @default_permission(defaultPermissions.CASTERS) + async removeSongFromPlaylist (opts: CommandOptions): Promise { + if (opts.parameters.length < 1) { + return []; + } + const videoID = opts.parameters; + + const song = await SongPlaylist.findOneBy({ videoId: videoID }); + if (song) { + SongPlaylist.delete({ videoId: videoID }); + const response = prepare('songs.song-was-removed-from-playlist', { name: song.title }); + isCachedTagsValid = false; + return [{ response, ...opts }]; + } else { + return [{ response: translate('songs.song-was-not-found'), ...opts }]; + } + } + + async getSongsIdsFromPlaylist (playlist: string) { + try { + const data = await ytpl(playlist, { limit: Number.MAX_SAFE_INTEGER }); + return data.items.map(o => o.id); + } catch (e: any) { + error(e); + } + } + + @command('!playlist import') + @default_permission(defaultPermissions.CASTERS) + async importPlaylist (opts: CommandOptions): Promise<(CommandResponse & { imported: number; skipped: number })[]> { + if (opts.parameters.length < 1) { + return []; + } + const ids = await this.getSongsIdsFromPlaylist(opts.parameters); + + if (!ids || ids.length === 0) { + return [{ + response: prepare('songs.playlist-is-empty'), ...opts, imported: 0, skipped: 0, + }]; + } else { + let imported = 0; + let done = 0; + importInProgress = true; + + const idsFromDB = (await SongPlaylist.find()).map(o => o.videoId); + const banFromDb = (await SongBan.find()).map(o => o.videoId); + + for (const id of ids) { + if (!importInProgress) { + info(`=> Skipped ${id} - Importing was canceled`); + } else if (idsFromDB.includes(id)) { + info(`=> Skipped ${id} - Already in playlist`); + done++; + } else if (banFromDb.includes(id)) { + info(`=> Skipped ${id} - Song is banned`); + done++; + } else { + try { + done++; + const videoInfo = await ytdl.getInfo('https://www.youtube.com/watch?v=' + id); + info(`=> Imported ${id} - ${videoInfo.videoDetails.title}`); + const songPlaylist = SongPlaylist.create({ + videoId: id, + title: videoInfo.videoDetails.title, + loudness: Number(videoInfo.loudness ?? - 15), + length: Number(videoInfo.videoDetails.lengthSeconds), + lastPlayedAt: new Date().toISOString(), + seed: 1, + volume: 20, + startTime: 0, + tags: [ opts.attr.forcedTag ? opts.attr.forcedTag : this.currentTag ], + endTime: Number(videoInfo.videoDetails.lengthSeconds), + }); + await songPlaylist.save(); + imported++; + } catch (e: any) { + error(`=> Skipped ${id} - ${e.message}`); + } + } + } + + await this.refreshPlaylistVolume(); + await this.getMeanLoudness(); + info(`=> Playlist import done, ${imported} imported, ${done - imported} skipped`); + isCachedTagsValid = false; + return [{ + response: prepare('songs.playlist-imported', { imported, skipped: done - imported }), imported, skipped: done - imported, ...opts, + }]; + } + } +} + +export default new Songs(); diff --git a/backend/src/systems/timers.ts b/backend/src/systems/timers.ts new file mode 100644 index 000000000..7cbd8b833 --- /dev/null +++ b/backend/src/systems/timers.ts @@ -0,0 +1,372 @@ +import { + Timer, TimerResponse, +} from '@entity/timer.js'; +import { Mutex } from 'async-mutex'; +import { validateOrReject } from 'class-validator'; +import * as _ from 'lodash-es'; +import { merge, sortBy } from 'lodash-es'; + +import System from './_interface.js'; +import { command, default_permission } from '../decorators.js'; +import { Expects } from '../expects.js'; + +import { onStartup } from '~/decorators/on.js'; +import { isStreamOnline } from '~/helpers/api/index.js'; +import { announce } from '~/helpers/commons/index.js'; +import { isDbConnected } from '~/helpers/database.js'; +import { app } from '~/helpers/panel.js'; +import { linesParsed } from '~/helpers/parser.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { adminMiddleware } from '~/socket.js'; +import { translate } from '~/translate.js'; + +/* + * !timers - gets an info about timers usage + * !timers set -name [name-of-timer] -messages [num-of-msgs-to-trigger|default:0] -seconds [trigger-every-x-seconds|default:60] [-offline] - add new timer + * !timers unset -name [name-of-timer] - remove timer + * !timers add -name [name-of-timer] -response '[response]' - add new response to timer + * !timers rm -id [response-id] - remove response by id + * !timers toggle -name [name-of-timer] - enable/disable timer by name + * !timers toggle -id [id-of-response] - enable/disable response by id + * !timers list - get timers list + * !timers list -name [name-of-timer] - get list of responses on timer + */ +const mutex = new Mutex(); + +class Timers extends System { + sockets() { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/systems/timer', adminMiddleware, async (req, res) => { + res.send({ + data: await Timer.find({ relations: ['messages'] }), + }); + }); + app.get('/api/systems/timer/:id', adminMiddleware, async (req, res) => { + res.send({ + data: await Timer.findOne({ where: { id: req.params.id }, relations: ['messages'] }), + }); + }); + app.delete('/api/systems/timer/:id', adminMiddleware, async (req, res) => { + await Timer.delete({ id: req.params.id }); + res.status(404).send(); + }); + app.post('/api/systems/timer', adminMiddleware, async (req, res) => { + try { + const itemToSave = new Timer(); + merge(itemToSave, req.body); + await validateOrReject(itemToSave); + await itemToSave.save(); + + await TimerResponse.delete({ timer: { id: itemToSave.id } }); + const responses = req.body.messages; + for (const response of responses) { + const resToSave = new TimerResponse(); + merge(resToSave, response); + resToSave.timer = itemToSave; + await resToSave.save(); + } + + res.send({ data: itemToSave }); + } catch (e) { + res.status(400).send({ errors: e }); + } + }); + } + + @command('!timers') + @default_permission(defaultPermissions.CASTERS) + main (opts: CommandOptions): CommandResponse[] { + let url = 'http://sogebot.github.io/sogeBot/#/systems/timers'; + if ((process.env?.npm_package_version ?? 'x.y.z-SNAPSHOT').includes('SNAPSHOT')) { + url = 'http://sogebot.github.io/sogeBot/#/_master/systems/timers'; + } + return [{ response: translate('core.usage') + ' => ' + url, ...opts }]; + } + + @onStartup() + async init () { + if (!isDbConnected) { + setTimeout(() => this.init(), 1000); + return; + } + + this.addMenu({ + category: 'manage', name: 'timers', id: 'manage/timers', this: this, + }); + const timers = await Timer.find({ relations: ['messages'] }); + for (const timer of timers) { + timer.triggeredAtMessages = 0; + timer.triggeredAtTimestamp = new Date().toISOString(); + await timer.save(); + } + + setInterval(async () => { + if (!mutex.isLocked()) { + const release = await mutex.acquire(); + try { + await this.check(); + } finally { + release(); + } + } + }, 1000); + } + + announceResponse (responses: TimerResponse[]) { + // check if at least one response is enabled + if (responses.filter(o => o.isEnabled).length === 0) { + return; + } + + responses = _.orderBy(responses, 'timestamp', 'asc'); + const response = responses.shift(); + if (response) { + TimerResponse.update({ id: response.id }, { timestamp: new Date().toISOString() }); + if (!response.isEnabled) { + // go to next possibly enabled response + this.announceResponse(responses); + } else { + announce(response.response, 'timers'); + } + } + } + + async check () { + if (!isStreamOnline.value) { + await Timer.update({ tickOffline: false }, { triggeredAtMessages: linesParsed, triggeredAtTimestamp: new Date().toISOString() }); + } + + const timers = await Timer.find({ + relations: ['messages'], + where: isStreamOnline.value ? { isEnabled: true } : { isEnabled: true, tickOffline: true }, + }); + + for (const timer of timers) { + if (timer.triggerEveryMessage > 0 && (timer.triggeredAtMessages || 0) - linesParsed + timer.triggerEveryMessage > 0) { + continue; + } // not ready to trigger with messages + if (timer.triggerEverySecond > 0 && new Date().getTime() - new Date(timer.triggeredAtTimestamp || 0).getTime() < timer.triggerEverySecond * 1000) { + continue; + } // not ready to trigger with seconds + + this.announceResponse(timer.messages); + await Timer.update({ id: timer.id }, { triggeredAtMessages: linesParsed, triggeredAtTimestamp: new Date().toISOString() }); + } + } + + @command('!timers set') + @default_permission(defaultPermissions.CASTERS) + async set (opts: CommandOptions): Promise { + // -name [name-of-timer] -messages [num-of-msgs-to-trigger|default:0] -seconds [trigger-every-x-seconds|default:60] -offline + const nameMatch = opts.parameters.match(/-name ([a-zA-Z0-9_]+)/); + const messagesMatch = opts.parameters.match(/-messages ([0-9]+)/); + const secondsMatch = opts.parameters.match(/-seconds ([0-9]+)/); + const tickOffline = !!opts.parameters.match(/-offline/); + + let name = ''; + let messages = 0; + let seconds = 0; + + if (_.isNil(nameMatch)) { + return [{ response: translate('timers.name-must-be-defined'), ...opts }]; + } else { + name = nameMatch[1]; + } + + messages = _.isNil(messagesMatch) ? 0 : parseInt(messagesMatch[1], 10); + seconds = _.isNil(secondsMatch) ? 60 : parseInt(secondsMatch[1], 10); + + if (messages === 0 && seconds === 0) { + return [{ response: translate('timers.cannot-set-messages-and-seconds-0'), ...opts }]; + } + const timer = await Timer.findOne({ + relations: ['messages'], + where: { name }, + }) || new Timer(); + + timer.tickOffline = tickOffline; + timer.name = name; + timer.triggerEveryMessage = messages; + timer.triggerEverySecond = seconds; + timer.isEnabled = true; + timer.triggeredAtMessages = linesParsed; + timer.triggeredAtTimestamp = new Date().toISOString(); + await timer.save(); + + return [{ + response: translate(tickOffline ? 'timers.timer-was-set-with-offline-flag' : 'timers.timer-was-set') + .replace(/\$name/g, name) + .replace(/\$messages/g, messages) + .replace(/\$seconds/g, seconds), ...opts, + }]; + } + + @command('!timers unset') + @default_permission(defaultPermissions.CASTERS) + async unset (opts: CommandOptions): Promise { + // -name [name-of-timer] + const nameMatch = opts.parameters.match(/-name ([\S]+)/); + let name = ''; + if (_.isNil(nameMatch)) { + return [{ response: translate('timers.name-must-be-defined'), ...opts }]; + } else { + name = nameMatch[1]; + } + + const timer = await Timer.findOneBy({ name: name }); + if (!timer) { + return [{ response: translate('timers.timer-not-found').replace(/\$name/g, name), ...opts }]; + } + + await Timer.remove(timer); + return [{ response: translate('timers.timer-deleted').replace(/\$name/g, name), ...opts }]; + } + + @command('!timers rm') + @default_permission(defaultPermissions.CASTERS) + async rm (opts: CommandOptions): Promise { + // -id [id-of-response] + try { + const id = new Expects(opts.parameters).argument({ type: 'uuid', name: 'id' }).toArray()[0]; + await TimerResponse.delete({ id }); + return [{ + response: translate('timers.response-deleted') + .replace(/\$id/g, id), ...opts, + }]; + } catch (e: any) { + return [{ response: translate('timers.id-must-be-defined'), ...opts }]; + } + } + + @command('!timers add') + @default_permission(defaultPermissions.CASTERS) + async add (opts: CommandOptions): Promise { + // -name [name-of-timer] -response '[response]' + const nameMatch = opts.parameters.match(/-name ([\S]+)/); + const responseMatch = opts.parameters.match(/-response ['"](.+)['"]/); + let name = ''; + let response = ''; + if (_.isNil(nameMatch)) { + return [{ response: translate('timers.name-must-be-defined'), ...opts }]; + } else { + name = nameMatch[1]; + } + + if (_.isNil(responseMatch)) { + return [{ response: translate('timers.response-must-be-defined'), ...opts }]; + } else { + response = responseMatch[1]; + } + const timer = await Timer.findOne({ + relations: ['messages'], + where: { name }, + }); + if (!timer) { + return [{ + response: translate('timers.timer-not-found') + .replace(/\$name/g, name), ...opts, + }]; + } + + const item = new TimerResponse(); + item.isEnabled = true; + item.timestamp = new Date().toISOString(); + item.response = response; + item.timer = timer; + await item.save(); + + return [{ + response: translate('timers.response-was-added') + .replace(/\$id/g, item.id) + .replace(/\$name/g, name) + .replace(/\$response/g, response), ...opts, + }]; + } + + @command('!timers list') + @default_permission(defaultPermissions.CASTERS) + async list (opts: CommandOptions): Promise { + // !timers list -name [name-of-timer] + const nameMatch = opts.parameters.match(/-name ([\S]+)/); + let name = ''; + + if (_.isNil(nameMatch)) { + const timers = await Timer.find(); + return [{ response: translate('timers.timers-list').replace(/\$list/g, _.orderBy(timers, 'name').map((o) => (o.isEnabled ? '⚫' : '⚪') + ' ' + o.name).join(', ')), ...opts }]; + } else { + name = nameMatch[1]; + } + + const timer = await Timer.findOne({ + relations: ['messages'], + where: { name }, + }); + if (!timer) { + return [{ + response: translate('timers.timer-not-found') + .replace(/\$name/g, name), ...opts, + }]; + } + const responses: CommandResponse[] = []; + responses.push({ response: translate('timers.responses-list').replace(/\$name/g, name), ...opts }); + for (const response of sortBy(timer.messages, 'response')) { + responses.push({ response: (response.isEnabled ? '⚫ ' : '⚪ ') + `${response.id} - ${response.response}`, ...opts }); + } + return responses; + } + + @command('!timers toggle') + @default_permission(defaultPermissions.CASTERS) + async toggle (opts: CommandOptions): Promise { + // -name [name-of-timer] or -id [id-of-response] + const [id, name] = new Expects(opts.parameters) + .argument({ + type: 'uuid', name: 'id', optional: true, + }) + .argument({ + type: String, name: 'name', optional: true, + }) + .toArray(); + + if ((_.isNil(id) && _.isNil(name)) || (!_.isNil(id) && !_.isNil(name))) { + return [{ response: translate('timers.id-or-name-must-be-defined'), ...opts }]; + } + + if (!_.isNil(id)) { + const response = await TimerResponse.findOneBy({ id }); + if (!response) { + return [{ response: translate('timers.response-not-found').replace(/\$id/g, id), ...opts }]; + } + + response.isEnabled = !response.isEnabled; + await response.save(); + + return [{ + response: translate(response.isEnabled ? 'timers.response-enabled' : 'timers.response-disabled') + .replace(/\$id/g, id), ...opts, + }]; + } + + if (!_.isNil(name)) { + const timer = await Timer.findOneBy({ name: name }); + if (!timer) { + return [{ response: translate('timers.timer-not-found').replace(/\$name/g, name), ...opts }]; + } + + timer.isEnabled = !timer.isEnabled; + await timer.save(); + + return [{ + response: translate(timer.isEnabled ? 'timers.timer-enabled' : 'timers.timer-disabled') + .replace(/\$name/g, name), ...opts, + }]; + } + return []; + } +} + +export default new Timers(); diff --git a/backend/src/systems/top.ts b/backend/src/systems/top.ts new file mode 100644 index 000000000..3614e9521 --- /dev/null +++ b/backend/src/systems/top.ts @@ -0,0 +1,327 @@ +import { User } from '@entity/user.js'; +import { dayjs } from '@sogebot/ui-helpers/dayjsHelper.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import { format } from '@sogebot/ui-helpers/number.js'; +import _ from 'lodash-es'; + +import System from './_interface.js'; +import levels from './levels.js'; +import points from './points.js'; +import { command, default_permission } from '../decorators.js'; +import general from '../general.js'; + +import { AppDataSource } from '~/database.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import { debug } from '~/helpers/log.js'; +import defaultPermissions from '~/helpers/permissions/defaultPermissions.js'; +import { getPointsName } from '~/helpers/points/index.js'; +import { unserialize } from '~/helpers/type.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { getIgnoreList, isIgnored } from '~/helpers/user/isIgnored.js'; +import twitch from '~/services/twitch.js'; +import { translate } from '~/translate.js'; +import { variables } from '~/watchers.js'; + +enum TYPE { + TIME = '0', + TIPS = '1', + POINTS = '2', + MESSAGES = '3', + SUBAGE = '5', + BITS = '6', + GIFTS = '7', + SUBMONTHS = '8', + LEVEL = '9', +} + +/* + * !top time + * !top tips + * !top points + * !top messages + * !top subage + * !top submonths + * !top bits + * !top gifts + */ + +class Top extends System { + @command('!top time') + @default_permission(defaultPermissions.CASTERS) + async time(opts: CommandOptions) { + opts.parameters = TYPE.TIME; + return this.showTop(opts); + } + + @command('!top tips') + @default_permission(defaultPermissions.CASTERS) + async tips(opts: CommandOptions) { + opts.parameters = TYPE.TIPS; + return this.showTop(opts); + } + + @command('!top points') + @default_permission(defaultPermissions.CASTERS) + async points(opts: CommandOptions) { + opts.parameters = TYPE.POINTS; + return this.showTop(opts); + } + + @command('!top messages') + @default_permission(defaultPermissions.CASTERS) + async messages(opts: CommandOptions) { + opts.parameters = TYPE.MESSAGES; + return this.showTop(opts); + } + + @command('!top subage') + @default_permission(defaultPermissions.CASTERS) + async subage(opts: CommandOptions) { + opts.parameters = TYPE.SUBAGE; + return this.showTop(opts); + } + + @command('!top submonths') + @default_permission(defaultPermissions.CASTERS) + async submonths(opts: CommandOptions) { + opts.parameters = TYPE.SUBMONTHS; + return this.showTop(opts); + } + + @command('!top bits') + @default_permission(defaultPermissions.CASTERS) + async bits(opts: CommandOptions) { + opts.parameters = TYPE.BITS; + return this.showTop(opts); + } + + @command('!top gifts') + @default_permission(defaultPermissions.CASTERS) + async gifts(opts: CommandOptions) { + opts.parameters = TYPE.GIFTS; + return this.showTop(opts); + } + + @command('!top level') + @default_permission(defaultPermissions.CASTERS) + async level(opts: CommandOptions) { + opts.parameters = TYPE.LEVEL; + return this.showTop(opts); + } + + private async showTop(opts: CommandOptions): Promise { + let sorted: {userName: string; value: string | number}[] = []; + let message; + let i = 0; + const type = opts.parameters; + + // count ignored users + const _total = 10 + getIgnoreList().length; + + const botUsername = variables.get('services.twitch.botUsername') as string; + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + + await changelog.flush(); + switch (type) { + case TYPE.LEVEL: { + let rawSQL = ''; + if (AppDataSource.options.type === 'better-sqlite3') { + rawSQL = `SELECT JSON_EXTRACT("user"."extra", '$.levels.xp') AS "data", "userId", "userName" + FROM "user" "user" + WHERE "user"."userName" IS NOT '${botUsername.toLowerCase()}' + AND "user"."userName" IS NOT '${broadcasterUsername.toLowerCase()}' + ORDER BY length(data) DESC, data DESC LIMIT ${_total}`; + } else if (AppDataSource.options.type === 'postgres') { + rawSQL = `SELECT "user"."userId", "user"."userName", CAST("data" as text) + FROM "user", JSON_EXTRACT_PATH("extra"::json, 'levels') AS "data" + WHERE "user"."userName" != '${botUsername.toLowerCase()}' + AND "user"."userName" != '${broadcasterUsername.toLowerCase()}' + ORDER BY length("data"::text) DESC, "data"::text DESC + LIMIT ${_total}`; + } else if (AppDataSource.options.type === 'mysql') { + rawSQL = `SELECT JSON_EXTRACT(\`user\`.\`extra\`, '$.levels.xp') AS \`data\`, \`userId\`, \`userName\` + FROM \`user\` \`user\` + WHERE \`user\`.\`userName\` != '${botUsername.toLowerCase()}' + AND \`user\`.\`userName\` != '${broadcasterUsername.toLowerCase()}' + ORDER BY length(\`data\`) DESC, data DESC LIMIT ${_total}`; + } + const users = (await AppDataSource.query(rawSQL)).filter((o: any) => !isIgnored({ userName: o.userName, userId: o.userId })); + + for (const rawUser of users) { + const user = await changelog.get(rawUser.userId); + if (user) { + const currentXP = unserialize(user.extra?.levels?.xp) ?? BigInt(0); + sorted.push({ userName: user.userName, value: `${levels.getLevelOf(user)} (${currentXP}XP)` }); + } + } + message = translate('systems.top.level').replace(/\$amount/g, 10); + break; + } + case TYPE.TIME: + sorted + = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .orderBy('user.watchedTime', 'DESC') + .limit(_total) + .getMany()) + .filter(o => !isIgnored({ userName: o.userName, userId: o.userId })) + .map(o => { + return { userName: o.userName, value: o.watchedTime }; + }); + message = translate('systems.top.time').replace(/\$amount/g, 10); + break; + case TYPE.TIPS: { + const joinTip = AppDataSource.options.type === 'postgres' ? '"user_tip"."userId" = "user"."userId"' : 'user_tip.userId = user.userId'; + sorted + = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .orderBy('value', 'DESC') + .addSelect('COALESCE(SUM(user_tip.sortAmount), 0)', 'value') + .addSelect('user.userName') + .limit(_total) + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .innerJoin('user_tip', 'user_tip', joinTip) + .groupBy('user.userId') + .getRawMany() + ).filter(o => !isIgnored({ userName: o.userName, userId: o.userId })); + message = translate('systems.top.tips').replace(/\$amount/g, 10); + break; + } + case TYPE.POINTS: + if (!points.enabled) { + return []; + } + sorted + = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .orderBy('user.points', 'DESC') + .limit(_total) + .getMany()) + .filter(o => !isIgnored({ userName: o.userName, userId: o.userId })) + .map(o => { + return { userName: o.userName, value: o.points }; + }); + message = translate('systems.top.points').replace(/\$amount/g, 10); + break; + case TYPE.MESSAGES: + sorted + = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .orderBy('user.messages', 'DESC') + .limit(_total) + .getMany()) + .filter(o => !isIgnored({ userName: o.userName, userId: o.userId })) + .map(o => { + return { userName: o.userName, value: o.messages }; + }); + message = translate('systems.top.messages').replace(/\$amount/g, 10); + break; + case TYPE.SUBAGE: + sorted + = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .andWhere('user.isSubscriber = :isSubscriber', { isSubscriber: true }) + .andWhere('user.subscribedAt IS NOT NULL') + .orderBy('user.subscribedAt', 'ASC') + .limit(_total) + .getMany()) + .filter(o => !isIgnored({ userName: o.userName, userId: o.userId })) + .map(o => { + return { userName: o.userName, value: o.subscribedAt as string }; + }); + message = translate('systems.top.subage').replace(/\$amount/g, 10); + break; + case TYPE.BITS: { + const joinBit = AppDataSource.options.type === 'postgres' ? '"user_bit"."userId" = "user"."userId"' : 'user_bit.userId = user.userId'; + sorted + = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .orderBy('value', 'DESC') + .addSelect('COALESCE(SUM(user_bit.amount), 0)', 'value') + .addSelect('user.userName') + .limit(_total) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .innerJoin('user_bit', 'user_bit', joinBit) + .groupBy('user.userId') + .getRawMany() + ).filter(o => !isIgnored({ userName: o.userName, userId: o.userId })); + message = translate('systems.top.bits').replace(/\$amount/g, 10); + break; + } + case TYPE.GIFTS: + sorted + = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .orderBy('user.giftedSubscribes', 'DESC') + .limit(_total) + .getMany()) + .filter(o => !isIgnored({ userName: o.userName, userId: o.userId })) + .map(o => { + return { userName: o.userName, value: o.giftedSubscribes }; + }); + message = translate('systems.top.gifts').replace(/\$amount/g, 10); + break; + case TYPE.SUBMONTHS: + sorted + = (await AppDataSource.getRepository(User).createQueryBuilder('user') + .where('user.userName != :botusername', { botusername: botUsername.toLowerCase() }) + .andWhere('user.userName != :broadcasterusername', { broadcasterusername: broadcasterUsername.toLowerCase() }) + .orderBy('user.subscribeCumulativeMonths', 'DESC') + .limit(_total) + .getMany()) + .filter(o => !isIgnored({ userName: o.userName, userId: o.userId })) + .map(o => { + return { userName: o.userName, value: o.subscribeCumulativeMonths }; + }); + message = translate('systems.top.submonths').replace(/\$amount/g, 10); + break; + } + + if (sorted.length > 0) { + // remove ignored users + sorted = _.chunk(sorted, 10)[0]; + + for (const user of sorted) { + message += (i + 1) + '. ' + (twitch.showWithAt ? '@' : '') + (user.userName || 'unknown') + ' - '; + switch (type) { + case TYPE.TIME: + message += Intl.NumberFormat(general.lang, { + style: 'unit', unit: 'hour', minimumFractionDigits: 1, maximumFractionDigits: 1, + }).format(Number(user.value) / 1000 / 60 / 60); + break; + case TYPE.SUBMONTHS: + message += [user.value, getLocalizedName(user.value, translate('core.months'))].join(' '); + break; + case TYPE.TIPS: + message += Intl.NumberFormat(general.lang, { style: 'currency', currency: mainCurrency.value }).format(Number(user.value)); + break; + case TYPE.POINTS: + message += format(general.numberFormat, 0)(Number(user.value)) + ' ' + getPointsName(Number(user.value)); + break; + case TYPE.MESSAGES: + case TYPE.BITS: + case TYPE.GIFTS: + case TYPE.LEVEL: + message += String(user.value); + break; + case TYPE.SUBAGE: + message += `${dayjs.utc(user.value).format('L')} (${dayjs.utc(user.value).fromNow()})`; + break; + } + if (i + 1 < 10 && !_.isNil(sorted[i + 1])) { + message += ', '; + } + i++; + } + } else { + message += 'no data available'; + } + debug('systems.top', message); + return [{ response: message, ...opts }]; + } +} + +export default new Top(); diff --git a/backend/src/systems/userinfo.ts b/backend/src/systems/userinfo.ts new file mode 100644 index 000000000..1bc016425 --- /dev/null +++ b/backend/src/systems/userinfo.ts @@ -0,0 +1,361 @@ +import { + User, UserBit, UserTip, +} from '@entity/user.js'; +import { dayjs, timezone } from '@sogebot/ui-helpers/dayjsHelper.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import { format } from '@sogebot/ui-helpers/number.js'; + +import System from './_interface.js'; +import levels from './levels.js'; +import points from './points.js'; +import ranks from './ranks.js'; +import { dateDiff } from '../commons.js'; +import { + command, default_permission, settings, +} from '../decorators.js'; +import { Expects } from '../expects.js'; +import general from '../general.js'; +import { isFollowerUpdate } from '../services/twitch/calls/isFollowerUpdate.js'; +import users from '../users.js'; + +import { AppDataSource } from '~/database.js'; +import { prepare } from '~/helpers/commons/index.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import { error } from '~/helpers/log.js'; +import { get } from '~/helpers/permissions/get.js'; +import { getUserHighestPermission } from '~/helpers/permissions/getUserHighestPermission.js'; +import { getPointsName } from '~/helpers/points/index.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import twitch from '~/services/twitch.js'; +import { translate } from '~/translate.js'; + +/* + * !me + * !stats + * !lastseen + * !watched + * !followage + * !subage + * !age + */ + +class UserInfo extends System { + @settings('me') + order: string[] = ['$sender', '$level', '$rank', '$role', '$watched', '$points', '$messages', '$tips', '$bits', '$subMonths']; + + @settings('me') + _formatDisabled: string[] = ['$role']; + + @settings('me') + formatSeparator = ' | '; + + @settings('customization') + lastSeenFormat = 'L LTS'; + + @command('!followage') + protected async followage(opts: CommandOptions): Promise { + const [userName] = new Expects(opts.parameters).username({ optional: true, default: opts.sender.userName }).toArray(); + const id = await users.getIdByName(userName); + const followedAt = await isFollowerUpdate(id); + + if (!followedAt) { + return [{ response: prepare('followage.' + (opts.sender.userName === userName.toLowerCase() ? 'successSameUsername' : 'success') + '.never', { username: userName }), ...opts }]; + } else { + const units = ['years', 'months', 'days', 'hours', 'minutes'] as const; + const diff = dateDiff(new Date(followedAt).getTime(), Date.now()); + + const output: string[] = []; + for (const unit of units) { + if (diff[unit]) { + const v = Number(diff[unit]).toFixed(); + output.push(v + ' ' + getLocalizedName(v, translate('core.' + unit))); + } + } + if (output.length === 0) { + output.push(0 + ' ' + getLocalizedName(0, translate('core.minutes'))); + } + + return [{ + response: prepare('followage.' + (opts.sender.userName === userName.toLowerCase() ? 'successSameUsername' : 'success') + '.time', { + username: userName, + diff: output.join(', '), + }), ...opts, + }]; + } + } + + @command('!subage') + protected async subage(opts: CommandOptions): Promise { + const [userName] = new Expects(opts.parameters).username({ optional: true, default: opts.sender.userName }).toArray(); + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userName }); + const subCumulativeMonths = user?.subscribeCumulativeMonths; + const subStreak = user?.subscribeStreak; + const localePath = 'subage.' + (opts.sender.userName === userName.toLowerCase() ? 'successSameUsername' : 'success') + '.'; + + if (!user || !user.isSubscriber) { + return [{ + response: prepare(localePath + (subCumulativeMonths ? 'notNow' : 'never'), { + username: userName, + subCumulativeMonths, + subCumulativeMonthsName: getLocalizedName(subCumulativeMonths || 0, translate('core.months')), + }), ...opts, + }]; + } else { + const units = ['years', 'months', 'days', 'hours', 'minutes'] as const; + const diff = user.subscribedAt ? dateDiff(new Date(user.subscribedAt).getTime(), Date.now()) : null; + const output: string[] = []; + + if (diff) { + for (const unit of units) { + if (diff[unit]) { + const v = Number(diff[unit]).toFixed(); + output.push(v + ' ' + getLocalizedName(v, translate('core.' + unit))); + } + } + if (output.length === 0) { + output.push(0 + ' ' + getLocalizedName(0, translate('core.minutes'))); + } + } + + return [{ + response: prepare(localePath + (subStreak ? 'timeWithSubStreak' : 'time'), { + username: userName, + subCumulativeMonths, + subCumulativeMonthsName: getLocalizedName(subCumulativeMonths ?? 1, translate('core.months')), + subStreak, + subStreakMonthsName: getLocalizedName(subStreak ?? 1, translate('core.months')), + diff: output.join(', '), + }), ...opts, + }]; + } + } + + @command('!age') + protected async age(opts: CommandOptions, retry = false): Promise { + const [userName] = new Expects(opts.parameters).username({ optional: true, default: opts.sender.userName }).toArray(); + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userName }); + if (!user || !user.createdAt) { + try { + + const getUserByName = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUserByName(userName)); + if (getUserByName) { + changelog.update(getUserByName.id, { userName, createdAt: new Date(getUserByName.creationDate).toISOString() }); + } + if (!retry) { + return this.age(opts, true); + } else { + throw new Error('retry'); + } + } catch (e: any) { + if (e.message !== 'retry') { + error(e); + } + return [{ response: prepare('age.failed', { username: userName }), ...opts }]; + } + } else { + const units = ['years', 'months', 'days', 'hours', 'minutes'] as const; + const diff = dateDiff(new Date(user.createdAt).getTime(), Date.now()); + const output: string[] = []; + for (const unit of units) { + if (diff[unit]) { + const v = Number(diff[unit]).toFixed(); + output.push(v + ' ' + getLocalizedName(v, translate('core.' + unit))); + } + } + if (output.length === 0) { + output.push(0 + ' ' + getLocalizedName(0, translate('core.minutes'))); + } + return [{ + response: prepare('age.success.' + (opts.sender.userName === userName.toLowerCase() ? 'withoutUsername' : 'withUsername'), { + username: userName, + diff: output.join(', '), + }), ...opts, + }]; + } + } + + @command('!lastseen') + protected async lastseen(opts: CommandOptions): Promise { + try { + const [userName] = new Expects(opts.parameters).username().toArray(); + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userName: userName }); + if (!user || !user.seenAt) { + return [{ response: translate('lastseen.success.never').replace(/\$username/g, userName), ...opts }]; + } else { + return [{ + response: translate('lastseen.success.time') + .replace(/\$username/g, userName) + .replace(/\$when/g, dayjs(user.seenAt).tz(timezone).format(this.lastSeenFormat)), ...opts, + }]; + } + } catch (e: any) { + return [{ response: translate('lastseen.failed.parse'), ...opts }]; + } + } + + @command('!watched') + protected async watched(opts: CommandOptions): Promise { + try { + const [username] = new Expects(opts.parameters).username({ optional: true, default: opts.sender.userName }).toArray(); + + let id; + if (opts.sender.userName === username) { + id = opts.sender.userId; + } else { + id = await users.getIdByName(username); + } + const time = id ? Number((await users.getWatchedOf(id) / (60 * 60 * 1000))).toFixed(1) : 0; + return [{ response: prepare('watched.success.time', { time: String(time), username }), ...opts }]; + } catch (e: any) { + return [{ response: translate('watched.failed.parse'), ...opts }]; + } + } + + @command('!me') + async showMe(opts: CommandOptions, returnOnly = false): Promise { + try { + const message: (string | null)[] = []; + const [user, tips, bits] = await Promise.all([ + changelog.get(opts.sender.userId), + AppDataSource.getRepository(UserTip).find({ where: { userId: opts.sender.userId } }), + AppDataSource.getRepository(UserBit).find({ where: { userId: opts.sender.userId } }), + ]); + + if (!user) { + throw Error(`User ${opts.sender.userName}#${opts.sender.userId} not found.`); + } + + // build message + for (const i of this.order) { + if (!this._formatDisabled.includes(i)) { + message.push(i); + } + } + + if (message.includes('$rank')) { + const idx = message.indexOf('$rank'); + const rank = await ranks.get(await changelog.get(opts.sender.userId)); + if (ranks.enabled && rank.current !== null) { + message[idx] = typeof rank.current === 'string' ? rank.current : rank.current.rank; + } else { + message.splice(idx, 1); + } + } + + if (message.includes('$level')) { + const idx = message.indexOf('$level'); + if (levels.enabled) { + const level = await levels.getLevelOf(await changelog.get(opts.sender.userId)); + message[idx] = `Level ${level}`; + } else { + message.splice(idx, 1); + } + } + + if (message.includes('$watched')) { + const idx = message.indexOf('$watched'); + message[idx] = format(general.numberFormat, 1)(user.watchedTime / 1000 / 60 / 60) + ' ' + getLocalizedName(user.watchedTime, translate('core.hours')); + } + + if (message.includes('$points')) { + const idx = message.indexOf('$points'); + if (points.enabled) { + message[idx] = format(general.numberFormat, 0)(user.points) + ' ' + getPointsName(user.points); + } else { + message.splice(idx, 1); + } + } + + if (message.includes('$messages')) { + const idx = message.indexOf('$messages'); + message[idx] = format(general.numberFormat, 0)(user.messages) + ' ' + getLocalizedName(user.messages, translate('core.messages')); + } + + if (message.includes('$tips')) { + const idx = message.indexOf('$tips'); + let tipAmount = 0; + for (const t of tips) { + tipAmount += exchange(Number(t.amount), t.currency, mainCurrency.value); + } + message[idx] = Intl.NumberFormat(general.lang, { style: 'currency', currency: mainCurrency.value }).format(tipAmount); + } + + if (message.includes('$subMonths')) { + const idx = message.indexOf('$subMonths'); + message[idx] = format(general.numberFormat, 0)(user.subscribeCumulativeMonths) + ' ' + getLocalizedName(user.subscribeCumulativeMonths, translate('core.months')); + } + + if (message.includes('$bits')) { + const idx = message.indexOf('$bits'); + const bitAmount = bits.map(o => Number(o.amount)).reduce((a, b) => a + b, 0); + message[idx] = `${format(general.numberFormat, 0)(bitAmount)} ${getLocalizedName(bitAmount, translate('core.bits'))}`; + } + + if (message.includes('$role')) { + const idx = message.indexOf('$role'); + message[idx] = null; + const permId = await getUserHighestPermission(opts.sender.userId); + if (permId) { + const pItem = await get(permId); + if (pItem) { + message[idx] = pItem.name; + } + } + } + + const response = message.filter(o => o !== null).join(this.formatSeparator); + if (returnOnly) { + return response; + } else { + return [{ + response, sender: opts.sender, attr: opts.attr, discord: opts.discord, + }]; + } + } catch (e: any) { + error(e.stack); + return []; + } + } + + @command('!stats') + @default_permission(null) + async showStats(opts: CommandOptions): Promise { + try { + const userName = new Expects(opts.parameters).username().toArray()[0].toLowerCase(); + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userName: userName.toLowerCase() }); + + if (!user) { + throw Error(`User ${userName} not found.`); + } + + const response = await this.showMe({ + ...opts, + sender: { + ...opts.sender, + userName, + userId: String(user.userId), + }, + }, true) as string; + return [ + { + response: response.replace('$sender', '$touser'), sender: opts.sender, attr: { ...opts.attr, param: userName }, discord: opts.discord, + }, + ]; + } catch (e: any) { + if (e.message.includes('')) { + return this.showMe(opts) as Promise; // fallback to me without param + } else { + error(e.stack); + return []; + } + } + + } +} + +export default new UserInfo(); diff --git a/backend/src/translate.ts b/backend/src/translate.ts new file mode 100644 index 000000000..5a5cfbe2c --- /dev/null +++ b/backend/src/translate.ts @@ -0,0 +1,134 @@ +import fs from 'fs'; +import { normalize } from 'path'; + +import { glob } from 'glob'; +import { set, isNil, remove, isUndefined, cloneDeep, each, get } from 'lodash-es'; + +import { areDecoratorsLoaded } from './decorators.js'; + +import { Settings } from '~/database/entity/settings.js'; +import { Translation } from '~/database/entity/translation.js'; +import { AppDataSource } from '~/database.js'; +import { flatten } from '~/helpers/flatten.js'; +import { getLang, setLang } from '~/helpers/locales.js'; +import { error, warning } from '~/helpers/log.js'; +import { addMenu } from '~/helpers/panel.js'; + +class Translate { + custom: any[] = []; + translations: any = {}; + isLoaded = false; + + constructor () { + addMenu({ + category: 'settings', name: 'translations', id: 'settings/translations', this: null, + }); + } + + async check(lang: string): Promise { + return typeof this.translations[lang] !== 'undefined'; + } + + async _load () { + this.custom = await AppDataSource.getRepository(Translation).find(); + return new Promise((resolve, reject) => { + const load = async () => { + if (!areDecoratorsLoaded) { + // waiting for full load + setImmediate(() => load()); + return; + } + + // we need to manually get if lang is changed so we have proper translations on init + const lang = await AppDataSource.getRepository(Settings).findOneBy({ namespace: '/core/general', name: 'lang' }); + if (lang) { + setLang(JSON.parse(lang.value)); + } + + glob('./locales/**').then((files) => { + for (const f of files) { + if (!f.endsWith('.json')) { + continue; + } + const withoutLocales = normalize(f).replace(/\\/g, '/').replace('locales/', '').replace('.json', ''); + try { + set(this.translations, withoutLocales.split('/').join('.'), JSON.parse(fs.readFileSync(f, 'utf8'))); + } catch (e: any) { + error('Incorrect JSON file: ' + f); + error(e.stack); + } + } + + // dayjs locale include + for(const key of Object.keys(this.translations)) { + import(`dayjs/locale/${key}.js`); + } + + for (const c of this.custom) { + if (isNil(flatten(this.translations.en)[c.name])) { + // remove if lang doesn't exist anymore + AppDataSource.getRepository(Translation).delete({ name: c.name }); + this.custom = remove(this.custom, (i) => i.name === c.name); + } + } + this.isLoaded = true; + resolve(); + }); + }; + load(); + }); + } + + async _save () { + for (const c of this.custom) { + await AppDataSource.getRepository(Translation).save({ + name: c.name, + value: c.value, + }); + await this._load(); + } + } + + translate (text: string | { root: string }, orig = false): any { + if (!translate_class.isLoaded) { + const stack = (new Error('Translations are not yet loaded.')).stack; + warning(stack); + } + if (isUndefined(translate_class.translations[getLang()]) && !isUndefined(text)) { + return '{missing_translation: ' + getLang() + '.' + String(text) + '}'; + } else if (typeof text === 'object') { + const t = cloneDeep(translate_class.translations)[getLang()][text.root]; + for (const c of translate_class.custom) { + t[c.name.replace(`${text.root}.`, '')] = c.value; + } + return t; + } else if (typeof text !== 'undefined') { + return translate_class.get(text, orig); + } + } + + get (text: string, orig: string | boolean) { + try { + let translated = ''; + const customTranslated = this.custom.find((o) => { + return o.name === text; + }); + if (customTranslated && customTranslated.value && !orig) { + translated = customTranslated.value; + } else { + translated = get(this.translations[getLang()], String(text), undefined); + } + each(translated.match(/(\{[\w-.]+\})/g), (toTranslate) => { + translated = translated.replace(toTranslate, this.get(toTranslate.replace('{', '').replace('}', ''), orig)); + }); + return translated; + } catch (err: any) { + return '{missing_translation: ' + getLang() + '.' + String(text) + '}'; + } + } +} + +const translate_class = new Translate(); +const translate = translate_class.translate; +export default translate_class; +export { translate }; diff --git a/backend/src/tts.ts b/backend/src/tts.ts new file mode 100644 index 000000000..64a5ce57f --- /dev/null +++ b/backend/src/tts.ts @@ -0,0 +1,195 @@ +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { JWT } from 'google-auth-library'; +import { google } from 'googleapis'; + +import Core from '~/_interface.js'; +import { GooglePrivateKeys } from '~/database/entity/google.js'; +import { AppDataSource } from '~/database.js'; +import { + onStartup, +} from '~/decorators/on.js'; +import { settings } from '~/decorators.js'; +import { error, info, warning } from '~/helpers/log.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; + +/* secureKeys are used to authenticate use of public overlay endpoint */ +const secureKeys = new Set(); + +export enum services { + 'NONE' = -1, + 'RESPONSIVEVOICE', + 'GOOGLE' +} + +let jwtClient: null | JWT = null; + +class TTS extends Core { + @settings() + service: services = services.NONE; + + @settings() + responsiveVoiceKey = ''; + + @settings() + googlePrivateKey = ''; + @settings() + googleVoices: string[] = []; + + addSecureKey(key: string) { + secureKeys.add(key); + setTimeout(() => { + secureKeys.delete(key); + }, 10 * MINUTE); + } + + sockets() { + adminEndpoint('/core/tts', 'settings.refresh', async () => { + this.onStartup(); // reset settings + }); + + adminEndpoint('/core/tts', 'google::speak', async (opts, cb) => { + const audioContent = await this.googleSpeak(opts); + if (cb) { + cb(null, audioContent); + } + }); + + publicEndpoint('/core/tts', 'speak', async (opts, cb) => { + if (secureKeys.has(opts.key)) { + secureKeys.delete(opts.key); + + if (!this.ready) { + cb(new Error('TTS is not properly set and ready.')); + return; + } + + if (this.service === services.GOOGLE) { + try { + const audioContent = await this.googleSpeak(opts); + cb(null, audioContent); + } catch (e) { + cb(e); + } + } + } else { + cb(new Error('Invalid auth.')); + } + }); + } + + @onStartup() + async onStartup() { + switch(this.service) { + case services.NONE: + warning('TTS: no selected service has been configured.'); + break; + case services.GOOGLE: + try { + if (this.googlePrivateKey.length === 0) { + throw new Error('Missing private key'); + } + + // get private key + const privateKey = await AppDataSource.getRepository(GooglePrivateKeys).findOneByOrFail({ id: this.googlePrivateKey }); + + // configure a JWT auth client + jwtClient = new google.auth.JWT( + privateKey.clientEmail, + undefined, + privateKey.privateKey, + ['https://www.googleapis.com/auth/cloud-platform']); + } catch (err) { + error('TTS: Something went wrong with authentication to Google Service.'); + error(err); + jwtClient = null; + } + + // authenticate request + jwtClient?.authorize(async (err) => { + if (err) { + error('TTS: Something went wrong with authentication to Google Service.'); + error(err); + jwtClient = null; + return; + } else { + if (!jwtClient) { + // this shouldn't occur but make TS happy + return; + } + + info('TTS: Authentication to Google Service successful.'); + + const texttospeech = google.texttospeech({ + auth: jwtClient, + version: 'v1', + }); + + // get voices list + const list = await texttospeech.voices.list(); + this.googleVoices = Array.from(new Set(list.data.voices?.map(o => String(o.name)).sort() ?? [])); + info(`TTS: Cached ${this.googleVoices.length} Google Service voices.`); + } + }); + break; + case services.RESPONSIVEVOICE: + if (this.responsiveVoiceKey.length > 0) { + info('TTS: ResponsiveVoice ready.'); + } else { + warning('TTS: ResponsiveVoice ApiKey is not properly set.'); + } + break; + } + } + + get ready() { + if (this.service === services.NONE) { + return false; + } + + if (this.service === services.RESPONSIVEVOICE) { + return this.responsiveVoiceKey.length > 0; + } + + if (this.service === services.GOOGLE) { + return this.googlePrivateKey.length > 0; + } + } + + async googleSpeak(opts: { + volume: number; + pitch: number; + rate: number; + text: string; + voice: string; + }) { + if (!jwtClient) { + throw new Error('JWT Client is not set'); + } + const texttospeech = google.texttospeech({ + auth: jwtClient, + version: 'v1', + }); + + const volumeGainDb = -6 + (12 * opts.volume); + const synthesize = await texttospeech.text.synthesize({ + requestBody: { + audioConfig: { + audioEncoding: 'MP3', + pitch: opts.pitch, + speakingRate: opts.rate, + volumeGainDb: volumeGainDb, + }, + input: { + text: opts.text, + }, + voice: { + languageCode: `${opts.voice.split('-')[0]}-${opts.voice.split('-')[1]}`, + name: opts.voice, + }, + }, + }); + return synthesize.data.audioContent; + } +} + +export default new TTS(); diff --git a/backend/src/ui.ts b/backend/src/ui.ts new file mode 100644 index 000000000..20ab55ce9 --- /dev/null +++ b/backend/src/ui.ts @@ -0,0 +1,128 @@ +import { timezone } from '@sogebot/ui-helpers/dayjsHelper.js'; +import { + filter, isString, set, +} from 'lodash-es'; + +import { app } from './helpers/panel.js'; + +import Core from '~/_interface.js'; +import { onChange, onLoad } from '~/decorators/on.js'; +import { settings } from '~/decorators.js'; +import general from '~/general.js'; +import { mainCurrency, symbol } from '~/helpers/currency/index.js'; +import { find, list } from '~/helpers/register.js'; +import { publicEndpoint } from '~/helpers/socket.js'; +import { domain } from '~/helpers/ui/index.js'; +import { variables } from '~/watchers.js'; + +class UI extends Core { + @settings() + public enablePublicPage = false; + + @settings() + public domain = 'localhost'; + + @settings() + public percentage = true; + + @settings() + public shortennumbers = true; + + @settings() + public showdiff = true; + + @onChange('domain') + @onLoad('domain') + setDomain() { + domain.value = this.domain; + } + + sockets() { + if (!app) { + setTimeout(() => this.sockets(), 100); + return; + } + + app.get('/api/ui/configuration', async (req, res) => { + const data: any = {}; + + if (req.headers.adminAccess) { + for (const system of ['currency', 'ui', 'general', 'dashboard', 'tts']) { + if (typeof data.core === 'undefined') { + data.core = {}; + } + const self = find('core', system); + if (!self) { + throw new Error(`core.${system} not found in list`); + } + data.core[system] = await self.getAllSettings(true); + } + for (const dir of ['systems', 'games', 'overlays', 'integrations', 'services']) { + for (const system of list(dir as any)) { + set(data, `${dir}.${system.__moduleName__}`, await system.getAllSettings(true)); + } + } + // currencies + data.currency = mainCurrency.value; + data.currencySymbol = symbol(mainCurrency.value); + + // timezone + data.timezone = timezone; + + // lang + data.lang = general.lang; + + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const generalOwners = variables.get('services.twitch.generalOwners') as string[]; + + data.isCastersSet = filter(generalOwners, (o) => isString(o) && o.trim().length > 0).length > 0 || broadcasterUsername !== ''; + } else { + for (const dir of ['systems', 'games']) { + for (const system of list(dir as any)) { + set(data, `${dir}.${system.__moduleName__}`, await system.getAllSettings(true)); + } + } + + // currencies + data.currency = mainCurrency.value; + data.currencySymbol = symbol(mainCurrency.value); + + // timezone + data.timezone = timezone; + + // lang + data.lang = general.lang; + + } + res.send(JSON.stringify(data)); + }); + + publicEndpoint('/core/ui', 'configuration', async (cb) => { + try { + const data: any = {}; + + for (const dir of ['systems', 'games']) { + for (const system of list(dir as any)) { + set(data, `${dir}.${system.__moduleName__}`, await system.getAllSettings(true)); + } + } + + // currencies + data.currency = mainCurrency.value; + data.currencySymbol = symbol(mainCurrency.value); + + // timezone + data.timezone = timezone; + + // lang + data.lang = general.lang; + + cb(null, data); + } catch (e: any) { + cb(e.stack); + } + }); + } +} + +export default new UI(); diff --git a/backend/src/users.ts b/backend/src/users.ts new file mode 100644 index 000000000..adde05bd4 --- /dev/null +++ b/backend/src/users.ts @@ -0,0 +1,499 @@ +import { setTimeout } from 'timers'; + +import { HOUR } from '@sogebot/ui-helpers/constants.js'; +import { + Brackets, IsNull, +} from 'typeorm'; + +import { defaultPermissions } from './helpers/permissions/defaultPermissions.js'; +import { getUserHighestPermission } from './helpers/permissions/getUserHighestPermission.js'; +import getNameById from './helpers/user/getNameById.js'; + +import Core from '~/_interface.js'; +import { Permissions } from '~/database/entity/permissions.js'; +import { + User, UserBit, UserInterface, UserTip, +} from '~/database/entity/user.js'; +import { AppDataSource } from '~/database.js'; +import { onStartup } from '~/decorators/on.js'; +import { isStreamOnline, stats } from '~/helpers/api/index.js'; +import exchange from '~/helpers/currency/exchange.js'; +import { mainCurrency } from '~/helpers/currency/index.js'; +import rates from '~/helpers/currency/rates.js'; +import { isDebugEnabled } from '~/helpers/debug.js'; +import { + debug, error, +} from '~/helpers/log.js'; +import { adminEndpoint, viewerEndpoint } from '~/helpers/socket.js'; +import * as changelog from '~/helpers/user/changelog.js'; +import { getIdFromTwitch } from '~/services/twitch/calls/getIdFromTwitch.js'; + +class Users extends Core { + constructor () { + super(); + this.addMenu({ + category: 'manage', name: 'viewers', id: 'manage/viewers', this: null, + }); + } + + @onStartup() + startup() { + this.updateWatchTime(true); + this.checkDuplicateUsernames(); + } + + async checkDuplicateUsernames() { + let query; + await changelog.flush(); + + await AppDataSource.getRepository(User).delete({ + userName: '__AnonymousUser__', + }); + + if (AppDataSource.options.type === 'postgres') { + query = AppDataSource.getRepository(User).createQueryBuilder('user') + .select('COUNT(*)') + .addSelect('"user"."userName"') + .groupBy('"user"."userName"') + .having('COUNT(*) > 1'); + } else { + query = AppDataSource.getRepository(User).createQueryBuilder('user') + .select('COUNT(*)', 'count') + .addSelect('user.userName') + .groupBy('user.userName') + .having('count > 1'); + } + const viewers = await query.getRawMany(); + if (viewers.length > 0) { + debug('users', `Duplicate usernames: ${viewers.map(o => o.user_userName).join(', ')}`); + } else { + debug('users', `No duplicated usernames found.`); + } + await Promise.all(viewers.map(async (duplicate) => { + const userName = duplicate.user_userName; + const duplicates = await AppDataSource.getRepository(User).find({ where: { userName } }); + await Promise.all(duplicates.map(async (user) => { + try { + const twitch = ( await import('./services/twitch.js')).default; + const getUserById = await twitch.apiClient?.asIntent(['bot'], ctx => ctx.users.getUserById(user.userId)); + if (!getUserById) { + throw new Error('unknown'); + } + if (getUserById.name !== userName) { + changelog.update(user.userId, { userName: getUserById.name }); + debug('users', `Duplicate username ${user.userName}#${user.userId} changed to ${getUserById.name}#${user.userId}`); + } + } catch (e) { + if (e instanceof Error) { + if (e.message.includes('not found in auth provider')) { + return; // do duplication check next time + } + } + // remove users not in Twitch anymore + debug('users', `Duplicate username ${user.userName}#${user.userId} not found on Twitch => '__inactive__${user.userName}#${user.userId}`); + changelog.update(user.userId, { userName: '__inactive__' + user.userName }); + } + })); + })); + setTimeout(() => this.checkDuplicateUsernames(), HOUR); + } + + async getChatOf (id: string, online: boolean): Promise { + const user = await changelog.get(id); + let chat = 0; + + if (user) { + if (online) { + chat = user.chatTimeOnline; + } else { + chat = user.chatTimeOffline; + } + + return Number(chat) <= Number.MAX_SAFE_INTEGER + ? chat + : Number.MAX_SAFE_INTEGER; + } else { + return 0; + } + } + + async updateWatchTime (isInit = false) { + const interval = 30000; + try { + if (isInit) { + // set all users offline on start + debug('tmi.watched', `Setting all users as offline.`); + await changelog.flush(); + await AppDataSource.getRepository(User).update({}, { isOnline: false }); + } else { + // get new users + await changelog.flush(); + const newChatters = await AppDataSource.getRepository(User).find({ where: { isOnline: true, watchedTime: 0 } }); + debug('tmi.watched', `Adding ${newChatters.length} users as new chatters.`); + stats.value.newChatters = stats.value.newChatters + newChatters.length; + + if (isStreamOnline.value) { + debug('tmi.watched', `Incrementing watchedTime by ${interval}`); + await changelog.flush(); + const incrementedUsers = await AppDataSource.getRepository(User).increment({ isOnline: true }, 'watchedTime', interval); + // chatTimeOnline + chatTimeOffline is solely use for points distribution + debug('tmi.watched', `Incrementing chatTimeOnline by ${interval}`); + await changelog.flush(); + await AppDataSource.getRepository(User).increment({ isOnline: true }, 'chatTimeOnline', interval); + + if (typeof incrementedUsers.affected === 'undefined') { + await changelog.flush(); + const users = await AppDataSource.getRepository(User).find({ where: { isOnline: true } }); + if (isDebugEnabled('tmi.watched')) { + for (const user of users) { + debug('tmi.watched', `User ${user.userName}#${user.userId} added watched time ${interval}`); + } + } + stats.value.currentWatchedTime = stats.value.currentWatchedTime + users.length * interval; + } else { + stats.value.currentWatchedTime = stats.value.currentWatchedTime + incrementedUsers.affected * interval; + } + } else { + debug('tmi.watched', `Incrementing chatTimeOffline users by ${interval}`); + await changelog.flush(); + await AppDataSource.getRepository(User).increment({ isOnline: true }, 'chatTimeOffline', interval); + } + } + } catch (e: any) { + error(e.stack); + } finally { + setTimeout(() => this.updateWatchTime(), interval); + } + } + + async getWatchedOf (id: string): Promise { + const user = await changelog.get(id); + + if (user) { + return Number(user.watchedTime) <= Number.MAX_SAFE_INTEGER + ? user.watchedTime + : Number.MAX_SAFE_INTEGER; + } else { + return 0; + } + } + + async getMessagesOf (id: string): Promise { + const user = await changelog.get(id); + + if (user) { + return Number(user.messages) <= Number.MAX_SAFE_INTEGER + ? user.messages + : Number.MAX_SAFE_INTEGER; + } else { + return 0; + } + } + + async getUsernamesFromIds (IdsList: string[]) { + const uniqueWithUsername = (await Promise.all( + [...new Set(IdsList)] + .map(async (id) => { + const user = await changelog.get(id); + if (user) { + return { [id]: user.userName }; + } + return { [id]: 'n/a' }; + }), + )).reduce((prev, cur) => { + const entries = Object.entries(cur)[0]; + return { ...prev, [entries[0]]: entries[1] }; + }, {}); + + return new Map(Object.entries(uniqueWithUsername)); + } + + async getIdByName (userName: string) { + if (userName.startsWith('@')) { + userName = userName.substring(1); + } + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userName }); + if (!user) { + const userId = await getIdFromTwitch(userName); + changelog.update(userId, { userName }); + return userId; + } + return user.userId; + } + + async getUserByUserId(userId: string) { + await changelog.flush(); + return changelog.get(userId) as Promise>>; + } + + async getUserByUsername(userName: string) { + await changelog.flush(); + const userByUsername = await AppDataSource.getRepository(User).findOneBy({ userName }); + + if (userByUsername) { + return userByUsername; + } + + const userId = await this.getIdByName(userName); + await changelog.flush(); + return changelog.get(userId) as Promise>>; + } + + sockets () { + adminEndpoint('/core/users', 'viewers::resetPointsAll', async (cb) => { + await changelog.flush(); + await AppDataSource.getRepository(User).update({}, { points: 0 }); + if (cb) { + cb(null); + } + }); + adminEndpoint('/core/users', 'viewers::resetMessagesAll', async (cb) => { + await changelog.flush(); + await AppDataSource.getRepository(User).update({}, { messages: 0, pointsByMessageGivenAt: 0 }); + if (cb) { + cb(null); + } + }); + adminEndpoint('/core/users', 'viewers::resetWatchedTimeAll', async (cb) => { + await changelog.flush(); + await AppDataSource.getRepository(User).update({}, { watchedTime: 0 }); + if (cb) { + cb(null); + } + }); + adminEndpoint('/core/users', 'viewers::resetSubgiftsAll', async (cb) => { + await changelog.flush(); + await AppDataSource.getRepository(User).update({}, { giftedSubscribes: 0 }); + if (cb) { + cb(null); + } + }); + adminEndpoint('/core/users', 'viewers::resetBitsAll', async (cb) => { + await AppDataSource.getRepository(UserBit).clear(); + if (cb) { + cb(null); + } + }); + adminEndpoint('/core/users', 'viewers::resetTipsAll', async (cb) => { + await AppDataSource.getRepository(UserTip).clear(); + if (cb) { + cb(null); + } + }); + + adminEndpoint('/core/users', 'viewers::update', async ([userId, update], cb) => { + try { + if (typeof update.tips !== 'undefined') { + for (const tip of update.tips) { + if (typeof tip.exchangeRates === 'undefined') { + tip.exchangeRates = rates; + } + tip.sortAmount = exchange(Number(tip.amount), tip.currency, 'EUR'); + if (typeof tip.id === 'string') { + delete tip.id; // remove tip id as it is string (we are expecting number -> autoincrement) + } + await AppDataSource.getRepository(UserTip).save({ ...tip, userId }); + } + cb(null); + return; + } + + if (typeof update.bits !== 'undefined') { + for (const bit of update.bits) { + if (typeof bit.id === 'string') { + delete bit.id; // remove bit id as it is string (we are expecting number -> autoincrement) + } + await AppDataSource.getRepository(UserBit).save({ ...bit, userId }); + } + cb(null); + return; + } + + if (typeof update.messages !== 'undefined') { + update.pointsByMessageGivenAt = update.messages; + } + + changelog.update(userId, update); + // as cascade remove set ID as null, we need to get rid of tips/bits + await AppDataSource.getRepository(UserTip).delete({ userId: IsNull() }); + await AppDataSource.getRepository(UserBit).delete({ userId: IsNull() }); + cb(null); + } catch (e: any) { + cb(e.stack); + } + }); + adminEndpoint('/core/users', 'viewers::remove', async (userId, cb) => { + try { + await changelog.flush(); + await AppDataSource.getRepository(UserTip).delete({ userId }); + await AppDataSource.getRepository(UserBit).delete({ userId }); + await AppDataSource.getRepository(User).delete({ userId }); + cb(null); + } catch (e: any) { + error(e); + cb(e.stack); + } + }); + adminEndpoint('/core/users', 'getNameById', async (id, cb) => { + try { + cb(null, await getNameById(id)); + } catch (e: any) { + cb(e.stack, null); + } + }); + adminEndpoint('/core/users', 'find.viewers', async (opts, cb) => { + try { + opts.page = opts.page ?? 0; + opts.perPage = opts.perPage ?? 25; + if (opts.perPage === -1) { + opts.perPage = Number.MAX_SAFE_INTEGER; + } + + /* + SQL query: + select user.*, COALESCE(sumTips, 0) as sumTips, COALESCE(sumBits, 0) as sumBits + from user + left join (select userId, sum(sortAmount) as sumTips from user_tip group by userId) user_tip on user.userId = user_tip.userId + left join (select userId, sum(amount) as sumBits from user_bit group by userId) user_bit on user.userId = user_bit.userId + */ + await changelog.flush(); + let query; + if (AppDataSource.options.type === 'postgres') { + query = AppDataSource.getRepository(User).createQueryBuilder('user') + .orderBy(opts.order?.orderBy ?? 'user.userName' , opts.order?.sortOrder ?? 'ASC') + .select('COALESCE("sumTips", 0)', 'sumTips') + .addSelect('COALESCE("sumBits", 0)', 'sumBits') + .addSelect('"user".*') + .offset(opts.page * opts.perPage) + .limit(opts.perPage) + .leftJoin('(select "userId", sum("amount") as "sumBits" from "user_bit" group by "userId")', 'user_bit', '"user_bit"."userId" = "user"."userId"') + .leftJoin('(select "userId", sum("sortAmount") as "sumTips" from "user_tip" group by "userId")', 'user_tip', '"user_tip"."userId" = "user"."userId"'); + } else { + query = AppDataSource.getRepository(User).createQueryBuilder('user') + .orderBy(opts.order?.orderBy ?? 'user.userName' , opts.order?.sortOrder ?? 'ASC') + .select('JSON_EXTRACT(`user`.`extra`, \'$.levels.xp\')', 'levelXP') + .addSelect('COALESCE(sumTips, 0)', 'sumTips') + .addSelect('COALESCE(sumBits, 0)', 'sumBits') + .addSelect('user.*') + .offset(opts.page * opts.perPage) + .limit(opts.perPage) + .leftJoin('(select userId, sum(amount) as sumBits from user_bit group by userId)', 'user_bit', 'user_bit.userId = user.userId') + .leftJoin('(select userId, sum(sortAmount) as sumTips from user_tip group by userId)', 'user_tip', 'user_tip.userId = user.userId'); + } + + if (typeof opts.order !== 'undefined') { + if (opts.order.orderBy === 'level') { + if (AppDataSource.options.type === 'better-sqlite3') { + query.orderBy('LENGTH("levelXP")', opts.order.sortOrder); + query.addOrderBy('"levelXP"', opts.order.sortOrder); + } else if (AppDataSource.options.type === 'postgres') { + query.orderBy('length(COALESCE(JSON_EXTRACT_PATH("extra"::json, \'levels\'), \'{}\')::text)', opts.order.sortOrder); + query.addOrderBy('COALESCE(JSON_EXTRACT_PATH("extra"::json, \'levels\'), \'{}\')::text', opts.order.sortOrder); + } else { + query.orderBy('LENGTH(`levelXP`)', opts.order.sortOrder); + query.addOrderBy('`levelXP`', opts.order.sortOrder); + } + } else if (AppDataSource.options.type === 'postgres') { + opts.order.orderBy = opts.order.orderBy.split('.').map(o => `"${o}"`).join('.'); + query.orderBy(opts.order.orderBy, opts.order.sortOrder); + + } else { + query.orderBy({ [opts.order.orderBy]: opts.order.sortOrder }); + } + } + + if (typeof opts.filter !== 'undefined') { + for (const filter of opts.filter) { + query.andWhere(new Brackets(w => { + if (AppDataSource.options.type === 'postgres') { + if (filter.operation === 'contains') { + w.where(`CAST("user"."${filter.columnName}" AS TEXT) like :${filter.columnName}`, { [filter.columnName]: `%${filter.value}%` }); + } else if (filter.operation === 'equal') { + w.where(`"user"."${filter.columnName}" = :${filter.columnName}`, { [filter.columnName]: filter.value }); + } else if (filter.operation === 'notEqual') { + w.where(`"user"."${filter.columnName}" != :${filter.columnName}`, { [filter.columnName]: filter.value }); + } else if (filter.operation === 'greaterThanOrEqual') { + w.where(`"user"."${filter.columnName}" >= :${filter.columnName}`, { [filter.columnName]: filter.value }); + } else if (filter.operation === 'greaterThan') { + w.where(`"user"."${filter.columnName}" >= :${filter.columnName}`, { [filter.columnName]: filter.value }); + } else if (filter.operation === 'lessThanOrEqual') { + w.where(`"user"."${filter.columnName}" <= :${filter.columnName}`, { [filter.columnName]: filter.value }); + } else if (filter.operation === 'lessThan') { + w.where(`"user"."${filter.columnName}" <= :${filter.columnName}`, { [filter.columnName]: filter.value }); + } + } else { + if (filter.operation === 'contains') { + w.where(`CAST(\`user\`.\`${filter.columnName}\` AS CHAR) like :${filter.columnName}`, { [filter.columnName]: `%${filter.value}%` }); + } else if (filter.operation === 'equal') { + w.where(`\`user\`.\`${filter.columnName}\` = :${filter.columnName}`, { [filter.columnName]: filter.value }); + } else if (filter.operation === 'notEqual') { + w.where(`\`user\`.\`${filter.columnName}\` != :${filter.columnName}`, { [filter.columnName]: filter.value }); + } else if (filter.operation === 'greaterThanOrEqual') { + w.where(`\`user\`.\`${filter.columnName}\` >= :${filter.columnName}`, { [filter.columnName]: filter.value }); + } else if (filter.operation === 'greaterThan') { + w.where(`\`user\`.\`${filter.columnName}\` > :${filter.columnName}`, { [filter.columnName]: filter.value }); + } else if (filter.operation === 'lessThanOrEqual') { + w.where(`\`user\`.\`${filter.columnName}\` <= :${filter.columnName}`, { [filter.columnName]: filter.value }); + } else if (filter.operation === 'lessThan') { + w.where(`\`user\`.\`${filter.columnName}\` < :${filter.columnName}`, { [filter.columnName]: filter.value }); + } + } + })); + } + } + + const viewers = await query.getRawMany(); + + const levels = (await import('~/systems/levels.js')).default; + for (const viewer of viewers) { + // add level to user + viewer.extra = JSON.parse(viewer.extra); + viewer.level = levels.getLevelOf(viewer); + } + let count = await query.getCount(); + + if (opts.exactUsernameFromTwitch && opts.search) { + // we need to check if viewers have already opts.search in list (we don't need to fetch twitch data) + if (!viewers.find(o => o.userName === opts.search)) { + try { + const userId = await getIdFromTwitch(opts.search); + viewers.unshift({ userId, userName: opts.search }); + count++; + } catch (e: any) { + // we don't care if user is not found + } + } + } + + cb(null, viewers, count, opts.state); + } catch (e: any) { + cb(e.stack, [], 0, null); + } + }); + viewerEndpoint('/core/users', 'viewers::findOneBy', async (userId, cb) => { + try { + const viewer = await changelog.get(userId); + const tips = await AppDataSource.getRepository(UserTip).find({ where: { userId } }); + const bits = await AppDataSource.getRepository(UserBit).find({ where: { userId } }); + + if (viewer) { + const aggregatedTips = tips.map((o) => exchange(o.amount, o.currency, mainCurrency.value)).reduce((a, b) => a + b, 0); + const aggregatedBits = bits.map((o) => Number(o.amount)).reduce((a, b) => a + b, 0); + + const permId = await getUserHighestPermission(userId); + const permissionGroup = await Permissions.findOneByOrFail({ id: permId || defaultPermissions.VIEWERS }); + cb(null, { + ...viewer, aggregatedBits, aggregatedTips, permission: permissionGroup, tips, bits, + }); + } else { + cb(null); + } + } catch (e: any) { + cb(e.stack); + } + }); + } +} + +export default new Users(); diff --git a/backend/src/watchers.ts b/backend/src/watchers.ts new file mode 100644 index 000000000..aee90bf1a --- /dev/null +++ b/backend/src/watchers.ts @@ -0,0 +1,123 @@ +import { + cloneDeep, get, isEqual, set, +} from 'lodash-es'; + +import { Settings } from '~/database/entity/settings.js'; +import { AppDataSource } from '~/database.js'; +import { getFunctionList } from '~/decorators/on.js'; +import { isDbConnected } from '~/helpers/database.js'; +import emitter from '~/helpers/interfaceEmitter.js'; +import { debug, error } from '~/helpers/log.js'; +import { logAvgTime } from '~/helpers/profiler.js'; +import { find } from '~/helpers/register.js'; + +export const variables = new Map(); +export const readonly = new Map(); +let checkInProgress = false; + +export const check = async (forceCheck = false) => { + debug('watcher', `watcher::start ${forceCheck ? '(forced)': ''}`); + if (checkInProgress) { + await new Promise((resolve) => { + const awaiter = () => { + if (!checkInProgress) { + resolve(true); + } else { + setImmediate(() => { + awaiter(); + }); + } + }; + awaiter(); + }); + } + if (isDbConnected && !checkInProgress) { + try { + checkInProgress = true; + debug('watcher', 'watcher::check'); + const time = process.hrtime(); + await VariableWatcher.check(); + logAvgTime('VariableWatcher.check()', process.hrtime(time)); + debug('watcher', `watcher::check Finished after ${process.hrtime(time)[0]}s ${process.hrtime(time)[1] / 1000000}ms`); + } catch (e: any) { + error(e.stack); + } finally { + checkInProgress = false; + } + } else { + debug('watcher', `watcher::skipped ${JSON.stringify({ isDbConnected, checkInProgress })}`); + } +}; + +export const startWatcher = () => { + check(); + setInterval(check, 100); +}; + +export const VariableWatcher = { + add(key: string, value: any, isReadOnly: boolean) { + if (isReadOnly) { + readonly.set(key, cloneDeep(value)); + } else { + variables.set(key, cloneDeep(value)); + } + }, + async check() { + for (const k of variables.keys()) { + const [ type, name, ...variableArr ] = k.split('.'); + let variable = variableArr.join('.'); + const checkedModule = find(type as any, name); + if (!checkedModule) { + throw new Error(`${type}.${name} not found in list`); + } + const value = cloneDeep(get(checkedModule, variable, undefined)); + if (typeof value === 'undefined') { + throw new Error('Value not found, check your code!!! ' + JSON.stringify({ + k, variable, value, + })); + } + if (!isEqual(value, variables.get(k))) { + const oldValue = variables.get(k); + variables.set(k, value); + const savedSetting = await AppDataSource.getRepository(Settings).findOneBy({ + name: variable, + namespace: checkedModule.nsp, + }); + await AppDataSource.getRepository(Settings).save({ + ...savedSetting, + name: variable, + namespace: checkedModule.nsp, + value: JSON.stringify(value), + }); + + if (variable.includes('__permission_based__')) { + variable = variable.replace('__permission_based__', ''); + } + debug('watcher', `watcher::change *** ${type}.${name}.${variable} changed from ${JSON.stringify(oldValue)} to ${JSON.stringify(value)}`); + const events = getFunctionList('change', type === 'core' ? `${name}.${variable}` : `${type}.${name}.${variable}`); + for (const event of events) { + emitter.emit('change', `${type}.${name}.${variable}`, cloneDeep(value)); + if (typeof (checkedModule as any)[event.fName] === 'function') { + (checkedModule as any)[event.fName](variable, cloneDeep(value)); + } else { + error(`${event.fName}() is not function in ${checkedModule._name}/${checkedModule.__moduleName__.toLowerCase()}`); + } + } + } + } + for (const k of readonly.keys()) { + const [ type, name, ...variableArr ] = k.split('.'); + const variable = variableArr.join('.'); + const checkedModule = find(type as any, name); + if (!checkedModule) { + throw new Error(`${type}.${name} not found in list`); + } + const value = cloneDeep(get(checkedModule, variable, undefined)); + + if (!isEqual(value, readonly.get(k))) { + error(`Cannot change read-only variable, forcing initial value for ${type}.${name}.${variable}`); + set(checkedModule, variable, readonly.get(k)); + } + } + }, +}; \ No newline at end of file diff --git a/backend/src/widgets/_interface.ts b/backend/src/widgets/_interface.ts new file mode 100644 index 000000000..1684d0398 --- /dev/null +++ b/backend/src/widgets/_interface.ts @@ -0,0 +1,9 @@ +import Module from '../_interface.js'; + +class Widget extends Module { + constructor() { + super('widgets', true); + } +} + +export default Widget; diff --git a/backend/src/widgets/chat.ts b/backend/src/widgets/chat.ts new file mode 100644 index 000000000..e8ec705e9 --- /dev/null +++ b/backend/src/widgets/chat.ts @@ -0,0 +1,87 @@ +import axios from 'axios'; + +import Widget from './_interface.js'; +import { timer } from '../decorators.js'; +import { badgesCache } from '../services/twitch/calls/getChannelChatBadges.js'; + +import { onMessage } from '~/decorators/on.js'; +import { getUserSender } from '~/helpers/commons/index.js'; +import { sendMessage } from '~/helpers/commons/sendMessage.js'; +import { ioServer } from '~/helpers/panel.js'; +import { parseTextWithEmotes } from '~/helpers/parseTextWithEmotes.js'; +import { adminEndpoint, publicEndpoint } from '~/helpers/socket.js'; +import { getIgnoreList } from '~/helpers/user/isIgnored.js'; +import { variables } from '~/watchers.js'; + +class Chat extends Widget { + @timer() + async withEmotes (text: string | undefined) { + return parseTextWithEmotes(text); + } + + @onMessage() + message(message: onEventMessage) { + this.withEmotes(message.message).then(data => { + if (!message.sender) { + return; + } + const badgeImages: {url: string, title: string }[] = []; + for (const messageBadgeId of message.sender.badges.keys()) { + const badge = badgesCache.find(o => o.id === messageBadgeId); + if (badge) { + const badgeImage = badge.getVersion(message.sender.badges.get(messageBadgeId) as string)?.getImageUrl(1); + if (badgeImage) { + let title = ''; + + const badgeInfo = message.sender.badgeInfo.get(badge.id); + if (badge.id === 'subscriber') { + title = `${badgeInfo}-Month Subscriber`; + } else if (badge.id === 'broadcaster') { + title = 'Broadcaster'; + } else if (badgeInfo) { + title = `${badgeInfo}`; + } + badgeImages.push({ url: badgeImage, title }); + } + } + } + ioServer?.of('/widgets/chat').emit('message', { + timestamp: message.timestamp, + username: message.sender.displayName.toLowerCase() === message.sender.userName ? message.sender.displayName : `${message.sender.displayName} (${message.sender.userName})`, + message: data, + badges: badgeImages, + }); + }); + } + + public sockets() { + adminEndpoint('/widgets/chat', 'chat.message.send', async (message) => { + const botUsername = variables.get('services.twitch.botUsername') as string; + const botId = variables.get('services.twitch.botId') as string; + sendMessage(message, getUserSender(botId, botUsername), { force: true }); + }); + + publicEndpoint('/widgets/chat', 'room', async (cb: (error: null, data: string) => void) => { + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + cb(null, broadcasterUsername.toLowerCase()); + }); + + adminEndpoint('/widgets/chat', 'viewers', async (cb) => { + try { + const broadcasterUsername = variables.get('services.twitch.broadcasterUsername') as string; + const url = `https://tmi.twitch.tv/group/user/${broadcasterUsername.toLowerCase()}/chatters`; + const response = await axios.get<{chatters: { viewers: string[] }}>(url); + + if (response.status === 200) { + const chatters = response.data.chatters; + chatters.viewers = chatters.viewers.filter((o) => !getIgnoreList().includes(o)); + cb(null, { chatters }); + } + } catch (e: any) { + cb(e.message, { chatters: [] }); + } + }); + } +} + +export default new Chat(); diff --git a/backend/src/widgets/custom.ts b/backend/src/widgets/custom.ts new file mode 100644 index 000000000..9a13329a5 --- /dev/null +++ b/backend/src/widgets/custom.ts @@ -0,0 +1,26 @@ +import { AppDataSource } from '~/database.js'; + +import { WidgetCustom } from '../database/entity/widget.js'; +import Widget from './_interface.js'; + +import { adminEndpoint } from '~/helpers/socket.js'; + +class Custom extends Widget { + public sockets() { + adminEndpoint('/widgets/custom', 'generic::getAll', async (userId, cb) => { + cb(null, await AppDataSource.getRepository(WidgetCustom).find({ + where: { userId }, + order: { name: 'DESC' }, + })); + }); + adminEndpoint('/widgets/custom', 'generic::save', async (item, cb) => { + cb(null, await AppDataSource.getRepository(WidgetCustom).save(item)); + }); + adminEndpoint('/widgets/custom', 'generic::deleteById', async (id, cb) => { + await AppDataSource.getRepository(WidgetCustom).delete({ id }); + cb(null); + }); + } +} + +export default new Custom(); diff --git a/backend/src/widgets/customvariables.ts b/backend/src/widgets/customvariables.ts new file mode 100644 index 000000000..1bebf7274 --- /dev/null +++ b/backend/src/widgets/customvariables.ts @@ -0,0 +1,64 @@ +import { + Variable, VariableWatch, +} from '@entity/variable.js'; + +import Widget from './_interface.js'; + +import { AppDataSource } from '~/database.js'; +import { isVariableSetById, setValueOf } from '~/helpers/customvariables/index.js'; +import { eventEmitter } from '~/helpers/events/index.js'; +import { adminEndpoint } from '~/helpers/socket.js'; + +class CustomVariables extends Widget { + constructor() { + super(); + eventEmitter.on('CustomVariable:OnRefresh', () => { + this.emit('refresh'); + }); + } + + public sockets() { + adminEndpoint('/widgets/customvariables', 'watched::save', async (items, cb) => { + try { + await AppDataSource.getRepository(VariableWatch).delete({}); + const variables = await AppDataSource.getRepository(VariableWatch).save(items); + cb(null, variables); + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/widgets/customvariables', 'customvariables::list', async (cb) => { + try { + const variables = await AppDataSource.getRepository(Variable).find(); + cb(null, variables); + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/widgets/customvariables', 'list.watch', async (cb) => { + try { + const variables = await AppDataSource.getRepository(VariableWatch).find({ order: { order: 'ASC' } }); + cb(null, variables); + } catch (e: any) { + cb(e.stack, []); + } + }); + adminEndpoint('/widgets/customvariables', 'watched::setValue', async (opts, cb) => { + try { + const variable = await isVariableSetById(opts.id); + if (variable) { + await setValueOf(variable.variableName, opts.value, { readOnlyBypass: true }); + } + if (cb) { + cb(null); + } + } catch (e: any) { + if (cb) { + cb(e.stack); + } + } + }); + } +} + +export default new CustomVariables(); diff --git a/backend/src/widgets/eventlist.ts b/backend/src/widgets/eventlist.ts new file mode 100644 index 000000000..a6fda5bcd --- /dev/null +++ b/backend/src/widgets/eventlist.ts @@ -0,0 +1,206 @@ +import type { EmitData } from '@entity/alert.js'; +import { EventList as EventListDB } from '@entity/eventList.js'; +import { UserTip } from '@entity/user.js'; +import { SECOND } from '@sogebot/ui-helpers/constants.js'; +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; +import { Between } from 'typeorm'; + +import Widget from './_interface.js'; +import alerts from '../registries/alerts.js'; + +import { AppDataSource } from '~/database.js'; +import { error } from '~/helpers/log.js'; +import { adminEndpoint } from '~/helpers/socket.js'; +import getNameById from '~/helpers/user/getNameById.js'; +import { translate } from '~/translate.js'; + +class EventList extends Widget { + public sockets() { + adminEndpoint('/widgets/eventlist', 'eventlist::removeById', async (idList, cb) => { + const ids = Array.isArray(idList) ? [...idList] : [idList]; + for (const id of ids) { + await AppDataSource.getRepository(EventListDB).update(id, { isHidden: true }); + } + if (cb) { + cb(null); + } + }); + adminEndpoint('/widgets/eventlist', 'eventlist::get', async (count) => { + this.update(count); + }); + + adminEndpoint('/widgets/eventlist', 'skip', () => { + alerts.skip(); + }); + + adminEndpoint('/widgets/eventlist', 'cleanup', () => { + AppDataSource.getRepository(EventListDB).update({ isHidden: false }, { isHidden: true }); + }); + + adminEndpoint('/widgets/eventlist', 'eventlist::resend', async (id) => { + const event = await AppDataSource.getRepository(EventListDB).findOneBy({ id: String(id) }); + if (event) { + const values = JSON.parse(event.values_json); + switch(event.event) { + case 'follow': + case 'sub': + alerts.trigger({ + event: event.event, + name: await getNameById(event.userId), + amount: 0, + tier: String(values.tier) as EmitData['tier'], + currency: '', + monthsName: '', + message: '', + }); + break; + case 'raid': + alerts.trigger({ + event: event.event, + name: await getNameById(event.userId), + amount: Number(values.viewers), + tier: null, + currency: '', + monthsName: '', + message: '', + }); + break; + case 'resub': + alerts.trigger({ + event: event.event, + name: await getNameById(event.userId), + amount: Number(values.subCumulativeMonths), + tier: String(values.tier) as EmitData['tier'], + currency: '', + monthsName: getLocalizedName(values.subCumulativeMonths, translate('core.months')), + message: values.message, + }); + break; + case 'subgift': + alerts.trigger({ + event: event.event, + name: await getNameById(values.fromId), + tier: null, + recipient: await getNameById(event.userId), + amount: Number(values.months), + monthsName: getLocalizedName(Number(values.months), translate('core.months')), + currency: '', + message: '', + }); + break; + case 'cheer': + alerts.trigger({ + event: event.event, + name: await getNameById(event.userId), + amount: Number(values.bits), + tier: null, + currency: '', + monthsName: '', + message: values.message, + }); + break; + case 'tip': + alerts.trigger({ + event: event.event, + name: await getNameById(event.userId), + amount: Number(values.amount), + tier: null, + currency: values.currency, + monthsName: '', + message: values.message, + }); + break; + case 'rewardredeem': + alerts.trigger({ + event: event.event, + name: values.titleOfReward, + rewardId: values.rewardId, + amount: 0, + tier: null, + currency: '', + monthsName: '', + message: values.message, + recipient: await getNameById(event.userId), + }); + break; + default: + error(`event.event ${event.event} cannot be retriggered`); + } + + } else { + error(`Event ${id} not found.`); + } + }); + } + + public async askForGet() { + this.emit('askForGet'); + } + + public async update(count: number) { + try { + const events = await AppDataSource.getRepository(EventListDB).find({ + where: { isHidden: false }, + order: { timestamp: 'DESC' }, + take: count, + }); + // we need to change userId => username and from => from username for eventlist compatibility + const mapping = new Map() as Map; + const tipMapping = new Map() as Map; + for (const event of events) { + const values = JSON.parse(event.values_json); + if (values.fromId && values.fromId != '0') { + if (!mapping.has(values.fromId)) { + try { + mapping.set(values.fromId, await getNameById(values.fromId)); + } catch { + event.isHidden = true; // hide event if user is unknown + await AppDataSource.getRepository(EventListDB).save(event); + } + } + } + if (!event.userId.includes('__anonymous__')) { + if (!mapping.has(event.userId)) { + try { + mapping.set(event.userId, await getNameById(event.userId)); + } catch { + event.isHidden = true; // hide event if user is unknown + await AppDataSource.getRepository(EventListDB).save(event); + } + } + } else { + mapping.set(event.userId, event.userId.replace('#__anonymous__', '')); + } + // pair tips so we have sortAmount to use in eventlist filter + if (event.event === 'tip') { + // search in DB for corresponding tip, unfortunately pre 13.0.0 timestamp won't exactly match (we are adding 10 seconds up/down) + const tip = await AppDataSource.getRepository(UserTip).findOneBy({ + userId: event.userId, + tippedAt: Between(event.timestamp - (10 * SECOND), event.timestamp + (10 * SECOND)), + }); + tipMapping.set(event.id, tip?.sortAmount ?? 0); + } + } + this.emit('update', + events + .filter(o => !o.isHidden) // refilter as we might have new hidden events + .map(event => { + const values = JSON.parse(event.values_json); + if (values.fromId && values.fromId != '0') { + values.fromId = mapping.get(values.fromId); + } + return { + ...event, + username: mapping.get(event.userId), + sortAmount: tipMapping.get(event.id), + values_json: JSON.stringify(values), + }; + }), + ); + } catch (e: any) { + this.emit('update', []); + } + } +} + +export default new EventList(); diff --git a/backend/src/widgets/joinpart.ts b/backend/src/widgets/joinpart.ts new file mode 100644 index 000000000..1d8177926 --- /dev/null +++ b/backend/src/widgets/joinpart.ts @@ -0,0 +1,9 @@ +import Widget from './_interface.js'; + +class JoinPart extends Widget { + public send(event: { users: string[], type: 'join' | 'part' }) { + this.emit('joinpart', { users: event.users, type: event.type }); + } +} + +export default new JoinPart(); \ No newline at end of file diff --git a/backend/src/widgets/quickaction.ts b/backend/src/widgets/quickaction.ts new file mode 100644 index 000000000..eac19120e --- /dev/null +++ b/backend/src/widgets/quickaction.ts @@ -0,0 +1,74 @@ +import Widget from './_interface.js'; +import { parserReply } from '../commons.js'; +import { QuickAction as QuickActionEntity, QuickActions } from '../database/entity/dashboard.js'; +import { Randomizer } from '../database/entity/randomizer.js'; +import { getUserSender } from '../helpers/commons/index.js'; +import { setValueOf } from '../helpers/customvariables/index.js'; +import { info } from '../helpers/log.js'; + +import { AppDataSource } from '~/database.js'; +import { adminEndpoint } from '~/helpers/socket.js'; + +const trigger = async (item: QuickActions.Item, user: { userId: string, userName: string }, value?: string) => { + info(`Quick Action ${item.id} triggered by ${user.userName}#${user.userId}`); + switch (item.type) { + case 'randomizer': { + AppDataSource.getRepository(Randomizer).update({ id: item.options.randomizerId }, { isShown: Boolean(value) ?? false }); + break; + } + case 'command': { + const parser = new ((await import('../parser.js')).Parser)(); + const alias = (await import('../systems/alias.js')).default; + const customcommands = (await import('../systems/customcommands.js')).default; + + const responses = await parser.command(getUserSender(user.userId, user.userName), item.options.command, true); + for (let i = 0; i < responses.length; i++) { + await parserReply(responses[i].response, { sender: responses[i].sender, discord: responses[i].discord, attr: responses[i].attr, id: '' }); + } + + if (customcommands.enabled) { + await customcommands.run({ + sender: getUserSender(user.userId, user.userName), id: 'null', skip: true, quiet: false, message: item.options.command, parameters: item.options.command.trim().replace(/^(!\w+)/i, ''), parser: parser, isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, isParserOptions: true, + }); + } + if (alias.enabled) { + await alias.run({ + sender: getUserSender(user.userId, user.userName), id: 'null', skip: true, message: item.options.command, parameters: item.options.command.trim().replace(/^(!\w+)/i, ''), parser: parser, isAction: false, isHighlight: false, emotesOffsets: new Map(), isFirstTimeMessage: false, discord: undefined, isParserOptions: true, + }); + } + + break; + } + case 'customvariable': { + setValueOf(item.options.customvariable, value, {}); + break; + } + } +}; + +class QuickAction extends Widget { + public sockets() { + adminEndpoint('/widgets/quickaction', 'generic::deleteById', async (id, cb) => { + try { + const item = await AppDataSource.getRepository(QuickActionEntity).findOneByOrFail({ id }); + await AppDataSource.getRepository(QuickActionEntity).remove(item); + cb(null); + } catch (e) { + cb(e as Error); + } + }); + adminEndpoint('/widgets/quickaction', 'generic::save', async (item, cb) => { + cb(null, await AppDataSource.getRepository(QuickActionEntity).save(item)); + }); + adminEndpoint('/widgets/quickaction', 'generic::getAll', async (userId, cb) => { + const items = await AppDataSource.getRepository(QuickActionEntity).find({ where: { userId } }); + cb(null, items); + }); + adminEndpoint('/widgets/quickaction', 'trigger', async ({ user, id, value }) => { + const item = await AppDataSource.getRepository(QuickActionEntity).findOneByOrFail({ id, userId: user.userId }); + trigger(item, { userId: user.userId, userName: user.userName }, value); + }); + } +} + +export default new QuickAction(); diff --git a/backend/test/general.js b/backend/test/general.js new file mode 100644 index 000000000..2bb74f0ef --- /dev/null +++ b/backend/test/general.js @@ -0,0 +1,24 @@ +global.mocha = true; +import('./mocks.js'); +import('../dest/main.js'); +import('./mocks.js'); +import { VariableWatcher } from '../dest/watchers.js'; + +beforeEach(async () => { + await VariableWatcher.check(); +}); + +let url = 'http://sogebot.github.io/sogeBot/#'; +if ((process.env?.npm_package_version ?? 'x.y.z-SNAPSHOT').includes('SNAPSHOT')) { + url = 'http://sogebot.github.io/sogeBot/#/_master'; +} + +import * as db from './helpers/db.js'; +import * as message from './helpers/messages.js'; +import * as user from './helpers/user.js'; +import * as tmi from './helpers/tmi.js'; +import * as variable from './helpers/variable.js'; +import * as time from './helpers/time.js'; + + +export { db, message, user, time, variable, tmi, url }; \ No newline at end of file diff --git a/backend/test/helpers/db.js b/backend/test/helpers/db.js new file mode 100644 index 000000000..b3c9efee7 --- /dev/null +++ b/backend/test/helpers/db.js @@ -0,0 +1,100 @@ +import chalk from 'chalk'; + +// eslint-disable-next-line import/order +import { debug } from '../../dest/helpers/log.js'; +// eslint-disable-next-line import/order +import { waitMs } from './time.js'; + +import { Alias, AliasGroup } from '../../dest/database/entity/alias.js'; +import { Commands, CommandsCount, CommandsGroup } from '../../dest/database/entity/commands.js'; +import { Cooldown } from '../../dest/database/entity/cooldown.js'; +import { DiscordLink } from '../../dest/database/entity/discord.js'; +import { Duel } from '../../dest/database/entity/duel.js'; +import { Event, EventOperation } from '../../dest/database/entity/event.js'; +import { EventList } from '../../dest/database/entity/eventList.js'; +import { HeistUser } from '../../dest/database/entity/heist.js'; +import { Keyword, KeywordGroup } from '../../dest/database/entity/keyword.js'; +import { ModerationPermit } from '../../dest/database/entity/moderation.js'; +import { PermissionCommands } from '../../dest/database/entity/permissions.js'; +import { PointsChangelog } from '../../dest/database/entity/points.js'; +import { Price } from '../../dest/database/entity/price.js'; +import { Quotes } from '../../dest/database/entity/quotes.js'; +import { Raffle, RaffleParticipant } from '../../dest/database/entity/raffle.js'; +import { Rank } from '../../dest/database/entity/rank.js'; +import { Settings } from '../../dest/database/entity/settings.js'; +import { SongRequest } from '../../dest/database/entity/song.js'; +import { Timer, TimerResponse } from '../../dest/database/entity/timer.js'; +import { User, UserTip, UserBit } from '../../dest/database/entity/user.js'; +import { Variable } from '../../dest/database/entity/variable.js'; +import { getIsDbConnected, getIsBotStarted } from '../../dest/helpers/database.js'; +import emitter from '../../dest/helpers/interfaceEmitter.js'; +import translation from '../../dest/translate.js'; +import { AppDataSource } from '../../dest/database.js'; + +export const cleanup = async () => { + const waitForIt = async (resolve, reject) => { + if (!getIsBotStarted() || !translation.isLoaded || !getIsDbConnected()) { + debug('test', `Bot is not yet started, waiting 100ms, bot: ${getIsBotStarted()} | db: ${getIsDbConnected()} | translation: ${translation.isLoaded}`); + return setTimeout(() => waitForIt(resolve, reject), 100); + } else { + debug('test', `Bot is started`); + } + + const permissions = (await import('../../dest/permissions.js')).default; + const changelog = (await import('../../dest/helpers/user/changelog.js')); + + debug('test', chalk.bgRed('*** Cleaning up collections ***')); + await changelog.flush(); + await waitMs(1000); // wait little bit for transactions to be done + + const entities = [Settings, AliasGroup, CommandsGroup, KeywordGroup, HeistUser, EventList, PointsChangelog, SongRequest, RaffleParticipant, Rank, PermissionCommands, Event, EventOperation, Variable, Raffle, Duel, TimerResponse, Timer, UserTip, UserBit, User, ModerationPermit, Alias, Commands, CommandsCount, Quotes, Cooldown, Keyword, Price, DiscordLink]; + if (['postgres', 'mysql'].includes(AppDataSource.options.type)) { + const metadatas = []; + for (const entity of entities) { + metadatas.push(AppDataSource.getMetadata(entity)); + } + + await AppDataSource.transaction(async transactionalEntityManager => { + if (['mysql'].includes(AppDataSource.options.type)) { + await transactionalEntityManager.query('SET FOREIGN_KEY_CHECKS=0;'); + } + for (const metadata of metadatas) { + debug('test', chalk.bgRed(`*** Cleaning up ${metadata.tableName} ***`)); + + if (['mysql'].includes(AppDataSource.options.type)) { + await transactionalEntityManager.query(`DELETE FROM \`${metadata.tableName}\` WHERE 1=1`); + } else { + await transactionalEntityManager.query(`DELETE FROM "${metadata.tableName}" WHERE 1=1`); + } + } + if (['mysql'].includes(AppDataSource.options.type)) { + await transactionalEntityManager.query('SET FOREIGN_KEY_CHECKS=1;'); + } + }); + } else { + for (const entity of entities) { + await AppDataSource.getRepository(entity).clear(); + } + } + + debug('test', chalk.bgRed('*** Cleaned successfully ***')); + + await permissions.ensurePreservedPermissionsInDb(); // re-do core permissions + + // set owner as broadcaster + emitter.emit('set', '/services/twitch', 'broadcasterUsername', '__broadcaster__'); + emitter.emit('set', '/services/twitch', 'botUsername', '__bot__'); + emitter.emit('set', '/services/twitch', 'botId', '12345'); + emitter.emit('set', '/services/twitch', 'broadcasterId', '54321'); + emitter.emit('set', '/services/twitch', 'generalOwners', ['__broadcaster__', '__owner__']); + emitter.emit('set', '/services/twitch', 'ignorelist', []); + emitter.emit('set', '/services/twitch', 'sendAsReply', true); + + resolve(); + }; + + return new Promise((resolve, reject) => { + debug('test', chalk.bgRed('cleanup init')); + waitForIt(resolve, reject); + }); +} diff --git a/backend/test/helpers/messages.js b/backend/test/helpers/messages.js new file mode 100644 index 000000000..e0af3b70b --- /dev/null +++ b/backend/test/helpers/messages.js @@ -0,0 +1,289 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import assert from 'assert'; +import util from 'util'; + +import chalk from 'chalk'; +import _ from 'lodash'; +import sinon from 'sinon'; +import until from 'test-until'; + +let eventSpy; + +import * as log from '../../dest/helpers/log.js'; + +export const prepare = () => { + Promise.all([ + import('../../dest/helpers/events/emitter.js'), + import('../../dest/services/twitch/chat.js'), + ]).then((imports) => { + const eventEmitter = imports[0].eventEmitter; + const tmi = imports[1].default; + + log.debug('test', chalk.bgRed('*** Restoring all spies ***')); + + if (eventSpy) { + eventSpy.restore(); + } + eventSpy = sinon.spy(eventEmitter, 'emit'); + + tmi.client = { + bot: { + say: function () {}, + deleteMessage: function() {}, + color: function () {}, + timeout: function () {}, + on: function () {}, + connect: function () {}, + join: function () {}, + part: function () {}, + }, + broadcaster: { + say: function () {}, + deleteMessage: function() {}, + color: function () {}, + timeout: function () {}, + on: function () {}, + connect: function () {}, + join: function () {}, + part: function () {}, + }, + }; + + log.chatOut.reset(); + log.warning.reset(); + log.debug.reset(); + }) +} + export const debug = async (category, expected, waitMs = 5000) => { + await until(setError => { + if (Array.isArray(expected)) { + for (const ex of expected) { + if (log.debug.calledWith(category, ex)) { + return true; + } + } + } else { + if (log.debug.calledWith(category, expected)) { + return true; + } + } + return setError( + '\n+\t' + expected + + log.debug.args.filter(o => o.includes(category)).join('\n-\t'), + ); + }, waitMs); + } + export const isWarnedRaw = async (entry, user, opts) => { + opts = opts || {}; + await until(async setError => { + let expected = []; + if (_.isArray(opts)) { + for (let i = 0; i < opts.length; i++) { + expected.push(entry); + } + } else { + expected = [entry]; + } + try { + let isCorrectlyCalled = false; + for (const e of expected) { + if (log.warning.calledWith(e)) { + isCorrectlyCalled = true; + break; + } + } + assert(isCorrectlyCalled); + log.warning.reset(); + return true; + } catch (err) { + return setError( + '\nExpected message:\t"' + JSON.stringify(expected) + '"\nActual message:\t"' + (!_.isNil(log.warning.lastCall) ? log.warning.lastCall.args[0] : '') + '"'); + } + }, 5000); + } + export const isWarned = async (entry, user, opts) => { + const { prepare } = await import('../../dest/helpers/commons/prepare.js'); + user = _.cloneDeep(user); + opts = opts || {}; + await until(async setError => { + let expected = []; + if (_.isArray(opts)) { + for (const o of opts) { + o.sender = _.isNil(user.userName) ? '' : user.userName; + expected.push(prepare(entry, o)); + } + } else { + opts.sender = _.isNil(user.userName) ? '' : user.userName; + expected = [prepare(entry, opts)]; + } + try { + let isCorrectlyCalled = false; + for (const e of expected) { + if (log.warning.calledWith(e)) { + isCorrectlyCalled = true; + break; + } + } + assert(isCorrectlyCalled); + log.warning.reset(); + return true; + } catch (err) { + return setError( + '\nExpected message:\t"' + JSON.stringify(expected) + '"\nActual message:\t"' + (!_.isNil(log.warning.lastCall) ? log.warning.lastCall.args[0] : '') + '"'); + } + }, 5000); + } + export const isSent = util.deprecate(async function (entry, user, opts, wait) { + const { prepare } = await import('../../dest/helpers/commons/prepare.js'); + if (typeof user === 'string') { + user = { + userName: user, + }; + } + user = _.cloneDeep(user); + opts = opts || {}; + return until(async setError => { + const expected = []; + if (_.isArray(opts)) { + for (const o of opts) { + o.sender = _.isNil(user.userName) ? '' : user.userName; + if (_.isArray(entry)) { + for (const e of entry) { + expected.push(prepare(e, o)); + } + } else { + expected.push(prepare(entry, o)); + } + } + } else { + opts.sender = _.isNil(user.userName) ? '' : user.userName; + if (_.isArray(entry)) { + for (const e of entry) { + expected.push(prepare(e, opts)); + } + } else { + expected.push(prepare(entry, opts)); + } + } + try { + let isCorrectlyCalled = false; + for (let e of expected) { + if (e.includes('missing_translation')) { + setError('Missing translations! ' + e); + return false; + } + if (user.userName) { + e += ` [${user.userName}]`; + } + /* + console.dir(log.chatOut.args, { depth: null }) + console.log({ expected: e, user }) + */ + if (log.chatOut.calledWith(e)) { + isCorrectlyCalled = true; + break; + } + } + assert(isCorrectlyCalled); + return true; + } catch (err) { + return setError( + '\nExpected message:\t"' + expected + '"' + + '\nActual message:\t\t"' + log.chatOut.args.join('\n\t\t\t') + '"', + ); + } + }, wait || 5000); + }, 'We should not use isSent as it may cause false positive tests') + export const sentMessageContain = async (expected, wait) => { + if (!Array.isArray(expected)) { + expected = [expected]; + } + return until(setError => { + try { + let isOK = false; + for (const e of expected) { + if (isOK) { + break; + } + for (const args of log.chatOut.args) { + if (args[0].includes(e)) { + isOK = true; + break; + } + } + } + assert(isOK); + return true; + } catch (err) { + return setError( + '\nExpected message to contain:\t' + expected + + '\nActual message:\t\t' + log.chatOut.args.join('\n\t\t\t'), + ); + } + }, wait || 5000); + } + export const isSentRaw = async (expected, user, wait) => { + if (!Array.isArray(expected)) { + expected = [expected]; + } + if (typeof user === 'string') { + user = { + userName: user, + }; + } + if (!user) { + user = { userName: '__bot__' }; + } + user = _.cloneDeep(user); + return until(setError => { + try { + let isOK = false; + for (let e of expected) { + if (user.userName) { + e += ` [${user.userName}]`; + } + if (log.chatOut.calledWith(e)) { + isOK = true; + break; + } + } + assert(isOK); + return true; + } catch (err) { + return setError( + '\nExpected message:\t' + expected + ` [${user.userName}]` + + '\nActual message:\t\t' + log.chatOut.args.join('\n\t\t\t'), + ); + } + }, wait || 5000); + } + export const isNotSent = async (expected, user, wait) => { + if (typeof user === 'string') { + user = { + userName: user, + }; + } + user = _.cloneDeep(user); + const race = await Promise.race([ + isSent(expected, user, wait * 2), + new Promise((resolve) => { + setTimeout(() => resolve(false), wait); + }), + ]); + assert(!race, 'Message was unexpectedly sent ' + expected); + } + export const isNotSentRaw = async (expected, user, wait) => { + if (typeof user === 'string') { + user = { + userName: user, + }; + } + user = _.cloneDeep(user); + const race = await Promise.race([ + isSentRaw(expected, user, wait * 2), + new Promise((resolve) => { + setTimeout(() => resolve(false), wait); + }), + ]); + assert(!race, 'Message was unexpectedly sent ' + expected); + } \ No newline at end of file diff --git a/backend/test/helpers/time.js b/backend/test/helpers/time.js new file mode 100644 index 000000000..620e5bbc6 --- /dev/null +++ b/backend/test/helpers/time.js @@ -0,0 +1,3 @@ +export const waitMs = async (ms) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; diff --git a/backend/test/helpers/tmi.js b/backend/test/helpers/tmi.js new file mode 100644 index 000000000..148bc6410 --- /dev/null +++ b/backend/test/helpers/tmi.js @@ -0,0 +1,26 @@ + +import sinon from 'sinon'; +import _ from 'lodash-es'; + +let connected = false; + +export const waitForConnection = async () => { + await new Promise((resolve, reject) => { + if (!connected || _.isNil(global.client)) { + global.client.on('connected', function (address, port) { + connected = true; + + try { + sinon.stub(global.commons, 'sendMessage'); + sinon.stub(global.commons, 'timeout'); + sinon.stub(global.events, 'fire'); + } catch (e) { } + + resolve(true); + }); + setTimeout(() => reject(new Error('Not connected in specified time')), 20000); + } else { + resolve(true); + } + }); +}; diff --git a/backend/test/helpers/user.js b/backend/test/helpers/user.js new file mode 100644 index 000000000..737343f33 --- /dev/null +++ b/backend/test/helpers/user.js @@ -0,0 +1,98 @@ +import { AppDataSource } from '../../dest/database.js' +import { User } from '../../dest/database/entity/user.js' +import * as changelog from '../../dest/helpers/user/changelog.js' +import emitter from '../../dest/helpers/interfaceEmitter.js' + +export const viewer = { + points: 0, + userId: '1', + userName: '__viewer__', + badges: {}, + emotes: [], +}; + +export const viewer2 = { + points: 0, + userId: '3', + userName: '__viewer2__', + badges: {}, + emotes: [], +}; + +export const viewer3 = { + points: 0, + userId: '5', + userName: '__viewer3__', + badges: {}, + emotes: [], +}; + +export const viewer4 = { + points: 0, + userId: '50', + userName: '__viewer4__', + badges: {}, + emotes: [], +}; + +export const viewer5 = { + points: 0, + userId: '55', + userName: '__viewer5__', + badges: {}, + emotes: [], +}; + +export const viewer6 = { + points: 0, + userId: '56', + userName: '__viewer6__', + badges: {}, + emotes: [], +}; + +export const viewer7 = { + points: 0, + userId: '57', + userName: '__viewer7__', + badges: {}, + emotes: [], +}; + +export const owner = { + points: 0, + userId: '2', + userName: '__broadcaster__', + badges: {}, + emotes: [], +}; + +export const mod = { + points: 0, + userId: '4', + userName: '__mod__', + badges: {}, + emotes: [], + isModerator: true, + isMod: true, +}; + +export const prepare = async () => { + await changelog.flush(); + await AppDataSource.getRepository(User).save(viewer); + await AppDataSource.getRepository(User).save(viewer2); + await AppDataSource.getRepository(User).save(viewer3); + await AppDataSource.getRepository(User).save(viewer4); + await AppDataSource.getRepository(User).save(viewer5); + await AppDataSource.getRepository(User).save(viewer6); + await AppDataSource.getRepository(User).save(viewer7); + await AppDataSource.getRepository(User).save(owner); + await AppDataSource.getRepository(User).save(mod); + emitter.emit('set', '/services/twitch', 'broadcasterUsername', owner.userName); + emitter.emit('set', '/services/twitch', 'broadcasterId', owner.userId); + await new Promise((resolve => { + setTimeout(() =>{ + resolve(); + }, 1000); + })); +} diff --git a/backend/test/helpers/variable.js b/backend/test/helpers/variable.js new file mode 100644 index 000000000..d0d13c84c --- /dev/null +++ b/backend/test/helpers/variable.js @@ -0,0 +1,23 @@ +import assert from 'assert'; +import until from 'test-until'; +import _ from 'lodash'; +import util from 'util'; + +export const isEqual = async (variablePath, expected) => { + let isOk = false; + await until(setError => { + if (isOk) { + return true; + } + + const current = _.get(global, variablePath.replace('global.', '')); + try { + assert(_.isEqual(current, expected)); + isOk = true; + } catch (err) { + return setError( + '\nExpected value: "' + util.inspect(expected) + '"\nActual value: "' + util.inspect(current) + '"' + + '\n\nVariable: "global.' + variablePath); + } + }, 1000); +} diff --git a/backend/test/mocks.js b/backend/test/mocks.js new file mode 100644 index 000000000..71db4a49a --- /dev/null +++ b/backend/test/mocks.js @@ -0,0 +1,72 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { v4 } from 'uuid'; + +import { AppDataSource } from '../dest/database.js'; +import { User } from '../dest/database/entity/user.js'; +import * as users from './helpers/user.js'; + +const getLatest100FollowersMockData = []; +for (let i = 0; i < 100; i++) { + // we want to have each follower every minute + getLatest100FollowersMockData.push({ + 'userId': String(Math.floor(Math.random() * 10000000)), + 'userName': v4(), + 'followDate': null, // we are refreshing on each call + }); +} + +global.mockClient = (account) => { + return { + chat: { + getGlobalEmotes: () => ([]), + getChannelEmotes: () => ([]), + }, + users: { + getUserByName: async (userName) => { + console.log(`Mocking call users.getUserByName(${userName}) for ${account}`); + let id = String(Math.floor(Math.random() * 100000)); + + const user = await AppDataSource.getRepository(User).findOneBy({ userName }); + const mockUser = getLatest100FollowersMockData.find(o => o.userName === userName); + if (user) { + id = user.userId; + } else if (mockUser) { + id = mockUser.userId; + } else { + for (const key of Object.keys(users)) { + if (users[key].userName === userName) { + id = users[key].userId; + } + } + } + return { + id, + name: userName, + displayName: userName, + profilePictureUrl: '', + }; + }, + getFollows: () => { + console.log('Mocking call users.getFollows for ' + account); + // update follow time + for (let i = 0; i < 100; i++) { + getLatest100FollowersMockData[i].followDate = new Date(Date.now() - (i * 60000)); + } + return { + data: getLatest100FollowersMockData, + }; + }, + }, + clips: { + getClipById: () => { + console.log('Mocking call clips.getClipById for ' + account); + }, + }, + }; +}; + +global.mock = new MockAdapter(axios); +global.mock + .onGet('http://localhost/get?test=a\\nb').reply(200, { test: 'a\\nb' }) + .onAny().passThrough(); // pass through others \ No newline at end of file diff --git a/backend/test/tests/alias/#3680_alias_should_override_command_permission.js b/backend/test/tests/alias/#3680_alias_should_override_command_permission.js new file mode 100644 index 000000000..4bc4ba9c0 --- /dev/null +++ b/backend/test/tests/alias/#3680_alias_should_override_command_permission.js @@ -0,0 +1,34 @@ +import('../../general.js'); + +import assert from 'assert'; + +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { Parser } from '../../../dest/parser.js'; +import alias from '../../../dest/systems/alias.js'; +import { db, message, user } from '../../general.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Alias - @func1 - #3680 - alias should override command permission', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('create alias !test for command !queue open (caster only)', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !test -c !queue open' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!test', command: '!queue open' })); + }); + + it('call !queue open directly with regular viewer should send permission error', async () => { + const parse = new Parser({ sender: user.viewer, message: '!queue open', skip: false, quiet: false }); + const r = await parse.process(); + assert.strictEqual(r[0].response, 'You don\'t have enough permissions for \'!queue open\''); + }); + + it('call alias with regular viewer should process it correctly', async () => { + await alias.run({ sender: user.viewer, message: '!test' }); + await message.debug('alias.process', '!queue open'); + }); +}); diff --git a/backend/test/tests/alias/#3738_alias_should_trigger_commands_by_variable.js b/backend/test/tests/alias/#3738_alias_should_trigger_commands_by_variable.js new file mode 100644 index 000000000..96447206e --- /dev/null +++ b/backend/test/tests/alias/#3738_alias_should_trigger_commands_by_variable.js @@ -0,0 +1,44 @@ +/* global describe it */ +import assert from 'assert'; + +import('../../general.js'); +import { db, message, user } from '../../general.js'; +import alias from '../../../dest/systems/alias.js'; + +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; + +import { Variable } from '../../../dest/database/entity/variable.js'; +import { AppDataSource } from '../../../dest/database.js' + +describe('Alias - @func1 - #3738 - alias should trigger commands by variable', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('create variable $_alert', async () => { + const variable = Variable.create({ + variableName: '$_alert', + readOnly: false, + currentValue: '!queue open', + type: 'string', + responseType: 0, + permission: defaultPermissions.CASTERS, + evalValue: '', + usableOptions: [], + }) + await variable.save(); + }); + + it('create alias !test for command !queue open (caster only)', async () => { + const r = await alias.add({ sender: user.owner, parameters: '-a !test -c $_alert' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!test', command: '$_alert' })); + }); + + it('call alias with regular viewer should process it correctly', async () => { + await alias.run({ sender: user.viewer, message: '!test' }); + await message.debug('alias.process', '!queue open'); + await message.debug('parser.command', 'Running !queue open'); + }); +}); diff --git a/backend/test/tests/alias/#4860_group_filter_and_permissions.js b/backend/test/tests/alias/#4860_group_filter_and_permissions.js new file mode 100644 index 000000000..4ef1ae0d7 --- /dev/null +++ b/backend/test/tests/alias/#4860_group_filter_and_permissions.js @@ -0,0 +1,162 @@ +/* global */ +import assert from 'assert'; + +import('../../general.js'); +import { Alias, AliasGroup } from '../../../dest/database/entity/alias.js'; +import { AppDataSource } from '../../../dest/database.js' +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import alias from '../../../dest/systems/alias.js'; +import { db, message, user } from '../../general.js'; + +describe('Alias - @func1 - #4860 - alias group permissions and filter should be considered', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it('create filterGroup with filter | $game === "Dota 2"', async () => { + await AppDataSource.getRepository(AliasGroup).insert({ + name: 'filterGroup', + options: { + filter: '$game === "Dota 2"', + permission: null, + }, + }); + }); + + it('create permGroup with permission | CASTERS', async () => { + await AppDataSource.getRepository(AliasGroup).insert({ + name: 'permGroup', + options: { + filter: null, + permission: defaultPermissions.CASTERS, + }, + }); + }); + + it('create permGroup2 without permission', async () => { + await AppDataSource.getRepository(AliasGroup).insert({ + name: 'permGroup2', + options: { + filter: null, + permission: null, + }, + }); + }); + + it('create alias !testfilter with filterGroup', async () => { + await AppDataSource.getRepository(Alias).insert({ + id: '1a945d76-2d3c-4c7a-ae03-e0daf17142c5', + alias: '!testfilter', + command: '!me', + enabled: true, + visible: true, + permission: defaultPermissions.VIEWERS, + group: 'filterGroup', + }); + }); + + it('create alias !testpermnull with permGroup', async () => { + await AppDataSource.getRepository(Alias).insert({ + id: '2584b3c1-d2da-4fae-bf9a-95048724acde', + alias: '!testpermnull', + command: '!me', + enabled: true, + visible: true, + permission: null, + group: 'permGroup', + }); + }); + + it('create alias !testpermnull2 with permGroup2', async () => { + await AppDataSource.getRepository(Alias).insert({ + id: 'ed5f2925-ba73-4146-906b-3856d2583b6a', + alias: '!testpermnull2', + command: '!me', + enabled: true, + visible: true, + permission: null, + group: 'permGroup2', + }); + }); + + it('create alias !testpermmods with permGroup', async () => { + await AppDataSource.getRepository(Alias).insert({ + id: '2d33f59d-4900-454e-9d3a-22472ae1d3a7', + alias: '!testpermmods', + command: '!me', + enabled: true, + visible: true, + permission: defaultPermissions.MODERATORS, + group: 'permGroup', + }); + }); + + it('!testpermnull should be triggered by CASTER', async () => { + message.prepare(); + alias.run({ sender: user.owner, message: '!testpermnull' }); + await message.isSentRaw('@__broadcaster__ | Level 0 | 0 hours | 0 points | 0 messages | €0.00 | 0 bits | 0 months', user.owner); + }); + + it('!testpermnull should not be triggered by VIEWER', async () => { + message.prepare(); + alias.run({ sender: user.viewer, message: '!testpermnull' }); + await message.isNotSentRaw('@__viewer__ | Level 0 | 0 hours | 0 points | 0 messages | €0.00 | 0 bits | 0 months', user.viewer); + }); + + it('!testpermnull2 should be triggered by CASTER', async () => { + message.prepare(); + alias.run({ sender: user.owner, message: '!testpermnull2' }); + await message.isWarnedRaw('Alias !testpermnull2#ed5f2925-ba73-4146-906b-3856d2583b6a doesn\'t have any permission set, treating as CASTERS permission.'); + await message.isSentRaw('@__broadcaster__ | Level 0 | 0 hours | 0 points | 0 messages | €0.00 | 0 bits | 0 months', user.owner); + }); + + it('!testpermnull2 should not be triggered by VIEWER', async () => { + message.prepare(); + alias.run({ sender: user.viewer, message: '!testpermnull2' }); + await message.isWarnedRaw('Alias !testpermnull2#ed5f2925-ba73-4146-906b-3856d2583b6a doesn\'t have any permission set, treating as CASTERS permission.'); + await message.isNotSentRaw('@__viewer__ | Level 0 | 0 hours | 0 points | 0 messages | €0.00 | 0 bits | 0 months', user.viewer); + }); + + it('!testpermmods should be triggered by MOD', async () => { + message.prepare(); + alias.run({ sender: user.mod, message: '!testpermmods' }); + await message.isSentRaw('@__mod__ | Level 0 | 0 hours | 0 points | 0 messages | €0.00 | 0 bits | 0 months', user.mod); + }); + + it('!testpermmods should not be triggered by VIEWER', async () => { + message.prepare(); + alias.run({ sender: user.viewer, message: '!testpermmods' }); + await message.isNotSentRaw('@__viewer__ | Level 0 | 0 hours | 0 points | 0 messages | €0.00 | 0 bits | 0 months', user.viewer); + }); + + describe('Test incorrect filter', () => { + before(() => { + message.prepare(); + }); + it('set $game to Test', async () => { + const {stats} = await import('../../../dest/helpers/api/stats.js'); + stats.value.currentGame = 'Test'; + }); + it('!testfilter alias should not be triggered', async () => { + alias.run({ sender: user.owner, message: '!testfilter' }); + await message.isWarnedRaw('Alias !testfilter#1a945d76-2d3c-4c7a-ae03-e0daf17142c5 didn\'t pass group filter.'); + }); + }); + + describe('Test correct filter', () => { + before(() => { + message.prepare(); + }); + it('set $game to Dota 2', async() => { + const {stats} = await import('../../../dest/helpers/api/stats.js'); + stats.value.currentGame = 'Dota 2'; + }); + it('!testfilter alias should be triggered', async () => { + alias.run({ sender: user.owner, message: '!testfilter' }); + await message.isSentRaw('@__broadcaster__ | Level 0 | 0 hours | 0 points | 0 messages | €0.00 | 0 bits | 0 months', user.owner); + }); + }); +}); diff --git a/backend/test/tests/alias/add.js b/backend/test/tests/alias/add.js new file mode 100644 index 000000000..b894bf201 --- /dev/null +++ b/backend/test/tests/alias/add.js @@ -0,0 +1,74 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import alias from '../../../dest/systems/alias.js'; +import assert from 'assert'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +const failedTests = [ + { permission: null, alias: null, command: null }, + { permission: null, alias: '!alias', command: null }, + { permission: null, alias: '!alias', command: 'asd' }, + { permission: null, alias: 'alias', command: null }, + { permission: null, alias: 'alias', command: '!asd' }, + { permission: null, alias: 'alias', command: 'asd' }, + { permission: 'unknownpermission', alias: '!a', command: '!me' }, + { permission: '0efd7b1c-e460-4167-8e06-8aaf2c170319', alias: '!a', command: '!me' }, // unknown uuid +]; + +const successTests = [ + { permission: null, alias: '!a', command: '!me' }, + { permission: null, alias: '!한국어', command: '!me' }, + { permission: null, alias: '!русский', command: '!me' }, + { permission: null, alias: '!with link', command: '!me http://google.com' }, + { permission: null, alias: '!a with spaces', command: '!top messages' }, + { permission: defaultPermissions.VIEWERS, alias: '!a', command: '!me' }, + { permission: 'casters', alias: '!a', command: '!me' }, +]; + +function generateCommand(opts) { + const p = opts.permission ? '-p ' + opts.permission : ''; + const a = opts.alias ? '-a ' + opts.alias : ''; + const c = opts.command ? '-c ' + opts.command : ''; + return [p, a, c].join(' '); +} + +describe('Alias - @func1 - add()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + describe('Expected parsed fail', () => { + for (const t of failedTests) { + it(generateCommand(t), async () => { + const r = await alias.add({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r[0].response, prepare('alias.alias-parse-failed')); + }); + } + }); + + describe('Expected to pass', () => { + for (const t of successTests) { + it(generateCommand(t), async () => { + const r = await alias.add({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { ...t })); + }); + } + + it('2x - -a !a -c !me', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a -c !me' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a', command: '!me' })); + + const r2 = await alias.add({ sender: owner, parameters: '-a !a -c !me' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-added', { alias: '!a', command: '!me' })); + }); + }); +}); diff --git a/backend/test/tests/alias/discord#707718945515503748_alias_should_parse_response.js b/backend/test/tests/alias/discord#707718945515503748_alias_should_parse_response.js new file mode 100644 index 000000000..e2c015b03 --- /dev/null +++ b/backend/test/tests/alias/discord#707718945515503748_alias_should_parse_response.js @@ -0,0 +1,41 @@ +/* global describe it */ +import('../../general.js'); + +import { db, message, user } from '../../general.js'; +import alias from '../../../dest/systems/alias.js'; +import {streamStatusChangeSince} from '../../../dest/helpers/api/streamStatusChangeSince.js'; +import assert from 'assert'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Alias - @func1 - discord#707718945515503748 - alias should parse response', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('create alias !test for command !uptime', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !test -c !uptime' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!test', command: '!uptime' })); + }); + + it('alias should return correct offline message', async () => { + streamStatusChangeSince.value = Date.now(); + await alias.run({ sender: user.viewer, message: '!test' }); + await message.debug('sendMessage.message', [ + 'Stream is currently offline for 0s', + 'Stream is currently offline for 1s', + 'Stream is currently offline for 2s', + 'Stream is currently offline for 3s', + 'Stream is currently offline for 4s', + 'Stream is currently offline for 5s', + 'Stream is currently offline for 6s', + 'Stream is currently offline for 7s', + 'Stream is currently offline for 8s', + 'Stream is currently offline for 9s', + 'Stream is currently offline for 10s', + ]); + }); +}); diff --git a/backend/test/tests/alias/edit.js b/backend/test/tests/alias/edit.js new file mode 100644 index 000000000..4f58566e2 --- /dev/null +++ b/backend/test/tests/alias/edit.js @@ -0,0 +1,90 @@ +import { db, message } from '../../general.js'; + +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import alias from '../../../dest/systems/alias.js'; +import assert from 'assert'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +const parseFailedTests = [ + { permission: null, alias: null, command: null }, + { permission: null, alias: '!alias', command: null }, + { permission: null, alias: '!alias', command: 'uptime' }, +]; + +const notFoundTests = [ + { permission: null, alias: '!unknown', command: '!uptime' }, +]; + +const successTests = [ + { + from: { permission: defaultPermissions.VIEWERS, alias: '!a', command: '!me' }, + to: { permission: defaultPermissions.VIEWERS, alias: '!a', command: '!uptime' }, + }, + { + from: { permission: 'casters', alias: '!a', command: '!me' }, + to: { permission: defaultPermissions.VIEWERS, alias: '!a', command: '!uptime' }, + }, + { + from: { permission: defaultPermissions.VIEWERS, alias: '!a', command: '!me' }, + to: { permission: 'moderators', alias: '!a', command: '!uptime' }, + }, + { + from: { permission: defaultPermissions.VIEWERS, alias: '!한국어', command: '!me' }, + to: { permission: defaultPermissions.VIEWERS, alias: '!한국어', command: '!uptime' }, + }, + { + from: { permission: defaultPermissions.VIEWERS, alias: '!русский', command: '!me' }, + to: { permission: defaultPermissions.VIEWERS, alias: '!русский', command: '!uptime' }, + }, + { + from: { permission: defaultPermissions.VIEWERS, alias: '!a with spaces', command: '!me' }, + to: { permission: defaultPermissions.VIEWERS, alias: '!a with spaces', command: '!uptime' }, + }, +]; + +function generateCommand(opts) { + const p = opts.permission ? '-p ' + opts.permission : ''; + const a = opts.alias ? '-a ' + opts.alias : ''; + const c = opts.command ? '-c ' + opts.command : ''; + return [p, a, c].join(' '); +} + +describe('Alias - @func1 - edit()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + describe('Expected parsed fail', () => { + for (const t of parseFailedTests) { + it(generateCommand(t), async () => { + const r = await alias.edit({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r[0].response, prepare('alias.alias-parse-failed')); + }); + } + }); + + describe('Expected not found fail', () => { + for (const t of notFoundTests) { + it(generateCommand(t), async () => { + const r = await alias.edit({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-not-found', { alias: t.alias })); + }); + } + }); + + describe('Expected to pass', () => { + for (const t of successTests) { + it(generateCommand(t.from) + ' => ' + generateCommand(t.to), async () => { + const r = await alias.add({ sender: owner, parameters: generateCommand(t.from) }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: t.from.alias, command: t.from.command })); + + const r2 = await alias.edit({ sender: owner, parameters: generateCommand(t.to) }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-edited', { alias: t.from.alias, command: t.to.command })); + }); + } + }); +}); diff --git a/backend/test/tests/alias/list.js b/backend/test/tests/alias/list.js new file mode 100644 index 000000000..17778f4d7 --- /dev/null +++ b/backend/test/tests/alias/list.js @@ -0,0 +1,34 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import alias from '../../../dest/systems/alias.js'; +import assert from 'assert'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Alias - @func1 - list()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('empty list', async () => { + const r = await alias.list({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, prepare('alias.list-is-empty')); + }); + + it('populated list', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a -c !me' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a', command: '!me' })); + + const r2 = await alias.add({ sender: owner, parameters: '-a !b -c !me' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-added', { alias: '!b', command: '!me' })); + + const r3 = await alias.list({ sender: owner, parameters: '' }); + assert.strictEqual(r3[0].response, prepare('alias.list-is-not-empty', { list: '!a, !b' })); + }); +}); diff --git a/backend/test/tests/alias/remove.js b/backend/test/tests/alias/remove.js new file mode 100644 index 000000000..db22369fa --- /dev/null +++ b/backend/test/tests/alias/remove.js @@ -0,0 +1,76 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import alias from '../../../dest/systems/alias.js'; +import assert from 'assert'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Alias - @func1 - remove()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('', async () => { + const r = await alias.remove({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, prepare('alias.alias-parse-failed')); + }); + + it('!alias', async () => { + const r = await alias.remove({ sender: owner, parameters: '!alias' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-not-found', { alias: '!alias' })); + }); + + it('alias', async () => { + const r = await alias.remove({ sender: owner, parameters: 'alias' }); + assert.strictEqual(r[0].response, prepare('alias.alias-parse-failed')); + }); + + it('!a', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a -c !me' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a', command: '!me' })); + + const r2 = await alias.remove({ sender: owner, parameters: '!a' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!a' })); + }); + + it('!a with spaces', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a with spaces -c !me' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a with spaces', command: '!me' })); + + const r2 = await alias.remove({ sender: owner, parameters: '!a with spaces' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!a with spaces' })); + }); + + it('!한국어', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !한국어 -c !me' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!한국어', command: '!me' })); + + const r2 = await alias.remove({ sender: owner, parameters: '!한국어' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!한국어' })); + }); + + it('!русский', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !русский -c !me' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!русский', command: '!me' })); + + const r2 = await alias.remove({ sender: owner, parameters: '!русский' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!русский' })); + }); + + it('2x - !a !me', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a -c !me' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a', command: '!me' })); + + const r2 = await alias.remove({ sender: owner, parameters: '!a' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!a' })); + + const r3 = await alias.remove({ sender: owner, parameters: '!a' }); + assert.strictEqual(r3[0].response, prepare('alias.alias-was-not-found', { alias: '!a' })); + }); +}); diff --git a/backend/test/tests/alias/run.js b/backend/test/tests/alias/run.js new file mode 100644 index 000000000..d1915520f --- /dev/null +++ b/backend/test/tests/alias/run.js @@ -0,0 +1,126 @@ +/* global describe it beforeEach afterEach */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import alias from '../../../dest/systems/alias.js'; +import duel from '../../../dest/games/duel.js' +import gamble from '../../../dest/games/gamble.js' +import customCommands from '../../../dest/systems/customcommands.js' + +// users +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; +const user = { userName: 'user', userId: String(Math.floor(Math.random() * 100000)) }; + +describe('Alias - @func1 - run()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + await AppDataSource.getRepository(User).save({ userName: user.userName, userId: user.userId }); + + duel.enabled = true; + gamble.enabled = true; + }); + + afterEach(() => { + duel.enabled = false; + gamble.enabled = false; + }); + + it('!a should show correctly command with link (skip is true)', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a -c !test http://google.com' }); + const r2 = await customCommands.add({ sender: owner, parameters: '-c !test -r $param' }); + + alias.run({ sender: user, message: '!a' }); + await message.debug('alias.process', '!test http://google.com'); + + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a', command: '!test http://google.com' })); + assert.strictEqual(r2[0].response, prepare('customcmds.command-was-added', { response: '$param', command: '!test' })); + }); + + it('!a will show !duel', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a -c !duel' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a', command: '!duel' })); + + alias.run({ sender: owner, message: '!a' }); + await message.debug('sendMessage.message', '@__broadcaster__, you need to specify points to dueling'); + + const r2 = await alias.remove({ sender: owner, parameters: '!a' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!a' })); + + assert(await alias.run({ sender: owner, message: '!a' })); + }); + + it('#668 - alias is case insensitive', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a -c !duel' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a', command: '!duel' })); + + alias.run({ sender: owner, message: '!A' }); + await message.debug('sendMessage.message', '@__broadcaster__, you need to specify points to dueling'); + + const r2 = await alias.remove({ sender: owner, parameters: '!a' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!a' })); + + assert(await alias.run({ sender: owner, message: '!a' })); + }); + + it('!a with spaces - will show !duel', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a with spaces -c !duel' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a with spaces', command: '!duel' })); + + alias.run({ sender: owner, message: '!a with spaces' }); + await message.debug('sendMessage.message', '@__broadcaster__, you need to specify points to dueling'); + + const r2 = await alias.remove({ sender: owner, parameters: '!a with spaces' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!a with spaces' })); + + assert(await alias.run({ sender: owner, message: '!a with spaces' })); + }); + + it('!한국어 - will show !duel', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !한국어 -c !duel' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!한국어', command: '!duel' })); + + alias.run({ sender: owner, message: '!한국어' }); + await message.debug('sendMessage.message', '@__broadcaster__, you need to specify points to dueling'); + + const r2 = await alias.remove({ sender: owner, parameters: '!한국어' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!한국어' })); + + assert(await alias.run({ sender: owner, message: '!한국어' })); + }); + + it('!русский - will show !duel', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !русский -c !duel' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!русский', command: '!duel' })); + + alias.run({ sender: owner, message: '!русский' }); + await message.debug('sendMessage.message', '@__broadcaster__, you need to specify points to dueling'); + + const r2 = await alias.remove({ sender: owner, parameters: '!русский' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!русский' })); + + assert(await alias.run({ sender: owner, message: '!русский' })); + }); + + it('!крутить 1000 - will show !gamble 1000', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !крутить -c !gamble' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!крутить', command: '!gamble' })); + + alias.run({ sender: owner, message: '!крутить 1000' }); + await message.debug('sendMessage.message', '@__broadcaster__, you don\'t have 1000 points to gamble'); + + const r2 = await alias.remove({ sender: owner, parameters: '!крутить' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-removed', { alias: '!крутить' })); + + assert(await alias.run({ sender: owner, message: '!крутить' })); + }); +}); diff --git a/backend/test/tests/alias/toggle.js b/backend/test/tests/alias/toggle.js new file mode 100644 index 000000000..b4bfe6d52 --- /dev/null +++ b/backend/test/tests/alias/toggle.js @@ -0,0 +1,72 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import alias from '../../../dest/systems/alias.js'; +import assert from 'assert'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Alias - @func1 - toggle()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('', async () => { + const r = await alias.toggle({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, prepare('alias.alias-parse-failed')); + }); + + it('!unknown', async () => { + const r = await alias.toggle({ sender: owner, parameters: '!unknown' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-not-found', { alias: '!unknown' })); + }); + + it('!a', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a -c !uptime' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a', command: '!uptime' })); + + const r2 = await alias.toggle({ sender: owner, parameters: '!a' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-disabled', { alias: '!a' })); + + const r3 = await alias.toggle({ sender: owner, parameters: '!a' }); + assert.strictEqual(r3[0].response, prepare('alias.alias-was-enabled', { alias: '!a' })); + }); + + it('!a with spaces', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a with spaces -c !uptime' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a with spaces', command: '!uptime' })); + + const r2 = await alias.toggle({ sender: owner, parameters: '!a with spaces' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-disabled', { alias: '!a with spaces' })); + + const r3 = await alias.toggle({ sender: owner, parameters: '!a with spaces' }); + assert.strictEqual(r3[0].response, prepare('alias.alias-was-enabled', { alias: '!a with spaces' })); + }); + + it('!한국어', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !한국어 -c !uptime' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!한국어', command: '!uptime' })); + + const r2 = await alias.toggle({ sender: owner, parameters: '!한국어' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-disabled', { alias: '!한국어' })); + + const r3 = await alias.toggle({ sender: owner, parameters: '!한국어' }); + assert.strictEqual(r3[0].response, prepare('alias.alias-was-enabled', { alias: '!한국어' })); + }); + + it('!русский', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !русский -c !uptime' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!русский', command: '!uptime' })); + + const r2 = await alias.toggle({ sender: owner, parameters: '!русский' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-disabled', { alias: '!русский' })); + + const r3 = await alias.toggle({ sender: owner, parameters: '!русский' }); + assert.strictEqual(r3[0].response, prepare('alias.alias-was-enabled', { alias: '!русский' })); + }); +}); diff --git a/backend/test/tests/alias/toggleVisibility.js b/backend/test/tests/alias/toggleVisibility.js new file mode 100644 index 000000000..8e9f7511c --- /dev/null +++ b/backend/test/tests/alias/toggleVisibility.js @@ -0,0 +1,72 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import alias from '../../../dest/systems/alias.js'; +import assert from 'assert'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Alias - @func1 - toggleVisibility()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('', async () => { + const r = await alias.toggleVisibility({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, prepare('alias.alias-parse-failed')); + }); + + it('!unknown', async () => { + const r = await alias.toggleVisibility({ sender: owner, parameters: '!unknown' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-not-found', { alias: '!unknown' })); + }); + + it('!a', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a -c !uptime' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a', command: '!uptime' })); + + const r2 = await alias.toggleVisibility({ sender: owner, parameters: '!a' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-concealed', { alias: '!a' })); + + const r3 = await alias.toggleVisibility({ sender: owner, parameters: '!a' }); + assert.strictEqual(r3[0].response, prepare('alias.alias-was-exposed', { alias: '!a' })); + }); + + it('!a with spaces', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !a with spaces -c !uptime' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!a with spaces', command: '!uptime' })); + + const r2 = await alias.toggleVisibility({ sender: owner, parameters: '!a with spaces' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-concealed', { alias: '!a with spaces' })); + + const r3 = await alias.toggleVisibility({ sender: owner, parameters: '!a with spaces' }); + assert.strictEqual(r3[0].response, prepare('alias.alias-was-exposed', { alias: '!a with spaces' })); + }); + + it('!한국어', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !한국어 -c !uptime' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!한국어', command: '!uptime' })); + + const r2 = await alias.toggleVisibility({ sender: owner, parameters: '!한국어' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-concealed', { alias: '!한국어' })); + + const r3 = await alias.toggleVisibility({ sender: owner, parameters: '!한국어' }); + assert.strictEqual(r3[0].response, prepare('alias.alias-was-exposed', { alias: '!한국어' })); + }); + + it('!русский', async () => { + const r = await alias.add({ sender: owner, parameters: '-a !русский -c !uptime' }); + assert.strictEqual(r[0].response, prepare('alias.alias-was-added', { alias: '!русский', command: '!uptime' })); + + const r2 = await alias.toggleVisibility({ sender: owner, parameters: '!русский' }); + assert.strictEqual(r2[0].response, prepare('alias.alias-was-concealed', { alias: '!русский' })); + + const r3 = await alias.toggleVisibility({ sender: owner, parameters: '!русский' }); + assert.strictEqual(r3[0].response, prepare('alias.alias-was-exposed', { alias: '!русский' })); + }); +}); diff --git a/backend/test/tests/commands/#4860_group_filter_and_permissions.js b/backend/test/tests/commands/#4860_group_filter_and_permissions.js new file mode 100644 index 000000000..ee187b06f --- /dev/null +++ b/backend/test/tests/commands/#4860_group_filter_and_permissions.js @@ -0,0 +1,196 @@ +/* global */ +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); +import { Commands, CommandsGroup } from '../../../dest/database/entity/commands.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import customcommands from '../../../dest/systems/customcommands.js'; +import { db, message, user } from '../../general.js'; + +describe('Custom Commands - @func2 - #4860 - customcommands group permissions and filter should be considered', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it('create filterGroup with filter | $game === "Dota 2"', async () => { + await AppDataSource.getRepository(CommandsGroup).insert({ + name: 'filterGroup', + options: { + filter: '$game === "Dota 2"', + permission: null, + }, + }); + }); + + it('create permGroup with permission | CASTERS', async () => { + await AppDataSource.getRepository(CommandsGroup).insert({ + name: 'permGroup', + options: { + filter: null, + permission: defaultPermissions.CASTERS, + }, + }); + }); + + it('create permGroup2 without permission', async () => { + await AppDataSource.getRepository(CommandsGroup).insert({ + name: 'permGroup2', + options: { + filter: null, + permission: null, + }, + }); + }); + + it('create command !testfilter with filterGroup', async () => { + const command = new Commands(); + command.id = '1a945d76-2d3c-4c7a-ae03-e0daf17142c5'; + command.command = '!testfilter'; + command.enabled = true; + command.visible = true; + command.group = 'filterGroup'; + command.responses = [{ + stopIfExecuted: false, + response: 'bad449ae-f0b3-488c-a7b0-39a853d5333f', + filter: '', + order: 0, + permission: defaultPermissions.VIEWERS, + }, { + stopIfExecuted: false, + response: 'c0f68c62-630b-412b-9c97-f5b1afc734d2', + filter: '$title === \'test\'', + order: 1, + permission: defaultPermissions.VIEWERS, + }, { + stopIfExecuted: false, + response: '4b310000-b105-475a-8a85-a573a0bca1b7', + filter: '$title !== \'test\'', + order: 2, + permission: defaultPermissions.VIEWERS, + }]; + await command.save(); + }); + + it('create command !testpermnull with permGroup', async () => { + const command = new Commands(); + command.id = '2584b3c1-d2da-4fae-bf9a-95048724acda'; + command.command = '!testpermnull'; + command.enabled = true; + command.visible = true; + command.group = 'permGroup'; + command.responses = [{ + stopIfExecuted: false, + response: '430ea834-da5f-48b1-bf2f-3acaf1f04c63', + filter: '', + order: 0, + permission: null, + }]; + await command.save(); + }); + + it('create command !testpermnull2 with permGroup2', async () => { + const command = new Commands(); + command.id = '2584b3c1-d2da-4fae-bf9a-95048724acdb'; + command.command = '!testpermnull2'; + command.enabled = true; + command.visible = true; + command.group = 'permGroup2'; + command.responses = [{ + stopIfExecuted: false, + response: '1594a86e-158d-4b7d-9898-0f80bd6a0c98', + filter: '', + order: 0, + permission: null, + }]; + await command.save(); + }); + + it('create command !testpermmods with permGroup2', async () => { + const command = new Commands(); + command.id = '2584b3c1-d2da-4fae-bf9a-95048724acdc'; + command.command = '!testpermmods'; + command.enabled = true; + command.visible = true; + command.group = 'permGroup2'; + command.responses = [{ + stopIfExecuted: false, + response: 'cae8f74f-046a-4756-b6c5-f2219d9a0f4e', + filter: '', + order: 1, + permission: defaultPermissions.MODERATORS, + }]; + await command.save(); + }); + + it('!testpermnull should be triggered by CASTER', async () => { + message.prepare(); + customcommands.run({ sender: user.owner, message: '!testpermnull' }); + await message.isSentRaw('430ea834-da5f-48b1-bf2f-3acaf1f04c63', user.owner); + }); + + it('!testpermnull should not be triggered by VIEWER', async () => { + message.prepare(); + customcommands.run({ sender: user.viewer, message: '!testpermnull' }); + await message.isNotSentRaw('430ea834-da5f-48b1-bf2f-3acaf1f04c63', user.viewer); + }); + + it('!testpermnull2 should be triggered by CASTER', async () => { + message.prepare(); + customcommands.run({ sender: user.owner, message: '!testpermnull2' }); + await message.isWarnedRaw('Custom command !testpermnull2#2584b3c1-d2da-4fae-bf9a-95048724acdb|0 doesn\'t have any permission set, treating as CASTERS permission.'); + await message.isSentRaw('1594a86e-158d-4b7d-9898-0f80bd6a0c98', user.owner); + }); + + it('!testpermnull2 should not be triggered by VIEWER', async () => { + message.prepare(); + customcommands.run({ sender: user.viewer, message: '!testpermnull2' }); + await message.isWarnedRaw('Custom command !testpermnull2#2584b3c1-d2da-4fae-bf9a-95048724acdb|0 doesn\'t have any permission set, treating as CASTERS permission.'); + await message.isNotSentRaw('1594a86e-158d-4b7d-9898-0f80bd6a0c98', user.viewer); + }); + + it('!testpermmods should be triggered by MOD', async () => { + message.prepare(); + customcommands.run({ sender: user.mod, message: '!testpermmods' }); + await message.isSentRaw('cae8f74f-046a-4756-b6c5-f2219d9a0f4e', user.mod); + }); + + it('!testpermmods should not be triggered by VIEWER', async () => { + message.prepare(); + customcommands.run({ sender: user.viewer, message: '!testpermmods' }); + await message.isNotSentRaw('cae8f74f-046a-4756-b6c5-f2219d9a0f4e', user.viewer); + }); + + describe('Test incorrect filter', () => { + before(() => { + message.prepare(); + }); + it('set $game to Test', async () => { + const {stats} = await import('../../../dest/helpers/api/stats.js'); + stats.value.currentGame = 'Test'; + }); + it('!testfilter customcommands should not be triggered', async () => { + customcommands.run({ sender: user.owner, message: '!testfilter' }); + await message.isWarnedRaw('Custom command !testfilter#1a945d76-2d3c-4c7a-ae03-e0daf17142c5 didn\'t pass group filter.'); + }); + }); + + describe('Test correct filter', () => { + before(() => { + message.prepare(); + }); + it('set $game to Dota 2', async () => { + const {stats} = await import('../../../dest/helpers/api/stats.js'); + stats.value.currentGame = 'Dota 2'; + }); + it('!testfilter customcommands should be triggered', async () => { + customcommands.run({ sender: user.owner, message: '!testfilter' }); + await message.isSentRaw('bad449ae-f0b3-488c-a7b0-39a853d5333f', user.owner); + await message.isNotSentRaw('c0f68c62-630b-412b-9c97-f5b1afc734d2', user.owner); + await message.isSentRaw('4b310000-b105-475a-8a85-a573a0bca1b7', user.owner); + }); + }); +}); diff --git a/backend/test/tests/commands/add.js b/backend/test/tests/commands/add.js new file mode 100644 index 000000000..c2dd4e97e --- /dev/null +++ b/backend/test/tests/commands/add.js @@ -0,0 +1,78 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db } from '../../general.js'; +import assert from 'assert'; +import { message } from '../../general.js'; + +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { AppDataSource } from '../../../dest/database.js'; + +import customcommands from '../../../dest/systems/customcommands.js'; + +// users +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; + +const failedTests = [ + { permission: null, command: null, response: null }, + { permission: null, command: '!cmd', response: null }, + { permission: null, command: 'cmd', response: null }, + { permission: null, command: 'cmd', response: 'Lorem Ipsum Dolor Sit Amet' }, + { permission: null, command: null, response: 'Lorem Ipsum Dolor Sit Amet' }, + { permission: 'unknownpermission', command: '!cmd', response: 'Lorem Ipsum Dolor Sit Amet' }, + { permission: '0efd7b1c-e460-4167-8e06-8aaf2c170319', command: '!cmd', response: 'Lorem Ipsum Dolor Sit Amet' }, // unknown uuid +]; + +const successTests = [ + { permission: null, command: '!cmd', response: 'Lorem Ipsum Dolor Sit Amet 1' }, + { permission: null, command: '!한국어', response: 'Lorem Ipsum Dolor Sit Amet 2' }, + { permission: null, command: '!русский', response: 'Lorem Ipsum Dolor Sit Amet 3' }, + { permission: defaultPermissions.VIEWERS, command: '!cmd', response: 'Lorem Ipsum Dolor Sit Amet 4' }, + { permission: 'casters', command: '!cmd', response: 'Lorem Ipsum Dolor Sit Amet 5' }, +]; + +function generateCommand(opts) { + const p = opts.permission ? '-p ' + opts.permission : ''; + const c = opts.command ? '-c ' + opts.command : ''; + const r = opts.response ? '-r ' + opts.response : ''; + return [p, c, r].join(' '); +} + +describe('Custom Commands - @func1 - add()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + }); + + describe('Expected parsed fail', () => { + for (const t of failedTests) { + it(generateCommand(t), async () => { + const r = await customcommands.add({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this command is not correct, use !command'); + }); + } + }); + + describe('Expected to pass', () => { + for (const t of successTests) { + it(generateCommand(t), async () => { + const r = await customcommands.add({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r[0].response, '$sender, command ' + t.command + ' was added'); + + customcommands.run({ sender: owner, message: t.command }); + await message.isSentRaw(t.response, owner); + }); + } + + it('2x - !a Lorem Ipsum', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !a -r Lorem Ipsum' }); + const r2 = await customcommands.add({ sender: owner, parameters: '-c !a -r Lorem Ipsum' }); + + assert.strictEqual(r[0].response, '$sender, command !a was added'); + assert.strictEqual(r2[0].response, '$sender, command !a was added'); + }); + }); +}); diff --git a/backend/test/tests/commands/count_filter.js b/backend/test/tests/commands/count_filter.js new file mode 100644 index 000000000..684a44a35 --- /dev/null +++ b/backend/test/tests/commands/count_filter.js @@ -0,0 +1,75 @@ +/* global describe it before */ + + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { User } from '../../../dest/database/entity/user.js'; + +import customcommands from '../../../dest/systems/customcommands.js'; + +// users +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; +const user1 = { userName: 'user1', userId: String(Math.floor(Math.random() * 100000)) }; + +describe('Custom Commands - @func1 - count filter', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + await AppDataSource.getRepository(User).save({ userName: user1.userName, userId: user1.userId }); + }); + + describe('$count(\'!cmd2\') should be properly parsed', () => { + it('create command and response with $count variable', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !cmd -r Count of !cmd2 is $count(\'!cmd2\') and count of !second $count(\'!second\')' }); + assert.strictEqual(r[0].response, '$sender, command !cmd was added'); + }); + + it('create command to increment count', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !cmd2 -r !uptime' }); + assert.strictEqual(r[0].response, '$sender, command !cmd2 was added'); + }); + + it('$count should be 0', async () => { + customcommands.run({ sender: owner, message: '!cmd' }); + await message.isSentRaw('Count of !cmd2 is 0 and count of !second 0', owner); + }); + + it('0 even second time', async () => { + customcommands.run({ sender: owner, message: '!cmd' }); + await message.isSentRaw('Count of !cmd2 is 0 and count of !second 0', owner); + }); + + it('trigger command to increment count', () => { + customcommands.run({ sender: owner, message: '!cmd2' }); + }); + + it('$count should be 1 and 0', async () => { + customcommands.run({ sender: owner, message: '!cmd' }); + await message.isSentRaw('Count of !cmd2 is 1 and count of !second 0', owner); + }); + }); + + describe('$count should be properly parsed', () => { + it('create command and response with $count variable', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !cmd3 -r Command usage count: $count' }); + assert.strictEqual(r[0].response, '$sender, command !cmd3 was added'); + }); + + it('$count should be 1', async () => { + customcommands.run({ sender: owner, message: '!cmd3' }); + await message.isSentRaw('Command usage count: 1', owner); + }); + + it('$count should be 2', async () => { + customcommands.run({ sender: owner, message: '!cmd3' }); + await message.isSentRaw('Command usage count: 2', owner); + }); + }); +}); diff --git a/backend/test/tests/commands/discord#715206737908465775_custom_commands_can_be_called_from_custom_command.js b/backend/test/tests/commands/discord#715206737908465775_custom_commands_can_be_called_from_custom_command.js new file mode 100644 index 000000000..a5a229917 --- /dev/null +++ b/backend/test/tests/commands/discord#715206737908465775_custom_commands_can_be_called_from_custom_command.js @@ -0,0 +1,61 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db, time } from '../../general.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; +import { message } from '../../general.js'; + +import { User } from '../../../dest/database/entity/user.js'; + +import customcommands from '../../../dest/systems/customcommands.js'; + +// users +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; + +describe('Custom Commands - @func1 - https://discordapp.com/channels/317348946144002050/619437014001123338/715206737908465775 - Custom command can be called from custom command', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + }); + + describe('Custom command should correctly run second custom command', () => { + it('Add custom command !test to call !test2', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !test -r (!test2)' }); + const r2 = await customcommands.add({ sender: owner, parameters: '-c !test2 -r Lorem Ipsum' }); + + assert.strictEqual(r[0].response, '$sender, command !test was added'); + assert.strictEqual(r2[0].response, '$sender, command !test2 was added'); + }); + + it('Run custom command !test', async () => { + await customcommands.run({ sender: owner, message: '!test' }); + }); + + it('Expect !test2 response', async() => { + await message.isSentRaw('Lorem Ipsum', owner); + }); + }); + + describe('Custom command should warn if we have infinite loop between commands', () => { + it('Add custom command infinite loop test3 -> test4 -> test5 -> test3', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !test3 -r (!test4)' }); + const r2 = await customcommands.add({ sender: owner, parameters: '-c !test4 -r (!test5)' }); + const r3 = await customcommands.add({ sender: owner, parameters: '-c !test5 -r (!test3)' }); + + assert.strictEqual(r[0].response, '$sender, command !test3 was added'); + assert.strictEqual(r2[0].response, '$sender, command !test4 was added'); + assert.strictEqual(r3[0].response, '$sender, command !test5 was added'); + }); + + it('Run custom command !test3', async () => { + await customcommands.run({ sender: owner, message: '!test3' }); + }); + + it('Expect loop error', async() => { + await message.debug('message.error', 'Response (!test3) seems to be in loop! !test3->!test4->!test5'); + }); + }); +}); diff --git a/backend/test/tests/commands/discord#794732878595752016_param_should_be_properly_filtered.js.js b/backend/test/tests/commands/discord#794732878595752016_param_should_be_properly_filtered.js.js new file mode 100644 index 000000000..9de47cb1e --- /dev/null +++ b/backend/test/tests/commands/discord#794732878595752016_param_should_be_properly_filtered.js.js @@ -0,0 +1,60 @@ +import('../../general.js'); + +import assert from 'assert'; + +import { User } from '../../../dest/database/entity/user.js'; +import { Commands } from '../../../dest/database/entity/commands.js'; + +import { db, time, message, user } from '../../general.js'; + +import customcommands from '../../../dest/systems/customcommands.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; + +describe('Custom Commands - @func1 - https://discord.com/channels/317348946144002050/317349069024395264/794732878595752016 - Custom command $param filter should be properly evaluated', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + const command = new Commands(); + command.id = '2584b3c1-d2da-4fae-bf9a-95048724acdc'; + command.command = '!test'; + command.enabled = true; + command.visible = true; + command.group = 'permGroup2'; + command.responses = [{ + stopIfExecuted: false, + response: '1', + filter: '$param === "1"', + order: 1, + permission: defaultPermissions.MODERATORS, + }, { + stopIfExecuted: false, + response: '2', + filter: '$param === "2"', + order: 1, + permission: defaultPermissions.VIEWERS, + }]; + await command.save(); + }); + + it('Run custom command !test 1', async () => { + await message.prepare(); + await customcommands.run({ sender: user.owner, message: '!test 1', parameters: '1' }); + }); + + it('Expect response 1', async() => { + await message.isSentRaw('1', user.owner); + await message.isNotSentRaw('2', user.owner); + }); + + it('Run custom command !test 2', async () => { + await message.prepare(); + await customcommands.run({ sender: user.owner, message: '!test 2', parameters: '2' }); + }); + + it('Expect response 2', async() => { + await message.isSentRaw('2', user.owner); + await message.isNotSentRaw('1', user.owner); + }); +}); diff --git a/backend/test/tests/commands/edit.js b/backend/test/tests/commands/edit.js new file mode 100644 index 000000000..8909e8f58 --- /dev/null +++ b/backend/test/tests/commands/edit.js @@ -0,0 +1,126 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import _ from 'lodash-es' +import assert from 'assert'; + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import customcommands from '../../../dest/systems/customcommands.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import { AppDataSource } from '../../../dest/database.js'; + +// users +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; + +const parseFailedTests = [ + { permission: null, command: null, rid: null, response: null }, + { permission: null, command: '!cmd', rid: null, response: null }, + { permission: null, command: '!cmd', rid: '1', response: null }, + { permission: null, command: 'cmd', rid: '1', response: null }, + { permission: null, command: 'cmd', response: 'Lorem Ipsum Dolor Sit Amet' }, + { permission: null, command: null, response: 'Lorem Ipsum Dolor Sit Amet' }, + { permission: 'unknownpermission', command: 'cmd', rid: '1', response: 'Lorem Ipsum Dolor Sit Amet' }, + { permission: '0efd7b1c-e460-4167-8e06-8aaf2c170319', command: 'cmd', rid: '1', response: 'Lorem Ipsum Dolor Sit Amet' }, // unknown uuid +]; + +const unknownCommandTests = [ + { permission: defaultPermissions.VIEWERS, command: '!cmd', rid: '1', response: 'Lorem Ipsum Dolor Sit Amet' }, +]; + +const unknownResponseTests = [ + { permission: defaultPermissions.VIEWERS, command: '!cmd', rid: '2', response: 'Lorem Ipsum Dolor Sit Amet' }, +]; + +const successTests = [ + { permission: null, command: '!cmd', rid: '1', response: 'Lorem Ipsum', edit: 'Dolor Ipsum'}, + { permission: defaultPermissions.VIEWERS, command: '!cmd', rid: '1', response: 'Lorem Ipsum', edit: 'Dolor Ipsum'}, + { permission: 'casters', command: '!cmd', rid: '1', response: 'Lorem Ipsum', edit: 'Dolor Ipsum'}, + { permission: null, command: '!한글', rid: '1', response: 'Lorem Ipsum', edit: 'Dolor Ipsum'}, + { permission: null, command: '!русский', rid: '1', response: 'Lorem Ipsum', edit: 'Dolor Ipsum'}, +]; + +function generateCommand(opts) { + const p = opts.permission ? '-p ' + opts.permission : ''; + const c = opts.command ? '-c ' + opts.command : ''; + const r = opts.response ? '-r ' + opts.response : ''; + const rid = opts.rid ? '-rid ' + opts.rid : ''; + return [p, c, r, rid].join(' '); +} + +describe('Custom Commands - @func1 - edit()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + }); + + describe('Expected parsed fail', () => { + for (const t of parseFailedTests) { + it(generateCommand(t), async () => { + const r = await customcommands.edit({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this command is not correct, use !command'); + }); + } + }); + + describe('Expected command not found', () => { + for (const t of unknownCommandTests) { + it(generateCommand(t), async () => { + const r = await customcommands.edit({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r[0].response, '$sender, command !cmd was not found in database'); + }); + } + }); + + describe('Expected response not found', () => { + for (const t of unknownResponseTests) { + it(generateCommand(t), async () => { + const add = _.cloneDeep(t); delete add.rid; + const r = await customcommands.add({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r[0].response, '$sender, command ' + t.command + ' was added'); + + const r2 = await customcommands.edit({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r2[0].response, '$sender, response #2 of command !cmd was not found in database'); + }); + } + }); + + describe('Expected to pass', () => { + for (const t of successTests) { + it(generateCommand(t), async () => { + const add = _.cloneDeep(t); delete add.rid; + const r = await customcommands.add({ sender: owner, parameters: generateCommand(add) }); + assert.strictEqual(r[0].response, '$sender, command ' + t.command + ' was added'); + + customcommands.run({ sender: owner, message: t.command }); + await message.isSentRaw(t.response, owner); + + const edit = _.cloneDeep(t); + edit.response = edit.edit; + const r2 = await customcommands.edit({ sender: owner, parameters: generateCommand(edit) }); + assert.strictEqual(r2[0].response, '$sender, command ' + t.command + ' is changed to \'' + t.edit + '\''); + + customcommands.run({ sender: owner, message: t.command }); + await message.isSentRaw(t.edit, owner); + }); + } + it('!a Lorem Ipsum -> !a Ipsum Lorem', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !a -r Lorem Ipsum' }); + assert.strictEqual(r[0].response, '$sender, command !a was added'); + + customcommands.run({ sender: owner, message: '!a' }); + await message.isSentRaw('Lorem Ipsum', owner); + + const r2 = await customcommands.edit({ sender: owner, parameters: '-c !a -rid 1 -r Ipsum Lorem' }); + assert.strictEqual(r2[0].response, `$sender, command !a is changed to 'Ipsum Lorem'`); + + customcommands.run({ sender: owner, message: '!a' }); + await message.isSentRaw('Ipsum Lorem', owner); + }); + }); +}); diff --git a/backend/test/tests/commands/list.js b/backend/test/tests/commands/list.js new file mode 100644 index 000000000..473a813bd --- /dev/null +++ b/backend/test/tests/commands/list.js @@ -0,0 +1,42 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import assert from 'assert'; + +import customcommands from '../../../dest/systems/customcommands.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Custom Commands - @func1 - list()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('empty list', async () => { + const r = await customcommands.list({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, '$sender, list of commands is empty'); + }); + + it('populated list', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-p casters -c !a -r me' }); + assert.strictEqual(r[0].response, '$sender, command !a was added'); + + const r2 = await customcommands.add({ sender: owner, parameters: '-p moderators -s true -c !a -r me2' }); + assert.strictEqual(r2[0].response, '$sender, command !a was added'); + + const r3 = await customcommands.add({ sender: owner, parameters: '-c !b -r me' }); + assert.strictEqual(r3[0].response, '$sender, command !b was added'); + + const r4 = await customcommands.list({ sender: owner, parameters: '' }); + assert.strictEqual(r4[0].response, '$sender, list of commands: !a, !b'); + + const r5 = await customcommands.list({ sender: owner, parameters: '!a' }); + assert.strictEqual(r5[0].response, '!a#1 (Casters) v| me'); + assert.strictEqual(r5[1].response, '!a#2 (Moderators) _| me2'); + }); +}); diff --git a/backend/test/tests/commands/remove.js b/backend/test/tests/commands/remove.js new file mode 100644 index 000000000..f39272fff --- /dev/null +++ b/backend/test/tests/commands/remove.js @@ -0,0 +1,78 @@ +/* global describe it beforeEach */ +import('../../general.js'); +import assert from 'assert'; + +import { db, message, user } from '../../general.js'; + +import customcommands from '../../../dest/systems/customcommands.js'; + +describe('Custom Commands - @func1 - remove()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it('', async () => { + const r = await customcommands.remove({ sender: user.owner, parameters: '' }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this command is not correct, use !command'); + }); + + it('!alias', async () => { + const r = await customcommands.remove({ sender: user.owner, parameters: '-c !alias' }); + assert.strictEqual(r[0].response, '$sender, command !alias was not found in database'); + }); + + it('alias', async () => { + const r = await customcommands.remove({ sender: user.owner, parameters: '-c alias' }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this command is not correct, use !command'); + }); + + it('!a', async () => { + const r = await customcommands.add({ sender: user.owner, parameters: '-c !a -r !me' }); + assert.strictEqual(r[0].response, '$sender, command !a was added'); + + const r2 = await customcommands.remove({ sender: user.owner, parameters: '-c !a' }); + assert.strictEqual(r2[0].response, '$sender, command !a was removed'); + }); + + it('!한글', async () => { + const r = await customcommands.add({ sender: user.owner, parameters: '-c !한글 -r !me' }); + assert.strictEqual(r[0].response, '$sender, command !한글 was added'); + + const r2 = await customcommands.remove({ sender: user.owner, parameters: '-c !한글' }); + assert.strictEqual(r2[0].response, '$sender, command !한글 was removed'); + }); + + it('!русский', async () => { + const r = await customcommands.add({ sender: user.owner, parameters: '-c !русский -r !me' }); + assert.strictEqual(r[0].response, '$sender, command !русский was added'); + + const r2 = await customcommands.remove({ sender: user.owner, parameters: '-c !русский' }); + assert.strictEqual(r2[0].response, '$sender, command !русский was removed'); + }); + + it('2x - !a !me', async () => { + const r = await customcommands.add({ sender: user.owner, parameters: '-c !a -r !me' }); + assert.strictEqual(r[0].response, '$sender, command !a was added'); + + const r2 = await customcommands.remove({ sender: user.owner, parameters: '-c !a' }); + assert.strictEqual(r2[0].response, '$sender, command !a was removed'); + + const r3 = await customcommands.remove({ sender: user.owner, parameters: '-c !a' }); + assert.strictEqual(r3[0].response, '$sender, command !a was not found in database'); + }); + + it('remove response', async () => { + const r = await customcommands.add({ sender: user.owner, parameters: '-c !a -r !me' }); + assert.strictEqual(r[0].response, '$sender, command !a was added'); + const r2 = await customcommands.add({ sender: user.owner, parameters: '-c !a -r !me2' }); + assert.strictEqual(r2[0].response, '$sender, command !a was added'); + + const r3 = await customcommands.remove({ sender: user.owner, parameters: '-c !a -rid 1' }); + assert.strictEqual(r3[0].response, '$sender, response #1 of !a was removed'); + + await customcommands.run({ sender: user.owner, message: '!a', parameters: '' }); + await message.isSentRaw('!me2', user.owner); + }); +}); diff --git a/backend/test/tests/commands/run.js b/backend/test/tests/commands/run.js new file mode 100644 index 000000000..0c16727f3 --- /dev/null +++ b/backend/test/tests/commands/run.js @@ -0,0 +1,161 @@ +import assert from 'assert'; + +import { Commands } from '../../../dest/database/entity/commands.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import { AppDataSource } from '../../../dest/database.js' +import customcommands from '../../../dest/systems/customcommands.js'; + +import('../../general.js'); +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +// users +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; +const user1 = { userName: 'user1', userId: String(Math.floor(Math.random() * 100000)) }; + +describe('Custom Commands - @func1 - run()', () => { + before(async () => { + await db.cleanup(); + message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + await AppDataSource.getRepository(User).save({ userName: user1.userName, userId: user1.userId }); + }); + + describe('\'!test qwerty\' should trigger correct commands', () => { + it('create \'!test\' command with $_variable', async () => { + const command = new Commands(); + command.command = '!test'; + command.enabled = true; + command.visible = true; + command.group = null; + command.responses = [{ + filter: '', response: '$_variable', permission: defaultPermissions.VIEWERS, stopIfExecuted: false, order: 0, + }]; + await command.save(); + }); + it('create \'!test\' command with $param', async () => { + const command = new Commands(); + command.command = '!test'; + command.enabled = true; + command.visible = true; + command.group = null; + command.responses = [{ + filter: '', response: '$param by !test command with param', permission: defaultPermissions.VIEWERS, stopIfExecuted: false, order: 0, + }]; + await command.save(); + }); + it('create \'!test\' command without $param', async () => { + const command = new Commands(); + command.command = '!test'; + command.enabled = true; + command.visible = true; + command.group = null; + command.responses = [{ + filter: '!$haveParam', response: 'This should not be triggered', permission: defaultPermissions.VIEWERS, stopIfExecuted: false, order: 0, + }]; + await command.save(); + }); + it('create \'!test qwerty\' command without $param', async () => { + const command = new Commands(); + command.command = '!test qwerty'; + command.enabled = true; + command.visible = true; + command.group = null; + command.responses = [{ + filter: '', response: 'This should be triggered', permission: defaultPermissions.VIEWERS, stopIfExecuted: false, order: 0, + }]; + await command.save(); + }); + it('create second \'!test qwerty\' command without $param', async () => { + const command = new Commands(); + command.command = '!test qwerty'; + command.enabled = true; + command.visible = true; + command.group = null; + command.responses = [{ + filter: '', response: 'This should be triggered as well', permission: defaultPermissions.VIEWERS, stopIfExecuted: false, order: 0, + }]; + await command.save(); + }); + + it('run command by owner', async () => { + await customcommands.run({ sender: owner, message: '!test qwerty', parameters: 'qwerty' }); + await message.isSentRaw('qwerty by !test command with param', owner); + await message.isSentRaw('@__broadcaster__, $_variable was set to qwerty.', owner); + await message.isSentRaw('This should be triggered', owner); + await message.isSentRaw('This should be triggered as well', owner); + await message.isNotSentRaw('This should not be triggered', owner); + }); + + it('run command by viewer', async () => { + await customcommands.run({ sender: user1, message: '!test qwerty', parameters: 'qwerty' }); + await message.isSentRaw('This should be triggered', user1); + await message.isSentRaw('This should be triggered as well', user1); + await message.isSentRaw('qwerty by !test command with param', user1); + await message.isNotSentRaw('This should not be triggered', user1); + await message.isNotSentRaw('@user1, $_variable was set to qwerty.', user1); + }); + }); + + describe('!cmd with username filter', () => { + beforeEach(async () => { + await message.prepare(); + }); + it('create command and response with filter', async () => { + const command = new Commands(); + command.command = '!cmd'; + command.enabled = true; + command.visible = true; + command.group = null; + command.responses = [{ + filter: '$sender == "user1"', response: 'Lorem Ipsum', permission: defaultPermissions.VIEWERS, stopIfExecuted: false, order: 0, + }]; + await command.save(); + }); + + it('run command as user not defined in filter', async () => { + customcommands.run({ sender: owner, message: '!cmd', parameters: '' }); + await message.isNotSentRaw('Lorem Ipsum', owner); + }); + + it('run command as user defined in filter', async () => { + customcommands.run({ sender: user1, message: '!cmd', parameters: '' }); + await message.isSentRaw('Lorem Ipsum', user1); + }); + }); + + it('!a will show Lorem Ipsum', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !a -r Lorem Ipsum' }); + assert.strictEqual(r[0].response, '$sender, command !a was added'); + + customcommands.run({ sender: owner, message: '!a', parameters: '' }); + await message.isSentRaw('Lorem Ipsum', owner); + + const r2 = await customcommands.remove({ sender: owner, parameters: '-c !a' }); + assert.strictEqual(r2[0].response, '$sender, command !a was removed'); + }); + + it('!한글 will show Lorem Ipsum', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !한글 -r Lorem Ipsum' }); + assert.strictEqual(r[0].response, '$sender, command !한글 was added'); + + customcommands.run({ sender: owner, message: '!한글', parameters: '' }); + await message.isSentRaw('Lorem Ipsum', owner); + + const r2 = await customcommands.remove({ sender: owner, parameters: '-c !한글' }); + assert.strictEqual(r2[0].response, '$sender, command !한글 was removed'); + }); + + it('!русский will show Lorem Ipsum', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !русский -r Lorem Ipsum' }); + assert.strictEqual(r[0].response, '$sender, command !русский was added'); + + customcommands.run({ sender: owner, message: '!русский', parameters: '' }); + await message.isSentRaw('Lorem Ipsum', owner); + + const r2 = await customcommands.remove({ sender: owner, parameters: '-c !русский' }); + assert.strictEqual(r2[0].response, '$sender, command !русский was removed'); + }); +}); diff --git a/backend/test/tests/commands/toggle.js b/backend/test/tests/commands/toggle.js new file mode 100644 index 000000000..2e5c13894 --- /dev/null +++ b/backend/test/tests/commands/toggle.js @@ -0,0 +1,61 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import assert from 'assert'; + +import customcommands from '../../../dest/systems/customcommands.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Custom Commands - @func1 - toggle()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('', async () => { + const r = await customcommands.toggle({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this command is not correct, use !command'); + }); + + it('!unknown', async () => { + const r = await customcommands.toggle({ sender: owner, parameters: '!unknown' }); + assert.strictEqual(r[0].response, '$sender, command !unknown was not found in database'); + }); + + it('!a', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !a -r !uptime' }); + assert.strictEqual(r[0].response, '$sender, command !a was added'); + + const r2 = await customcommands.toggle({ sender: owner, parameters: '!a' }); + assert.strictEqual(r2[0].response, '$sender, command !a was disabled'); + + const r3 = await customcommands.toggle({ sender: owner, parameters: '!a' }); + assert.strictEqual(r3[0].response, '$sender, command !a was enabled'); + }); + + it('!한글', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !한글 -r !uptime' }); + assert.strictEqual(r[0].response, '$sender, command !한글 was added'); + + const r2 = await customcommands.toggle({ sender: owner, parameters: '!한글' }); + assert.strictEqual(r2[0].response, '$sender, command !한글 was disabled'); + + const r3 = await customcommands.toggle({ sender: owner, parameters: '!한글' }); + assert.strictEqual(r3[0].response, '$sender, command !한글 was enabled'); + }); + + it('!русский', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !русский -r !uptime' }); + assert.strictEqual(r[0].response, '$sender, command !русский was added'); + + const r2 = await customcommands.toggle({ sender: owner, parameters: '!русский' }); + assert.strictEqual(r2[0].response, '$sender, command !русский was disabled'); + + const r3 = await customcommands.toggle({ sender: owner, parameters: '!русский' }); + assert.strictEqual(r3[0].response, '$sender, command !русский was enabled'); + }); +}); diff --git a/backend/test/tests/commands/toggleVisibility.js b/backend/test/tests/commands/toggleVisibility.js new file mode 100644 index 000000000..753dbb74e --- /dev/null +++ b/backend/test/tests/commands/toggleVisibility.js @@ -0,0 +1,60 @@ +/* global describe it beforeEach */ +import('../../general.js'); +import assert from 'assert'; + +import customcommands from '../../../dest/systems/customcommands.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Custom Commands - @func1 - toggleVisibility()', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('', async () => { + const r = await customcommands.toggleVisibility({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this command is not correct, use !command'); + }); + + it('!unknown', async () => { + const r = await customcommands.toggleVisibility({ sender: owner, parameters: '!unknown' }); + assert.strictEqual(r[0].response, '$sender, command !unknown was not found in database'); + }); + + it('!a', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !a -r !uptime' }); + assert.strictEqual(r[0].response, '$sender, command !a was added'); + + const r2 = await customcommands.toggleVisibility({ sender: owner, parameters: '!a' }); + assert.strictEqual(r2[0].response, '$sender, command !a was concealed'); + + const r3 = await customcommands.toggleVisibility({ sender: owner, parameters: '!a' }); + assert.strictEqual(r3[0].response, '$sender, command !a was exposed'); + }); + + it('!한글', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !한글 -r !uptime' }); + assert.strictEqual(r[0].response, '$sender, command !한글 was added'); + + const r2 = await customcommands.toggleVisibility({ sender: owner, parameters: '!한글' }); + assert.strictEqual(r2[0].response, '$sender, command !한글 was concealed'); + + const r3 = await customcommands.toggleVisibility({ sender: owner, parameters: '!한글' }); + assert.strictEqual(r3[0].response, '$sender, command !한글 was exposed'); + }); + + it('!русский', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !русский -r !uptime' }); + assert.strictEqual(r[0].response, '$sender, command !русский was added'); + + const r2 = await customcommands.toggleVisibility({ sender: owner, parameters: '!русский' }); + assert.strictEqual(r2[0].response, '$sender, command !русский was concealed'); + + const r3 = await customcommands.toggleVisibility({ sender: owner, parameters: '!русский' }); + assert.strictEqual(r3[0].response, '$sender, command !русский was exposed'); + }); +}); diff --git a/backend/test/tests/commons/#3620_announce_is_not_parsing_filters.js b/backend/test/tests/commons/#3620_announce_is_not_parsing_filters.js new file mode 100644 index 000000000..b61f22570 --- /dev/null +++ b/backend/test/tests/commons/#3620_announce_is_not_parsing_filters.js @@ -0,0 +1,27 @@ + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import alias from '../../../dest/systems/alias.js'; +import customcommands from '../../../dest/systems/customcommands.js'; +import { announce } from '../../../dest/helpers/commons/announce.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Commons - @func2 - #3620 - announce is not parsing message filters', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await alias.add({ sender: owner, parameters: '-a !testAlias -c !me' }); + await customcommands.add({ sender: owner, parameters: '-c !testCmd -r Lorem Ipsum' }); + }); + + it('Announce() should have propery parsed filters', async () => { + announce('Prikazy bota: !klip, !me, !heist, (list.!command), (list.!alias)', 'general'); + await message.isSentRaw('Prikazy bota: !klip, !me, !heist, !testCmd, !testAlias', '__bot__', 20000); + + }); +}); diff --git a/backend/test/tests/commons/flatten.js b/backend/test/tests/commons/flatten.js new file mode 100644 index 000000000..eda892c8c --- /dev/null +++ b/backend/test/tests/commons/flatten.js @@ -0,0 +1,41 @@ +/* global describe it before */ + +import('../../general.js'); + +import { db, message, variable } from '../../general.js'; +import { flatten, unflatten } from '../../../dest/helpers/flatten.js'; + +import assert from 'assert'; + +describe('lib/commons - @func2 - flatten()', () => { + it('Object with string should be correctly flatten', async () => { + const object = { a: { b: { c: { d: { e: { f: 'lorem'}}}}}}; + assert.deepEqual(flatten(object), { 'a.b.c.d.e.f': 'lorem' }); + }); + + it('Object with array should be correctly flatten', async () => { + const object = { a: { b: { c: { d: { e: { f: ['lorem', 'ipsum']}}}}}}; + assert.deepEqual(flatten(object), { 'a.b.c.d.e.f': ['lorem', 'ipsum'] }); + }); +}); + +describe('lib/commons - @func2 - unflatten()', () => { + it('Object with string should be correctly unflatten', async () => { + const object = { a: { b: { c: { d: { e: { f: 'lorem'}}}}}}; + assert.deepEqual(unflatten({ 'a.b.c.d.e.f': 'lorem' }), object); + }); + + it('Object with array should be correctly unflatten', async () => { + const object = { a: { b: { c: { d: { e: { f: ['lorem', 'ipsum']}}}}}}; + assert.deepEqual(unflatten({ 'a.b.c.d.e.f': ['lorem', 'ipsum'] }), object); + }); + + it('Array of object should be correctly unflatten', async () => { + const object = [ { userName: 'test' }, { userName: 'test2' }, { 'user.name': 'test3' } ]; + assert.deepEqual(unflatten(object), [ + { userName: 'test' }, + { userName: 'test2' }, + { user: { name: 'test3' } }, + ]); + }); +}); \ No newline at end of file diff --git a/backend/test/tests/commons/isOwner.js b/backend/test/tests/commons/isOwner.js new file mode 100644 index 000000000..5cc22a633 --- /dev/null +++ b/backend/test/tests/commons/isOwner.js @@ -0,0 +1,21 @@ +import assert from 'assert'; + +import { isOwner } from '../../../dest/helpers/user/isOwner.js'; +import('../../general.js'); +import { db, message, user } from '../../general.js'; + +describe('lib/commons - @func2 - isOwner()', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it('should be returned as owner', async () => { + assert(isOwner(user.owner)); + }); + + it('should not be returned as owner', async () => { + assert(!isOwner(user.viewer)); + }); +}); diff --git a/backend/test/tests/commons/round5.js b/backend/test/tests/commons/round5.js new file mode 100644 index 000000000..f758551c4 --- /dev/null +++ b/backend/test/tests/commons/round5.js @@ -0,0 +1,26 @@ +/* global describe it before */ + +import('../../general.js'); + +import { db, message, variable } from '../../general.js'; +import { round5 } from '../../../dest/helpers/commons/round5.js'; + +import assert from 'assert'; + +describe('lib/commons - @func2 - round5()', () => { + it('6 => 5', async () => { + assert.deepEqual(round5(6), 5); + }); + it('10 => 10', async () => { + assert.deepEqual(round5(10), 10); + }); + it('50 => 50', async () => { + assert.deepEqual(round5(50), 50); + }); + it('9 => 10', async () => { + assert.deepEqual(round5(9), 10); + }); + it('159 => 160', async () => { + assert.deepEqual(round5(159), 160); + }); +}); \ No newline at end of file diff --git a/backend/test/tests/commons/sendMessage.js b/backend/test/tests/commons/sendMessage.js new file mode 100644 index 000000000..f542be5dd --- /dev/null +++ b/backend/test/tests/commons/sendMessage.js @@ -0,0 +1,51 @@ +/* global */ + +import assert from 'assert'; + +import('../../general.js'); +import { getOwnerAsSender } from '../../../dest/helpers/commons/getOwnerAsSender.js'; +import { sendMessage } from '../../../dest/helpers/commons/sendMessage.js'; +import emitter from '../../../dest/helpers/interfaceEmitter.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +describe('lib/commons - @func2 - sendMessage()', () => { + describe('remove /me when in color mode', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('enable color-mode', async () => { + emitter.emit('set', '/services/twitch', 'sendWithMe', true); + }); + + it('send message containing /me', () => { + sendMessage('/me lorem ipsum', getOwnerAsSender()); + }); + + it('message is sent without /me', async () => { + await message.isSentRaw('lorem ipsum', getOwnerAsSender()); + }); + }); + + describe('keep /me when in normal mode', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('enable normal-mode', async () => { + emitter.emit('set', '/services/twitch', 'sendWithMe', false); + }); + + it('send message containing /me', () => { + sendMessage('/me lorem ipsum', getOwnerAsSender()); + }); + + it('message is sent with /me', async () => { + await message.isSentRaw('/me lorem ipsum', getOwnerAsSender()); + }); + }); + +}); diff --git a/backend/test/tests/cooldowns/#3720_cooldown_should_be_properly_reverted.js b/backend/test/tests/cooldowns/#3720_cooldown_should_be_properly_reverted.js new file mode 100644 index 000000000..6633133e3 --- /dev/null +++ b/backend/test/tests/cooldowns/#3720_cooldown_should_be_properly_reverted.js @@ -0,0 +1,98 @@ +import('../../general.js'); + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { Cooldown } from '../../../dest/database/entity/cooldown.js'; +import { Price } from '../../../dest/database/entity/price.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { Parser } from '../../../dest/parser.js'; +import cooldown from '../../../dest/systems/cooldown.js' +import { db, message, user, time } from '../../general.js'; + +describe('Cooldowns - @func3 - #3720 - global cooldown should be properly reverted', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + await AppDataSource.getRepository(User).save({ ...user.viewer2, points: 100 }); + }); + + it('create cooldown on !me [global 60]', async () => { + const [command, type, seconds, quiet] = ['!me', 'global', '5', true]; + const r = await cooldown.main({ sender: user.owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, global cooldown for !me was set to 5s'); + }); + + it('check if cooldown is created', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!me' } }); + assert(item); + timestamp = item.timestamp; + }); + + it('testuser should be able to use !me', async () => { + const parse = new Parser({ sender: user.viewer, message: '!me', skip: false, quiet: false }); + await parse.process(); + }); + + it('save timestamp', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!me' } }); + assert(item); + timestamp = item.timestamp; + }); + + it('add price for !me', async () => { + await AppDataSource.getRepository(Price).save({ command: '!me', price: '100' }); + }); + + it('wait 6s to cool off cooldown', async() => { + await time.waitMs(6000); + }); + + it('testuser should not have enough points', async () => { + const parse = new Parser({ sender: user.viewer, message: '!me', skip: false, quiet: false }); + await parse.process(); + }); + + it('cooldown should be reverted', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!me' } }); + assert.strictEqual(item.timestamp, new Date(0).toISOString()); + }); + + it('testuser2 should have enough points', async () => { + const parse = new Parser({ sender: user.viewer2, message: '!me', skip: false, quiet: false }); + await parse.process(); + }); + + let timestamp = 0; + it('cooldown should be changed', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!me' } }); + timestamp = item.timestamp; + assert(new Date(item.timestamp).getTime() > 0); + }); + + it('testuser2 should not have enough points', async () => { + const parse = new Parser({ sender: user.viewer2, message: '!me', skip: false, quiet: false }); + await parse.process(); + }); + + it('cooldown should be not changed (still on cooldown period)', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!me' } }); + assert.strictEqual(item.timestamp, timestamp); + }); + + it('wait 6s to cool off cooldown', async() => { + await time.waitMs(6000); + }); + + it('testuser2 should not have enough points', async () => { + const parse = new Parser({ sender: user.viewer2, message: '!me', skip: false, quiet: false }); + await parse.process(); + }); + + it('cooldown should be reverted', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!me' } }); + assert.strictEqual(item.timestamp, new Date(0).toISOString()); + }); +}); diff --git a/backend/test/tests/cooldowns/check.js b/backend/test/tests/cooldowns/check.js new file mode 100644 index 000000000..f241080e0 --- /dev/null +++ b/backend/test/tests/cooldowns/check.js @@ -0,0 +1,789 @@ + +/* global */ + +import('../../general.js'); + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { Cooldown } from '../../../dest/database/entity/cooldown.js'; +import { Keyword } from '../../../dest/database/entity/keyword.js'; +import { User } from '../../../dest/database/entity/user.js'; +import gamble from '../../../dest/games/gamble.js'; +import cooldown from '../../../dest/systems/cooldown.js' +import customcommands from '../../../dest/systems/customcommands.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +// users +const owner = { + userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__', badges: {}, +}; +const usermod1 = { + userId: String(Math.floor(Math.random() * 100000)), userName: 'usermod1', badges: { moderator: 1 }, +}; +const subuser1 = { + userId: String(Math.floor(Math.random() * 100000)), userName: 'subuser1', badges: { subscriber: 1 }, +}; +const testUser = { + userId: String(Math.floor(Math.random() * 100000)), userName: 'test', badges: {}, +}; +const testUser2 = { + userId: String(Math.floor(Math.random() * 100000)), userName: 'test2', badges: {}, +}; + +describe('Cooldowns - @func3 - check()', () => { + describe('#1969 - commands with special chars should not threadlock check', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + + }); + + it('Command !_debug should pass', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!_debug' }); + assert(isOk); + }); + + it('Command !$debug should pass', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!$debug' }); + assert(isOk); + }); + + it('Command `!_debug test` should pass', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!_debug test' }); + assert(isOk); + }); + + it('Command `!$debug test` should pass', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!$debug test' }); + assert(isOk); + }); + + it('Command `!_debug te$st` should pass', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!_debug te$st' }); + assert(isOk); + }); + + it('Command `!$debug te$st` should pass', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!$debug te$st' }); + assert(isOk); + }); + }); + + describe('#1938 - !cmd with param (*)', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + it('create command', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !cmd -r $param' }); + assert.strictEqual(r[0].response, '$sender, command !cmd was added'); + }); + + it('Add !cmd to cooldown', async () => { + const c = new Cooldown(); + c.name = '!cmd'; + c.miliseconds = 60000; + c.type = 'global'; + c.timestamp = new Date(0).toISOString(); + c.isErrorMsgQuiet = true; + c.isEnabled = true; + c.isOwnerAffected = true; + c.isModeratorAffected = true; + c.isSubscriberAffected = true; + await c.save(); + }); + + it('First user should PASS', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!cmd (*)' }); + assert(isOk); + }); + + it('Second user should FAIL', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!cmd (*)' }); + assert(!isOk); + }); + }); + + describe('#1658 - cooldown not working on not full cooldown object KonCha', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(() => { + gamble.enabled = false; + }); + + it('Add global KonCha to cooldown', async () => { + const c = new Cooldown(); + c.name = 'KonCha'; + c.miliseconds = 60000; + c.type = 'global'; + c.timestamp = new Date(0).toISOString(); + c.isErrorMsgQuiet = true; + c.isEnabled = true; + c.isOwnerAffected = true; + c.isModeratorAffected = true; + c.isSubscriberAffected = true; + await c.save(); + }); + + it('Add koncha to keywords', async () => { + await AppDataSource.getRepository(Keyword).save({ + keyword: 'koncha', + response: '$sender KonCha', + enabled: true, + }); + }); + + it('First user should PASS', async () => { + const isOk = await cooldown.check({ sender: testUser, message: 'KonCha' }); + assert(isOk); + }); + + for (const user of [testUser, testUser2, owner, usermod1, subuser1]) { + it('Other users should FAIL', async () => { + const isOk = await cooldown.check({ sender: user, message: 'koncha' }); + assert(!isOk); + }); + } + }); + + describe('#1658 - cooldown not working on !followage', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(() => { + gamble.enabled = false; + }); + + it('Add global !followage to cooldown', async () => { + const c = new Cooldown(); + c.name = '!followage'; + c.miliseconds = 30000; + c.type = 'global'; + c.timestamp = new Date(1544713598872).toISOString(); + c.isErrorMsgQuiet = true; + c.isEnabled = true; + c.isOwnerAffected = false; + c.isModeratorAffected = false; + c.isSubscriberAffected = true; + await c.save(); + }); + + it('First user should PASS', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!followage' }); + assert(isOk); + }); + + it('Owner user should PASS', async () => { + const isOk = await cooldown.check({ sender: owner, message: '!followage' }); + assert(isOk); + }); + + it('Moderator user should PASS', async () => { + const isOk = await cooldown.check({ sender: usermod1, message: '!followage' }); + assert(isOk); + }); + + for (const user of [testUser, testUser2, subuser1]) { + it('Other users should FAIL', async () => { + const isOk = await cooldown.check({ sender: user, message: '!followage' }); + assert(!isOk); + }); + } + }); + + describe('#3209 - cooldown not working on gamble changed command to !фортуна', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + gamble.setCommand('!gamble', '!фортуна'); + + const c = new Cooldown(); + c.name = '!фортуна'; + c.miliseconds = 200000; + c.type = 'user'; + c.timestamp = new Date(1569490204420).toISOString(); + c.isErrorMsgQuiet = false; + c.isEnabled = true; + c.isOwnerAffected = true; + c.isModeratorAffected = true; + c.isSubscriberAffected = true; + await c.save(); + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(async () => { + gamble.setCommand('!gamble', '!gamble'); + gamble.enabled = false; + }); + + it('testuser should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!фортуна 10' }); + assert(isOk); + }); + + it('testuser should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!фортуна 15' }); + assert(!isOk); // second should fail + }); + + it('testuser2 should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!фортуна 20' }); + assert(isOk); + }); + + it('testuser2 should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!фортуна 25' }); + assert(!isOk); // second should fail + }); + + it('owner should be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: owner, message: '!фортуна 20' }); + assert(isOk); + }); + + it('owner should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: owner, message: '!фортуна 25' }); + assert(!isOk); + }); + + it('owner should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: owner, message: '!фортуна 25' }); + assert(!isOk); + }); + + it('testuser should be affected by cooldown third time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!фортуна 15' }); + assert(!isOk); + }); + }); + + describe('#3209 - cooldown not working on gamble changed command to !play', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + gamble.setCommand('!gamble', '!play'); + await AppDataSource.getRepository(Cooldown).update({}, { isOwnerAffected: false }); + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(async () => { + gamble.setCommand('!gamble', '!gamble'); + gamble.enabled = false; + }); + + it('create cooldown on !play [user 300]', async () => { + const [command, type, seconds, quiet] = ['!play', 'user', '300', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !play was set to 300s'); + }); + + it('check if cooldown is created', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!play' } }); + assert(item.length !== 0); + }); + + it('testuser should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!play 10' }); + assert(isOk); + }); + + it('testuser should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!play 15' }); + assert(!isOk); // second should fail + }); + + it('testuser2 should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!play 20' }); + assert(isOk); + }); + + it('testuser2 should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!play 25' }); + assert(!isOk); // second should fail + }); + + it('owner should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: owner, message: '!play 20' }); + assert(isOk); + }); + + it('owner should not be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: owner, message: '!play 25' }); + assert(isOk); + }); + + it('owner should not be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: owner, message: '!play 25' }); + assert(isOk); + }); + }); + + describe('#3209 - global cooldown not working on gamble changed command to !play', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + gamble.setCommand('!gamble', '!play'); + // owners should not be persecuted + await AppDataSource.getRepository(Cooldown).update({}, { isOwnerAffected: false }); + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(async () => { + gamble.setCommand('!gamble', '!gamble'); + gamble.enabled = false; + }); + + it('create cooldown on !play [global 300]', async () => { + const [command, type, seconds, quiet] = ['!play', 'global', '300', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, global cooldown for !play was set to 300s'); + }); + + it('check if cooldown is created', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!play' } }); + assert(item.length !== 0); + }); + + it('testuser should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!play 10' }); + assert(isOk); + }); + + it('testuser should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!play 15' }); + assert(!isOk); // second should fail + }); + + it('testuser2 should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!play 25' }); + assert(!isOk); // third should fail + }); + + it('owner should not be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: owner, message: '!play 25' }); + assert(isOk); + }); + }); + + describe('#1352 - command in a sentence', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(() => { + gamble.enabled = false; + }); + + it('test', async () => { + const [command, type, seconds, quiet] = ['!test', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !test was set to 60s'); + + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!test' } }); + assert(item.length !== 0); + + let isOk = await cooldown.check({ sender: testUser, message: 'Lorem Ipsum !test' }); + assert(isOk); + + isOk = await cooldown.check({ sender: testUser, message: 'Lorem Ipsum !test' }); + assert(isOk); // second should fail + }); + }); + + describe('command with subcommand - user', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + gamble.setCommand('!gamble', '!test me'); + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(async () => { + gamble.setCommand('!gamble', '!gamble'); + gamble.enabled = false; + }); + + it('create cooldown on !test me [user 60]', async () => { + const [command, type, seconds, quiet] = ['\'!test me\'', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !test me was set to 60s'); + }); + + it('check if cooldown is created', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!test me' } }); + assert(item.length !== 0); + }); + + it('testuser with `!test me` should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test me 10' }); + assert(isOk); + }); + + it('testuser with `!test me` should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test me 11' }); + assert(!isOk); // second should fail + }); + + it('testuser2 with `!test me` should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!test me 12' }); + assert(isOk); + }); + + it('testuser2 with `!test me` should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!test me 13' }); + assert(!isOk); // second should fail + }); + + it('testuser with `!test` should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test' }); + assert(isOk); + }); + + it('testuser with `!test` should not be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test' }); + assert(isOk); + }); + + it('testuser2 with `!test` should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!test' }); + assert(isOk); + }); + + it('testuser2 with `!test` should not be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!test' }); + assert(isOk); + }); + }); + + describe('command - user', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(() => { + gamble.enabled = false; + }); + + it('create cooldown on !test [user 60]', async () => { + const [command, type, seconds, quiet] = ['!test', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !test was set to 60s'); + }); + + it('check if cooldown is created', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!test' } }); + assert(item.length !== 0); + }); + + it('testuser should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test 10' }); + assert(isOk); + }); + + it('testuser should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test 15' }); + assert(!isOk); // second should fail + }); + + it('testuser2 should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!test 25' }); + assert(isOk); + }); + + it('testuser2 should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!test 15' }); + assert(!isOk); // second should fail + }); + }); + + describe('command - global', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(() => { + gamble.enabled = false; + }); + + it('create cooldown on !test [global 60]', async () => { + const [command, type, seconds, quiet] = ['!test', 'global', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, global cooldown for !test was set to 60s'); + }); + + it('check if cooldown is created', async () => { + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: '!test' } }); + assert(item.length !== 0); + }); + + it('testuser should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test 10' }); + assert(isOk); + }); + + it('testuser should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test 15' }); + assert(!isOk); + }); + + it('testuser2 should be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!test 15' }); + assert(!isOk); + }); + }); + + describe('keyword - user', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(() => { + gamble.enabled = false; + }); + + it('test', async () => { + await AppDataSource.getRepository(Keyword).save({ + keyword: 'me', + response: '(!me)', + enabled: true, + }); + + const [command, type, seconds, quiet] = ['me', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, user cooldown for me was set to 60s'); + + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: 'me' } }); + assert(typeof item !== 'undefined'); + + let isOk = await cooldown.check({ sender: testUser, message: 'me' }); + assert(isOk); + + isOk = await cooldown.check({ sender: testUser, message: 'me' }); + assert(!isOk); // second should fail + + isOk = await cooldown.check({ sender: testUser2, message: 'me' }); + assert(isOk); + }); + }); + + describe('keyword - global', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + + await AppDataSource.getRepository(User).save({ + userName: usermod1.userName, userId: usermod1.userId, isModerator: true, + }); + await AppDataSource.getRepository(User).save({ + userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true, + }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ + userName: owner.userName, userId: owner.userId, isSubscriber: true, + }); + }); + + after(() => { + gamble.enabled = false; + }); + + it('test', async () => { + await AppDataSource.getRepository(Keyword).save({ + keyword: 'me', + response: '(!me)', + enabled: true, + }); + + const [command, type, seconds, quiet] = ['me', 'global', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, global cooldown for me was set to 60s'); + + const item = await AppDataSource.getRepository(Cooldown).findOne({ where: { name: 'me' } }); + assert(typeof item !== 'undefined'); + + let isOk = await cooldown.check({ sender: testUser, message: 'me' }); + assert(isOk); + + isOk = await cooldown.check({ sender: testUser, message: 'me' }); + assert(!isOk); // second should fail + + isOk = await cooldown.check({ sender: testUser2, message: 'me' }); + assert(!isOk); // another user should fail as well + }); + }); +}); diff --git a/backend/test/tests/cooldowns/check_default_values.js b/backend/test/tests/cooldowns/check_default_values.js new file mode 100644 index 000000000..92cba0620 --- /dev/null +++ b/backend/test/tests/cooldowns/check_default_values.js @@ -0,0 +1,139 @@ + +/* global describe it before */ + + +import('../../general.js'); + +import { Cooldown } from '../../../dest/database/entity/cooldown.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { Keyword } from '../../../dest/database/entity/keyword.js'; + +import assert from 'assert'; + +import { db } from '../../general.js'; +import { message, time } from '../../general.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import { AppDataSource } from '../../../dest/database.js'; + +import cooldown from '../../../dest/systems/cooldown.js' +import customcommands from '../../../dest/systems/customcommands.js'; +import gamble from '../../../dest/games/gamble.js'; + +// users +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__', badges: {} }; +const usermod1 = { userId: String(Math.floor(Math.random() * 100000)), userName: 'usermod1', badges: { moderator: 1 } }; +const subuser1 = { userId: String(Math.floor(Math.random() * 100000)), userName: 'subuser1', badges: { subscriber: 1 } }; +const testUser = { userId: String(Math.floor(Math.random() * 100000)), userName: 'test', badges: {} }; +const testUser2 = { userId: String(Math.floor(Math.random() * 100000)), userName: 'test2', badges: {} }; + + +describe('Cooldowns - @func3 - default check', () => { + describe('command - default', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + cooldown.__permission_based__defaultCooldownOfCommandsInSeconds[defaultPermissions.VIEWERS] = 5; + + await AppDataSource.getRepository(User).save({ userName: usermod1.userName, userId: usermod1.userId, isModerator: true }); + await AppDataSource.getRepository(User).save({ userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId, isSubscriber: true }); + + await time.waitMs(5000); + }); + + after(async () => { + gamble.enabled = false; + cooldown.__permission_based__defaultCooldownOfCommandsInSeconds[defaultPermissions.VIEWERS] = 0; + await time.waitMs(5000); + }); + + it('testuser should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test 10' }); + assert(isOk); + }); + + it('testuser should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test 15' }); + assert(!isOk); + }); + + it('wait 2 seconds', async () => { + await time.waitMs(2000); + }); + + it('testuser2 should be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!test 25' }); + assert(!isOk); + }); + + it('subuser1 should NOT be affected by cooldown as it is different permGroup', async () => { + const isOk = await cooldown.check({ sender: subuser1, message: '!test 25' }); + assert(isOk); + }); + + it('wait 4 seconds', async () => { + await time.waitMs(4000); + }); + + it('testuser2 should not be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser2, message: '!test 15' }); + assert(isOk); + }); + }); + + describe('keyword - default', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + cooldown.__permission_based__defaultCooldownOfKeywordsInSeconds[defaultPermissions.VIEWERS] = 5; + + await AppDataSource.getRepository(User).save({ userName: usermod1.userName, userId: usermod1.userId, isModerator: true }); + await AppDataSource.getRepository(User).save({ userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId, isSubscriber: true }); + + await time.waitMs(5000); + }); + + after(async () => { + gamble.enabled = false; + cooldown.__permission_based__defaultCooldownOfKeywordsInSeconds[defaultPermissions.VIEWERS] = 0; + await time.waitMs(5000); + }); + + it('test', async () => { + await AppDataSource.getRepository(Keyword).save({ + keyword: 'me', + response: '(!me)', + enabled: true, + }); + + let isOk = await cooldown.check({ sender: testUser, message: 'me' }); + assert(isOk); + + isOk = await cooldown.check({ sender: testUser, message: 'me' }); + assert(!isOk); // second should fail + + isOk = await cooldown.check({ sender: subuser1, message: 'me' }); + assert(isOk); // another perm group should not fail + + await time.waitMs(2000); + + isOk = await cooldown.check({ sender: testUser2, message: 'me' }); + assert(!isOk); // another user should fail as well + + await time.waitMs(4000); + + isOk = await cooldown.check({ sender: testUser2, message: 'me' }); + assert(isOk); + + }); + }); +}); diff --git a/backend/test/tests/cooldowns/check_should_not_loop.js b/backend/test/tests/cooldowns/check_should_not_loop.js new file mode 100644 index 000000000..efda2ae1d --- /dev/null +++ b/backend/test/tests/cooldowns/check_should_not_loop.js @@ -0,0 +1,37 @@ + +/* global describe it before */ + + +import('../../general.js'); + +import { Cooldown } from '../../../dest/database/entity/cooldown.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { Keyword } from '../../../dest/database/entity/keyword.js'; + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import cooldown from '../../../dest/systems/cooldown.js' + +// users +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__', badges: {} }; +const testUser = { userId: String(Math.floor(Math.random() * 100000)), userName: 'test', badges: {} }; + + +describe('cooldown - @func3 check should not endlessly loop', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId, isSubscriber: true }); + + }); + + it('Command `!someCommand hello` should pass', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!someCommand hello' }); + assert(isOk); + }); +}); diff --git a/backend/test/tests/cooldowns/default_cooldown_can_be_overrided.js b/backend/test/tests/cooldowns/default_cooldown_can_be_overrided.js new file mode 100644 index 000000000..d9199fe37 --- /dev/null +++ b/backend/test/tests/cooldowns/default_cooldown_can_be_overrided.js @@ -0,0 +1,126 @@ + +/* global describe it before */ + + +import('../../general.js'); + +import { Cooldown } from '../../../dest/database/entity/cooldown.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { Keyword } from '../../../dest/database/entity/keyword.js'; + +import assert from 'assert'; + +import { db } from '../../general.js'; +import { message, time } from '../../general.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import { AppDataSource } from '../../../dest/database.js'; + +import cooldown from '../../../dest/systems/cooldown.js' +import customcommands from '../../../dest/systems/customcommands.js'; +import gamble from '../../../dest/games/gamble.js'; + +// users +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__', badges: {} }; +const usermod1 = { userId: String(Math.floor(Math.random() * 100000)), userName: 'usermod1', badges: { moderator: 1 } }; +const subuser1 = { userId: String(Math.floor(Math.random() * 100000)), userName: 'subuser1', badges: { subscriber: 1 } }; +const testUser = { userId: String(Math.floor(Math.random() * 100000)), userName: 'test', badges: {} }; +const testUser2 = { userId: String(Math.floor(Math.random() * 100000)), userName: 'test2', badges: {} }; + + +describe('Cooldowns - @func3 - default cooldown can be overrided', () => { + describe('command - override', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + cooldown.__permission_based__defaultCooldownOfCommandsInSeconds[defaultPermissions.VIEWERS] = 5; + + await AppDataSource.getRepository(User).save({ userName: usermod1.userName, userId: usermod1.userId, isModerator: true }); + await AppDataSource.getRepository(User).save({ userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId, isSubscriber: true }); + + await time.waitMs(5000); + }); + + after(async () => { + gamble.enabled = false; + cooldown.__permission_based__defaultCooldownOfCommandsInSeconds[defaultPermissions.VIEWERS] = 0; + await time.waitMs(5000); + }); + + it('testuser should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test 10' }); + assert(isOk); + }); + + it('testuser should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test 15' }); + assert(!isOk); + }); + + it('Override !test global 0 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!test global 0 true' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for !test was set to 0s'); + }); + + it('testuser should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: '!test 10' }); + assert(isOk); + }); + }); + + describe('keyword - override', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + gamble.enabled = true; + cooldown.__permission_based__defaultCooldownOfKeywordsInSeconds[defaultPermissions.VIEWERS] = 5; + + await AppDataSource.getRepository(User).save({ userName: usermod1.userName, userId: usermod1.userId, isModerator: true }); + await AppDataSource.getRepository(User).save({ userName: subuser1.userName, userId: subuser1.userId, isSubscriber: true }); + await AppDataSource.getRepository(User).save({ userName: testUser.userName, userId: testUser.userId }); + await AppDataSource.getRepository(User).save({ userName: testUser2.userName, userId: testUser2.userId }); + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId, isSubscriber: true }); + + await time.waitMs(5000); + }); + + after(async () => { + gamble.enabled = false; + cooldown.__permission_based__defaultCooldownOfKeywordsInSeconds[defaultPermissions.VIEWERS] = 0; + await time.waitMs(5000); + }); + + it('Create me keyword', async () => { + await AppDataSource.getRepository(Keyword).save({ + keyword: 'me', + response: '(!me)', + enabled: true, + }); + }); + + it('testuser should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: 'me' }); + assert(isOk); + }); + + it('testuser should be affected by cooldown second time', async () => { + const isOk = await cooldown.check({ sender: testUser, message: 'me' }); + assert(!isOk); + }); + + it('Override me global 0 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: 'me global 0 true' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for me was set to 0s'); + }); + + it('testuser should not be affected by cooldown', async () => { + const isOk = await cooldown.check({ sender: testUser, message: 'me' }); + assert(isOk); + }); + }); +}); diff --git a/backend/test/tests/cooldowns/set.js b/backend/test/tests/cooldowns/set.js new file mode 100644 index 000000000..3b967d3f3 --- /dev/null +++ b/backend/test/tests/cooldowns/set.js @@ -0,0 +1,122 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db } from '../../general.js'; +import { message, url } from '../../general.js'; + +import cooldown from '../../../dest/systems/cooldown.js' +import assert from 'assert'; +// users +const owner = { userName: '__broadcaster__' }; + +describe('Cooldowns - set() - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('', async () => { + const r = await cooldown.main({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, 'Usage => ' + url + '/systems/cooldowns'); + }); + + it('!alias', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!alias' }); + assert.strictEqual(r[0].response, 'Usage => ' + url + '/systems/cooldowns'); + }); + + it('alias', async () => { + const r = await cooldown.main({ sender: owner, parameters: 'alias' }); + assert.strictEqual(r[0].response, 'Usage => ' + url + '/systems/cooldowns'); + }); + + it('test global 20', async () => { + const r = await cooldown.main({ sender: owner, parameters: 'test global 20' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for test was set to 20s'); + }); + + it('test user 20', async () => { + const r = await cooldown.main({ sender: owner, parameters: 'test user 20' }); + assert.strictEqual(r[0].response, '$sender, user cooldown for test was set to 20s'); + }); + + it('!test global 20', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!test global 20' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for !test was set to 20s'); + }); + + it('!test user 20', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!test user 20' }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !test was set to 20s'); + }); + + it('test global 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: 'test global 20 true' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for test was set to 20s'); + }); + + it('test user 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: 'test user 20 true' }); + assert.strictEqual(r[0].response, '$sender, user cooldown for test was set to 20s'); + }); + + it('!test global 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!test global 20 true' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for !test was set to 20s'); + }); + + it('!test user 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!test user 20 true' }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !test was set to 20s'); + }); + + it('!한국어 global 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!한국어 global 20 true' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for !한국어 was set to 20s'); + }); + + it('!한국어 user 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!한국어 user 20 true' }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !한국어 was set to 20s'); + }); + + it('한국어 global 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: '한국어 global 20 true' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for 한국어 was set to 20s'); + }); + + it('한국어 user 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: '한국어 user 20 true' }); + assert.strictEqual(r[0].response, '$sender, user cooldown for 한국어 was set to 20s'); + }); + + it('!русский global 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!русский global 20 true' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for !русский was set to 20s'); + }); + + it('!русский user 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!русский user 20 true' }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !русский was set to 20s'); + }); + + it('русский global 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: 'русский global 20 true' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for русский was set to 20s'); + }); + + it('русский user 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: 'русский user 20 true' }); + assert.strictEqual(r[0].response, '$sender, user cooldown for русский was set to 20s'); + }); + + it('unset OK', async () => { + const r = await cooldown.unset({ sender: owner, parameters: '!test' }); + assert.strictEqual(r[0].response, '$sender, cooldown for !test was unset'); + }); + + it('unset without param', async () => { + const r = await cooldown.unset({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, 'Usage => ' + url + '/systems/cooldowns'); + }); +}); diff --git a/backend/test/tests/cooldowns/toggleEnabled.js b/backend/test/tests/cooldowns/toggleEnabled.js new file mode 100644 index 000000000..68ffe83c4 --- /dev/null +++ b/backend/test/tests/cooldowns/toggleEnabled.js @@ -0,0 +1,85 @@ +/* global */ + +import assert from 'assert'; + +import cooldown from '../../../dest/systems/cooldown.js' +import { db } from '../../general.js'; +import { message, url } from '../../general.js'; + +import('../../general.js'); + +// users +const owner = { + userId: String(Math.floor(Math.random() * 100000)), badges: {}, userName: '__broadcaster__', +}; +const testUser = { + userId: String(Math.floor(Math.random() * 100000)), badges: {}, userName: 'test', +}; + +describe('Cooldowns - toggleEnabled() - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('incorrect toggle', async () => { + const [command, type, seconds, quiet] = ['!me', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + const r2 = await cooldown.toggleEnabled({ sender: owner, parameters: command }); + + assert.strictEqual(r[0].response, '$sender, user cooldown for !me was set to 60s'); + assert.strictEqual(r2[0].response, 'Usage => ' + url + '/systems/cooldowns'); + }); + + it('correct toggle - command', async () => { + const [command, type, seconds, quiet] = ['!me', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + const r2 = await cooldown.toggleEnabled({ sender: owner, parameters: `${command} ${type}` }); + + assert.strictEqual(r[0].response, '$sender, user cooldown for !me was set to 60s'); + assert.strictEqual(r2[0].response, '$sender, cooldown for !me was disabled'); + + let isOk = await cooldown.check({ sender: testUser, message: '!me' }); + assert(isOk); + isOk = await cooldown.check({ sender: testUser, message: '!me' }); + assert(isOk); + + const r3 = await cooldown.toggleEnabled({ sender: owner, parameters: `${command} ${type}` }); + assert.strictEqual(r3[0].response, '$sender, cooldown for !me was enabled'); + }); + + it('correct toggle - group', async () => { + const [command, type, seconds, quiet] = ['g:voice', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + const r2 = await cooldown.toggleEnabled({ sender: owner, parameters: `${command} ${type}` }); + + assert.strictEqual(r[0].response, '$sender, user cooldown for g:voice was set to 60s'); + assert.strictEqual(r2[0].response, '$sender, cooldown for g:voice was disabled'); + + let isOk = await cooldown.check({ sender: testUser, message: 'g:voice' }); + assert(isOk); + isOk = await cooldown.check({ sender: testUser, message: 'g:voice' }); + assert(isOk); + + const r3 = await cooldown.toggleEnabled({ sender: owner, parameters: `${command} ${type}` }); + assert.strictEqual(r3[0].response, '$sender, cooldown for g:voice was enabled'); + }); + + it('correct toggle - keyword', async () => { + const [command, type, seconds, quiet] = ['KEKW', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + + const r2 = await cooldown.toggleEnabled({ sender: owner, parameters: `${command} ${type}` }); + + assert.strictEqual(r[0].response, '$sender, user cooldown for KEKW was set to 60s'); + assert.strictEqual(r2[0].response, '$sender, cooldown for KEKW was disabled'); + + let isOk = await cooldown.check({ sender: testUser, message: 'KEKW' }); + assert(isOk); + isOk = await cooldown.check({ sender: testUser, message: 'KEKW' }); + assert(isOk); + + const r3 = await cooldown.toggleEnabled({ sender: owner, parameters: `${command} ${type}` }); + assert.strictEqual(r3[0].response, '$sender, cooldown for KEKW was enabled'); + }); +}); diff --git a/backend/test/tests/cooldowns/toggleModerators.js b/backend/test/tests/cooldowns/toggleModerators.js new file mode 100644 index 000000000..f67a957f6 --- /dev/null +++ b/backend/test/tests/cooldowns/toggleModerators.js @@ -0,0 +1,52 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { db } from '../../general.js'; +import { message, url } from '../../general.js'; + +import cooldown from '../../../dest/systems/cooldown.js' + +import { User } from '../../../dest/database/entity/user.js'; + +// users +const owner = { userId: String(Math.floor(Math.random() * 100000)), badges: {}, userName: '__broadcaster__' }; +const mod = { userId: String(Math.floor(Math.random() * 100000)), badges: {}, userName: 'mod' }; + +describe('Cooldowns - toggleModerators() - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + await AppDataSource.getRepository(User).save({ userName: mod.userName, userId: mod.userId, isModerator: true }); + }); + + it('incorrect toggle', async () => { + const [command, type, seconds, quiet] = ['!me', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + const r2 = await cooldown.toggleModerators({ sender: owner, parameters: command }); + + assert.strictEqual(r[0].response, '$sender, user cooldown for !me was set to 60s'); + assert.strictEqual(r2[0].response, 'Usage => ' + url + '/systems/cooldowns'); + }); + + it('correct toggle', async () => { + const [command, type, seconds, quiet] = ['!me', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !me was set to 60s'); + + const r2 = await cooldown.toggleModerators({ sender: owner, parameters: `${command} ${type}` }); + assert.strictEqual(r2[0].response, '$sender, cooldown for !me was enabled for moderators'); + + let isOk = await cooldown.check({ sender: mod, message: '!me' }); + assert(isOk); + isOk = await cooldown.check({ sender: mod, message: '!me' }); + assert(!isOk); + + const r3 = await cooldown.toggleModerators({ sender: owner, parameters: `${command} ${type}` }); + assert.strictEqual(r3[0].response, '$sender, cooldown for !me was disabled for moderators'); + }); +}); diff --git a/backend/test/tests/cooldowns/toggleOwners.js b/backend/test/tests/cooldowns/toggleOwners.js new file mode 100644 index 000000000..ec05b035e --- /dev/null +++ b/backend/test/tests/cooldowns/toggleOwners.js @@ -0,0 +1,49 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { db } from '../../general.js'; +import { message, url } from '../../general.js'; + +import cooldown from '../../../dest/systems/cooldown.js' + +import { User } from '../../../dest/database/entity/user.js'; + +// users +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__', badges: {} }; + +describe('Cooldowns - toggleOwners() - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + }); + + it('incorrect toggle', async () => { + const [command, type, seconds, quiet] = ['!me', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + const r2 = await cooldown.toggleOwners({ sender: owner, parameters: command }); + + assert.strictEqual(r[0].response, '$sender, user cooldown for !me was set to 60s'); + assert.strictEqual(r2[0].response, 'Usage => ' + url + '/systems/cooldowns'); + }); + + it('correct toggle', async () => { + const [command, type, seconds, quiet] = ['!me', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !me was set to 60s'); + + const r2 = await cooldown.toggleOwners({ sender: owner, parameters: `${command} ${type}` }); + assert.strictEqual(r2[0].response, '$sender, cooldown for !me was enabled for owners'); + let isOk = await cooldown.check({ sender: owner, message: '!me' }); + assert(isOk); + isOk = await cooldown.check({ sender: owner, message: '!me' }); + assert(!isOk); + + const r3 = await cooldown.toggleOwners({ sender: owner, parameters: `${command} ${type}` }); + assert.strictEqual(r3[0].response, '$sender, cooldown for !me was disabled for owners'); + }); +}); diff --git a/backend/test/tests/cooldowns/toggleSubscribers.js b/backend/test/tests/cooldowns/toggleSubscribers.js new file mode 100644 index 000000000..da40a57f5 --- /dev/null +++ b/backend/test/tests/cooldowns/toggleSubscribers.js @@ -0,0 +1,57 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { db } from '../../general.js'; +import { message, url } from '../../general.js'; + +import cooldown from '../../../dest/systems/cooldown.js' + +import { User } from '../../../dest/database/entity/user.js'; + +// users +const owner = { userId: String(Math.floor(Math.random() * 100000)), badges: {}, userName: '__broadcaster__' }; +const subscriber = { userId: String(Math.floor(Math.random() * 100000)), badges: { subscriber: 1 }, userName: 'sub1'}; + +describe('Cooldowns - toggleSubscribers() - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + await AppDataSource.getRepository(User).save({ userName: subscriber.userName, userId: subscriber.userId, isSubscriber: true }); + }); + + it('incorrect toggle', async () => { + const [command, type, seconds, quiet] = ['!me', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + const r2 = await cooldown.toggleSubscribers({ sender: owner, parameters: command }); + + assert.strictEqual(r[0].response, '$sender, user cooldown for !me was set to 60s'); + assert.strictEqual(r2[0].response, 'Usage => ' + url + '/systems/cooldowns'); + }); + + it('correct toggle', async () => { + const [command, type, seconds, quiet] = ['!me', 'user', '60', true]; + const r = await cooldown.main({ sender: owner, parameters: `${command} ${type} ${seconds} ${quiet}` }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !me was set to 60s'); + + const r2 = await cooldown.toggleSubscribers({ sender: owner, parameters: `${command} ${type}` }); + assert.strictEqual(r2[0].response, '$sender, cooldown for !me was disabled for subscribers'); + + let isOk = await cooldown.check({ sender: subscriber, message: '!me' }); + assert(isOk); + isOk = await cooldown.check({ sender: subscriber, message: '!me' }); + assert(isOk); + + const r3 = await cooldown.toggleSubscribers({ sender: owner, parameters: `${command} ${type}` }); + assert.strictEqual(r3[0].response, '$sender, cooldown for !me was enabled for subscribers'); + + isOk = await cooldown.check({ sender: subscriber, message: '!me' }); + assert(isOk); + isOk = await cooldown.check({ sender: subscriber, message: '!me' }); + assert(!isOk); + }); +}); diff --git a/backend/test/tests/customvariable/#3379_customvariable_command_reply.js b/backend/test/tests/customvariable/#3379_customvariable_command_reply.js new file mode 100644 index 000000000..d52724606 --- /dev/null +++ b/backend/test/tests/customvariable/#3379_customvariable_command_reply.js @@ -0,0 +1,84 @@ +/* global describe it before */ + +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import { Parser } from '../../../dest/parser.js'; + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message, user } from '../../general.js'; +import customcommands from '../../../dest/systems/customcommands.js'; + +import assert from 'assert'; +import _ from 'lodash-es'; + +import { Variable } from '../../../dest/database/entity/variable.js'; +import { AppDataSource } from '../../../dest/database.js' + +// stub +_.set(global, 'widgets.custom_variables.io.emit', function () { + return; +}); + +describe('Custom Variable - #3379 - Command reply should return correct reply - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Create command `!test` with `My awesome variable is set to $_variable`', async () => { + const r = await customcommands.add({ sender: user.owner, parameters: '-c !test -r My awesome variable is set to $_variable' }); + assert.strictEqual(r[0].response, '$sender, command !test was added'); + }); + + it(`Create initial value 0 of $_variable`, async () => { + await Variable.create({ + variableName: '$_variable', + readOnly: false, + currentValue: '0', + type: 'number', + responseType: 2, + permission: defaultPermissions.VIEWERS, + evalValue: '', + usableOptions: [], + }).save(); + }); + + it ('`!test` should return `My awesome variable is set to 0`', async () => { + customcommands.run({ sender: user.owner, message: '!test' }); + await message.isSentRaw('My awesome variable is set to 0', user.owner); + }); + + describe('!_test 5', () => { + it(`Run !_test 5`, async () => { + const parse = new Parser({ sender: user.owner, message: '!test 5', skip: false, quiet: false }); + await parse.process(); + }); + + it('Expecting `My awesome variable is set to 5`', async () => { + await message.isSentRaw(`My awesome variable is set to 5`, user.owner, 1000); + }); + }); + + describe('!_test +', () => { + it(`Run !_test +`, async () => { + const parse = new Parser({ sender: user.owner, message: '!test +', skip: false, quiet: false }); + await parse.process(); + }); + + it('Expecting `My awesome variable is set to 6`', async () => { + await message.isSentRaw(`My awesome variable is set to 6`, user.owner, 1000); + }); + }); + + describe('!_test -', () => { + it(`Run !_test -`, async () => { + const parse = new Parser({ sender: user.owner, message: '!test -', skip: false, quiet: false }); + await parse.process(); + }); + + it('Expecting `My awesome variable is set to 5`', async () => { + await message.isSentRaw(`My awesome variable is set to 5`, user.owner, 1000); + }); + }); +}); diff --git a/backend/test/tests/customvariable/#3737_infinite_script_loop_should_not_lock_bot.js b/backend/test/tests/customvariable/#3737_infinite_script_loop_should_not_lock_bot.js new file mode 100644 index 000000000..83e77c668 --- /dev/null +++ b/backend/test/tests/customvariable/#3737_infinite_script_loop_should_not_lock_bot.js @@ -0,0 +1,34 @@ +import('../../general.js'); +import assert from 'assert'; + +import { runScript } from '../../../dest/helpers/customvariables/runScript.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +describe('Custom Variable - #3737 - Infinite script loop should not lock bot - @func1', () => { + let result = ''; + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Run infinite loop script', async () => { + result = await runScript('let i = 0; while(true) { i++; } return i;', {}); + }); + + it ('We should have loop error after 100000 operations', async () => { + await message.debug('customvariables.eval', 'Running script seems to be in infinite loop.'); + }); + + it ('We should have empty result', async () => { + assert.strictEqual(result, ''); + }); + + it ('Run normal loop script', async () => { + result = await runScript('let i = 0; while(true) { if (i<10) { i++; } else { break; } } return i;', {}); + }); + + it ('We should have eval result', async () => { + assert.strictEqual(result, 10); + }); +}); diff --git a/backend/test/tests/customvariable/#3820_for_loop_should_work_correctly.js b/backend/test/tests/customvariable/#3820_for_loop_should_work_correctly.js new file mode 100644 index 000000000..5e29f3d16 --- /dev/null +++ b/backend/test/tests/customvariable/#3820_for_loop_should_work_correctly.js @@ -0,0 +1,25 @@ +/* global describe it before */ + + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import { runScript } from '../../../dest/helpers/customvariables/runScript.js'; +import assert from 'assert'; + +describe('Custom Variable - #3820 - For loop should work corectly - @func1', () => { + let result = ''; + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Run for loop script', async () => { + result = await runScript('let i = 0; for(let j=0; j<10; j++) { i++; } return i;', {}); + }); + + it ('We should have eval result', async () => { + assert.strictEqual(result, 10); + }); +}); diff --git a/backend/test/tests/customvariable/#3879_eval_should_trigger_with_param.js b/backend/test/tests/customvariable/#3879_eval_should_trigger_with_param.js new file mode 100644 index 000000000..1295600af --- /dev/null +++ b/backend/test/tests/customvariable/#3879_eval_should_trigger_with_param.js @@ -0,0 +1,102 @@ +/* global describe it before */ + +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import { Parser } from '../../../dest/parser.js'; + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import customcommands from '../../../dest/systems/customcommands.js'; +import { user } from '../../general.js'; + +import assert from 'assert'; +import _ from 'lodash-es'; + +import { Variable } from '../../../dest/database/entity/variable.js'; +import { AppDataSource } from '../../../dest/database.js' + +// stub +_.set(global, 'widgets.custom_variables.io.emit', function () { + return; +}); + +describe('Custom Variable - #3879 - Eval should trigger with param with proper permissions - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it ('Create command `!test` with `My awesome variable is set to $_variable`', async () => { + const r = await customcommands.add({ sender: user.owner, parameters: '-c !test -r My awesome variable is set to $_variable' }); + assert.strictEqual(r[0].response, '$sender, command !test was added'); + }); + + it(`Create eval $_variable to return param`, async () => { + await Variable.create({ + variableName: '$_variable', + readOnly: false, + currentValue: '0', + type: 'eval', + responseType: 2, + runEvery: 0, + permission: defaultPermissions.MODERATORS, + evalValue: 'return param || "no param sent";', + usableOptions: [], + }).save(); + }); + + describe('!_test 4 by owner', () => { + it(`Run !_test 4`, async () => { + const parse = new Parser({ sender: user.owner, message: '!test 4', skip: false, quiet: false }); + await parse.process(); + }); + + it('Expecting `My awesome variable is set to 4`', async () => { + await message.isSentRaw(`My awesome variable is set to 4`, user.owner, 1000); + }); + }); + describe('!_test 5 by mod', () => { + it(`Run !_test 5`, async () => { + const parse = new Parser({ sender: user.mod, message: '!test 5', skip: false, quiet: false }); + await parse.process(); + }); + + it('Expecting `My awesome variable is set to 5`', async () => { + await message.isSentRaw(`My awesome variable is set to 5`, user.mod, 1000); + }); + }); + describe('!_test 6 by viewer', () => { + it(`Run !_test 6`, async () => { + const parse = new Parser({ sender: user.viewer, message: '!test 6', skip: false, quiet: false }); + await parse.process(); + }); + + it('Expecting old value `My awesome variable is set to 5`', async () => { + await message.isSentRaw(`My awesome variable is set to 5`, user.viewer, 1000); + }); + }); + + describe('!_test by viewer', () => { + it(`Run !_test`, async () => { + const parse = new Parser({ sender: user.viewer, message: '!test', skip: false, quiet: false }); + await parse.process(); + }); + + it('Expecting old value `My awesome variable is set to 5`', async () => { + await message.isSentRaw(`My awesome variable is set to 5`, user.viewer, 1000); + }); + }); + + describe('!_test by mod', () => { + it(`Run !_test`, async () => { + const parse = new Parser({ sender: user.mod, message: '!test', skip: false, quiet: false }); + await parse.process(); + }); + + it('Expecting value `My awesome variable is set to no param sent`', async () => { + await message.isSentRaw(`My awesome variable is set to no param sent`, user.mod, 1000); + }); + }); +}); diff --git a/backend/test/tests/customvariable/#4083_get_url_on_eval_should_return_correct_value.js b/backend/test/tests/customvariable/#4083_get_url_on_eval_should_return_correct_value.js new file mode 100644 index 000000000..56169c007 --- /dev/null +++ b/backend/test/tests/customvariable/#4083_get_url_on_eval_should_return_correct_value.js @@ -0,0 +1,55 @@ +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; + +import('../../general.js'); + +import { db, message, user } from '../../general.js'; + +import assert from 'assert'; + +import _ from 'lodash-es'; +import axios from 'axios'; + +import { Variable } from '../../../dest/database/entity/variable.js'; + +import { v4 } from 'uuid' + +// stub +_.set(global, 'widgets.custom_variables.io.emit', function () { + return; +}); + +describe('Custom Variable - #4083 - Get url on eval should return correct value - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + const urlId = v4(); + it(`Create eval $_variable to return Date.now()`, async () => { + await (Variable.create({ + variableName: '$_variable', + readOnly: false, + currentValue: '0', + type: 'eval', + responseType: 2, + permission: defaultPermissions.MODERATORS, + evalValue: 'return Date.now();', + runEvery: 0, + usableOptions: [], + urls: [{ + id: urlId, + GET: true, + POST: true, + showResponse: true, + }], + }).save()); + }); + + it(`Fetch endpoint for value and check`, async () => { + const now = Date.now(); + const response = await axios.get(`http://localhost:20000/customvariables/${urlId}`); + console.log(JSON.stringify(response.data)); + assert(response.data.value > now && response.data.value < Date.now()); + }); +}); diff --git a/backend/test/tests/customvariable/getURL.js b/backend/test/tests/customvariable/getURL.js new file mode 100644 index 000000000..1f65e31f9 --- /dev/null +++ b/backend/test/tests/customvariable/getURL.js @@ -0,0 +1,110 @@ +/* global describe it before */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' + +import { v4 } from 'uuid'; + +import { Variable } from '../../../dest/database/entity/variable.js'; +import {getURL} from '../../../dest/helpers/customvariables/getURL.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import('../../general.js'); +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +describe('Custom Variable - helpers/customvariables/getURL - @func1', () => { + let urlId = v4(); + let urlIdWithoutGET = v4(); + + before(async () => { + await db.cleanup(); + await message.prepare(); + + await Variable.create({ + variableName: '$_variable', + readOnly: false, + currentValue: '0', + type: 'number', + responseType: 2, + permission: defaultPermissions.VIEWERS, + evalValue: '', + usableOptions: [], + urls: [ + { GET: true, POST: false, showResponse: false, id: urlId }, + { GET: false, POST: false, showResponse: false, id: urlIdWithoutGET } + ] + }).save(); + }); + + it ('with enabled GET', async () => { + const res = { + _status: 0, + _toSend: null, + status(value) { + this._status = value; + return this; + }, + send(value) { + this._toSend = value; + return this; + }, + }; + await getURL({ + params: { + id: urlId, + }, + }, res); + + assert.strictEqual(res._toSend.value, '0'); + assert.strictEqual(res._toSend.code, undefined); + assert.strictEqual(res._status, 200); + }); + + it ('with disabled GET', async () => { + const res = { + _status: 0, + _toSend: null, + status(value) { + this._status = value; + return this; + }, + send(value) { + this._toSend = value; + return this; + }, + }; + await getURL({ + params: { + id: urlIdWithoutGET, + }, + }, res); + + assert.strictEqual(res._toSend.error, 'This endpoint is not enabled for GET'); + assert.strictEqual(res._toSend.code, 403); + assert.strictEqual(res._status, 403); + }); + + it ('Nonexistent id', async () => { + const res = { + _status: 0, + _toSend: null, + status(value) { + this._status = value; + return this; + }, + send(value) { + this._toSend = value; + return this; + }, + }; + await getURL({ + params: { + id: v4(), + }, + }, res); + + assert.strictEqual(res._toSend.error, 'Variable not found'); + assert.strictEqual(res._toSend.code, 404); + assert.strictEqual(res._status, 404); + }); +}); diff --git a/backend/test/tests/customvariable/postURL.js b/backend/test/tests/customvariable/postURL.js new file mode 100644 index 000000000..9f73f5046 --- /dev/null +++ b/backend/test/tests/customvariable/postURL.js @@ -0,0 +1,225 @@ +/* global describe it before */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' + +import { v4 } from 'uuid'; + +import { Variable } from '../../../dest/database/entity/variable.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import('../../general.js'); +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import {postURL} from '../../../dest/helpers/customvariables/postURL.js'; + +describe('Custom Variable - helpers/customvariables/postURL - @func1', () => { + let urlId = v4(); + let urlIdWithoutPOST = v4(); + let urlIdWithResponse1 = v4(); + let urlIdWithResponse2 = v4(); + + before(async () => { + await db.cleanup(); + await message.prepare(); + + Variable.create({ + variableName: '$_variable', + readOnly: false, + currentValue: '0', + type: 'number', + responseType: 2, + permission: defaultPermissions.VIEWERS, + evalValue: '', + usableOptions: [], + urls: [ + { GET: false, POST: true, showResponse: false, id: urlId }, + { GET: false, POST: false, showResponse: false, id: urlIdWithoutPOST } + ] + }).save(); + + Variable.create({ + variableName: '$_variable2', + readOnly: false, + currentValue: '0', + type: 'number', + responseType: 0, + permission: defaultPermissions.VIEWERS, + evalValue: '', + usableOptions: [], + urls: [{ GET: false, POST: true, showResponse: true, id: urlIdWithResponse1 }] + }).save(); + + Variable.create({ + variableName: '$_variable3', + readOnly: false, + currentValue: '0', + type: 'number', + responseType: 1, + responseText: 'This is custom update text: $value', + permission: defaultPermissions.VIEWERS, + evalValue: '', + usableOptions: [], + urls: [{ GET: false, POST: true, showResponse: true, id: urlIdWithResponse2 }] + }).save(); + }); + + it ('with enabled POST - correct value', async () => { + const res = { + _status: 0, + _toSend: null, + status(value) { + this._status = value; + return this; + }, + send(value) { + this._toSend = value; + return this; + }, + }; + await postURL({ + params: { + id: urlId, + }, + body: { + value: 100, + }, + }, res); + assert.strictEqual(res._toSend.oldValue, '0'); + assert.strictEqual(res._toSend.value, '100'); + assert.strictEqual(res._toSend.code, undefined); + assert.strictEqual(res._status, 200); + await message.isNotSentRaw('@__bot__, $_variable2 was set to 101.', '__bot__'); + await message.isNotSentRaw('This is custom update text: 101', '__bot__'); + }); + + it ('with enabled POST and response type 0 - correct value', async () => { + const res = { + _status: 0, + _toSend: null, + status(value) { + this._status = value; + return this; + }, + send(value) { + this._toSend = value; + return this; + }, + }; + await postURL({ + params: { + id: urlIdWithResponse1, + }, + body: { + value: 101, + }, + }, res); + assert.strictEqual(res._toSend.oldValue, '0'); + assert.strictEqual(res._toSend.value, '101'); + assert.strictEqual(res._toSend.code, undefined); + assert.strictEqual(res._status, 200); + await message.isSentRaw('@__bot__, $_variable2 was set to 101.', '__bot__'); + }); + + it ('with enabled POST and response type 1 - correct value', async () => { + const res = { + _status: 0, + _toSend: null, + status(value) { + this._status = value; + return this; + }, + send(value) { + this._toSend = value; + return this; + }, + }; + await postURL({ + params: { + id: urlIdWithResponse2, + }, + body: { + value: 101, + }, + }, res); + assert.strictEqual(res._toSend.oldValue, '0'); + assert.strictEqual(res._toSend.value, '101'); + assert.strictEqual(res._toSend.code, undefined); + assert.strictEqual(res._status, 200); + await message.isSentRaw('This is custom update text: 101', '__bot__'); + }); + + it ('with enabled POST - incorrect value', async () => { + const res = { + _status: 0, + _toSend: null, + status(value) { + this._status = value; + return this; + }, + send(value) { + this._toSend = value; + return this; + }, + }; + await postURL({ + params: { + id: urlId, + }, + body: { + value: 'lorem ipsum', + }, + }, res); + + assert.strictEqual(res._toSend.error, 'This value is not applicable for this endpoint'); + assert.strictEqual(res._toSend.code, 400); + assert.strictEqual(res._status, 400); + }); + + it ('with disabled POST', async () => { + const res = { + _status: 0, + _toSend: null, + status(value) { + this._status = value; + return this; + }, + send(value) { + this._toSend = value; + return this; + }, + }; + await postURL({ + params: { + id: urlIdWithoutPOST, + }, + }, res); + + assert.strictEqual(res._toSend.error, 'This endpoint is not enabled for POST'); + assert.strictEqual(res._toSend.code, 403); + assert.strictEqual(res._status, 403); + }); + + it ('Nonexistent id', async () => { + const res = { + _status: 0, + _toSend: null, + status(value) { + this._status = value; + return this; + }, + send(value) { + this._toSend = value; + return this; + }, + }; + await postURL({ + params: { + id: v4(), + }, + }, res); + + assert.strictEqual(res._toSend.error, 'Variable not found'); + assert.strictEqual(res._toSend.code, 404); + assert.strictEqual(res._status, 404); + }); +}); diff --git a/backend/test/tests/customvariable/return_random_user.js b/backend/test/tests/customvariable/return_random_user.js new file mode 100644 index 000000000..d93ad643a --- /dev/null +++ b/backend/test/tests/customvariable/return_random_user.js @@ -0,0 +1,32 @@ +import assert from 'assert'; + +import('../../general.js'); + +import { runScript } from '../../../dest/helpers/customvariables/runScript.js'; +import { db, message, user } from '../../general.js'; + +describe('Custom Variable - Return random user - @func1', () => { + let result = ''; + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it ('Run infinite loop script', async () => { + result = await runScript('return (await randomViewer()).userName;', {}); + }); + + it ('We should have some result', async () => { + assert.strictEqual([ + '__viewer__', + '__viewer2__', + '__viewer3__', + '__viewer4__', + '__viewer5__', + '__viewer6__', + '__viewer7__', + '__mod__', + ].includes(result), true); + }); +}); diff --git a/backend/test/tests/emotes/emotes_combo.js b/backend/test/tests/emotes/emotes_combo.js new file mode 100644 index 000000000..845f0a911 --- /dev/null +++ b/backend/test/tests/emotes/emotes_combo.js @@ -0,0 +1,173 @@ +import assert from 'assert'; + +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; + +import { translate } from '../../../dest/translate.js'; +import { db, message, user } from '../../general.js'; + + +const emotesOffsetsKappa = new Map(); +emotesOffsetsKappa.set('25', ['0-4']); + +const emotesOffsetsHeyGuys = new Map(); +emotesOffsetsHeyGuys.set('30259', ['0-6']); + + +describe('Emotes - combo - @func2', () => { + let emotes = null; + beforeEach(async () => { + await db.cleanup(); + emotes = (await import('../../../dest/systems/emotescombo.js')).default + }); + describe('Emotes combo should send proper message after 3 emotes', () => { + let comboLastBreak = 0; + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + // we run it twice as to test without cooldown + for (let j = 0; j < 2; j++) { + for (let i = 0; i < 3; i++) { + it('Send a message with Kappa emote', async () => { + await emotes.containsEmotes({ + emotesOffsets: emotesOffsetsKappa, + sender: user.owner, + parameters: 'Kappa', + message: 'Kappa', + }); + }); + } + + it ('Send a message with HeyGuys emote', async () => { + await emotes.containsEmotes({ + emotesOffsets: emotesOffsetsHeyGuys, + sender: user.owner, + parameters: 'HeyGuys', + message: 'HeyGuys', + }); + }); + + it ('We are expecting KAPPA combo break message', async () => { + await message.isSentRaw('3x Kappa combo', user.owner); + }); + + it ('Combo last break should be updated', async () => { + assert(comboLastBreak !== emotes.comboLastBreak); + comboLastBreak = emotes.comboLastBreak; + }); + } + }); + + describe('Emotes combo should send proper message after 3 and 3 emotes', () => { + let comboLastBreak = 0; + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + emotes.comboEmoteCount = 0; + }); + + // we run it twice as to test without cooldown + for (let j = 0; j < 2; j++) { + for (let i = 0; i < 3; i++) { + it('Send a message with Kappa emote', async () => { + await emotes.containsEmotes({ + emotesOffsets: emotesOffsetsKappa, + sender: user.owner, + parameters: 'Kappa', + message: 'Kappa', + }); + }); + } + for (let i = 0; i < 3; i++) { + it('Send a message with HeyGuys emote', async () => { + await emotes.containsEmotes({ + emotesOffsets: emotesOffsetsHeyGuys, + sender: user.owner, + parameters: 'HeyGuys', + message: 'HeyGuys', + }); + }); + } + + it('Send a message with Kappa emote', async () => { + await emotes.containsEmotes({ + emotesOffsets: emotesOffsetsKappa, + sender: user.owner, + parameters: 'Kappa', + message: 'Kappa', + }); + }); + + it ('We are expecting KAPPA combo break message', async () => { + await message.isSentRaw('3x Kappa combo', user.owner); + }); + + it ('We are expecting HEYGUYS combo break message', async () => { + await message.isSentRaw('3x HeyGuys combo', user.owner); + }); + + it ('Combo last break should be updated', async () => { + assert(comboLastBreak !== emotes.comboLastBreak); + comboLastBreak = emotes.comboLastBreak; + }); + } + }); + + describe('Emotes combo should send proper message after 3 emotes with cooldown', () => { + let comboLastBreak = 0; + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + emotes.comboLastBreak = 0; + emotes.comboCooldown = 60; + emotes.comboEmoteCount = 0; + }); + after(() => { + emotes.comboCooldown = 0; + }); + + // we run it twice as to test without cooldown + for (let j = 0; j < 2; j++) { + for (let i = 0; i < 3; i++) { + it('Send a message with Kappa emote', async () => { + console.log(emotes.comboEmote) + console.log(emotes.comboEmoteCount) + await emotes.containsEmotes({ + emotesOffsets: emotesOffsetsKappa, + sender: user.owner, + parameters: 'Kappa', + message: 'Kappa', + }); + }); + } + + it ('Send a message with HeyGuys emote', async () => { + await emotes.containsEmotes({ + emotesOffsets: emotesOffsetsHeyGuys, + sender: user.owner, + parameters: 'HeyGuys', + message: 'HeyGuys', + }); + }); + + it ('We are expecting KAPPA combo break message', async () => { + await message.isSentRaw('3x Kappa combo', user.owner); + }); + + if (j === 0) { + it ('Combo last break should be updated', async () => { + assert(comboLastBreak !== emotes.comboLastBreak); + comboLastBreak = emotes.comboLastBreak; + }); + } else { + it ('Combo last break should not be updated', async () => { + assert(comboLastBreak === emotes.comboLastBreak); + }); + } + } + }); +}); diff --git a/backend/test/tests/events/cheer.js b/backend/test/tests/events/cheer.js new file mode 100644 index 000000000..ee18d4e4c --- /dev/null +++ b/backend/test/tests/events/cheer.js @@ -0,0 +1,60 @@ +import assert from 'assert'; + +import('../../general.js'); + +import { v4 as uuidv4 } from 'uuid'; + +import events from '../../../dest/events.js'; +import { Event } from '../../../dest/database/entity/event.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { AppDataSource } from '../../../dest/database.js'; +import * as changelog from '../../../dest/helpers/user/changelog.js'; +import { time } from '../../general.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +describe('Events - cheer event - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + describe('#1699 - Cheer event is not waiting for user to save id', function () { + before(async function () { + await AppDataSource.getRepository(Event).save({ + id: uuidv4(), + name: 'cheer', + givenName: 'Cheer alert', + triggered: {}, + definitions: {}, + filter: '', + isEnabled: true, + operations: [{ + name: 'run-command', + definitions: { + isCommandQuiet: false, + commandToRun: '!points add $username (math.$bits*10)', + }, + }], + }); + }); + + for (const username of ['losslezos', 'rigneir', 'mikasa_hraje', 'foufhs']) { + const userId = String(Math.floor(Math.random() * 10000)); + describe(username + ' cheer event', () => { + it('trigger cheer event for 1 bit - ' + username, async () => { + await events.fire('cheer', { userName: username, userId, bits: 1, message: Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 5) }); + }); + + it('we are expecting message to be sent', async () => { + await message.isSentRaw(`@${username} just received 10 points!`, username); + }); + + it('user should have 10 points', async () => { + const points = (await import('../../../dest/systems/points.js')).default; + assert.strict.equal(await points.getPointsOf(userId), 10); + }); + }); + } + }); +}); diff --git a/backend/test/tests/events/discord#752632256270696478_incorrect_parse_of_attrs.js b/backend/test/tests/events/discord#752632256270696478_incorrect_parse_of_attrs.js new file mode 100644 index 000000000..2d0883fb0 --- /dev/null +++ b/backend/test/tests/events/discord#752632256270696478_incorrect_parse_of_attrs.js @@ -0,0 +1,63 @@ + +/* global describe it before */ + +import { v4 as uuidv4 } from 'uuid'; + +import('../../general.js'); +import { Event } from '../../../dest/database/entity/event.js'; +import { User } from '../../../dest/database/entity/user.js'; +import events from '../../../dest/events.js'; +import * as log from '../../../dest/helpers/log.js'; + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import { time } from '../../general.js'; + +const userName = 'randomPerson'; + +describe('discord#752632256270696478 - event attrs are not correctly parsed - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await AppDataSource.getRepository(Event).save({ + id: uuidv4(), + name: 'tip', + givenName: 'Tip alert', + triggered: {}, + definitions: {}, + filter: '', + isEnabled: true, + operations: [{ + name: 'send-chat-message', + definitions: { + messageToSend: '$username ; $amount ; $currency ; $message ; $amountInBotCurrency ; $currencyInBot', + }, + }], + }); + + await AppDataSource.getRepository(User).save({ userName: userName, userId: String(Math.floor(Math.random() * 100000)) }); + }); + + it('trigger tip event for 10 EUR - ' + userName, async () => { + log.tip(`${userName}, amount: 10.00EUR, message: Ahoj jak je`); + events.fire('tip', { + userName, + amount: 10.00, + message: 'Ahoj jak je', + currency: 'EUR', + amountInBotCurrency: '100', + currencyInBot: 'CZK', + }); + }); + + it('wait 1s', async () => { + await time.waitMs(1000); + }); + + it('we are expecting correctly parsed message', async () => { + await message.debug('sendMessage.message', '@randomPerson ; 10 ; EUR ; Ahoj jak je ; 100 ; CZK'); + }); +}); diff --git a/backend/test/tests/events/discord#812872046077411328_run_command_should_be_able_trigger_caster_perm_command.js b/backend/test/tests/events/discord#812872046077411328_run_command_should_be_able_trigger_caster_perm_command.js new file mode 100644 index 000000000..604dd4539 --- /dev/null +++ b/backend/test/tests/events/discord#812872046077411328_run_command_should_be_able_trigger_caster_perm_command.js @@ -0,0 +1,78 @@ +/* global describe it before */ + +import _ from 'lodash-es'; +import { v4 as uuidv4 } from 'uuid'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +import { Event } from '../../../dest/database/entity/event.js'; +import { User } from '../../../dest/database/entity/user.js'; +import events from '../../../dest/events.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import alias from '../../../dest/systems/alias.js'; +import commercial from '../../../dest/systems/commercial.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import { url } from '../../general.js'; +import { time } from '../../general.js'; +import { user } from '../../general.js'; + +describe('Events - event run command should be able to run caster command and alias - https://discord.com/channels/317348946144002050/317349069024395264/812872046077411328 - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + commercial.setCommand('!commercial', '!test'); + + const event = {}; + event.id = uuidv4(); + event.name = 'follow'; + event.givenName = 'Follow alert'; + event.triggered = {}; + event.definitions = {}; + event.filter = ''; + event.isEnabled = true; + event.operations = [{ + name: 'run-command', + definitions: { + commandToRun: '!test', + isCommandQuiet: false, + }, + }, { + name: 'run-command', + definitions: { + commandToRun: '!test2', + isCommandQuiet: false, + }, + }]; + const a = await alias.add({ sender: user.owner, parameters: '-a !test2 -c !command -p ' + defaultPermissions.CASTERS }); + await AppDataSource.getRepository(Event).save(event); + }); + + after(() => { + commercial.setCommand('!commercial', '!commercial'); + }); + + for (const follower of [user.viewer, user.viewer2, user.viewer3]) { + it ('reset message', async () => { + await message.prepare(); + }); + it('trigger follow event', async () => { + await events.fire('follow', { userName: follower.userName, userId: follower.userId }); + }); + + it('command should be triggered', async () => { + await message.isSentRaw(`Usage: !test [duration] [optional-message]`, follower); + }); + + it('alias should be triggered', async () => { + await message.isSentRaw(`Usage => ${url}/systems/custom-commands`, follower); + }); + + it('wait 5s', async () => { + await time.waitMs(5000); + }); + } +}); diff --git a/backend/test/tests/events/discord#814270704161652766_run_command_should_skip_pricing_and_cooldown.js b/backend/test/tests/events/discord#814270704161652766_run_command_should_skip_pricing_and_cooldown.js new file mode 100644 index 000000000..7e265be37 --- /dev/null +++ b/backend/test/tests/events/discord#814270704161652766_run_command_should_skip_pricing_and_cooldown.js @@ -0,0 +1,69 @@ +/* global */ + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { v4 as uuidv4 } from 'uuid'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +import { Event } from '../../../dest/database/entity/event.js'; +import { User } from '../../../dest/database/entity/user.js'; +import events from '../../../dest/events.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import alias from '../../../dest/systems/alias.js'; +import commercial from '../../../dest/systems/commercial.js'; +import cooldown from '../../../dest/systems/cooldown.js' +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import { time } from '../../general.js'; +import { user } from '../../general.js'; + +describe('Events - event run command should be able to skip pricing and cooldown - https://discord.com/channels/317348946144002050/619437014001123338/814270704161652766 - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + const event = {}; + event.id = uuidv4(); + event.name = 'follow'; + event.givenName = 'Follow alert'; + event.triggered = {}; + event.definitions = {}; + event.filter = ''; + event.isEnabled = true; + event.operations = [{ + name: 'run-command', + definitions: { + commandToRun: '!test', + isCommandQuiet: false, + }, + }]; + await AppDataSource.getRepository(Event).save(event); + await alias.add({ sender: user.owner, parameters: '-a !test -c !commercial -p ' + defaultPermissions.CASTERS }); + + const r = await cooldown.main({ sender: user.owner, parameters: '!test global 20 true' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for !test was set to 20s'); + }); + + for (const follower of [user.viewer, user.viewer2, user.viewer3]) { + it ('reset message', async () => { + await message.prepare(); + }); + it('trigger follow event', async () => { + await events.fire('follow', { userName: follower.userName, userId: follower.userId }); + }); + + it('parsers should be skipped', async () => { + await message.debug('parser.process', 'Skipped Cooldown.check'); + await message.debug('parser.process', 'Skipped Price.check'); + await message.debug('parser.process', 'Skipped Points.messagePoints'); + }); + + it('command should be triggered', async () => { + await message.isSentRaw(`Usage: !commercial [duration] [optional-message]`, follower); + }); + } +}); diff --git a/backend/test/tests/events/discord#815733350039158795_run_command_should_correctly_check_filters.js b/backend/test/tests/events/discord#815733350039158795_run_command_should_correctly_check_filters.js new file mode 100644 index 000000000..9438efde2 --- /dev/null +++ b/backend/test/tests/events/discord#815733350039158795_run_command_should_correctly_check_filters.js @@ -0,0 +1,111 @@ +import _ from 'lodash-es'; +import { v4 as uuidv4 } from 'uuid'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +import { Commands } from '../../../dest/database/entity/commands.js'; +import { Event } from '../../../dest/database/entity/event.js'; +import { User } from '../../../dest/database/entity/user.js'; +import events from '../../../dest/events.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import { isBotSubscriber } from '../../../dest/helpers/user/isBot.js'; +import alias from '../../../dest/systems/alias.js'; +import commercial from '../../../dest/systems/commercial.js'; +import customcommands from '../../../dest/systems/customcommands.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import { time } from '../../general.js'; +import { user } from '../../general.js'; + +let event; +describe('Events - event run command should correctly parse filters and be able to use CASTERS permissions - https://discord.com/channels/317348946144002050/619437014001123338/8157333500391587958 - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + event = {}; + event.id = uuidv4(); + event.name = 'follow'; + event.givenName = 'Follow alert'; + event.triggered = {}; + event.definitions = {}; + event.filter = ''; + event.isEnabled = true; + event.operations = [{ + name: 'run-command', + definitions: { + commandToRun: '!test33', + isCommandQuiet: false, + }, + }]; + await AppDataSource.getRepository(Event).save(event); + + const command = new Commands(); + command.id = '1a945d76-2d3c-4c7a-ae03-e0daf17142c5'; + command.command = '!test33'; + command.enabled = true; + command.visible = true; + command.group = null; + command.responses = [{ + stopIfExecuted: false, + response: '1', + filter: '$isBotSubscriber', + order: 0, + permission: defaultPermissions.CASTERS, + }, { + stopIfExecuted: false, + response: '2', + filter: `$sender === '${user.viewer2.userName}'`, + order: 1, + permission: defaultPermissions.MODERATORS, + }, { + stopIfExecuted: false, + response: '3', + filter: '', + order: 2, + permission: defaultPermissions.VIEWERS, + }]; + await command.save(); + }); + + it('set bot as subscriber', async () => { + await message.prepare(); + isBotSubscriber(true); + }); + + it('trigger follow event', async () => { + await events.fire('follow', { userName: user.viewer.userName, userId: user.viewer.userId }); + }); + + it('commands should be triggered', async () => { + await message.isSentRaw(`1`, user.viewer); + }); + + it('unset bot as subscriber', async () => { + await message.prepare(); + isBotSubscriber(false); + }); + + it('trigger follow event', async () => { + await events.fire('follow', { userName: user.viewer2.userName, userId: user.viewer2.userId }); + }); + + it('commands should be triggered', async () => { + await message.isSentRaw(`2`, user.viewer2); + }); + + it('trigger follow event', async () => { + await events.fire('follow', { userName: user.viewer3.userName, userId: user.viewer3.userId }); + }); + + it('commands should be triggered', async () => { + await message.isSentRaw(`3`, user.viewer3); + }); + + it('set bot as subscriber', async () => { + await message.prepare(); + isBotSubscriber(true); + }); +}); diff --git a/backend/test/tests/events/follow.js b/backend/test/tests/events/follow.js new file mode 100644 index 000000000..702244092 --- /dev/null +++ b/backend/test/tests/events/follow.js @@ -0,0 +1,64 @@ +/* global describe it before */ + +import _ from 'lodash-es'; +import { v4 as uuidv4 } from 'uuid'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +import { Event } from '../../../dest/database/entity/event.js'; +import { User } from '../../../dest/database/entity/user.js'; +import events from '../../../dest/events.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import { time } from '../../general.js'; +import { user } from '../../general.js'; + +describe('Events - follow event - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + describe('#1370 - Second follow event didn\'t trigger event ', function () { + before(async function () { + const event = {}; + event.id = uuidv4(); + event.name = 'follow'; + event.givenName = 'Follow alert'; + event.triggered = {}; + event.definitions = {}; + event.filter = ''; + event.isEnabled = true; + event.operations = [{ + name: 'emote-explosion', + definitions: { emotesToExplode: 'purpleHeart <3' }, + }, { + name: 'run-command', + definitions: { + commandToRun: '!duel', + isCommandQuiet: true, + }, + }, { + name: 'send-chat-message', + definitions: { messageToSend: 'Diky za follow, $username!' }, + }]; + await AppDataSource.getRepository(Event).save(event); + }); + + for (const follower of [user.viewer, user.viewer2, user.viewer3]) { + it('trigger follow event', async () => { + await events.fire('follow', { userName: follower.userName, userId: follower.userId }); + }); + + it('message should be send', async () => { + await message.isSentRaw(`Diky za follow, @${follower.userName}!`, { userName: follower.userName }); + }); + + it('wait 5s', async () => { + await time.waitMs(5000); + }); + } + }); +}); diff --git a/backend/test/tests/events/tip.js b/backend/test/tests/events/tip.js new file mode 100644 index 000000000..b32f12ea9 --- /dev/null +++ b/backend/test/tests/events/tip.js @@ -0,0 +1,75 @@ + +/* global */ + +import assert from 'assert'; + +import('../../general.js'); + +import { v4 as uuidv4 } from 'uuid'; +import { AppDataSource } from '../../../dest/database.js'; + +import { Event } from '../../../dest/database/entity/event.js'; +import { User } from '../../../dest/database/entity/user.js'; +import events from '../../../dest/events.js'; +import * as log from '../../../dest/helpers/log.js'; +import * as changelog from '../../../dest/helpers/user/changelog.js'; +import { time } from '../../general.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +describe('Events - tip event - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + describe('#2219 - Give points on tip not working', function () { + before(async function () { + await AppDataSource.getRepository(Event).save({ + id: uuidv4(), + name: 'tip', + givenName: 'Tip alert', + triggered: {}, + definitions: {}, + filter: '', + isEnabled: true, + operations: [{ + name: 'run-command', + definitions: { + isCommandQuiet: true, + commandToRun: '!points add $username (math.$amount*10)', + }, + }], + }); + + for (const [idx, user] of ['losslezos', 'rigneir', 'mikasa_hraje', 'foufhs'].entries()) { + await AppDataSource.getRepository(User).save({ userName: user, userId: String(idx * 100000) }); + } + }); + + for (const [idx, username] of ['losslezos', 'rigneir', 'mikasa_hraje', 'foufhs'].entries()) { + describe(username + ' tip event', () => { + it('trigger tip event for 10 EUR - ' + username, async () => { + log.tip(`${username}, amount: 10.00EUR, message: Ahoj jak je`); + events.fire('tip', { + userId: String(idx * 100000), userName: username, amount: 10.00, message: 'Ahoj jak je', currency: 'EUR', + }); + }); + + it('wait 1s', async () => { + await time.waitMs(5000); + }); + + it('we are not expecting any messages to be sent - quiet mode', async () => { + assert.strict.equal(log.chatOut.callCount, 0); + }); + + it('user should have 100 points', async () => { + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userName: username }); + assert.strict.equal(user.points, 100); + }); + }); + } + }); +}); diff --git a/backend/test/tests/gallery/#4969_id_can_be_shortid.js b/backend/test/tests/gallery/#4969_id_can_be_shortid.js new file mode 100644 index 000000000..c1c9a217a --- /dev/null +++ b/backend/test/tests/gallery/#4969_id_can_be_shortid.js @@ -0,0 +1,28 @@ +import('../../general.js'); + +import assert from 'assert'; + +import {nanoid} from 'nanoid'; +import { AppDataSource } from '../../../dest/database.js'; + +import { Gallery } from '../../../dest/database/entity/gallery.js'; +import { db } from '../../general.js'; + +const id = nanoid(); + +describe('Gallery - #4969 - id can be nanoid - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + }); + + it(`Save pseudo-file with nanoid`, async () => { + await AppDataSource.getRepository(Gallery).save({ + id, type: '', data: '', name: 'unknown', + }); + }); + + it(`Pseudo-file should exist in db`, async () => { + const count = await AppDataSource.getRepository(Gallery).countBy({ id }); + assert.strictEqual(count, 1); + }); +}); diff --git a/backend/test/tests/games/gamble.js b/backend/test/tests/games/gamble.js new file mode 100644 index 000000000..d693f30b4 --- /dev/null +++ b/backend/test/tests/games/gamble.js @@ -0,0 +1,179 @@ + +/* global */ + +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { AppDataSource } from '../../../dest/database.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import gamble from '../../../dest/games/gamble.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { getPointsName } from '../../../dest/helpers/points/getPointsName.js'; +import points from '../../../dest/systems/points.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const user1 = { userName: 'user1', userId: String(_.random(999999, false)) }; +const command = '!gamble'; + +describe('Gambling - gamble - @func3', () => { + beforeEach(async () => { + const changelog = await import('../../../dest/helpers/user/changelog.js'); + await changelog.flush(); + await AppDataSource.getRepository(User).save({ + userId: user1.userId, userName: user1.userName, points: 100, + }); + }); + + describe('User uses !gamble', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + gamble.enableJackpot = false; + }); + + it('user should unsuccessfully !gamble 0', async () => { + const r = await gamble.main({ + sender: user1, parameters: '0', command, + }); + const updatedPoints = await points.getPointsOf(user1.userId); + + assert(r[0].response === '$sender, you cannot gamble 0 points', JSON.stringify({ r }, null, 2)); + assert.strictEqual(updatedPoints, 100); + }); + + it('user should successfully !gamble 1', async () => { + const r = await gamble.main({ + sender: user1, parameters: '1', command, + }); + const updatedPoints = await points.getPointsOf(user1.userId); + + const msg1 = prepare('gambling.gamble.win', { + pointsName: await getPointsName(updatedPoints), points: updatedPoints, command, + }); + const msg2 = prepare('gambling.gamble.lose', { + pointsName: await getPointsName(updatedPoints), points: updatedPoints, command, + }); + assert(r[0].response === msg1 || r[0].response === msg2, JSON.stringify({ + r, msg1, msg2, + }, null, 2)); + assert(updatedPoints === 99 || updatedPoints === 101, updatedPoints); + }); + + it('!gamble jackpot should show disabled jackpot', async () => { + const r = await gamble.jackpot({ sender: user1, command }); + assert.strictEqual(r[0].response, '$sender, jackpot is disabled for !gamble.'); + }); + + it('user should successfully !gamble all', async () => { + const r = await gamble.main({ + sender: user1, parameters: 'all', command, + }); + const updatedPoints = await points.getPointsOf(user1.userId); + const msg1 = prepare('gambling.gamble.win', { + pointsName: await getPointsName(updatedPoints), points: updatedPoints, command, + }); + const msg2 = prepare('gambling.gamble.lose', { + pointsName: await getPointsName(updatedPoints), points: updatedPoints, command, + }); + assert(r[0].response === msg1 || r[0].response === msg2, JSON.stringify({ + r, msg1, msg2, + }, null, 2)); + assert(updatedPoints === 0 || updatedPoints === 200, updatedPoints); + }); + }); + + describe('User uses !gamble with not enough options', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('user should fail !gamble', async () => { + const r = await gamble.main({ + sender: user1, parameters: '', command, + }); + assert(r[0].response === '$sender, you need to specify points to gamble', JSON.stringify({ r }, null, 2)); + }); + + describe('User uses !gamble with minimal value 10', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + gamble.minimalBet = 10; + }); + after(() => { + gamble.minimalBet = 0; + }); + it('user should unsuccessfully !gamble 1', async () => { + const r = await gamble.main({ + sender: user1, parameters: '1', command, + }); + const updatedPoints = await points.getPointsOf(user1.userId); + + assert(r[0].response === '$sender, minimal bet for !gamble is 10 points', JSON.stringify({ r }, null, 2)); + assert.strictEqual(updatedPoints, 100); + }); + + it('user should successfully !gamble 10', async () => { + const r = await gamble.main({ + sender: user1, parameters: '10', command, + }); + const updatedPoints = await points.getPointsOf(user1.userId); + + const msg1 = prepare('gambling.gamble.win', { + pointsName: await getPointsName(updatedPoints), points: updatedPoints, command, + }); + const msg2 = prepare('gambling.gamble.lose', { + pointsName: await getPointsName(updatedPoints), points: updatedPoints, command, + }); + assert(r[0].response === msg1 || r[0].response === msg2, JSON.stringify({ + r, msg1, msg2, + }, null, 2)); + assert(updatedPoints === 90 || updatedPoints === 110, { updatedPoints }); + }); + + it('user should successfully !gamble all', async () => { + const r = await gamble.main({ + sender: user1, parameters: 'all', command, + }); + const updatedPoints = await points.getPointsOf(user1.userId); + + const msg1 = prepare('gambling.gamble.win', { + pointsName: await getPointsName(updatedPoints), points: updatedPoints, command, + }); + const msg2 = prepare('gambling.gamble.lose', { + pointsName: await getPointsName(updatedPoints), points: updatedPoints, command, + }); + assert(r[0].response === msg1 || r[0].response === msg2, JSON.stringify({ + r, msg1, msg2, + }, null, 2)); + assert(updatedPoints === 0 || updatedPoints === 200, updatedPoints); + }); + }); + }); + + describe('User uses !gamble all with minimal value and with not enough points - https://community.sogebot.xyz/t/bypass-the-minimum-amount-for-gamble/219', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + gamble.minimalBet = 110; + }); + after(() => { + gamble.minimalBet = 0; + }); + + it('user should unsuccessfully !gamble all', async () => { + const r = await gamble.main({ + sender: user1, parameters: 'all', command, + }); + const updatedPoints = await points.getPointsOf(user1.userId); + + assert(r[0].response === '$sender, minimal bet for !gamble is 110 points', JSON.stringify({ r }, null, 2)); + assert.strictEqual(updatedPoints, 100); + }); + }); +}); diff --git a/backend/test/tests/games/gambleWithJackpot.js b/backend/test/tests/games/gambleWithJackpot.js new file mode 100644 index 000000000..2540a8f29 --- /dev/null +++ b/backend/test/tests/games/gambleWithJackpot.js @@ -0,0 +1,85 @@ + +/* global describe it before */ + +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { AppDataSource } from '../../../dest/database.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import gamble from '../../../dest/games/gamble.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const user1 = { userName: 'user1', userId: String(_.random(999999, false)) }; +const command = '!gamble'; + +describe('Gambling - gamble with Jackpot - @func1', () => { + describe('User uses !gamble', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + // enable jackpot and set chance to win to 0 so we fill up jackpot bank + gamble.enableJackpot = true; + gamble.chanceToTriggerJackpot = -1; + gamble.chanceToWin = -1; + gamble.lostPointsAddedToJackpot = 10; + gamble.jackpotValue = 0; + }); + + after(() => { + gamble.enableJackpot = false; + }); + + it('add points for user', async () => { + await AppDataSource.getRepository(User).save({ + userId: user1.userId, userName: user1.userName, points: 1000, + }); + }); + + it('user should lose !gamble 125', async () => { + const r = await gamble.main({ + sender: user1, parameters: '125', command, + }); + assert.strictEqual(r[0].response, '$sender, you LOST! You now have 875 points. Jackpot increased to 13 points'); + }); + + it('user should lose again !gamble 100', async () => { + const r = await gamble.main({ + sender: user1, parameters: '200', command, + }); + assert.strictEqual(r[0].response, '$sender, you LOST! You now have 675 points. Jackpot increased to 33 points'); + }); + + it('set lostPointsAddedToJackpot to 100%', () => { + gamble.lostPointsAddedToJackpot = 100; + }); + + it('user should lose again !gamble 100', async () => { + const r = await gamble.main({ + sender: user1, parameters: '100', command, + }); + assert.strictEqual(r[0].response, '$sender, you LOST! You now have 575 points. Jackpot increased to 133 points'); + }); + + it('!gamble jackpot should show correct jackpot', async () => { + const r = await gamble.jackpot({ sender: user1, command }); + assert.strictEqual(r[0].response, '$sender, current jackpot for !gamble is 133 points'); + }); + + it('set chance for jackpot to 100%', () => { + gamble.chanceToTriggerJackpot = 100; + }); + + it('user should win jackpot !gamble 100', async () => { + const r = await gamble.main({ + sender: user1, parameters: '100', command, + }); + assert.strictEqual(r[0].response, '$sender, you hit JACKPOT! You won 133 points in addition to your bet. You now have 808 points'); + }); + }); +}); diff --git a/backend/test/tests/games/heist.js b/backend/test/tests/games/heist.js new file mode 100644 index 000000000..f20d014ca --- /dev/null +++ b/backend/test/tests/games/heist.js @@ -0,0 +1,475 @@ +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); +import { User } from '../../../dest/database/entity/user.js'; +import { db, message, user } from '../../general.js'; +import { time } from '../../general.js'; + +const command = '!bankheist'; +let heist; + +describe('Heist - !bankheist - @func2', () => { + before(async () => { + await db.cleanup(); + await user.prepare(); + + heist = (await import('../../../dest/games/heist.js')).default; + }); + + describe('!bankheist when nobody joined', () => { + before(async () => { + await message.prepare(); + await AppDataSource.getRepository(User).save({ + userId: user.owner.userId, userName: user.owner.userName, points: 1000, + }); + + // reset heist + heist.startedAt = null; + heist.lastAnnouncedLevel = ''; + heist.lastHeistTimestamp = 0; + heist.lastAnnouncedCops = 0; + heist.lastAnnouncedHeistInProgress = 0; + heist.lastAnnouncedStart = 0; + heist.showMaxUsers = 20; + }); + + it('User start new bankheist with !bankheist', async () => { + await heist.main({ + sender: user.viewer, parameters: '', command, + }); + }); + + it('Heist should be announced', async () => { + await message.isSentRaw('@__viewer__ has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type !bankheist to enter.', { userName: '__bot__' }); + }); + + it('Already started bankheist should show entryInstruction with !bankheist without points', async () => { + const r = await heist.main({ + sender: user.viewer2, parameters: '', command, + }); + assert.strictEqual(r[0].response, '$sender, type !bankheist to enter.'); + }); + + it('Force heist to end', async () => { + heist.startedAt = 0; + }); + + it('Correct !bankheist should show lateEntryMessage', async () => { + const r = await heist.main({ + sender: user.owner, parameters: '100', command, + }); + assert.strictEqual(r[0].response, '$sender, heist is currently in progress!'); + }); + + it('We need to wait at least 30 seconds', async() =>{ + const steps = 10; + process.stdout.write(`\t... waiting ${(30)}s ... `); + for (let i = 0; i < steps; i++) { + await time.waitMs(30000 / steps); + process.stdout.write(`\r\t... waiting ${30 - ((30 / steps) * i)}s ... `); + } + }); + + it('Heist should be finished - nobody joined', async () => { + await message.isSentRaw('Nobody joins a crew to heist.', { userName: '__bot__' }); + }); + }); + + describe('!bankheist with several joins', () => { + before(async () => { + await message.prepare(); + + // reset heist + heist.startedAt = null; + heist.lastAnnouncedLevel = ''; + heist.lastHeistTimestamp = 0; + heist.lastAnnouncedCops = 0; + heist.lastAnnouncedHeistInProgress = 0; + heist.lastAnnouncedStart = 0; + heist.showMaxUsers = 1; + + await AppDataSource.getRepository(User).save({ + userId: user.owner.userId, userName: user.owner.userName, points: 1000, + }); + await AppDataSource.getRepository(User).save({ + userId: user.viewer.userId, userName: user.viewer.userName, points: 1000, + }); + await AppDataSource.getRepository(User).save({ + userId: user.viewer2.userId, userName: user.viewer2.userName, points: 1000, + }); + await AppDataSource.getRepository(User).save({ + userId: user.mod.userId, userName: user.mod.userName, points: 1000, + }); + // generate 1000 users + for (let i=0; i < 1000; i++) { + await AppDataSource.getRepository(User).save({ + userId: String(i * 9999), userName: `user${i}`, points: 1000, + }); + } + }); + + it('User start new bankheist with !bankheist 100', async () => { + const r = await heist.main({ + sender: user.viewer, parameters: '100', command, + }); + // if correct we don't expect any message + assert.strictEqual(r.length, 0, JSON.stringify(r, null, 2)); + }); + + it('Heist should be announced', async () => { + await message.isSentRaw('@__viewer__ has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type !bankheist to enter.', { userName: '__bot__' }); + }); + + it('Another viewer joins with all points', async () => { + const r = await heist.main({ + sender: user.viewer2, parameters: 'all', command, + }); + // if correct we don't expect any message + assert.strictEqual(r.length, 0); + }); + + it('Another viewer joins with 0 points', async () => { + const r = await heist.main({ + sender: user.viewer2, parameters: '0', command, + }); + assert.strictEqual(r[0].response, '$sender, type !bankheist to enter.'); + }); + + it('Another viewer joins with all points', async () => { + const r = await heist.main({ + sender: user.mod, parameters: 'all', command, + }); + // if correct we don't expect any message + assert.strictEqual(r.length, 0); + }); + + it('Another viewer joins with all points (and not having any points)', async () => { + const r = await heist.main({ + sender: user.mod, parameters: 'all', command, + }); + assert.strictEqual(r[0].response, '$sender, type !bankheist to enter.'); + }); + + it(`1000 users joins bankheist`, async () => { + for (let i=0; i < 1000; i++) { + await heist.main({ + sender: { userId: String(i*9999), userName: `user${i}` }, parameters: '100', command, + }); + } + }); + + it('Force heist to end', async () => { + heist.startedAt = 0; + // force instant result + heist.iCheckFinished(); + }); + it('We need to wait at least 10 seconds', async() =>{ + const steps = 10; + process.stdout.write(`\t... waiting ${(10)}s ... `); + for (let i = 0; i < steps; i++) { + await time.waitMs(10000 / steps); + process.stdout.write(`\r\t... waiting ${10 - ((10 / steps) * i)}s ... `); + } + }); + + it('Heist should be finished - start message', async () => { + await message.isSentRaw('Alright guys, check your equipment, this is what we trained for. This is not a game, this is real life. We will get money from Federal reserve!', { userName: '__bot__' }); + }); + it('Heist should be finished - result message', async () => { + await message.isSentRaw([ + 'Everyone was mercilessly obliterated. This is slaughter.', + 'Only 1/3rd of team get its money from heist.', + 'Half of heist team was killed or catched by police.', + 'Some loses of heist team is nothing of what remaining crew have in theirs pockets.', + 'God divinity, nobody is dead, everyone won!', + ], { userName: '__bot__' }); + }); + it('We need to wait at least 10 seconds', async() =>{ + const steps = 10; + process.stdout.write(`\t... waiting ${(10)}s ... `); + for (let i = 0; i < steps; i++) { + await time.waitMs(10000 / steps); + process.stdout.write(`\r\t... waiting ${10 - ((10 / steps) * i)}s ... `); + } + }); + it('Heist should be finished - userslist', async () => { + await message.sentMessageContain('The heist payouts are: '); + await message.sentMessageContain('more...'); + }); + }); + + describe('!bankheist with single join', () => { + before(async () => { + await message.prepare(); + + // reset heist + heist.startedAt = null; + heist.lastAnnouncedLevel = ''; + heist.lastHeistTimestamp = 0; + heist.lastAnnouncedCops = 0; + heist.lastAnnouncedHeistInProgress = 0; + heist.lastAnnouncedStart = 0; + heist.showMaxUsers = 20; + + await AppDataSource.getRepository(User).save({ + userId: user.viewer.userId, userName: user.viewer.userName, points: 1000, + }); + }); + + it('User start new bankheist with !bankheist 100', async () => { + const r = await heist.main({ + sender: user.viewer, parameters: '100', command, + }); + // if correct we don't expect any message + assert.strictEqual(r.length, 0); + }); + + it('Heist should be announced', async () => { + await message.isSentRaw('@__viewer__ has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type !bankheist to enter.', { userName: '__bot__' }); + }); + + it('Force heist to end', async () => { + heist.startedAt = 0; + heist.iCheckFinished(); + }); + + it('Heist should be finished - start message', async () => { + await message.isSentRaw('Alright guys, check your equipment, this is what we trained for. This is not a game, this is real life. We will get money from Bank van!', { userName: '__bot__' }); + }); + it('Heist should be finished - result message', async () => { + await message.isSentRaw([ + '@__viewer__ was like a ninja. Nobody noticed missing money.', + '@__viewer__ failed to get rid of police and will be spending his time in jail.', + ], { userName: '__bot__' }); + }); + }); + + describe('!bankheist cops cooldown', () => { + before(async () => { + await message.prepare(); + + // reset heist + heist.startedAt = null; + heist.lastAnnouncedLevel = ''; + heist.lastHeistTimestamp = 0; + heist.lastAnnouncedCops = 0; + heist.lastAnnouncedHeistInProgress = 0; + heist.lastAnnouncedStart = 0; + heist.showMaxUsers = 20; + heist.copsCooldownInMinutes = 1; + heist.entryCooldownInSeconds = 5; // adds 10 seconds to announce results + + await AppDataSource.getRepository(User).save({ + userId: user.viewer.userId, userName: user.viewer.userName, points: 1000, + }); + }); + + after(() => { + heist.copsCooldownInMinutes = 10; + heist.entryCooldownInSeconds = 120; + }); + + it('User start new bankheist with !bankheist 100', async () => { + const r = await heist.main({ + sender: user.viewer, parameters: '100', command, + }); + // if correct we don't expect any message + assert.strictEqual(r.length, 0); + }); + + it('Heist should be announced', async () => { + await message.isSentRaw('@__viewer__ has started planning a bank heist! Looking for a bigger crew for a bigger score. Join in! Type !bankheist to enter.', { userName: '__bot__' }); + }); + it('We need to wait at least 20 seconds', async() =>{ + const steps = 10; + process.stdout.write(`\t... waiting ${(20)}s ... `); + for (let i = 0; i < steps; i++) { + await time.waitMs(20000 / steps); + process.stdout.write(`\r\t... waiting ${20 - ((20 / steps) * i)}s ... `); + } + }); + + it('Heist should be finished - start message', async () => { + await message.isSentRaw('Alright guys, check your equipment, this is what we trained for. This is not a game, this is real life. We will get money from Bank van!', { userName: '__bot__' }); + }); + it('Heist should be finished - result message', async () => { + await message.isSentRaw([ + '@__viewer__ was like a ninja. Nobody noticed missing money.', + '@__viewer__ failed to get rid of police and will be spending his time in jail.', + ], { userName: '__bot__' }); + }); + + it('User start new bankheist with !bankheist 100, but cops are patrolling', async () => { + const r = await heist.main({ + sender: user.viewer, parameters: '100', command, + }); + assert([ + '$sender, cops are still searching for last heist team. Try again after 1.0 minute.', + '$sender, cops are still searching for last heist team. Try again after 0.9 minutes.', + '$sender, cops are still searching for last heist team. Try again after 0.8 minutes.', + '$sender, cops are still searching for last heist team. Try again after 0.10 minutes.', + '$sender, cops are still searching for last heist team. Try again after 0.6 minutes.', + '$sender, cops are still searching for last heist team. Try again after 0.5 minutes.', + '$sender, cops are still searching for last heist team. Try again after 0.4 minutes.', + '$sender, cops are still searching for last heist team. Try again after 0.3 minutes.', + '$sender, cops are still searching for last heist team. Try again after 0.2 minutes.', + '$sender, cops are still searching for last heist team. Try again after 0.1 minutes.', + ].includes(r[0].response), r[0].response); + }); + + it('Cops are still patrolling but we should not have new message in succession', async () => { + const r = await heist.main({ + sender: user.viewer, parameters: '100', command, + }); + assert(r.length === 0); + }); + it('We need to wait at least 105 seconds', async() =>{ + const steps = 15; + process.stdout.write(`\t... waiting ${(105)}s ... `); + for (let i = 0; i < steps; i++) { + await time.waitMs(105000 / steps); + process.stdout.write(`\r\t... waiting ${105 - ((105 / steps) * i)}s ... `); + } + }).timeout(60000 * 3); + it('We should get announce that cops are not on cooldown', async () => { + await message.isSentRaw('Alright guys, looks like police forces are eating donuts and we can get that sweet money!', { userName: '__bot__' }); + }); + }); + + describe('!bankheist levels announcement', () => { + before(async () => { + await message.prepare(); + + // reset heist + heist.startedAt = null; + heist.lastAnnouncedLevel = ''; + heist.lastHeistTimestamp = 0; + heist.lastAnnouncedCops = 0; + heist.lastAnnouncedHeistInProgress = 0; + heist.lastAnnouncedStart = 0; + heist.showMaxUsers = 20; + heist.entryCooldownInSeconds = 5; // adds 10 seconds to announce results + + await AppDataSource.getRepository(User).save({ + userId: user.viewer.userId, userName: user.viewer.userName, points: 1000, + }); + // generate 50 users + for (let i=0; i < 50; i++) { + await AppDataSource.getRepository(User).save({ + userId: String(i * 9999), userName: `user${i}`, points: 1000, + }); + } + }); + after(() => { + heist.entryCooldownInSeconds = 120; + }); + + it('User start new bankheist with !bankheist 100', async () => { + const r = await heist.main({ + sender: user.viewer, parameters: '100', command, + }); + // if correct we don't expect any message + assert.strictEqual(r.length, 0); + }); + + it('bankVan level should be announced', async () => { + const current = 'Bank van'; + const next = 'City bank'; + await message.isSentRaw(`With this crew, we can heist ${current}! Let's see if we can get enough crew to heist ${next}`, { userName: '__bot__' }); + }); + + it(`5 users joins bankheist`, async () => { + for (let i=0; i < 5; i++) { + await heist.main({ + sender: { userId: String(i*9999), userName: `user${i}` }, parameters: '100', command, + }); + } + }); + + it('cityBank level should be announced', async () => { + const current = 'City bank'; + const next = 'State bank'; + await message.isSentRaw(`With this crew, we can heist ${current}! Let's see if we can get enough crew to heist ${next}`, { userName: '__bot__' }); + }); + + it(`10 users joins bankheist`, async () => { + for (let i=5; i < 10; i++) { + await heist.main({ + sender: { userId: String(i*9999), userName: `user${i}` }, parameters: '100', command, + }); + } + }); + + it('stateBank level should be announced', async () => { + const current = 'State bank'; + const next = 'National reserve'; + await message.isSentRaw(`With this crew, we can heist ${current}! Let's see if we can get enough crew to heist ${next}`, { userName: '__bot__' }); + }); + + it(`10 users joins bankheist`, async () => { + for (let i=10; i < 20; i++) { + await heist.main({ + sender: { userId: String(i*9999), userName: `user${i}` }, parameters: '100', command, + }); + } + }); + + it('nationalReserve level should be announced', async () => { + const current = 'National reserve'; + const next = 'Federal reserve'; + await message.isSentRaw(`With this crew, we can heist ${current}! Let's see if we can get enough crew to heist ${next}`, { userName: '__bot__' }); + }); + + it(`30 users joins bankheist`, async () => { + for (let i=20; i < 50; i++) { + await heist.main({ + sender: { userId: String(i*9999), userName: `user${i}` }, parameters: '100', command, + }); + } + }); + + it('maxLevelMessage level should be announced', async () => { + const current = 'Federal reserve'; + await message.isSentRaw(`With this crew, we can heist ${current}! It cannot be any better!`, { userName: '__bot__' }); + }); + }); + + describe('!bankheist no levels', () => { + before(async () => { + await message.prepare(); + + // reset heist + heist.startedAt = null; + heist.lastAnnouncedLevel = ''; + heist.lastHeistTimestamp = 0; + heist.lastAnnouncedCops = 0; + heist.lastAnnouncedHeistInProgress = 0; + heist.lastAnnouncedStart = 0; + heist.showMaxUsers = 20; + heist.entryCooldownInSeconds = 5; // adds 10 seconds to announce results + heist.levelsValues = []; + + await AppDataSource.getRepository(User).save({ + userId: user.viewer.userId, userName: user.viewer.userName, points: 1000, + }); + }); + after(() => { + heist.entryCooldownInSeconds = 120; + }); + + it('User start new bankheist with !bankheist 100', async () => { + const r = await heist.main({ + sender: user.viewer, parameters: '100', command, + }); + // if correct we don't expect any message + assert.strictEqual(r.length, 0); + }); + + it(`No levels to check return`, async () => { + heist.startedAt = 0; + await heist.iCheckFinished(); + await message.debug('heist', 'no level to check'); + }); + }); +}); diff --git a/backend/test/tests/games/roulette.js b/backend/test/tests/games/roulette.js new file mode 100644 index 000000000..ea5b15f75 --- /dev/null +++ b/backend/test/tests/games/roulette.js @@ -0,0 +1,138 @@ + +/* global */ + +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { AppDataSource } from '../../../dest/database.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import roulette from '../../../dest/games/roulette.js'; +import * as changelog from '../../../dest/helpers/user/changelog.js'; +import points from '../../../dest/systems/points.js'; +import { db, message, user } from '../../general.js'; + +const tests = [ + { user: { userName: 'user1', userId: String(_.random(999999, false)) } }, + { user: user.owner }, + { user: user.mod }, +]; + +describe('game/roulette - !roulette - @func3', () => { + for (const test of tests) { + describe(`${test.user.userName} uses !roulette`, async () => { + let r; + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it(`${test.user.userName} starts roulette`, async () => { + r = await roulette.main({ sender: test.user }); + }); + + if (user.mod.userName === test.user.userName) { + it('Expecting mod message', async () => { + assert(r[1].response === '$sender is incompetent and completely missed his head!', JSON.stringify({ r }, null, 2)); + }); + } else if (user.owner.userName === test.user.userName) { + it('Expecting owner message', async () => { + assert(r[1].response === '$sender is using blanks, boo!', JSON.stringify({ r }, null, 2)); + }); + } else { + it('Expecting win or lose', async () => { + const msg1 = '$sender is alive! Nothing happened.'; + const msg2 = '$sender\'s brain was splashed on the wall!'; + assert(r[1].response === msg1 || r[1].response === msg2, JSON.stringify({ + r, msg1, msg2, + }, null, 2)); + }); + } + }); + } + + describe('/t/seppuku-and-roulette-points-can-be-negated/36', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await AppDataSource.getRepository(User).save(tests[0].user); + }); + + it(`set lose value to 100`, () => { + roulette.loserWillLose = 100; + }); + + it(`User starts roulette and we are waiting for lose`, async () => { + let isAlive = true; + let r; + while(isAlive) { + r = await roulette.main({ sender: tests[0].user }); + isAlive = r[1].isAlive; + } + const msg1 = '$sender\'s brain was splashed on the wall!'; + assert(r[1].response === msg1, JSON.stringify({ r, msg1 })); + }); + + it(`User should not have negative points`, async () => { + await changelog.flush(); + const user1 = await AppDataSource.getRepository(User).findOneBy({ userId: tests[0].user.userId }); + assert.strictEqual(user1.points, 0); + }); + }); + + describe('game/roulette - winnerWillGet, loserWillLose', () => { + describe(`Winner should get points`, async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + roulette.winnerWillGet = 100; + }); + after(() => { + roulette.winnerWillGet = 0; + }); + + it(`${user.viewer.userName} starts roulettes and we wait for win`, async () => { + let r = ''; + while (r !== '$sender is alive! Nothing happened.') { + const responses = await roulette.main({ sender: user.viewer }); + r = responses[1].response; + } + }); + + it('User should get 100 points from win', async () => { + assert.strictEqual(await points.getPointsOf(user.viewer.userId), 100); + }); + }); + + describe(`Loser should lose points`, async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + roulette.loserWillLose = 100; + await AppDataSource.getRepository(User).save({ + userId: user.viewer.userId, userName: user.viewer.userName, points: 100, + }); + }); + after(() => { + roulette.loserWillLose = 0; + }); + + it(`${user.viewer.userName} starts roulettes and we wait for lose`, async () => { + let r = ''; + while (r !== '$sender\'s brain was splashed on the wall!') { + const responses = await roulette.main({ sender: user.viewer }); + r = responses[1].response; + } + }); + + it('User should lose 100 points from win', async () => { + assert.strictEqual(await points.getPointsOf(user.viewer.userId), 0); + }); + }); + }); +}); diff --git a/backend/test/tests/helpers/changelog.js b/backend/test/tests/helpers/changelog.js new file mode 100644 index 000000000..0d49a861c --- /dev/null +++ b/backend/test/tests/helpers/changelog.js @@ -0,0 +1,149 @@ +/* global */ + +import('../../general.js'); +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { db } from '../../general.js'; + +describe('User changelog tests - @func1', () => { + let User; + let changelog; + before(async () => { + await db.cleanup(); + changelog = await import('../../../dest/helpers/user/changelog.js'); + User = (await import('../../../dest/database/entity/user.js')).User; + }); + + it('get of unknown user should return null', async () => { + const data = await changelog.get('12345'); + assert.equal(data, null); + }); + + it('add if several data should return correct data', async () => { + const expected = { + userId: '999999', + userName: 'lorem', + watchedTime: 55555, + points: 9, + messages: 19, + subscribedAt: null, + subscribeTier: '0', + subscribeStreak: 0, + pointsByMessageGivenAt: 0, + pointsOfflineGivenAt: 0, + pointsOnlineGivenAt: 0, + profileImageUrl: '', + rank: '', + seenAt: null, + subscribeCumulativeMonths: 0, + giftedSubscribes: 0, + haveCustomRank: false, + haveSubscribedAtLock: false, + haveSubscriberLock: false, + isModerator: false, + isOnline: false, + isSubscriber: false, + isVIP: false, + chatTimeOffline: 0, + chatTimeOnline: 0, + createdAt: null, + displayname: '', + extra: { + jackpotWins: 3, + levels: { + xp: 1, + xpOfflineGivenAt: 2, + xpOfflineMessages: 3, + xpOnlineGivenAt: 2, + xpOnlineMessages: 3, + }, + }, + }; + changelog.update('999999', { + userName: 'aaaa', + watchedTime: 55555, + }); + changelog.update('999999', { + userName: 'lorem', + points: 20, + messages: 18, + }); + changelog.update('999999', { + points: 8, + extra: { + jackpotWins: 1, + levels: { + xp: 1, + xpOfflineGivenAt: 1, + xpOfflineMessages: 3, + xpOnlineGivenAt: 2, + }, + }, + }); + changelog.update('999999', { + extra: { + jackpotWins: 2, + levels: { + xp: 1, + xpOfflineGivenAt: 2, + xpOfflineMessages: 3, + xpOnlineGivenAt: 2, + xpOnlineMessages: 3, + }, + }, + }); + changelog.increment('999999', { + points: 1, + messages: 1, + extra: { jackpotWins: 1 }, + }); + const data = await changelog.get('999999'); + assert.deepEqual(data, expected); + }); + + it('after flush all data should be in database', async () => { + const expected = { + userId: '999999', + userName: 'lorem', + watchedTime: 55555, + points: 9, + messages: 19, + subscribedAt: null, + subscribeTier: '0', + subscribeStreak: 0, + pointsByMessageGivenAt: 0, + pointsOfflineGivenAt: 0, + pointsOnlineGivenAt: 0, + profileImageUrl: '', + rank: '', + seenAt: null, + subscribeCumulativeMonths: 0, + giftedSubscribes: 0, + haveCustomRank: false, + haveSubscribedAtLock: false, + haveSubscriberLock: false, + isModerator: false, + isOnline: false, + isSubscriber: false, + isVIP: false, + chatTimeOffline: 0, + chatTimeOnline: 0, + createdAt: null, + displayname: '', + extra: { + jackpotWins: 3, + levels: { + xp: 1, + xpOfflineGivenAt: 2, + xpOfflineMessages: 3, + xpOnlineGivenAt: 2, + xpOnlineMessages: 3, + }, + }, + }; + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userId: '999999'}); + assert.deepEqual(user, expected); + }); +}); diff --git a/backend/test/tests/helpers/discord#963386192089460767.js b/backend/test/tests/helpers/discord#963386192089460767.js new file mode 100644 index 000000000..f0a37790e --- /dev/null +++ b/backend/test/tests/helpers/discord#963386192089460767.js @@ -0,0 +1,24 @@ +import assert from 'assert'; + +import('../../general.js'); +import { db } from '../../general.js'; +import { user } from '../../general.js'; + +describe('checkFilter should properly parse $param in stream - https://discord.com/channels/317348946144002050/317349069024395264/963386192089460767 - @func2', () => { + before(async () => { + await db.cleanup(); + await user.prepare(); + }); + + it('returns correct value - true filter', async () => { + const checkFilter = (await import('../../../dest/helpers/checkFilter.js')).checkFilter; + assert(await checkFilter({ parameters: 'test', sender: user.viewer }, '"(stream|$param|link)" === "twitch.tv/test"')); + assert(await checkFilter({ parameters: 'test', sender: user.viewer }, '"(stream|$touser|link)" === "twitch.tv/test"')); + }); + + it('returns correct value - false filter', async () => { + const checkFilter = (await import('../../../dest/helpers/checkFilter.js')).checkFilter; + assert(!(await checkFilter({ parameters: 'test2', sender: user.viewer }, '"(stream|$param|link)" === "twitch.tv/test"'))); + assert(!(await checkFilter({ parameters: 'test2', sender: user.viewer }, '"(stream|$touser|link)" === "twitch.tv/test"'))); + }); +}); diff --git a/backend/test/tests/helpers/persistent_null.js b/backend/test/tests/helpers/persistent_null.js new file mode 100644 index 000000000..06feb0d6a --- /dev/null +++ b/backend/test/tests/helpers/persistent_null.js @@ -0,0 +1,67 @@ + +/* global describe it before */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' + +import { Settings } from '../../../dest/database/entity/settings.js'; +import('../../general.js'); +import { db } from '../../general.js'; +import { time } from '../../general.js'; + +let stats; +let onChangeTriggered = 0; + +describe('Persistent null - @func1', () => { + before(async () => { + await db.cleanup(); + const { persistent } = await import('../../../dest/helpers/core/persistent.js'); + + stats = persistent({ + value: null, + name: 'null', + namespace: '/test', + onChange: () => { + onChangeTriggered++; + }, + }); + + await new Promise((resolve) => { + (function check () { + if (!stats.__loaded__) { + setImmediate(() => check()); + } else { + resolve(true); + } + })(); + }); + }); + + describe('null = test', () => { + it('trigger change', () => { + stats.value = 'test'; + }); + it('value should be changed in db', async () => { + await time.waitMs(1000); + const value = await AppDataSource.getRepository(Settings).findOneByOrFail({ name: 'null', namespace: '/test' }); + assert(JSON.parse(value.value) === 'test'); + }); + }); + + describe('null = null', () => { + it('trigger change', () => { + stats.value = null; + }); + it('value should be changed in db', async () => { + await time.waitMs(1000); + const value = await AppDataSource.getRepository(Settings).findOneByOrFail({ name: 'null', namespace: '/test' }); + assert(JSON.parse(value.value) === null); + }); + }); + + describe('On change should be triggered', () => { + it('check on change value', () => { + assert.strictEqual(onChangeTriggered, 2); + }); + }); +}); diff --git a/backend/test/tests/helpers/persistent_number.js b/backend/test/tests/helpers/persistent_number.js new file mode 100644 index 000000000..a8aa3397b --- /dev/null +++ b/backend/test/tests/helpers/persistent_number.js @@ -0,0 +1,78 @@ + +/* global describe it before */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' + +import { Settings } from '../../../dest/database/entity/settings.js'; +import('../../general.js'); +import { db } from '../../general.js'; +import { time } from '../../general.js'; + +let stats; +let onChangeTriggered = 0; + +describe('Persistent number - @func1', () => { + before(async () => { + await db.cleanup(); + const { persistent } = await import('../../../dest/helpers/core/persistent.js'); + + stats = persistent({ + value: 0, + name: 'number', + namespace: '/test', + onChange: (val) => { + onChangeTriggered++; + }, + }); + + await new Promise((resolve) => { + (function check () { + if (!stats.__loaded__) { + setImmediate(() => check()); + } else { + resolve(true); + } + })(); + }); + }); + + describe('Number++', () => { + it('trigger change', () => { + stats.value++; + }); + it('value should be changed in db', async () => { + await time.waitMs(1000); + const value = await AppDataSource.getRepository(Settings).findOneByOrFail({ name: 'number', namespace: '/test' }); + assert(JSON.parse(value.value) === 1); + }); + }); + + describe('Number--', () => { + it('trigger change', () => { + stats.value--; + }); + it('value should be changed in db', async () => { + await time.waitMs(1000); + const value = await AppDataSource.getRepository(Settings).findOneByOrFail({ name: 'number', namespace: '/test' }); + assert(JSON.parse(value.value) === 0); + }); + }); + + describe('Number = 100', () => { + it('trigger change', () => { + stats.value = 100; + }); + it('value should be changed in db', async () => { + await time.waitMs(1000); + const value = await AppDataSource.getRepository(Settings).findOneByOrFail({ name: 'number', namespace: '/test' }); + assert(JSON.parse(value.value) === 100); + }); + }); + + describe('On change should be triggered', () => { + it('check on change value', () => { + assert.strictEqual(onChangeTriggered, 3); + }); + }); +}); diff --git a/backend/test/tests/helpers/persistent_object.js b/backend/test/tests/helpers/persistent_object.js new file mode 100644 index 000000000..5cc1f3ff6 --- /dev/null +++ b/backend/test/tests/helpers/persistent_object.js @@ -0,0 +1,160 @@ + +/* global describe it before */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' + +import { Settings } from '../../../dest/database/entity/settings.js'; +import('../../general.js'); +import { db } from '../../general.js'; +import { time } from '../../general.js'; + +let stats; +let onChangeTriggered = 0; + +describe('Persistent object - @func1', () => { + before(async () => { + await db.cleanup(); + const { persistent } = await import('../../../dest/helpers/core/persistent.js'); + + // save incorrect value + await AppDataSource.getRepository(Settings).save({ + name: 'stats', namespace: '/test', value: '{"language":"da","currentWatchedTime":140040000,"currentViewers":5,"maxViewers":8,"currentSubscribers":13,"currentBits":0,"currentTips":0,"currentFollowers":2286,"currentViews":87594,"currentGame":"World of Warcraft","currentTitle":"{ENG/DA}:teddy_bear: 9/10 HC Progression - Rygstopsforsøg dag 7 - !donogoal !nytskema !job !uddannelse:teddy_bear:","currentHosts":0,"newChatters":11,"value":{"language":"da","currentWatchedTime":0,"currentViewers":0,"maxViewers":0,"currentSubscribers":13,"currentBits":0,"currentTips":0,"currentFollowers":2288,"currentViews":87131,"currentGame":"World of Warcraft","currentTitle":"{ENG/DA}:teddy_bear:Late night wow hygge - Stadig høj over afsluttet uddannelse! - !RGB !uddannelse:teddy_bear:\n","currentHosts":0,"newChatters":0}}', + }); + + stats = persistent({ + value: { + language: 'en', + currentWatchedTime: 0, + currentViewers: 0, + maxViewers: 0, + currentSubscribers: 0, + currentBits: 0, + currentTips: 0, + currentFollowers: 0, + currentViews: 0, + currentGame: null, + currentTitle: null, + currentHosts: 0, + newChatters: 0, + }, + name: 'stats', + namespace: '/test', + onChange: () => { + onChangeTriggered++; + }, + }); + + await new Promise((resolve) => { + (function check () { + if (!stats.__loaded__) { + setImmediate(() => check()); + } else { + resolve(true); + } + })(); + }); + stats.value = { + language: 'en', + currentWatchedTime: 0, + currentViewers: 0, + maxViewers: 0, + currentSubscribers: 0, + currentBits: 0, + currentTips: 0, + currentFollowers: 0, + currentViews: 0, + currentGame: null, + currentTitle: null, + currentHosts: 0, + newChatters: 0, + }; + }); + + describe('Change language to fr', () => { + it('trigger change', () => { + stats.value.language = 'fr'; + }); + it('value should be changed in db', async () => { + await time.waitMs(100); + const value = await AppDataSource.getRepository(Settings).findOneByOrFail({ name: 'stats', namespace: '/test' }); + assert.notStrictEqual(JSON.parse(value.value),{ + language: 'fr', + currentWatchedTime: 0, + currentViewers: 0, + maxViewers: 0, + currentSubscribers: 0, + currentBits: 0, + currentTips: 0, + currentFollowers: 0, + currentViews: 0, + currentGame: null, + currentTitle: null, + currentHosts: 0, + newChatters: 0, + }); + }); + }); + + describe('Change several attributes (spread)', () => { + it('trigger change', () => { + stats.value = { + ...stats.value, + language: 'cs', + currentBits: 100, + }; + }); + it('change of attributes should be properly saved', async () => { + await time.waitMs(100); + const value = await AppDataSource.getRepository(Settings).findOneByOrFail({ name: 'stats', namespace: '/test' }); + assert.notStrictEqual(JSON.parse(value.value),{ + language: 'cs', + currentWatchedTime: 0, + currentViewers: 0, + maxViewers: 0, + currentSubscribers: 0, + currentBits: 100, + currentTips: 0, + currentFollowers: 0, + currentViews: 0, + currentGame: null, + currentTitle: null, + currentHosts: 0, + newChatters: 0, + }); + }); + }); + + describe('Change several attributes (one by one)', () => { + it('trigger change', () => { + stats.value.language = 'cy'; + stats.value.currentBits = 1000; + stats.value.currentGame = 'Lorem Ipsum'; + }); + it('change of attributes should be properly saved', async () => { + await time.waitMs(100); + const value = await AppDataSource.getRepository(Settings).findOneByOrFail({ name: 'stats', namespace: '/test' }); + assert.notStrictEqual(JSON.parse(value.value),{ + language: 'cy', + currentWatchedTime: 0, + currentViewers: 0, + maxViewers: 0, + currentSubscribers: 0, + currentBits: 1000, + currentTips: 0, + currentFollowers: 0, + currentViews: 0, + currentGame: 'Lorem Ipsum', + currentTitle: null, + currentHosts: 0, + newChatters: 0, + }); + }); + }); + + describe('On change should be triggered', () => { + it('check on change value', () => { + assert.strictEqual(onChangeTriggered, 6); + }); + }); +}); diff --git a/backend/test/tests/helpers/persistent_string.js b/backend/test/tests/helpers/persistent_string.js new file mode 100644 index 000000000..6b1d0151e --- /dev/null +++ b/backend/test/tests/helpers/persistent_string.js @@ -0,0 +1,56 @@ + +/* global describe it before */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' + +import { Settings } from '../../../dest/database/entity/settings.js'; +import('../../general.js'); +import { db } from '../../general.js'; +import { time } from '../../general.js'; + +let stats; +let onChangeTriggered = 0; + +describe('Persistent string - @func1', () => { + before(async () => { + await db.cleanup(); + const { persistent } = await import('../../../dest/helpers/core/persistent.js'); + + stats = persistent({ + value: '0', + name: 'string', + namespace: '/test', + onChange: () => { + onChangeTriggered++; + }, + }); + + await new Promise((resolve) => { + (function check () { + if (!stats.__loaded__) { + setImmediate(() => check()); + } else { + resolve(true); + } + })(); + }); + }); + + describe('string = test', () => { + it('trigger change', () => { + stats.value = 'test'; + }); + it('value should be changed in db', async () => { + await time.waitMs(1000); + const value = await AppDataSource.getRepository(Settings).findOneByOrFail({ name: 'string', namespace: '/test' }); + assert(JSON.parse(value.value) === 'test'); + }); + }); + + describe('On change should be triggered', () => { + it('check on change value', () => { + assert.strictEqual(onChangeTriggered, 1); + }); + }); +}); diff --git a/backend/test/tests/helpers/sql.js b/backend/test/tests/helpers/sql.js new file mode 100644 index 000000000..566ac80a9 --- /dev/null +++ b/backend/test/tests/helpers/sql.js @@ -0,0 +1,34 @@ + +/* global describe it before */ + +import('../../general.js'); + +import assert from 'assert'; +import { db } from '../../general.js'; + +describe('SQLVariableLimit should have correct value - @func1', () => { + before(async () => { + await db.cleanup(); + }); + + if (process.env.TYPEORM_CONNECTION === 'postgres') { + it('SQLVariableLimit should be 32767', async () => { + const { SQLVariableLimit } = await import('../../../dest/helpers/sql.js'); + assert.strictEqual(SQLVariableLimit, 32767); + }); + } + + if (process.env.TYPEORM_CONNECTION === 'mysql') { + it('SQLVariableLimit should be 16382', async () => { + const { SQLVariableLimit } = await import('../../../dest/helpers/sql.js'); + assert.strictEqual(SQLVariableLimit, 16382); + }); + } + + if (process.env.TYPEORM_CONNECTION === 'better-sqlite3') { + it('SQLVariableLimit should be 999', async () => { + const { SQLVariableLimit } = await import('../../../dest/helpers/sql.js'); + assert.strictEqual(SQLVariableLimit, 999); + }); + } +}); diff --git a/backend/test/tests/integrations/discord/link.js b/backend/test/tests/integrations/discord/link.js new file mode 100644 index 000000000..07aa4f3df --- /dev/null +++ b/backend/test/tests/integrations/discord/link.js @@ -0,0 +1,130 @@ +import assert from 'assert'; + +import { MINUTE } from '@sogebot/ui-helpers/constants.js'; +import { v4 } from 'uuid' + +import { db, message, user } from '../../../general.js'; + +import { DiscordLink } from '../../../../dest/database/entity/discord.js'; +import { AppDataSource } from '../../../../dest/database.js'; +import discord from '../../../../dest/integrations/discord.js' + +describe('integrations/discord - !link - @func2', () => { + describe('removeExpiredLinks removes 10 minutes old links', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + // enable integration + discord.status({ state: true }); + + for (let i=0; i<100; i++) { + await AppDataSource.getRepository(DiscordLink).save({ + tag: 'test@0123', + discordId: 'n/a', + createdAt: Date.now() - (MINUTE / 2) * i, + userId: null, + }); + } + }); + + after(() => { + discord.status({ state: false }); + }); + + it('Purge all old links', async () => { + await discord.removeExpiredLinks(); + }); + + it('We should have only 20 links', async () => { + const count = await AppDataSource.getRepository(DiscordLink).count(); + assert(count === 20, `Expected 20 links, got ${count}`); + }); + }); + + describe('!link on discord should properly create DiscordLink and !unlink should delete', async () => { + let link; + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + // enable integration + discord.status({ state: true }); + }); + + after(() => { + discord.status({ state: false }); + }); + + it('Generate new link through discord', async () => { + await discord.message('!link', { + name: 'test', + }, + { + id: '12345', + tag: 'test#1234', + send: () => { + return; + }, + }, + { + reply: () => { + return; + }, + delete: () => { + return; + }, + }); + }); + + it('Purge all old links', async () => { + await discord.removeExpiredLinks(); + }); + + it('We should have one link in DiscordLink', async () => { + const data = await AppDataSource.getRepository(DiscordLink).findAndCount(); + assert(data[1] === 1, `Expected 1 link, got ${data[1]}`); + link = data[0][0]; + }); + + it('User should be able to !link on Twitch', async () => { + const r = await discord.linkAccounts({ + parameters: link.id, + sender: user.viewer, + }); + assert.strictEqual(r[0].response, `@__viewer__, this account was linked with test#1234.`); + }); + + it('Accounts should be linked in DiscordLink', async () => { + const discord_link = (await AppDataSource.getRepository(DiscordLink).find())[0]; + assert(discord_link.userId === user.viewer.userId, `Link is not properly linked.\n${JSON.stringify(link, null, 2)}`); + }); + + it('User2 should not be able to use same !link on Twitch', async () => { + const r = await discord.linkAccounts({ + parameters: link.id, + sender: user.viewer2, + }); + assert.strictEqual(r[0].response, `@__viewer2__, invalid or expired token.`); + }); + + it('Accounts should be linked in DiscordLink to first user', async () => { + const discord_link = (await AppDataSource.getRepository(DiscordLink).find())[0]; + assert(discord_link.userId === user.viewer.userId, `Link is not properly linked.\n${JSON.stringify(link, null, 2)}`); + }); + + it('User should be able to !unlink on Twitch', async () => { + const r = await discord.unlinkAccounts({ + sender: user.viewer, + }); + assert.strictEqual(r[0].response, `@__viewer__, all your links were deleted`); + }); + + it('We should have zero link in DiscordLink', async () => { + const count = await AppDataSource.getRepository(DiscordLink).count(); + assert(count === 0, `Expected 0 links, got ${count}`); + }); + }); +}); diff --git a/backend/test/tests/keywords/#4860_group_filter_and_permissions.js b/backend/test/tests/keywords/#4860_group_filter_and_permissions.js new file mode 100644 index 000000000..37654f1af --- /dev/null +++ b/backend/test/tests/keywords/#4860_group_filter_and_permissions.js @@ -0,0 +1,211 @@ +/* global */ +import assert from 'assert'; + +import('../../general.js'); +import { Keyword, KeywordGroup, KeywordResponses } from '../../../dest/database/entity/keyword.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import keywords from '../../../dest/systems/keywords.js'; +import { db, message, user } from '../../general.js'; + +describe('Keywords - @func3 - #4860 - keywords group permissions and filter should be considered', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it('create filterGroup with filter | $game === "Dota 2"', async () => { + const group = new KeywordGroup(); + group.name = 'filterGroup'; + group.options = { + filter: '$game === "Dota 2"', + permission: null, + }; + await group.save(); + }); + + it('create permGroup with permission | CASTERS', async () => { + const group = new KeywordGroup(); + group.name = 'permGroup'; + group.options = { + filter: null, + permission: defaultPermissions.CASTERS, + }; + await group.save(); + }); + + it('create permGroup2 without permission', async () => { + const group = new KeywordGroup(); + group.name = 'permGroup2'; + group.options = { + filter: null, + permission: null, + }; + await group.save(); + }); + + let testfilter = ''; + + it('create keyword testfilter with filterGroup', async () => { + const keyword = new Keyword(); + keyword.keyword = 'testfilter'; + keyword.enabled = true; + keyword.visible = true; + keyword.group = 'filterGroup'; + await keyword.save(); + testfilter = keyword.id; + + const response1 = new KeywordResponses(); + response1.stopIfExecuted = false; + response1.response = 'bad449ae-f0b3-488c-a7b0-39a853d5333f'; + response1.filter = ''; + response1.order = 0; + response1.permission = defaultPermissions.VIEWERS; + response1.keyword = keyword; + await response1.save(); + + const response2 = new KeywordResponses(); + response2.stopIfExecuted = false; + response2.response = 'c0f68c62-630b-412b-9c97-f5b1afc734d2'; + response2.filter = '$title === \'test\''; + response2.order = 1; + response2.permission = defaultPermissions.VIEWERS; + response2.keyword = keyword; + await response2.save(); + + const response3 = new KeywordResponses(); + response3.stopIfExecuted = false; + response3.response = '4b310000-b105-475a-8a85-a573a0bca1b7'; + response3.filter = '$title !== \'test\''; + response3.order = 2; + response3.permission = defaultPermissions.VIEWERS; + response3.keyword = keyword; + await response3.save(); + }); + + it('create keyword testpermnull with permGroup', async () => { + const keyword = new Keyword(); + keyword.keyword = 'testpermnull'; + keyword.enabled = true; + keyword.visible = true; + keyword.group = 'permGroup'; + await keyword.save(); + + const response1 = new KeywordResponses(); + response1.stopIfExecuted = false; + response1.response = '430ea834-da5f-48b1-bf2f-3acaf1f04c63'; + response1.filter = ''; + response1.order = 0; + response1.permission = null; + response1.keyword = keyword; + await response1.save(); + }); + + let testpermnull2 = ''; + + it('create keyword testpermnull2 with permGroup2', async () => { + const keyword = new Keyword(); + keyword.keyword = 'testpermnull2'; + keyword.enabled = true; + keyword.visible = true; + keyword.group = 'permGroup2'; + await keyword.save(); + testpermnull2 = keyword.id; + + const response1 = new KeywordResponses(); + response1.stopIfExecuted = false; + response1.response = '1594a86e-158d-4b7d-9898-0f80bd6a0c98'; + response1.filter = ''; + response1.order = 0; + response1.permission = null; + response1.keyword = keyword; + await response1.save(); + }); + + it('create keyword testpermmods with permGroup2', async () => { + const keyword = new Keyword(); + keyword.keyword = 'testpermmods'; + keyword.enabled = true; + keyword.visible = true; + keyword.group = 'permGroup2'; + await keyword.save(); + + const response1 = new KeywordResponses(); + response1.stopIfExecuted = false; + response1.response = 'cae8f74f-046a-4756-b6c5-f2219d9a0f4e'; + response1.filter = ''; + response1.order = 0; + response1.permission = defaultPermissions.MODERATORS; + response1.keyword = keyword; + await response1.save(); + }); + + it('!testpermnull should be triggered by CASTER', async () => { + message.prepare(); + keywords.run({ sender: user.owner, message: 'testpermnull' }); + await message.isSentRaw('430ea834-da5f-48b1-bf2f-3acaf1f04c63', user.owner); + }); + + it('!testpermnull should not be triggered by VIEWER', async () => { + message.prepare(); + keywords.run({ sender: user.viewer, message: 'testpermnull' }); + await message.isNotSentRaw('430ea834-da5f-48b1-bf2f-3acaf1f04c63', user.viewer); + }); + + it('!testpermnull2 should be triggered by CASTER', async () => { + message.prepare(); + keywords.run({ sender: user.owner, message: 'testpermnull2' }); + await message.isWarnedRaw('Keyword testpermnull2#'+testpermnull2+'|0 doesn\'t have any permission set, treating as CASTERS permission.'); + await message.isSentRaw('1594a86e-158d-4b7d-9898-0f80bd6a0c98', user.owner); + }); + + it('!testpermnull2 should not be triggered by VIEWER', async () => { + message.prepare(); + keywords.run({ sender: user.viewer, message: 'testpermnull2' }); + await message.isWarnedRaw('Keyword testpermnull2#'+testpermnull2+'|0 doesn\'t have any permission set, treating as CASTERS permission.'); + await message.isNotSentRaw('1594a86e-158d-4b7d-9898-0f80bd6a0c98', user.viewer); + }); + + it('!testpermmods should be triggered by MOD', async () => { + message.prepare(); + keywords.run({ sender: user.mod, message: 'testpermmods' }); + await message.isSentRaw('cae8f74f-046a-4756-b6c5-f2219d9a0f4e', user.mod); + }); + + it('!testpermmods should not be triggered by VIEWER', async () => { + message.prepare(); + keywords.run({ sender: user.viewer, message: 'testpermmods' }); + await message.isNotSentRaw('cae8f74f-046a-4756-b6c5-f2219d9a0f4e', user.viewer); + }); + + describe('Test incorrect filter', () => { + before(() => { + message.prepare(); + }); + it('set $game to Test', async () => { + const {stats} = await import('../../../dest/helpers/api/stats.js'); + stats.value.currentGame = 'Test'; + }); + it('!testfilter keywords should not be triggered', async () => { + keywords.run({ sender: user.owner, message: 'testfilter' }); + await message.isWarnedRaw('Keyword testfilter#'+ testfilter +' didn\'t pass group filter.'); + }); + }); + + describe('Test correct filter', () => { + before(() => { + message.prepare(); + }); + it('set $game to Dota 2', async () => { + const {stats} = await import('../../../dest/helpers/api/stats.js'); + stats.value.currentGame = 'Dota 2'; + }); + it('!testfilter keywords should be triggered', async () => { + keywords.run({ sender: user.owner, message: 'testfilter' }); + await message.isSentRaw('bad449ae-f0b3-488c-a7b0-39a853d5333f', user.owner); + await message.isNotSentRaw('c0f68c62-630b-412b-9c97-f5b1afc734d2', user.owner); + await message.isSentRaw('4b310000-b105-475a-8a85-a573a0bca1b7', user.owner); + }); + }); +}); diff --git a/backend/test/tests/keywords/basic_workflow.js b/backend/test/tests/keywords/basic_workflow.js new file mode 100644 index 000000000..456745808 --- /dev/null +++ b/backend/test/tests/keywords/basic_workflow.js @@ -0,0 +1,184 @@ + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import assert from 'assert'; + +import { User } from '../../../dest/database/entity/user.js'; +import { Keyword } from '../../../dest/database/entity/keyword.js'; +import { AppDataSource } from '../../../dest/database.js'; + +import keywords from '../../../dest/systems/keywords.js'; + +// users +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__' }; + +function randomString() { + return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 5); +} +function generateCommand(opts) { + const k = opts.keyword ? '-k ' + opts.keyword : null; + const r = opts.response ? '-r ' + opts.response : null; + const rid = typeof opts.rid !== 'undefined' ? '-rId ' + opts.rid : null; + return [k, r, rid].join(' ').trim(); +} + +const failedTests = [ + { keyword: null, response: null }, + { keyword: null, response: 'Lorem Ipsum' }, + { keyword: 'ahoj', response: null }, + { keyword: 'ahoj|nebo', response: null }, +]; + +const successTests = [ + { + keyword: 'test', response: '(!me)', actualResponse: '@__broadcaster__ | Level 0 | 0 hours | 0 points | 0 messages | €0.00 | 0 bits | 0 months', + tests: [ + { type: 'add' }, + { type: 'run', triggers: ['This line will be triggered test'], '-triggers': [] }, + ], + }, + { + keyword: 'привет ты', response: randomString(), + tests: [ + { type: 'add' }, + { type: 'run', triggers: ['This line will be triggered привет ты', 'привет ты?', ',привет ты', 'Hi how привет ты you'], '-triggers': ['This line wont be triggered'] }, + ], + }, + { + keyword: 'hello.*|hi', response: randomString(), + tests: [ + { type: 'add' }, + { type: 'run', triggers: ['This line will be triggered hello', 'This line will be triggered hello?', 'Hi how are you'], '-triggers': ['This line wont be triggered'] }, + ], + }, + { + keyword: 'ahoj', response: randomString(), editResponse: randomString(), + tests: [ + { type: 'add' }, + { type: 'run', triggers: ['ahoj', 'ahoj jak je', 'jak je ahoj', ',ahoj', 'ahoj?'], '-triggers': ['ahojda', 'sorry jako'] }, + { type: 'edit' }, + { type: 'run', afterEdit: true, triggers: ['ahoj', 'ahoj jak je', 'jak je ahoj', ',ahoj', 'ahoj?'], '-triggers': ['ahojda', 'sorry jako'] }, + ], + }, + { + keyword: 'ahoj jak je', response: randomString(), + tests: [ + { type: 'add' }, + { type: 'run', triggers: ['ahoj jak je'], '-triggers': ['ahoj', 'ahojda', 'jak je ahoj', 'sorry jako'] }, + ], + }, + { + keyword: 'ahoj|jak', response: randomString(), + tests: [ + { type: 'add' }, + { type: 'run', triggers: ['ahoj', 'ahoj jak je', 'jak je ahoj'], '-triggers': ['ahojda', 'sorry jako'] }, + ], + }, + { + keyword: 'ahoj.*', response: randomString(), + tests: [ + { type: 'add' }, + { type: 'run', triggers: ['ahoj', 'ahojda', 'ahoj jak je', 'jak je ahoj'], '-triggers': ['sorry jako'] }, + ], + }, + { + keyword: 'ahoj.*|sorry jako', response: randomString(), + tests: [ + { type: 'add' }, + { type: 'run', triggers: ['Lorem ipsum dolor sit amet nevim co dal psat ahoj jak je ty vole?', 'ahoj', 'ahojda', 'ahoj jak je', 'jak je ahoj', 'sorry jako'], '-triggers': [] }, + { type: 'toggle' }, + { type: 'run', triggers: [], '-triggers': ['Lorem ipsum dolor sit amet nevim co dal psat ahoj jak je ty vole?', 'ahoj', 'ahojda', 'ahoj jak je', 'jak je ahoj', 'sorry jako'] }, + { type: 'toggle' }, + { type: 'run', triggers: ['Lorem ipsum dolor sit amet nevim co dal psat ahoj jak je ty vole?', 'ahoj', 'ahojda', 'ahoj jak je', 'jak je ahoj', 'sorry jako'], '-triggers': [] }, + { type: 'remove' }, + { type: 'run', triggers: [], '-triggers': ['Lorem ipsum dolor sit amet nevim co dal psat ahoj jak je ty vole?', 'ahoj', 'ahojda', 'ahoj jak je', 'jak je ahoj', 'sorry jako'] }, + ], + }, +]; + + +describe('Keywords - basic workflow (add, run, edit) - @func2', () => { + describe('Expected parsed fail', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + for (const t of failedTests) { + it(generateCommand(t), async () => { + const r = await keywords.add({ sender: owner, parameters: generateCommand(t) }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this command is not correct, use !keyword'); + }); + } + }); + + describe('Advanced tests', () => { + for (const test of successTests) { + describe(generateCommand(test), () => { + before(async () => { + await db.cleanup(); + await AppDataSource.getRepository(User).save(owner); + }); + beforeEach(async () => { + await message.prepare(); + }); + for (const t of test.tests) { + switch (t.type) { + case 'add': + it ('add()', async () => { + const r = await keywords.add({ sender: owner, parameters: generateCommand(test) }); + const k = await Keyword.findOneBy({ keyword: test.keyword }); + assert.strictEqual(r[0].response, `$sender, keyword ${test.keyword} (${k.id}) was added`); + }); + break; + case 'toggle': + it ('toggle()', async () => { + const k = await Keyword.findOneBy({ keyword: test.keyword }); + const r = await keywords.toggle({ sender: owner, parameters: generateCommand(test) }); + if (k.enabled) { + assert.strictEqual(r[0].response, `$sender, keyword ${test.keyword} was disabled`); + } else { + assert.strictEqual(r[0].response, `$sender, keyword ${test.keyword} was enabled`); + } + }); + break; + case 'remove': + it ('remove()', async () => { + const r = await keywords.remove({ sender: owner, parameters: generateCommand(test) }); + assert.strictEqual(r[0].response, `$sender, keyword ${test.keyword} was removed`); + }); + break; + case 'edit': + it (`edit() | ${test.response} => ${test.editResponse}`, async () => { + test.response = test.editResponse; + const r = await keywords.edit({ sender: owner, parameters: generateCommand({...test, ...t, rid: 1}) }); + assert.strictEqual(r[0].response, `$sender, keyword ${test.keyword} is changed to '${test.response}'`); + }); + break; + case 'run': + for (const r of t.triggers) { + it (`run() | ${r} => ${t.afterEdit ? test.editResponse : test.response}`, async () => { + await keywords.run({ sender: owner, message: r }); + await message.isSentRaw(t.afterEdit + ? test.editResponse + : (test.actualResponse + ? test.actualResponse + : test.response), owner); + }); + } + for (const r of t['-triggers']) { + it (`run() | ${r} => `, async () => { + await keywords.run({ sender: owner, message: r }); + await message.isNotSentRaw(t.afterEdit ? test.editResponse : test.response, owner); + }); + } + break; + default: + console.log('unknown: ' + t.type); + } + } + }); + } + }); +}); diff --git a/backend/test/tests/keywords/list.js b/backend/test/tests/keywords/list.js new file mode 100644 index 000000000..0b7361ad9 --- /dev/null +++ b/backend/test/tests/keywords/list.js @@ -0,0 +1,47 @@ +/* global describe it */ +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import assert from 'assert'; + +import keywords from '../../../dest/systems/keywords.js'; + +import { Keyword } from '../../../dest/database/entity/keyword.js'; +import { User } from '../../../dest/database/entity/user.js'; + +// users +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; + +describe('Keywords - list() - @func2', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('empty list', async () => { + const r = await keywords.list({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, '$sender, list of keywords is empty'); + }); + + it('populated list', async () => { + const r = await keywords.add({ sender: owner, parameters: '-p casters -k a -r me' }); + assert.strictEqual(r[0].response, `$sender, keyword a (${r[0].id}) was added`); + + const r2 = await keywords.add({ sender: owner, parameters: '-p moderators -s true -k a -r me2' }); + assert.strictEqual(r2[0].response, `$sender, keyword a (${r2[0].id}) was added`); + + const r3 = await keywords.add({ sender: owner, parameters: '-k b -r me' }); + assert.strictEqual(r3[0].response, `$sender, keyword b (${r3[0].id}) was added`); + + const r4 = await keywords.list({ sender: owner, parameters: '' }); + assert.strictEqual(r4[0].response, '$sender, list of keywords: a, b'); + + const r5 = await keywords.list({ sender: owner, parameters: 'a' }); + assert.strictEqual(r5[0].response, 'a#1 (Casters) v| me'); + assert.strictEqual(r5[1].response, 'a#2 (Moderators) _| me2'); + + const r6 = await keywords.list({ sender: owner, parameters: 'asdsad' }); + assert.strictEqual(r6[0].response, '$sender, asdsad have no responses or doesn\'t exists'); + }); +}); diff --git a/backend/test/tests/message/#3726_price_should_be_shown_alongside_alias_and_command_list.js b/backend/test/tests/message/#3726_price_should_be_shown_alongside_alias_and_command_list.js new file mode 100644 index 000000000..08c2a7ab4 --- /dev/null +++ b/backend/test/tests/message/#3726_price_should_be_shown_alongside_alias_and_command_list.js @@ -0,0 +1,103 @@ +/* global describe it */ +import('../../general.js'); + +import { db, message, user } from '../../general.js'; + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import {Message} from '../../../dest/message.js'; +import alias from '../../../dest/systems/alias.js'; +import customcommands from '../../../dest/systems/customcommands.js'; +import price from '../../../dest/systems/price.js'; +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; + +import { Price } from '../../../dest/database/entity/price.js'; + +describe('Message - #3726 - price should be shown alongside alias and command list - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + await AppDataSource.getRepository(Price).save({ command: '!a', price: 10 }); + await AppDataSource.getRepository(Price).save({ command: '!b', price: 0, priceBits: 10 }); + await AppDataSource.getRepository(Price).save({ command: '!c', price: 10, priceBits: 10 }); + }); + after(() => { + price.enabled = true; + }); + + describe('(list.alias) should return proper message with prices', () => { + it('enable price system', async () => { + price.enabled = true; + }); + + for (const aliasToCreate of ['!a', '!b', '!c', '!d']) { + it('Add alias ' + aliasToCreate, async () => { + const r = await alias.add({ sender: owner, parameters: `-a ${aliasToCreate} -c !me` }); + assert.strictEqual(r[0].response, `$sender, alias ${aliasToCreate} for !me was added`); + }); + } + + it('(list.alias) should return created aliases with price', async () => { + const r = await new Message('(list.alias)').parse({}); + assert.strictEqual(r, 'a(10 points), b(10 bits), c(10 points or 10 bits), d'); + }); + + it('(list.!alias) should return created aliases with price', async () => { + const r = await new Message('(list.!alias)').parse({}); + assert.strictEqual(r, '!a(10 points), !b(10 bits), !c(10 points or 10 bits), !d'); + }); + + it('disable price system', async () => { + price.enabled = false; + }); + + it('(list.alias) should return created aliases without price', async () => { + const r = await new Message('(list.alias)').parse({}); + assert.strictEqual(r, 'a, b, c, d'); + }); + + it('(list.!alias) should return created aliases without price', async () => { + const r = await new Message('(list.!alias)').parse({}); + assert.strictEqual(r, '!a, !b, !c, !d'); + }); + }); + + describe('(list.command) should return proper message with prices', () => { + it('enable price system', async () => { + price.enabled = true; + }); + + for (const command of ['!a', '!b', '!c', '!d']) { + it('Add command ' + command, async () => { + const r = await customcommands.add({ sender: owner, parameters: `-c ${command} -r Lorem Ipsum` }); + assert.strictEqual(r[0].response, `$sender, command ${command} was added`); + }); + } + + it('(list.command) should return created commands with price', async () => { + const r = await new Message('(list.command)').parse({}); + assert.strictEqual(r, 'a(10 points), b(10 bits), c(10 points or 10 bits), d'); + }); + + it('(list.!command) should return created commands with price', async () => { + const r = await new Message('(list.!command)').parse({}); + assert.strictEqual(r, '!a(10 points), !b(10 bits), !c(10 points or 10 bits), !d'); + }); + + it('disable price system', async () => { + price.enabled = false; + }); + + it('(list.command) should return created commands without price', async () => { + const r = await new Message('(list.command)').parse({}); + assert.strictEqual(r, 'a, b, c, d'); + }); + + it('(list.!command) should return created commands without price', async () => { + const r = await new Message('(list.!command)').parse({}); + assert.strictEqual(r, '!a, !b, !c, !d'); + }); + }); +}); diff --git a/backend/test/tests/message/api.js b/backend/test/tests/message/api.js new file mode 100644 index 000000000..cf525abc2 --- /dev/null +++ b/backend/test/tests/message/api.js @@ -0,0 +1,26 @@ +/* global */ +import('../../general.js'); +import('../../mocks.js'); + +import assert from 'assert'; + +import {Message} from '../../../dest/message.js'; +import { db, message as msg } from '../../general.js'; + +describe('Message - api filter - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + await msg.prepare(); + }); + + describe('#1989 - ?test=a\\\\nb should be correctly parsed', () => { + // we are using mock http://localhost/get?test=a\\nb + + const toParse = '(api|http://localhost/get?test=a\\nb) Lorem (api.test)'; + + it('Expecting response Lorem a\\\\nb', async () => { + const message = await new Message(toParse).parse({ }); + assert(message === 'Lorem a\\nb'); + }); + }); +}); diff --git a/backend/test/tests/message/count.js b/backend/test/tests/message/count.js new file mode 100644 index 000000000..8b05a453a --- /dev/null +++ b/backend/test/tests/message/count.js @@ -0,0 +1,100 @@ +/* global */ + +import('../../general.js'); + +import assert from 'assert'; + +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__' }; +import constants from '@sogebot/ui-helpers/constants.js'; + +import { EventList } from '../../../dest/database/entity/eventList.js'; +import { User, UserTip, UserBit } from '../../../dest/database/entity/user.js'; +import { AppDataSource } from '../../../dest/database.js'; +import rates from '../../../dest/helpers/currency/rates.js'; +import {Message} from '../../../dest/message.js'; +import { db } from '../../general.js'; + +const tests = [ + { text: `(count|subs|hour)`, expect: '5' }, + { text: `(count|subs|day)`, expect: '10' }, + { text: `(count|subs|week)`, expect: '15' }, + { text: `(count|subs|month)`, expect: '20' }, + { text: `(count|subs|year)`, expect: '25' }, + + { text: `(count|follows|hour)`, expect: '5' }, + { text: `(count|follows|day)`, expect: '10' }, + { text: `(count|follows|week)`, expect: '15' }, + { text: `(count|follows|month)`, expect: '20' }, + { text: `(count|follows|year)`, expect: '25' }, + + { text: `(count|bits|hour)`, expect: '5' }, + { text: `(count|bits|day)`, expect: '10' }, + { text: `(count|bits|week)`, expect: '15' }, + { text: `(count|bits|month)`, expect: '20' }, + { text: `(count|bits|year)`, expect: '25' }, + + { text: `(count|tips|hour)`, expect: '5' }, + { text: `(count|tips|day)`, expect: '10' }, + { text: `(count|tips|week)`, expect: '15' }, + { text: `(count|tips|month)`, expect: '20' }, + { text: `(count|tips|year)`, expect: '25' }, +]; + +const getTimestamp = (idx) => { + if (idx < 5) { + return Date.now() - (constants.HOUR / 2); + } else if (idx < 10) { + return Date.now() - (constants.DAY / 2); + } else if (idx < 15) { + return Date.now() - ((constants.DAY * 7) / 2); + } else if (idx < 20) { + return Date.now() - ((constants.DAY * 31) / 2); + } + return Date.now() - (constants.DAY * 180); +}; + +describe('Message - (count|#) filter - @func3', async () => { + beforeEach(async () => { + await db.cleanup(); + + const currency = (await import('../../../dest/currency.js')).default; + for (let i = 0; i < 25; i++) { + await AppDataSource.getRepository(EventList).save({ + isTest: false, + event: 'sub', + timestamp: getTimestamp(i), + userId: `${i}`, + values_json: '{}', + }); + await AppDataSource.getRepository(EventList).save({ + isTest: false, + event: 'follow', + timestamp: getTimestamp(i), + userId: `${i*10000}`, + values_json: '{}', + }); + await AppDataSource.getRepository(UserTip).save({ + amount: 1, + sortAmount: 1, + currency: 'EUR', + message: 'test', + tippedAt: getTimestamp(i), + exchangeRates: rates, + userId: `${i*10000}`, + }); + await AppDataSource.getRepository(UserBit).save({ + amount: 1, + message: 'test', + cheeredAt: getTimestamp(i), + userId: `${i*10000}`, + }); + } + }); + + for (const test of tests) { + it(`${test.text} => ${test.expect}`, async () => { + const message = await new Message(test.text).parse({ sender: owner }); + assert.strictEqual(message, test.expect); + }); + } +}); diff --git a/backend/test/tests/message/cvars.js b/backend/test/tests/message/cvars.js new file mode 100644 index 000000000..bc92e4db1 --- /dev/null +++ b/backend/test/tests/message/cvars.js @@ -0,0 +1,308 @@ +/* global describe it before */ + +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import { getOwner } from '../../../dest/helpers/commons/getOwner.js'; + +import('../../general.js'); + +import { db, message as msg } from '../../general.js'; +import {Message} from '../../../dest/message.js'; +import assert from 'assert'; +import _ from 'lodash-es'; + +import { User } from '../../../dest/database/entity/user.js'; +import { Variable } from '../../../dest/database/entity/variable.js'; +import { AppDataSource } from '../../../dest/database.js' + +// stub +_.set(global, 'widgets.custom_variables.io.emit', function () {}); + +describe('Message - cvars filter - @func3', async () => { + const users = [ + { userName: '__owner__', userId: String(Math.floor(Math.random() * 100000)), permission: defaultPermissions.CASTERS }, + { userName: '__viewer__', userId: String(Math.floor(Math.random() * 100000)), permission: defaultPermissions.VIEWERS }, + ]; + const tests = [ + { + test: '$_test', + variable: '$_test', + initialValue: 5, + afterValue: 10, + type: 'number', + command: 'This is $_test', + expectedSent: false, + params: { param: '+5' }, + responseType: 2, + }, + { + test: '$_test', + variable: '$_test', + initialValue: 5, + afterValue: 0, + type: 'number', + command: 'This is $_test', + expectedSent: false, + params: { param: '-5' }, + responseType: 2, + }, + { + test: '$_test', + variable: '$_test', + initialValue: 0, + afterValue: 0, + type: 'number', + command: 'This is $_test', + expectedSent: false, + params: { param: 'asd' }, + responseType: 2, + }, + { + test: '$_test', + variable: '$_test', + initialValue: 0, + afterValue: 5, + type: 'number', + command: 'This is $_test', + expectedSent: true, + params: { param: 5 }, + }, + { + test: '$_test', + variable: '$_test', + initialValue: 0, + afterValue: 1, + type: 'number', + command: 'This is $_test', + expectedSent: true, + params: { param: '+' }, + }, + { + test: '$_test', + variable: '$_test', + initialValue: '0', + afterValue: 1, + type: 'number', + command: 'This is $_test', + expectedSent: true, + params: { param: '+' }, + }, + { + test: '$!_test', + variable: '$_test', + initialValue: 0, + afterValue: -1, + type: 'number', + command: 'This is $!_test', + expectedSent: false, + params: { param: '-' }, + }, + { + test: '$!_test', + variable: '$_test', + initialValue: '0', + afterValue: -1, + type: 'number', + command: 'This is $!_test', + expectedSent: false, + params: { param: '-' }, + }, + { + test: '$!_test', + variable: '$_test', + initialValue: 0, + afterValue: 1, + type: 'number', + command: 'This is $!_test', + expectedSent: false, + params: { param: '+' }, + }, + { + test: '$_test', + variable: '$_test', + initialValue: 0, + afterValue: -1, + type: 'number', + command: 'This is $_test', + expectedSent: true, + params: { param: '-' }, + }, + { + test: '$!_test', + variable: '$_test', + initialValue: 0, + afterValue: 5, + type: 'number', + command: 'This is $!_test', + expectedSent: false, + params: { param: 5 }, + }, + ]; + + for (const p of ['CASTERS'] /*Object.keys(defaultPermissions)*/) { + describe('Custom variable with ' + p + ' permission', async () => { + for (const user of users) { + describe('Custom variable with ' + p + ' permission => Testing with ' + user.userName, async () => { + for (const test of tests) { + let message = null; + let testName = null; + if (user.userName === '__owner__' || (user.userName === '__viewer__' && p === 'VIEWERS')) { + testName =`'${test.test}' expect '${test.command.replace(/\$_test|\$!_test/g, test.afterValue)}' with value after ${test.afterValue}`; + } else { + testName =`'${test.test}' expect '${test.command.replace(/\$_test|\$!_test/g, test.initialValue)}' with value after ${test.afterValue} because insufficient permissions`; + } + + describe(testName, () => { + before(async () => { + await db.cleanup(); + await msg.prepare(); + + for (const user of users) { + await AppDataSource.getRepository(User).save(user); + } + }); + it(`create initial value '${test.initialValue}' of ${test.variable}`, async () => { + await Variable.create({ + variableName: test.variable, + readOnly: false, + currentValue: String(test.initialValue), + type: test.type, responseType: typeof test.responseType === 'undefined' ? 0 : test.responseType, + permission: defaultPermissions[p], + evalValue: '', + usableOptions: [], + }).save(); + }); + it(`parse '${test.command}' with params`, async () => { + message = await new Message(test.command).parse({ + ...test.params, + sender: user, + }); + }); + it('message parsed correctly', async () => { + if (user.userName === '__owner__' || (user.userName === '__viewer__' && p === 'VIEWERS')) { + if (test.responseType === 2 ) { + assert.strictEqual(message, test.command.replace(/\$_test|\$!_test/g, test.afterValue)); + } else { + assert.strictEqual(message, ''); + } + } else { + assert.strictEqual(message, test.command.replace(/\$_test|\$!_test/g, test.initialValue)); + } + }); + + if (test.params.param) { + if (test.expectedSent && (user.userName === '__owner__' || (user.userName === '__viewer__' && p === 'VIEWERS'))) { + it('expecting set message', async () => { + await msg.isSent('filters.setVariable', { userName: user.userName }, { sender: getOwner(), variable: '$_test', value: test.afterValue }, 1000); + }); + } else { + it('not expecting set message', async () => { + let notSent = false; + try { + await msg.isSent('filters.setVariable', { userName: user.userName }, { sender: getOwner(), variable: '$_test', value: test.afterValue }, 1000); + } catch (e) { + notSent = true; + } + assert(notSent); + }); + } + } + + if (user.userName === '__owner__' || (user.userName === '__viewer__' && p === 'VIEWERS')) { + it(`check if after value is ${test.afterValue}`, async () => { + const cvar = await Variable.findOneBy({ variableName: test.variable }); + assert.strictEqual(String(cvar.currentValue), String(test.afterValue)); + }); + } else { + it(`check if after value is ${test.initialValue}`, async () => { + const cvar = await Variable.findOneBy({ variableName: test.variable }); + assert.strictEqual(String(cvar.currentValue), String(test.initialValue)); + }); + } + + it(`parse '${test.command}' without params`, async () => { + const params = _.cloneDeep(test.params); + delete params.param; + message = await new Message(test.command).parse({ + ...params, + sender: user, + }); + }); + it('message parsed correctly', async () => { + if (user.userName === '__owner__' || (user.userName === '__viewer__' && p === 'VIEWERS')) { + assert.strictEqual(message, test.command.replace(/\$_test|\$!_test/g, test.afterValue)); + } else { + assert.strictEqual(message, test.command.replace(/\$_test|\$!_test/g, test.initialValue)); + } + }); + }); + } + + // read only tests + for (const test of tests) { + let message = null; + describe(`'${test.test}' expect '${test.command.replace(/\$_test|\$!_test/g, test.initialValue)}' with value after ${test.initialValue} because readOnly`, () => { + before(async () => { + await db.cleanup(); + await msg.prepare(); + + for (const user of users) { + await AppDataSource.getRepository(User).save(user); + } + }); + it(`create initial value '${test.initialValue}' of ${test.variable}`, async () => { + await Variable.create({ + variableName: test.variable, + readOnly: true, + currentValue: String(test.initialValue), + type: test.type, + responseType: 0, + permission: defaultPermissions[p], + evalValue: '', + usableOptions: [], + }).save(); + }); + it(`parse '${test.command}' with params`, async () => { + message = await new Message(test.command).parse({ + ...test.params, + sender: user, + }); + }); + it('message parsed correctly', async () => { + assert.strictEqual(message, test.command.replace(/\$_test|\$!_test/g, test.initialValue)); + }); + + if (test.params.param) { + it('not expecting set message', async () => { + let notSent = false; + try { + await msg.isSent('filters.setVariable', { userName: user.userName }, { sender: getOwner(), variable: '$_test', value: test.afterValue }, 1000); + } catch (e) { + notSent = true; + } + assert(notSent); + }); + } + + it(`check if after value is ${test.initialValue}`, async () => { + const cvar = await Variable.findOneBy({ variableName: test.variable }); + assert.strictEqual(String(cvar.currentValue), String(test.initialValue)); + }); + + it(`parse '${test.command}' without params`, async () => { + const params = _.cloneDeep(test.params); + delete params.param; + message = await new Message(test.command).parse({ + ...params, + sender: user, + }); + }); + it('message parsed correctly', async () => { + assert.strictEqual(message, test.command.replace(/\$_test|\$!_test/g, test.initialValue)); + }); + }); + } + }); + } + }); + } +}); diff --git a/backend/test/tests/message/discord#706756329204613160_latest_variable_are_not_correct.js b/backend/test/tests/message/discord#706756329204613160_latest_variable_are_not_correct.js new file mode 100644 index 000000000..8bd7f3520 --- /dev/null +++ b/backend/test/tests/message/discord#706756329204613160_latest_variable_are_not_correct.js @@ -0,0 +1,131 @@ + +import assert from 'assert'; + +import('../../general.js'); + +import { setImmediateAwait } from '../../../dest//helpers/setImmediateAwait.js'; +import { AppDataSource } from '../../../dest/database.js'; +import { EventList } from '../../../dest/database/entity/eventList.js'; +import { User } from '../../../dest/database/entity/user.js'; +import {Message} from '../../../dest/message.js'; +import eventlist from '../../../dest/overlays/eventlist.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Message - https://discordapp.com/channels/317348946144002050/619437014001123338/706756329204613160 - latest global variables are not correct - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + for (let i = 10000000; i < 10000040; i++) { + await AppDataSource.getRepository(User).save({ userName: `user${i}`, userId: String(i) }); + } + + }); + + it ('Add 10 follow events', async () => { + for (let i = 10000000; i < 10000010; i++) { + await AppDataSource.getRepository(EventList).save({ + isTest: false, + event: 'follow', + timestamp: i, + userId: `${i}`, + values_json: '{}', + }); + await setImmediateAwait(); + } + }); + + it ('Add 10 sub/resub/subgift events', async () => { + for (let i = 10000010; i < 10000020; i++) { + await AppDataSource.getRepository(EventList).save({ + isTest: false, + event: ['sub', 'resub', 'subgift'][Math.floor(Math.random() * 3)], + timestamp: i, + userId: `${i}`, + values_json: '{}', + }); + await setImmediateAwait(); + } + }); + + it ('Add 10 tips events', async () => { + for (let i = 10000020; i < 10000030; i++) { + await AppDataSource.getRepository(EventList).save({ + isTest: false, + event: 'tip', + timestamp: i, + userId: `${i}`, + values_json: JSON.stringify({ + amount: i, + currency: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'][i-10000020], + message: `message${i-20}`, + }), + }); + await setImmediateAwait(); + } + }); + + it ('Add 10 cheer events', async () => { + for (let i = 10000030; i < 10000040; i++) { + await AppDataSource.getRepository(EventList).save({ + isTest: false, + event: 'cheer', + timestamp: i, + userId: `${i}`, + values_json: JSON.stringify({ + bits: i, + message: `message${i-30}`, + }), + }); + await setImmediateAwait(); + } + }); + + it ('$latestFollower should have correct user10000009', async () => { + const parsed = await new Message('$latestFollower').parse({ sender: owner }); + assert.strictEqual(parsed, 'user10000009'); + }); + + it ('$latestSubscriber should have correct user10000019', async () => { + const parsed = await new Message('$latestSubscriber').parse({ sender: owner }); + assert.strictEqual(parsed, 'user10000019'); + }); + + it ('$latestTip should have correct user10000029', async () => { + const parsed = await new Message('$latestTip').parse({ sender: owner }); + assert.strictEqual(parsed, 'user10000029'); + }); + + it ('$latestTipAmount should have correct 10000029', async () => { + const parsed = await new Message('$latestTipAmount').parse({ sender: owner }); + assert.strictEqual(parsed, '10000029.00'); + }); + + it ('$latestTipCurrency should have correct j', async () => { + const parsed = await new Message('$latestTipCurrency').parse({ sender: owner }); + assert.strictEqual(parsed, 'j'); + }); + + it ('$latestTipMessage should have correct message10000009', async () => { + const parsed = await new Message('$latestTipMessage').parse({ sender: owner }); + assert.strictEqual(parsed, 'message10000009'); + }); + + it ('$latestCheer should have correct user10000039', async () => { + const parsed = await new Message('$latestCheer').parse({ sender: owner }); + assert.strictEqual(parsed, 'user10000039'); + }); + + it ('$latestCheerAmount should have correct 10000039', async () => { + const parsed = await new Message('$latestCheerAmount').parse({ sender: owner }); + assert.strictEqual(parsed, '10000039'); + }); + + it ('$latestCheerMessage should have correct message10000009', async () => { + const parsed = await new Message('$latestCheerMessage').parse({ sender: owner }); + assert.strictEqual(parsed, 'message10000009'); + }); +}); diff --git a/backend/test/tests/message/discord#706782624416399422_sender_object_should_be_ignored.js b/backend/test/tests/message/discord#706782624416399422_sender_object_should_be_ignored.js new file mode 100644 index 000000000..5485fd7a5 --- /dev/null +++ b/backend/test/tests/message/discord#706782624416399422_sender_object_should_be_ignored.js @@ -0,0 +1,52 @@ +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import { time } from '../../general.js'; + +import { isStreamOnline } from '../../../dest/helpers/api/isStreamOnline.js' +import alias from '../../../dest/systems/alias.js'; +import customcommands from '../../../dest/systems/customcommands.js'; +import timers from '../../../dest/systems/timers.js'; +import {check} from '../../../dest/watchers.js' + +import { AppDataSource } from '../../../dest/database.js'; +import { Timer, TimerResponse } from '../../../dest/database/entity/timer.js'; + +import { linesParsed } from '../../../dest/helpers/parser.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Message - https://discordapp.com/channels/317348946144002050/619437014001123338/706782624416399422 - sender object should be owner on timers with (!#) - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await alias.add({ sender: owner, parameters: '-a !testAlias -c !me' }); + await customcommands.add({ sender: owner, parameters: '-c !testCmd -r Lorem Ipsum' }); + const timer = await AppDataSource.getRepository(Timer).save({ + name: 'test', + triggerEveryMessage: 0, + triggerEverySecond: 1, + isEnabled: true, + triggeredAtTimestamp: Date.now(), + triggeredAtMessage: linesParsed, + }); + await AppDataSource.getRepository(TimerResponse).save({ + response: '(!top time)', + timestamp: Date.now(), + isEnabled: true, + timer, + }); + for (let i = 0; i < 5; i++) { + await time.waitMs(1000); + isStreamOnline.value = true; + await check(); + await timers.check(); + } + }); + + it('!top time should be properly triggered', async () => { + await message.isSentRaw('Top 10 (watch time): no data available', '__bot__', 20000); + }); +}); diff --git a/backend/test/tests/message/discord#708371785157967873_permission_of_command_should_be_ignored.js b/backend/test/tests/message/discord#708371785157967873_permission_of_command_should_be_ignored.js new file mode 100644 index 000000000..9f7040b27 --- /dev/null +++ b/backend/test/tests/message/discord#708371785157967873_permission_of_command_should_be_ignored.js @@ -0,0 +1,46 @@ +import('../../general.js'); + +import { db, message, user } from '../../general.js'; + +import assert from 'assert'; + +import customcommands from '../../../dest/systems/customcommands.js'; +import { Parser } from '../../../dest/parser.js'; + +describe('Message - https://discordapp.com/channels/317348946144002050/619437014001123338/708371785157967873 - permission of command with (!#) should be ignored - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await customcommands.add({ sender: user.owner, parameters: '-c !test -r (!queue open)' }); + }); + + it('call !queue open directly with regular viewer should send permission error', async () => { + const parse = new Parser({ sender: user.viewer, message: '!queue open', skip: false, quiet: false }); + const r = await parse.process(); + assert.strictEqual(r[0].response, 'You don\'t have enough permissions for \'!queue open\''); + }); + + it('!test should properly trigger !queue open', async () => { + await customcommands.run({ sender: user.viewer, message: '!test' }); + await message.debug('message.process', '!queue open'); + }); +}); + +describe('Message - https://discordapp.com/channels/317348946144002050/619437014001123338/708371785157967873 - permission of command with (!!#) should be ignored - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await customcommands.add({ sender: user.owner, parameters: '-c !test -r (!!queue open)' }); + }); + + it('call !queue open directly with regular viewer should send permission error', async () => { + const parse = new Parser({ sender: user.viewer, message: '!queue open', skip: false, quiet: false }); + const r = await parse.process(); + assert.strictEqual(r[0].response, 'You don\'t have enough permissions for \'!queue open\''); + }); + + it('!test should properly trigger !queue open', async () => { + await customcommands.run({ sender: user.viewer, message: '!test' }); + await message.debug('message.process', '!queue open'); + }); +}); diff --git a/backend/test/tests/message/discord#968557749908701245_stream_username_with_at_not_parsed.js b/backend/test/tests/message/discord#968557749908701245_stream_username_with_at_not_parsed.js new file mode 100644 index 000000000..d94eff62a --- /dev/null +++ b/backend/test/tests/message/discord#968557749908701245_stream_username_with_at_not_parsed.js @@ -0,0 +1,22 @@ +import assert from 'assert'; + +import('../../general.js'); + +import { db, message, user } from '../../general.js'; + +describe('Message - https://discord.com/channels/317348946144002050/317349069024395264/968557749908701245 - stream response filter should be able to parse user with at - @func3', () => { + before(async () => { + const customcommands = (await import('../../../dest/systems/customcommands.js')).default; + + await db.cleanup(); + await message.prepare(); + await customcommands.add({ sender: user.owner, parameters: '-c !test -r (stream|$param|link) (stream|$touser|link)' }); + }); + + it('!test should properly parse @user', async () => { + const customcommands = (await import('../../../dest/systems/customcommands.js')).default; + + await customcommands.run({ sender: user.viewer, message: '!test @user' }); + await message.debug('sendMessage.message', 'twitch.tv/user twitch.tv/user'); + }); +}); \ No newline at end of file diff --git a/backend/test/tests/message/if.js b/backend/test/tests/message/if.js new file mode 100644 index 000000000..4b7470604 --- /dev/null +++ b/backend/test/tests/message/if.js @@ -0,0 +1,26 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db, message as msg } from '../../general.js'; +import {Message} from '../../../dest/message.js'; +import assert from 'assert'; + +describe('Message - if filter - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + await msg.prepare(); + }); + + describe('(if \'$!param\'==\'n/a\'| $sender (random.online.viewer) chosed | $sender and $param (random.number-1-to-100)%)', () => { + const toParse = '(if \'$!param\'==\'n/a\'| $sender (random.online.viewer) chosed | $sender and $param (random.number-1-to-100)%)'; + + it('Check true condition', async () => { + const message = await new Message(toParse).parse({ param: 'n/a' }); + assert(message === '$sender unknown chosed'); + }); + it('Check false condition', async () => { + const message = await new Message(toParse).parse({ param: 'asd' }); + assert(message.match(/\$sender and asd \d{1,3}%/).length > 0); + }); + }); +}); diff --git a/backend/test/tests/message/list.js b/backend/test/tests/message/list.js new file mode 100644 index 000000000..e4ae13976 --- /dev/null +++ b/backend/test/tests/message/list.js @@ -0,0 +1,114 @@ +/* global */ +import('../../general.js'); + +import assert from 'assert'; + +import { User } from '../../../dest/database/entity/user.js'; +import { AppDataSource } from '../../../dest/database.js'; +import {Message} from '../../../dest/message.js'; +import alias from '../../../dest/systems/alias.js'; +import cooldown from '../../../dest/systems/cooldown.js' +import customcommands from '../../../dest/systems/customcommands.js'; +import ranks from '../../../dest/systems/ranks.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; + +describe('Message - list filter - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + }); + + describe('(list.alias) should return proper message', () => { + for (const aliasToCreate of ['!a', '!b', '!c']) { + it('Add alias ' + aliasToCreate, async () => { + const r = await alias.add({ sender: owner, parameters: `-a ${aliasToCreate} -c !me` }); + assert.strictEqual(r[0].response, `$sender, alias ${aliasToCreate} for !me was added`); + }); + } + + it('(list.alias) should return created aliases', async () => { + const r = await new Message('(list.alias)').parse({}); + assert.strictEqual(r, 'a, b, c'); + }); + + it('(list.!alias) should return created aliases', async () => { + const r = await new Message('(list.!alias)').parse({}); + assert.strictEqual(r, '!a, !b, !c'); + }); + }); + + describe('(list.command) should return proper message', () => { + for (const command of ['!a', '!b', '!c']) { + it('Add command ' + command, async () => { + const r = await customcommands.add({ sender: owner, parameters: `-c ${command} -r Lorem Ipsum` }); + assert.strictEqual(r[0].response, `$sender, command ${command} was added`); + }); + } + + it('(list.command) should return created commands', async () => { + const r = await new Message('(list.command)').parse({}); + assert.strictEqual(r, 'a, b, c'); + }); + + it('(list.!command) should return created commands', async () => { + const r = await new Message('(list.!command)').parse({}); + assert.strictEqual(r, '!a, !b, !c'); + }); + }); + + describe('(list.cooldown) should return proper message', () => { + it('!test user 20', async () => { + const r = await cooldown.main({ sender: owner, parameters: '!test user 20' }); + assert.strictEqual(r[0].response, '$sender, user cooldown for !test was set to 20s'); + }); + + it('test global 20 true', async () => { + const r = await cooldown.main({ sender: owner, parameters: 'test global 20 true' }); + assert.strictEqual(r[0].response, '$sender, global cooldown for test was set to 20s'); + + }); + + it('(list.cooldown) should return created cooldowns', async () => { + const r = await new Message('(list.cooldown)').parse({}); + assert.strictEqual(r, '!test: 20s, test: 20s'); + }); + }); + + describe('(list.ranks) should return proper message', () => { + it('test - 20h', async () => { + const r = await ranks.add({ sender: owner, parameters: '20 test' }); + assert.strictEqual(r[0].response, '$sender, new rank viewer test(20hours) was added'); + }); + + it('test2 - 40h', async () => { + const r = await ranks.add({ sender: owner, parameters: '40 test2' }); + assert.strictEqual(r[0].response, '$sender, new rank viewer test2(40hours) was added'); + }); + + it('(list.ranks) should return created ranks', async () => { + const r = await new Message('(list.ranks)').parse({}); + assert.strictEqual(r, 'test (20h), test2 (40h)'); + }); + }); + + describe('(list.core.) should return proper message', () => { + it('(list.core.CASTERS) should return core commands', async () => { + const r = await new Message('(list.core.CASTERS)').parse({}); + assert.strictEqual(r, '_debug, alias, alias add, alias edit, alias group, alias list, alias remove, alias toggle, alias toggle-visibility, bansong, clip, command, command add, command edit, command list, command remove, command toggle, command toggle-visibility, commercial, cooldown set, cooldown toggle enabled, cooldown toggle moderators, cooldown toggle owners, cooldown toggle subscribers, cooldown unset, disable, enable, game set, highlight, highlight list, hltb, ignore add, ignore check, ignore remove, immune, keyword, keyword add, keyword edit, keyword list, keyword remove, keyword toggle, level change, makeitrain, permission exclude-add, permission exclude-rm, permission list, permit, playlist, playlist add, playlist import, playlist list, playlist remove, playlist set, playlist steal, points add, points all, points get, points online, points remove, points set, points undo, price, price list, price set, price toggle, price unset, queue clear, queue close, queue list, queue open, queue pick, queue random, quote add, quote remove, quote set, raffle open, raffle pick, raffle remove, rank add, rank add-sub, rank edit, rank edit-sub, rank help, rank list, rank list-sub, rank rm, rank rm-sub, rank set, rank unset, reconnect, replay, scrim stop, set, skipsong, snipe, timers, timers add, timers list, timers rm, timers set, timers toggle, timers unset, title set, top bits, top gifts, top level, top messages, top points, top subage, top submonths, top time, top tips, tts, unbansong'); + }); + + it('(list.core.VIEWERS) should return core commands', async () => { + const r = await new Message('(list.core.VIEWERS)').parse({}); + assert.strictEqual(r, 'age, currentsong, followage, followers, game, lastseen, level, level buy, me, ping, points, points give, queue, queue join, quote, quote list, raffle, rank, snipe match, songrequest, subage, subs, time, title, uptime, watched, wrongsong'); + }); + + it('(list.!core.VIEWERS) should return core commands', async () => { + const r = await new Message('(list.!core.VIEWERS)').parse({}); + assert.strictEqual(r, '!age, !currentsong, !followage, !followers, !game, !lastseen, !level, !level buy, !me, !ping, !points, !points give, !queue, !queue join, !quote, !quote list, !raffle, !rank, !snipe match, !songrequest, !subage, !subs, !time, !title, !uptime, !watched, !wrongsong'); + }); + }); +}); diff --git a/backend/test/tests/message/random.js b/backend/test/tests/message/random.js new file mode 100644 index 000000000..720db2915 --- /dev/null +++ b/backend/test/tests/message/random.js @@ -0,0 +1,152 @@ +/* global */ +import('../../general.js'); + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__' }; +const ignoredUser = { userId: String(Math.floor(Math.random() * 100000)), userName: 'ignoreduser' }; +const user = { userId: String(Math.floor(Math.random() * 100000)), userName: 'user1' }; + +import { User } from '../../../dest/database/entity/user.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import {Message} from '../../../dest/message.js'; +import { db, message as msg } from '../../general.js'; + +async function setUsersOnline(users) { + await AppDataSource.getRepository(User).update({}, { isOnline: false }); + for (const userName of users) { + await AppDataSource.getRepository(User).update({ userName }, { isOnline: true }); + } +} + +let twitch; +describe('Message - random filter - @func3', () => { + before(async () => { + twitch = (await import('../../../dest/services/twitch.js')).default; + }); + describe('(random.online.viewer) should exclude ignored user', () => { + before(async () => { + await db.cleanup(); + await msg.prepare(); + + await AppDataSource.getRepository(User).save(owner); + await AppDataSource.getRepository(User).save(ignoredUser); + await AppDataSource.getRepository(User).save(user); + + const r = await twitch.ignoreRm({ sender: owner, parameters: 'ignoreduser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.removed', { userName: 'ignoreduser' })); + }); + + it('add user ignoreduser to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'ignoreduser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added', { userName: 'ignoreduser' })); + }); + + it('From 100 randoms ignoreduser shouldn\'t be picked', async () => { + for (let i = 0; i < 100; i++) { + await setUsersOnline(['ignoreduser', 'user1']); + const message = await new Message('(random.online.viewer)').parse({ sender: owner }); + assert.notEqual(message, 'ignoreduser'); + } + }); + }); + + describe('(random.online.subscriber) should exclude ignored user', () => { + before(async () => { + await db.cleanup(); + await msg.prepare(); + + await AppDataSource.getRepository(User).save(owner); + await AppDataSource.getRepository(User).save(ignoredUser); + await AppDataSource.getRepository(User).save(user); + + const r = await twitch.ignoreRm({ sender: owner, parameters: 'ignoreduser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.removed', { userName: 'ignoreduser' })); + }); + it('add user ignoreduser to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'ignoreduser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added', { userName: 'ignoreduser' })); + }); + + const users = ['ignoreduser', 'user1']; + for (const userName of users) { + it('add user ' + userName + ' to users list', async () => { + await AppDataSource.getRepository(User).save({ userId: String(Math.floor(Math.random() * 100000)), userName, isSubscriber: true }); + }); + } + + it('From 100 randoms ignoreduser shouldn\'t be picked', async () => { + for (let i = 0; i < 100; i++) { + await setUsersOnline(['ignoreduser', 'user1']); + const message = await new Message('(random.online.subscriber)').parse({ sender: owner }); + assert.notEqual(message, 'ignoreduser'); + } + }); + }); + + describe('(random.viewer) should exclude ignored user', () => { + before(async () => { + await db.cleanup(); + await msg.prepare(); + + await AppDataSource.getRepository(User).save(owner); + await AppDataSource.getRepository(User).save(ignoredUser); + await AppDataSource.getRepository(User).save(user); + + const r = await twitch.ignoreRm({ sender: owner, parameters: 'ignoreduser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.removed', { userName: 'ignoreduser' })); + }); + + it('add user ignoreduser to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'ignoreduser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added', { userName: 'ignoreduser' })); + }); + + const users = ['ignoreduser', 'user1']; + for (const userName of users) { + it('add user ' + userName + ' to users list', async () => { + await AppDataSource.getRepository(User).save({ userId: String(Math.floor(Math.random() * 100000)), userName }); + }); + } + + it('From 100 randoms ignoreduser shouldn\'t be picked', async () => { + for (let i = 0; i < 100; i++) { + const message = await new Message('(random.viewer)').parse({ sender: owner }); + assert.notEqual(message, 'ignoreduser'); + } + }); + }); + + describe('(random.subscriber) should exclude ignored user', () => { + before(async () => { + await db.cleanup(); + await msg.prepare(); + + await AppDataSource.getRepository(User).save(owner); + await AppDataSource.getRepository(User).save(ignoredUser); + await AppDataSource.getRepository(User).save(user); + + const r = await twitch.ignoreRm({ sender: owner, parameters: 'ignoreduser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.removed', { userName: 'ignoreduser' })); + }); + it('add user ignoreduser to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'ignoreduser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added', { userName: 'ignoreduser' })); + }); + + const users = ['ignoreduser', 'user1']; + for (const userName of users) { + it('add user ' + userName + ' to users list', async () => { + await AppDataSource.getRepository(User).save({ userId: String(Math.floor(Math.random() * 100000)), userName, isSubscriber: true }); + }); + } + + it('From 100 randoms ignoreduser shouldn\'t be picked', async () => { + for (let i = 0; i < 100; i++) { + const message = await new Message('(random.subscriber)').parse({ sender: owner }); + assert.notEqual(message, 'ignoreduser'); + } + }); + }); +}); diff --git a/backend/test/tests/message/random_number.js b/backend/test/tests/message/random_number.js new file mode 100644 index 000000000..6044cb464 --- /dev/null +++ b/backend/test/tests/message/random_number.js @@ -0,0 +1,46 @@ + + +/* global describe it before */ + +import('../../general.js'); + +import { db } from '../../general.js'; +import {Message} from '../../../dest/message.js'; +import assert from 'assert'; +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__' }; + +describe('Message - (random.number-#-to-#) filter - @func3', async () => { + beforeEach(async () => { + await db.cleanup(); + }); + + it(`Several (random.number-#-to-#) should return different numbers`, async () => { + let number = 0; + for (let i = 0; i < 10; i++) { + const message = await new Message('(random.number-0-to-' + i + ')').parse({ sender: owner }); + number += Number(message); + } + assert(number > 0); + }); + + it(`(random.number-5-to-6) should return 5 or 6`, async () => { + for (let i = 0; i < 100; i++) { + const message = await new Message('(random.number-5-to-6)').parse({ sender: owner }); + assert(Number(message) >= 5 && Number(message) <= 6); + } + }); + + it(`(random.number-0-to-1) should return 0 or 1`, async () => { + for (let i = 0; i < 100; i++) { + const message = await new Message('(random.number-0-to-1)').parse({ sender: owner }); + assert(Number(message) >= 0 && Number(message) <= 1); + } + }); + + it(`(random.number-0-to-0) should return 0`, async () => { + for (let i = 0; i < 100; i++) { + const message = await new Message('(random.number-0-to-0)').parse({ sender: owner }); + assert(Number(message) === 0); + } + }); +}); diff --git a/backend/test/tests/message/toFloat.js b/backend/test/tests/message/toFloat.js new file mode 100644 index 000000000..7741df4e7 --- /dev/null +++ b/backend/test/tests/message/toFloat.js @@ -0,0 +1,28 @@ +/* global describe it before */ + +import('../../general.js'); + +import { db } from '../../general.js'; +import {Message} from '../../../dest/message.js'; +import assert from 'assert'; +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__' }; + +const tests = [ + { text: `(toFloat|2|0.5)`, expect: '0.50' }, + { text: `(toFloat|0.5)`, expect: '1' }, + { text: `(toFloat|0.4321)`, expect: '0' }, + { text: `(toFloat|2|0.43211123)`, expect: '0.43' }, +]; + +describe('Message - (toFloat|#) filter - @func3', async () => { + beforeEach(async () => { + await db.cleanup(); + }); + + for (const test of tests) { + it(`${test.text} => ${test.expect}`, async () => { + const message = await new Message(test.text).parse({ sender: owner }); + assert.strictEqual(message, test.expect); + }); + } +}); diff --git a/backend/test/tests/message/toPercent.js b/backend/test/tests/message/toPercent.js new file mode 100644 index 000000000..c5e6e879a --- /dev/null +++ b/backend/test/tests/message/toPercent.js @@ -0,0 +1,28 @@ +/* global describe it before */ + +import('../../general.js'); + +import { db } from '../../general.js'; +import {Message} from '../../../dest/message.js'; +import assert from 'assert'; +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__' }; + +const tests = [ + { text: `(toPercent|2|0.5)`, expect: '50.00' }, + { text: `(toPercent|0.5)`, expect: '50' }, + { text: `(toPercent|0.4321)`, expect: '43' }, + { text: `(toPercent|2|0.43211123)`, expect: '43.21' }, +]; + +describe('Message - (toPercent|#) filter - @func3', async () => { + beforeEach(async () => { + await db.cleanup(); + }); + + for (const test of tests) { + it(`${test.text} => ${test.expect}`, async () => { + const message = await new Message(test.text).parse({ sender: owner }); + assert.strictEqual(message, test.expect); + }); + } +}); diff --git a/backend/test/tests/message/touser.js b/backend/test/tests/message/touser.js new file mode 100644 index 000000000..de1b95cda --- /dev/null +++ b/backend/test/tests/message/touser.js @@ -0,0 +1,46 @@ +/* global describe it before */ + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; +const someuser = { userName: 'someuser', userId: String(Math.floor(Math.random() * 100000)) }; + +import { AppDataSource } from '../../../dest/database.js'; +import { User } from '../../../dest/database/entity/user.js'; + +import customcommands from '../../../dest/systems/customcommands.js'; + +describe('Message - $touser filter - @func3', async () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save(owner); + await AppDataSource.getRepository(User).save(someuser); + + await customcommands.add({ sender: owner, parameters: '-c !point -r $sender points to $touser'}); + }); + + it('!point someuser', async () => { + customcommands.run({ sender: owner, message: '!point someuser' }); + await message.isSentRaw('@__broadcaster__ points to @someuser', owner); + }); + + it('!point @someuser', async () => { + customcommands.run({ sender: owner, message: '!point @someuser' }); + await message.isSentRaw('@__broadcaster__ points to @someuser', owner); + }); + + it('!point', async () => { + customcommands.run({ sender: owner, message: '!point' }); + await message.isSentRaw('@__broadcaster__ points to @__broadcaster__', owner); + }); + + it('!point @', async () => { + customcommands.run({ sender: owner, message: '!point' }); + await message.isSentRaw('@__broadcaster__ points to @__broadcaster__', owner); + }); +}); diff --git a/backend/test/tests/moderation/#5583_if_some_permission_allows_then_it_should_be_allowed.js b/backend/test/tests/moderation/#5583_if_some_permission_allows_then_it_should_be_allowed.js new file mode 100644 index 000000000..1cc5362a7 --- /dev/null +++ b/backend/test/tests/moderation/#5583_if_some_permission_allows_then_it_should_be_allowed.js @@ -0,0 +1,196 @@ +/* global */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +import { Permissions } from '../../../dest/database/entity/permissions.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import moderation from '../../../dest/systems/moderation.js'; +import { db } from '../../general.js'; +import { variable } from '../../general.js'; +import { message } from '../../general.js'; +import { user } from '../../general.js'; + +describe('#5583 - If lower permission allows, then it should be allowed - @func1', () => { + after(async () => { + await AppDataSource + .createQueryBuilder() + .delete() + .from(Permissions) + .where('1 = 1') + .execute(); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.CASTERS, + name: 'Casters', + automation: 'casters', + isCorePermission: true, + isWaterfallAllowed: true, + order: 0, + userIds: [], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.MODERATORS, + name: 'Moderators', + automation: 'moderators', + isCorePermission: true, + isWaterfallAllowed: true, + order: 1, + userIds: [], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.SUBSCRIBERS, + name: 'Subscribers', + automation: 'subscribers', + isCorePermission: true, + isWaterfallAllowed: true, + order: 2, + userIds: [], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.VIP, + name: 'VIP', + automation: 'vip', + isCorePermission: true, + isWaterfallAllowed: true, + order: 3, + userIds: [], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.VIEWERS, + name: 'Viewers', + automation: 'viewers', + isCorePermission: true, + isWaterfallAllowed: true, + order: 4, + userIds: [], + excludeUserIds: [], + filters: [], + }); + }); + + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + await AppDataSource + .createQueryBuilder() + .delete() + .from(Permissions) + .where('1 = 1') + .execute(); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.CASTERS, + name: 'Casters', + automation: 'casters', + isCorePermission: true, + isWaterfallAllowed: true, + order: 0, + userIds: [], + excludeUserIds: [], + filters: [], + }); + moderation.__permission_based__cLinksEnabled[defaultPermissions.CASTERS] = true; + + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.MODERATORS, + name: 'Moderators', + automation: 'moderators', + isCorePermission: true, + isWaterfallAllowed: true, + order: 1, + userIds: [], + excludeUserIds: [], + filters: [], + }); + moderation.__permission_based__cLinksEnabled[defaultPermissions.MODERATORS] = true; + await AppDataSource.getRepository(Permissions).insert({ + id: '262e0172-bf00-41d7-b363-346bea52838b', + name: 'Test', + automation: 'moderators', + isCorePermission: false, + isWaterfallAllowed: true, + order: 2, + userIds: [user.viewer.userId], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: '162e0172-bf00-41d7-b363-346bea52838b', + name: 'Test 2', + automation: 'moderators', + isCorePermission: false, + isWaterfallAllowed: true, + order: 2, + userIds: [user.viewer.userId], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.SUBSCRIBERS, + name: 'Subscribers', + automation: 'subscribers', + isCorePermission: true, + isWaterfallAllowed: true, + order: 3, + userIds: [], + excludeUserIds: [], + filters: [], + }); + moderation.__permission_based__cLinksEnabled[defaultPermissions.SUBSCRIBERS] = true; + + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.VIP, + name: 'VIP', + automation: 'vip', + isCorePermission: true, + isWaterfallAllowed: true, + order: 4, + userIds: [], + excludeUserIds: [], + filters: [], + }); + moderation.__permission_based__cLinksEnabled[defaultPermissions.VIP] = true; + + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.VIEWERS, + name: 'Viewers', + automation: 'viewers', + isCorePermission: true, + isWaterfallAllowed: true, + order: 5, + userIds: [], + excludeUserIds: [], + filters: [], + }); + moderation.__permission_based__cLinksEnabled[defaultPermissions.VIEWERS] = true; + }); + + it (`Enable link moderation for Test group`, () => { + moderation.__permission_based__cLinksEnabled['262e0172-bf00-41d7-b363-346bea52838b'] = true; + moderation.__permission_based__cLinksEnabled['162e0172-bf00-41d7-b363-346bea52838b'] = true; + }); + + it(`Link 'http://www.foobarpage.com' should timeout`, async () => { + assert(!(await moderation.containsLink({ sender: user.viewer, message: 'http://www.foobarpage.com' }))); + }); + + it (`Disable link moderation for Test group`, () => { + moderation.__permission_based__cLinksEnabled['262e0172-bf00-41d7-b363-346bea52838b'] = true; + moderation.__permission_based__cLinksEnabled['162e0172-bf00-41d7-b363-346bea52838b'] = false; + }); + + it(`Link 'http://www.foobarpage.com' should not timeout`, async () => { + assert((await moderation.containsLink({ sender: user.viewer, message: 'http://www.foobarpage.com' }))); + }); +}); diff --git a/backend/test/tests/moderation/blacklist.js b/backend/test/tests/moderation/blacklist.js new file mode 100644 index 000000000..f2d6745b0 --- /dev/null +++ b/backend/test/tests/moderation/blacklist.js @@ -0,0 +1,121 @@ +/* global describe it before */ + + +import('../../general.js'); + +import { db } from '../../general.js'; +import { variable } from '../../general.js'; +import { message } from '../../general.js'; +import { user } from '../../general.js'; + +import _ from 'lodash-es'; +import assert from 'assert'; +import moderation from '../../../dest/systems/moderation.js'; + +const tests = { + 'test': { + 'should.return.false': [ + 'test', 'a test', 'test a', 'a test a', 'test?', '?test', + ], + 'should.return.true': [ + 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'testa', 'testaa', 'atesta', 'aatestaa', 'test1', '1test1', 'test11', '11test11', 'русскийtestрусский', 'testрусский', '한국어test한국어', 'test한국어', + ], + }, + '*test': { + 'should.return.false': [ + 'test', 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'a test', 'test a', 'test?', '?test', + ], + 'should.return.true': [ + 'testa', 'testaa', 'atesta', 'aatestaa', 'test1', '1test1', 'test11', '11test11', 'русскийtestрусский', 'testрусский', '한국어test한국어', 'test한국어', + ], + }, + 'test*': { + 'should.return.false': [ + 'test', 'a test', 'test a', 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', 'test?', '?test', + ], + 'should.return.true': [ + 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', + ], + }, + '*test*': { + 'should.return.true': [ + 'abc', + ], + 'should.return.false': [ + 'test', 'a test', 'test a', 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', 'test?', '?test', + ], + }, + '+test': { + 'should.return.false': [ + 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'atest?', + ], + 'should.return.true': [ + 'test', 'a test', 'test a', 'testa', 'testaa', 'atesta', 'aatestaa', 'test1', '1test1', 'test11', '11test11', 'русскийtestрусский', 'testрусский', '한국어test한국어', 'test한국어', '?test', + ], + }, + 'test+': { + 'should.return.false': [ + 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', '?testa', + ], + 'should.return.true': [ + 'test', 'a test', 'test a', 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', 'test?', + ], + }, + '+test+': { + 'should.return.false': [ + 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', + ], + 'should.return.true': [ + 'test', 'abc', 'a test', 'test a', 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'test?', '?test', + ], + }, + '*test+': { + 'should.return.false': [ + 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', + ], + 'should.return.true': [ + 'test', 'abc', 'a test', 'test a', 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'test?', '?test', + ], + }, + '+test*': { + 'should.return.false': [ + 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', + ], + 'should.return.true': [ + 'test', 'abc', 'a test', 'test a', 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', 'test?', '?test', + ], + }, + '*саня*': { + 'should.return.false': ['саня'], + 'should.return.true': [], + }, + ' ': { + 'should.return.false': [], + 'should.return.true': ['have a good night brotha'], + }, +}; + +describe('systems/moderation - blacklist() - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + for (const [pattern, test] of Object.entries(tests)) { + for (const text of _.get(test, 'should.return.true', [])) { + it(`pattern '${pattern}' should ignore '${text}'`, async () => { + moderation.cListsBlacklist = [pattern]; + const result = await moderation.blacklist({ sender: user.viewer, message: text }); + assert(result); + }); + } + for (const text of _.get(test, 'should.return.false', [])) { + it(`pattern '${pattern}' should timeout on '${text}'`, async () => { + moderation.cListsBlacklist = [pattern]; + const result = await moderation.blacklist({ sender: user.viewer, message: text }); + assert(!result); + }); + } + } +}); diff --git a/backend/test/tests/moderation/caps.js b/backend/test/tests/moderation/caps.js new file mode 100644 index 000000000..bb324a909 --- /dev/null +++ b/backend/test/tests/moderation/caps.js @@ -0,0 +1,101 @@ +/* global describe it before */ + +import assert from 'assert'; + +import('../../general.js'); +import moderation from '../../../dest/systems/moderation.js'; +import { db } from '../../general.js'; +import { variable } from '../../general.js'; +import { message } from '../../general.js'; +import { user } from '../../general.js'; +import { time } from '../../general.js'; + +const emotesOffsetsKAPOW = new Map(); +emotesOffsetsKAPOW.set('133537', ['7-11', '13-17']); + +const tests = { + 'timeout': [ + { message: 'AAAAAAAAAAAAAAAAAAAAAA', sender: user.viewer }, + { message: 'ЙЦУЦЙУЙЦУЙЦУЙЦУЙЦУЙЦ', sender: user.viewer }, + { message: 'AAAAAAAAAAAAAaaaaaaaaaaaa', sender: user.viewer }, + { message: 'SomeMSG SomeMSG', sender: user.viewer }, + ], + 'ok': [ + { message: 'SomeMSG SomeMSg', sender: user.viewer }, + { message: '123123123213123123123123213123', sender: user.viewer }, + { + message: 'zdarec KAPOW KAPOW', sender: user.viewer, emotesOffsets: emotesOffsetsKAPOW, + }, + { message: '😀 😁 😂 🤣 😃 😄 😅 😆 😉 😊 😋 😎 😍 😘 😗 😙 😚 🙂 🤗 🤩 🤔 🤨 😐 😑 😶 🙄 😏 😣 😥 😮 🤐 😯 😪 😫 😴 😌 😛 😜 😝 🤤 😒 😓 😔 😕 🙃 🤑 😲 ☹️ 🙁 😖 😞 😟 😤 😢 😭 😦 😧 😨 😩 🤯 😬 😰 😱', sender: user.viewer }, + ], +}; + +describe('systems/moderation - Caps() - @func2', () => { + describe('moderationCaps=false', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cCapsEnabled = false; + }); + + for (const test of tests.timeout) { + it(`message '${test.message}' should not timeout`, async () => { + assert(await moderation.caps({ emotesOffsets: test.emotesOffsets ? test.emotesOffsets : new Map(), sender: test.sender, message: test.message })); + }); + } + + for (const test of tests.ok) { + it(`message '${test.message}' should not timeout`, async () => { + assert(await moderation.caps({ emotesOffsets: test.emotesOffsets ? test.emotesOffsets : new Map(), sender: test.sender, message: test.message })); + }); + } + }); + describe('moderationCaps=true', async () => { + before(async () => { + await message.prepare(); + moderation.cCapsEnabled = true; + }); + + for (const test of tests.timeout) { + it(`message '${test.message}' should timeout`, async () => { + assert(!(await moderation.caps({ emotesOffsets: test.emotesOffsets ? test.emotesOffsets : new Map(), sender: test.sender, message: test.message }))); + }); + } + + for (const test of tests.ok) { + it(`message '${test.message}' should not timeout`, async () => { + assert(await moderation.caps({ emotesOffsets: test.emotesOffsets ? test.emotesOffsets : new Map(), sender: test.sender, message: test.message })); + }); + } + }); + describe('immune user', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cCapsEnabled = true; + }); + + it(`'AAAAAAAAAAAAAAAAAAAAAA' should timeout`, async () => { + assert(!(await moderation.caps({ emotesOffsets: new Map(), sender: user.viewer, message: 'AAAAAAAAAAAAAAAAAAAAAA' }))); + }); + + it(`add user immunity`, async () => { + const r = await moderation.immune({ parameters: `${user.viewer.userName} caps 5s` }); + assert(r[0].response === '$sender, user @__viewer__ have caps immunity for 5 seconds'); + }); + + it(`'AAAAAAAAAAAAAAAAAAAAAA' should not timeout`, async () => { + assert((await moderation.caps({ emotesOffsets: new Map(), sender: user.viewer, message: 'AAAAAAAAAAAAAAAAAAAAAA' }))); + }); + + it(`wait 10 seconds`, async () => { + await time.waitMs(10000); + }); + + it(`'AAAAAAAAAAAAAAAAAAAAAA' should timeout`, async () => { + assert(!(await moderation.caps({ emotesOffsets: new Map(), sender: user.viewer, message: 'AAAAAAAAAAAAAAAAAAAAAA' }))); + }); + }); +}); diff --git a/backend/test/tests/moderation/containsLink.js b/backend/test/tests/moderation/containsLink.js new file mode 100644 index 000000000..a6554150a --- /dev/null +++ b/backend/test/tests/moderation/containsLink.js @@ -0,0 +1,227 @@ +/* global describe it before */ + +import assert from 'assert'; + +import('../../general.js'); +import moderation from '../../../dest/systems/moderation.js'; +import { db } from '../../general.js'; +import { variable } from '../../general.js'; +import { message } from '../../general.js'; +import { user } from '../../general.js'; +import { time } from '../../general.js'; + +const tests = { + 'clips': [ + 'clips.twitch.tv/TolerantExquisiteDuckOneHand', + 'www.twitch.tv/7ssk7/clip/StormyBraveAniseM4xHeh?filter=clips&range=30d&sort=time', // https://discordapp.com/channels/317348946144002050/619437014001123338/713323104574898186 + ], + 'links': [ // tests will test links, http://links, https://links + 'foobarpage.me', + 'foobarpage.shop', + 'foobarpage.com', + 'foobarpage.COM', + 'FOOBARPAGE.com', + 'FOOBARPAGE.COM', + 'foobarpage .com', + 'foobarpage .COM', + 'FOOBARPAGE .com', + 'FOOBARPAGE .COM', + 'foobarpage . com', + 'foobarpage . COM', + 'FOOBARPAGE . com', + 'FOOBARPAGE . COM', + 'foobarpage . com', + 'www.foobarpage.com', + 'www.foobarpage.COM', + 'www.FOOBARPAGE.com', + 'www.FOOBARPAGE.COM', + 'WWW.FOOBARPAGE.COM', + 'www.foobarpage .com', + 'www.foobarpage .COM', + 'www.FOOBARPAGE .com', + 'www.FOOBARPAGE .COM', + 'WWW.FOOBARPAGE .COM', + 'www.foobarpage . com', + 'www.foobarpage . COM', + 'www.FOOBARPAGE . com', + 'www.FOOBARPAGE . COM', + 'WWW.FOOBARPAGE . COM', + 'www. foobarpage.com', + 'www. foobarpage.COM', + 'www. FOOBARPAGE.com', + 'www. FOOBARPAGE.COM', + 'WWW. FOOBARPAGE.COM', + 'youtu.be/123jAJD123', + ], + 'texts': [ + '#42 - proc hrajes tohle auto je dost na nic ....', + '#44 - 1.2.3.4', + '#47 - vypadá že máš problémy nad touto počítačovou hrou....doporučuji tvrdý alkohol', + 'community/t/links-detection/183 - die Zellen sind nur dafür da um deine Maschinen zu überlasten bzw. stärker und schneller zu machen', + ], +}; + +describe('systems/moderation - containsLink() - @func3', () => { + describe('moderationLinksClips=true & moderationLinksWithSpaces=true', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + moderation.cLinksIncludeSpaces = true; + moderation.cLinksIncludeClips = true; + }); + + for (const [type, listOfTests] of Object.entries(tests)) { + if (type === 'links') { + const protocols = ['', 'http://', 'https://']; + for (const protocol of protocols) { + for (const test of listOfTests) { + it(`link '${protocol}${test}' should timeout`, async () => { + assert(!(await moderation.containsLink({ sender: user.viewer, message: protocol + test }))); + }); + } + } + } + + if (type === 'clips') { + const protocols = ['', 'http://', 'https://']; + for (const protocol of protocols) { + for (const test of listOfTests) { + it(`clip '${protocol}${test}' should timeout`, async () => { + assert(!(await moderation.containsLink({ sender: user.viewer, message: protocol + test }))); + }); + } + } + } + + if (type === 'texts') { + for (const test of listOfTests) { + it(`text '${test}' should not timeout`, async () => { + assert(await moderation.containsLink({ sender: user.viewer, message: test })); + }); + } + } + } + }); + describe('moderationLinksClips=false & moderationLinksWithSpaces=true', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + moderation.cLinksIncludeSpaces = true; + moderation.cLinksIncludeClips = false; + }); + + for (const [type, listOfTests] of Object.entries(tests)) { + if (type === 'links') { + const protocols = ['', 'http://', 'https://']; + for (const protocol of protocols) { + for (const test of listOfTests) { + it(`link '${protocol}${test}' should timeout`, async () => { + assert(!(await moderation.containsLink({ sender: user.viewer, message: protocol + test }))); + }); + } + } + } + + if (type === 'clips') { + const protocols = ['', 'http://', 'https://']; + for (const protocol of protocols) { + for (const test of listOfTests) { + it(`clip '${protocol}${test}' should not timeout`, async () => { + assert(await moderation.containsLink({ sender: user.viewer, message: protocol + test })); + }); + } + } + } + + if (type === 'texts') { + for (const test of listOfTests) { + it(`text '${test}' should not timeout`, async () => { + assert(await moderation.containsLink({ sender: user.viewer, message: test })); + }); + } + } + } + }); + describe('moderationLinksClips=true & moderationLinksWithSpaces=false', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + moderation.cLinksIncludeSpaces = false; + moderation.cLinksIncludeClips = true; + }); + + for (const [type, listOfTests] of Object.entries(tests)) { + if (type === 'links') { + const protocols = ['', 'http://', 'https://']; + for (const protocol of protocols) { + for (const test of listOfTests) { + if (test.indexOf(' ') > -1 && test.toLowerCase().indexOf('www. ') === -1) { // even if moderationLinksWithSpaces is false - www. FOOBARPAGE.com should be timeouted + it(`link '${protocol}${test}' should not timeout`, async () => { + assert(await moderation.containsLink({ sender: user.viewer, message: protocol + test })); + }); + } else { + it(`link '${protocol}${test}' should timeout`, async () => { + assert(!(await moderation.containsLink({ sender: user.viewer, message: protocol + test }))); + }); + } + } + } + } + + if (type === 'clips') { + const protocols = ['', 'http://', 'https://']; + for (const protocol of protocols) { + for (const test of listOfTests) { + it(`clip '${protocol}${test}' should timeout`, async () => { + assert(!(await moderation.containsLink({ sender: user.viewer, message: test }))); + }); + } + } + } + + if (type === 'texts') { + for (const test of listOfTests) { + it(`text '${test}' should not timeout`, async () => { + assert(await moderation.containsLink({ sender: user.viewer, message: test })); + }); + } + } + } + }); + + describe('immune user', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cLinksEnabled = true; + }); + + it(`'www.google.com' should timeout`, async () => { + assert(!(await moderation.containsLink({ sender: user.viewer, message: 'www.google.com' }))); + }); + + it(`add user immunity`, async () => { + const r = await moderation.immune({ parameters: `${user.viewer.userName} links 5s` }); + assert(r[0].response === '$sender, user @__viewer__ have links immunity for 5 seconds'); + }); + + it(`'www.google.com' should not timeout`, async () => { + assert((await moderation.containsLink({ sender: user.viewer, message: 'www.google.com' }))); + }); + + it(`wait 10 seconds`, async () => { + await time.waitMs(10000); + }); + + it(`'www.google.com' should timeout`, async () => { + assert(!(await moderation.containsLink({ sender: user.viewer, message: 'www.google.com' }))); + }); + }); +}); diff --git a/backend/test/tests/moderation/discord#868236481406324747_manually_included_users_with_link_disabled.js b/backend/test/tests/moderation/discord#868236481406324747_manually_included_users_with_link_disabled.js new file mode 100644 index 000000000..c3cb6185b --- /dev/null +++ b/backend/test/tests/moderation/discord#868236481406324747_manually_included_users_with_link_disabled.js @@ -0,0 +1,182 @@ +/* global */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +import { Permissions } from '../../../dest/database/entity/permissions.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js';import moderation from '../../../dest/systems/moderation.js'; +import { db } from '../../general.js'; +import { variable } from '../../general.js'; +import { message } from '../../general.js'; +import { user } from '../../general.js'; + +describe('discord#868236481406324747 - Manually included users with link disabled should not be purged - @func1', () => { + after(async () => { + await AppDataSource + .createQueryBuilder() + .delete() + .from(Permissions) + .where('1 = 1') + .execute(); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.CASTERS, + name: 'Casters', + automation: 'casters', + isCorePermission: true, + isWaterfallAllowed: true, + order: 0, + userIds: [], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.MODERATORS, + name: 'Moderators', + automation: 'moderators', + isCorePermission: true, + isWaterfallAllowed: true, + order: 1, + userIds: [], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.SUBSCRIBERS, + name: 'Subscribers', + automation: 'subscribers', + isCorePermission: true, + isWaterfallAllowed: true, + order: 2, + userIds: [], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.VIP, + name: 'VIP', + automation: 'vip', + isCorePermission: true, + isWaterfallAllowed: true, + order: 3, + userIds: [], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.VIEWERS, + name: 'Viewers', + automation: 'viewers', + isCorePermission: true, + isWaterfallAllowed: true, + order: 4, + userIds: [], + excludeUserIds: [], + filters: [], + }); + }); + + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + await AppDataSource + .createQueryBuilder() + .delete() + .from(Permissions) + .where('1 = 1') + .execute(); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.CASTERS, + name: 'Casters', + automation: 'casters', + isCorePermission: true, + isWaterfallAllowed: true, + order: 0, + userIds: [], + excludeUserIds: [], + filters: [], + }); + moderation.__permission_based__cLinksEnabled[defaultPermissions.CASTERS] = true; + + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.MODERATORS, + name: 'Moderators', + automation: 'moderators', + isCorePermission: true, + isWaterfallAllowed: true, + order: 1, + userIds: [], + excludeUserIds: [], + filters: [], + }); + moderation.__permission_based__cLinksEnabled[defaultPermissions.MODERATORS] = true; + await AppDataSource.getRepository(Permissions).insert({ + id: '162e0172-bf00-41d7-b363-346bea52838b', + name: 'Test', + automation: 'moderators', + isCorePermission: false, + isWaterfallAllowed: true, + order: 2, + userIds: [user.viewer.userId], + excludeUserIds: [], + filters: [], + }); + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.SUBSCRIBERS, + name: 'Subscribers', + automation: 'subscribers', + isCorePermission: true, + isWaterfallAllowed: true, + order: 3, + userIds: [], + excludeUserIds: [], + filters: [], + }); + moderation.__permission_based__cLinksEnabled[defaultPermissions.SUBSCRIBERS] = true; + + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.VIP, + name: 'VIP', + automation: 'vip', + isCorePermission: true, + isWaterfallAllowed: true, + order: 4, + userIds: [], + excludeUserIds: [], + filters: [], + }); + moderation.__permission_based__cLinksEnabled[defaultPermissions.VIP] = true; + + await AppDataSource.getRepository(Permissions).insert({ + id: defaultPermissions.VIEWERS, + name: 'Viewers', + automation: 'viewers', + isCorePermission: true, + isWaterfallAllowed: true, + order: 5, + userIds: [], + excludeUserIds: [], + filters: [], + }); + moderation.__permission_based__cLinksEnabled[defaultPermissions.VIEWERS] = true; + }); + + it (`Enable link moderation for Test group`, () => { + moderation.__permission_based__cLinksEnabled['162e0172-bf00-41d7-b363-346bea52838b'] = true; + }); + + it(`Link 'http://www.foobarpage.com' should timeout`, async () => { + assert(!(await moderation.containsLink({ sender: user.viewer, message: 'http://www.foobarpage.com' }))); + }); + + it (`Disable link moderation for Test group`, () => { + moderation.__permission_based__cLinksEnabled['162e0172-bf00-41d7-b363-346bea52838b'] = false; + }); + + it(`Link 'http://www.foobarpage.com' should not timeout`, async () => { + assert((await moderation.containsLink({ sender: user.viewer, message: 'http://www.foobarpage.com' }))); + }); +}); diff --git a/backend/test/tests/moderation/emotes.js b/backend/test/tests/moderation/emotes.js new file mode 100644 index 000000000..e4df2bdfc --- /dev/null +++ b/backend/test/tests/moderation/emotes.js @@ -0,0 +1,72 @@ +/* global describe it before */ + +import assert from 'assert'; + +import('../../general.js'); +import moderation from '../../../dest/systems/moderation.js'; +import { db } from '../../general.js'; +import { variable } from '../../general.js'; +import { message } from '../../general.js'; +import { user } from '../../general.js'; +import { time } from '../../general.js'; + +describe('systems/moderation - Emotes() - @func2', () => { + const cEmotesEmojisAreEmotes = { message: '😀 😁 😂 🤣 😃 😄 😅 😆 😉 😊 😋 😎 😍 😘 😗 😙 😚 🙂 🤗 🤩 🤔 🤨 😐 😑 😶 🙄 😏 😣 😥 😮 🤐 😯 😪 😫 😴 😌 😛 😜 😝 🤤 😒 😓 😔 😕 🙃 🤑 😲 ☹️ 🙁 😖 😞 😟 😤 😢 😭 😦 😧 😨 😩 🤯 😬 😰 😱', sender: user.viewer }; + + describe('cEmotesEmojisAreEmotes=false', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cEmotesEmojisAreEmotes = false; + }); + + it(`message '${cEmotesEmojisAreEmotes.message}' should not timeout`, async () => { + assert(await moderation.emotes({ emotesOffsets: new Map(), sender: cEmotesEmojisAreEmotes.sender, message: cEmotesEmojisAreEmotes.message })); + }); + }); + + describe('cEmotesEmojisAreEmotes=true', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cEmotesEmojisAreEmotes = true; + }); + + it(`message '${cEmotesEmojisAreEmotes.message}' should timeout`, async () => { + assert(!(await moderation.emotes({ emotesOffsets: new Map(), sender: cEmotesEmojisAreEmotes.sender, message: cEmotesEmojisAreEmotes.message }))); + }); + }); + + describe('immune user', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cEmotesEmojisAreEmotes = true; + moderation.cEmotesEnabled = true; + }); + + it(`'${cEmotesEmojisAreEmotes.message}' should timeout`, async () => { + assert(!(await moderation.emotes({ emotesOffsets: new Map(), sender: user.viewer, message: cEmotesEmojisAreEmotes.message }))); + }); + + it(`add user immunity`, async () => { + const r = await moderation.immune({ parameters: `${user.viewer.userName} emotes 5s` }); + assert(r[0].response === '$sender, user @__viewer__ have emotes immunity for 5 seconds'); + }); + + it(`'${cEmotesEmojisAreEmotes.message}' should not timeout`, async () => { + assert((await moderation.emotes({ emotesOffsets: new Map(), sender: user.viewer, message: cEmotesEmojisAreEmotes.message }))); + }); + + it(`wait 10 seconds`, async () => { + await time.waitMs(10000); + }); + + it(`'${cEmotesEmojisAreEmotes.message}' should timeout`, async () => { + assert(!(await moderation.emotes({ emotesOffsets: new Map(), sender: user.viewer, message: cEmotesEmojisAreEmotes.message }))); + }); + }); +}); diff --git a/backend/test/tests/moderation/longMessage.js b/backend/test/tests/moderation/longMessage.js new file mode 100644 index 000000000..9d1c2e6c3 --- /dev/null +++ b/backend/test/tests/moderation/longMessage.js @@ -0,0 +1,93 @@ +/* global describe it before */ + +import assert from 'assert'; + +import('../../general.js'); +import moderation from '../../../dest/systems/moderation.js'; +import { db } from '../../general.js'; +import { variable } from '../../general.js'; +import { message } from '../../general.js'; +import { user } from '../../general.js'; +import { time } from '../../general.js'; + +const tests = { + 'timeout': [ + 'asdfstVTzgo3KrfNekGTjomK7nBjEX9B3Vw4qctminLjzfqbT8q6Cd23pVSuw0wuWPAJE9vaBDC4PIYkKCleX8yBXBiQMKwJWb8uonmbOzNgpuMpcF6vpF3mRc8bbonrfVHqbT00QpjPJHXOF88XrjgR8v0BQVlsX61lpT8vbqjZRlizoMa2bruKU3GtONgZhtJJQyRJEVo3OTiAgha2kC0PHUa8ZSRNCoTsDWc76BTfa2JntlTgIXmX2aXTDQEyBomkSQAof4APE0sfX9HvEROQqP9SSf09VK1weXNcsmMs', + ], + 'ok': [ + 'asdfstVTzgo3KrfNekGTjomK7nBjEX9B3Vw4qctminLjzfqbT8q6Cd23pVSuw0wuWPAJE9vaBDC4PIYkKCleX8yBXBiQMKwJWb8uonmbOzNgpuMpcF6vpF3mRc8bbonrfVHqbT00QpjPJHXOF88XrjgR8v0', + ], +}; + +describe('systems/moderation - longMessage() - @func3', () => { + describe('moderationLongMessage=false', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cLongMessageEnabled = false; + }); + + for (const test of tests.timeout) { + it(`message '${test}' should not timeout`, async () => { + assert(await moderation.longMessage({ sender: user.viewer, message: test })); + }); + } + + for (const test of tests.ok) { + it(`message '${test}' should not timeout`, async () => { + assert(await moderation.longMessage({ sender: user.viewer, message: test })); + }); + } + }); + describe('moderationLongMessage=true', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cLongMessageEnabled = true; + }); + + for (const test of tests.timeout) { + it(`message '${test}' should timeout`, async () => { + assert(!(await moderation.longMessage({ sender: user.viewer, message: test }))); + }); + } + + for (const test of tests.ok) { + it(`message '${test}' should not timeout`, async () => { + assert(await moderation.longMessage({ sender: user.viewer, message: test })); + }); + } + }); + + describe('immune user', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cLongMessageEnabled = true; + }); + + it(`'asdfstVTzgo3KrfNekGTjomK7nBjEX9B3Vw4qctminLjzfqbT8q6Cd23pVSuw0wuWPAJE9vaBDC4PIYkKCleX8yBXBiQMKwJWb8uonmbOzNgpuMpcF6vpF3mRc8bbonrfVHqbT00QpjPJHXOF88XrjgR8v0BQVlsX61lpT8vbqjZRlizoMa2bruKU3GtONgZhtJJQyRJEVo3OTiAgha2kC0PHUa8ZSRNCoTsDWc76BTfa2JntlTgIXmX2aXTDQEyBomkSQAof4APE0sfX9HvEROQqP9SSf09VK1weXNcsmMs' should timeout`, async () => { + assert(!(await moderation.longMessage({ sender: user.viewer, message: 'asdfstVTzgo3KrfNekGTjomK7nBjEX9B3Vw4qctminLjzfqbT8q6Cd23pVSuw0wuWPAJE9vaBDC4PIYkKCleX8yBXBiQMKwJWb8uonmbOzNgpuMpcF6vpF3mRc8bbonrfVHqbT00QpjPJHXOF88XrjgR8v0BQVlsX61lpT8vbqjZRlizoMa2bruKU3GtONgZhtJJQyRJEVo3OTiAgha2kC0PHUa8ZSRNCoTsDWc76BTfa2JntlTgIXmX2aXTDQEyBomkSQAof4APE0sfX9HvEROQqP9SSf09VK1weXNcsmMs' }))); + }); + + it(`add user immunity`, async () => { + const r = await moderation.immune({ parameters: `${user.viewer.userName} longmessage 5s` }); + assert(r[0].response === '$sender, user @__viewer__ have longmessage immunity for 5 seconds'); + }); + + it(`'asdfstVTzgo3KrfNekGTjomK7nBjEX9B3Vw4qctminLjzfqbT8q6Cd23pVSuw0wuWPAJE9vaBDC4PIYkKCleX8yBXBiQMKwJWb8uonmbOzNgpuMpcF6vpF3mRc8bbonrfVHqbT00QpjPJHXOF88XrjgR8v0BQVlsX61lpT8vbqjZRlizoMa2bruKU3GtONgZhtJJQyRJEVo3OTiAgha2kC0PHUa8ZSRNCoTsDWc76BTfa2JntlTgIXmX2aXTDQEyBomkSQAof4APE0sfX9HvEROQqP9SSf09VK1weXNcsmMs' should not timeout`, async () => { + assert((await moderation.longMessage({ sender: user.viewer, message: 'asdfstVTzgo3KrfNekGTjomK7nBjEX9B3Vw4qctminLjzfqbT8q6Cd23pVSuw0wuWPAJE9vaBDC4PIYkKCleX8yBXBiQMKwJWb8uonmbOzNgpuMpcF6vpF3mRc8bbonrfVHqbT00QpjPJHXOF88XrjgR8v0BQVlsX61lpT8vbqjZRlizoMa2bruKU3GtONgZhtJJQyRJEVo3OTiAgha2kC0PHUa8ZSRNCoTsDWc76BTfa2JntlTgIXmX2aXTDQEyBomkSQAof4APE0sfX9HvEROQqP9SSf09VK1weXNcsmMs' }))); + }); + + it(`wait 10 seconds`, async () => { + await time.waitMs(10000); + }); + + it(`'asdfstVTzgo3KrfNekGTjomK7nBjEX9B3Vw4qctminLjzfqbT8q6Cd23pVSuw0wuWPAJE9vaBDC4PIYkKCleX8yBXBiQMKwJWb8uonmbOzNgpuMpcF6vpF3mRc8bbonrfVHqbT00QpjPJHXOF88XrjgR8v0BQVlsX61lpT8vbqjZRlizoMa2bruKU3GtONgZhtJJQyRJEVo3OTiAgha2kC0PHUa8ZSRNCoTsDWc76BTfa2JntlTgIXmX2aXTDQEyBomkSQAof4APE0sfX9HvEROQqP9SSf09VK1weXNcsmMs' should timeout`, async () => { + assert(!(await moderation.longMessage({ sender: user.viewer, message: 'asdfstVTzgo3KrfNekGTjomK7nBjEX9B3Vw4qctminLjzfqbT8q6Cd23pVSuw0wuWPAJE9vaBDC4PIYkKCleX8yBXBiQMKwJWb8uonmbOzNgpuMpcF6vpF3mRc8bbonrfVHqbT00QpjPJHXOF88XrjgR8v0BQVlsX61lpT8vbqjZRlizoMa2bruKU3GtONgZhtJJQyRJEVo3OTiAgha2kC0PHUa8ZSRNCoTsDWc76BTfa2JntlTgIXmX2aXTDQEyBomkSQAof4APE0sfX9HvEROQqP9SSf09VK1weXNcsmMs' }))); + }); + }); +}); diff --git a/backend/test/tests/moderation/permitLink.js b/backend/test/tests/moderation/permitLink.js new file mode 100644 index 000000000..38faa7985 --- /dev/null +++ b/backend/test/tests/moderation/permitLink.js @@ -0,0 +1,64 @@ +/* global describe it before */ +import * as commons from '../../../dest/commons.js' +import moderation from '../../../dest/systems/moderation.js'; + +import('../../general.js'); + +import { db, message, user } from '../../general.js'; +import assert from 'assert'; + +const owner = Object.freeze({ userName: '__broadcaster__', badges: {}, userId: 12345 }); + +describe('systems/moderation - permitLink() - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + describe('!permit', function () { + describe('parsing \'!permit\'', function () { + it('should send parse error', async function () { + const r = await moderation.permitLink({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this command is not correct, use !permit [username]'); + }); + }); + describe('parsing \'!permit [username] 100\'', function () { + it('should send success message', async function () { + const r = await moderation.permitLink({ sender: owner, parameters: '__viewer__ 100' }); + assert.strictEqual(r[0].response, 'User @__viewer__ can post a 100 links to chat'); + }); + it('should not timeout user 100 messages', async () => { + for (let i = 0; i < 100; i++) { + assert(await moderation.containsLink({ sender: user.viewer, message: 'http://www.google.com' })); + } + }); + it('should timeout user on 1001 message', async function () { + assert(!(await moderation.containsLink({ sender: user.viewer, message: 'http://www.google.com' }))); + }); + }); + describe('parsing \'!permit [username]\'', function () { + it('should send success message', async function () { + const r = await moderation.permitLink({ sender: owner, parameters: '__viewer__' }); + assert.strictEqual(r[0].response, 'User @__viewer__ can post a 1 link to chat'); + }); + it('should not timeout user on first link message', async () => { + assert(await moderation.containsLink({ sender: user.viewer, message: 'http://www.google.com' })); + }); + it('should timeout user on second link message', async function () { + assert(!(await moderation.containsLink({ sender: user.viewer, message: 'http://www.google.com' }))); + }); + }); + describe('parsing \'!permit [username]\' - case sensitive test', function () { + it('should send success message', async function () { + const r = await moderation.permitLink({ sender: owner, parameters: '__VIEWER__' }); + assert.strictEqual(r[0].response, 'User @__viewer__ can post a 1 link to chat'); + }); + it('should not timeout user on first link message', async () => { + assert(await moderation.containsLink({ sender: user.viewer, message: 'http://www.google.com' })); + }); + it('should timeout user on second link message', async function () { + assert(!(await moderation.containsLink({ sender: user.viewer, message: 'http://www.google.com' }))); + }); + }); + }); +}); diff --git a/backend/test/tests/moderation/spam.js b/backend/test/tests/moderation/spam.js new file mode 100644 index 000000000..05d44ae85 --- /dev/null +++ b/backend/test/tests/moderation/spam.js @@ -0,0 +1,95 @@ +/* global describe it before */ + +import assert from 'assert'; + +import('../../general.js'); +import moderation from '../../../dest/systems/moderation.js'; +import { db } from '../../general.js'; +import { variable } from '../../general.js'; +import { message } from '../../general.js'; +import { time } from '../../general.js'; +import { user } from '../../general.js'; + +const tests = { + 'timeout': [ + 'Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum', + 'Lorem Ipsum Lorem Ipsum test 1 2 3 4 Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum test 1 2 3 4 Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum test 1 2 3 4 Lorem Ipsum Lorem Ipsum', + ], + 'ok': [ + 'Lorem Ipsum Lorem Ipsum test 1 2 3 4 Lorem Ipsum Lorem Ipsum', + 'Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum', + ], +}; + +describe('systems/moderation - Spam() - @func2', () => { + describe('moderationSpam=false', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cSpamEnabled = false; + }); + + for (const test of tests.timeout) { + it(`message '${test}' should not timeout`, async () => { + assert(await moderation.spam({ sender: user.viewer, message: test })); + }); + } + + for (const test of tests.ok) { + it(`message '${test}' should not timeout`, async () => { + assert(await moderation.spam({ sender: user.viewer, message: test })); + }); + } + }); + describe('moderationSpam=true', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cSpamEnabled = true; + }); + + for (const test of tests.timeout) { + it(`message '${test}' should timeout`, async () => { + assert(!(await moderation.spam({ sender: user.viewer, message: test }))); + }); + } + + for (const test of tests.ok) { + it(`message '${test}' should not timeout`, async () => { + assert(await moderation.spam({ sender: user.viewer, message: test })); + }); + } + }); + + describe('immune user', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cSpamEnabled = true; + }); + + it(`'Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum' should timeout`, async () => { + assert(!(await moderation.spam({ sender: user.viewer, message: 'Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum' }))); + }); + + it(`add user immunity`, async () => { + const r = await moderation.immune({ parameters: `${user.viewer.userName} spam 5s` }); + assert(r[0].response === '$sender, user @__viewer__ have spam immunity for 5 seconds'); + }); + + it(`'Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum' should not timeout`, async () => { + assert((await moderation.spam({ sender: user.viewer, message: 'Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum' }))); + }); + + it(`wait 10 seconds`, async () => { + await time.waitMs(10000); + }); + + it(`'Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum' should timeout`, async () => { + assert(!(await moderation.spam({ sender: user.viewer, message: 'Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum Lorem Ipsum' }))); + }); + }); +}); diff --git a/backend/test/tests/moderation/symbols.js b/backend/test/tests/moderation/symbols.js new file mode 100644 index 000000000..2fe43b217 --- /dev/null +++ b/backend/test/tests/moderation/symbols.js @@ -0,0 +1,95 @@ +/* global describe it before */ + +import assert from 'assert'; + +import('../../general.js'); +import moderation from '../../../dest/systems/moderation.js'; +import { db } from '../../general.js'; +import { variable } from '../../general.js'; +import { message } from '../../general.js'; +import { user } from '../../general.js'; +import { time } from '../../general.js'; + +const tests = { + 'timeout': [ + '!@#$%^&*()(*&^%$#@#$%^&*)', + '!@#$%^&*( one two (*&^%$#@#', + ], + 'ok': [ + '!@#$%^&*( one two three four (*&^%$#@ one two three four #$%^&*)', + '!@#$%^&*()(*&^', + ], +}; + +describe('systems/moderation - symbols() - @func3', () => { + describe('moderationSymbols=false', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cSymbolsEnabled = false; + }); + + for (const test of tests.timeout) { + it(`symbols '${test}' should not timeout`, async () => { + assert(await moderation.symbols({ sender: user.viewer, message: test })); + }); + } + + for (const test of tests.ok) { + it(`symbols '${test}' should not timeout`, async () => { + assert(await moderation.symbols({ sender: user.viewer, message: test })); + }); + } + }); + describe('moderationSymbols=true', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cSymbolsEnabled = true; + }); + + for (const test of tests.timeout) { + it(`symbols '${test}' should timeout`, async () => { + assert(!(await moderation.symbols({ sender: user.viewer, message: test }))); + }); + } + + for (const test of tests.ok) { + it(`symbols '${test}' should not timeout`, async () => { + assert(await moderation.symbols({ sender: user.viewer, message: test })); + }); + } + }); + + describe('immune user', async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + moderation.cSymbolsEnabled = true; + }); + + it(`'!@#$%^&*()(*&^%$#@#$%^&*)' should timeout`, async () => { + assert(!(await moderation.symbols({ sender: user.viewer, message: '!@#$%^&*()(*&^%$#@#$%^&*)' }))); + }); + + it(`add user immunity`, async () => { + const r = await moderation.immune({ parameters: `${user.viewer.userName} symbols 5s` }); + assert(r[0].response === '$sender, user @__viewer__ have symbols immunity for 5 seconds'); + }); + + it(`'!@#$%^&*()(*&^%$#@#$%^&*)' should not timeout`, async () => { + assert((await moderation.symbols({ sender: user.viewer, message: '!@#$%^&*()(*&^%$#@#$%^&*)' }))); + }); + + it(`wait 10 seconds`, async () => { + await time.waitMs(10000); + }); + + it(`'!@#$%^&*()(*&^%$#@#$%^&*)' should timeout`, async () => { + assert(!(await moderation.symbols({ sender: user.viewer, message: '!@#$%^&*()(*&^%$#@#$%^&*)' }))); + }); + }); +}); diff --git a/backend/test/tests/moderation/whitelist.js b/backend/test/tests/moderation/whitelist.js new file mode 100644 index 000000000..cc213c8ff --- /dev/null +++ b/backend/test/tests/moderation/whitelist.js @@ -0,0 +1,214 @@ +/* global describe it before */ +import assert from 'assert'; + +import _ from 'lodash-es'; +import('../../general.js'); + +import alias from '../../../dest/systems/alias.js';import moderation from '../../../dest/systems/moderation.js'; +import songs from '../../../dest/systems/songs.js'; +import { message } from '../../general.js'; +import { db } from '../../general.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +const tests = { + 'domain:prtzl.io': { + 'should.return.changed': [ + 'Now Playing: Eden (Waveshaper Remix) - Instrumental by Scandroid -> https://prtzl.io/QbHKjGenxvZg49uG', + ], + 'should.return.same': [ + ], + }, + 'osu.ppy.sh': { + 'should.return.changed': [ + 'Lorem Ipsum osu.ppy.sh dolor sit amet', + ], + 'should.return.same': [ + 'Lorem Ipsum http://osu.ppy.sh dolor sit amet', + 'Lorem Ipsum https://osu.ppy.sh dolor sit amet', + ], + }, + '(https?:\\/\\/)?osu.ppy.sh(*)?': { + 'should.return.changed': [ + 'Lorem Ipsum osu.ppy.sh dolor sit amet', + 'Lorem Ipsum http://osu.ppy.sh dolor sit amet', + 'Lorem Ipsum https://osu.ppy.sh dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum http://osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum https://osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/p/2131231231 dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/beatmapsets/59670/#osu/1263997 dolor sit amet', + 'https://osu.ppy.sh/beatmapsets/554084#osu/1173113', + ], + 'should.return.same': [ + ], + }, + '(https?:\\/\\/)?(www\\.)?osu.ppy.sh(*)?': { + 'should.return.changed': [ + 'Lorem Ipsum osu.ppy.sh dolor sit amet', + 'Lorem Ipsum http://osu.ppy.sh dolor sit amet', + 'Lorem Ipsum https://osu.ppy.sh dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum http://osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum https://osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/p/2131231231 dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/beatmapsets/59670/#osu/1263997 dolor sit amet', + 'https://osu.ppy.sh/beatmapsets/554084#osu/1173113', + ], + 'should.return.same': [ + ], + }, + 'domain:osu.ppy.sh': { + 'should.return.changed': [ + 'Lorem Ipsum osu.ppy.sh dolor sit amet', + 'Lorem Ipsum http://osu.ppy.sh dolor sit amet', + 'Lorem Ipsum https://osu.ppy.sh dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum http://osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum https://osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/p/2131231231 dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/beatmapsets/59670/#osu/1263997 dolor sit amet', + 'https://osu.ppy.sh/beatmapsets/554084#osu/1173113', + ], + 'should.return.same': [ + ], + }, + 'domain:ppy.sh': { + 'should.return.changed': [ + 'Lorem Ipsum osu.ppy.sh dolor sit amet', + 'Lorem Ipsum http://osu.ppy.sh dolor sit amet', + 'Lorem Ipsum https://osu.ppy.sh dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum http://osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum https://osu.ppy.sh/asd dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/p/2131231231 dolor sit amet', + 'Lorem Ipsum osu.ppy.sh/beatmapsets/59670/#osu/1263997 dolor sit amet', + 'https://osu.ppy.sh/beatmapsets/554084#osu/1173113', + ], + 'should.return.same': [ + ], + }, + 'testForSongRequest': { + 'should.return.changed': [ + '!songrequest https://youtu.be/HmZYgqBp1gI', + '!sr https://youtu.be/HmZYgqBp1gI', + ], + }, + 'test': { + 'should.return.changed': [ + 'test', 'a test', 'test a', 'a test a', '?test', 'test?', + ], + 'should.return.same': [ + 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'testa', 'testaa', 'atesta', 'aatestaa', 'test1', '1test1', 'test11', '11test11', 'русскийtestрусский', 'testрусский', '한국어test한국어', 'test한국어', + ], + }, + '*test': { + 'should.return.changed': [ + 'test', 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'a test', 'test a', + ], + 'should.return.same': [ + 'testa', 'testaa', 'atesta', 'aatestaa', 'test1', '1test1', 'test11', '11test11', 'русскийtestрусский', 'testрусский', '한국어test한국어', 'test한국어', + ], + }, + 'test*': { + 'should.return.changed': [ + 'test', 'a test', 'test a', 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', + ], + 'should.return.same': [ + 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', + ], + }, + '*test*': { + 'should.return.same': [ + 'abc', + ], + 'should.return.changed': [ + 'test', 'a test', 'test a', 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', + ], + }, + '+test': { + 'should.return.changed': [ + 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', + ], + 'should.return.same': [ + 'test', 'a test', 'test a', 'testa', 'testaa', 'atesta', 'aatestaa', 'test1', '1test1', 'test11', '11test11', 'русскийtestрусский', 'testрусский', '한국어test한국어', 'test한국어', + ], + }, + 'test+': { + 'should.return.changed': [ + 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', + ], + 'should.return.same': [ + 'test', 'a test', 'test a', 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', + ], + }, + '+test+': { + 'should.return.same': [ + 'test', 'abc', 'a test', 'test a', 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', + ], + 'should.return.changed': [ + 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', + ], + }, + '*test+': { + 'should.return.same': [ + 'test', 'abc', 'a test', 'test a', 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', + ], + 'should.return.changed': [ + 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', + ], + }, + '+test*': { + 'should.return.same': [ + 'test', 'abc', 'a test', 'test a', 'testa', 'testaa', 'test1', 'test11', 'testрусский', 'test한국어', + ], + 'should.return.changed': [ + 'atest', 'aatest', '1test', '11test', 'русскийtest', '한국어test', 'atesta', 'aatestaa', '1test1', '11test11', 'русскийtestрусский', '한국어test한국어', + ], + }, +}; + +describe('systems/moderation - whitelist() - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + const r = await alias.add({ sender: owner, parameters: '-a !sr -c !songrequest' }); + assert.strictEqual(r[0].response, '$sender, alias !sr for !songrequest was added'); + await songs.setCommand('!songrequest', '!songrequest'); + }); + + for (const [pattern, test] of Object.entries(tests)) { + for (const text of _.get(test, 'should.return.changed', [])) { + it(`pattern '${pattern}' should change '${text}'`, async () => { + moderation.cListsWhitelist = [pattern]; + const result = await moderation.whitelist(text, '0efd7b1c-e460-4167-8e06-8aaf2c170311'); + assert(text !== result); + }); + } + for (const text of _.get(test, 'should.return.same', [])) { + it(`pattern '${pattern}' should not change '${text}'`, async () => { + moderation.cListsWhitelist = [pattern]; + const result = await moderation.whitelist(text, '0efd7b1c-e460-4167-8e06-8aaf2c170311'); + assert(text === result); + }); + } + } + + describe(`#2392 - changed !songrequest => !zahrej should be whitelisted`, () => { + after(async () => { + await songs.setCommand('!songrequest', '!songrequest'); + }); + + it('change command from !songrequest => !zahrej', async () => { + await songs.setCommand('!songrequest', '!zahrej'); + }); + + it('!zahrej command should be whitelisted', async () => { + const text = '!zahrej https://youtu.be/HmZYgqBp1gI'; + const result = await moderation.whitelist(text, '0efd7b1c-e460-4167-8e06-8aaf2c170311'); + assert(text !== result); + }); + }); +}); diff --git a/backend/test/tests/parser/caseSensitiveCommands.js b/backend/test/tests/parser/caseSensitiveCommands.js new file mode 100644 index 000000000..7c41e9b8f --- /dev/null +++ b/backend/test/tests/parser/caseSensitiveCommands.js @@ -0,0 +1,49 @@ +/* global describe it before */ + + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { Parser } from '../../../dest/parser.js'; + +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; + +import { User } from '../../../dest/database/entity/user.js'; + +describe('Parser - case sensitive commands - @func2', async () => { + const tests = [ + { + test: '!me', + expected: '@__broadcaster__ | 0 hours | 0 points | 0 messages | 0.00€ | 0 bits', + }, + { + test: '!ME', + expected: '@__broadcaster__ | 0 hours | 0 points | 0 messages | 0.00€ | 0 bits', + }, + ]; + + for (const test of tests) { + describe(`'${test.test}' expect '${test.expected}'`, async () => { + let r; + before(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + }); + + it(`Run command '${test.test}'`, async () => { + const parse = new Parser({ sender: owner, message: test.test, skip: false, quiet: false }); + r = await parse.process(); + }); + + it(`Expect message '${test.expected}`, async () => { + assert(r[0].response, test.expected); + }); + }); + } +}); diff --git a/backend/test/tests/parser/time.js b/backend/test/tests/parser/time.js new file mode 100644 index 000000000..53a0930a6 --- /dev/null +++ b/backend/test/tests/parser/time.js @@ -0,0 +1,46 @@ +/* global describe it before */ + + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { Parser } from '../../../dest/parser.js'; + +const owner = { userName: '__broadcaster__', userId: String(Math.floor(Math.random() * 100000)) }; + +import { User } from '../../../dest/database/entity/user.js'; + +describe('Parser - parse time check - @func2', async () => { + const tests = [ + { + test: '!me', + expected: 400, + }, + ]; + + for (const test of tests) { + describe(`'${test.test}' expect ${test.expected}ms`, async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + }); + + let time; + it(`Run command '${test.test}'`, async () => { + time = Date.now(); + const parse = new Parser({ sender: owner, message: test.test, skip: false, quiet: false }); + await parse.process(); + }); + + it(`Should take to ${test.expected}ms to parse`, async () => { + assert(Date.now() - time < test.expected, `${Date.now() - time} > ${test.expected}`); + }); + }); + } +}); diff --git a/backend/test/tests/permissions/check.js b/backend/test/tests/permissions/check.js new file mode 100644 index 000000000..345446f89 --- /dev/null +++ b/backend/test/tests/permissions/check.js @@ -0,0 +1,556 @@ +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); +const currency = (await import('../../../dest/currency.js')).default; +import { Permissions, PermissionCommands } from '../../../dest/database/entity/permissions.js'; +import { User, UserBit, UserTip } from '../../../dest/database/entity/user.js'; +import rates from '../../../dest/helpers/currency/rates.js'; +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import { check } from '../../../dest/helpers/permissions/check.js'; +import { serialize } from '../../../dest/helpers/type.js'; +import { Parser } from '../../../dest/parser.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const users = [ + { + userName: '__owner__', userId: String(1), id: 1, + }, + { + userName: '__moderator__', userId: String(2), id: 2, isModerator: true, badges: { moderator: true }, + }, + { + userName: '__subscriber__', userId: String(3), id: 3, isSubscriber: true, + }, + { + userName: '__vip__', userId: String(4), id: 4, isVIP: true, badges: { vip: true }, + }, + { + userName: '__viewer__', userId: String(6), id: 6, + }, + { + userName: '__viewer_points__', userId: String(7), id: 7, points: 100, + }, + { + userName: '__viewer_watched__', userId: String(8), id: 8, watchedTime: 100 * (60 * 60 * 1000 /*hours*/), + }, + { + userName: '__viewer_tips__', userId: String(9), id: 9, tips: [{ + userId: String(9), exchangeRates: rates, currency: 'EUR', amount: 100, sortAmount: 100, timestamp: Math.random(), message: '', + }], + }, + { + userName: '__viewer_bits__', userId: String(10), id: 10, bits: [{ + amount: 100, timestamp: Math.random(), message: '', userId: String(10), + }], + }, + { + userName: '__viewer_messages__', userId: String(11), id: 11, messages: 100, + }, + { + userName: '__viewer_subtier__', userId: String(12), id: 12, subscribeTier: 2, + }, + { + userName: '__viewer_subcumulativemonths__', userId: String(13), id: 13, subscribeCumulativeMonths: 2, + }, + { + userName: '__viewer_substreakmonths__', userId: String(14), id: 14, subscribeStreak: 2, + }, + { + userName: '__viewer_customrank__', userId: String(15), id: 15, haveCustomRank: true, rank: 'Lorem Ipsum', + }, + { + userName: '__viewer_level5__', userId: String(16), id: 16, extra: { levels: { xp: serialize(BigInt(5000)) } }, + }, +]; + +describe('Permissions - check() - @func1', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + for (const u of users) { + if (typeof u.tips !== 'undefined') { + await AppDataSource.getRepository(UserTip).save(u.tips); + } + if (typeof u.bits !== 'undefined') { + await AppDataSource.getRepository(UserBit).save(u.bits); + } + await AppDataSource.getRepository(User).save(u); + } + }); + + for (let i = 0, keys = Object.keys(defaultPermissions); i < keys.length; i++) { + describe(`Permission ${keys[i]}`, () => { + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = defaultPermissions[keys[i]]; + if (i >= j || (keys[i] === 'VIEWERS' && user.userName.includes('viewer'))) { + // have access + it(`+++ ${users[j].userName} should have access to ${keys[i]}`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to ${keys[i]}`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + } + + describe(`Permission only for __viewer__ userId`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f8004b34dac3', + name: '__viewer__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'none', + userIds: [6], + excludeUserIds: [], + filters: [], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f8004b34dac3'; + if (user.userName === '__viewer__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + + describe(`Permission only for user with 100 points (__viewer_points__)`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f8004b34dac3', + name: '__viewer_points__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: [], + filters: [{ + comparator: '==', type: 'points', value: 100, + }], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f8004b34dac3'; + if (user.userName === '__viewer_points__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer_points__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer_points__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + + describe(`Permission only for user with 100h watched (__viewer_watched__)`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f8004b34dac3', + name: '__viewer_watched__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: [], + filters: [{ + comparator: '==', type: 'watched', value: 100, + }], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f8004b34dac3'; + if (user.userName === '__viewer_watched__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer_watched__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer_watched__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + + describe(`Permission only for user with 100 tips (__viewer_tips__)`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f8004b34dac3', + name: '__viewer_tips__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: [], + filters: [{ + comparator: '>=', type: 'tips', value: 100, + }], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f8004b34dac3'; + if (user.userName === '__viewer_tips__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer_tips__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer_tips__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + + describe(`Permission only for user with 100 bits (__viewer_bits__)`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f8004b34dac3', + name: '__viewer_bits__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: [], + filters: [{ + comparator: '>=', type: 'bits', value: 100, + }], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f8004b34dac3'; + if (user.userName === '__viewer_bits__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer_bits__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer_bits__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + + describe(`Permission only for user with 100 messages (__viewer_messages__)`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f8004b34dac3', + name: '__viewer_messages__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: [], + filters: [{ + comparator: '>=', type: 'messages', value: 100, + }], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f8004b34dac3'; + if (user.userName === '__viewer_messages__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer_messages__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer_messages__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + + describe(`Permission only for user with 2 subtier (__viewer_subtier__)`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f8004b34dac3', + name: '__viewer_subtier__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: [], + filters: [{ + comparator: '>=', type: 'subtier', value: 2, + }], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f8004b34dac3'; + if (user.userName === '__viewer_subtier__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer_subtier__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer_subtier__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + + describe(`Permission only for user with 2 subcumulativemonths (__viewer_subcumulativemonths__)`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f8004b34dac3', + name: '__viewer_subcumulativemonths__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: [], + filters: [{ + comparator: '>=', type: 'subcumulativemonths', value: 2, + }], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f8004b34dac3'; + if (user.userName === '__viewer_subcumulativemonths__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer_subcumulativemonths__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer_subcumulativemonths__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + + describe(`Permission only for user with 2 substreakmonths (__viewer_substreakmonths__)`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f8004b34dac3', + name: '__viewer_substreakmonths__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: [], + filters: [{ + comparator: '>=', type: 'substreakmonths', value: 2, + }], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f8004b34dac3'; + if (user.userName === '__viewer_substreakmonths__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer_substreakmonths__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer_substreakmonths__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + + describe(`Permission only for user with rank Lorem Ipsum (__viewer_customrank__)`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f9904b34dac3', + name: '__viewer_customrank__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: [], + filters: [{ + comparator: '==', type: 'ranks', value: 'Lorem Ipsum', + }], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f9904b34dac3'; + if (user.userName === '__viewer_customrank__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer_customrank__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer_customrank__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + + describe(`Permission only for user with level 5 (__viewer_level5__)`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac999-923f-4063-99e3-f9904b34dac3', + name: '__viewer_level5__only', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: [], + filters: [{ + comparator: '==', type: 'level', value: 5, + }], + }); + }); + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac999-923f-4063-99e3-f9904b34dac3'; + if (user.userName === '__viewer_level5__') { + // have access + it(`+++ ${users[j].userName} should have access to __viewer_level5__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __viewer_level5__only`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } + }); + describe(`Enabled !me command should work`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(PermissionCommands).clear(); + }); + for (let j = 0; j < users.length; j++) { + it (`--- ${users[j].userName} should trigger command !me`, async () => { + const parse = new Parser({ + sender: users[j], message: '!me', skip: false, quiet: false, + }); + const r = await parse.process(); + + let hours = '0'; + let level = 'Level 0'; + let points = '0'; + let messages = '0'; + let tips = '0.00'; + let bits = '0'; + let months = '0'; + let rank = ''; + if (users[j].userName === '__viewer_points__') { + points = '100'; + } + if (users[j].userName === '__viewer_watched__') { + hours = '100'; + } + if (users[j].userName === '__viewer_tips__') { + tips = '100.00'; + } + if (users[j].userName === '__viewer_bits__') { + bits = '100'; + } + if (users[j].userName === '__viewer_messages__') { + messages = '100'; + } + if (users[j].userName === '__viewer_customrank__') { + rank = 'Lorem Ipsum | '; + } + if (users[j].userName === '__viewer_level5__') { + level = 'Level 5'; + } + if (users[j].userName === '__viewer_subcumulativemonths__') { + months = '2'; + } + assert.strictEqual(r[0].response, `$sender | ${level} | ${rank}${hours} hours | ${points} points | ${messages} messages | €${tips} | ${bits} bits | ${months} months`); + }); + } + }); + + describe(`Disabled !me command should not work`, () => { + beforeEach(async () => { + await AppDataSource.getRepository(PermissionCommands).save({ + name: '!me', + permission: null, + }); + }); + after(async () => { + await AppDataSource.getRepository(PermissionCommands).clear(); + }); + for (let j = 0; j < users.length; j++) { + it (`--- ${users[j].userName} should NOT trigger disabled command !me`, async () => { + const parse = new Parser({ + sender: users[j], message: '!me', skip: false, quiet: false, + }); + const r = await parse.process(); + assert.strictEqual(r.length, 0); + }); + } + }); +}); diff --git a/backend/test/tests/permissions/community#192_exclude_user_from_permission.js b/backend/test/tests/permissions/community#192_exclude_user_from_permission.js new file mode 100644 index 000000000..37d47ea9f --- /dev/null +++ b/backend/test/tests/permissions/community#192_exclude_user_from_permission.js @@ -0,0 +1,64 @@ +/* global describe it beforeEach */ + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import assert from 'assert'; + +import { defaultPermissions } from '../../../dest/helpers/permissions/defaultPermissions.js'; +import { check } from '../../../dest/helpers/permissions/check.js'; +import { Parser } from '../../../dest/parser.js'; +const currency = (await import('../../../dest/currency.js')).default; + +import { Permissions, PermissionCommands } from '../../../dest/database/entity/permissions.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { AppDataSource } from '../../../dest/database.js'; + +const users = [ + { userName: '__viewer__', userId: String(6), id: 6 }, + { userName: '__excluded_viewer__', userId: String(7), id: 7 }, +]; + +describe('Permissions - https://community.sogebot.xyz/t/spotify-user-banlist/192 - exclude user from permission - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + for (const u of users) { + await AppDataSource.getRepository(User).save(u); + } + }); + + it('Add permission with excluded __excluded_viewer__', async () => { + await AppDataSource.getRepository(Permissions).save({ + id: 'bbaac669-923f-4063-99e3-f8004b34dac3', + name: '__permission_with_excluded_user__', + order: Object.keys(defaultPermissions).length + 1, + isCorePermission: false, + isWaterfallAllowed: false, + automation: 'viewers', + userIds: [], + excludeUserIds: ['7'], + filters: [], + }); + }); + + for (let j = 0; j < users.length; j++) { + const user = users[j]; + const pHash = 'bbaac669-923f-4063-99e3-f8004b34dac3'; + if (user.userName === '__viewer__') { + // have access + it(`+++ ${users[j].userName} should have access to __permission_with_excluded_user__`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, true); + }); + } else { + // no access + it(`--- ${users[j].userName} should NOT have access to __permission_with_excluded_user__`, async () => { + const _check = await check(user.userId, pHash); + assert.strictEqual(_check.access, false); + }); + } + } +}); diff --git a/backend/test/tests/permissions/list.js b/backend/test/tests/permissions/list.js new file mode 100644 index 000000000..6cc21f2b7 --- /dev/null +++ b/backend/test/tests/permissions/list.js @@ -0,0 +1,28 @@ +import('../../general.js'); + +import assert from 'assert'; + +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import permissions from '../../../dest/permissions.js' +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Permissions - list() - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('Permission list should be correct', async () => { + const r = await permissions.list({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, prepare('core.permissions.list')); + assert.strictEqual(r[1].response, '≥ | Casters | 4300ed23-dca0-4ed9-8014-f5f2f7af55a9', owner); + assert.strictEqual(r[2].response, '≥ | Moderators | b38c5adb-e912-47e3-937a-89fabd12393a', owner); + assert.strictEqual(r[3].response, '≥ | Subscribers | e3b557e7-c26a-433c-a183-e56c11003ab7', owner); + assert.strictEqual(r[4].response, '≥ | VIP | e8490e6e-81ea-400a-b93f-57f55aad8e31', owner); + assert.strictEqual(r[5].response, '≥ | Viewers | 0efd7b1c-e460-4167-8e06-8aaf2c170311', owner); + }); +}); diff --git a/backend/test/tests/points/all.js b/backend/test/tests/points/all.js new file mode 100644 index 000000000..e84e4f89d --- /dev/null +++ b/backend/test/tests/points/all.js @@ -0,0 +1,84 @@ +/* global describe it before */ + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { User } from '../../../dest/database/entity/user.js'; + +import points from '../../../dest/systems/points.js'; + +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__' }; +const user1 = { userId: String(Math.floor(Math.random() * 100000)), userName: 'user1', points: 100 }; +const user2 = { userId: String(Math.floor(Math.random() * 100000)), userName: 'user2' }; + +describe('Points - all() - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save(owner); + await AppDataSource.getRepository(User).save(user1); + await AppDataSource.getRepository(User).save(user2); + }); + + describe('Points should be correctly given', () => { + it('!points get should return 0 for owner', async () => { + const r = await points.get({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, '@__broadcaster__ has currently 0 points. Your position is ?/3.'); + }); + + it('!points get should return 100 for user1', async () => { + const r = await points.get({ sender: user1, parameters: '' }); + assert.strictEqual(r[0].response, '@user1 has currently 100 points. Your position is 1/3.'); + }); + + it('!points get should return 0 for user2', async () => { + const r = await points.get({ sender: user2, parameters: '@user2 has currently 0 points. Your position is 2/3.' }); + assert.strictEqual(r[0].response, '@user2 has currently 0 points. Your position is 2/3.'); + }); + + it('!points all 100', async () => { + const r = await points.all({ sender: owner, parameters: '100' }); + assert.strictEqual(r[0].response, 'All users just received 100 points!'); + }); + + it('!points get should return 100 for owner', async () => { + const r = await points.get({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, '@__broadcaster__ has currently 100 points. Your position is ?/3.'); + }); + + it('!points get should return 200 for user1', async () => { + const r = await points.get({ sender: user1, parameters: '' }); + assert.strictEqual(r[0].response, '@user1 has currently 200 points. Your position is 1/3.'); + }); + + it('!points get should return 100 for user2', async () => { + const r = await points.get({ sender: user2, parameters: '' }); + assert.strictEqual(r[0].response, '@user2 has currently 100 points. Your position is 2/3.'); + }); + + it('!points all -150', async () => { + const r = await points.all({ sender: owner, parameters: '-150' }); + assert.strictEqual(r[0].response, 'All users just lost -150 points!'); + }); + + it('!points get should return 0 for owner', async () => { + const r = await points.get({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, '@__broadcaster__ has currently 0 points. Your position is ?/3.'); + }); + + it('!points get should return 50 for user1', async () => { + const r = await points.get({ sender: user1, parameters: '' }); + assert.strictEqual(r[0].response, '@user1 has currently 50 points. Your position is 1/3.'); + }); + + it('!points get should return 0 for user2', async () => { + const r = await points.get({ sender: user2, parameters: '' }); + assert.strictEqual(r[0].response, '@user2 has currently 0 points. Your position is 2/3.'); + }); + }); +}); diff --git a/backend/test/tests/points/discord#1010671480973041844_user_have_proper_points_counted.js b/backend/test/tests/points/discord#1010671480973041844_user_have_proper_points_counted.js new file mode 100644 index 000000000..04d19468a --- /dev/null +++ b/backend/test/tests/points/discord#1010671480973041844_user_have_proper_points_counted.js @@ -0,0 +1,80 @@ +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' + + +import { User } from '../../../dest/database/entity/user.js'; +import * as changelog from '../../../dest/helpers/user/changelog.js'; +import points from '../../../dest/systems/points.js'; +import('../../general.js'); +import { db, message, user } from '../../general.js'; + +describe('Points - User have proper points count - https://discord.com/channels/317348946144002050/689472714544513025/1010671480973041844 - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + await AppDataSource.getRepository(User).update({ userName: user.viewer.userName }, { isOnline: true }); + + points.messageOfflineInterval = 5; + points.perMessageOfflineInterval = 1; + points.offlineInterval = 10; // every 10 minutes + points.perOfflineInterval = 10; + }); + + it('!points set points to 0', async () => { + const r = await points.set({ sender: user.owner, parameters: user.viewer.userName + ' 0' }); + assert.strictEqual(r[0].response, `@${user.viewer.userName} was set to 0 points`); + }); + + it('User sends 50 messages', async () => { + for (let i = 0; i < 50; i++) { + changelog.increment(user.viewer.userId, { messages: 1 }); + await points.messagePoints({ sender: user.viewer, skip: false, message: '' }); + } + }); + + it('!points get should return 10 points', async () => { + const r = await points.get({ sender: user.viewer, parameters: '' }); + assert.strictEqual(r[0].response, `@${user.viewer.userName} has currently 10 points. Your position is 1/9.`); + }); + + it('Set user pointsOfflineGivenAt to 0 and chatTimeOffline to 10 minutes', async () => { + changelog.update(user.viewer.userId, { chatTimeOffline: 60000 * 10, pointsOfflineGivenAt: 0 }); + }); + + it('Trigger updatePoints()', async () => { + await points.updatePoints(); + }); + + it('!points get should return 20 points', async () => { + const r = await points.get({ sender: user.viewer, parameters: '' }); + assert.strictEqual(r[0].response, `@${user.viewer.userName} has currently 20 points. Your position is 1/9.`); + }); + + it('Set user and chatTimeOffline to 20 minutes', async () => { + changelog.update(user.viewer.userId, { chatTimeOffline: 60000 * 20 }); + }); + + it('Trigger updatePoints()', async () => { + await points.updatePoints(); + }); + + it('!points get should return 30 points', async () => { + const r = await points.get({ sender: user.viewer, parameters: '' }); + assert.strictEqual(r[0].response, `@${user.viewer.userName} has currently 30 points. Your position is 1/9.`); + }); + + it('Set user chatTimeOffline to 9 hours', async () => { + changelog.update(user.viewer.userId, { chatTimeOffline: 60000 * 60 * 9 }); + }); + + it('Trigger updatePoints()', async () => { + await points.updatePoints(); + }); + + it('!points get should return 550 points', async () => { + const r = await points.get({ sender: user.viewer, parameters: '' }); + assert.strictEqual(r[0].response, `@${user.viewer.userName} has currently 550 points. Your position is 1/9.`); + }); +}); diff --git a/backend/test/tests/points/get.js b/backend/test/tests/points/get.js new file mode 100644 index 000000000..c10a427de --- /dev/null +++ b/backend/test/tests/points/get.js @@ -0,0 +1,75 @@ +/* global describe it before */ + +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { AppDataSource } from '../../../dest/database.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import points from '../../../dest/systems/points.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const hugePointsUser = { + userName: 'hugeuser', points: 99999999999999999999999999999999, userId: String(_.random(999999, false)), +}; +const tinyPointsUser = { + userName: 'tinyuser', points: 100, userId: String(_.random(999999, false)), +}; + +describe('Points - get() - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + describe('User with more than safe points should return safe points', () => { + it('create user with huge amount of points', async () => { + await AppDataSource.getRepository(User).save({ + userName: hugePointsUser.userName, userId: hugePointsUser.userId, points: hugePointsUser.points, + }); + }); + + it('points should be returned in safe points bounds', async () => { + const r = await points.get({ sender: hugePointsUser, parameters: '' }); + assert.strictEqual(r[0].response, '@hugeuser has currently 9007199254740991 points. Your position is 1/1.'); + }); + }); + + describe('User with less than safe points should return unchanged points', () => { + it('create user with normal amount of points', async () => { + await AppDataSource.getRepository(User).save({ + userName: tinyPointsUser.userName, userId: tinyPointsUser.userId, points: tinyPointsUser.points, + }); + }); + + it('points should be returned in safe points bounds', async () => { + const r = await points.get({ sender: tinyPointsUser, parameters: '' }); + assert.strictEqual(r[0].response, '@tinyuser has currently 100 points. Your position is 2/2.'); + }); + }); + + describe('Users should have correct order', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + for (let i = 1; i <= 10; i++) { + it(`create user${i} with ${i*100} points`, async () => { + await AppDataSource.getRepository(User).save({ + userName: `user${i}`, userId: String(i), points: i*100, + }); + }); + } + + for (let i = 1; i <= 10; i++) { + it(`user${i} should have correct order and position`, async () => { + const r = await points.get({ sender: { userName: `user${i}`, userId: String(i) }, parameters: '' }); + assert.strictEqual(r[0].response, `@user${i} has currently ${i*100} points. Your position is ${11-i}/10.`); + }); + } + }); +}); diff --git a/backend/test/tests/points/give.js b/backend/test/tests/points/give.js new file mode 100644 index 000000000..8bf33937d --- /dev/null +++ b/backend/test/tests/points/give.js @@ -0,0 +1,275 @@ +/* global describe it before */ + +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { AppDataSource } from '../../../dest/database.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import points from '../../../dest/systems/points.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const user1 = { + userName: 'user1', points: 100, userId: String(_.random(999999, false)), +}; +const user2 = { + userName: 'user2', points: 100, userId: String(_.random(999999, false)), +}; + +describe('Points - give() - @func1', () => { + describe('user1 will give 50 points to user2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('create user1', async () => { + await AppDataSource.getRepository(User).save({ + userName: user1.userName, userId: user1.userId, points: user1.points, + }); + }); + + it('create user2', async () => { + await AppDataSource.getRepository(User).save({ + userName: user2.userName, userId: user2.userId, points: user2.points, + }); + }); + + it('user1 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 100); + }); + + it('user2 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 100); + }); + + it('user1 send 50 points', async () => { + const r = await points.give({ sender: user1, parameters: 'user2 50' }); + assert.strictEqual(r[0].response, `$sender just gave his 50 points to @user2`); + }); + + it('user1 should have 50 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 50); + }); + + it('user2 should have 150 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 150); + }); + }); + + describe('user1 will give 150 points to user2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('create user1', async () => { + await AppDataSource.getRepository(User).save({ + userName: user1.userName, userId: user1.userId, points: user1.points, + }); + }); + + it('create user2', async () => { + await AppDataSource.getRepository(User).save({ + userName: user2.userName, userId: user2.userId, points: user2.points, + }); + }); + + it('user1 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 100); + }); + + it('user2 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 100); + }); + + it('user1 send 150 points', async () => { + const r = await points.give({ sender: user1, parameters: 'user2 150' }); + assert.strictEqual(r[0].response, `Sorry, $sender, you don't have 150 points to give it to @user2`); + }); + + it('user1 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 100); + }); + + it('user2 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 100); + }); + }); + + describe('user1 will give all points to user2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('create user1', async () => { + await AppDataSource.getRepository(User).save({ + userName: user1.userName, userId: user1.userId, points: user1.points, + }); + }); + + it('create user2', async () => { + await AppDataSource.getRepository(User).save({ + userName: user2.userName, userId: user2.userId, points: user2.points, + }); + }); + + it('user1 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 100); + }); + + it('user2 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 100); + }); + + it('user1 send all points', async () => { + const r = await points.give({ sender: user1, parameters: 'user2 all' }); + assert.strictEqual(r[0].response, `$sender just gave his 100 points to @user2`); + }); + + it('user1 should have 0 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 0); + }); + + it('user2 should have 200 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 200); + }); + }); + + describe('user1 will give points without points value', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('create user1', async () => { + await AppDataSource.getRepository(User).save({ + userName: user1.userName, userId: user1.userId, points: user1.points, + }); + }); + + it('create user2', async () => { + await AppDataSource.getRepository(User).save({ + userName: user2.userName, userId: user2.userId, points: user2.points, + }); + }); + + it('user1 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 100); + }); + + it('user2 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 100); + }); + + it('user1 send wrong command', async () => { + const r = await points.give({ + sender: user1, parameters: 'user2', command: '!points give', + }); + assert.strictEqual(r[0].response, `Sorry, $sender, but this command is not correct, use !points give [username] [amount]`); + }); + + it('user1 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 100); + }); + + it('user2 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 100); + }); + }); + + describe('user1 will give string points to user2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('create user1', async () => { + await AppDataSource.getRepository(User).save({ + userName: user1.userName, userId: user1.userId, points: user1.points, + }); + }); + + it('create user2', async () => { + await AppDataSource.getRepository(User).save({ + userName: user2.userName, userId: user2.userId, points: user2.points, + }); + }); + + it('user1 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 100); + }); + + it('user2 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 100); + }); + + it('user1 send wrong string points', async () => { + const r = await points.give({ + sender: user1, parameters: 'user2 something', command: '!points give', + }); + assert.strictEqual(r[0].response, `Sorry, $sender, but this command is not correct, use !points give [username] [amount]`); + }); + + it('user1 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 100); + }); + + it('user2 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 100); + }); + }); + + describe('Giving 0 points should trigger error - https://community.sogebot.xyz/t/sending-0-points/214', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('create user1', async () => { + await AppDataSource.getRepository(User).save({ + userName: user1.userName, userId: user1.userId, points: user1.points, + }); + }); + + it('create user2', async () => { + await AppDataSource.getRepository(User).save({ + userName: user2.userName, userId: user2.userId, points: 0, + }); + }); + + it('user1 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 100); + }); + + it('user2 should have 0 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 0); + }); + + it('user1 send 0 points', async () => { + const r = await points.give({ + sender: user1, parameters: 'user2 0', command: '!points give', + }); + assert.strictEqual(r[0].response, `Sorry, $sender, you cannot give 0 points to @user2`); + }); + + it('user2 send all points', async () => { + const r = await points.give({ + sender: user2, parameters: 'user1 all', command: '!points give', + }); + assert.strictEqual(r[0].response, `Sorry, $sender, you cannot give 0 points to @user1`); + }); + + it('user1 should have 100 points', async () => { + assert.strict.equal(await points.getPointsOf(user1.userId), 100); + }); + + it('user2 should have 0 points', async () => { + assert.strict.equal(await points.getPointsOf(user2.userId), 0); + }); + }); +}); diff --git a/backend/test/tests/points/online.js b/backend/test/tests/points/online.js new file mode 100644 index 000000000..d2b8f6f27 --- /dev/null +++ b/backend/test/tests/points/online.js @@ -0,0 +1,95 @@ +/* global describe it before */ + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { User } from '../../../dest/database/entity/user.js'; + +import points from '../../../dest/systems/points.js'; + +const owner = { userId: String(Math.floor(Math.random() * 100000)), userName: '__broadcaster__' }; +const user1 = { userId: String(Math.floor(Math.random() * 100000)), userName: 'user1', points: 100 }; +const user2 = { userId: String(Math.floor(Math.random() * 100000)), userName: 'user2' }; + +async function setUsersOnline(users) { + await AppDataSource.getRepository(User).update({}, { isOnline: false }); + for (const userName of users) { + await AppDataSource.getRepository(User).update({ userName }, { isOnline: true }); + } +} + +describe('Points - online() - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save(owner); + await AppDataSource.getRepository(User).save(user1); + await AppDataSource.getRepository(User).save(user2); + }); + + describe('Points should be correctly given', () => { + it('set users online', async () => { + await setUsersOnline(['__broadcaster__', 'user1', 'user2']); + }); + + it('!points get should return 0 for owner', async () => { + const r = await points.get({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, `@__broadcaster__ has currently 0 points. Your position is ?/3.`); + }); + + it('!points get should return 100 for user1', async () => { + const r = await points.get({ sender: user1, parameters: '' }); + assert.strictEqual(r[0].response, `@user1 has currently 100 points. Your position is 1/3.`); + }); + + it('!points get should return 0 for user2', async () => { + const r = await points.get({ sender: user2, parameters: '' }); + assert.strictEqual(r[0].response, `@user2 has currently 0 points. Your position is 2/3.`); + }); + + it('!points all 100', async () => { + const r = await points.all({ sender: owner, parameters: '100' }); + assert.strictEqual(r[0].response, `All users just received 100 points!`); + }); + + it('!points get should return 100 for owner', async () => { + const r = await points.get({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, `@__broadcaster__ has currently 100 points. Your position is ?/3.`); + }); + + it('!points get should return 200 for user1', async () => { + const r = await points.get({ sender: user1, parameters: '' }); + assert.strictEqual(r[0].response, `@user1 has currently 200 points. Your position is 1/3.`); + }); + + it('!points get should return 100 for user2', async () => { + const r = await points.get({ sender: user2, parameters: '' }); + assert.strictEqual(r[0].response, `@user2 has currently 100 points. Your position is 2/3.`); + }); + + it('!points all -150', async () => { + const r = await points.all({ sender: owner, parameters: '-150' }); + assert.strictEqual(r[0].response, `All users just lost -150 points!`); + }); + + it('!points get should return 0 for owner', async () => { + const r = await points.get({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, `@__broadcaster__ has currently 0 points. Your position is ?/3.`); + }); + + it('!points get should return 50 for user1', async () => { + const r = await points.get({ sender: user1, parameters: '' }); + assert.strictEqual(r[0].response, `@user1 has currently 50 points. Your position is 1/3.`); + }); + + it('!points get should return 0 for user2', async () => { + const r = await points.get({ sender: user2, parameters: '' }); + assert.strictEqual(r[0].response, `@user2 has currently 0 points. Your position is 2/3.`); + }); + }); +}); diff --git a/backend/test/tests/points/set.js b/backend/test/tests/points/set.js new file mode 100644 index 000000000..ecc17d2ba --- /dev/null +++ b/backend/test/tests/points/set.js @@ -0,0 +1,53 @@ +/* global describe it before */ + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +import { User } from '../../../dest/database/entity/user.js'; +import points from '../../../dest/systems/points.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const user = { userName: 'oneuser', userId: String(_.random(999999, false)) }; + +describe('Points - set() - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + describe('Points should be correctly set, not added', () => { + it('create user', async () => { + await AppDataSource.getRepository(User).save({ userName: user.userName, userId: user.userId }); + }); + + it('!points get should return 0', async () => { + const r = await points.get({ sender: user, parameters: '' }); + assert.strictEqual(r[0].response, `@oneuser has currently 0 points. Your position is 1/1.`); + }); + + it('!points set should correctly set value 5', async () => { + const r = await points.set({ sender: user, parameters: user.userName + ' 5' }); + assert.strictEqual(r[0].response, `@oneuser was set to 5 points`); + }); + + it('!points get should return 5', async () => { + const r = await points.get({ sender: user, parameters: '' }); + assert.strictEqual(r[0].response, `@oneuser has currently 5 points. Your position is 1/1.`); + }); + + it('!points set should correctly set value 10', async () => { + const r = await points.set({ sender: user, parameters: user.userName + ' 10' }); + assert.strictEqual(r[0].response, `@oneuser was set to 10 points`); + }); + + it('!points get should return 10', async () => { + const r = await points.get({ sender: user, parameters: '' }); + assert.strictEqual(r[0].response, `@oneuser has currently 10 points. Your position is 1/1.`); + }); + }); +}); diff --git a/backend/test/tests/points/undo.js b/backend/test/tests/points/undo.js new file mode 100644 index 000000000..99a14c9fa --- /dev/null +++ b/backend/test/tests/points/undo.js @@ -0,0 +1,200 @@ + +/* global */ + +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { AppDataSource } from '../../../dest/database.js'; + +import { PointsChangelog } from '../../../dest/database/entity/points.js'; +import { User } from '../../../dest/database/entity/user.js'; +import * as userChangelog from '../../../dest/helpers/user/changelog.js'; +import points from '../../../dest/systems/points.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const user = { userName: 'oneuser', userId: String(_.random(999999, false)) }; + +describe('Points - undo() - @func', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + describe('!point add command should be undoable', () => { + it('create user', async () => { + await AppDataSource.getRepository(User).save({ + userName: user.userName, userId: user.userId, points: 150, + }); + }); + + it('!points add 100', async () => { + const r = await points.add({ sender: user, parameters: user.userName + ' 100' }); + assert.strictEqual(r[0].response, '@oneuser just received 100 points!'); + }); + + it('User should have correctly added 100 points', async () => { + await userChangelog.flush(); + const updatedUser = await AppDataSource.getRepository(User).findOneBy({ userName: user.userName }); + assert.strictEqual(updatedUser.points, 250); + }); + + it('Changelog should have 150 -> 250 log', async () => { + const changelog = await AppDataSource.getRepository(PointsChangelog).findOneBy({ userId: user.userId }); + assert(typeof changelog !== 'undefined'); + assert.strictEqual(changelog.originalValue, 150); + assert.strictEqual(changelog.updatedValue, 250); + }); + + it('!points undo ' + user.userName, async () => { + const r = await points.undo({ sender: user, parameters: user.userName }); + assert.strictEqual(r[0].response, '$sender, points \'add\' for @oneuser was reverted (250 points to 150 points).'); + }); + + it('User should have correctly set 150 points', async () => { + await userChangelog.flush(); + const updatedUser = await AppDataSource.getRepository(User).findOneBy({ userName: user.userName }); + assert.strictEqual(updatedUser.points, 150); + }); + + it('Changelog should be empty', async () => { + const changelog = await AppDataSource.getRepository(PointsChangelog).find(); + assert.strictEqual(changelog.length, 0); + }); + + it('!points undo ' + user.userName, async () => { + const r = await points.undo({ sender: user, parameters: user.userName }); + assert.strictEqual(r[0].response, '$sender, username wasn\'t found in database or user have no undo operations'); + }); + }); + + describe('!point set command should be undoable', () => { + it('create user', async () => { + await AppDataSource.getRepository(User).save({ + userName: user.userName, userId: user.userId, points: 0, + }); + }); + + it('!points set 100', async () => { + const r = await points.set({ sender: user, parameters: user.userName + ' 100' }); + assert.strictEqual(r[0].response, '@oneuser was set to 100 points'); + }); + + it('User should have correctly set 100 points', async () => { + await userChangelog.flush(); + const updatedUser = await AppDataSource.getRepository(User).findOneBy({ userName: user.userName }); + assert.strictEqual(updatedUser.points, 100); + }); + + it('Changelog should have 0 -> 100 log', async () => { + const changelog = await AppDataSource.getRepository(PointsChangelog).findOneBy({ userId: user.userId }); + assert(typeof changelog !== 'undefined'); + assert.strictEqual(changelog.originalValue, 0); + assert.strictEqual(changelog.updatedValue, 100); + }); + + it('!points undo ' + user.userName, async () => { + const r = await points.undo({ sender: user, parameters: user.userName }); + assert.strictEqual(r[0].response, '$sender, points \'set\' for @oneuser was reverted (100 points to 0 points).'); + }); + + it('User should have correctly set 0 points', async () => { + await userChangelog.flush(); + const updatedUser = await AppDataSource.getRepository(User).findOneBy({ userName: user.userName }); + assert.strictEqual(updatedUser.points, 0); + }); + + it('Changelog should be empty', async () => { + const changelog = await AppDataSource.getRepository(PointsChangelog).find(); + assert.strictEqual(changelog.length, 0); + }); + + it('!points undo ' + user.userName, async () => { + const r = await points.undo({ sender: user, parameters: user.userName }); + assert.strictEqual(r[0].response, '$sender, username wasn\'t found in database or user have no undo operations'); + }); + }); + + describe('!point remove command should be undoable', () => { + it('create user', async () => { + await AppDataSource.getRepository(User).save({ + userName: user.userName, userId: user.userId, points: 100, + }); + }); + + it('!points remove 25', async () => { + const r = await points.remove({ sender: user, parameters: user.userName + ' 25' }); + assert.strictEqual(r[0].response, 'Ouch, 25 points was removed from @oneuser!'); + }); + + it('User should have 75 points', async () => { + await userChangelog.flush(); + const updatedUser = await AppDataSource.getRepository(User).findOneBy({ userName: user.userName }); + assert.strictEqual(updatedUser.points, 75); + }); + + it('Changelog should have 100 -> 75 log', async () => { + const changelog = await AppDataSource.getRepository(PointsChangelog).findOne({ where: { userId: user.userId }, order: { updatedAt: 'DESC' } }); + assert(typeof changelog !== 'undefined'); + assert.strictEqual(changelog.originalValue, 100); + assert.strictEqual(changelog.updatedValue, 75); + }); + + it('!points remove all', async () => { + const r = await points.remove({ sender: user, parameters: user.userName + ' all' }); + assert.strictEqual(r[0].response, 'Ouch, all points was removed from @oneuser!'); + }); + + it('User should have 0 points', async () => { + await userChangelog.flush(); + const updatedUser = await AppDataSource.getRepository(User).findOneBy({ userName: user.userName }); + assert.strictEqual(updatedUser.points, 0); + }); + + it('Changelog should have 75 -> 0 log', async () => { + const changelog = await AppDataSource.getRepository(PointsChangelog).findOne({ where: { userId: user.userId }, order: { updatedAt: 'DESC' } }); + assert(typeof changelog !== 'undefined'); + assert.strictEqual(changelog.originalValue, 75); + assert.strictEqual(changelog.updatedValue, 0); + }); + + it('!points undo ' + user.userName, async () => { + const r = await points.undo({ sender: user, parameters: user.userName }); + assert.strictEqual(r[0].response, '$sender, points \'remove\' for @oneuser was reverted (0 points to 75 points).'); + }); + + it('User should have correctly set 75 points', async () => { + await userChangelog.flush(); + const updatedUser = await AppDataSource.getRepository(User).findOneBy({ userName: user.userName }); + assert.strictEqual(updatedUser.points, 75); + }); + + it('Changelog should have one change', async () => { + const changelog = await AppDataSource.getRepository(PointsChangelog).find(); + assert.strictEqual(changelog.length, 1); + }); + + it('!points undo ' + user.userName, async () => { + const r = await points.undo({ sender: user, parameters: user.userName }); + assert.strictEqual(r[0].response, '$sender, points \'remove\' for @oneuser was reverted (75 points to 100 points).'); + }); + + it('User should have correctly set 100 points', async () => { + await userChangelog.flush(); + const updatedUser = await AppDataSource.getRepository(User).findOneBy({ userName: user.userName }); + assert.strictEqual(updatedUser.points, 100); + }); + + it('Changelog should be empty', async () => { + const changelog = await AppDataSource.getRepository(PointsChangelog).find(); + assert.strictEqual(changelog.length, 0); + }); + + it('!points undo ' + user.userName, async () => { + const r = await points.undo({ sender: user, parameters: user.userName }); + assert.strictEqual(r[0].response, '$sender, username wasn\'t found in database or user have no undo operations'); + }); + }); +}); diff --git a/backend/test/tests/price/check.js b/backend/test/tests/price/check.js new file mode 100644 index 000000000..30d6da841 --- /dev/null +++ b/backend/test/tests/price/check.js @@ -0,0 +1,141 @@ +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; + +import { Price } from '../../../dest/database/entity/price.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { AppDataSource } from '../../../dest/database.js'; +import alias from '../../../dest/systems/alias.js'; +import {cheer } from '../../../dest/helpers/events/cheer.js'; +import customcommands from '../../../dest/systems/customcommands.js'; +import price from '../../../dest/systems/price.js'; +import { db, message, user, url } from '../../general.js'; + +const tests = [ + { + user: user.owner.userName, + user_id: user.owner.userId, + points: 10, + command: '!me', + price: 15, + priceOn: '!me', + expected: true, + }, + { + user: user.viewer.userName, + user_id: user.viewer.userId, + points: 15, + command: '!me', + price: 15, + priceOn: '!me', + expected: true, + }, + { + user: user.viewer.userName, + user_id: user.viewer.userId, + points: 10, + command: '!me', + price: 15, + priceOn: '!me', + expected: false, + }, + { + user: user.viewer.userName, + user_id: user.viewer.userId, + points: 20, + command: '!me', + price: 15, + priceOn: '!me', + expected: true, + }, +]; + +describe('Price - check() - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + + await AppDataSource.getRepository(User).save({ userName: user.viewer.userName, userId: user.viewer.userId }); + }); + + for (const test of tests) { + it(`${test.user} with ${test.points} points calls ${test.command}, price on ${test.priceOn} set to ${test.price} and should ${test.expected ? 'pass' : 'fail'}`, async () => { + await AppDataSource.getRepository(User).update({ userId: test.user_id }, { points: test.points }); + await AppDataSource.getRepository(Price).save({ command: test.command, price: test.price }); + const haveEnoughPoints = await price.check({ sender: { userName: test.user, userId: test.user_id }, message: test.command }); + assert(haveEnoughPoints === test.expected); + }); + } + + it(`Bits only price should return correct error response`, async () => { + await AppDataSource.getRepository(Price).save({ + command: '!me', price: 0, priceBits: 10, + }); + const haveEnoughPoints = await price.check({ sender: { userName: user.viewer.userName, userId: user.viewer.userId }, message: '!me' }); + assert(haveEnoughPoints === false); + await message.isSentRaw('Sorry, @__viewer__, but you need to redeem command by 10 bits to use !me', user.viewer.userName, 20000); + }); + + it(`Points and Bits price should return correct error response`, async () => { + await AppDataSource.getRepository(User).update({ userId: user.viewer.userId }, { points: 10 }); + await AppDataSource.getRepository(Price).save({ + command: '!me', price: 100, priceBits: 10, + }); + const haveEnoughPoints = await price.check({ sender: { userName: user.viewer.userName, userId: user.viewer.userId }, message: '!me' }); + assert(haveEnoughPoints === false); + await message.isSentRaw('Sorry, @__viewer__, but you don\'t have 100 points or redeem command by 10 bits to use !me', user.viewer.userName, 20000); + }); + + it(`Points and Bits price should be OK if user have points`, async () => { + await AppDataSource.getRepository(User).update({ userId: user.viewer.userId }, { points: 100 }); + await AppDataSource.getRepository(Price).save({ + command: '!me', price: 100, priceBits: 10, + }); + const haveEnoughPoints = await price.check({ sender: { userName: user.viewer.userName, userId: user.viewer.userId }, message: '!me' }); + assert(haveEnoughPoints === true); + }); + + it(`Cheer should trigger alias`, async () => { + await alias.add({ sender: user.owner, parameters: '-a !a -c !alias' }); + await AppDataSource.getRepository(Price).save({ + command: '!a', price: 100, priceBits: 10, + }); + cheer({ + user_login: user.viewer.userName, + user_id: user.viewer.userId, + message: '!a', + bits: 100, + }); + await message.isSentRaw('Usage => ' + url + '/systems/alias', user.viewer.userName, 20000); + }); + + it(`Cheer should trigger custom command`, async () => { + await customcommands.add({ sender: user.owner, parameters: '-c !b -r Lorem Ipsum' }); + await AppDataSource.getRepository(Price).save({ + command: '!b', price: 100, priceBits: 10, + }); + cheer({ + user_login: user.viewer.userName, + user_id: user.viewer.userId, + message: '!b', + bits: 100, + }); + await message.isSentRaw('Lorem Ipsum', user.viewer.userName, 20000); + }); + + it(`Cheer should trigger core command`, async () => { + await AppDataSource.getRepository(Price).save({ + command: '!me', price: 100, priceBits: 10, + }); + cheer({ + user_login: user.viewer.userName, + user_id: user.viewer.userId, + message: '!me', + bits: 100, + }); + await message.isSentRaw('@__viewer__ | Level 0 | 0 hours | 0 points | 0 messages | €0.00 | 100 bits | 0 months', user.viewer.userName, 20000); + }); +}); diff --git a/backend/test/tests/quotes/add.js b/backend/test/tests/quotes/add.js new file mode 100644 index 000000000..02437de37 --- /dev/null +++ b/backend/test/tests/quotes/add.js @@ -0,0 +1,75 @@ + +/* global describe it before */ + + +import('../../general.js'); + +import { db } from '../../general.js'; +import assert from 'assert'; +import { message } from '../../general.js'; + +import { AppDataSource } from '../../../dest/database.js'; +import { Quotes } from '../../../dest/database/entity/quotes.js'; + +import quotes from '../../../dest/systems/quotes.js' + +// users +const owner = { userName: '__broadcaster__', userId: 1 }; + +const tests = [ + { sender: owner, parameters: '', shouldFail: true }, + { sender: owner, parameters: '-quote -tags', shouldFail: true }, + { sender: owner, parameters: '-quote -tags ', shouldFail: true }, + { sender: owner, parameters: '-quote -tags lorem ipsum', shouldFail: true }, + { sender: owner, parameters: '-tags -quote', shouldFail: true }, + { sender: owner, parameters: '-tags lorem ipsum -quote', shouldFail: true }, + { sender: owner, parameters: '-tags Lorem Ipsum Dolor', shouldFail: true }, + { sender: owner, parameters: '-quote Lorem Ipsum Dolor', quote: 'Lorem Ipsum Dolor', tags: 'general', shouldFail: false }, + { sender: owner, parameters: '-quote Lorem Ipsum Dolor -tags lorem', quote: 'Lorem Ipsum Dolor', tags: 'lorem', shouldFail: false }, + { sender: owner, parameters: '-quote Lorem Ipsum Dolor -tags lorem ipsum', quote: 'Lorem Ipsum Dolor', tags: 'lorem ipsum', shouldFail: false }, + { sender: owner, parameters: ' -tags lorem ipsum, dolor sit -quote Lorem Ipsum Dolor', quote: 'Lorem Ipsum Dolor', tags: 'lorem ipsum, dolor sit', shouldFail: false }, +]; + +describe('Quotes - add() - @func3', () => { + for (const test of tests) { + describe(test.parameters, async () => { + let id = null; + let response = ''; + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('Run !quote add', async () => { + const quote = await quotes.add({ sender: test.sender, parameters: test.parameters, command: '!quote add' }); + id = quote[0].id; + response = quote[0].response; + }); + if (test.shouldFail) { + it('Should throw error', async () => { + assert.strictEqual(response, '$sender, !quote add is not correct or missing -quote parameter'); + }); + it('Database should be empty', async () => { + const items = await AppDataSource + .createQueryBuilder() + .select('quotes') + .from(Quotes, 'quotes') + .getMany(); + assert(items.length === 0); + }); + } else { + it('Should sent success message', async () => { + assert.strictEqual(response, `$sender, quote ${id} '${test.quote}' was added. (tags: ${test.tags})`); + }); + it('Database should contain new quote', async () => { + const items = await AppDataSource + .createQueryBuilder() + .select('quotes') + .from(Quotes, 'quotes') + .getMany(); + assert(items.length > 0); + }); + } + }); + } +}); diff --git a/backend/test/tests/quotes/remove.js b/backend/test/tests/quotes/remove.js new file mode 100644 index 000000000..17a1689f7 --- /dev/null +++ b/backend/test/tests/quotes/remove.js @@ -0,0 +1,88 @@ + +/* global describe it before */ + + +import('../../general.js'); + +import { db } from '../../general.js'; +import assert from 'assert'; +import { message } from '../../general.js'; + +import { AppDataSource } from '../../../dest/database.js'; +import { Quotes } from '../../../dest/database/entity/quotes.js'; + +import quotes from '../../../dest/systems/quotes.js' + +// users +const owner = { userName: '__broadcaster__', userId: 1 }; + + +const tests = [ + { sender: owner, parameters: '', shouldFail: true }, + { sender: owner, parameters: '-id', shouldFail: true }, + { sender: owner, parameters: '-id a', id: 'a', shouldFail: true, exist: false }, + { sender: owner, parameters: '-id 99999', id: 99999, shouldFail: false, exist: false }, + { sender: owner, parameters: '-id $id', id: 1, shouldFail: false, exist: true }, +]; + +describe('Quotes - remove() - @func3', () => { + for (const test of tests) { + let responses = []; + describe(test.parameters, async () => { + let id = null; + + before(async () => { + await db.cleanup(); + await message.prepare(); + const quote = await quotes.add({ sender: test.sender, parameters: '-tags lorem ipsum -quote Lorem Ipsum', command: '!quote add' }); + id = quote[0].id; + if (test.id === 1) { + test.id = id; + } + }); + + it('Run !quote remove', async () => { + responses = await quotes.remove({ sender: test.sender, parameters: test.parameters.replace('$id', id), command: '!quote remove' }); + }); + if (test.shouldFail) { + it('Should throw error', async () => { + assert.strictEqual(responses[0].response, '$sender, quote ID is missing.'); + }); + it('Database should not be empty', async () => { + const items = await AppDataSource + .createQueryBuilder() + .select('quotes') + .from(Quotes, 'quotes') + .getMany(); + assert(items.length > 0); + }); + } else { + if (test.exist) { + it('Should sent success message', async () => { + assert.strictEqual(responses[0].response, `$sender, quote ${id} was successfully deleted.`) + }); + it('Database should be empty', async () => { + const items = await AppDataSource + .createQueryBuilder() + .select('quotes') + .from(Quotes, 'quotes') + .getMany(); + assert(items.length === 0); + }); + } else { + it('Should sent not-found message', async () => { + assert.strictEqual(responses[0].response, `$sender, quote ${test.id} was not found.`); + }); + it('Database should not be empty', async () => { + const items = await AppDataSource + .createQueryBuilder() + .select('quotes') + .from(Quotes, 'quotes') + .getMany(); + assert(items.length > 0); + }); + } + } + }); + } +}); diff --git a/backend/test/tests/quotes/set.js b/backend/test/tests/quotes/set.js new file mode 100644 index 000000000..cf4e431d1 --- /dev/null +++ b/backend/test/tests/quotes/set.js @@ -0,0 +1,85 @@ + +/* global describe it before */ + + +import('../../general.js'); + +import { db } from '../../general.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; +import { message } from '../../general.js'; + +import { Quotes } from '../../../dest/database/entity/quotes.js'; + +import quotes from '../../../dest/systems/quotes.js' + +// users +const owner = { userName: '__broadcaster__', userId: 1 }; + +const tests = [ + { sender: owner, parameters: '', shouldFail: true }, + { sender: owner, parameters: '-id', shouldFail: true }, + { sender: owner, parameters: '-id a', shouldFail: true }, + { sender: owner, parameters: '-id $id -tag', shouldFail: true }, + + { sender: owner, parameters: '-id $id -tag ipsum, dolor', id: 1, tags: 'ipsum, dolor', shouldFail: false, exist: true }, + { sender: owner, parameters: '-tag ipsum, dolor -id $id', id: 1, tags: 'ipsum, dolor', shouldFail: false, exist: true }, + { sender: owner, parameters: '-id 99999 -tag ipsum, dolor', id: 99999, tags: 'ipsum, dolor', shouldFail: false, exist: false }, + { sender: owner, parameters: '-tag ipsum, dolor -id 99999', id: 99999, tags: 'ipsum, dolor', shouldFail: false, exist: false }, +]; + +let id; +describe('Quotes - set() - @func3', () => { + for (const test of tests) { + describe(test.parameters, async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + const quote = await quotes.add({ sender: test.sender, parameters: '-tags lorem ipsum -quote Lorem Ipsum', command: '!quote add' }); + id = quote[0].id; + if (test.id === 1) { + test.id = id; + } + test.parameters = test.parameters.replace('$id', id); + }); + + let responses = ''; + it('Run !quote set', async () => { + responses = await quotes.set({ sender: test.sender, parameters: test.parameters, command: '!quote set' }); + }); + if (test.shouldFail) { + it('Should throw error', async () => { + assert.strictEqual(responses[0].response, '$sender, !quote set is missing -id or -tag.'); + }); + } else { + if (test.exist) { + it('Should sent success message', async () => { + assert.strictEqual(responses[0].response, `$sender, quote ${id} tags were set. (tags: ${test.tags})`); + }); + it('Tags should be changed', async () => { + const item = await AppDataSource + .createQueryBuilder() + .select('quote') + .from(Quotes, 'quote') + .where('id = :id', { id: test.id }) + .getOne(); + assert.deepEqual(item.tags, test.tags.split(',').map((o) => o.trim())); + }); + } else { + it('Should sent not-found message', async () => { + assert.strictEqual(responses[0].response, `$sender, quote ${test.id} was not found.`); + }); + it('Quote should not be created', async () => { + const item = await AppDataSource + .createQueryBuilder() + .select('quote') + .from(Quotes, 'quote') + .where('id = :id', { id: test.id }) + .getOne(); + assert(item === null); + }); + } + } + }); + } +}); diff --git a/backend/test/tests/quotes/show.js b/backend/test/tests/quotes/show.js new file mode 100644 index 000000000..90e86f7b5 --- /dev/null +++ b/backend/test/tests/quotes/show.js @@ -0,0 +1,73 @@ +/* global describe it before */ + + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import { User } from '../../../dest/database/entity/user.js'; + +import quotes from '../../../dest/systems/quotes.js' + +// users +const owner = { userName: '__broadcaster__', userId: '1' }; +const user = { userName: 'user', userId: '3' }; + +const tests = [ + { sender: owner, parameters: '', shouldFail: true, error: 'systems.quotes.show.error.no-parameters' }, + { sender: owner, parameters: '-id', shouldFail: true, error: 'systems.quotes.show.error.no-parameters' }, + { sender: owner, parameters: '-tag', shouldFail: true, error: 'systems.quotes.show.error.no-parameters' }, + { sender: owner, parameters: '-tag -id ', shouldFail: true, error: 'systems.quotes.show.error.no-parameters' }, + { sender: owner, parameters: '-tag -id', shouldFail: true, error: 'systems.quotes.show.error.no-parameters' }, + { sender: owner, parameters: '-id -tag', shouldFail: true, error: 'systems.quotes.show.error.no-parameters' }, + + { sender: owner, parameters: '-id $id', id: 1, tag: 'general', shouldFail: false, exist: true }, + { sender: owner, parameters: '-id $id -tag', id: 1, tag: 'general', shouldFail: false, exist: true }, + { sender: owner, parameters: '-id 99999', id: 99999, tag: 'general', shouldFail: false, exist: false }, + { sender: owner, parameters: '-id 99999 -tag', id: 99999, tag: 'general', shouldFail: false, exist: false }, + + { sender: owner, parameters: '-tag lorem ipsum', id: 1, tag: 'lorem ipsum', shouldFail: false, exist: true }, + { sender: owner, parameters: '-tag general', id: 1, tag: 'general', shouldFail: false, exist: false }, +]; + +describe('Quotes - main() - @func3', () => { + for (const test of tests) { + let id, r; + describe(test.parameters, async () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await AppDataSource.getRepository(User).save({ userName: user.userName, userId: user.userId }); + await AppDataSource.getRepository(User).save({ userName: owner.userName, userId: owner.userId }); + const quote = await quotes.add({ sender: test.sender, parameters: '-tags lorem ipsum -quote Lorem Ipsum', command: '!quote add' }); + id = quote[0].id; + if (test.id === 1) { + test.id = id; + } + test.parameters = test.parameters.replace('$id', id); + }); + + it('Run !quote', async () => { + r = await quotes.main({ sender: test.sender, parameters: test.parameters, command: '!quote' }); + }); + if (test.shouldFail) { + it('Should throw error', async () => { + assert.strictEqual(r[0].response, `$sender, !quote is missing -id or -tag.`); + }); + } else { + if (test.exist) { + it('Should show quote', async () => { + assert.strictEqual(r[0].response, `Quote ${id} by __broadcaster__ 'Lorem Ipsum'`); + }); + } else { + it('Should sent not-found message', async () => { + assert(r[0].response === `$sender, no quotes with tag general was not found.` || r[0].response === `$sender, quote ${test.id} was not found.`); + }); + } + } + }); + } +}); diff --git a/backend/test/tests/raffles/allowOverTicketing.js b/backend/test/tests/raffles/allowOverTicketing.js new file mode 100644 index 000000000..d83e783c2 --- /dev/null +++ b/backend/test/tests/raffles/allowOverTicketing.js @@ -0,0 +1,133 @@ + +/* global describe it before */ + +import('../../general.js'); + +import { db, message, user } from '../../general.js'; +import * as commons from '../../../dest/commons.js' + +import { User } from '../../../dest/database/entity/user.js'; +import { Raffle } from '../../../dest/database/entity/raffle.js'; + +import raffles from '../../../dest/systems/raffles.js'; + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +describe('Raffles - allowOverTicketing - @func1', () => { + describe('Disabled allowOverTicketing', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.allowOverTicketing = false; + }); + + it('create ticket raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme -min 0 -max 500' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-500>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('Update viewer to have 10 points', async () => { + await AppDataSource.getRepository(User).save({ userName: user.viewer.userName, userId: user.viewer.userId, points: 10 }); + }); + + it('Viewer bets over 10 points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 100' }); + assert(a === false); + }); + + it('expecting 0 participant', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: null, isClosed: false }, + }); + assert(raffle.participants.length === 0); + }); + + it('Viewer bets 10 points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 10' }); + assert(a); + }); + + it('expecting 1 participant', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: null, isClosed: false }, + }); + assert(raffle.participants.length === 1); + }); + + it('Participant bet 10 points', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: null, isClosed: false }, + }); + assert(raffle.participants[0].tickets === 10, `${raffle.participants[0].tickets} != 10`); + }); + }); + + describe('Enabled allowOverTicketing', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.allowOverTicketing = true; + }); + + after(() => { + raffles.allowOverTicketing = false; + }); + + it('create ticket raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme -min 0 -max 500' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-500>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('Update viewer to have 10 points', async () => { + await AppDataSource.getRepository(User).save({ userName: user.viewer.userName, userId: user.viewer.userId, points: 10 }); + }); + + it('Viewer bets over 10 points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 100' }); + assert(a); + }); + + it('expecting 1 participant', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: null, isClosed: false }, + }); + assert(raffle.participants.length === 1); + }); + + it('Participant bet 10 points', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: null, isClosed: false }, + }); + assert(raffle.participants[0].tickets === 10, `${raffle.participants[0].tickets} != 10`); + }); + + it('Viewer bets 10 points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 10' }); + assert(a); + }); + + it('expecting 1 participant', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: null, isClosed: false }, + }); + assert(raffle.participants.length === 1); + }); + + it('Participant bet 10 points', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: null, isClosed: false }, + }); + assert(raffle.participants[0].tickets === 10, `${raffle.participants[0].tickets} != 10`); + }); + }); +}); diff --git a/backend/test/tests/raffles/bug#3546.js b/backend/test/tests/raffles/bug#3546.js new file mode 100644 index 000000000..2a38d438a --- /dev/null +++ b/backend/test/tests/raffles/bug#3546.js @@ -0,0 +1,68 @@ + +/* global*/ + +import('../../general.js'); +import assert from 'assert'; +import { IsNull } from 'typeorm'; + +import * as commons from '../../../dest/commons.js' +import { AppDataSource } from '../../../dest/database.js'; +import { Raffle } from '../../../dest/database/entity/raffle.js'; +import { User } from '../../../dest/database/entity/user.js'; +import * as changelog from '../../../dest/helpers/user/changelog.js'; +import raffles from '../../../dest/systems/raffles.js'; +import { db, message, user } from '../../general.js'; + +describe('Raffles - raffle with 1 point cannot over point #3546 - @func2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.allowOverTicketing = true; + }); + + it('create ticket raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme -min 0 -max 500' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-500>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('Update viewer to have 1 point', async () => { + await AppDataSource.getRepository(User).save({ + userName: user.viewer.userName, userId: user.viewer.userId, points: 1, + }); + }); + + it('Viewer bets over 10 points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 100' }); + assert(a); + }); + + it('expecting 1 participant to have bet of 1', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert(raffle.participants.length === 1); + assert(raffle.participants[0].tickets === 1); + }); + + it('Viewer bets over 10 points again', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 100' }); + assert(a); + }); + + it('expecting 1 participant to have bet of 1', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert(raffle.participants.length === 1); + assert(raffle.participants[0].tickets === 1); + }); + + it('User should have 0 points', async () => { + await changelog.flush(); + const result = await AppDataSource.getRepository(User).findOneBy({ userName: user.viewer.userName, userId: user.viewer.userId }); + assert(result.points === 0); + }); +}); diff --git a/backend/test/tests/raffles/bug#3547.js b/backend/test/tests/raffles/bug#3547.js new file mode 100644 index 000000000..ac9ca63d1 --- /dev/null +++ b/backend/test/tests/raffles/bug#3547.js @@ -0,0 +1,54 @@ + +/* global */ + +import('../../general.js'); +import assert from 'assert'; +import { IsNull } from 'typeorm'; + +import * as commons from '../../../dest/commons.js' +import { AppDataSource } from '../../../dest/database.js'; +import { Raffle } from '../../../dest/database/entity/raffle.js'; +import { User } from '../../../dest/database/entity/user.js'; +import * as changelog from '../../../dest/helpers/user/changelog.js'; +import raffles from '../../../dest/systems/raffles.js'; +import { db, message, user } from '../../general.js'; + +describe('Raffles - over max limit points not adding to raffle #3547 - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.allowOverTicketing = true; + }); + + it('create ticket raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme -min 0 -max 100' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-100>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('Update viewer to have 1000 points', async () => { + await AppDataSource.getRepository(User).save({ + userName: user.viewer.userName, userId: user.viewer.userId, points: 1000, + }); + }); + + it('Viewer bets over max points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 1000' }); + assert(a); + }); + + it('expecting 1 participant to have bet of 100', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert(raffle.participants.length === 1, 'Participant not found in raffle - ' + JSON.stringify(raffle.participants)); + assert(raffle.participants[0].tickets === 100, `Participant doesn't have correct points - ${raffle.participants[0].tickets} === 100`); + }); + + it('User should have 900 points', async () => { + await changelog.flush(); + const result = await AppDataSource.getRepository(User).findOneBy({ userName: user.viewer.userName, userId: user.viewer.userId }); + assert(result.points === 900); + }); +}); diff --git a/backend/test/tests/raffles/bug#3587.js b/backend/test/tests/raffles/bug#3587.js new file mode 100644 index 000000000..9db373b4b --- /dev/null +++ b/backend/test/tests/raffles/bug#3587.js @@ -0,0 +1,106 @@ + +/* global */ + +import('../../general.js'); +import assert from 'assert'; +import { IsNull } from 'typeorm'; + +import * as commons from '../../../dest/commons.js' +import { AppDataSource } from '../../../dest/database.js'; +import { Raffle } from '../../../dest/database/entity/raffle.js'; +import { User } from '../../../dest/database/entity/user.js'; +import * as changelog from '../../../dest/helpers/user/changelog.js'; +import raffles from '../../../dest/systems/raffles.js'; +import { db, message, user } from '../../general.js'; + +describe('Raffles - user will lose points when join raffle with number and all #3587 - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.allowOverTicketing = true; + }); + + it('create ticket raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme -min 0 -max 100' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-100>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('Update viewer and viewer2 to have 200 points', async () => { + await AppDataSource.getRepository(User).save({ + userName: user.viewer.userName, userId: user.viewer.userId, points: 200, + }); + await AppDataSource.getRepository(User).save({ + userName: user.viewer2.userName, userId: user.viewer2.userId, points: 200, + }); + }); + + it('Viewer bets max allowed points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 100' }); + assert(a); + }); + + it('Viewer2 bets 50 points', async () => { + const a = await raffles.participate({ sender: user.viewer2, message: '!winme 50' }); + assert(a); + }); + + it('expecting 2 participants to have bet of 100 and 50', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: null, isClosed: false }, + }); + assert.strictEqual(raffle.participants.length, 2); + try { + assert.strictEqual(raffle.participants[0].tickets, 100); + assert.strictEqual(raffle.participants[1].tickets, 50); + } catch (e) { + assert.strictEqual(raffle.participants[0].tickets, 50); + assert.strictEqual(raffle.participants[1].tickets, 100); + } + }); + + it('expecting viewer to have 100 points', async () => { + await changelog.flush(); + const userFromDb = await AppDataSource.getRepository(User).findOne({ where: { userName: user.viewer.userName } }); + assert.strictEqual(userFromDb.points, 100); + }); + + it('expecting viewer2 to have 150 points', async () => { + await changelog.flush(); + const userFromDb = await AppDataSource.getRepository(User).findOne({ where: { userName: user.viewer2.userName } }); + assert.strictEqual(userFromDb.points, 150); + }); + + it('Viewer bets max points again with all', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme all' }); + assert(a); + }); + + it('Viewer2 bets max points with all', async () => { + const a = await raffles.participate({ sender: user.viewer2, message: '!winme all' }); + assert(a); + }); + + it('expecting 2 participants to have bet of 100', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert.strictEqual(raffle.participants.length, 2); + assert.strictEqual(raffle.participants[0].tickets, 100); + assert.strictEqual(raffle.participants[1].tickets, 100); + }); + + it('expecting viewer to still have 100 points', async () => { + await changelog.flush(); + const userFromDb = await AppDataSource.getRepository(User).findOne({ where: { userName: user.viewer.userName } }); + assert.strictEqual(userFromDb.points, 100); + }); + + it('expecting viewer2 to have 100 points', async () => { + await changelog.flush(); + const userFromDb = await AppDataSource.getRepository(User).findOne({ where: { userName: user.viewer2.userName } }); + assert.strictEqual(userFromDb.points, 100); + }); +}); diff --git a/backend/test/tests/raffles/community#32.js b/backend/test/tests/raffles/community#32.js new file mode 100644 index 000000000..e830078f0 --- /dev/null +++ b/backend/test/tests/raffles/community#32.js @@ -0,0 +1,49 @@ +/* global describe it before */ + +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { IsNull } from 'typeorm'; +import { AppDataSource } from '../../../dest/database.js'; + +import { Raffle } from '../../../dest/database/entity/raffle.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { getOwnerAsSender } from '../../../dest/helpers/commons/getOwnerAsSender.js'; +import raffles from '../../../dest/systems/raffles.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const max = Math.floor(Number.MAX_SAFE_INTEGER / 10000000); + +const owner = { userName: '__broadcaster__', userId: String(_.random(999999, false)) }; +const testuser = { userName: 'testuser', userId: String(_.random(999999, false)) }; +const testuser2 = { userName: 'testuser2', userId: String(_.random(999999, false)) }; + +describe('/t/raffle-owner-can-join-raffle-more-then-1-time/32 - @func2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('create normal raffle', async () => { + raffles.open({ sender: owner, parameters: '!winme' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('loop through owner participations', async () => { + for (let i = 0; i < 100; i++) { + const a = await raffles.participate({ sender: getOwnerAsSender(), message: `!winme` }); + assert(a); + } + }); + + it('expecting only one participator', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert(raffle.participants.length === 1); + }); +}); diff --git a/backend/test/tests/raffles/community#38.js b/backend/test/tests/raffles/community#38.js new file mode 100644 index 000000000..5fe000a81 --- /dev/null +++ b/backend/test/tests/raffles/community#38.js @@ -0,0 +1,72 @@ +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { IsNull } from 'typeorm'; + +import * as commons from '../../../dest/commons.js' +import { AppDataSource } from '../../../dest/database.js'; +import { Raffle } from '../../../dest/database/entity/raffle.js'; +import { User } from '../../../dest/database/entity/user.js'; +import raffles from '../../../dest/systems/raffles.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +const owner = { userName: '__broadcaster__', userId: String(_.random(999999, false)) }; + +describe('/t/raffle-everyone-can-join-even-raffle-runned-for-subscribers/38 - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('Create subscribers raffle', async () => { + raffles.open({ sender: owner, parameters: '!winme -for subscribers.' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme". Raffle is opened for subscribers.', { userName: '__bot__' }); + }); + + const users = ['user1', 'user2']; + for (const [id, v] of Object.entries(users)) { + it('Add user ' + v + ' to db', async () => { + await AppDataSource.getRepository(User).save({ userName: v , userId: String('100' + id) }); + }); + + it('Add user ' + v + ' to raffle should fail', async () => { + const a = await raffles.participate({ sender: { userName: v, userId: String('100' + id) }, message: '!winme' }); + assert(!a); + }); + + it('User should not be in raffle', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + + assert(typeof raffle.participants.find(o => o.userName === v) === 'undefined'); + }); + } + + const subs = ['sub1', 'sub2']; + for (const [id, v] of Object.entries(subs)) { + it('Add user ' + v + ' to db', async () => { + await AppDataSource.getRepository(User).save({ + userName: v , userId: String('100' + id), isSubscriber: true, + }); + }); + + it('Add user ' + v + ' to raffle', async () => { + const a = await raffles.participate({ sender: { userName: v, userId: String('100' + id) }, message: '!winme' }); + assert(a); + }); + + it('User should be in raffle', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + + assert(typeof raffle.participants.find(o => o.username === v) !== 'undefined'); + }); + } +}); diff --git a/backend/test/tests/raffles/cumulativeTickets.js b/backend/test/tests/raffles/cumulativeTickets.js new file mode 100644 index 000000000..dd0d50330 --- /dev/null +++ b/backend/test/tests/raffles/cumulativeTickets.js @@ -0,0 +1,97 @@ + +/* global describe it before */ + +import('../../general.js'); + +import { db, message, user } from '../../general.js'; +import * as commons from '../../../dest/commons.js' + +import { User } from '../../../dest/database/entity/user.js'; +import { Raffle } from '../../../dest/database/entity/raffle.js'; + +import raffles from '../../../dest/systems/raffles.js'; + +import assert from 'assert'; +import { IsNull } from 'typeorm'; +import { AppDataSource } from '../../../dest/database.js'; + +describe('Raffles - cumulativeTickets - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.allowOverTicketing = true; + }); + + it('create ticket raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme -min 0 -max 500' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-500>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('Update viewer to have 25 points', async () => { + await AppDataSource.getRepository(User).save({ userName: user.viewer.userName, userId: user.viewer.userId, points: 25 }); + }); + + it('Viewer bets 10 points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 10' }); + assert(a); + }); + + it('expecting 1 participant', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert(raffle.participants.length === 1); + }); + + it('Participant bet 10 points', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert(raffle.participants[0].tickets === 10, `${raffle.participants[0].tickets} != 10`); + }); + + it('Viewer bets another 10 points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 10' }); + assert(a); + }); + + it('expecting 1 participant', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert(raffle.participants.length === 1); + }); + + it('Participant bet 20 points', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert(raffle.participants[0].tickets === 20, `${raffle.participants[0].tickets} != 20`); + }); + + it('Viewer bets another 10 points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 10' }); + assert(a); + }); + + it('expecting 1 participant', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert(raffle.participants.length === 1); + }); + + it('Participant bet 25 points', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert(raffle.participants[0].tickets === 25, `${raffle.participants[0].tickets} != 25`); + }); +}); diff --git a/backend/test/tests/raffles/feat#4174_announceEntriesIfSet.js b/backend/test/tests/raffles/feat#4174_announceEntriesIfSet.js new file mode 100644 index 000000000..5081e564c --- /dev/null +++ b/backend/test/tests/raffles/feat#4174_announceEntriesIfSet.js @@ -0,0 +1,124 @@ + +/* global describe it before */ + +import('../../general.js'); + +import { db, message, user } from '../../general.js'; +import * as commons from '../../../dest/commons.js' + +import { User } from '../../../dest/database/entity/user.js'; +import { Raffle } from '../../../dest/database/entity/raffle.js'; + +import raffles from '../../../dest/systems/raffles.js'; + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; +import { IsNull } from 'typeorm'; + +describe('Raffles - announce entries if set #4174 - @func2', () => { + describe('ticket raffle', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.announceNewEntries = true; + raffles.announceNewEntriesBatchTime = 10; + }); + + it('create ticket raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme -min 0 -max 100' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-100>". Raffle is opened for everyone.', { userName: '__bot__' }) + }); + + it('Update viewer, viewer2, mod to have 200 points', async () => { + await AppDataSource.getRepository(User).save({ userName: user.viewer.userName, userId: user.viewer.userId, points: 200 }); + await AppDataSource.getRepository(User).save({ userName: user.viewer2.userName, userId: user.viewer2.userId, points: 200 }); + await AppDataSource.getRepository(User).save({ userName: user.mod.userName, userId: user.mod.userId, points: 200 }); + }); + + it('Viewer bets max points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 100' }); + assert(a); + }); + + it('Viewer2 bets 50 points', async () => { + const a = await raffles.participate({ sender: user.viewer2, message: '!winme 50' }); + assert(a); + }); + + it('expecting 2 participants to have bet of 100 and 50', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert.strictEqual(raffle.participants.length, 2); + try { + assert.strictEqual(raffle.participants[0].tickets, 100); + assert.strictEqual(raffle.participants[1].tickets, 50); + } catch (e) { + assert.strictEqual(raffle.participants[0].tickets, 50); + assert.strictEqual(raffle.participants[1].tickets, 100); + } + }); + + it('expecting 150 entries in announce message', async () => { + await raffles.announceEntries(); + await message.isSentRaw('Added 150 entries to raffle (150 total). To enter type "!winme <1-100>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('Mod bets 50 points', async () => { + const a = await raffles.participate({ sender: user.mod, message: '!winme 50' }); + assert(a); + }); + + it('expecting 50 new entries and 200 total in announce message', async () => { + await raffles.announceEntries(); + await message.isSentRaw('Added 50 entries to raffle (200 total). To enter type "!winme <1-100>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + }); + + describe('normal raffle', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.announceNewEntries = true; + raffles.announceNewEntriesBatchTime = 10; + }); + + it('create raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme". Raffle is opened for everyone.', { userName: '__bot__' }) + }); + + it('2 viewers participate in raffle', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme' }); + assert(a); + const b = await raffles.participate({ sender: user.viewer2, message: '!winme' }); + assert(b); + }); + + it('expecting 2 participants in db', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert.strictEqual(raffle.participants.length, 2); + }); + + it('expecting 2 entries in announce message', async () => { + await raffles.announceEntries(); + await message.isSentRaw('Added 2 entries to raffle (2 total). To enter type "!winme". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('1 viewers participate in raffle', async () => { + const a = await raffles.participate({ sender: user.mod, message: '!winme' }); + assert(a); + }); + + it('expecting 1 new entry and 3 total in announce message', async () => { + await raffles.announceEntries(); + await message.isSentRaw('Added 1 entry to raffle (3 total). To enter type "!winme". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + }); +}); diff --git a/backend/test/tests/raffles/feat#4175_raffleAnnounceShouldContainTotalEntries.js b/backend/test/tests/raffles/feat#4175_raffleAnnounceShouldContainTotalEntries.js new file mode 100644 index 000000000..9a2c6aee1 --- /dev/null +++ b/backend/test/tests/raffles/feat#4175_raffleAnnounceShouldContainTotalEntries.js @@ -0,0 +1,74 @@ + +/* global describe it before */ + +import('../../general.js'); + +import { db, message, user } from '../../general.js'; +import * as commons from '../../../dest/commons.js' + +import { User } from '../../../dest/database/entity/user.js'; +import { Raffle } from '../../../dest/database/entity/raffle.js'; + +import raffles from '../../../dest/systems/raffles.js'; +import {isStreamOnline} from '../../../dest/helpers/api/isStreamOnline.js' +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; +import { IsNull } from 'typeorm'; + +describe('Raffles - announce should contain total entries #4175 - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.allowOverTicketing = true; + }); + + after(async () => { + raffles.raffleAnnounceMessageInterval = 20; + isStreamOnline.value = false; + }) + + it('create ticket raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme -min 0 -max 100' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-100>". Raffle is opened for everyone.', { userName: '__bot__' }) + }); + + it('Update viewer and viewer2 to have 200 points', async () => { + await AppDataSource.getRepository(User).save({ userName: user.viewer.userName, userId: user.viewer.userId, points: 200 }); + await AppDataSource.getRepository(User).save({ userName: user.viewer2.userName, userId: user.viewer2.userId, points: 200 }); + }); + + it('Viewer bets max points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 100' }); + assert(a); + }); + + it('Viewer2 bets 50 points', async () => { + const a = await raffles.participate({ sender: user.viewer2, message: '!winme 50' }); + assert(a); + }); + + it('expecting 2 participants to have bet of 100 and 50', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert.strictEqual(raffle.participants.length, 2); + try { + assert.strictEqual(raffle.participants[0].tickets, 100); + assert.strictEqual(raffle.participants[1].tickets, 50); + } catch (e) { + assert.strictEqual(raffle.participants[0].tickets, 50); + assert.strictEqual(raffle.participants[1].tickets, 100); + } + }); + + it('expecting 2 entries in announce message', async () => { + isStreamOnline.value = true; + raffles.lastAnnounceMessageCount = 0; + raffles.lastAnnounce = 0; + raffles.raffleAnnounceMessageInterval = 0; + await raffles.announce(); + await message.isSentRaw('Raffle is running (150 entries). To enter type "!winme <1-100>". Raffle is opened for everyone.', { userName: '__bot__' }) + }); +}); diff --git a/backend/test/tests/raffles/feat#4176_raffleAnnounceShouldContainDeleteInfo.js b/backend/test/tests/raffles/feat#4176_raffleAnnounceShouldContainDeleteInfo.js new file mode 100644 index 000000000..16c651b01 --- /dev/null +++ b/backend/test/tests/raffles/feat#4176_raffleAnnounceShouldContainDeleteInfo.js @@ -0,0 +1,76 @@ + +/* global describe it before */ + +import('../../general.js'); + +import { db, message, user } from '../../general.js'; +import * as commons from '../../../dest/commons.js' + +import { User } from '../../../dest/database/entity/user.js'; +import { Raffle } from '../../../dest/database/entity/raffle.js'; + +import raffles from '../../../dest/systems/raffles.js'; +import { isStreamOnline } from '../../../dest/helpers/api/isStreamOnline.js' + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; +import { IsNull } from 'typeorm'; + +describe('Raffles - announce should contain delete info #4176 - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.deleteRaffleJoinCommands = true; + }); + + after(async () => { + raffles.raffleAnnounceMessageInterval = 20; + isStreamOnline.value = false; + raffles.deleteRaffleJoinCommands = false; + }) + + it('create ticket raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme -min 0 -max 100' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-100>". Raffle is opened for everyone. Your raffle messages will be deleted on join.', { userName: '__bot__' }) + }); + + it('Update viewer and viewer2 to have 200 points', async () => { + await AppDataSource.getRepository(User).save({ userName: user.vieweruserName, userId: user.viewer.userId, points: 200 }); + await AppDataSource.getRepository(User).save({ userName: user.viewer2userName, userId: user.viewer2.userId, points: 200 }); + }); + + it('Viewer bets max points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 100' }); + assert(a); + }); + + it('Viewer2 bets 50 points', async () => { + const a = await raffles.participate({ sender: user.viewer2, message: '!winme 50' }); + assert(a); + }); + + it('expecting 2 participants to have bet of 100 and 50', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert.strictEqual(raffle.participants.length, 2); + try { + assert.strictEqual(raffle.participants[0].tickets, 100); + assert.strictEqual(raffle.participants[1].tickets, 50); + } catch (e) { + assert.strictEqual(raffle.participants[0].tickets, 50); + assert.strictEqual(raffle.participants[1].tickets, 100); + } + }); + + it('expecting 2 entries in announce message', async () => { + isStreamOnline.value = true; + raffles.lastAnnounceMessageCount = 0; + raffles.lastAnnounce = 0; + raffles.raffleAnnounceMessageInterval = 0; + await raffles.announce(); + await message.isSentRaw('Raffle is running (150 entries). To enter type "!winme <1-100>". Raffle is opened for everyone. Your raffle messages will be deleted on join.', { userName: '__bot__' }) + }); +}); diff --git a/backend/test/tests/raffles/pick.js b/backend/test/tests/raffles/pick.js new file mode 100644 index 000000000..3da563818 --- /dev/null +++ b/backend/test/tests/raffles/pick.js @@ -0,0 +1,148 @@ +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { AppDataSource } from '../../../dest/database.js'; + +import { Raffle } from '../../../dest/database/entity/raffle.js'; +import { User } from '../../../dest/database/entity/user.js'; +import raffles from '../../../dest/systems/raffles.js'; +import { message } from '../../general.js'; +import { db } from '../../general.js'; + +const max = Math.floor(Number.MAX_SAFE_INTEGER / 10000000); + +const owner = { userName: '__broadcaster__', userId: String(_.random(999999, false)) }; +const testuser = { userName: 'testuser', userId: String(_.random(999999, false)) }; +const testuser2 = { userName: 'testuser2', userId: String(_.random(999999, false)) }; + +describe('Raffles - pick() - @func2', () => { + let user1, user2; + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + describe('Empty raffle with pick should be closed', () => { + it('create ticket raffle', async () => { + raffles.open({ sender: owner, parameters: '!winme -min 0 -max ' + max }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-' + max + '>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('pick a winner', async () => { + const r = await raffles.pick({ sender: owner }); + const raffle = (await AppDataSource.getRepository(Raffle).find({ order: { timestamp: 'DESC' } }))[0]; + assert.strictEqual(r[0].response, '$sender, nobody joined a raffle'); + assert(raffle.isClosed); + assert(raffle.winner === null); + }); + }); + + describe('#1318 - 4 subs should have 25% win', () => { + it('Set subscribers luck to 150%', async () => { + raffles.subscribersPercent = 150; + }); + + it('Create subscribers raffle', async () => { + raffles.open({ sender: owner, parameters: '!winme -for subscribers.' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme". Raffle is opened for subscribers.', { userName: '__bot__' }); + }); + + const subs = ['sub1', 'sub2', 'sub3', 'sub4']; + for (const [id, v] of Object.entries(subs)) { + it('Add user ' + v + ' to db', async () => { + await AppDataSource.getRepository(User).save({ + userName: v , userId: String('100' + id), isSubscriber: true, + }); + }); + + it('Add user ' + v + ' to raffle', async () => { + const a = await raffles.participate({ sender: { userName: v, userId: String('100' + id) }, message: '!winme' }); + assert(a); + }); + } + + it('pick a winner', async () => { + await raffles.pick({ sender: owner }); + await message.isSentRaw([ + 'Winner of raffle !winme is @sub1! Win probability was 25%!', + 'Winner of raffle !winme is @sub2! Win probability was 25%!', + 'Winner of raffle !winme is @sub3! Win probability was 25%!', + 'Winner of raffle !winme is @sub4! Win probability was 25%!', + ], { userName: '__bot__' }); + }); + }); + + describe('Raffle should return winner', () => { + it('create ticket raffle', async () => { + raffles.open({ sender: owner, parameters: '!winme -min 0 -max ' + max }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-'+max+'>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('Create testuser/testuser2 with max points', async () => { + await AppDataSource.getRepository(User).delete({ userName: testuser.userName }); + await AppDataSource.getRepository(User).delete({ userName: testuser2.userName }); + user1 = await AppDataSource.getRepository(User).save({ + userName: testuser.userName , userId: testuser.userId, points: max, + }); + user2 = await AppDataSource.getRepository(User).save({ + userName: testuser2.userName , userId: testuser2.userId, points: max, + }); + }); + + it('testuser bets max', async () => { + const a = await raffles.participate({ sender: testuser, message: `!winme ${max}` }); + assert(a); + }); + + it('testuser2 bets half of max', async () => { + const a = await raffles.participate({ sender: testuser2, message: `!winme ${max / 2}` }); + assert(a); + }); + + it('pick a winner', async () => { + await raffles.pick({ sender: owner }); + await message.isSentRaw([ + 'Winner of raffle !winme is @' + testuser.userName + '! Win probability was 66.67%!', + 'Winner of raffle !winme is @' + testuser2.userName + '! Win probability was 33.33%!', + ], { userName: '__bot__' }); + }); + }); + + describe('Raffle with subscriber should return winner', () => { + it('create ticket raffle', async () => { + raffles.open({ sender: owner, parameters: '!winme -min 0 -max ' + max }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-'+max+'>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('Create testuser/testuser2 with max points', async () => { + await AppDataSource.getRepository(User).delete({ userName: testuser.userName }); + await AppDataSource.getRepository(User).delete({ userName: testuser2.userName }); + user1 = await AppDataSource.getRepository(User).save({ + isSubscriber: true, userName: testuser.userName , userId: testuser.userId, points: max, + }); + user2 = await AppDataSource.getRepository(User).save({ + userName: testuser2.userName , userId: testuser2.userId, points: max, + }); + }); + + it('testuser bets 100', async () => { + const a = await raffles.participate({ sender: testuser, message: '!winme 100' }); + assert(a); + }); + + it('testuser2 bets 100', async () => { + const a = await raffles.participate({ sender: testuser2, message: '!winme 100' }); + assert(a); + }); + + it('pick a winner', async () => { + await raffles.pick({ sender: owner }); + await message.isSentRaw([ + 'Winner of raffle !winme is @' + testuser.userName + '! Win probability was 60%!', + 'Winner of raffle !winme is @' + testuser2.userName + '! Win probability was 40%!', + ], { userName: '__bot__' }); + }); + }); +}); diff --git a/backend/test/tests/raffles/severalRaffleJoinsShouldntGoOverMax.js b/backend/test/tests/raffles/severalRaffleJoinsShouldntGoOverMax.js new file mode 100644 index 000000000..d62801ca7 --- /dev/null +++ b/backend/test/tests/raffles/severalRaffleJoinsShouldntGoOverMax.js @@ -0,0 +1,79 @@ + +/* global */ + +import('../../general.js'); +import assert from 'assert'; +import { IsNull } from 'typeorm'; + +import * as commons from '../../../dest/commons.js' +import { AppDataSource } from '../../../dest/database.js'; +import { Raffle } from '../../../dest/database/entity/raffle.js'; +import { User } from '../../../dest/database/entity/user.js'; +import * as changelog from '../../../dest/helpers/user/changelog.js'; +import raffles from '../../../dest/systems/raffles.js'; +import { db, message, user } from '../../general.js'; + +describe('Raffles - several raffle joins shouldnt go over max - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + raffles.allowOverTicketing = true; + }); + + it('create ticket raffle', async () => { + raffles.open({ sender: user.owner, parameters: '!winme -min 0 -max 100' }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-100>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('Update viewer and viewer2 to have 200 points', async () => { + await AppDataSource.getRepository(User).save({ + userName: user.viewer.userName, userId: user.viewer.userId, points: 200, + }); + await AppDataSource.getRepository(User).save({ + userName: user.viewer2.userName, userId: user.viewer2.userId, points: 200, + }); + }); + + it('Viewer bets max allowed points', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 100' }); + assert(a); + }); + + it('Viewer2 bets 50 points', async () => { + const a = await raffles.participate({ sender: user.viewer2, message: '!winme 50' }); + assert(a); + }); + + it('Viewer bets max allowed points - again', async () => { + const a = await raffles.participate({ sender: user.viewer, message: '!winme 100' }); + assert(a); + }); + + it('expecting 2 participants to have bet of 100 and 50', async () => { + const raffle = await AppDataSource.getRepository(Raffle).findOne({ + relations: ['participants'], + where: { winner: IsNull(), isClosed: false }, + }); + assert.strictEqual(raffle.participants.length, 2); + try { + assert.strictEqual(raffle.participants[0].tickets, 100); + assert.strictEqual(raffle.participants[1].tickets, 50); + } catch (e) { + assert.strictEqual(raffle.participants[0].tickets, 50); + assert.strictEqual(raffle.participants[1].tickets, 100); + } + }); + + it('expecting viewer to have 100 points', async () => { + await changelog.flush(); + const userFromDb = await AppDataSource.getRepository(User).findOne({ where: { userName: user.viewer.userName } }); + assert.strictEqual(userFromDb.points, 100); + }); + + it('expecting viewer2 to have 150 points', async () => { + await changelog.flush(); + const userFromDb = await AppDataSource.getRepository(User).findOne({ where: { userName: user.viewer2.userName } }); + assert.strictEqual(userFromDb.points, 150); + }); +}); diff --git a/backend/test/tests/raffles/ticketsBoundaries.js b/backend/test/tests/raffles/ticketsBoundaries.js new file mode 100644 index 000000000..a1048170d --- /dev/null +++ b/backend/test/tests/raffles/ticketsBoundaries.js @@ -0,0 +1,90 @@ +/* global describe it before */ + +import('../../general.js'); + +import assert from 'assert'; + +import _ from 'lodash-es'; +import { AppDataSource } from '../../../dest/database.js'; + +import { RaffleParticipant } from '../../../dest/database/entity/raffle.js'; +import { User } from '../../../dest/database/entity/user.js'; +import raffles from '../../../dest/systems/raffles.js'; +import { message } from '../../general.js'; +import { db } from '../../general.js'; + +const max = 100; + +const owner = { userName: '__broadcaster__', userId: String(_.random(999999, false)) }; +const testuser = { userName: 'testuser', userId: String(_.random(999999, false)) }; +const testuser2 = { userName: 'testuser2', userId: String(_.random(999999, false)) }; +const testuser3 = { userName: 'testuser3', userId: String(_.random(999999, false)) }; +const testuser4 = { userName: 'testuser4', userId: String(_.random(999999, false)) }; + +describe('Raffles - user should be able to compete within boundaries of tickets - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + raffles.allowOverTicketing = false; + }); + + it('create ticket raffle', async () => { + raffles.open({ sender: owner, parameters: '!winme -min 0 -max ' + max }); + await message.isSentRaw('Raffle is running (0 entries). To enter type "!winme <1-100>". Raffle is opened for everyone.', { userName: '__bot__' }); + }); + + it('create testuser/testuser2/testuser3 with max points', async () => { + await AppDataSource.getRepository(User).delete({ userName: testuser.userName }); + await AppDataSource.getRepository(User).delete({ userName: testuser2.userName }); + await AppDataSource.getRepository(User).delete({ userName: testuser3.userName }); + await AppDataSource.getRepository(User).delete({ userName: testuser4.userName }); + await AppDataSource.getRepository(User).save({ + userName: testuser.userName, userId: testuser.userId, points: max, + }); + await AppDataSource.getRepository(User).save({ + userName: testuser2.userName, userId: testuser2.userId, points: max, + }); + await AppDataSource.getRepository(User).save({ + userName: testuser3.userName, userId: testuser3.userId, points: max, + }); + await AppDataSource.getRepository(User).save({ + userName: testuser4.userName, userId: testuser4.userId, points: max, + }); + }); + + it('testuser bets min', async () => { + const a = await raffles.participate({ sender: testuser, message: '!winme 1' }); + assert.ok(a); + }); + + it('testuser2 bets max', async () => { + const a = await raffles.participate({ sender: testuser2, message: '!winme 100' }); + assert.ok(a); + }); + + it('testuser3 bets below min', async () => { + const a = await raffles.participate({ sender: testuser2, message: '!winme 0' }); + assert.ok(!a); + }); + + it('testuser4 bets above max', async () => { + const a = await raffles.participate({ sender: testuser2, message: '!winme 101' }); + assert.ok(!a); + }); + + it('we should have only 2 raffle participants', async () => { + assert.strictEqual(await AppDataSource.getRepository(RaffleParticipant).count(), 2); + }); + + for (const viewer of [testuser.userName, testuser2.userName]) { + it(`user ${viewer} should be in raffle participants`, async () => { + assert.strictEqual(await AppDataSource.getRepository(RaffleParticipant).countBy({ username: viewer }), 1); + }); + } + + for (const viewer of [testuser3.userName, testuser4.userName]) { + it(`user ${viewer} should not be in raffle participants`, async () => { + assert.strictEqual(await AppDataSource.getRepository(RaffleParticipant).countBy({ username: viewer }), 0); + }); + } +}); diff --git a/backend/test/tests/ranks/custom.js b/backend/test/tests/ranks/custom.js new file mode 100644 index 000000000..ae4dd5dde --- /dev/null +++ b/backend/test/tests/ranks/custom.js @@ -0,0 +1,38 @@ + +/* global describe it */ +import('../../general.js'); + +import { db } from '../../general.js'; +import { message, user } from '../../general.js'; +const { prepare, viewer, owner } = user +import assert from 'assert'; + +import ranks from '../../../dest/systems/ranks.js'; + +describe('Ranks - custom rank - @func2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await prepare(); + }); + + it(`Set custom rank 'My amazing rank'`, async () => { + const r = await ranks.set({ sender: owner, parameters: `${viewer.userName} My amazing rank` }); + assert.strictEqual(r[0].response, `$sender, you set My amazing rank to @__viewer__`); + }); + + it('Rank of user should be \'My amazing rank\'', async () => { + const r = await ranks.main({ sender: viewer }); + assert.strictEqual(r[0].response, '$sender, you have My amazing rank rank'); + }); + + it(`Unset custom rank`, async () => { + const r = await ranks.unset({ sender: owner, parameters: `${viewer.userName}` }); + assert.strictEqual(r[0].response, `$sender, custom rank for @__viewer__ was unset`); + }); + + it('Rank of user should be empty', async () => { + const r = await ranks.main({ sender: viewer }); + assert.strictEqual(r[0].response, '$sender, you don\'t have a rank yet'); + }); +}); diff --git a/backend/test/tests/scrim/workflow.js b/backend/test/tests/scrim/workflow.js new file mode 100644 index 000000000..9dce7a359 --- /dev/null +++ b/backend/test/tests/scrim/workflow.js @@ -0,0 +1,282 @@ +/* global describe it before */ +import assert from 'assert'; + +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; + +import getBotUserName from '../../../dest/helpers/user/getBotUserName.js' +import { translate } from '../../../dest/translate.js'; +import('../../general.js'); +import { db } from '../../general.js'; +import { message } from '../../general.js'; +// users +const owner = { userName: '__broadcaster__' }; + +describe('Scrim - full workflow', () => { + let scrim; + before(async () => { + scrim = (await import('../../../dest/systems/scrim.js')).default + }) + + describe('cooldown only - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + scrim.waitForMatchIdsInSeconds = 10; + }); + + it('Create cooldown only scrim for 1 minute', async () => { + scrim.main({ sender: owner, parameters: '-c duo 1' }); + }); + + it('Expecting 1 minute message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: 1, + type: 'duo', + unit: getLocalizedName(1, translate('core.minutes')), + }); + }); + + it('Expecting 45 seconds message cooldown', async () => { + await message.isSentRaw([ + 'Snipe match (duo) starting in 45 seconds', + ], getBotUserName(), 20000); + }); + + it('Expecting 30 seconds message cooldown', async () => { + await message.isSentRaw([ + 'Snipe match (duo) starting in 30 seconds', + ], getBotUserName(), 20000); + }); + + it('Expecting 15 seconds message cooldown', async () => { + await message.isSentRaw([ + 'Snipe match (duo) starting in 15 seconds', + ], getBotUserName(), 20000); + }); + + it('Expecting 3 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: '3.', + type: 'duo', + unit: '', + }, 19000); // still need high wait time, because its after 15s + }); + + it('Expecting 2 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: '2.', + type: 'duo', + unit: '', + }, 3000); + }); + + it('Expecting 1 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: '1.', + type: 'duo', + unit: '', + }, 3000); + }); + + it('Expecting go! message', async () => { + await message.isSent('systems.scrim.go', getBotUserName(), {}, 3000); + }); + + it('NOT expecting put match id in chat message', async () => { + await message.isNotSent('systems.scrim.putMatchIdInChat', getBotUserName(), { command: '!snipe match' }, 19000); + }); + + it('NOT expecting empty message list', async () => { + await message.isNotSent('systems.scrim.currentMatches', getBotUserName(), { matches: '<' + translate('core.empty') + '>' }, 19000); + }); + + it('Check match list by command', async () => { + const r = await scrim.match({ sender: { userName: 'test' }, parameters: '' }); + assert.strictEqual(r[0].response, 'Current Matches: '); + }); + }); + + describe('without matches - @func2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + scrim.waitForMatchIdsInSeconds = 10; + }); + + it('Create scrim for 1 minute', async () => { + scrim.main({ sender: owner, parameters: 'duo 1' }); + }); + + it('Expecting 1 minute message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: 1, + type: 'duo', + unit: getLocalizedName(1, translate('core.minutes')), + }); + }); + + it('Expecting 45 seconds message cooldown', async () => { + await message.isSentRaw([ + 'Snipe match (duo) starting in 45 seconds', + ], getBotUserName(), 20000); + }); + + it('Expecting 30 seconds message cooldown', async () => { + await message.isSentRaw([ + 'Snipe match (duo) starting in 30 seconds', + ], getBotUserName(), 20000); + }); + + it('Expecting 15 seconds message cooldown', async () => { + await message.isSentRaw([ + 'Snipe match (duo) starting in 15 seconds', + ], getBotUserName(), 20000); + }); + + it('Expecting 3 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: '3.', + type: 'duo', + unit: '', + }, 19000); // still need high wait time, because its after 15s + }); + + it('Expecting 2 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: '2.', + type: 'duo', + unit: '', + }, 3000); + }); + + it('Expecting 1 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: '1.', + type: 'duo', + unit: '', + }, 3000); + }); + + it('Expecting go! message', async () => { + await message.isSent('systems.scrim.go', getBotUserName(), {}, 3000); + }); + + it('Expecting put match id in chat message', async () => { + await message.isSent('systems.scrim.putMatchIdInChat', getBotUserName(), { command: '!snipe match' }, 19000); + }); + + it('Expecting empty message list', async () => { + await message.isSent('systems.scrim.currentMatches', getBotUserName(), { matches: '<' + translate('core.empty') + '>' }, 19000); + }); + + it('Check match list by command', async () => { + const r = await scrim.match({ sender: { userName: 'test' }, parameters: '' }); + assert.strictEqual(r[0].response, 'Current Matches: '); + }); + }); + + describe('with matches - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + scrim.waitForMatchIdsInSeconds = 10; + }); + + it('Create scrim for 1 minute', async () => { + scrim.main({ sender: owner, parameters: 'duo 1' }); + }); + + it('Expecting 1 minute message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: 1, + type: 'duo', + unit: getLocalizedName(1, translate('core.minutes')), + }); + }); + + it('Expecting 45 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: 45, + type: 'duo', + unit: getLocalizedName(45, translate('core.seconds')), + }, 19000); + }); + + it('Expecting 30 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: 30, + type: 'duo', + unit: getLocalizedName(30, translate('core.seconds')), + }, 19000); + }); + + it('Expecting 15 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: 15, + type: 'duo', + unit: getLocalizedName(15, translate('core.seconds')), + }, 19000); + }); + + it('Expecting 3 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: '3.', + type: 'duo', + unit: '', + }, 19000); // still need high wait time, because its after 15s + }); + + it('Expecting 2 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: '2.', + type: 'duo', + unit: '', + }, 3000); + }); + + it('Expecting 1 seconds message cooldown', async () => { + await message.isSent('systems.scrim.countdown', getBotUserName(), { + time: '1.', + type: 'duo', + unit: '', + }, 3000); + }); + + it('Expecting go! message', async () => { + await message.isSent('systems.scrim.go', getBotUserName(), {}, 3000); + }); + + it('Expecting put match id in chat message', async () => { + await message.isSent('systems.scrim.putMatchIdInChat', getBotUserName(), { command: '!snipe match' }, 19000); + }); + + for (const user of ['user1', 'user2', 'user3']) { + const matchId = 'ABC'; + it('Add ' + user + ' to match with id ' + matchId, async () => { + scrim.match({ + parameters: matchId, + sender: { userName: user, userId: String(Math.floor(Math.random() * 100000)) }, + }); + }); + } + + it('Add user4 to match with id ABD', async () => { + scrim.match({ + parameters: 'ABD', + sender: { userName: 'user4' }, + }); + }); + + it('Expecting populated message list', async () => { + await message.isSent('systems.scrim.currentMatches', getBotUserName(), + { matches: 'ABC - @user1, @user2, @user3 | ABD - @user4' }, 19000); + }); + + it('Check match list by command', async () => { + const r = await scrim.match({ sender: { userName: 'test' }, parameters: '' }); + assert.strictEqual(r[0].response, 'Current Matches: ABC - @user1, @user2, @user3 | ABD - @user4'); + }); + }); +}); diff --git a/backend/test/tests/songs/addSongToQueue.js b/backend/test/tests/songs/addSongToQueue.js new file mode 100644 index 000000000..932054efa --- /dev/null +++ b/backend/test/tests/songs/addSongToQueue.js @@ -0,0 +1,326 @@ + +/* global */ +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' + +import { SongRequest, SongPlaylist } from '../../../dest/database/entity/song.js'; +import { user } from '../../general.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +describe('Songs - addSongToQueue() - @func1', () => { + let songs; + before(async () => { + songs = (await import('../../../dest/systems/songs.js')).default; + }); + describe('Add music song by videoId', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = false; + songs.allowRequestsOnlyFromPlaylist = false; + }); + const videoId = 'bmQwZhcZkbU'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add music song ${videoId}`, async () => { + const r = await songs.addSongToQueue({ parameters: videoId, sender: user.owner }); + assert.strictEqual(r[0].response, '$sender, song The Witcher 3 - Steel for Humans / Lazare (Gingertail Cover) was added to queue'); + }); + + it(`Queue contains song`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 1); + }); + }); + + describe('Add music song by url', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = false; + songs.allowRequestsOnlyFromPlaylist = false; + }); + const videoUrl = 'https://www.youtube.com/watch?v=bmQwZhcZkbU'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add music song ${videoUrl}`, async () => { + const r = await songs.addSongToQueue({ parameters: videoUrl, sender: user.owner }); + assert.strictEqual(r[0].response, '$sender, song The Witcher 3 - Steel for Humans / Lazare (Gingertail Cover) was added to queue'); + }); + + it(`Queue contains song`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 1); + }); + }); + + describe('Add music song by search string', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = false; + songs.allowRequestsOnlyFromPlaylist = false; + }); + const videoSearch = 'The Witcher 3 - Steel for Humans / Lazare (Gingertail Cover)'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add music song ${videoSearch}`, async () => { + const r = await songs.addSongToQueue({ parameters: videoSearch, sender: user.owner }); + assert.strictEqual(r[0].response, '$sender, song The Witcher 3 - Steel for Humans / Lazare (Gingertail Cover) was added to queue'); + }); + + it(`Queue contains song`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 1); + }); + }); + + describe('Add music song by videoId - music only', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = true; + songs.allowRequestsOnlyFromPlaylist = false; + }); + const videoId = 'bmQwZhcZkbU'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add music song ${videoId}`, async () => { + const r = await songs.addSongToQueue({ parameters: videoId, sender: user.owner }); + assert.strictEqual(r[0].response, '$sender, song The Witcher 3 - Steel for Humans / Lazare (Gingertail Cover) was added to queue'); + }); + + it(`Queue contains song`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 1); + }); + }); + + describe('Add music song by url - music only', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = true; + songs.allowRequestsOnlyFromPlaylist = false; + }); + const videoUrl = 'https://www.youtube.com/watch?v=bmQwZhcZkbU'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add music song ${videoUrl}`, async () => { + const r = await songs.addSongToQueue({ parameters: videoUrl, sender: user.owner }); + assert.strictEqual(r[0].response, '$sender, song The Witcher 3 - Steel for Humans / Lazare (Gingertail Cover) was added to queue'); + }); + + it(`Queue contains song`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 1); + }); + }); + + describe('Add music song by search string - music only', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = true; + songs.allowRequestsOnlyFromPlaylist = false; + }); + const videoSearch = 'The Witcher 3 - Steel for Humans / Lazare (Gingertail Cover)'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add music song ${videoSearch}`, async () => { + const r = await songs.addSongToQueue({ parameters: videoSearch, sender: user.owner }); + assert.strictEqual(r[0].response, '$sender, song The Witcher 3 - Steel for Humans / Lazare (Gingertail Cover) was added to queue'); + }); + + it(`Queue contains song`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 1); + }); + }); + + describe('Add non-music video by videoId - music only', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = true; + songs.allowRequestsOnlyFromPlaylist = false; + }); + const videoId = 'RwtZrI6HuwY'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add non-music video ${videoId}`, async () => { + const r = await songs.addSongToQueue({ parameters: videoId, sender: user.owner }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this song must be music category'); + }); + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + }); + + describe('Add non-music video by url - music only', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = true; + songs.allowRequestsOnlyFromPlaylist = false; + }); + const videoUrl = 'https://www.youtube.com/watch?v=RwtZrI6HuwY'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add non-music video ${videoUrl}`, async () => { + const r = await songs.addSongToQueue({ parameters: videoUrl, sender: user.owner }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this song must be music category'); + }); + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + }); + + describe('Add non-music video by search string - music only', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = true; + songs.allowRequestsOnlyFromPlaylist = false; + }); + const videoSearch = 'Annoying customers after closing time - In and Out'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add non-music video ${videoSearch}`, async () => { + const r = await songs.addSongToQueue({ parameters: videoSearch, sender: user.owner }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this song must be music category'); + }); + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + }); + + describe('Add music song by videoId', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = false; + songs.allowRequestsOnlyFromPlaylist = false; + }); + const videoId = 'bmQwZhcZkbU'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add music song ${videoId}`, async () => { + const r = await songs.addSongToQueue({ parameters: videoId, sender: user.owner }); + assert.strictEqual(r[0].response, '$sender, song The Witcher 3 - Steel for Humans / Lazare (Gingertail Cover) was added to queue'); + }); + + it(`Queue contains song`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 1); + }); + }); + + describe('Add music song by url - allowRequestsOnlyFromPlaylist', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + songs.onlyMusicCategory = false; + songs.allowRequestsOnlyFromPlaylist = true; + }); + const videoUrl = 'https://www.youtube.com/watch?v=bmQwZhcZkbU'; + + it(`Queue is empty`, async () => { + const count = await AppDataSource.getRepository(SongRequest).count(); + assert(count === 0); + }); + + it(`Add songs to playlist`, async () => { + await AppDataSource.getRepository(SongPlaylist).save({ + videoId: 'bmQwZhcZkbU', + seed: 0, + title: 'test', + loudness: 0, + length: 0, + volume: 0, + startTime: 0, + endTime: 0, + tags: ['general', 'lorem'], + }); + await AppDataSource.getRepository(SongPlaylist).save({ + videoId: 'RwtZrI6HuwY', + seed: 0, + endTime: 0, + startTime: 0, + volume: 0, + length: 0, + title: 'test2', + loudness: 0, + tags: ['lorem'], + }); + }); + + it(`Add song bmQwZhcZkbU from playlist`, async () => { + const r = await songs.addSongToQueue({ parameters: 'bmQwZhcZkbU', sender: user.owner }); + assert.strictEqual(r[0].response, '$sender, song The Witcher 3 - Steel for Humans / Lazare (Gingertail Cover) was added to queue'); + }); + + it(`Add song RwtZrI6HuwY without playlist`, async () => { + const r = await songs.addSongToQueue({ parameters: 'RwtZrI6HuwY', sender: user.owner }); + assert.strictEqual(r[0].response, 'Sorry, $sender, but this song is not in current playlist'); + }); + }); +}); diff --git a/backend/test/tests/songs/getSongsIdsFromPlaylist.js b/backend/test/tests/songs/getSongsIdsFromPlaylist.js new file mode 100644 index 000000000..c54bfbbe0 --- /dev/null +++ b/backend/test/tests/songs/getSongsIdsFromPlaylist.js @@ -0,0 +1,21 @@ + +/* global describe it */ + +import('../../general.js'); +import assert from 'assert'; +import songs from '../../../dest/systems/songs.js'; + +describe('Songs - getSongsIdsFromPlaylist() - @func1', () => { + describe('Load songs ids', () => { + let ids = []; + it(`Load playlist video IDs`, async () => { + ids = await songs.getSongsIdsFromPlaylist('https://www.youtube.com/playlist?list=PLjpw-QGgMkeDv8N68j2WCMPlmOBH-_Lw2') + }); + + for (const id of ['lm4OJxGQm_E', 'q8Vk8Wx0xJo', 'fugQAnzL1uk']) { + it(`${id} should be returned by playlist`, async () => { + assert(ids.includes(id)); + }); + } + }); +}); diff --git a/backend/test/tests/timers/add.js b/backend/test/tests/timers/add.js new file mode 100644 index 000000000..b5156c41b --- /dev/null +++ b/backend/test/tests/timers/add.js @@ -0,0 +1,58 @@ +/* global describe it beforeEach */ + + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import timers from '../../../dest/systems/timers.js'; + +import { Timer, TimerResponse } from '../../../dest/database/entity/timer.js'; + +import { linesParsed } from '../../../dest/helpers/parser.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Timers - add() - @func2', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + const timer = new Timer(); + timer.name = 'test'; + timer.triggerEveryMessage = 0; + timer.triggerEverySecond = 60; + timer.isEnabled = true; + timer.triggeredAtTimestamp = Date.now(); + timer.triggeredAtMessage = linesParsed; + await timer.save(); + }); + + it('', async () => { + const r = await timers.add({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, '$sender, timer name must be defined.'); + }); + + it('-name test', async () => { + const r = await timers.add({ sender: owner, parameters: '-name test' }); + assert.strictEqual(r[0].response, '$sender, timer response must be defined.'); + }); + + it('-name unknown -response "Lorem Ipsum"', async () => { + const r = await timers.add({ sender: owner, parameters: '-name unknown -response "Lorem Ipsum"' }); + assert.strictEqual(r[0].response, '$sender, timer (name: unknown) was not found in database. Check timers with !timers list'); + }); + + it('-name test -response "Lorem Ipsum"', async () => { + const r = await timers.add({ sender: owner, parameters: '-name test -response "Lorem Ipsum"' }); + + const item = await AppDataSource.getRepository(TimerResponse).findOneBy({ response: 'Lorem Ipsum' }); + assert(typeof item !== 'undefined'); + + assert.strictEqual(r[0].response, `$sender, response (id: ${item.id}) for timer (name: test) was added - 'Lorem Ipsum'`); + }); +}); diff --git a/backend/test/tests/timers/bug#4209_custom_command_filter_is_not_triggered.js b/backend/test/tests/timers/bug#4209_custom_command_filter_is_not_triggered.js new file mode 100644 index 000000000..c976c122e --- /dev/null +++ b/backend/test/tests/timers/bug#4209_custom_command_filter_is_not_triggered.js @@ -0,0 +1,64 @@ +/* global describe it beforeEach */ + + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import timers from '../../../dest/systems/timers.js'; +import customcommands from '../../../dest/systems/customcommands.js'; +import { isStreamOnline } from '../../../dest/helpers/api/isStreamOnline.js' + +import { Timer, TimerResponse } from '../../../dest/database/entity/timer.js'; + +import { linesParsed } from '../../../dest/helpers/parser.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Timers - https://github.com/sogehige/sogeBot/issues/4209 - custom command filter is not properly triggered - @func2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + after(async () => { + isStreamOnline.value = false; + }); + + it('Create timer', async () => { + const timer = new Timer(); + timer.name = 'test'; + timer.triggerEveryMessage = 0; + timer.triggerEverySecond = 0; + timer.isEnabled = true; + timer.triggeredAtTimestamp = Date.now(); + timer.triggeredAtMessage = linesParsed; + await timer.save(); + }); + + it('Add custom command !telemetry', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !telemetry -r Lorem Ipsum Dolor Sit Amet' }); + assert.strictEqual(r[0].response, '$sender, command !telemetry was added'); + }); + + it('Add (!telemetry) response to timer', async () => { + const r = await timers.add({ sender: owner, parameters: '-name test -response "(!telemetry)"' }); + + const item = await AppDataSource.getRepository(TimerResponse).findOneBy({ response: '(!telemetry)' }); + assert(typeof item !== 'undefined'); + + assert.strictEqual(r[0].response, `$sender, response (id: ${item.id}) for timer (name: test) was added - '(!telemetry)'`); + }); + + it('Set manually stream to be online and manually trigger timers check', () => { + isStreamOnline.value = true; + timers.check(); + }); + + it('We should have correct response in chat in a while', async () => { + await message.isSentRaw('Lorem Ipsum Dolor Sit Amet', '__bot__', 5000); + }); +}); diff --git a/backend/test/tests/timers/community#254_timer_tickOffline.js b/backend/test/tests/timers/community#254_timer_tickOffline.js new file mode 100644 index 000000000..8d06bc8f9 --- /dev/null +++ b/backend/test/tests/timers/community#254_timer_tickOffline.js @@ -0,0 +1,105 @@ +/* global describe it beforeEach */ + +import assert from 'assert'; + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import timers from '../../../dest/systems/timers.js'; +import { isStreamOnline } from '../../../dest/helpers/api/isStreamOnline.js' + +import { Timer, TimerResponse } from '../../../dest/database/entity/timer.js'; + +import { linesParsed } from '../../../dest/helpers/parser.js'; + +describe('Timers - tickOffline should send response into chat when stream is off - https://community.sogebot.xyz/t/timers-offline-mode/254 - @func2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + const timer = new Timer(); + timer.name = 'test'; + timer.triggerEveryMessage = 0; + timer.triggerEverySecond = 1; + timer.tickOffline = true; + timer.isEnabled = true; + timer.triggeredAtTimestamp = Date.now(); + timer.triggeredAtMessage = linesParsed; + await timer.save(); + + const timer2 = new Timer(); + timer2.name = 'test'; + timer2.triggerEveryMessage = 0; + timer2.triggerEverySecond = 1; + timer2.tickOffline = false; + timer2.isEnabled = true; + timer2.triggeredAtTimestamp = Date.now(); + timer2.triggeredAtMessage = linesParsed; + await timer2.save(); + + const response1 = new TimerResponse() + response1.isEnabled = true; + response1.response = '1'; + response1.timer = timer; + await response1.save(); + + const response2 = new TimerResponse() + response2.isEnabled = false; + response2.response = '2'; + response2.timer = timer; + await response2.save(); + + const response3 = new TimerResponse() + response3.isEnabled = true; + response3.response = '3'; + response3.timer = timer; + await response3.save(); + + const response4 = new TimerResponse() + response4.isEnabled = true; + response4.response = '4'; + response4.timer = timer2; + await response4.save(); + + const response5 = new TimerResponse() + response5.isEnabled = true; + response5.response = '5'; + response5.timer = timer2; + await response5.save(); + + const response6 = new TimerResponse() + response6.isEnabled = true; + response6.response = '6'; + response6.timer = timer2; + await response6.save(); + + isStreamOnline.value = false; + }); + + it('We should have response 1 in chat in a while', async () => { + await message.isSentRaw('1', '__bot__', 45000); + // we need to wait little more as interval when offline is 30s + }).timeout(60000); + + it('We should NOT have response 2 in chat in a while', async () => { + await message.isNotSentRaw('2', '__bot__', 5000); + }) + + it('We should have response 3 in chat in a while', async () => { + await message.isSentRaw('3', '__bot__', 5000); + }) + + it('We should NOT have response 4 in chat in a while', async () => { + await message.isNotSentRaw('4', '__bot__', 5000); + }) + + it('We should NOT have response 5 in chat in a while', async () => { + await message.isNotSentRaw('5', '__bot__', 5000); + }) + + it('We should NOT have response 5 in chat in a while', async () => { + await message.isNotSentRaw('5', '__bot__', 5000); + }) +}); diff --git a/backend/test/tests/timers/discord#794910249910796288_disabled_response_should_not_be_sent_to_chat.js b/backend/test/tests/timers/discord#794910249910796288_disabled_response_should_not_be_sent_to_chat.js new file mode 100644 index 000000000..74dce90b1 --- /dev/null +++ b/backend/test/tests/timers/discord#794910249910796288_disabled_response_should_not_be_sent_to_chat.js @@ -0,0 +1,141 @@ +/* global describe it beforeEach */ + + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import timers from '../../../dest/systems/timers.js'; +import { isStreamOnline } from '../../../dest/helpers/api/isStreamOnline.js' + +import { Timer, TimerResponse } from '../../../dest/database/entity/timer.js'; + +import { linesParsed } from '../../../dest/helpers/parser.js'; + +describe('Timers - disabled response should not be sent to chat - https://discord.com/channels/317348946144002050/619437014001123338/794910249910796288 - @func2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + const timer = new Timer(); + timer.name = 'test'; + timer.triggerEveryMessage = 0; + timer.triggerEverySecond = 1; + timer.isEnabled = true; + timer.triggeredAtTimestamp = Date.now(); + timer.triggeredAtMessage = linesParsed; + await timer.save(); + + const response1 = new TimerResponse() + response1.isEnabled = true; + response1.response = '1'; + response1.timer = timer; + await response1.save(); + + const response2 = new TimerResponse() + response2.isEnabled = false; + response2.response = '2'; + response2.timer = timer; + await response2.save(); + + const response3 = new TimerResponse() + response3.isEnabled = true; + response3.response = '3'; + response3.timer = timer; + await response3.save(); + + isStreamOnline.value = true; + }); + after(async () => { + isStreamOnline.value = false; + }); + + it('We should have response 1 in chat in a while', async () => { + await message.isSentRaw('1', '__bot__', 45000); + // we need to wait little more as interval when offline is 30s + }).timeout(60000); + + it('We should NOT have response 2 in chat in a while', async () => { + await message.isNotSentRaw('2', '__bot__', 5000); + }) + + it('We should have response 3 in chat in a while', async () => { + await message.isSentRaw('3', '__bot__', 5000); + }) +}); + +describe('Timers - disabled responses should not be sent to chat - https://discord.com/channels/317348946144002050/619437014001123338/794910249910796288', () => { + let timer; + before(async () => { + await db.cleanup(); + await message.prepare(); + + const timer = new Timer(); + timer.name = 'test'; + timer.triggerEveryMessage = 0; + timer.triggerEverySecond = 1; + timer.isEnabled = true; + timer.triggeredAtTimestamp = Date.now(); + timer.triggeredAtMessage = linesParsed; + await timer.save(); + + const response1 = new TimerResponse() + response1.isEnabled = false; + response1.response = '1'; + response1.timer = timer; + await response1.save(); + + const response2 = new TimerResponse() + response2.isEnabled = false; + response2.response = '2'; + response2.timer = timer; + await response2.save(); + + const response3 = new TimerResponse() + response3.isEnabled = false; + response3.response = '3'; + response3.timer = timer; + await response3.save(); + + isStreamOnline.value = true; + }); + after(async () => { + isStreamOnline.value = false; + }); + + it('Timer should be updated in DB => checked', async () => { + const time = Date.now(); + const checkTimer = new Promise((resolve) => { + const check = async () => { + const updatedTimer = await AppDataSource.getRepository(Timer).findOneBy(timer.id); + if (timer.triggeredAtTimestamp < updatedTimer.triggeredAtTimestamp) { + resolve(true); + } else { + if (Date.now() - time > 60000) { + resolve(false); + } else { + setTimeout(() => check(), 500) + } + } + } + }); + + assert(checkTimer, 'Timer was not updated in 60s'); + }).timeout(65000) + + it('We should NOT have response 1 in chat in a while', async () => { + await message.isNotSentRaw('1', '__bot__', 2000); + // we need to wait little more as interval when offline is 45s (15s should be OK in general) + }) + + it('We should NOT have response 2 in chat in a while', async () => { + await message.isNotSentRaw('2', '__bot__', 2000); + }) + + it('We should NOT have response 3 in chat in a while', async () => { + await message.isNotSentRaw('3', '__bot__', 2000); + }) +}); diff --git a/backend/test/tests/timers/list.js b/backend/test/tests/timers/list.js new file mode 100644 index 000000000..83b2cef6b --- /dev/null +++ b/backend/test/tests/timers/list.js @@ -0,0 +1,76 @@ +/* global describe it beforeEach */ +import('../../general.js'); + +import { db } from '../../general.js'; +import assert from 'assert'; +import { message } from '../../general.js'; + +import timers from '../../../dest/systems/timers.js'; + +import { linesParsed } from '../../../dest/helpers/parser.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +import { Timer, TimerResponse } from '../../../dest/database/entity/timer.js'; +import { AppDataSource } from '../../../dest/database.js'; + +describe('Timers - list() - @func2', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + const timer = new Timer(); + timer.name = 'test'; + timer.triggerEveryMessage = 0; + timer.triggerEverySecond = 60; + timer.tickOffline = true; + timer.isEnabled = true; + timer.triggeredAtTimestamp = Date.now(); + timer.triggeredAtMessage = linesParsed; + await timer.save(); + + const timer2 = new Timer(); + timer2.name = 'test2'; + timer2.triggerEveryMessage = 0; + timer2.triggerEverySecond = 60; + timer2.tickOffline = false; + timer2.isEnabled = false; + timer2.triggeredAtTimestamp = Date.now(); + timer2.triggeredAtMessage = linesParsed; + await timer2.save(); + + const response1 = new TimerResponse() + response1.isEnabled = true; + response1.response = 'Lorem Ipsum'; + response1.timer = timer2; + await response1.save(); + + const response2 = new TimerResponse() + response2.isEnabled = false; + response2.response = 'Lorem Ipsum 2'; + response2.timer = timer2; + await response2.save(); + }); + + it('', async () => { + const r = await timers.list({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, '$sender, timers list: ⚫ test, ⚪ test2'); + }); + + it('-name unknown', async () => { + const r = await timers.list({ sender: owner, parameters: '-name unknown' }); + assert.strictEqual(r[0].response, '$sender, timer (name: unknown) was not found in database. Check timers with !timers list'); + }); + + it('-name test2', async () => { + const r = await timers.list({ sender: owner, parameters: '-name test2' }); + + const response1 = await AppDataSource.getRepository(TimerResponse).findOneBy({ response: 'Lorem Ipsum' }); + const response2 = await AppDataSource.getRepository(TimerResponse).findOneBy({ response: 'Lorem Ipsum 2' }); + + assert.strictEqual(r[0].response, '$sender, timer (name: test2) list'); + assert.strictEqual(r[1].response, `⚫ ${response1.id} - Lorem Ipsum`); + assert.strictEqual(r[2].response, `⚪ ${response2.id} - Lorem Ipsum 2`); + }); +}); diff --git a/backend/test/tests/timers/set.js b/backend/test/tests/timers/set.js new file mode 100644 index 000000000..0229722c7 --- /dev/null +++ b/backend/test/tests/timers/set.js @@ -0,0 +1,121 @@ +/* global describe it beforeEach */ + + +import assert from 'assert'; +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import timers from '../../../dest/systems/timers.js'; + +import { Timer } from '../../../dest/database/entity/timer.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Timers - set() - @func2', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it('', async () => { + const r = await timers.set({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, '$sender, timer name must be defined.'); + }); + + it('-name test', async () => { + const r = await timers.set({ sender: owner, parameters: '-name test' }); + assert.strictEqual(r[0].response, '$sender, timer test was set with 0 messages and 60 seconds to trigger'); + + const item = await Timer.findOne({ + relations: ['messages'], + where: { name: 'test' }, + }); + assert.strictEqual(item.triggerEverySecond, 60); + assert.strictEqual(item.triggerEveryMessage, 0); + }); + + it('-name test -seconds 20', async () => { + const r = await timers.set({ sender: owner, parameters: '-name test -seconds 20' }); + assert.strictEqual(r[0].response, '$sender, timer test was set with 0 messages and 20 seconds to trigger'); + + const item = await Timer.findOne({ + relations: ['messages'], + where: { name: 'test' }, + }); + assert.strictEqual(item.triggerEverySecond, 20); + assert.strictEqual(item.triggerEveryMessage, 0); + }); + + it('-name test -seconds 0', async () => { + const r = await timers.set({ sender: owner, parameters: '-name test -seconds 0' }); + assert.strictEqual(r[0].response, '$sender, you cannot set both messages and seconds to 0.'); + const item = await Timer.findOne({ + relations: ['messages'], + where: { name: 'test' }, + }); + assert(item === null); + }); + + it('-name test -messages 20', async () => { + const r = await timers.set({ sender: owner, parameters: '-name test -messages 20' }); + assert.strictEqual(r[0].response, '$sender, timer test was set with 20 messages and 60 seconds to trigger'); + + const item = await Timer.findOne({ + relations: ['messages'], + where: { name: 'test' }, + }); + assert.strictEqual(item.triggerEverySecond, 60); + assert.strictEqual(item.triggerEveryMessage, 20); + }); + + it('-name test -messages 0', async () => { + const r = await timers.set({ sender: owner, parameters: '-name test -messages 0' }); + assert.strictEqual(r[0].response, '$sender, timer test was set with 0 messages and 60 seconds to trigger'); + + const item = await Timer.findOne({ + relations: ['messages'], + where: { name: 'test' }, + }); + assert.strictEqual(item.triggerEverySecond, 60); + assert.strictEqual(item.triggerEveryMessage, 0); + }); + + it('-name test -seconds 0 -messages 0', async () => { + const r = await timers.set({ sender: owner, parameters: '-name test -seconds 0 -messages 0' }); + assert.strictEqual(r[0].response, '$sender, you cannot set both messages and seconds to 0.'); + + const item = await Timer.findOne({ + relations: ['messages'], + where: { name: 'test' }, + }); + assert(item === null); + }); + + it('-name test -seconds 5 -messages 6', async () => { + const r = await timers.set({ sender: owner, parameters: '-name test -seconds 5 -messages 6' }); + assert.strictEqual(r[0].response, '$sender, timer test was set with 6 messages and 5 seconds to trigger'); + + const item = await Timer.findOne({ + relations: ['messages'], + where: { name: 'test' }, + }); + assert.strictEqual(item.triggerEverySecond, 5); + assert.strictEqual(item.triggerEveryMessage, 6); + }); + + it('-name test -seconds 5 -messages 6 -offline', async () => { + const r = await timers.set({ sender: owner, parameters: '-name test -seconds 5 -messages 6 -offline' }); + assert.strictEqual(r[0].response, '$sender, timer test was set with 6 messages and 5 seconds to trigger even when stream is offline'); + + const item = await Timer.findOne({ + relations: ['messages'], + where: { name: 'test' }, + }); + assert.strictEqual(item.triggerEverySecond, 5); + assert.strictEqual(item.triggerEveryMessage, 6); + assert.strictEqual(item.tickOffline, true); + }); +}); diff --git a/backend/test/tests/timers/toggle.js b/backend/test/tests/timers/toggle.js new file mode 100644 index 000000000..4a30a0f9a --- /dev/null +++ b/backend/test/tests/timers/toggle.js @@ -0,0 +1,75 @@ +/* global describe it beforeEach */ +import('../../general.js'); +import assert from 'assert'; + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import { linesParsed } from '../../../dest/helpers/parser.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +import timers from '../../../dest/systems/timers.js'; + +import { Timer, TimerResponse } from '../../../dest/database/entity/timer.js'; + +describe('Timers - toggle() - @func2', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + const timer = new Timer(); + timer.name = 'test'; + timer.triggerEveryMessage = 0; + timer.triggerEverySecond = 60; + timer.tickOffline = true; + timer.isEnabled = true; + timer.triggeredAtTimestamp = Date.now(); + timer.triggeredAtMessage = linesParsed; + await timer.save(); + + const response1 = new TimerResponse() + response1.isEnabled = true; + response1.response = 'Lorem Ipsum'; + response1.timer = timer; + await response1.save(); + }); + + it('', async () => { + const r = await timers.toggle({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, '$sender, response id or timer name must be defined.'); + }); + + it('-id something -name something', async () => { + const r = await timers.toggle({ sender: owner, parameters: '-id something -name something' }); + assert.strictEqual(r[0].response, '$sender, timer (name: something) was not found in database. Check timers with !timers list'); + }); + + it('-id unknown', async () => { + const r = await timers.toggle({ sender: owner, parameters: '-id unknown' }); + assert.strictEqual(r[0].response, '$sender, response id or timer name must be defined.'); + }); + + it('-id response_id', async () => { + const response = await TimerResponse.findOneBy({ response: 'Lorem Ipsum' }); + const r1 = await timers.toggle({ sender: owner, parameters: '-id ' + response.id }); + assert.strictEqual(r1[0].response, `$sender, response (id: ${response.id}) was disabled`); + + const r2 = await timers.toggle({ sender: owner, parameters: '-id ' + response.id }); + assert.strictEqual(r2[0].response, `$sender, response (id: ${response.id}) was enabled`); + }); + + it('-name unknown', async () => { + const r = await timers.toggle({ sender: owner, parameters: '-name unknown' }); + assert.strictEqual(r[0].response, '$sender, timer (name: unknown) was not found in database. Check timers with !timers list'); + }); + + it('-name test', async () => { + const r1 = await timers.toggle({ sender: owner, parameters: '-name test' }); + assert.strictEqual(r1[0].response, '$sender, timer (name: test) was disabled'); + + const r2 = await timers.toggle({ sender: owner, parameters: '-name test' }); + assert.strictEqual(r2[0].response, '$sender, timer (name: test) was enabled'); + }); +}); diff --git a/backend/test/tests/timers/unset.js b/backend/test/tests/timers/unset.js new file mode 100644 index 000000000..bd22e2d6d --- /dev/null +++ b/backend/test/tests/timers/unset.js @@ -0,0 +1,54 @@ +/* global describe it beforeEach */ + + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import timers from '../../../dest/systems/timers.js'; + +import { Timer } from '../../../dest/database/entity/timer.js'; + +import { linesParsed } from '../../../dest/helpers/parser.js'; +// users +const owner = { userName: '__broadcaster__' }; + +describe('Timers - unset() - @func2', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + + const timer = new Timer(); + timer.name = 'test'; + timer.triggerEveryMessage = 0; + timer.triggerEverySecond = 60; + timer.tickOffline = true; + timer.isEnabled = true; + timer.triggeredAtTimestamp = Date.now(); + timer.triggeredAtMessage = linesParsed; + await timer.save(); + }); + + it('', async () => { + const r = await timers.unset({ sender: owner, parameters: '' }); + assert.strictEqual(r[0].response, '$sender, timer name must be defined.'); + }); + it('-name test', async () => { + const r = await timers.unset({ sender: owner, parameters: '-name test' }); + assert.strictEqual(r[0].response, '$sender, timer test and its responses was deleted.'); + + const item = await AppDataSource.getRepository(Timer).findOneBy({ name: 'test' }); + assert(item === null); + }); + it('-name nonexistent', async () => { + const r = await timers.unset({ sender: owner, parameters: '-name nonexistent' }); + assert.strictEqual(r[0].response, '$sender, timer (name: nonexistent) was not found in database. Check timers with !timers list'); + + const item = await AppDataSource.getRepository(Timer).findOneBy({ name: 'test' }); + assert.strictEqual(item.triggerEverySecond, 60); + assert.strictEqual(item.triggerEveryMessage, 0); + }); +}); diff --git a/backend/test/tests/tmi/ignore.js b/backend/test/tests/tmi/ignore.js new file mode 100644 index 000000000..d9cd60f1b --- /dev/null +++ b/backend/test/tests/tmi/ignore.js @@ -0,0 +1,130 @@ +/* global describe it before */ + +import assert from 'assert'; +import('../../general.js'); + +import { db } from '../../general.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { message } from '../../general.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import { Settings } from '../../../dest/database/entity/settings.js'; + +import twitch from '../../../dest/services/twitch.js'; + +// users +const owner = { userName: '__broadcaster__' }; +const testuser = { userName: 'testuser', userId: String(1) }; +const testuser2 = { userName: 'testuser2', userId: String(2) }; +const testuser3 = { userName: 'testuser3', userId: String(3) }; +const nightbot = { userName: 'nightbot', userId: String(4) }; +const botwithchangedname = { userName: 'asdsadas', userId: String(24900234) }; + +import { isIgnored } from '../../../dest/helpers/user/isIgnored.js'; +import { AppDataSource } from '../../../dest/database.js'; + +describe('TMI - ignore - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + twitch.globalIgnoreListExclude = []; + twitch.ignorelist = []; + + await AppDataSource.getRepository(User).save(testuser); + await AppDataSource.getRepository(User).save(testuser2); + await AppDataSource.getRepository(User).save(testuser3); + }); + + describe('Global ignore workflow', () => { + it ('nightbot should be ignored by default', async () => { + assert(await isIgnored(nightbot)); // checked by known_alias + }); + + it ('botwithchangedname should be ignored by default', async () => { + assert(await isIgnored(botwithchangedname)); // checked by id + }); + }); + + describe('Ignore workflow', () => { + it('testuser is not ignored by default', async () => { + assert(!(await isIgnored(testuser))); + }); + + it('add testuser to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'testuser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added', { userName: 'testuser' })); + }); + + it('add @testuser2 to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: '@testuser2' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added', { userName: 'testuser2' })); + }); + + it('testuser should be in ignore list', async () => { + const r = await twitch.ignoreCheck({ sender: owner, parameters: 'testuser' }); + + const item = await AppDataSource.getRepository(Settings).findOne({ + where: { + namespace: '/services/twitch', + name: 'ignorelist', + }, + }); + + assert.strictEqual(r[0].response, prepare('ignore.user.is.ignored', { userName: 'testuser' })); + assert(await isIgnored(testuser)); + assert(typeof item !== 'undefined'); + assert(item.value.includes('testuser')); + }); + + it('@testuser2 should be in ignore list', async () => { + const r = await twitch.ignoreCheck({ sender: owner, parameters: '@testuser2' }); + const item = await AppDataSource.getRepository(Settings).findOne({ + where: { + namespace: '/services/twitch', + name: 'ignorelist', + }, + }); + + assert.strictEqual(r[0].response, prepare('ignore.user.is.ignored', { userName: 'testuser2' })); + assert(await isIgnored(testuser2)); + assert(typeof item !== 'undefined'); + assert(item.value.includes('testuser2')); + }); + + it('testuser3 should not be in ignore list', async () => { + const r = await twitch.ignoreCheck({ sender: owner, parameters: 'testuser3' }); + const item = await AppDataSource.getRepository(Settings).findOne({ + where: { + namespace: '/services/twitch', + name: 'ignorelist', + }, + }); + + assert.strictEqual(r[0].response, prepare('ignore.user.is.not.ignored', { userName: 'testuser3' })); + assert(!(await isIgnored(testuser3))); + assert(typeof item !== 'undefined'); + assert(!item.value.includes('testuser3')); + + }); + + it('remove testuser from ignore list', async () => { + const r = await twitch.ignoreRm({ sender: owner, parameters: 'testuser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.removed', { userName: 'testuser' })); + }); + + it('testuser should not be in ignore list', async () => { + const r = await twitch.ignoreCheck({ sender: owner, parameters: 'testuser' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.not.ignored', { userName: 'testuser' })); + assert(!(await isIgnored(testuser))); + }); + + it('add testuser by id to ignore list', async () => { + twitch.ignorelist = [ testuser.userId ]; + }); + + it('user should be ignored by id', async () => { + assert(await isIgnored(testuser)); + }); + }); +}); diff --git a/backend/test/tests/tmi/redeem_command.js b/backend/test/tests/tmi/redeem_command.js new file mode 100644 index 000000000..2e4c82970 --- /dev/null +++ b/backend/test/tests/tmi/redeem_command.js @@ -0,0 +1,113 @@ +import('../../general.js'); + +import {cheer } from '../../../dest/helpers/events/cheer.js'; +import {translate} from '../../../dest/translate.js' +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import { time } from '../../general.js'; +import { Price } from '../../../dest/database/entity/price.js'; +import customcommands from '../../../dest/systems/customcommands.js'; + +import { getLocalizedName } from '@sogebot/ui-helpers/getLocalized.js'; + +import assert from 'assert'; + +import { AppDataSource } from '../../../dest/database.js' + +const owner = { userName: '__broadcaster__', user_id: String(Math.floor(Math.random() * 10000)) }; + +describe('TMI - redeem command - @func3', () => { + before(async () => { + await db.cleanup(); + await time.waitMs(1000); + await message.prepare(); + }); + + it('Add custom command !test', async () => { + const r = await customcommands.add({ sender: owner, parameters: '-c !test -r Lorem Ipsum' }); + assert.strictEqual(r[0].response, '$sender, command !test was added'); + const r2 = await customcommands.add({ sender: owner, parameters: '-c !test2 -r Ipsum Lorem' }); + assert.strictEqual(r2[0].response, '$sender, command !test2 was added'); + }); + + it(`Add price !test with emitRedeemEvent`, async () => { + await AppDataSource.getRepository(Price).save({ + command: '!test', price: 0, priceBits: 10, emitRedeemEvent: true, + }); + }); + + it(`Add price !test2 without emitRedeemEvent`, async () => { + await AppDataSource.getRepository(Price).save({ + command: '!test2', price: 0, priceBits: 10, emitRedeemEvent: false, + }); + }); + + it(`User will cheer !test with 5 bits (not enough)`, async () => { + cheer({ + user_login: 'testuser', + user_id: String(Math.floor(Math.random() * 100000)), + message: '!test', + bits: 5, + }); + }); + + it(`User will cheer !test2 with 5 bits (not enough)`, async () => { + cheer({ + user_login: 'testuser', + user_id: String(Math.floor(Math.random() * 100000)), + message: '!test2', + bits: 5, + }); + }); + + it(`Command !test was not redeemed`, async () => { + try { + await message.debug('tmi.cmdredeems', '!test'); + assert(false, 'This should not get here'); + } catch (e) { + return; + } + }); + + it(`Command !test2 was not redeemed`, async () => { + try { + await message.debug('tmi.cmdredeems', '!test2'); + assert(false, 'This should not get here'); + } catch (e) { + return; + } + }); + + it(`User will cheer !test with 10 bits (enough)`, async () => { + cheer({ + user_login: 'testuser', + user_id: String(Math.floor(Math.random() * 100000)), + message: '!test', + bits: 10, + }); + }); + + it(`User will cheer !test2 with 10 bits (enough)`, async () => { + cheer({ + user_login: 'testuser', + user_id: String(Math.floor(Math.random() * 100000)), + message: '!test2', + bits: 10, + }); + }); + + it(`Command was !test redeemed`, async () => { + await message.isSentRaw('Lorem Ipsum', 'testuser'); + await message.debug('tmi.cmdredeems', '!test'); + }); + + it(`Command !test2 was redeemed but without alert`, async () => { + await message.isSentRaw('Ipsum Lorem', 'testuser'); + try { + await message.debug('tmi.cmdredeems', '!test2'); + assert(false, 'This should not get here'); + } catch (e) { + return; + } + }); +}); diff --git a/backend/test/tests/tmi/subcommunitygift_after_gifts_should_be_ignored.js b/backend/test/tests/tmi/subcommunitygift_after_gifts_should_be_ignored.js new file mode 100644 index 000000000..9f09330d6 --- /dev/null +++ b/backend/test/tests/tmi/subcommunitygift_after_gifts_should_be_ignored.js @@ -0,0 +1,78 @@ +/* global */ +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +import { User } from '../../../dest/database/entity/user.js'; +import * as changelog from '../../../dest/helpers/user/changelog.js'; +import { db } from '../../general.js'; +import { time } from '../../general.js'; +import { message } from '../../general.js'; +import { user } from '../../general.js'; + +describe('TMI - subcommunitygift after gifts should be ignored - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it('Trigger subcommunitygift', async () => { + const TMI = (await import('../../../dest/services/twitch/chat.js')).default; + const tmi = new TMI(); + tmi.subscriptionGiftCommunity(user.viewer.userName, { + gifterUserId: user.viewer.userId, + count: 5, + }); + }); + + it('Trigger subgift 1', async () => { + const TMI = (await import('../../../dest/services/twitch/chat.js')).default; + const tmi = new TMI(); + tmi.subgift(user.viewer2.userName, { gifter: user.viewer.userName, gifterUserId: user.viewer.userId, months: 1, userId: user.viewer2.userId, isPrime: true }); + await message.debug('tmi.subgift', 'Ignored: __viewer__#1 -> __viewer2__#3'); + }); + + it('Trigger subgift 2', async () => { + const TMI = (await import('../../../dest/services/twitch/chat.js')).default; + const tmi = new TMI(); + tmi.subgift(user.viewer3.userName, { gifter: user.viewer.userName, gifterUserId: user.viewer.userId, months: 1, userId: user.viewer3.userId, isPrime: true }); + await message.debug('tmi.subgift', 'Ignored: __viewer__#1 -> __viewer3__#5'); + }); + + it('Trigger subgift 3', async () => { + const TMI = (await import('../../../dest/services/twitch/chat.js')).default; + const tmi = new TMI(); + tmi.subgift(user.viewer4.userName, { gifter: user.viewer.userName, gifterUserId: user.viewer.userId, months: 1, userId: user.viewer4.userId, isPrime: true }); + await message.debug('tmi.subgift', 'Ignored: __viewer__#1 -> __viewer4__#50'); + }); + + it('Trigger subgift 4', async () => { + const TMI = (await import('../../../dest/services/twitch/chat.js')).default; + const tmi = new TMI(); + tmi.subgift(user.viewer5.userName, { gifter: user.viewer.userName, gifterUserId: user.viewer.userId, months: 1, userId: user.viewer5.userId, isPrime: true }); + await message.debug('tmi.subgift', 'Ignored: __viewer__#1 -> __viewer5__#55'); + }); + + it('Trigger subgift 5', async () => { + const TMI = (await import('../../../dest/services/twitch/chat.js')).default; + const tmi = new TMI(); + tmi.subgift(user.viewer6.userName, { gifter: user.viewer.userName, gifterUserId: user.viewer.userId, months: 1, userId: user.viewer6.userId, isPrime: true }); + await message.debug('tmi.subgift', 'Ignored: __viewer__#1 -> __viewer6__#56'); + }); + + it('Trigger subgift 6 > should be triggered', async () => { + const TMI = (await import('../../../dest/services/twitch/chat.js')).default; + const tmi = new TMI(); + tmi.subgift(user.viewer7.userName, { gifter: user.viewer.userName, gifterUserId: user.viewer.userId, months: 1, userId: user.viewer7.userId, isPrime: true }); + await message.debug('tmi.subgift', 'Triggered: __viewer__#1 -> __viewer7__#57'); + }); + + it('Viewer1 should have 6 subgifts', async () => { + await time.waitMs(1000); + await changelog.flush(); + const _user = await AppDataSource.getRepository(User).findOneBy({ userId: user.viewer.userId }); + assert(_user.giftedSubscribes === 6, `Expected 6 (5 community + 1 normal) subgifts, got ${_user.giftedSubscribes} subgifts`); + }); +}); \ No newline at end of file diff --git a/backend/test/tests/tmi/user_message_counted#3106.js b/backend/test/tests/tmi/user_message_counted#3106.js new file mode 100644 index 000000000..b5f7fbada --- /dev/null +++ b/backend/test/tests/tmi/user_message_counted#3106.js @@ -0,0 +1,85 @@ +/* global */ + +import assert from 'assert'; + +import('../../general.js'); + +import * as commons from '../../../dest/commons.js' +import { AppDataSource } from '../../../dest/database.js'; +import { Settings } from '../../../dest/database/entity/settings.js'; +import { User } from '../../../dest/database/entity/user.js'; +import { isStreamOnline } from '../../../dest/helpers/api/isStreamOnline.js' +import * as changelog from '../../../dest/helpers/user/changelog.js'; +import twitch from '../../../dest/services/twitch.js'; +// users +const owner = { userName: '__broadcaster__' }; +const testuser1 = { + userName: 'testuser1', userId: '1', +}; +const testuser2 = { + userName: 'testuser2', userId: '2', +}; + +import { VariableWatcher } from '../../../dest/watchers.js'; +import { message } from '../../general.js'; +import { db } from '../../general.js'; + +import TMI from '../../../dest/services/twitch/chat.js'; +const tmi = new TMI(); + +describe('TMI - User should have counted messages - https://github.com/sogehige/sogeBot/issues/3106 - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + twitch.globalIgnoreListExclude = []; + twitch.ignorelist = []; + + await AppDataSource.getRepository(User).save(testuser1); + await AppDataSource.getRepository(User).save(testuser2); + }); + + it ('Set stream as online', async () => { + isStreamOnline.value = true; + }); + + it ('Send 10 messages as testuser1', async () => { + for (let i = 0; i < 10; i++) { + await tmi.message({ userstate: testuser1, message: 'a' }); + } + }); + + it ('Send 5 messages as testuser2', async () => { + for (let i = 0; i < 5; i++) { + await tmi.message({ userstate: testuser2, message: 'a' }); + } + }); + + it ('Set stream as offline', async () => { + isStreamOnline.value = false; + }); + + it ('Send 10 messages as testuser1', async () => { + for (let i = 0; i < 10; i++) { + await tmi.message({ userstate: testuser1, message: 'a' }); + } + }); + + it ('Send 5 messages as testuser2', async () => { + for (let i = 0; i < 5; i++) { + await tmi.message({ userstate: testuser2, message: 'a' }); + } + }); + + it ('testuser1 should have 20 messages', async () => { + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userId: testuser1.userId }); + assert(user.messages === 20, `Expected 20 messages, got ${user.messages} messages`); + }); + + it ('testuser2 should have 10 messages', async () => { + await changelog.flush(); + const user = await AppDataSource.getRepository(User).findOneBy({ userId: testuser2.userId }); + assert(user.messages === 10, `Expected 10 messages, got ${user.messages} messages`); + }); +}); diff --git a/backend/test/tests/top/bits.js b/backend/test/tests/top/bits.js new file mode 100644 index 000000000..59673a6f7 --- /dev/null +++ b/backend/test/tests/top/bits.js @@ -0,0 +1,61 @@ +/* global describe it before */ +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +const currency = (await import('../../../dest/currency.js')).default; +import twitch from '../../../dest/services/twitch.js' +import { User, UserBit } from '../../../dest/database/entity/user.js'; +import { getOwner } from '../../../dest/helpers/commons/getOwner.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import top from '../../../dest/systems/top.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Top - !top bits - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Add 10 users into db and last user will don\'t have any bits', async () => { + for (let i = 0; i < 10; i++) { + const userId = String(Math.floor(Math.random() * 100000)); + const bits = []; + const user = { ...await AppDataSource.getRepository(User).save({ userId, userName: 'user' + i }) }; + + if (i === 0) { + continue; + } + + for (let j = 0; j <= i; j++) { + bits.push({ + amount: j, + cheeredAt: Date.now(), + message: '', + userId, + }); + } + await AppDataSource.getRepository(UserBit).save(bits); + } + }); + + it('run !top bits and expect correct output', async () => { + const r = await top.bits({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (bits): 1. @user9 - 45, 2. @user8 - 36, 3. @user7 - 28, 4. @user6 - 21, 5. @user5 - 15, 6. @user4 - 10, 7. @user3 - 6, 8. @user2 - 3, 9. @user1 - 1'); + }); + + it('add user1 to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'user1' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added' , { userName: 'user1' })); + }); + + it('run !top bits and expect correct output', async () => { + const r = await top.bits({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (bits): 1. @user9 - 45, 2. @user8 - 36, 3. @user7 - 28, 4. @user6 - 21, 5. @user5 - 15, 6. @user4 - 10, 7. @user3 - 6, 8. @user2 - 3'); + }); +}); diff --git a/backend/test/tests/top/gifts.js b/backend/test/tests/top/gifts.js new file mode 100644 index 000000000..538ff9183 --- /dev/null +++ b/backend/test/tests/top/gifts.js @@ -0,0 +1,48 @@ +/* global describe it before */ +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +import { User } from '../../../dest/database/entity/user.js'; +import { getOwner } from '../../../dest/helpers/commons/getOwner.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import top from '../../../dest/systems/top.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import twitch from '../../../dest/services/twitch.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Top - !top gifts - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Add 10 users into db and last user will don\'t have any gifts', async () => { + for (let i = 0; i < 10; i++) { + await AppDataSource.getRepository(User).save({ + userId: String(Math.floor(Math.random() * 100000)), + userName: 'user' + i, + giftedSubscribes: i * 100, + }); + } + }); + + it('run !top gifts and expect correct output', async () => { + const r = await top.gifts({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (subgifts): 1. @user9 - 900, 2. @user8 - 800, 3. @user7 - 700, 4. @user6 - 600, 5. @user5 - 500, 6. @user4 - 400, 7. @user3 - 300, 8. @user2 - 200, 9. @user1 - 100, 10. @user0 - 0', owner); + }); + + it('add user0 to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'user0' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added' , { userName: 'user0' })); + }); + + it('run !top gifts and expect correct output', async () => { + const r = await top.gifts({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (subgifts): 1. @user9 - 900, 2. @user8 - 800, 3. @user7 - 700, 4. @user6 - 600, 5. @user5 - 500, 6. @user4 - 400, 7. @user3 - 300, 8. @user2 - 200, 9. @user1 - 100', owner); + }); +}); diff --git a/backend/test/tests/top/level.js b/backend/test/tests/top/level.js new file mode 100644 index 000000000..4e95dcf3b --- /dev/null +++ b/backend/test/tests/top/level.js @@ -0,0 +1,55 @@ +import assert from 'assert'; + +import constants from '@sogebot/ui-helpers/constants.js'; +import { dayjs } from '@sogebot/ui-helpers/dayjsHelper.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import { getOwner } from '../../../dest/helpers/commons/getOwner.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { + serialize, +} from '../../../dest/helpers/type.js'; +import { AppDataSource } from '../../../dest/database.js' +import twitch from '../../../dest/services/twitch.js' +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import('../../general.js'); + +// users +const owner = { userName: '__broadcaster__' }; + + +describe('Top - !top level - @func1', () => { + let top; + before(async () => { + top = (await import('../../../dest/systems/top.js')).default; + await db.cleanup(); + await message.prepare(); + }); + + it ('Add 10 users into db and last user will don\'t have any xp', async () => { + for (let i = 0; i < 10; i++) { + await AppDataSource.getRepository(User).save({ + userId: String(Math.floor(Math.random() * 100000)), + userName: 'user' + i, + extra: { levels: { xp: serialize(BigInt(i * 1234)) } }, + }); + } + }); + + it('run !top level and expect correct output', async () => { + const r = await top.level({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, `Top 10 (level): 1. @user9 - 6 (11106XP), 2. @user8 - 6 (9872XP), 3. @user7 - 5 (8638XP), 4. @user6 - 5 (7404XP), 5. @user5 - 5 (6170XP), 6. @user4 - 5 (4936XP), 7. @user3 - 4 (3702XP), 8. @user2 - 4 (2468XP), 9. @user1 - 3 (1234XP), 10. @user0 - 0 (0XP)`, owner); + }); + + it('add user9 to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'user9' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added' , { userName: 'user9' })); + }); + + it('run !top level and expect correct output', async () => { + const r = await top.level({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, `Top 10 (level): 1. @user8 - 6 (9872XP), 2. @user7 - 5 (8638XP), 3. @user6 - 5 (7404XP), 4. @user5 - 5 (6170XP), 5. @user4 - 5 (4936XP), 6. @user3 - 4 (3702XP), 7. @user2 - 4 (2468XP), 8. @user1 - 3 (1234XP), 9. @user0 - 0 (0XP)`, owner); + }); +}); diff --git a/backend/test/tests/top/messages.js b/backend/test/tests/top/messages.js new file mode 100644 index 000000000..9c394de10 --- /dev/null +++ b/backend/test/tests/top/messages.js @@ -0,0 +1,50 @@ +/* global describe it before */ +import { getOwner } from '../../../dest/helpers/commons/getOwner.js'; + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import top from '../../../dest/systems/top.js'; + +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { User } from '../../../dest/database/entity/user.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' +import twitch from '../../../dest/services/twitch.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Top - !top messages - @func2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Add 10 users into db and last user will don\'t have any messages', async () => { + for (let i = 0; i < 10; i++) { + await AppDataSource.getRepository(User).save({ + userId: String(Math.floor(Math.random() * 100000)), + userName: 'user' + i, + messages: i, + }); + } + }); + + it('run !top messages and expect correct output', async () => { + const r = await top.messages({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (messages): 1. @user9 - 9, 2. @user8 - 8, 3. @user7 - 7, 4. @user6 - 6, 5. @user5 - 5, 6. @user4 - 4, 7. @user3 - 3, 8. @user2 - 2, 9. @user1 - 1, 10. @user0 - 0', owner); + }); + + it('add user0 to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'user0' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added' , { userName: 'user0' })); + }); + + it('run !top messages and expect correct output', async () => { + const r = await top.messages({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (messages): 1. @user9 - 9, 2. @user8 - 8, 3. @user7 - 7, 4. @user6 - 6, 5. @user5 - 5, 6. @user4 - 4, 7. @user3 - 3, 8. @user2 - 2, 9. @user1 - 1', owner); + }); +}); diff --git a/backend/test/tests/top/points.js b/backend/test/tests/top/points.js new file mode 100644 index 000000000..3d5847af4 --- /dev/null +++ b/backend/test/tests/top/points.js @@ -0,0 +1,51 @@ +/* global describe it before */ +import { getOwner } from '../../../dest/helpers/commons/getOwner.js'; + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import { User } from '../../../dest/database/entity/user.js'; + +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import top from '../../../dest/systems/top.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' +import twitch from '../../../dest/services/twitch.js'; + +// users +const owner = { userName: '__broadcaster__' }; +let user; + +describe('Top - !top points - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Add 10 users into db and last user will don\'t have any points', async () => { + for (let i = 0; i < 10; i++) { + user = await AppDataSource.getRepository(User).save({ + userId: String(Math.floor(Math.random() * 100000)), + userName: 'user' + i, + points: i * 15, + }); + } + }); + + it('run !top points and expect correct output', async () => { + const r = await top.points({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (points): 1. @user9 - 135 points, 2. @user8 - 120 points, 3. @user7 - 105 points, 4. @user6 - 90 points, 5. @user5 - 75 points, 6. @user4 - 60 points, 7. @user3 - 45 points, 8. @user2 - 30 points, 9. @user1 - 15 points, 10. @user0 - 0 points', owner); + }); + + it('add user0 to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'user0' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added' , { userName: 'user0' })); + }); + + it('run !top points and expect correct output', async () => { + const r = await top.points({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (points): 1. @user9 - 135 points, 2. @user8 - 120 points, 3. @user7 - 105 points, 4. @user6 - 90 points, 5. @user5 - 75 points, 6. @user4 - 60 points, 7. @user3 - 45 points, 8. @user2 - 30 points, 9. @user1 - 15 points', owner); + }); +}); diff --git a/backend/test/tests/top/subage.js b/backend/test/tests/top/subage.js new file mode 100644 index 000000000..70d2e6999 --- /dev/null +++ b/backend/test/tests/top/subage.js @@ -0,0 +1,66 @@ +import assert from 'assert'; + +import constants from '@sogebot/ui-helpers/constants.js'; +import { dayjs } from '@sogebot/ui-helpers/dayjsHelper.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import { getOwner } from '../../../dest/helpers/commons/getOwner.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { AppDataSource } from '../../../dest/database.js' +import twitch from '../../../dest/services/twitch.js'; +import top from '../../../dest/systems/top.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Top - !top subage - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Add 10 users into db and last user will don\'t have any subage', async () => { + for (let i = 0; i < 10; i++) { + await AppDataSource.getRepository(User).save({ + userId: String(Math.floor(Math.random() * 100000)), + userName: 'user' + i, + isSubscriber: true, + subscribedAt: new Date(Date.now() - (constants.HOUR * i)).toISOString(), + }); + } + }); + + it ('Add user with long subage but not subscriber', async () => { + await AppDataSource.getRepository(User).save({ + userId: String(Math.floor(Math.random() * 100000)), + userName: 'user11', + isSubscriber: false, + subscribedAt: new Date(Date.now() - (constants.HOUR * 24 * 30)).toISOString(), + }); + }); + + it('run !top subage and expect correct output', async () => { + const r = await top.subage({ sender: { userName: getOwner() } }); + const dates = []; + for (let i = 0; i < 10; i++) { + dates.push(`${dayjs.utc(Date.now() - (constants.HOUR * i)).format('L')} (${dayjs.utc(Date.now() - (constants.HOUR * i)).fromNow()})`); + } + assert.strictEqual(r[0].response, `Top 10 (subage): 1. @user9 - ${dates[9]}, 2. @user8 - ${dates[8]}, 3. @user7 - ${dates[7]}, 4. @user6 - ${dates[6]}, 5. @user5 - ${dates[5]}, 6. @user4 - ${dates[4]}, 7. @user3 - ${dates[3]}, 8. @user2 - ${dates[2]}, 9. @user1 - ${dates[1]}, 10. @user0 - ${dates[0]}`, owner); + }); + + it('add user0 to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'user0' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added' , { userName: 'user0' })); + }); + + it('run !top subage and expect correct output', async () => { + const r = await top.subage({ sender: { userName: getOwner() } }); + const dates = []; + for (let i = 0; i < 10; i++) { + dates.push(`${dayjs.utc(Date.now() - (constants.HOUR * i)).format('L')} (${dayjs.utc(Date.now() - (constants.HOUR * i)).fromNow()})`); + } + assert.strictEqual(r[0].response, `Top 10 (subage): 1. @user9 - ${dates[9]}, 2. @user8 - ${dates[8]}, 3. @user7 - ${dates[7]}, 4. @user6 - ${dates[6]}, 5. @user5 - ${dates[5]}, 6. @user4 - ${dates[4]}, 7. @user3 - ${dates[3]}, 8. @user2 - ${dates[2]}, 9. @user1 - ${dates[1]}`, owner); + }); +}); diff --git a/backend/test/tests/top/submonths.js b/backend/test/tests/top/submonths.js new file mode 100644 index 000000000..2d6d49272 --- /dev/null +++ b/backend/test/tests/top/submonths.js @@ -0,0 +1,50 @@ +/* global describe it before */ +import { getOwner } from '../../../dest/helpers/commons/getOwner.js'; + +import('../../general.js'); + +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +import top from '../../../dest/systems/top.js'; + +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { User } from '../../../dest/database/entity/user.js'; +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' +import twitch from '../../../dest/services/twitch.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Top - !top submonths - @func2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Add 10 users into db and last user will don\'t have any submonths', async () => { + for (let i = 0; i < 10; i++) { + await AppDataSource.getRepository(User).save({ + userId: String(Math.floor(Math.random() * 100000)), + userName: 'user' + i, + subscribeCumulativeMonths: i * 100, + }); + } + }); + + it('run !top submonths and expect correct output', async () => { + const r = await top.submonths({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (submonths): 1. @user9 - 900 months, 2. @user8 - 800 months, 3. @user7 - 700 months, 4. @user6 - 600 months, 5. @user5 - 500 months, 6. @user4 - 400 months, 7. @user3 - 300 months, 8. @user2 - 200 months, 9. @user1 - 100 months, 10. @user0 - 0 months', owner); + }); + + it('add user0 to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'user0' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added' , { userName: 'user0' })); + }); + + it('run !top submonths and expect correct output', async () => { + const r = await top.submonths({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (submonths): 1. @user9 - 900 months, 2. @user8 - 800 months, 3. @user7 - 700 months, 4. @user6 - 600 months, 5. @user5 - 500 months, 6. @user4 - 400 months, 7. @user3 - 300 months, 8. @user2 - 200 months, 9. @user1 - 100 months', owner); + }); +}); diff --git a/backend/test/tests/top/time.js b/backend/test/tests/top/time.js new file mode 100644 index 000000000..b10e946d5 --- /dev/null +++ b/backend/test/tests/top/time.js @@ -0,0 +1,49 @@ +/* global describe it before */ +import assert from 'assert'; + +import('../../general.js'); +import constants from '@sogebot/ui-helpers/constants.js'; + +import { User } from '../../../dest/database/entity/user.js'; +import { getOwner } from '../../../dest/helpers/commons/getOwner.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import { AppDataSource } from '../../../dest/database.js'; +import top from '../../../dest/systems/top.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import twitch from '../../../dest/services/twitch.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Top - !top time - @func3', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Add 10 users into db and last user will don\'t have any time', async () => { + for (let i = 0; i < 10; i++) { + await AppDataSource.getRepository(User).save({ + userId: String(Math.floor(Math.random() * 100000)), + userName: 'user' + i, + watchedTime: i * constants.HOUR, + }); + } + }); + + it('run !top time and expect correct output', async () => { + const r = await top.time({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (watch time): 1. @user9 - 9.0 hr, 2. @user8 - 8.0 hr, 3. @user7 - 7.0 hr, 4. @user6 - 6.0 hr, 5. @user5 - 5.0 hr, 6. @user4 - 4.0 hr, 7. @user3 - 3.0 hr, 8. @user2 - 2.0 hr, 9. @user1 - 1.0 hr, 10. @user0 - 0.0 hr', owner); + }); + + it('add user0 to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'user0' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added' , { userName: 'user0' })); + }); + + it('run !top time and expect correct output', async () => { + const r = await top.time({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (watch time): 1. @user9 - 9.0 hr, 2. @user8 - 8.0 hr, 3. @user7 - 7.0 hr, 4. @user6 - 6.0 hr, 5. @user5 - 5.0 hr, 6. @user4 - 4.0 hr, 7. @user3 - 3.0 hr, 8. @user2 - 2.0 hr, 9. @user1 - 1.0 hr', owner); + }); +}); diff --git a/backend/test/tests/top/tips.js b/backend/test/tests/top/tips.js new file mode 100644 index 000000000..8b1c289b4 --- /dev/null +++ b/backend/test/tests/top/tips.js @@ -0,0 +1,69 @@ +/* global */ +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; + +import('../../general.js'); + +const currency = (await import('../../../dest/currency.js')).default; +import { User, UserTip } from '../../../dest/database/entity/user.js'; +import { getOwner } from '../../../dest/helpers/commons/getOwner.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import rates from '../../../dest/helpers/currency/rates.js'; +import twitch from '../../../dest/services/twitch.js'; +import top from '../../../dest/systems/top.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +// users +const owner = { userName: '__broadcaster__' }; + +describe('Top - !top tips - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + }); + + it ('Add 10 users into db and last user will don\'t have any tips', async () => { + for (let i = 0; i < 10; i++) { + const userId = String(Math.floor(Math.random() * 100000)); + const tips = []; + const user = { ...await AppDataSource.getRepository(User).save({ userId, userName: 'user' + i }) }; + + if (i === 0) { + continue; + } + + for (let j = 0; j <= i; j++) { + tips.push({ + amount: j, + sortAmount: 2*j, + currency: 'EUR', + message: 'test', + timestamp: Date.now(), + exchangeRates: rates, + userId, + }); + } + await AppDataSource.getRepository(UserTip).save(tips); + } + }); + + it('Update change rates', async() => { + await currency.recalculateSortAmount(); + }); + + it('run !top tips and expect correct output', async () => { + const r = await top.tips({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (tips): 1. @user9 - €45.00, 2. @user8 - €36.00, 3. @user7 - €28.00, 4. @user6 - €21.00, 5. @user5 - €15.00, 6. @user4 - €10.00, 7. @user3 - €6.00, 8. @user2 - €3.00, 9. @user1 - €1.00', owner); + }); + + it('add user1 to ignore list', async () => { + const r = await twitch.ignoreAdd({ sender: owner, parameters: 'user1' }); + assert.strictEqual(r[0].response, prepare('ignore.user.is.added' , { userName: 'user1' })); + }); + + it('run !top tips and expect correct output', async () => { + const r = await top.tips({ sender: { userName: getOwner() } }); + assert.strictEqual(r[0].response, 'Top 10 (tips): 1. @user9 - €45.00, 2. @user8 - €36.00, 3. @user7 - €28.00, 4. @user6 - €21.00, 5. @user5 - €15.00, 6. @user4 - €10.00, 7. @user3 - €6.00, 8. @user2 - €3.00', owner); + }); +}); diff --git a/backend/test/tests/twitch/followers.js b/backend/test/tests/twitch/followers.js new file mode 100644 index 000000000..76d2b7a51 --- /dev/null +++ b/backend/test/tests/twitch/followers.js @@ -0,0 +1,69 @@ +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' + +import { User } from '../../../dest/database/entity/user.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import eventlist from '../../../dest/overlays/eventlist.js'; +import twitch from '../../../dest/services/twitch.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; +import { time } from '../../general.js'; +import { user } from '../../general.js'; + +import('../../general.js'); + +describe('lib/twitch - followers() - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it('Set user.viewer, user.viewer2, user.viewer3 as followers', async () => { + for (const u of [user.viewer, user.viewer2, user.viewer3]) { + await AppDataSource.getRepository(User).save({ + userId: u.userId, userName: u.userName, + }); + } + }); + + it('add user.viewer to event', async () => { + await time.waitMs(100); + await eventlist.add({ + event: 'follow', + userId: user.viewer.userId, + }); + }); + + it('add user.viewer2 to event', async () => { + await time.waitMs(100); + await eventlist.add({ + event: 'follow', + userId: user.viewer2.userId, + }); + }); + + it('!followers should return user.viewer2', async () => { + const r = await twitch.followers({ sender: user.viewer }); + assert.strictEqual(r[0].response, prepare('followers', { + lastFollowAgo: 'a few seconds ago', + lastFollowUsername: user.viewer2.userName, + })); + }); + + it('add user.viewer3 to events', async () => { + await time.waitMs(100); + await eventlist.add({ + event: 'follow', + userId: user.viewer3.userId, + }); + }); + + it('!followers should return user.viewer3', async () => { + const r = await twitch.followers({ sender: user.viewer }); + assert.strictEqual(r[0].response, prepare('followers', { + lastFollowAgo: 'a few seconds ago', + lastFollowUsername: user.viewer3.userName, + })); + }); +}); diff --git a/backend/test/tests/twitch/subs.js b/backend/test/tests/twitch/subs.js new file mode 100644 index 000000000..d8f843943 --- /dev/null +++ b/backend/test/tests/twitch/subs.js @@ -0,0 +1,86 @@ +/* global describe it before */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js' + +import { User } from '../../../dest/database/entity/user.js'; +import { prepare } from '../../../dest/helpers/commons/prepare.js'; +import eventlist from '../../../dest/overlays/eventlist.js'; +import twitch from '../../../dest/services/twitch.js'; +import { db } from '../../general.js'; +import { time } from '../../general.js'; +import { message } from '../../general.js'; +import { user } from '../../general.js'; + +import('../../general.js'); + +describe('lib/twitch - subs() - @func2', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + it('Set viewer, viewer2, viewer3 as subs', async () => { + for (const u of [user.viewer, user.viewer2, user.viewer3]) { + await AppDataSource.getRepository(User).save({ + userId: u.userId, userName: u.userName, isSubscriber: true, + }); + } + }); + + it('add user.viewer to event', async () => { + await time.waitMs(100); + await eventlist.add({ + event: 'sub', + userId: user.viewer.userId, + }); + }); + + it('add user.viewer2 to event', async () => { + await time.waitMs(100); + await eventlist.add({ + event: 'sub', + userId: user.viewer2.userId, + }); + }); + + it('!subs should return user.viewer2', async () => { + const r = await twitch.subs({ sender: user.viewer }); + assert.strictEqual(r[0].response, prepare('subs', { + lastSubAgo: 'a few seconds ago', + lastSubUsername: user.viewer2.userName, + onlineSubCount: 0, + })); + }); + + it('add user.viewer3 to events', async () => { + await time.waitMs(100); + await eventlist.add({ + event: 'sub', + userId: user.viewer3.userId, + }); + }); + + it('!subs should return user.viewer3', async () => { + const r = await twitch.subs({ sender: user.viewer }); + assert.strictEqual(r[0].response, prepare('subs', { + lastSubAgo: 'a few seconds ago', + lastSubUsername: user.viewer3.userName, + onlineSubCount: 0, + })); + }); + + it('Add user.viewer, user.viewer2, user.viewer3 to online users', async () => { + await AppDataSource.getRepository(User).update({}, { isOnline: true }); + }); + + it('!subs should return user.viewer3 and 3 online subs', async () => { + const r = await twitch.subs({ sender: user.viewer }); + assert.strictEqual(r[0].response, prepare('subs', { + lastSubAgo: 'a few seconds ago', + lastSubUsername: user.viewer3.userName, + onlineSubCount: 3, + })); + }); +}); diff --git a/backend/test/tests/userinfo/stats.js b/backend/test/tests/userinfo/stats.js new file mode 100644 index 000000000..956ff8940 --- /dev/null +++ b/backend/test/tests/userinfo/stats.js @@ -0,0 +1,33 @@ +/* global describe it beforeEach */ + +import('../../general.js'); + +import { db, message, user } from '../../general.js'; + +import assert from 'assert'; +import userinfo from '../../../dest/systems/userinfo.js' + +describe('Userinfo - stats() - @func3', () => { + beforeEach(async () => { + await db.cleanup(); + await message.prepare(); + await user.prepare(); + }); + + const hours = '0'; + const points = '0'; + const messages = '0'; + const tips = '0.00'; + const bits = '0'; + const level = '0'; + + it('!stats testuser should show testuser data', async () => { + const r = await userinfo.showStats({ parameters: user.viewer.userName, sender: user.owner }); + assert.strictEqual(r[0].response, `$touser | Level ${level} | ${hours} hours | ${points} points | ${messages} messages | €${tips} | ${bits} bits | 0 months`, user.owner, 1000); + }); + + it('!stats should show owner data', async () => { + const r = await userinfo.showStats({ parameters: '', sender: user.owner }); + assert.strictEqual(r[0].response, `$sender | Level ${level} | ${hours} hours | ${points} points | ${messages} messages | €${tips} | ${bits} bits | 0 months`, user.owner, 1000); + }); +}); diff --git a/backend/test/tests/users/getUsernamesFromIds.js b/backend/test/tests/users/getUsernamesFromIds.js new file mode 100644 index 000000000..3b25e32b8 --- /dev/null +++ b/backend/test/tests/users/getUsernamesFromIds.js @@ -0,0 +1,46 @@ +/* global */ + +import assert from 'assert'; +import { AppDataSource } from '../../../dest/database.js'; +import('../../general.js'); + +import { User } from '../../../dest/database/entity/user.js'; +import users from '../../../dest/users.js'; +import { db } from '../../general.js'; +import { message } from '../../general.js'; + +// users +const testuser = { userName: 'testuser', userId: '1' }; +const testuser2 = { userName: 'testuser2', userId: '2' }; +const testuser3 = { userName: 'testuser3', userId: '3' }; +const nightbot = { userName: 'nightbot', userId: '4' }; + +let translatedIds; + +describe('User - getUsernamesFromIds - @func1', () => { + before(async () => { + await db.cleanup(); + await message.prepare(); + + await AppDataSource.getRepository(User).save(testuser); + await AppDataSource.getRepository(User).save(testuser2); + await AppDataSource.getRepository(User).save(testuser3); + await AppDataSource.getRepository(User).save(nightbot); + }); + + describe('getUsernamesFromIds should get correct usernames', () => { + it('ask for 4 ids + 1 duplication', async () => { + translatedIds = await users.getUsernamesFromIds(['1','2','3','4','1']); + }); + + it('expecting 4 values', async () => { + assert.strictEqual(translatedIds.size, 4); + }); + + for (const user of [testuser, testuser2, testuser3, nightbot]) { + it(`Expecting id ${user.userId} have correct username ${user.userName}`, () => { + assert.strictEqual(translatedIds.get(user.userId), user.userName); + }); + } + }); +}); diff --git a/backend/tools/backup.js b/backend/tools/backup.js new file mode 100644 index 000000000..f6ef24fbe --- /dev/null +++ b/backend/tools/backup.js @@ -0,0 +1,275 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +import fs from 'fs' +import { normalize } from 'path'; + +import _ from 'lodash'; +import { DataSource } from 'typeorm'; +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' + +const argv = yargs(hideBin(process.argv)) + .usage('node tools/backup.js') + .example('node tools/backup.js backup ./backup') + .example('node tools/backup.js restore ./backup') + .example('node tools/backup.js list') + .example('node tools/backup.js save alert ./backup-alert.json') + .example('node tools/backup.js load alert ./backup-alert.json') + .command('list', 'list of tables') + .command('backup [directory]', 'save full backup to [directory]', (yargs) => { + yargs.demandOption(['directory'], 'Please provide path to your backup directory'); + yargs.positional('path', { + type: 'string', + describe: '/path/to/your/backup/', + }); + }) + .command('restore [directory]', 'restore tables from [directory]', (yargs) => { + yargs.demandOption(['directory'], 'Please provide path to your backup directory'); + yargs.positional('path', { + type: 'string', + describe: '/path/to/your/backup/', + }); + }) + .command('save [table] [path]', 'save backup of [table] to [path]', (yargs) => { + yargs.demandOption(['table'], 'Please provide table to backup'); + yargs.demandOption(['path'], 'Please provide path to your backup JSON file'); + yargs.positional('table', { + type: 'string', + describe: 'table in database, e.g. settings, alert, etc.', + }); + yargs.positional('path', { + type: 'string', + describe: '/path/to/your/backup/file.json', + }); + }) + .command('load [table] [path]', 'load backup of [table] from [path]', (yargs) => { + yargs.demandOption(['table'], 'Please provide table to backup'); + yargs.demandOption(['path'], 'Please provide path to your backup JSON file'); + yargs.positional('table', { + type: 'string', + describe: 'table in database, e.g. settings, alert, etc.', + }); + yargs.positional('path', { + type: 'string', + describe: '/path/to/your/backup/file.json', + }); + }) + .demandCommand() + .help() + .argv; + +import { getMigrationType } from '../dest/helpers/getMigrationType.js'; + +async function main() { + const type = process.env.TYPEORM_CONNECTION; + const migrationsRun = argv._[0] === 'restore'; + + const MySQLDataSourceOptions = { + type: 'mysql', + connectTimeout: 60000, + acquireTimeout: 120000, + host: process.env.TYPEORM_HOST, + port: Number(process.env.TYPEORM_PORT ?? 3306), + username: process.env.TYPEORM_USERNAME, + password: process.env.TYPEORM_PASSWORD, + database: process.env.TYPEORM_DATABASE, + logging: ['error'], + synchronize: process.env.FORCE_DB_SYNC === 'IKnowWhatIamDoing', + migrationsRun: true, + entities: [ 'dest/database/entity/*.js' ], + subscribers: [ 'dest/database/entity/*.js' ], + migrations: [ `dest/database/migration/mysql/**/*.js` ], + } ; + + const PGDataSourceOptions = { + type: 'postgres', + host: process.env.TYPEORM_HOST, + port: Number(process.env.TYPEORM_PORT ?? 3306), + username: process.env.TYPEORM_USERNAME, + password: process.env.TYPEORM_PASSWORD, + database: process.env.TYPEORM_DATABASE, + logging: ['error'], + synchronize: process.env.FORCE_DB_SYNC === 'IKnowWhatIamDoing', + migrationsRun: true, + entities: [ 'dest/database/entity/*.js' ], + subscribers: [ 'dest/database/entity/*.js' ], + migrations: [ `dest/database/migration/postgres/**/*.js` ], + }; + + const SQLiteDataSourceOptions = { + type: 'better-sqlite3', + database: process.env.TYPEORM_DATABASE ?? 'sogebot.db', + logging: ['error'], + synchronize: process.env.FORCE_DB_SYNC === 'IKnowWhatIamDoing', + migrationsRun: true, + entities: [ 'dest/database/entity/*.js' ], + subscribers: [ 'dest/database/entity/*.js' ], + migrations: [ `dest/database/migration/sqlite/**/*.js` ], + }; + + let AppDataSource; + if (process.env.TYPEORM_CONNECTION === 'mysql' || process.env.TYPEORM_CONNECTION === 'mariadb') { + AppDataSource = new DataSource(MySQLDataSourceOptions); + } else if (process.env.TYPEORM_CONNECTION === 'postgres') { + AppDataSource = new DataSource(PGDataSourceOptions); + } else { + AppDataSource = new DataSource(SQLiteDataSourceOptions); + } + await AppDataSource.initialize(); + const typeToLog = { + 'better-sqlite3': 'SQLite3', + mariadb: 'MySQL/MariaDB', + mysql: 'MySQL/MariaDB', + postgres: 'PostgreSQL', + }; + console.log(`Initialized ${typeToLog[type]} database (${normalize(String(AppDataSource.options.database))})`); + if (argv._[0] === 'backup') { + const metadatas = AppDataSource.entityMetadatas; + const relationTable = []; + const tables = metadatas + .map((table) => { + const relations = table.ownRelations.filter(o => { + return o.relationType === 'many-to-one'; + }).map(o => o.target); + for (const rel of relations) { + if (!relationTable.includes(rel)) { + relationTable.push(rel); + } + } + return table.tableName; + }) + .filter(table => !relationTable.includes(table)); + + process.stdout.write(`Checking if directory ${argv.directory} exists`); + if (!fs.existsSync(argv.directory)) { + throw new Error('Directory does not exist!'); + } + process.stdout.write(`...OK\n`); + + for (const table of tables) { + if (table === 'twitch_tag') { + continue; // only cache + } + process.stdout.clearLine(0); + process.stdout.write(`Processing table ${table}`); + const entity = metadatas.find(o => o.tableName === table); + const relations = entity.ownRelations.map(o => o.propertyName); + const data = await AppDataSource.getRepository(entity.tableName).find({ relations }); + fs.writeFileSync(`${argv.directory}/${table}.json`, JSON.stringify(data, null, 2)); + process.stdout.write(`...OK\n`); + } + } + + if (argv._[0] === 'restore') { + process.stdout.write(`Checking if directory ${argv.directory} exists`); + if (!fs.existsSync(argv.directory)) { + throw new Error('Directory does not exist!'); + } + process.stdout.write(`...OK\n`); + + const files = fs.readdirSync(argv.directory); + + const tableDeleted = []; + + for (const table of files.map(o => o.split('.')[0])) { + process.stdout.clearLine(0); + process.stdout.write(`Processing table ${table}`); + + const backupData = JSON.parse(fs.readFileSync(`${argv.directory}/${table}.json`)); + const entity = await AppDataSource.entityMetadatas.find(o => o.tableName === table); + const relations = entity.ownRelations.map(o => o.type); + if (type === 'mysql' || type === 'mariadb') { + if (!tableDeleted.includes(entity.tableName)) { + await AppDataSource.getRepository(entity.tableName).query(`DELETE FROM \`${entity.tableName}\` WHERE 1=1`); + tableDeleted.push(entity.tableName); + } + for (const relation of relations) { + if (!tableDeleted.includes(AppDataSource.getRepository(relation).metadata.tableName)) { + await AppDataSource.getRepository(entity.tableName).query(`DELETE FROM \`${AppDataSource.getRepository(relation).metadata.tableName}\` WHERE 1=1`); + tableDeleted.push(AppDataSource.getRepository(relation).metadata.tableName); + } + } + } else { + if (!tableDeleted.includes(entity.tableName)) { + await AppDataSource.getRepository(entity.tableName).query(`DELETE FROM "${entity.tableName}" WHERE 1=1`); + tableDeleted.push(entity.tableName); + } + for (const relation of relations) { + if (!tableDeleted.includes(AppDataSource.getRepository(relation).metadata.tableName)) { + await AppDataSource.getRepository(entity.tableName).query(`DELETE FROM "${AppDataSource.getRepository(relation).metadata.tableName}" WHERE 1=1`); + tableDeleted.push(AppDataSource.getRepository(relation).metadata.tableName); + } + } + } + + for (const ch of _.chunk(backupData, 100)) { + process.stdout.write('.'); + await AppDataSource.getRepository(entity.tableName).save(ch); + } + + process.stdout.write(`...OK\n`); + + } + } + + if (argv._[0] === 'list') { + console.log('Available tables:\n'); + const metadatas = await AppDataSource.entityMetadatas; + const relationTable = []; + const tables = metadatas + .map((table) => { + const relations = table.ownRelations.filter(o => { + return o.relationType === 'many-to-one'; + }).map(o => o.target); + for (const rel of relations) { + if (!relationTable.includes(rel)) { + relationTable.push(rel); + } + } + return table.tableName; + }); + // main tables + console.log(tables + .filter(table => !relationTable.includes(table)) + .join('\n')); + } + + if (argv._[0] === 'save') { + const metadatas = await AppDataSource.entityMetadatas; + const entity = metadatas.find(o => o.tableName === argv.table); + if (!entity) { + console.error(`Table ${argv.table} was not found in database`); + process.exit(1); + } + const relations = entity.ownRelations.map(o => o.propertyName); + const backupData = await AppDataSource.getRepository(entity.tableName).find({ relations }); + console.log(`Database table ${argv.table} saved to ${argv.path}`); + fs.writeFileSync(argv.path, JSON.stringify(backupData, null, 2)); + } + + if (argv._[0] === 'load') { + const backupData = JSON.parse(fs.readFileSync(argv.path)); + const entity = await AppDataSource.entityMetadatas.find(o => o.tableName === argv.table); + const relations = entity.ownRelations.map(o => o.type); + if (type === 'mysql' || type === 'mariadb') { + await AppDataSource.getRepository(entity.tableName).query(`DELETE FROM \`${entity.tableName}\` WHERE 1=1`); + for (const relation of relations) { + await AppDataSource.getRepository(entity.tableName).query(`DELETE FROM \`${relation}\` WHERE 1=1`); + } + } else { + await AppDataSource.getRepository(entity.tableName).query(`DELETE FROM "${entity.tableName}" WHERE 1=1`); + for (const relation of relations) { + await AppDataSource.getRepository(entity.tableName).query(`DELETE FROM "${relation}" WHERE 1=1`); + } + } + process.stdout.write('Processing'); + for (const ch of _.chunk(backupData, 100)) { + process.stdout.write('.'); + await AppDataSource.getRepository(entity.tableName).save(ch); + } + console.log('DONE!'); + } + process.exit(); +} +main(); \ No newline at end of file diff --git a/backend/tools/changePackageVersion.js b/backend/tools/changePackageVersion.js new file mode 100644 index 000000000..4cb58e9ad --- /dev/null +++ b/backend/tools/changePackageVersion.js @@ -0,0 +1,5 @@ +import fs from 'fs' + +import pkg from '../package.json' assert { type: "json" }; + +fs.writeFileSync('./package.json', JSON.stringify({ ...pkg, version: process.argv[2] }, null, 2)); \ No newline at end of file diff --git a/backend/tools/changelog.js b/backend/tools/changelog.js new file mode 100644 index 000000000..9143c6862 --- /dev/null +++ b/backend/tools/changelog.js @@ -0,0 +1,223 @@ +import {spawnSync} from 'child_process'; + +import gitSemverTags from 'git-semver-tags'; +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' + +yargs(hideBin(process.argv)) + .command('cli ', 'create changelog between commits/tags', (yargs) => { + return yargs + .positional('commit', { + describe: 'commit(preferred) or tag interval e.g. 9.0.3 or 9.0.2..9.0.3', + type: 'string', + }) + }, (argv) => { + const changesSpawn = spawnSync('git', ['log', argv.commit, '--oneline']); + for (const output of changes(changesSpawn.stdout.toString().split('\n'))) { + process.stdout.write(output); + } + }) + .command('nextTagMajor', 'get next major tag', () => {}, (argv) => { + gitSemverTags().then((tags) => { + const latestTag = tags[0]; + + const changesList = []; + const changesSpawn = spawnSync('git', ['log', `${latestTag}...HEAD`, '--oneline']); + changesList.push(...changes(changesSpawn.stdout.toString().split('\n'))); + + const [ latestMajorVersion, latestMinorVersion, latestPatchVersion ] = tags[0].split('.'); + process.stdout.write(`${Number(latestMajorVersion)+1}.0.0`); + }); + }) + .command('nextTag', 'get next tag', () => {}, (argv) => { + gitSemverTags().then((tags) => { + const latestTag = tags[0]; + + const changesList = []; + const changesSpawn = spawnSync('git', ['log', `${latestTag}...HEAD`, '--oneline']); + changesList.push(...changes(changesSpawn.stdout.toString().split('\n'))); + + const [ latestMajorVersion, latestMinorVersion, latestPatchVersion ] = tags[0].split('.'); + + if (changesList.includes('### BREAKING CHANGES\n')) { + process.stdout.write(`${Number(latestMajorVersion)+1}.0.0`); + } else if (changesList.join().includes('-feat-blue')) { + // new tag + process.stdout.write(`${latestMajorVersion}.${Number(latestMinorVersion)+1}.0`); + } else { + process.stdout.write(`${latestMajorVersion}.${latestMinorVersion}.${Number(latestPatchVersion)+1}`); + } + }); + }) + .command('nextSnapshot', 'get next tag', () => {}, (argv) => { + gitSemverTags().then((tags) => { + const [ latestMajorVersion, latestMinorVersion ] = tags[0].split('.'); + process.stdout.write(`${latestMajorVersion}.${Number(latestMinorVersion)+1}.0-SNAPSHOT`); + }); + }) + .command('generate', 'generate changelog', () => {}, (argv) => { + gitSemverTags().then((tags) => { + const tagsToGenerate = []; + const [ latestMajorVersion, latestMinorVersion, latestPatchVersion ] = tags[0].split('.'); + + for (let i = latestPatchVersion; i >= 0; i--) { + tagsToGenerate.push(`${latestMajorVersion}.${latestMinorVersion}.${i}`); + } + + // we need last release before + const beforeTag = tags[tags.findIndex((val) => val === tagsToGenerate[tagsToGenerate.length - 1]) + 1]; + const majorTagRelease = tagsToGenerate[tagsToGenerate.length - 1]; + const changesList = []; + + // we have minor patches + if (tagsToGenerate.length > 1) { + const latestMinorTag = tagsToGenerate[0]; + + // get change between new and last versions + changesList.push(`## ${latestMinorTag}\n\n`); + let changesSpawn; + if (tagsToGenerate[tagsToGenerate.length - 2] === latestMinorTag) { + changesSpawn = spawnSync('git', ['log', `${majorTagRelease}...${latestMinorTag}`, '--oneline']); + } else { + changesSpawn = spawnSync('git', ['log', `${tagsToGenerate[1]}...${latestMinorTag}`, '--oneline']); + } + changesList.push(...changes(changesSpawn.stdout.toString().split('\n'))); + } + + // major patch changelog + let changesSpawn; + if (tagsToGenerate.length > 1 && tagsToGenerate[1] !== majorTagRelease) { + changesList.push(`## ${latestMajorVersion}.${latestMinorVersion}.0 - ${tagsToGenerate[1]}\n\n`); + changesSpawn = spawnSync('git', ['log', `${beforeTag}...${tagsToGenerate[1]}`, '--oneline']); + } else { + changesList.push(`## ${latestMajorVersion}.${latestMinorVersion}.0\n\n`); + changesSpawn = spawnSync('git', ['log', `${beforeTag}...${majorTagRelease}`, '--oneline']); + } + changesList.push(...changes(changesSpawn.stdout.toString().split('\n'))); + + for (const output of changesList) { + for (const line of output) { + process.stdout.write(line); + } + } + }) + }) + .demandCommand() + .parse() + +function changes(changesList) { + // sort alphabetically + changesList.sort((a, b) => { + const i = a.indexOf(' '); + const i2 = b.indexOf(' '); + a = a.slice(i+1).trim(); + b = b.slice(i2+1).trim(); + if(a < b) { + return -1; + } + if(a > b) { + return 1; + } + return 0; + }); + const output = []; + + // split commit and message and add fixes + changesList = changesList.map(o => { + const i = o.indexOf(' '); + const commit = o.slice(0, i).trim(); + + const body = spawnSync('git', ['log', commit, '-n', '1', '--pretty=format:%B']); + const fixesRegexp = /(Fixes|Closes|Fixed|Closed)\s(\#\d*)/gmi; + const fixesRegexpForum = /(Fixes|Closes|Fixed|Closed)\s(.*)/gmi; + const fixesRegexpDiscord = /(Fixes|Closes|Fixed|Closed)\s.*discord.*?(\d+)$/gmi; + const fixesRegexpIdeas = /(Fixes|Closes|Fixed|Closed)\s.*ideas\.sogebot\.xyz.*?(\d+)/gmi; + const fixesRegexpBreaking = /BREAKING (CHANGES|CHANGE):\s(.*)/gmis; + let fixes = []; + let breakingChange = null; + + if (body.stdout.toString().match(fixesRegexpBreaking)) { + const text = body.stdout.toString().match(fixesRegexpBreaking)[0]; + breakingChange = text; + } + + if (body.stdout.toString().match(fixesRegexpIdeas)) { + const text = body.stdout.toString().match(fixesRegexpIdeas)[0]; + const link = text.split(' ')[1]; + if (link) { + const number = link.match(/\d*$/)[0]; + fixes = [ + `Fixes [ideas(deprecated)#${number}](${link})`, + ]; + } + } else if (body.stdout.toString().match(fixesRegexpDiscord)) { + const text = body.stdout.toString().match(fixesRegexpDiscord)[0]; + const link = text.split(' ')[1]; + if (link) { + const number = link.match(/\d*$/)[0]; + fixes = [ + `Fixes [discord#${number}](${link})`, + ]; + } + } else if (body.stdout.toString().match(fixesRegexp)) { + fixes = body.stdout.toString().match(fixesRegexp); + } else if (body.stdout.toString().match(fixesRegexpForum)) { + const text = body.stdout.toString().match(fixesRegexpForum)[0]; + const link = text.split(' ')[1]; + if (link) { + const number = link.match(/\d*$/)[0]; + fixes = [ + `Fixes [community#${number}](${link})`, + ]; + } + } + + return { + commit, message: o.slice(i+1).trim(), fixes, breakingChange, + }; + }); + + // breaking changes from all commits + if (changesList.filter(o => o.breakingChange).length > 0) { + // print out bugfixes + output.push('### BREAKING CHANGES\n'); + for (const change of changesList.filter(o => o.breakingChange)) { + output.push('* ' + change.breakingChange + .replace('BREAKING', '') + .replace('CHANGES:', '') + .replace('CHANGE:', '') + .replace(/[\n\r]/g, ' ') // remove newlines + .replace(/\s{2,}/g, ' ') // remove multiple spaces + ); + } + output.push('\n\n'); + } + + // filter to have only fix and feat + changesList = changesList.filter(o => { + return !o.message.startsWith('build'); + }); + + for (const change of changesList) { + output.push(prepareMessage(change)); + } + + return output; +} + +function isFix (msg) { + return msg.startsWith('fix'); +} + +function prepareMessage(change) { + if (change.commit.length === 0) { + return '' + } + const regexp = /(.*?):\((?\w*)\)\: (?.*)/; + const match = regexp.exec(change.message); + try { + return `- ${change.commit} ${change.message}${change.fixes.length > 0 ? ', ' + change.fixes.join(', ') : ''}\n`; + } catch (e) { + return `- ${change.commit} ${change.message}\n`; + } +} \ No newline at end of file diff --git a/backend/tools/commits.sh b/backend/tools/commits.sh new file mode 100644 index 000000000..7486836c9 --- /dev/null +++ b/backend/tools/commits.sh @@ -0,0 +1 @@ +git --no-pager log --pretty=format:"* [%h] - %s" --reverse $1^..HEAD | sed 's/ - / \*\*/g' | sed 's/: /\*\*: /g' \ No newline at end of file diff --git a/backend/tools/fetchRates.js b/backend/tools/fetchRates.js new file mode 100644 index 000000000..0c5383ec3 --- /dev/null +++ b/backend/tools/fetchRates.js @@ -0,0 +1,13 @@ +import fs from 'fs' +import axios from 'axios' + +const appId = process.env.OPENEXCHANGE_APPID; +const ratesFile = './backend/src/helpers/currency/rates.ts'; + +axios.get('https://openexchangerates.org/api/latest.json?app_id=' + appId) + .then(res => { + const rates = res.data.rates; + fs.writeFileSync(ratesFile, `export default ${JSON.stringify(rates, null, 2)};`); + console.log('Rates fetched OK!'); + }) + .catch(err => console.error(err)); \ No newline at end of file diff --git a/backend/tools/generate-authors.sh b/backend/tools/generate-authors.sh new file mode 100644 index 000000000..9063f56a2 --- /dev/null +++ b/backend/tools/generate-authors.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -e + +cd "$(dirname "$(readlink -f "$BASH_SOURCE")")/.." + +# some authors doesn't want to be in AUTHORS -> space as \\s +EXCLUDED_AUTHORS=("alreadylostmyself") + +# see also ".mailmap" for how email addresses and names are deduplicated +{ + cat <<-'EOH' + # This file lists all individuals having contributed content to the repository. + # For how it is generated, see `tools/generate-authors.sh`. + EOH + git config --global grep.patternType perl + + excluded_regexp='^(?!' + for i in "${!EXCLUDED_AUTHORS[@]}" + do + : + if [ $i -gt 0 ] + then + excluded_regexp=$excluded_regexp'|'${EXCLUDED_AUTHORS[$i]} + else + excluded_regexp=$excluded_regexp${EXCLUDED_AUTHORS[$i]} + fi + done + excluded_regexp=$excluded_regexp').*$' + + echo + git log --format='%aN <%aE>' --author=$excluded_regexp | LC_ALL=C.UTF-8 sort -uf +} > AUTHORS diff --git a/backend/tools/migrationCheck.js b/backend/tools/migrationCheck.js new file mode 100644 index 000000000..2f6b4de30 --- /dev/null +++ b/backend/tools/migrationCheck.js @@ -0,0 +1,52 @@ +import {exec, spawn } from 'child_process' +import path from 'path'; +import dotenv from 'dotenv'; +dotenv.config(); + +import { getMigrationType } from '../dest/helpers/getMigrationType.js'; + +async function test() { + await new Promise((resolve, reject) => { + try { + exec('npx typeorm migration:run -d dest/database.js', { + cwd: path.join(process.cwd(), 'backend'), + env: { + ...process.env, + 'TYPEORM_ENTITIES': 'dest/database/entity/*.js', + 'TYPEORM_MIGRATIONS': `dest/database/migration/${getMigrationType(process.env.TYPEORM_CONNECTION)}/**/*.js`, + }, + }, (error, stdout, stderr) => { + process.stdout.write(stdout); + process.stderr.write(stderr); + if (error) { + reject(error); + process.stderr.write(error); + process.exit(1); + } + resolve(); + }); + } catch(e) { + reject(); + } + }); + + const out2 = spawn(process.platform === 'win32' ? 'npx.cmd' : 'npx', 'typeorm migration:generate -d dest/database.js --ch ./'.split(' '), { + cwd: path.join(process.cwd(), 'backend'), + env: { + ...process.env, + 'TYPEORM_ENTITIES': 'dest/database/entity/*.js', + 'TYPEORM_MIGRATIONS': `dest/database/migration/${getMigrationType(process.env.TYPEORM_CONNECTION)}/**/*.js`, + }, + }); + + out2.stdout.on('data', function(msg){ + console.log(`${msg}`); + }); + out2.stderr.on('data', function(msg){ + console.error(`${msg}`); + }); + + out2.on('exit', code => process.exit(code)); +} + +test(); \ No newline at end of file diff --git a/backend/tools/pre-commit-message.js b/backend/tools/pre-commit-message.js new file mode 100644 index 000000000..8b98178ba --- /dev/null +++ b/backend/tools/pre-commit-message.js @@ -0,0 +1,10 @@ +import fs from 'node:fs'; + +const COMMIT_EDITMSG = process.argv[2]; +const commitMessage = fs.readFileSync(COMMIT_EDITMSG).toString().trim(); + +console.log(`Cleaning up hard space from commit message: "${commitMessage}"`); + +fs.writeFileSync(COMMIT_EDITMSG, commitMessage + .replace(/[  ]/gm, ' '), // eslint-disable-line no-irregular-whitespace +); \ No newline at end of file diff --git a/backend/tools/pre-commit-tests.sh b/backend/tools/pre-commit-tests.sh new file mode 100644 index 000000000..20378fa99 --- /dev/null +++ b/backend/tools/pre-commit-tests.sh @@ -0,0 +1,7 @@ +echo "^^^^ checking describe.only ^^^^" +if ! grep -r -l 'describe.only' ./test/ +then + exit 0 +else + exit 1 +fi; \ No newline at end of file diff --git a/backend/tools/release-docs.sh b/backend/tools/release-docs.sh new file mode 100644 index 000000000..3421c5b9d --- /dev/null +++ b/backend/tools/release-docs.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +echo "Releasing new docs" +rm -rf new-docs +git checkout -B docs-release +mkdir new-docs +cp -r docs/_master new-docs/ +cp -r docs/_master/* new-docs/ +cp docs/.nojekyll new-docs/ +cp docs/_navbar.md new-docs/ +cp docs/index.html new-docs/ +sed -i 's/_master//g' new-docs/_sidebar.md +rm -rf docs +mv new-docs docs +git add -A docs +git commit -am 'chore: update docs + +[skip-tests]' +git push -fu origin docs-release +git checkout master +echo "Done" \ No newline at end of file diff --git a/backend/tools/release.sh b/backend/tools/release.sh new file mode 100644 index 000000000..fc80b19b5 --- /dev/null +++ b/backend/tools/release.sh @@ -0,0 +1,44 @@ +currentSnapshot=$(node tools/changelog.js nextSnapshot) +file=./package.json + +if [ "$1" = "major" ] + then + nextTag=$(node tools/changelog.js nextTagMajor) + else + nextTag=$(node tools/changelog.js nextTag) +fi + + +echo Current Snapshot: $currentSnapshot +echo Next tag: $nextTag +echo + +echo Switching to master branch +git checkout master + +echo Updating master branch +git pull -r origin master + +echo Updating package.json version from $currentSnapshot to $nextTag + +node ./tools/changePackageVersion.js $nextTag +git add $file +git commit -m "build: $nextTag" +echo Pushing build commit $nextTag +git push origin master + +echo Creating tag $nextTag +git tag $nextTag +echo Pushing to github and triggering release +git push origin --tags +echo Released $nextTag + +nextSnapshot=$(node tools/changelog.js nextSnapshot) +echo Updating package.json version from with $nextTag to $nextSnapshot +node ./tools/changePackageVersion.js $nextSnapshot +git add $file +git commit -m "build: $nextSnapshot" +echo Pushing snapshot commit $nextSnapshot +git push origin master + +echo Done! \ No newline at end of file diff --git a/backend/tools/runTests.js b/backend/tools/runTests.js new file mode 100644 index 000000000..dab363b38 --- /dev/null +++ b/backend/tools/runTests.js @@ -0,0 +1,146 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +import child_process from 'child_process' +import fs from 'fs' +import path from 'path' + +let status = 0; +async function retest() { + const file = fs.readFileSync('report').toString(); + const regexp = /^ {2}\d\)(.*)$/gm; + const match = file.match(regexp); + + if (match) { + for (const suite of new Set(match.map((o) => { + return o.trim().split(/\d\) /)[1]; + }))) { + await new Promise((resolve) => { + console.log('------------------------------------------------------------------------------'); + console.log('\tRemoving sogebot.db file'); + console.log('------------------------------------------------------------------------------'); + if (fs.existsSync('./sogebot.db')) { + fs.unlinkSync('./sogebot.db'); + } + + console.log('------------------------------------------------------------------------------'); + console.log('\t=> Re-Running ' + suite + ' tests'); + console.log('------------------------------------------------------------------------------'); + const p = child_process.spawn('npx', [ + 'nyc', + '--clean=false', + 'mocha', + '-r', 'tsconfig-paths/register', + '-r', 'source-map-support/register', + '--timeout', '1200000', + '--exit', + '--fgrep="' + suite + '"', + '--recursive', + 'test/', + ], { shell: true, env: process.env, cwd: path.join(process.cwd(), 'backend') }); + + let output = ''; + p.stdout.on('data', (data) => { + process.stdout.write(data.toString()); + output += data.toString(); + }); + + p.stderr.on('data', (data) => { + process.stderr.write(data.toString()); + output += data.toString(); + }); + + p.on('close', (code) => { + if (status === 0) { + status = code; + } + if (code !== 0 || output.includes(' 0 passing')) { + status = 1; // force status 1 + console.log('------------------------------------------------------------------------------'); + console.log('\t=> Failed ' + suite + ' tests'); + console.log('------------------------------------------------------------------------------'); + } else { + console.log('------------------------------------------------------------------------------'); + console.log('\t=> OK ' + suite + ' tests'); + console.log('------------------------------------------------------------------------------'); + } + resolve(); + }); + }); + } + } else { + if (status === 1) { + console.log('\n\n Didn\'t found any tests to rerun, but still got some error during test run'); + } else { + console.log('\n\t No tests to rerun :)\n\n'); + } + } + + console.log('------------------------------------------------------------------------------'); + console.log('\t=> Merging coverage.json'); + console.log('------------------------------------------------------------------------------'); + child_process.spawnSync('npx', [ + 'nyc', + 'merge', + './.nyc_output/', + './coverage/coverage-final.json', + ], { shell: true, env: process.env }); + process.exit(status); +} + +async function test() { + await new Promise((resolve) => { + let p; + if (process.env.TESTS) { + p = child_process.spawn('npx', [ + 'nyc', + '--reporter=json', + '--clean=false', + 'mocha', + '-r', 'source-map-support/register', + '--timeout', '1200000', + '--grep="' + process.env.TESTS + '"', + '--exit', + '--recursive', + 'test/', + ], { shell: true, env: process.env, cwd: path.join(process.cwd(), 'backend') }); + } else { + // run all default behavior + p = child_process.spawn('npx', [ + 'nyc', + '--reporter=json', + '--clean=false', + 'mocha', + '-r', 'source-map-support/register', + '--timeout', '1200000', + '--exit', + '--recursive', + 'test/', + ], { shell: true, env: process.env, cwd: path.join(process.cwd(), 'backend') }); + } + + const report = fs.createWriteStream('report'); + p.stdout.on('data', (data) => { + report.write(data.toString()); + process.stdout.write(data.toString()); + }); + + p.stderr.on('data', (data) => { + process.stderr.write(data.toString()); + }); + + p.on('close', (code) => { + status = code; + resolve(); + }); + }); + + if(status !== 0) { + status = 0; // reset status for retest + retest(); + } else { + process.exit(0); + } +} + +test(); \ No newline at end of file diff --git a/backend/tsconfig.eslint.json b/backend/tsconfig.eslint.json new file mode 100644 index 000000000..3221e04d6 --- /dev/null +++ b/backend/tsconfig.eslint.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "allowJs": true + }, + "extends": "./tsconfig.json", + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 000000000..1d34138a7 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,46 @@ +{ + "compilerOptions": { + "strictPropertyInitialization": false, + "forceConsistentCasingInFileNames": true, + "importHelpers": true, + "target": "es2022", + "strict": true, + "module": "es2022", + "incremental": true, + "removeComments": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "node", + "lib": ["es2022"], + "resolveJsonModule": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "declaration": true, + "noResolve": false, + "baseUrl": "./src", + "paths": { + "~/*": [ + "./*" + ], + "@entity/*": [ + "./database/entity/*" + ] + }, + "outDir": "./dest", + "inlineSourceMap": true, + "typeRoots": ["./node_modules/@types"] + }, + "include": [ + "./src", + "./d.ts/*", + "../d.ts/types.d.ts", + "../d.ts/*" + ], + "exclude": [ + "test", + "node_modules" + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0734d08ad..5a8e87608 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "sogebot.dev", + "name": "sogebot.tmp", "lockfileVersion": 3, "requires": true, "packages": { @@ -14,7 +14,10 @@ "custompatch": "^1.0.22" }, "devDependencies": { - "@types/uuid": "^9.0.2" + "@prantlf/jsonlint": "^14.0.3", + "@types/uuid": "^9.0.2", + "husky": "^8.0.0", + "lint-staged": "15.0.2" } }, "backend": { @@ -47,7 +50,6 @@ "cross-env": "7.0.3", "crypto-browserify": "3.12.0", "currency-symbol-map": "5.1.0", - "custompatch": "^1.0.23", "dayjs": "1.11.10", "decode-html": "2.0.0", "discord.js": "14.13.0", @@ -155,8 +157,6 @@ "eslint-plugin-require-extensions": "^0.1.3", "git-semver-tags": "7.0.1", "husky": "^8.0.3", - "jsonlint-mod": "1.7.6", - "lint-staged": "15.0.2", "minimist": "1.2.8", "mocha": "10.2.0", "nyc": "15.1.0", @@ -6296,6 +6296,134 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@prantlf/jsonlint": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/@prantlf/jsonlint/-/jsonlint-14.0.3.tgz", + "integrity": "sha512-Z9FrZd+cqCiqB6r/EHb4evj8HUqMgPvi7RVBQhFHYOJ292K7XmnKprNTngTqiUclCFn5MFqeZv20Jo5ZzlOCqw==", + "dev": true, + "dependencies": { + "ajv": "8.12.0", + "ajv-draft-04": "1.0.0", + "cosmiconfig": "8.1.3", + "diff": "5.1.0", + "fast-glob": "3.2.12" + }, + "bin": { + "jsonlint": "lib/cli.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@prantlf/jsonlint/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@prantlf/jsonlint/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@prantlf/jsonlint/node_modules/cosmiconfig": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.1.3.tgz", + "integrity": "sha512-/UkO2JKI18b5jVMJUp0lvKFMpa/Gye+ZgZjKD+DGEN9y7NRcf/nK1A0sp67ONmKtnDCNMS44E6jrk0Yc3bDuUw==", + "dev": true, + "dependencies": { + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + } + }, + "node_modules/@prantlf/jsonlint/node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@prantlf/jsonlint/node_modules/fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@prantlf/jsonlint/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@prantlf/jsonlint/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/@prantlf/jsonlint/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@reduxjs/toolkit": { "version": "1.9.7", "license": "MIT", @@ -20551,85 +20679,6 @@ "version": "0.1.1", "license": "MIT" }, - "node_modules/jsonlint-mod": { - "version": "1.7.6", - "dev": true, - "dependencies": { - "chalk": "^2.4.2", - "JSV": "^4.0.2", - "underscore": "^1.9.1" - }, - "bin": { - "jsonlint": "lib/cli.js" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/jsonlint-mod/node_modules/ansi-styles": { - "version": "3.2.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/jsonlint-mod/node_modules/chalk": { - "version": "2.4.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/jsonlint-mod/node_modules/color-convert": { - "version": "1.9.3", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/jsonlint-mod/node_modules/color-name": { - "version": "1.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonlint-mod/node_modules/escape-string-regexp": { - "version": "1.0.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/jsonlint-mod/node_modules/has-flag": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/jsonlint-mod/node_modules/supports-color": { - "version": "5.5.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/jsonparse": { "version": "1.3.1", "engines": [ @@ -20768,10 +20817,6 @@ "node": ">=0.8.0" } }, - "node_modules/JSV": { - "version": "4.0.2", - "dev": true - }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "license": "MIT", diff --git a/package.json b/package.json index 1eef82192..fcdcfdd2d 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,24 @@ "backend", "ui.public" ], + "main": "main.js", "scripts": { "postinstall": "custompatch || (exit 0)", - "tunnel": "ssh -L 5432:community.sogebot.xyz:15432 root@community.sogebot.xyz" + "tunnel": "ssh -L 5432:community.sogebot.xyz:15432 root@community.sogebot.xyz", + "prepare": "husky install", + "test": "node backend/tools/runTests.js", + "test:migration": "node backend/tools/migrationCheck.js", + "test:config:mysql": "cp backend/src/data/.env.mysql .env", + "test:config:postgres": "cp backend/src/data/.env.postgres .env", + "test:config:sqlite": "cp backend/src/data/.env.sqlite .env" }, "dependencies": { "custompatch": "^1.0.22" }, "devDependencies": { - "@types/uuid": "^9.0.2" + "@prantlf/jsonlint": "^14.0.3", + "@types/uuid": "^9.0.2", + "husky": "^8.0.0", + "lint-staged": "15.0.2" } }