├── TwitchChannelPointsMiner ├── classes │ ├── __init__.py │ ├── entities │ │ ├── __init__.py │ │ ├── Raid.py │ │ ├── PubsubTopic.py │ │ ├── Message.py │ │ ├── CommunityGoal.py │ │ ├── Campaign.py │ │ ├── EventPrediction.py │ │ ├── Stream.py │ │ ├── Drop.py │ │ ├── Streamer.py │ │ └── Bet.py │ ├── Exceptions.py │ ├── Gotify.py │ ├── Discord.py │ ├── Webhook.py │ ├── Pushover.py │ ├── Telegram.py │ ├── Settings.py │ ├── Matrix.py │ ├── TwitchWebSocket.py │ ├── Chat.py │ ├── AnalyticsServer.py │ └── TwitchLogin.py ├── __init__.py ├── utils.py ├── constants.py ├── logger.py └── TwitchChannelPointsMiner.py ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml ├── dependabot.yml ├── stale.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── deploy-docker.yml │ ├── github-clone-count-badge.yml │ └── github-traffic-count-badge.yml ├── assets ├── banner.png ├── prediction.png ├── chart-analytics-dark.png ├── chart-analytics-light.png ├── dark-theme.css ├── style.css ├── charts.html └── script.js ├── requirements.txt ├── DELETE_PYCACHE.bat ├── pickle_view.py ├── .vscode └── launch.json ├── TRAFFIC.md ├── CLONE.md ├── .pre-commit-config.yaml ├── Dockerfile ├── setup.py ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── example.py /TwitchChannelPointsMiner/classes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Timonade/Twitch-Channel-Points-Miner-v2/HEAD/assets/banner.png -------------------------------------------------------------------------------- /assets/prediction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Timonade/Twitch-Channel-Points-Miner-v2/HEAD/assets/prediction.png -------------------------------------------------------------------------------- /assets/chart-analytics-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Timonade/Twitch-Channel-Points-Miner-v2/HEAD/assets/chart-analytics-dark.png -------------------------------------------------------------------------------- /assets/chart-analytics-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Timonade/Twitch-Channel-Points-Miner-v2/HEAD/assets/chart-analytics-light.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | websocket-client 3 | pillow 4 | python-dateutil 5 | emoji 6 | millify 7 | pre-commit 8 | colorama 9 | flask 10 | irc 11 | pandas 12 | pytz 13 | validators 14 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __version__ = "2.0.0" 3 | from .TwitchChannelPointsMiner import TwitchChannelPointsMiner 4 | 5 | __all__ = [ 6 | "TwitchChannelPointsMiner", 7 | ] 8 | -------------------------------------------------------------------------------- /DELETE_PYCACHE.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | rmdir /s /q __pycache__ 3 | rmdir /s /q TwitchChannelPointsMiner\__pycache__ 4 | rmdir /s /q TwitchChannelPointsMiner\classes\__pycache__ 5 | rmdir /s /q TwitchChannelPointsMiner\classes\entities\__pycache__ -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/Exceptions.py: -------------------------------------------------------------------------------- 1 | class StreamerDoesNotExistException(Exception): 2 | pass 3 | 4 | 5 | class StreamerIsOfflineException(Exception): 6 | pass 7 | 8 | 9 | class WrongCookiesException(Exception): 10 | pass 11 | 12 | 13 | class BadCredentialsException(Exception): 14 | pass 15 | -------------------------------------------------------------------------------- /pickle_view.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Simple script to view contents of a cookie file stored in a pickle format 4 | 5 | import pickle 6 | import sys 7 | 8 | if __name__ == '__main__': 9 | argv = sys.argv 10 | if len(argv) <= 1: 11 | print("Specify a pickle file as a parameter, e.g. cookies/user.pkl") 12 | else: 13 | print(pickle.load(open(argv[1], 'rb'))) -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/Raid.py: -------------------------------------------------------------------------------- 1 | class Raid(object): 2 | __slots__ = ["raid_id", "target_login"] 3 | 4 | def __init__(self, raid_id, target_login): 5 | self.raid_id = raid_id 6 | self.target_login = target_login 7 | 8 | def __eq__(self, other): 9 | if isinstance(other, self.__class__): 10 | return self.raid_id == other.raid_id 11 | else: 12 | return False 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: run.py", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${cwd}/run.py", 12 | "console": "integratedTerminal", 13 | "justMyCode": true 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/PubsubTopic.py: -------------------------------------------------------------------------------- 1 | class PubsubTopic(object): 2 | __slots__ = ["topic", "user_id", "streamer"] 3 | 4 | def __init__(self, topic, user_id=None, streamer=None): 5 | self.topic = topic 6 | self.user_id = user_id 7 | self.streamer = streamer 8 | 9 | def is_user_topic(self): 10 | return self.streamer is None 11 | 12 | def __str__(self): 13 | if self.is_user_topic(): 14 | return f"{self.topic}.{self.user_id}" 15 | else: 16 | return f"{self.topic}.{self.streamer.channel_id}" 17 | -------------------------------------------------------------------------------- /TRAFFIC.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Markdown** 4 | ```markdown 5 | [![GitHub Traffic](https://img.shields.io/badge/dynamic/json?color=success&label=Views&query=count&url=https://gist.githubusercontent.com/rdavydov/ad9a3c6a8d9c322f9a6b62781ea94a93/raw/traffic.json&logo=github)](https://github.com/MShawon/github-clone-count-badge) 6 | 7 | ``` 8 | **HTML** 9 | ```html 10 | GitHub Traffic 11 | ``` 12 | -------------------------------------------------------------------------------- /CLONE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Markdown** 4 | 5 | ```markdown 6 | [![GitHub Clones](https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url=https://gist.githubusercontent.com/rdavydov/fed04b31a250ad522d9ea6547ce87f95/raw/clone.json&logo=github)](https://github.com/MShawon/github-clone-count-badge) 7 | 8 | ``` 9 | 10 | **HTML** 11 | ```html 12 | GitHub Clones 13 | ``` 14 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 150 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - pinned 10 | - security 11 | - bug 12 | - enhancement 13 | 14 | # Label to use when marking an issue as stale 15 | staleLabel: wontfix 16 | 17 | # Comment to post when marking an issue as stale. Set to `false` to disable 18 | markComment: > 19 | This issue has been automatically marked as stale because it has not had 20 | recent activity. It will be closed if no further activity occurs. Thank you 21 | for your contributions. 22 | 23 | # Comment to post when closing a stale issue. Set to `false` to disable 24 | closeComment: false 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.1.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-added-large-files 8 | - repo: https://github.com/pycqa/isort 9 | rev: 5.12.0 10 | hooks: 11 | - id: isort 12 | files: ^TwitchChannelPointsMiner/ 13 | args: ["--profile", "black"] 14 | - repo: https://github.com/psf/black 15 | rev: 22.3.0 16 | hooks: 17 | - id: black 18 | files: ^TwitchChannelPointsMiner/ 19 | - repo: https://github.com/pycqa/flake8 20 | rev: 3.9.2 21 | hooks: 22 | - id: flake8 23 | files: ^TwitchChannelPointsMiner/ 24 | args: 25 | - "--max-line-length=88" 26 | - "--extend-ignore=E501" 27 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/Gotify.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import requests 4 | 5 | from TwitchChannelPointsMiner.classes.Settings import Events 6 | 7 | class Gotify(object): 8 | __slots__ = ["endpoint", "priority", "events"] 9 | 10 | def __init__(self, endpoint: str, priority: int, events: list): 11 | self.endpoint = endpoint 12 | self.priority = priority 13 | self.events = [str(e) for e in events] 14 | 15 | def send(self, message: str, event: Events) -> None: 16 | if str(event) in self.events: 17 | requests.post( 18 | url=self.endpoint, 19 | data={ 20 | "message": dedent(message), 21 | "priority": self.priority 22 | }, 23 | ) 24 | -------------------------------------------------------------------------------- /assets/dark-theme.css: -------------------------------------------------------------------------------- 1 | body, .dropdown *, .input { 2 | background: #343E59; 3 | color: #fff; 4 | } 5 | a { 6 | color: #fff; 7 | } 8 | a:hover { 9 | color: #f9826c; 10 | } 11 | .box { 12 | background-color: #2B2D3E; 13 | } 14 | .tabs a { 15 | border-bottom-color: #dbdbdb; 16 | color: #fff; 17 | border-bottom-style: none; 18 | } 19 | .tabs li.is-active a { 20 | border-bottom-color: #f9826c; 21 | color: #fff; 22 | border-bottom-style: solid; 23 | } 24 | .tabs a:hover { 25 | border-bottom-color: #dbdbdb; 26 | color: #dbdbdb; 27 | border-bottom-style: solid; 28 | } 29 | .tabs ul{ 30 | margin-bottom: 5px; 31 | border-bottom-style: none; 32 | } 33 | .checkbox:hover{ 34 | color: #f9826c; 35 | } 36 | #log-content { 37 | color: #fff; 38 | background-color: #2B2D3E; 39 | } -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/Discord.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import requests 4 | 5 | from TwitchChannelPointsMiner.classes.Settings import Events 6 | 7 | 8 | class Discord(object): 9 | __slots__ = ["webhook_api", "events"] 10 | 11 | def __init__(self, webhook_api: str, events: list): 12 | self.webhook_api = webhook_api 13 | self.events = [str(e) for e in events] 14 | 15 | def send(self, message: str, event: Events) -> None: 16 | if str(event) in self.events: 17 | requests.post( 18 | url=self.webhook_api, 19 | data={ 20 | "content": dedent(message), 21 | "username": "Twitch Channel Points Miner", 22 | "avatar_url": "https://i.imgur.com/X9fEkhT.png", 23 | }, 24 | ) 25 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/Webhook.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import requests 4 | 5 | from TwitchChannelPointsMiner.classes.Settings import Events 6 | 7 | 8 | class Webhook(object): 9 | __slots__ = ["endpoint", "method", "events"] 10 | 11 | def __init__(self, endpoint: str, method: str, events: list): 12 | self.endpoint = endpoint 13 | self.method = method 14 | self.events = [str(e) for e in events] 15 | 16 | def send(self, message: str, event: Events) -> None: 17 | 18 | if str(event) in self.events: 19 | url = self.endpoint + f"?event_name={str(event)}&message={message}" 20 | 21 | if self.method.lower() == "get": 22 | requests.get(url=url) 23 | elif self.method.lower() == "post": 24 | requests.post(url=url) 25 | else: 26 | raise ValueError("Invalid method, use POST or GET") 27 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/Pushover.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import requests 4 | 5 | from TwitchChannelPointsMiner.classes.Settings import Events 6 | 7 | 8 | class Pushover(object): 9 | __slots__ = ["userkey", "token", "priority", "sound", "events"] 10 | 11 | def __init__(self, userkey: str, token: str, priority, sound, events: list): 12 | self.userkey = userkey 13 | self.token = token 14 | self. priority = priority 15 | self.sound = sound 16 | self.events = [str(e) for e in events] 17 | 18 | def send(self, message: str, event: Events) -> None: 19 | if str(event) in self.events: 20 | requests.post( 21 | url="https://api.pushover.net/1/messages.json", 22 | data={ 23 | "user": self.userkey, 24 | "token": self.token, 25 | "message": dedent(message), 26 | "title": "Twitch Channel Points Miner", 27 | "priority": self.priority, 28 | "sound": self.sound, 29 | }, 30 | ) 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | labels: enhancement 4 | 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Is your feature request related to a problem? 10 | description: A clear and concise description of what the problem is. 11 | placeholder: I'm always frustrated when [...] 12 | - type: textarea 13 | id: solution 14 | attributes: 15 | label: Proposed solution 16 | description: | 17 | Suggest your feature here. What benefit would it bring? 18 | 19 | Do you have any ideas on how to implement it? 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: alternatives 24 | attributes: 25 | label: Alternatives you've considered 26 | description: Suggest any alternative solutions or features you've considered. 27 | - type: textarea 28 | id: other-info 29 | attributes: 30 | label: Additional context 31 | description: Add any other context or screenshots about the feature request here. 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 2 | 3 | ARG BUILDX_QEMU_ENV 4 | 5 | WORKDIR /usr/src/app 6 | 7 | COPY ./requirements.txt ./ 8 | 9 | ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 10 | 11 | RUN pip install --upgrade pip 12 | 13 | RUN apt-get update 14 | RUN DEBIAN_FRONTEND=noninteractive apt-get install -qq -y --fix-missing --no-install-recommends \ 15 | gcc \ 16 | libffi-dev \ 17 | rustc \ 18 | zlib1g-dev \ 19 | libjpeg-dev \ 20 | libssl-dev \ 21 | libblas-dev \ 22 | liblapack-dev \ 23 | make \ 24 | cmake \ 25 | automake \ 26 | ninja-build \ 27 | g++ \ 28 | subversion \ 29 | python3-dev \ 30 | && if [ "${BUILDX_QEMU_ENV}" = "true" ] && [ "$(getconf LONG_BIT)" = "32" ]; then \ 31 | pip install -U cryptography==3.3.2; \ 32 | fi \ 33 | && pip install -r requirements.txt \ 34 | && pip cache purge \ 35 | && apt-get remove -y gcc rustc \ 36 | && apt-get autoremove -y \ 37 | && apt-get autoclean -y \ 38 | && apt-get clean -y \ 39 | && rm -rf /var/lib/apt/lists/* \ 40 | && rm -rf /usr/share/doc/* 41 | 42 | ADD ./TwitchChannelPointsMiner ./TwitchChannelPointsMiner 43 | ENTRYPOINT [ "python", "run.py" ] 44 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/Telegram.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import requests 4 | 5 | from TwitchChannelPointsMiner.classes.Settings import Events 6 | 7 | 8 | class Telegram(object): 9 | __slots__ = ["chat_id", "telegram_api", "events", "disable_notification"] 10 | 11 | def __init__( 12 | self, chat_id: int, token: str, events: list, disable_notification: bool = False 13 | ): 14 | self.chat_id = chat_id 15 | self.telegram_api = f"https://api.telegram.org/bot{token}/sendMessage" 16 | self.events = [str(e) for e in events] 17 | self.disable_notification = disable_notification 18 | 19 | def send(self, message: str, event: Events) -> None: 20 | if str(event) in self.events: 21 | requests.post( 22 | url=self.telegram_api, 23 | data={ 24 | "chat_id": self.chat_id, 25 | "text": dedent(message), 26 | "disable_web_page_preview": True, # include link to twitch streamer? 27 | "disable_notification": self.disable_notification, # no sound, notif just in tray 28 | }, 29 | ) 30 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) 14 | 15 | # How Has This Been Tested? 16 | 17 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. 18 | 19 | # Checklist: 20 | 21 | - [ ] My code follows the style guidelines of this project 22 | - [ ] I have performed a self-review of my code 23 | - [ ] I have commented on my code, particularly in hard-to-understand areas 24 | - [ ] I have made corresponding changes to the documentation (README.md) 25 | - [ ] My changes generate no new warnings 26 | - [ ] Any dependent changes have been updated in requirements.txt 27 | -------------------------------------------------------------------------------- /assets/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | overflow-x: hidden; 4 | overflow-y: auto; 5 | } 6 | 7 | a { 8 | text-decoration: none; 9 | } 10 | 11 | .tabs ul { 12 | -webkit-flex-direction: column; 13 | flex-direction: column; 14 | flex-grow: 0px; 15 | } 16 | 17 | .tabs { 18 | max-height: 500px; 19 | overflow-y: auto; 20 | direction: rtl; 21 | } 22 | 23 | .checkbox-label { 24 | font-size: 20px; 25 | padding-left: 15px; 26 | font-weight: bold 27 | } 28 | 29 | ::-webkit-scrollbar { 30 | width: 20px; 31 | } 32 | 33 | ::-webkit-scrollbar-track { 34 | background-color: transparent; 35 | } 36 | 37 | ::-webkit-scrollbar-thumb { 38 | background-color: #d6dee1; 39 | } 40 | 41 | ::-webkit-scrollbar-thumb { 42 | background-color: #d6dee1; 43 | border-radius: 20px; 44 | } 45 | 46 | ::-webkit-scrollbar-thumb { 47 | background-color: #d6dee1; 48 | border-radius: 20px; 49 | border: 6px solid transparent; 50 | background-clip: content-box; 51 | } 52 | 53 | ::-webkit-scrollbar-thumb:hover { 54 | background-color: #a8bbbf; 55 | } 56 | 57 | #log-content { 58 | text-align: left; 59 | white-space: pre-wrap; 60 | max-height: 400px; 61 | padding: 0; 62 | } 63 | 64 | #auto-update-log { 65 | display: none; 66 | background-color: transparent; 67 | font-size: 20px; 68 | padding: 0; 69 | border-radius: 5px; 70 | } -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/Settings.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | class Priority(Enum): 5 | ORDER = auto() 6 | STREAK = auto() 7 | DROPS = auto() 8 | SUBSCRIBED = auto() 9 | POINTS_ASCENDING = auto() 10 | POINTS_DESCENDING = auto() 11 | 12 | 13 | class FollowersOrder(Enum): 14 | ASC = auto() 15 | DESC = auto() 16 | 17 | def __str__(self): 18 | return self.name 19 | 20 | 21 | # Empty object shared between class 22 | class Settings(object): 23 | __slots__ = ["logger", "streamer_settings", 24 | "enable_analytics", "disable_ssl_cert_verification", "disable_at_in_nickname"] 25 | 26 | 27 | class Events(Enum): 28 | STREAMER_ONLINE = auto() 29 | STREAMER_OFFLINE = auto() 30 | GAIN_FOR_RAID = auto() 31 | GAIN_FOR_CLAIM = auto() 32 | GAIN_FOR_WATCH = auto() 33 | GAIN_FOR_WATCH_STREAK = auto() 34 | BET_WIN = auto() 35 | BET_LOSE = auto() 36 | BET_REFUND = auto() 37 | BET_FILTERS = auto() 38 | BET_GENERAL = auto() 39 | BET_FAILED = auto() 40 | BET_START = auto() 41 | BONUS_CLAIM = auto() 42 | MOMENT_CLAIM = auto() 43 | JOIN_RAID = auto() 44 | DROP_CLAIM = auto() 45 | DROP_STATUS = auto() 46 | CHAT_MENTION = auto() 47 | 48 | def __str__(self): 49 | return self.name 50 | 51 | @classmethod 52 | def get(cls, key): 53 | return getattr(cls, str(key)) if str(key) in dir(cls) else None 54 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/Matrix.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import logging 4 | import requests 5 | from urllib.parse import quote 6 | 7 | from TwitchChannelPointsMiner.classes.Settings import Events 8 | 9 | 10 | class Matrix(object): 11 | __slots__ = ["access_token", "homeserver", "room_id", "events"] 12 | 13 | def __init__(self, username: str, password: str, homeserver: str, room_id: str, events: list): 14 | self.homeserver = homeserver 15 | self.room_id = quote(room_id) 16 | self.events = [str(e) for e in events] 17 | 18 | body = requests.post( 19 | url=f"https://{self.homeserver}/_matrix/client/r0/login", 20 | json={ 21 | "user": username, 22 | "password": password, 23 | "type": "m.login.password" 24 | } 25 | ).json() 26 | 27 | self.access_token = body.get("access_token") 28 | 29 | if not self.access_token: 30 | logging.getLogger(__name__).info("Invalid Matrix password provided. Notifications will not be sent.") 31 | 32 | def send(self, message: str, event: Events) -> None: 33 | if str(event) in self.events: 34 | requests.post( 35 | url=f"https://{self.homeserver}/_matrix/client/r0/rooms/{self.room_id}/send/m.room.message?access_token={self.access_token}", 36 | json={ 37 | "body": dedent(message), 38 | "msgtype": "m.text" 39 | } 40 | ) 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | import setuptools 4 | import re 5 | 6 | 7 | def read(fname): 8 | return open(path.join(path.dirname(__file__), fname), encoding="utf-8").read() 9 | 10 | 11 | metadata = dict( 12 | re.findall( 13 | r"""__([a-z]+)__ = "([^"]+)""", read("TwitchChannelPointsMiner/__init__.py") 14 | ) 15 | ) 16 | 17 | setuptools.setup( 18 | name="Twitch-Channel-Points-Miner-v2", 19 | version=metadata["version"], 20 | author="Tkd-Alex (Alessandro Maggio) and rdavydov (Roman Davydov)", 21 | author_email="alex.tkd.alex@gmail.com", 22 | description="A simple script that will watch a stream for you and earn the channel points.", 23 | license="GPLv3+", 24 | keywords="python bot streaming script miner twtich channel-points", 25 | url="https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2", 26 | packages=setuptools.find_packages(), 27 | include_package_data=True, 28 | install_requires=[ 29 | "requests", 30 | "websocket-client", 31 | "pillow", 32 | "python-dateutil", 33 | "emoji", 34 | "millify", 35 | "pre-commit", 36 | "colorama", 37 | "flask", 38 | "irc", 39 | "pandas", 40 | "pytz" 41 | ], 42 | long_description=read("README.md"), 43 | long_description_content_type="text/markdown", 44 | classifiers=[ 45 | "Programming Language :: Python :: 3.6", 46 | "Programming Language :: Python :: 3.7", 47 | "Programming Language :: Python :: 3.8", 48 | "Programming Language :: Python :: 3.9", 49 | "Operating System :: OS Independent", 50 | "Topic :: Utilities", 51 | "Topic :: Software Development :: Libraries :: Python Modules", 52 | "Topic :: Scientific/Engineering", 53 | "Topic :: Software Development :: Version Control :: Git", 54 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 55 | "Natural Language :: English", 56 | ], 57 | python_requires=">=3.6", 58 | ) 59 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docker.yml: -------------------------------------------------------------------------------- 1 | name: deploy-docker 2 | 3 | on: 4 | push: 5 | # branches: [master] 6 | tags: 7 | - '*' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | deploy-docker: 12 | name: Deploy Docker Hub 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout source 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3.2.0 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v3.7.1 23 | 24 | - name: Login to DockerHub 25 | uses: docker/login-action@v3.3.0 26 | with: 27 | username: ${{ secrets.DOCKER_USERNAME }} 28 | password: ${{ secrets.DOCKER_TOKEN }} 29 | 30 | - name: Docker meta 31 | id: meta 32 | uses: docker/metadata-action@v5 33 | with: 34 | images: rdavidoff/twitch-channel-points-miner-v2 35 | tags: | 36 | type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags') }} 37 | type=raw,value=latest 38 | 39 | - name: Build and push AMD64, ARM64, ARMv7 40 | id: docker_build 41 | uses: docker/build-push-action@v6.9.0 42 | with: 43 | context: . 44 | push: true 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | platforms: linux/amd64,linux/arm64,linux/arm/v7 48 | build-args: BUILDX_QEMU_ENV=true 49 | cache-from: type=gha 50 | cache-to: type=gha,mode=max 51 | 52 | # File size exceeds the maximum allowed 25000 bytes 53 | # - name: Docker Hub Description 54 | # uses: peter-evans/dockerhub-description@v2 55 | # with: 56 | # username: ${{ secrets.DOCKER_USERNAME }} 57 | # password: ${{ secrets.DOCKER_TOKEN }} 58 | # repository: rdavidoff/twitch-channel-points-miner-v2 59 | 60 | - name: Image digest AMD64, ARM64, ARMv7 61 | run: echo ${{ steps.docker_build.outputs.digest }} 62 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/TwitchWebSocket.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | 5 | from websocket import WebSocketApp, WebSocketConnectionClosedException 6 | 7 | from TwitchChannelPointsMiner.utils import create_nonce 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class TwitchWebSocket(WebSocketApp): 13 | def __init__(self, index, parent_pool, *args, **kw): 14 | super().__init__(*args, **kw) 15 | self.index = index 16 | 17 | self.parent_pool = parent_pool 18 | self.is_closed = False 19 | self.is_opened = False 20 | 21 | self.is_reconnecting = False 22 | self.forced_close = False 23 | 24 | # Custom attribute 25 | self.topics = [] 26 | self.pending_topics = [] 27 | 28 | self.twitch = parent_pool.twitch 29 | self.streamers = parent_pool.streamers 30 | self.events_predictions = parent_pool.events_predictions 31 | 32 | self.last_message_timestamp = None 33 | self.last_message_type_channel = None 34 | 35 | self.last_pong = time.time() 36 | self.last_ping = time.time() 37 | 38 | # def close(self): 39 | # self.forced_close = True 40 | # super().close() 41 | 42 | def listen(self, topic, auth_token=None): 43 | data = {"topics": [str(topic)]} 44 | if topic.is_user_topic() and auth_token is not None: 45 | data["auth_token"] = auth_token 46 | nonce = create_nonce() 47 | self.send({"type": "LISTEN", "nonce": nonce, "data": data}) 48 | 49 | def ping(self): 50 | self.send({"type": "PING"}) 51 | self.last_ping = time.time() 52 | 53 | def send(self, request): 54 | try: 55 | request_str = json.dumps(request, separators=(",", ":")) 56 | logger.debug(f"#{self.index} - Send: {request_str}") 57 | super().send(request_str) 58 | except WebSocketConnectionClosedException: 59 | self.is_closed = True 60 | 61 | def elapsed_last_pong(self): 62 | return (time.time() - self.last_pong) // 60 63 | 64 | def elapsed_last_ping(self): 65 | return (time.time() - self.last_ping) // 60 66 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/Message.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from TwitchChannelPointsMiner.utils import server_time 4 | 5 | 6 | class Message(object): 7 | __slots__ = [ 8 | "topic", 9 | "topic_user", 10 | "message", 11 | "type", 12 | "data", 13 | "timestamp", 14 | "channel_id", 15 | "identifier", 16 | ] 17 | 18 | def __init__(self, data): 19 | self.topic, self.topic_user = data["topic"].split(".") 20 | 21 | self.message = json.loads(data["message"]) 22 | self.type = self.message["type"] 23 | 24 | self.data = self.message["data"] if "data" in self.message else None 25 | 26 | self.timestamp = self.__get_timestamp() 27 | self.channel_id = self.__get_channel_id() 28 | 29 | self.identifier = f"{self.type}.{self.topic}.{self.channel_id}" 30 | 31 | def __repr__(self): 32 | return f"{self.message}" 33 | 34 | def __str__(self): 35 | return f"{self.message}" 36 | 37 | def __get_timestamp(self): 38 | return ( 39 | server_time(self.message) 40 | if self.data is None 41 | else ( 42 | self.data["timestamp"] 43 | if "timestamp" in self.data 44 | else server_time(self.data) 45 | ) 46 | ) 47 | 48 | def __get_channel_id(self): 49 | return ( 50 | self.topic_user 51 | if self.data is None 52 | else ( 53 | self.data["prediction"]["channel_id"] 54 | if "prediction" in self.data 55 | else ( 56 | self.data["claim"]["channel_id"] 57 | if "claim" in self.data 58 | else ( 59 | self.data["channel_id"] 60 | if "channel_id" in self.data 61 | else ( 62 | self.data["balance"]["channel_id"] 63 | if "balance" in self.data 64 | else self.topic_user 65 | ) 66 | ) 67 | ) 68 | ) 69 | ) 70 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/CommunityGoal.py: -------------------------------------------------------------------------------- 1 | class CommunityGoal(object): 2 | __slots__ = [ 3 | "goal_id", 4 | "title", 5 | "is_in_stock", 6 | "points_contributed", 7 | "amount_needed", 8 | "per_stream_user_maximum_contribution", 9 | "status" 10 | ] 11 | 12 | def __init__( 13 | self, 14 | goal_id, 15 | title, 16 | is_in_stock, 17 | points_contributed, 18 | amount_needed, 19 | per_stream_user_maximum_contribution, 20 | status 21 | ): 22 | self.goal_id = goal_id 23 | self.title = title 24 | self.is_in_stock = is_in_stock 25 | self.points_contributed = points_contributed 26 | self.amount_needed = amount_needed 27 | self.per_stream_user_maximum_contribution = per_stream_user_maximum_contribution 28 | self.status = status 29 | 30 | def __eq__(self, other): 31 | if isinstance(other, self.__class__): 32 | return self.goal_id == other.goal_id 33 | else: 34 | return False 35 | 36 | def __repr__(self) -> str: 37 | return f"CommunityGoal(goal_id: {self.goal_id}, title: {self.title}, is_in_stock: {self.is_in_stock}, points_contributed: {self.points_contributed}, amount_needed: {self.amount_needed}, per_stream_user_maximum_contribution: {self.per_stream_user_maximum_contribution}, status: {self.status})" 38 | 39 | def amount_left(self): 40 | return self.amount_needed - self.points_contributed 41 | 42 | @classmethod 43 | def from_gql(cls, gql_goal): 44 | return cls( 45 | gql_goal["id"], 46 | gql_goal["title"], 47 | gql_goal["isInStock"], 48 | gql_goal["pointsContributed"], 49 | gql_goal["amountNeeded"], 50 | gql_goal["perStreamUserMaximumContribution"], 51 | gql_goal["status"] 52 | ) 53 | 54 | @classmethod 55 | def from_pubsub(cls, pubsub_goal): 56 | return cls( 57 | pubsub_goal["id"], 58 | pubsub_goal["title"], 59 | pubsub_goal["is_in_stock"], 60 | pubsub_goal["points_contributed"], 61 | pubsub_goal["goal_amount"], 62 | pubsub_goal["per_stream_maximum_user_contribution"], 63 | pubsub_goal["status"] 64 | ) 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | labels: bug 4 | 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Describe the bug 10 | description: | 11 | A clear and concise description of what the bug is. 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: steps-to-reproduce 16 | attributes: 17 | label: Steps to reproduce 18 | placeholder: | 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: expected-behavior 27 | attributes: 28 | label: Expected behavior 29 | description: What do you expect to happen? 30 | validations: 31 | required: true 32 | - type: input 33 | id: operating-system 34 | attributes: 35 | label: Operating system 36 | placeholder: Windows 11 Version 21H2 (OS Build 22000.1574) 37 | validations: 38 | required: true 39 | - type: input 40 | id: python-version 41 | attributes: 42 | label: Python version 43 | placeholder: "3.11.1" 44 | validations: 45 | required: true 46 | - type: input 47 | id: miner-version 48 | attributes: 49 | label: Miner version 50 | placeholder: "1.7.7" 51 | validations: 52 | required: true 53 | - type: textarea 54 | id: other-environment-info 55 | attributes: 56 | label: Other relevant software versions 57 | - type: textarea 58 | id: logs 59 | attributes: 60 | label: Logs 61 | description: | 62 | How to provide a DEBUG log: 63 | 1. Set this in your runner script (`run.py`): 64 | ```py 65 | logger_settings=LoggerSettings( 66 | save=True, 67 | console_level=logging.INFO, 68 | file_level=logging.DEBUG, 69 | less=True, 70 | ``` 71 | 2. Start the miner, wait for the error, then stop the miner and post the contents of the log file (`logs\username.log`) to https://gist.github.com/ and post a link here. 72 | 3. Create another gist with your console output, just in case. Paste a link here as well. 73 | validations: 74 | required: true 75 | - type: textarea 76 | id: other-info 77 | attributes: 78 | label: Additional context 79 | description: Add any other context about the problem here. 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # PyCharm 141 | .idea/ 142 | 143 | # Custom files 144 | run.py 145 | chromedriver* 146 | 147 | # Folders 148 | cookies/* 149 | logs/* 150 | screenshots/* 151 | htmls/* 152 | analytics/* 153 | 154 | # Replit 155 | keep_replit_alive.py 156 | .replit 157 | replit.nix 158 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/Campaign.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from TwitchChannelPointsMiner.classes.entities.Drop import Drop 4 | from TwitchChannelPointsMiner.classes.Settings import Settings 5 | 6 | def parse_datetime(datetime_str): 7 | for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"): 8 | try: 9 | return datetime.strptime(datetime_str, fmt) 10 | except ValueError: 11 | continue 12 | raise ValueError(f"time data '{datetime_str}' does not match format") 13 | 14 | class Campaign(object): 15 | __slots__ = [ 16 | "id", 17 | "game", 18 | "name", 19 | "status", 20 | "in_inventory", 21 | "end_at", 22 | "start_at", 23 | "dt_match", 24 | "drops", 25 | "channels", 26 | ] 27 | 28 | def __init__(self, dict): 29 | self.id = dict["id"] 30 | self.game = dict["game"] 31 | self.name = dict["name"] 32 | self.status = dict["status"] 33 | self.channels = ( 34 | [] 35 | if dict["allow"]["channels"] is None 36 | else list(map(lambda x: x["id"], dict["allow"]["channels"])) 37 | ) 38 | self.in_inventory = False 39 | 40 | self.end_at = parse_datetime(dict["endAt"]) 41 | self.start_at = parse_datetime(dict["startAt"]) 42 | self.dt_match = self.start_at < datetime.now() < self.end_at 43 | 44 | self.drops = list(map(lambda x: Drop(x), dict["timeBasedDrops"])) 45 | 46 | def __repr__(self): 47 | return f"Campaign(id={self.id}, name={self.name}, game={self.game}, in_inventory={self.in_inventory})" 48 | 49 | def __str__(self): 50 | return ( 51 | f"{self.name}, Game: {self.game['displayName']} - Drops: {len(self.drops)} pcs. - In inventory: {self.in_inventory}" 52 | if Settings.logger.less 53 | else self.__repr__() 54 | ) 55 | 56 | def clear_drops(self): 57 | self.drops = list( 58 | filter(lambda x: x.dt_match is True and x.is_claimed is False, self.drops) 59 | ) 60 | 61 | def __eq__(self, other): 62 | if isinstance(other, self.__class__): 63 | return self.id == other.id 64 | else: 65 | return False 66 | 67 | def sync_drops(self, drops, callback): 68 | # Iterate all the drops from inventory 69 | for drop in drops: 70 | # Iterate all the drops from out campaigns array 71 | # After id match update with: 72 | # [currentMinutesWatched, hasPreconditionsMet, dropInstanceID, isClaimed] 73 | for i in range(len(self.drops)): 74 | current_id = self.drops[i].id 75 | if drop["id"] == current_id: 76 | self.drops[i].update(drop["self"]) 77 | # If after update we all conditions are meet we can claim the drop 78 | if self.drops[i].is_claimable is True: 79 | claimed = callback(self.drops[i]) 80 | self.drops[i].is_claimed = claimed 81 | break 82 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/EventPrediction.py: -------------------------------------------------------------------------------- 1 | from TwitchChannelPointsMiner.classes.entities.Bet import Bet 2 | from TwitchChannelPointsMiner.classes.entities.Streamer import Streamer 3 | from TwitchChannelPointsMiner.classes.Settings import Settings 4 | from TwitchChannelPointsMiner.utils import _millify, float_round 5 | 6 | 7 | class EventPrediction(object): 8 | __slots__ = [ 9 | "streamer", 10 | "event_id", 11 | "title", 12 | "created_at", 13 | "prediction_window_seconds", 14 | "status", 15 | "result", 16 | "box_fillable", 17 | "bet_confirmed", 18 | "bet_placed", 19 | "bet", 20 | ] 21 | 22 | def __init__( 23 | self, 24 | streamer: Streamer, 25 | event_id, 26 | title, 27 | created_at, 28 | prediction_window_seconds, 29 | status, 30 | outcomes, 31 | ): 32 | self.streamer = streamer 33 | 34 | self.event_id = event_id 35 | self.title = title.strip() 36 | self.created_at = created_at 37 | self.prediction_window_seconds = prediction_window_seconds 38 | self.status = status 39 | self.result: dict = {"string": "", "type": None, "gained": 0} 40 | 41 | self.box_fillable = False 42 | self.bet_confirmed = False 43 | self.bet_placed = False 44 | self.bet = Bet(outcomes, streamer.settings.bet) 45 | 46 | def __repr__(self): 47 | return f"EventPrediction(event_id={self.event_id}, streamer={self.streamer}, title={self.title})" 48 | 49 | def __str__(self): 50 | return ( 51 | f"EventPrediction: {self.streamer} - {self.title}" 52 | if Settings.logger.less 53 | else self.__repr__() 54 | ) 55 | 56 | def elapsed(self, timestamp): 57 | return float_round((timestamp - self.created_at).total_seconds()) 58 | 59 | def closing_bet_after(self, timestamp): 60 | return float_round(self.prediction_window_seconds - self.elapsed(timestamp)) 61 | 62 | def print_recap(self) -> str: 63 | return f"{self}\n\t\t{self.bet}\n\t\tResult: {self.result['string']}" 64 | 65 | def parse_result(self, result) -> dict: 66 | result_type = result["type"] 67 | 68 | points = {} 69 | points["placed"] = ( 70 | self.bet.decision["amount"] if result_type != "REFUND" else 0 71 | ) 72 | points["won"] = ( 73 | result["points_won"] 74 | if result["points_won"] or result_type == "REFUND" 75 | else 0 76 | ) 77 | points["gained"] = ( 78 | points["won"] - points["placed"] if result_type != "REFUND" else 0 79 | ) 80 | points["prefix"] = "+" if points["gained"] >= 0 else "" 81 | 82 | action = ( 83 | "Lost" 84 | if result_type == "LOSE" 85 | else ("Refunded" if result_type == "REFUND" else "Gained") 86 | ) 87 | 88 | self.result = { 89 | "string": f"{result_type}, {action}: {points['prefix']}{_millify(points['gained'])}", 90 | "type": result_type, 91 | "gained": points["gained"], 92 | } 93 | 94 | return points 95 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/Chat.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from enum import Enum, auto 4 | from threading import Thread 5 | 6 | from irc.bot import SingleServerIRCBot 7 | 8 | from TwitchChannelPointsMiner.constants import IRC, IRC_PORT 9 | from TwitchChannelPointsMiner.classes.Settings import Events, Settings 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class ChatPresence(Enum): 15 | ALWAYS = auto() 16 | NEVER = auto() 17 | ONLINE = auto() 18 | OFFLINE = auto() 19 | 20 | def __str__(self): 21 | return self.name 22 | 23 | 24 | class ClientIRC(SingleServerIRCBot): 25 | def __init__(self, username, token, channel): 26 | self.token = token 27 | self.channel = "#" + channel 28 | self.__active = False 29 | 30 | super(ClientIRC, self).__init__( 31 | [(IRC, IRC_PORT, f"oauth:{token}")], username, username 32 | ) 33 | 34 | def on_welcome(self, client, event): 35 | client.join(self.channel) 36 | 37 | def start(self): 38 | self.__active = True 39 | self._connect() 40 | while self.__active: 41 | try: 42 | self.reactor.process_once(timeout=0.2) 43 | time.sleep(0.01) 44 | except Exception as e: 45 | logger.error( 46 | f"Exception raised: {e}. Thread is active: {self.__active}" 47 | ) 48 | 49 | def die(self, msg="Bye, cruel world!"): 50 | self.connection.disconnect(msg) 51 | self.__active = False 52 | 53 | """ 54 | def on_join(self, connection, event): 55 | logger.info(f"Event: {event}", extra={"emoji": ":speech_balloon:"}) 56 | """ 57 | 58 | # """ 59 | def on_pubmsg(self, connection, event): 60 | msg = event.arguments[0] 61 | mention = None 62 | 63 | if Settings.disable_at_in_nickname is True: 64 | mention = f"{self._nickname.lower()}" 65 | else: 66 | mention = f"@{self._nickname.lower()}" 67 | 68 | # also self._realname 69 | # if msg.startswith(f"@{self._nickname}"): 70 | if mention != None and mention in msg.lower(): 71 | # nickname!username@nickname.tmi.twitch.tv 72 | nick = event.source.split("!", 1)[0] 73 | # chan = event.target 74 | 75 | logger.info(f"{nick} at {self.channel} wrote: {msg}", extra={ 76 | "emoji": ":speech_balloon:", "event": Events.CHAT_MENTION}) 77 | # """ 78 | 79 | 80 | class ThreadChat(Thread): 81 | def __deepcopy__(self, memo): 82 | return None 83 | 84 | def __init__(self, username, token, channel): 85 | super(ThreadChat, self).__init__() 86 | 87 | self.username = username 88 | self.token = token 89 | self.channel = channel 90 | 91 | self.chat_irc = None 92 | 93 | def run(self): 94 | self.chat_irc = ClientIRC(self.username, self.token, self.channel) 95 | logger.info( 96 | f"Join IRC Chat: {self.channel}", extra={"emoji": ":speech_balloon:"} 97 | ) 98 | self.chat_irc.start() 99 | 100 | def stop(self): 101 | if self.chat_irc is not None: 102 | logger.info( 103 | f"Leave IRC Chat: {self.channel}", extra={"emoji": ":speech_balloon:"} 104 | ) 105 | self.chat_irc.die() 106 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/Stream.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | from base64 import b64encode 5 | 6 | from TwitchChannelPointsMiner.classes.Settings import Settings 7 | from TwitchChannelPointsMiner.constants import DROP_ID 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Stream(object): 13 | __slots__ = [ 14 | "broadcast_id", 15 | "title", 16 | "game", 17 | "tags", 18 | "drops_tags", 19 | "campaigns", 20 | "campaigns_ids", 21 | "viewers_count", 22 | "spade_url", 23 | "payload", 24 | "watch_streak_missing", 25 | "minute_watched", 26 | "__last_update", 27 | "__minute_watched_timestamp", 28 | ] 29 | 30 | def __init__(self): 31 | self.broadcast_id = None 32 | 33 | self.title = None 34 | self.game = {} 35 | self.tags = [] 36 | 37 | self.drops_tags = False 38 | self.campaigns = [] 39 | self.campaigns_ids = [] 40 | 41 | self.viewers_count = 0 42 | self.__last_update = 0 43 | 44 | self.spade_url = None 45 | self.payload = None 46 | 47 | self.init_watch_streak() 48 | 49 | def encode_payload(self) -> dict: 50 | json_event = json.dumps(self.payload, separators=(",", ":")) 51 | return {"data": (b64encode(json_event.encode("utf-8"))).decode("utf-8")} 52 | 53 | def update(self, broadcast_id, title, game, tags, viewers_count): 54 | self.broadcast_id = broadcast_id 55 | self.title = title.strip() 56 | self.game = game 57 | # #343 temporary workaround 58 | self.tags = tags or [] 59 | # ------------------------ 60 | self.viewers_count = viewers_count 61 | 62 | self.drops_tags = ( 63 | DROP_ID in [tag["id"] for tag in self.tags] and self.game != {} 64 | ) 65 | self.__last_update = time.time() 66 | 67 | logger.debug(f"Update: {self}") 68 | 69 | def __repr__(self): 70 | return f"Stream(title={self.title}, game={self.__str_game()}, tags={self.__str_tags()})" 71 | 72 | def __str__(self): 73 | return f"{self.title}" if Settings.logger.less else self.__repr__() 74 | 75 | def __str_tags(self): 76 | return ( 77 | None 78 | if self.tags == [] 79 | else ", ".join([tag["localizedName"] for tag in self.tags]) 80 | ) 81 | 82 | def __str_game(self): 83 | return None if self.game in [{}, None] else self.game["displayName"] 84 | 85 | def game_name(self): 86 | return None if self.game in [{}, None] else self.game["name"] 87 | 88 | def game_id(self): 89 | return None if self.game in [{}, None] else self.game["id"] 90 | 91 | def update_required(self): 92 | return self.__last_update == 0 or self.update_elapsed() >= 120 93 | 94 | def update_elapsed(self): 95 | return 0 if self.__last_update == 0 else (time.time() - self.__last_update) 96 | 97 | def init_watch_streak(self): 98 | self.watch_streak_missing = True 99 | self.minute_watched = 0 100 | self.__minute_watched_timestamp = 0 101 | 102 | def update_minute_watched(self): 103 | if self.__minute_watched_timestamp != 0: 104 | self.minute_watched += round( 105 | (time.time() - self.__minute_watched_timestamp) / 60, 5 106 | ) 107 | self.__minute_watched_timestamp = time.time() 108 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at alex.tkd.alex@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/workflows/github-clone-count-badge.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Clone Count Update Everyday 2 | 3 | on: 4 | schedule: 5 | - cron: "0 */24 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: gh login 16 | run: echo "${{ secrets.SECRET_TOKEN }}" | gh auth login --with-token 17 | 18 | - name: parse latest clone count 19 | run: | 20 | curl --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \ 21 | -H "Accept: application/vnd.github.v3+json" \ 22 | https://api.github.com/repos/${{ github.repository }}/traffic/clones \ 23 | > clone.json 24 | 25 | - name: create gist and download previous count 26 | id: set_id 27 | run: | 28 | if gh secret list | grep -q "GIST_ID" 29 | then 30 | echo "GIST_ID found" 31 | echo ::set-output name=GIST::${{ secrets.GIST_ID }} 32 | curl https://gist.githubusercontent.com/${{ github.actor }}/${{ secrets.GIST_ID }}/raw/clone.json > clone_before.json 33 | if cat clone_before.json | grep '404: Not Found'; then 34 | echo "GIST_ID not valid anymore. Creating another gist..." 35 | gist_id=$(gh gist create clone.json | awk -F / '{print $NF}') 36 | echo $gist_id | gh secret set GIST_ID 37 | echo ::set-output name=GIST::$gist_id 38 | cp clone.json clone_before.json 39 | git rm --ignore-unmatch CLONE.md 40 | fi 41 | else 42 | echo "GIST_ID not found. Creating a gist..." 43 | gist_id=$(gh gist create clone.json | awk -F / '{print $NF}') 44 | echo $gist_id | gh secret set GIST_ID 45 | echo ::set-output name=GIST::$gist_id 46 | cp clone.json clone_before.json 47 | fi 48 | 49 | - name: update clone.json 50 | run: | 51 | curl https://raw.githubusercontent.com/MShawon/github-clone-count-badge/master/main.py > main.py 52 | python3 main.py 53 | 54 | - name: Update gist with latest count 55 | run: | 56 | content=$(sed -e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\"/\\"/g' -e 's/\r//g' "clone.json" | sed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g') 57 | echo '{"description": "${{ github.repository }} clone statistics", "files": {"clone.json": {"content": "'"$content"'"}}}' > post_clone.json 58 | curl -s -X PATCH \ 59 | --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \ 60 | -H "Content-Type: application/json" \ 61 | -d @post_clone.json https://api.github.com/gists/${{ steps.set_id.outputs.GIST }} > /dev/null 2>&1 62 | 63 | if [ ! -f CLONE.md ]; then 64 | shields="https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url=" 65 | url="https://gist.githubusercontent.com/${{ github.actor }}/${{ steps.set_id.outputs.GIST }}/raw/clone.json" 66 | repo="https://github.com/MShawon/github-clone-count-badge" 67 | echo ''> CLONE.md 68 | echo ' 69 | **Markdown** 70 | 71 | ```markdown' >> CLONE.md 72 | echo "[![GitHub Clones]($shields$url&logo=github)]($repo)" >> CLONE.md 73 | echo ' 74 | ``` 75 | 76 | **HTML** 77 | ```html' >> CLONE.md 78 | echo "GitHub Clones" >> CLONE.md 79 | echo '```' >> CLONE.md 80 | 81 | git add CLONE.md 82 | git config --global user.name "GitHub Action" 83 | git config --global user.email "action@github.com" 84 | git commit -m "create clone count badge" 85 | fi 86 | 87 | - name: Push 88 | uses: ad-m/github-push-action@master 89 | with: 90 | github_token: ${{ secrets.GITHUB_TOKEN }} 91 | -------------------------------------------------------------------------------- /.github/workflows/github-traffic-count-badge.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Traffic Count Update Everyday 2 | 3 | on: 4 | schedule: 5 | - cron: "0 */24 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: gh login 16 | run: echo "${{ secrets.SECRET_TOKEN }}" | gh auth login --with-token 17 | 18 | - name: parse latest traffic count 19 | run: | 20 | curl --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \ 21 | -H "Accept: application/vnd.github.v3+json" \ 22 | https://api.github.com/repos/${{ github.repository }}/traffic/views \ 23 | > traffic.json 24 | - name: create gist and download previous count 25 | id: set_id 26 | run: | 27 | if gh secret list | grep -q "TRAFFIC_ID" 28 | then 29 | echo "TRAFFIC_ID found" 30 | echo ::set-output name=GIST::${{ secrets.TRAFFIC_ID }} 31 | curl https://gist.githubusercontent.com/${{ github.actor }}/${{ secrets.TRAFFIC_ID }}/raw/traffic.json > traffic_before.json 32 | if cat traffic_before.json | grep '404: Not Found'; then 33 | echo "TRAFFIC_ID not valid anymore. Creating another gist..." 34 | traffic_id=$(gh gist create traffic.json | awk -F / '{print $NF}') 35 | echo $traffic_id | gh secret set TRAFFIC_ID 36 | echo ::set-output name=GIST::$traffic_id 37 | cp traffic.json traffic_before.json 38 | git rm --ignore-unmatch TRAFFIC.md 39 | fi 40 | else 41 | echo "TRAFFIC_ID not found. Creating a gist..." 42 | traffic_id=$(gh gist create traffic.json | awk -F / '{print $NF}') 43 | echo $traffic_id | gh secret set TRAFFIC_ID 44 | echo ::set-output name=GIST::$traffic_id 45 | cp traffic.json traffic_before.json 46 | fi 47 | - name: update traffic.json 48 | run: | 49 | curl https://gist.githubusercontent.com/MShawon/d37c49ee4ce03f64b92ab58b0cec289f/raw/traffic.py > traffic.py 50 | python3 traffic.py 51 | - name: Update gist with latest count 52 | run: | 53 | content=$(sed -e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\"/\\"/g' -e 's/\r//g' "traffic.json" | sed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g') 54 | echo '{"description": "${{ github.repository }} traffic statistics", "files": {"traffic.json": {"content": "'"$content"'"}}}' > post_traffic.json 55 | curl -s -X PATCH \ 56 | --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \ 57 | -H "Content-Type: application/json" \ 58 | -d @post_traffic.json https://api.github.com/gists/${{ steps.set_id.outputs.GIST }} > /dev/null 2>&1 59 | if [ ! -f TRAFFIC.md ]; then 60 | shields="https://img.shields.io/badge/dynamic/json?color=success&label=Views&query=count&url=" 61 | url="https://gist.githubusercontent.com/${{ github.actor }}/${{ steps.set_id.outputs.GIST }}/raw/traffic.json" 62 | repo="https://github.com/MShawon/github-clone-count-badge" 63 | echo ''> TRAFFIC.md 64 | echo ' 65 | **Markdown** 66 | ```markdown' >> TRAFFIC.md 67 | echo "[![GitHub Traffic]($shields$url&logo=github)]($repo)" >> TRAFFIC.md 68 | echo ' 69 | ``` 70 | **HTML** 71 | ```html' >> TRAFFIC.md 72 | echo "GitHub Traffic" >> TRAFFIC.md 73 | echo '```' >> TRAFFIC.md 74 | 75 | git add TRAFFIC.md 76 | git config --global user.name "GitHub Action" 77 | git config --global user.email "action@github.com" 78 | git commit -m "create traffic count badge" 79 | fi 80 | - name: Push 81 | uses: ad-m/github-push-action@master 82 | with: 83 | github_token: ${{ secrets.GITHUB_TOKEN }} 84 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/Drop.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from TwitchChannelPointsMiner.classes.Settings import Settings 4 | from TwitchChannelPointsMiner.utils import percentage 5 | 6 | def parse_datetime(datetime_str): 7 | for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"): 8 | try: 9 | return datetime.strptime(datetime_str, fmt) 10 | except ValueError: 11 | continue 12 | raise ValueError(f"time data '{datetime_str}' does not match format") 13 | 14 | class Drop(object): 15 | __slots__ = [ 16 | "id", 17 | "name", 18 | "benefit", 19 | "minutes_required", 20 | "has_preconditions_met", 21 | "current_minutes_watched", 22 | "drop_instance_id", 23 | "is_claimed", 24 | "is_claimable", 25 | "percentage_progress", 26 | "end_at", 27 | "start_at", 28 | "dt_match", 29 | "is_printable", 30 | ] 31 | 32 | def __init__(self, dict): 33 | self.id = dict["id"] 34 | self.name = dict["name"] 35 | self.benefit = ", ".join( 36 | list(set([bf["benefit"]["name"] for bf in dict["benefitEdges"]])) 37 | ) 38 | self.minutes_required = dict["requiredMinutesWatched"] 39 | 40 | self.has_preconditions_met = None # [True, False], None we don't know 41 | self.current_minutes_watched = 0 42 | self.drop_instance_id = None 43 | self.is_claimed = False 44 | self.is_claimable = False 45 | self.is_printable = False 46 | self.percentage_progress = 0 47 | 48 | self.end_at = parse_datetime(dict["endAt"]) 49 | self.start_at = parse_datetime(dict["startAt"]) 50 | self.dt_match = self.start_at < datetime.now() < self.end_at 51 | 52 | def update( 53 | self, 54 | progress, 55 | ): 56 | self.has_preconditions_met = progress["hasPreconditionsMet"] 57 | 58 | updated_percentage = percentage( 59 | progress["currentMinutesWatched"], self.minutes_required 60 | ) 61 | quarter = round((updated_percentage / 25), 4).is_integer() 62 | self.is_printable = ( 63 | # The new currentMinutesWatched are GT than previous 64 | progress["currentMinutesWatched"] > self.current_minutes_watched 65 | and ( 66 | # The drop is printable when we have a new updated values and: 67 | # - also the percentage It's different and quarter is True (self.current_minutes_watched != 0 for skip boostrap phase) 68 | # - or we have watched 1 and the previous value is 0 - We are collecting a new drop :) 69 | ( 70 | updated_percentage > self.percentage_progress 71 | and quarter is True 72 | and self.current_minutes_watched != 0 73 | ) 74 | or ( 75 | progress["currentMinutesWatched"] == 1 76 | and self.current_minutes_watched == 0 77 | ) 78 | ) 79 | ) 80 | 81 | self.current_minutes_watched = progress["currentMinutesWatched"] 82 | self.drop_instance_id = progress["dropInstanceID"] 83 | self.is_claimed = progress["isClaimed"] 84 | self.is_claimable = ( 85 | self.is_claimed is False and self.drop_instance_id is not None 86 | ) 87 | self.percentage_progress = updated_percentage 88 | 89 | def __repr__(self): 90 | return f"Drop(id={self.id}, name={self.name}, benefit={self.benefit}, minutes_required={self.minutes_required}, has_preconditions_met={self.has_preconditions_met}, current_minutes_watched={self.current_minutes_watched}, percentage_progress={self.percentage_progress}%, drop_instance_id={self.drop_instance_id}, is_claimed={self.is_claimed})" 91 | 92 | def __str__(self): 93 | return ( 94 | f"{self.name} ({self.benefit}) {self.current_minutes_watched}/{self.minutes_required} ({self.percentage_progress}%)" 95 | if Settings.logger.less 96 | else self.__repr__() 97 | ) 98 | 99 | def progress_bar(self): 100 | progress = self.percentage_progress // 2 101 | remaining = (100 - self.percentage_progress) // 2 102 | if remaining + progress < 50: 103 | remaining += 50 - (remaining + progress) 104 | return f"|{('█' * progress)}{(' ' * remaining)}|\t{self.percentage_progress}% [{self.current_minutes_watched}/{self.minutes_required}]" 105 | 106 | def __eq__(self, other): 107 | if isinstance(other, self.__class__): 108 | return self.id == other.id 109 | else: 110 | return False 111 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/utils.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import re 3 | import socket 4 | import time 5 | from copy import deepcopy 6 | from datetime import datetime, timezone 7 | from os import path 8 | from random import randrange 9 | 10 | import requests 11 | from millify import millify 12 | 13 | from TwitchChannelPointsMiner.constants import USER_AGENTS, GITHUB_url 14 | 15 | 16 | def _millify(input, precision=2): 17 | return millify(input, precision) 18 | 19 | 20 | def get_streamer_index(streamers: list, channel_id) -> int: 21 | try: 22 | return next( 23 | i for i, x in enumerate(streamers) if str(x.channel_id) == str(channel_id) 24 | ) 25 | except StopIteration: 26 | return -1 27 | 28 | 29 | def float_round(number, ndigits=2): 30 | return round(float(number), ndigits) 31 | 32 | 33 | def server_time(message_data): 34 | return ( 35 | datetime.fromtimestamp( 36 | message_data["server_time"], timezone.utc).isoformat() 37 | + "Z" 38 | if message_data is not None and "server_time" in message_data 39 | else datetime.fromtimestamp(time.time(), timezone.utc).isoformat() + "Z" 40 | ) 41 | 42 | 43 | # https://en.wikipedia.org/wiki/Cryptographic_nonce 44 | def create_nonce(length=30) -> str: 45 | nonce = "" 46 | for i in range(length): 47 | char_index = randrange(0, 10 + 26 + 26) 48 | if char_index < 10: 49 | char = chr(ord("0") + char_index) 50 | elif char_index < 10 + 26: 51 | char = chr(ord("a") + char_index - 10) 52 | else: 53 | char = chr(ord("A") + char_index - 26 - 10) 54 | nonce += char 55 | return nonce 56 | 57 | # for mobile-token 58 | 59 | 60 | def get_user_agent(browser: str) -> str: 61 | """try: 62 | return USER_AGENTS[platform.system()][browser] 63 | except KeyError: 64 | # return USER_AGENTS["Linux"]["FIREFOX"] 65 | # return USER_AGENTS["Windows"]["CHROME"]""" 66 | return USER_AGENTS["Android"]["TV"] 67 | # return USER_AGENTS["Android"]["App"] 68 | 69 | 70 | def remove_emoji(string: str) -> str: 71 | emoji_pattern = re.compile( 72 | "[" 73 | "\U0001F600-\U0001F64F" # emoticons 74 | "\U0001F300-\U0001F5FF" # symbols & pictographs 75 | "\U0001F680-\U0001F6FF" # transport & map symbols 76 | "\U0001F1E0-\U0001F1FF" # flags (iOS) 77 | "\U00002500-\U00002587" # chinese char 78 | "\U00002589-\U00002BEF" # I need Unicode Character “█” (U+2588) 79 | "\U00002702-\U000027B0" 80 | "\U00002702-\U000027B0" 81 | "\U000024C2-\U00002587" 82 | "\U00002589-\U0001F251" 83 | "\U0001f926-\U0001f937" 84 | "\U00010000-\U0010ffff" 85 | "\u2640-\u2642" 86 | "\u2600-\u2B55" 87 | "\u200d" 88 | "\u23cf" 89 | "\u23e9" 90 | "\u231a" 91 | "\ufe0f" # dingbats 92 | "\u3030" 93 | "\u231b" 94 | "\u2328" 95 | "\u23cf" 96 | "\u23e9" 97 | "\u23ea" 98 | "\u23eb" 99 | "\u23ec" 100 | "\u23ed" 101 | "\u23ee" 102 | "\u23ef" 103 | "\u23f0" 104 | "\u23f1" 105 | "\u23f2" 106 | "\u23f3" 107 | "]+", 108 | flags=re.UNICODE, 109 | ) 110 | return emoji_pattern.sub(r"", string) 111 | 112 | 113 | def at_least_one_value_in_settings_is(items, attr, value=True): 114 | for item in items: 115 | if getattr(item.settings, attr) == value: 116 | return True 117 | return False 118 | 119 | 120 | def copy_values_if_none(settings, defaults): 121 | values = list( 122 | filter( 123 | lambda x: x.startswith("__") is False 124 | and callable(getattr(settings, x)) is False, 125 | dir(settings), 126 | ) 127 | ) 128 | 129 | for value in values: 130 | if getattr(settings, value) is None: 131 | setattr(settings, value, getattr(defaults, value)) 132 | return settings 133 | 134 | 135 | def set_default_settings(settings, defaults): 136 | # If no settings was provided use the default settings ... 137 | # If settings was provided but maybe are only partial set 138 | # Get the default values from Settings.streamer_settings 139 | return ( 140 | deepcopy(defaults) 141 | if settings is None 142 | else copy_values_if_none(settings, defaults) 143 | ) 144 | 145 | 146 | '''def char_decision_as_index(char): 147 | return 0 if char == "A" else 1''' 148 | 149 | 150 | def internet_connection_available(host="8.8.8.8", port=53, timeout=3): 151 | try: 152 | socket.setdefaulttimeout(timeout) 153 | socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) 154 | return True 155 | except socket.error: 156 | return False 157 | 158 | 159 | def percentage(a, b): 160 | return 0 if a == 0 else int((a / b) * 100) 161 | 162 | 163 | def create_chunks(lst, n): 164 | return [lst[i: (i + n)] for i in range(0, len(lst), n)] # noqa: E203 165 | 166 | 167 | def download_file(name, fpath): 168 | r = requests.get( 169 | path.join(GITHUB_url, name), 170 | headers={"User-Agent": get_user_agent("FIREFOX")}, 171 | stream=True, 172 | ) 173 | if r.status_code == 200: 174 | with open(fpath, "wb") as f: 175 | for chunk in r.iter_content(chunk_size=1024): 176 | if chunk: 177 | f.write(chunk) 178 | return True 179 | 180 | 181 | def read(fname): 182 | return open(path.join(path.dirname(__file__), fname), encoding="utf-8").read() 183 | 184 | 185 | def init2dict(content): 186 | return dict(re.findall(r"""__([a-z]+)__ = "([^"]+)""", content)) 187 | 188 | 189 | def check_versions(): 190 | try: 191 | current_version = init2dict(read("__init__.py")) 192 | current_version = ( 193 | current_version["version"] if "version" in current_version else "0.0.0" 194 | ) 195 | except Exception: 196 | current_version = "0.0.0" 197 | try: 198 | r = requests.get( 199 | "/".join( 200 | [ 201 | s.strip("/") 202 | for s in [GITHUB_url, "TwitchChannelPointsMiner", "__init__.py"] 203 | ] 204 | ) 205 | ) 206 | github_version = init2dict(r.text) 207 | github_version = ( 208 | github_version["version"] if "version" in github_version else "0.0.0" 209 | ) 210 | except Exception: 211 | github_version = "0.0.0" 212 | return current_version, github_version 213 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to this repository 2 | 3 | ## Getting started 4 | 5 | Before you begin: 6 | - Have you read the [code of conduct](CODE_OF_CONDUCT.md)? 7 | - Check out the [existing issues](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/issues) & see if there is already an opened issue. 8 | 9 | ### Ready to make a change? Fork the repo 10 | 11 | Fork using GitHub Desktop: 12 | 13 | - [Getting started with GitHub Desktop](https://docs.github.com/en/desktop/installing-and-configuring-github-desktop/getting-started-with-github-desktop) will guide you through setting up Desktop. 14 | - Once Desktop is set up, you can use it to [fork the repo](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/cloning-and-forking-repositories-from-github-desktop)! 15 | 16 | Fork using the command line: 17 | 18 | - [Fork the repo](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#fork-an-example-repository) so that you can make your changes without affecting the original project until you're ready to merge them. 19 | 20 | Fork with [GitHub Codespaces](https://github.com/features/codespaces): 21 | 22 | - [Fork, edit, and preview](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/creating-a-codespace) using [GitHub Codespaces](https://github.com/features/codespaces) without having to install and run the project locally. 23 | 24 | ### Open a pull request 25 | When you're done making changes, and you'd like to propose them for review, use the [pull request template](#pull-request-template) to open your PR (pull request). 26 | 27 | ### Submit your PR & get it reviewed 28 | - Once you submit your PR, other users from the community will review it with you. The first thing you're going to want to do is a [self review](#self-review). 29 | - After that, we may have questions. Check back on your PR to keep up with the conversation. 30 | - Did you have an issue, like a merge conflict? Check out our [git tutorial](https://lab.github.com/githubtraining/managing-merge-conflicts) on resolving merge conflicts and other issues. 31 | 32 | ### Your PR is merged! 33 | Congratulations! The whole GitHub community thanks you. :sparkles: 34 | 35 | Once your PR is merged, you will be proudly listed as a contributor in the [contributor chart](https://github.com/Tkd-Alex/Twitch-Channel-Points-Miner-v2/graphs/contributors). 36 | 37 | ### Keep contributing as you use GitHub Docs 38 | 39 | Now that you're a part of the GitHub Docs community, you can keep participating in many ways. 40 | 41 | **Learn more about contributing:** 42 | 43 | - [Types of contributions :memo:](#types-of-contributions-memo) 44 | - [:beetle: Issues](#beetle-issues) 45 | - [:hammer_and_wrench: Pull requests](#hammer_and_wrench-pull-requests) 46 | - [Starting with an issue](#starting-with-an-issue) 47 | - [Labels](#labels) 48 | - [Opening a pull request](#opening-a-pull-request) 49 | - [Reviewing](#reviewing) 50 | - [Self review](#self-review) 51 | - [Pull request template](#pull-request-template) 52 | - [Python Styleguide](#python-styleguide) 53 | - [Suggested changes](#suggested-changes) 54 | 55 | ## Types of contributions :memo: 56 | You can contribute to the Twitch-Channel-Points-Miner-v2 in several ways. Bug reporting, pull request, propose new features, fork, donate, and much more :muscle: . 57 | 58 | ### :beetle: Issues 59 | [Issues](https://docs.github.com/en/github/managing-your-work-on-github/about-issues) are used to report a bug, propose new features, or ask for help. When you open an issue, please use the appropriate template and label. 60 | 61 | ### :hammer_and_wrench: Pull requests 62 | A [pull request](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests) is a way to suggest changes in our repository. 63 | 64 | When we merge those changes, they should be deployed to the live site within 24 hours. :earth_africa: To learn more about opening a pull request in this repo, see [Opening a pull request](#opening-a-pull-request) below. 65 | 66 | ## Starting with an issue 67 | You can browse existing issues to find something that needs help! 68 | 69 | ### Labels 70 | Labels can help you find an issue you'd like to help with. 71 | - The `bug` label is used when something isn't working 72 | - The `documentation` label is used when you suggest improvements or additions to documentation (README.md update) 73 | - The `duplicate` label is used when this issue or pull request already exists 74 | - The `enhancement` label is used when you ask for / or propose a new feature or request 75 | - The `help wanted` is used when you need help with something 76 | - The `improvements` label is used when you would suggest improvements on already existing features 77 | - The `invalid` label is used for a non-valid issue 78 | - The `question` label is used for further information is requested 79 | - The `wontfix` label is used if we will not work on it 80 | 81 | ## Opening a pull request 82 | You can use the GitHub user interface :pencil2: for minor changes, like fixing a typo or updating a readme. You can also fork the repo and then clone it locally to view changes and run your tests on your machine. 83 | 84 | ### Self review 85 | You should always review your own PR first. 86 | 87 | For content changes, make sure that you: 88 | - [ ] Confirm that the changes address every part of the content design plan from your issue (if there are differences, explain them). 89 | - [ ] Review the content for technical accuracy. 90 | - [ ] Review the entire pull request using the checklist present in the template. 91 | - [ ] Copy-edit the changes for grammar, spelling, and adherence to the style guide. 92 | - [ ] Check new or updated Liquid statements to confirm that versioning is correct. 93 | - [ ] Check that all of your changes render correctly in staging. Remember, that lists and tables can be tricky. 94 | - [ ] If there are any failing checks in your PR, troubleshoot them until they're all passing. 95 | 96 | ### Pull request template 97 | When you open a pull request, you must fill out the "Ready for review" template before we can review your PR. This template helps reviewers understand your changes and the purpose of your pull request. 98 | 99 | ### Python Styleguide 100 | All Python code is formatted with [Black](https://github.com/psf/black) using the default settings. Your code will not be accepted if it is not blackened. 101 | You can use the pre-commit hook. 102 | ``` 103 | pip install pre-commit 104 | pre-commit install 105 | ``` 106 | 107 | ### Suggested changes 108 | We may ask for changes to be made before a PR can be merged, either using [suggested changes](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request) or pull request comments. You can apply suggested changes directly through the UI. You can make any other changes in your fork, then commit them to your branch. 109 | 110 | As you update your PR and apply changes, mark each conversation as [resolved](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations). 111 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/constants.py: -------------------------------------------------------------------------------- 1 | # Twitch endpoints 2 | URL = "https://www.twitch.tv" # Browser, Apps 3 | # URL = "https://m.twitch.tv" # Mobile Browser 4 | # URL = "https://android.tv.twitch.tv" # TV 5 | IRC = "irc.chat.twitch.tv" 6 | IRC_PORT = 6667 7 | WEBSOCKET = "wss://pubsub-edge.twitch.tv/v1" 8 | CLIENT_ID = "ue6666qo983tsx6so1t0vnawi233wa" # TV 9 | # CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" # Browser 10 | # CLIENT_ID = "r8s4dac0uhzifbpu9sjdiwzctle17ff" # Mobile Browser 11 | # CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp" # Android App 12 | # CLIENT_ID = "851cqzxpb9bqu9z6galo155du" # iOS App 13 | DROP_ID = "c2542d6d-cd10-4532-919b-3d19f30a768b" 14 | # CLIENT_VERSION = "32d439b2-bd5b-4e35-b82a-fae10b04da70" # Android App 15 | CLIENT_VERSION = "ef928475-9403-42f2-8a34-55784bd08e16" # Browser 16 | 17 | USER_AGENTS = { 18 | "Windows": { 19 | 'CHROME': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", 20 | "FIREFOX": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0", 21 | }, 22 | "Linux": { 23 | "CHROME": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36", 24 | "FIREFOX": "Mozilla/5.0 (X11; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0", 25 | }, 26 | "Android": { 27 | # "App": "Dalvik/2.1.0 (Linux; U; Android 7.1.2; SM-G975N Build/N2G48C) tv.twitch.android.app/13.4.1/1304010" 28 | "App": "Dalvik/2.1.0 (Linux; U; Android 7.1.2; SM-G977N Build/LMY48Z) tv.twitch.android.app/14.3.2/1403020", 29 | "TV": "Mozilla/5.0 (Linux; Android 7.1; Smart Box C1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" 30 | } 31 | } 32 | 33 | BRANCH = "master" 34 | GITHUB_url = ( 35 | "https://raw.githubusercontent.com/rdavydov/Twitch-Channel-Points-Miner-v2/" 36 | + BRANCH 37 | ) 38 | 39 | 40 | class GQLOperations: 41 | url = "https://gql.twitch.tv/gql" 42 | integrity_url = "https://gql.twitch.tv/integrity" 43 | WithIsStreamLiveQuery = { 44 | "operationName": "WithIsStreamLiveQuery", 45 | "extensions": { 46 | "persistedQuery": { 47 | "version": 1, 48 | "sha256Hash": "04e46329a6786ff3a81c01c50bfa5d725902507a0deb83b0edbf7abe7a3716ea", 49 | } 50 | }, 51 | } 52 | PlaybackAccessToken = { 53 | "operationName": "PlaybackAccessToken", 54 | "extensions": { 55 | "persistedQuery": { 56 | "version": 1, 57 | "sha256Hash": "3093517e37e4f4cb48906155bcd894150aef92617939236d2508f3375ab732ce", 58 | } 59 | }, 60 | } 61 | VideoPlayerStreamInfoOverlayChannel = { 62 | "operationName": "VideoPlayerStreamInfoOverlayChannel", 63 | "extensions": { 64 | "persistedQuery": { 65 | "version": 1, 66 | "sha256Hash": "a5f2e34d626a9f4f5c0204f910bab2194948a9502089be558bb6e779a9e1b3d2", 67 | } 68 | }, 69 | } 70 | ClaimCommunityPoints = { 71 | "operationName": "ClaimCommunityPoints", 72 | "extensions": { 73 | "persistedQuery": { 74 | "version": 1, 75 | "sha256Hash": "46aaeebe02c99afdf4fc97c7c0cba964124bf6b0af229395f1f6d1feed05b3d0", 76 | } 77 | }, 78 | } 79 | CommunityMomentCallout_Claim = { 80 | "operationName": "CommunityMomentCallout_Claim", 81 | "extensions": { 82 | "persistedQuery": { 83 | "version": 1, 84 | "sha256Hash": "e2d67415aead910f7f9ceb45a77b750a1e1d9622c936d832328a0689e054db62", 85 | } 86 | }, 87 | } 88 | DropsPage_ClaimDropRewards = { 89 | "operationName": "DropsPage_ClaimDropRewards", 90 | "extensions": { 91 | "persistedQuery": { 92 | "version": 1, 93 | "sha256Hash": "a455deea71bdc9015b78eb49f4acfbce8baa7ccbedd28e549bb025bd0f751930", 94 | } 95 | }, 96 | } 97 | ChannelPointsContext = { 98 | "operationName": "ChannelPointsContext", 99 | "extensions": { 100 | "persistedQuery": { 101 | "version": 1, 102 | "sha256Hash": "1530a003a7d374b0380b79db0be0534f30ff46e61cffa2bc0e2468a909fbc024", 103 | } 104 | }, 105 | } 106 | JoinRaid = { 107 | "operationName": "JoinRaid", 108 | "extensions": { 109 | "persistedQuery": { 110 | "version": 1, 111 | "sha256Hash": "c6a332a86d1087fbbb1a8623aa01bd1313d2386e7c63be60fdb2d1901f01a4ae", 112 | } 113 | }, 114 | } 115 | ModViewChannelQuery = { 116 | "operationName": "ModViewChannelQuery", 117 | "extensions": { 118 | "persistedQuery": { 119 | "version": 1, 120 | "sha256Hash": "df5d55b6401389afb12d3017c9b2cf1237164220c8ef4ed754eae8188068a807", 121 | } 122 | }, 123 | } 124 | Inventory = { 125 | "operationName": "Inventory", 126 | "variables": {"fetchRewardCampaigns": True}, 127 | # "variables": {}, 128 | "extensions": { 129 | "persistedQuery": { 130 | "version": 1, 131 | "sha256Hash": "37fea486d6179047c41d0f549088a4c3a7dd60c05c70956a1490262f532dccd9", 132 | } 133 | }, 134 | } 135 | MakePrediction = { 136 | "operationName": "MakePrediction", 137 | "extensions": { 138 | "persistedQuery": { 139 | "version": 1, 140 | "sha256Hash": "b44682ecc88358817009f20e69d75081b1e58825bb40aa53d5dbadcc17c881d8", 141 | } 142 | }, 143 | } 144 | ViewerDropsDashboard = { 145 | "operationName": "ViewerDropsDashboard", 146 | # "variables": {}, 147 | "variables": {"fetchRewardCampaigns": True}, 148 | "extensions": { 149 | "persistedQuery": { 150 | "version": 1, 151 | "sha256Hash": "8d5d9b5e3f088f9d1ff39eb2caab11f7a4cf7a3353da9ce82b5778226ff37268", 152 | } 153 | }, 154 | } 155 | DropCampaignDetails = { 156 | "operationName": "DropCampaignDetails", 157 | "extensions": { 158 | "persistedQuery": { 159 | "version": 1, 160 | "sha256Hash": "f6396f5ffdde867a8f6f6da18286e4baf02e5b98d14689a69b5af320a4c7b7b8", 161 | } 162 | }, 163 | } 164 | DropsHighlightService_AvailableDrops = { 165 | "operationName": "DropsHighlightService_AvailableDrops", 166 | "extensions": { 167 | "persistedQuery": { 168 | "version": 1, 169 | "sha256Hash": "9a62a09bce5b53e26e64a671e530bc599cb6aab1e5ba3cbd5d85966d3940716f", 170 | } 171 | }, 172 | } 173 | ReportMenuItem = { # Use for replace https://api.twitch.tv/helix/users?login={self.username} 174 | "operationName": "ReportMenuItem", 175 | "extensions": { 176 | "persistedQuery": { 177 | "version": 1, 178 | "sha256Hash": "8f3628981255345ca5e5453dfd844efffb01d6413a9931498836e6268692a30c", 179 | } 180 | }, 181 | } 182 | PersonalSections = ( 183 | { 184 | "operationName": "PersonalSections", 185 | "variables": { 186 | "input": { 187 | "sectionInputs": ["FOLLOWED_SECTION"], 188 | "recommendationContext": {"platform": "web"}, 189 | }, 190 | "channelLogin": None, 191 | "withChannelUser": False, 192 | "creatorAnniversariesExperimentEnabled": False, 193 | }, 194 | "extensions": { 195 | "persistedQuery": { 196 | "version": 1, 197 | "sha256Hash": "9fbdfb00156f754c26bde81eb47436dee146655c92682328457037da1a48ed39", 198 | } 199 | }, 200 | }, 201 | ) 202 | ChannelFollows = { 203 | "operationName": "ChannelFollows", 204 | "variables": {"limit": 100, "order": "ASC"}, 205 | "extensions": { 206 | "persistedQuery": { 207 | "version": 1, 208 | "sha256Hash": "eecf815273d3d949e5cf0085cc5084cd8a1b5b7b6f7990cf43cb0beadf546907", 209 | } 210 | }, 211 | } 212 | UserPointsContribution = { 213 | "operationName": "UserPointsContribution", 214 | "extensions": { 215 | "persistedQuery": { 216 | "version": 1, 217 | "sha256Hash": "23ff2c2d60708379131178742327ead913b93b1bd6f665517a6d9085b73f661f" 218 | } 219 | } 220 | } 221 | ContributeCommunityPointsCommunityGoal = { 222 | "operationName": "ContributeCommunityPointsCommunityGoal", 223 | "extensions": { 224 | "persistedQuery": { 225 | "version": 1, 226 | "sha256Hash": "5774f0ea5d89587d73021a2e03c3c44777d903840c608754a1be519f51e37bb6" 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/AnalyticsServer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from datetime import datetime 5 | from pathlib import Path 6 | from threading import Thread 7 | 8 | import pandas as pd 9 | from flask import Flask, Response, cli, render_template, request 10 | 11 | from TwitchChannelPointsMiner.classes.Settings import Settings 12 | from TwitchChannelPointsMiner.utils import download_file 13 | 14 | cli.show_server_banner = lambda *_: None 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def streamers_available(): 19 | path = Settings.analytics_path 20 | return [ 21 | f 22 | for f in os.listdir(path) 23 | if os.path.isfile(os.path.join(path, f)) and f.endswith(".json") 24 | ] 25 | 26 | 27 | def aggregate(df, freq="30Min"): 28 | df_base_events = df[(df.z == "Watch") | (df.z == "Claim")] 29 | df_other_events = df[(df.z != "Watch") & (df.z != "Claim")] 30 | 31 | be = df_base_events.groupby( 32 | [pd.Grouper(freq=freq, key="datetime"), "z"]).max() 33 | be = be.reset_index() 34 | 35 | oe = df_other_events.groupby( 36 | [pd.Grouper(freq=freq, key="datetime"), "z"]).max() 37 | oe = oe.reset_index() 38 | 39 | result = pd.concat([be, oe]) 40 | return result 41 | 42 | 43 | def filter_datas(start_date, end_date, datas): 44 | # Note: https://stackoverflow.com/questions/4676195/why-do-i-need-to-multiply-unix-timestamps-by-1000-in-javascript 45 | start_date = ( 46 | datetime.strptime(start_date, "%Y-%m-%d").timestamp() * 1000 47 | if start_date is not None 48 | else 0 49 | ) 50 | end_date = ( 51 | datetime.strptime(end_date, "%Y-%m-%d") 52 | if end_date is not None 53 | else datetime.now() 54 | ).replace(hour=23, minute=59, second=59).timestamp() * 1000 55 | 56 | original_series = datas["series"] 57 | 58 | if "series" in datas: 59 | df = pd.DataFrame(datas["series"]) 60 | df["datetime"] = pd.to_datetime(df.x // 1000, unit="s") 61 | 62 | df = df[(df.x >= start_date) & (df.x <= end_date)] 63 | 64 | datas["series"] = ( 65 | df.drop(columns="datetime") 66 | .sort_values(by=["x", "y"], ascending=True) 67 | .to_dict("records") 68 | ) 69 | else: 70 | datas["series"] = [] 71 | 72 | # If no data is found within the timeframe, that usually means the streamer hasn't streamed within that timeframe 73 | # We create a series that shows up as a straight line on the dashboard, with 'No Stream' as labels 74 | if len(datas["series"]) == 0: 75 | new_end_date = start_date 76 | new_start_date = 0 77 | df = pd.DataFrame(original_series) 78 | df["datetime"] = pd.to_datetime(df.x // 1000, unit="s") 79 | 80 | # Attempt to get the last known balance from before the provided timeframe 81 | df = df[(df.x >= new_start_date) & (df.x <= new_end_date)] 82 | last_balance = df.drop(columns="datetime").sort_values( 83 | by=["x", "y"], ascending=True).to_dict("records")[-1]['y'] 84 | 85 | datas["series"] = [{'x': start_date, 'y': last_balance, 'z': 'No Stream'}, { 86 | 'x': end_date, 'y': last_balance, 'z': 'No Stream'}] 87 | 88 | if "annotations" in datas: 89 | df = pd.DataFrame(datas["annotations"]) 90 | df["datetime"] = pd.to_datetime(df.x // 1000, unit="s") 91 | 92 | df = df[(df.x >= start_date) & (df.x <= end_date)] 93 | 94 | datas["annotations"] = ( 95 | df.drop(columns="datetime") 96 | .sort_values(by="x", ascending=True) 97 | .to_dict("records") 98 | ) 99 | else: 100 | datas["annotations"] = [] 101 | 102 | return datas 103 | 104 | 105 | def read_json(streamer, return_response=True): 106 | start_date = request.args.get("startDate", type=str) 107 | end_date = request.args.get("endDate", type=str) 108 | 109 | path = Settings.analytics_path 110 | streamer = streamer if streamer.endswith(".json") else f"{streamer}.json" 111 | 112 | # Check if the file exists before attempting to read it 113 | if not os.path.exists(os.path.join(path, streamer)): 114 | error_message = f"File '{streamer}' not found." 115 | logger.error(error_message) 116 | if return_response: 117 | return Response(json.dumps({"error": error_message}), status=404, mimetype="application/json") 118 | else: 119 | return {"error": error_message} 120 | 121 | try: 122 | with open(os.path.join(path, streamer), 'r') as file: 123 | data = json.load(file) 124 | except json.JSONDecodeError as e: 125 | error_message = f"Error decoding JSON in file '{streamer}': {str(e)}" 126 | logger.error(error_message) 127 | if return_response: 128 | return Response(json.dumps({"error": error_message}), status=500, mimetype="application/json") 129 | else: 130 | return {"error": error_message} 131 | 132 | # Handle filtering data, if applicable 133 | filtered_data = filter_datas(start_date, end_date, data) 134 | if return_response: 135 | return Response(json.dumps(filtered_data), status=200, mimetype="application/json") 136 | else: 137 | return filtered_data 138 | 139 | 140 | def get_challenge_points(streamer): 141 | datas = read_json(streamer, return_response=False) 142 | if "series" in datas and datas["series"]: 143 | return datas["series"][-1]["y"] 144 | return 0 # Default value when 'series' key is not found or empty 145 | 146 | 147 | def get_last_activity(streamer): 148 | datas = read_json(streamer, return_response=False) 149 | if "series" in datas and datas["series"]: 150 | return datas["series"][-1]["x"] 151 | return 0 # Default value when 'series' key is not found or empty 152 | 153 | 154 | def json_all(): 155 | return Response( 156 | json.dumps( 157 | [ 158 | { 159 | "name": streamer.strip(".json"), 160 | "data": read_json(streamer, return_response=False), 161 | } 162 | for streamer in streamers_available() 163 | ] 164 | ), 165 | status=200, 166 | mimetype="application/json", 167 | ) 168 | 169 | 170 | def index(refresh=5, days_ago=7): 171 | return render_template( 172 | "charts.html", 173 | refresh=(refresh * 60 * 1000), 174 | daysAgo=days_ago, 175 | ) 176 | 177 | 178 | def streamers(): 179 | return Response( 180 | json.dumps( 181 | [ 182 | {"name": s, "points": get_challenge_points( 183 | s), "last_activity": get_last_activity(s)} 184 | for s in sorted(streamers_available()) 185 | ] 186 | ), 187 | status=200, 188 | mimetype="application/json", 189 | ) 190 | 191 | 192 | def download_assets(assets_folder, required_files): 193 | Path(assets_folder).mkdir(parents=True, exist_ok=True) 194 | logger.info(f"Downloading assets to {assets_folder}") 195 | 196 | for f in required_files: 197 | if os.path.isfile(os.path.join(assets_folder, f)) is False: 198 | if ( 199 | download_file(os.path.join("assets", f), 200 | os.path.join(assets_folder, f)) 201 | is True 202 | ): 203 | logger.info(f"Downloaded {f}") 204 | 205 | 206 | def check_assets(): 207 | required_files = [ 208 | "banner.png", 209 | "charts.html", 210 | "script.js", 211 | "style.css", 212 | "dark-theme.css", 213 | ] 214 | assets_folder = os.path.join(Path().absolute(), "assets") 215 | if os.path.isdir(assets_folder) is False: 216 | logger.info(f"Assets folder not found at {assets_folder}") 217 | download_assets(assets_folder, required_files) 218 | else: 219 | for f in required_files: 220 | if os.path.isfile(os.path.join(assets_folder, f)) is False: 221 | logger.info(f"Missing file {f} in {assets_folder}") 222 | download_assets(assets_folder, required_files) 223 | break 224 | 225 | last_sent_log_index = 0 226 | 227 | class AnalyticsServer(Thread): 228 | def __init__( 229 | self, 230 | host: str = "127.0.0.1", 231 | port: int = 5000, 232 | refresh: int = 5, 233 | days_ago: int = 7, 234 | username: str = None 235 | ): 236 | super(AnalyticsServer, self).__init__() 237 | 238 | check_assets() 239 | 240 | self.host = host 241 | self.port = port 242 | self.refresh = refresh 243 | self.days_ago = days_ago 244 | self.username = username 245 | 246 | def generate_log(): 247 | global last_sent_log_index # Use the global variable 248 | 249 | # Get the last received log index from the client request parameters 250 | last_received_index = int(request.args.get("lastIndex", last_sent_log_index)) 251 | 252 | logs_path = os.path.join(Path().absolute(), "logs") 253 | log_file_path = os.path.join(logs_path, f"{username}.log") 254 | try: 255 | with open(log_file_path, "r", encoding="utf-8") as log_file: 256 | log_content = log_file.read() 257 | 258 | # Extract new log entries since the last received index 259 | new_log_entries = log_content[last_received_index:] 260 | last_sent_log_index = len(log_content) # Update the last sent index 261 | 262 | return Response(new_log_entries, status=200, mimetype="text/plain") 263 | 264 | except FileNotFoundError: 265 | return Response("Log file not found.", status=404, mimetype="text/plain") 266 | 267 | self.app = Flask( 268 | __name__, 269 | template_folder=os.path.join(Path().absolute(), "assets"), 270 | static_folder=os.path.join(Path().absolute(), "assets"), 271 | ) 272 | self.app.add_url_rule( 273 | "/", 274 | "index", 275 | index, 276 | defaults={"refresh": refresh, "days_ago": days_ago}, 277 | methods=["GET"], 278 | ) 279 | self.app.add_url_rule("/streamers", "streamers", 280 | streamers, methods=["GET"]) 281 | self.app.add_url_rule( 282 | "/json/", "json", read_json, methods=["GET"] 283 | ) 284 | self.app.add_url_rule("/json_all", "json_all", 285 | json_all, methods=["GET"]) 286 | self.app.add_url_rule( 287 | "/log", "log", generate_log, methods=["GET"]) 288 | 289 | def run(self): 290 | logger.info( 291 | f"Analytics running on http://{self.host}:{self.port}/", 292 | extra={"emoji": ":globe_with_meridians:"}, 293 | ) 294 | self.app.run(host=self.host, port=self.port, 295 | threaded=True, debug=False) 296 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/Streamer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import time 5 | from datetime import datetime 6 | from threading import Lock 7 | 8 | from TwitchChannelPointsMiner.classes.Chat import ChatPresence, ThreadChat 9 | from TwitchChannelPointsMiner.classes.entities.Bet import BetSettings, DelayMode 10 | from TwitchChannelPointsMiner.classes.entities.Stream import Stream 11 | from TwitchChannelPointsMiner.classes.Settings import Events, Settings 12 | from TwitchChannelPointsMiner.constants import URL 13 | from TwitchChannelPointsMiner.utils import _millify 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class StreamerSettings(object): 19 | __slots__ = [ 20 | "make_predictions", 21 | "follow_raid", 22 | "claim_drops", 23 | "claim_moments", 24 | "watch_streak", 25 | "community_goals", 26 | "bet", 27 | "chat", 28 | ] 29 | 30 | def __init__( 31 | self, 32 | make_predictions: bool = None, 33 | follow_raid: bool = None, 34 | claim_drops: bool = None, 35 | claim_moments: bool = None, 36 | watch_streak: bool = None, 37 | community_goals: bool = None, 38 | bet: BetSettings = None, 39 | chat: ChatPresence = None, 40 | ): 41 | self.make_predictions = make_predictions 42 | self.follow_raid = follow_raid 43 | self.claim_drops = claim_drops 44 | self.claim_moments = claim_moments 45 | self.watch_streak = watch_streak 46 | self.community_goals = community_goals 47 | self.bet = bet 48 | self.chat = chat 49 | 50 | def default(self): 51 | for name in [ 52 | "make_predictions", 53 | "follow_raid", 54 | "claim_drops", 55 | "claim_moments", 56 | "watch_streak", 57 | ]: 58 | if getattr(self, name) is None: 59 | setattr(self, name, True) 60 | if self.community_goals is None: 61 | self.community_goals = False 62 | if self.bet is None: 63 | self.bet = BetSettings() 64 | if self.chat is None: 65 | self.chat = ChatPresence.ONLINE 66 | 67 | def __repr__(self): 68 | return f"BetSettings(make_predictions={self.make_predictions}, follow_raid={self.follow_raid}, claim_drops={self.claim_drops}, claim_moments={self.claim_moments}, watch_streak={self.watch_streak}, community_goals={self.community_goals}, bet={self.bet}, chat={self.chat})" 69 | 70 | 71 | class Streamer(object): 72 | __slots__ = [ 73 | "username", 74 | "channel_id", 75 | "settings", 76 | "is_online", 77 | "stream_up", 78 | "online_at", 79 | "offline_at", 80 | "channel_points", 81 | "community_goals", 82 | "minute_watched_requests", 83 | "viewer_is_mod", 84 | "activeMultipliers", 85 | "irc_chat", 86 | "stream", 87 | "raid", 88 | "history", 89 | "streamer_url", 90 | "mutex", 91 | ] 92 | 93 | def __init__(self, username, settings=None): 94 | self.username: str = username.lower().strip() 95 | self.channel_id: str = "" 96 | self.settings = settings 97 | self.is_online = False 98 | self.stream_up = 0 99 | self.online_at = 0 100 | self.offline_at = 0 101 | self.channel_points = 0 102 | self.community_goals = {} 103 | self.minute_watched_requests = None 104 | self.viewer_is_mod = False 105 | self.activeMultipliers = None 106 | self.irc_chat = None 107 | 108 | self.stream = Stream() 109 | 110 | self.raid = None 111 | self.history = {} 112 | 113 | self.streamer_url = f"{URL}/{self.username}" 114 | 115 | self.mutex = Lock() 116 | 117 | def __repr__(self): 118 | return f"Streamer(username={self.username}, channel_id={self.channel_id}, channel_points={_millify(self.channel_points)})" 119 | 120 | def __str__(self): 121 | return ( 122 | f"{self.username} ({_millify(self.channel_points)} points)" 123 | if Settings.logger.less 124 | else self.__repr__() 125 | ) 126 | 127 | def set_offline(self): 128 | if self.is_online is True: 129 | self.offline_at = time.time() 130 | self.is_online = False 131 | 132 | self.toggle_chat() 133 | 134 | logger.info( 135 | f"{self} is Offline!", 136 | extra={ 137 | "emoji": ":sleeping:", 138 | "event": Events.STREAMER_OFFLINE, 139 | }, 140 | ) 141 | 142 | def set_online(self): 143 | if self.is_online is False: 144 | self.online_at = time.time() 145 | self.is_online = True 146 | self.stream.init_watch_streak() 147 | 148 | self.toggle_chat() 149 | 150 | logger.info( 151 | f"{self} is Online!", 152 | extra={ 153 | "emoji": ":partying_face:", 154 | "event": Events.STREAMER_ONLINE, 155 | }, 156 | ) 157 | 158 | def print_history(self): 159 | return "; ".join( 160 | [ 161 | f"{key} ({self.history[key]['counter']} times, {_millify(self.history[key]['amount'])} gained)" 162 | for key in sorted(self.history) 163 | if self.history[key]["counter"] != 0 164 | ] 165 | ) 166 | 167 | def update_history(self, reason_code, earned, counter=1): 168 | if reason_code not in self.history: 169 | self.history[reason_code] = {"counter": 0, "amount": 0} 170 | self.history[reason_code]["counter"] += counter 171 | self.history[reason_code]["amount"] += earned 172 | 173 | if reason_code == "WATCH_STREAK": 174 | self.stream.watch_streak_missing = False 175 | 176 | def stream_up_elapsed(self): 177 | return self.stream_up == 0 or ((time.time() - self.stream_up) > 120) 178 | 179 | def drops_condition(self): 180 | return ( 181 | self.settings.claim_drops is True 182 | and self.is_online is True 183 | # and self.stream.drops_tags is True 184 | and self.stream.campaigns_ids != [] 185 | ) 186 | 187 | def viewer_has_points_multiplier(self): 188 | return self.activeMultipliers is not None and len(self.activeMultipliers) > 0 189 | 190 | def total_points_multiplier(self): 191 | return ( 192 | sum( 193 | map( 194 | lambda x: x["factor"], 195 | self.activeMultipliers, 196 | ), 197 | ) 198 | if self.activeMultipliers is not None 199 | else 0 200 | ) 201 | 202 | def get_prediction_window(self, prediction_window_seconds): 203 | delay_mode = self.settings.bet.delay_mode 204 | delay = self.settings.bet.delay 205 | if delay_mode == DelayMode.FROM_START: 206 | return min(delay, prediction_window_seconds) 207 | elif delay_mode == DelayMode.FROM_END: 208 | return max(prediction_window_seconds - delay, 0) 209 | elif delay_mode == DelayMode.PERCENTAGE: 210 | return prediction_window_seconds * delay 211 | else: 212 | return prediction_window_seconds 213 | 214 | # === ANALYTICS === # 215 | def persistent_annotations(self, event_type, event_text): 216 | event_type = event_type.upper() 217 | if event_type in ["WATCH_STREAK", "WIN", "PREDICTION_MADE", "LOSE"]: 218 | primary_color = ( 219 | "#45c1ff" # blue #45c1ff yellow #ffe045 green #36b535 red #ff4545 220 | if event_type == "WATCH_STREAK" 221 | else ( 222 | "#ffe045" 223 | if event_type == "PREDICTION_MADE" 224 | else ("#36b535" if event_type == "WIN" else "#ff4545") 225 | ) 226 | ) 227 | data = { 228 | "borderColor": primary_color, 229 | "label": { 230 | "style": {"color": "#000", "background": primary_color}, 231 | "text": event_text, 232 | }, 233 | } 234 | self.__save_json("annotations", data) 235 | 236 | def persistent_series(self, event_type="Watch"): 237 | self.__save_json("series", event_type=event_type) 238 | 239 | def __save_json(self, key, data={}, event_type="Watch"): 240 | # https://stackoverflow.com/questions/4676195/why-do-i-need-to-multiply-unix-timestamps-by-1000-in-javascript 241 | now = datetime.now().replace(microsecond=0) 242 | data.update({"x": round(datetime.timestamp(now) * 1000)}) 243 | 244 | if key == "series": 245 | data.update({"y": self.channel_points}) 246 | if event_type is not None: 247 | data.update({"z": event_type.replace("_", " ").title()}) 248 | 249 | fname = os.path.join(Settings.analytics_path, f"{self.username}.json") 250 | temp_fname = fname + ".temp" # Temporary file name 251 | 252 | with self.mutex: 253 | # Create and write to the temporary file 254 | with open(temp_fname, "w") as temp_file: 255 | json_data = json.load(open(fname, "r")) if os.path.isfile(fname) else {} 256 | if key not in json_data: 257 | json_data[key] = [] 258 | json_data[key].append(data) 259 | json.dump(json_data, temp_file, indent=4) 260 | 261 | # Replace the original file with the temporary file 262 | os.replace(temp_fname, fname) 263 | 264 | def leave_chat(self): 265 | if self.irc_chat is not None: 266 | self.irc_chat.stop() 267 | 268 | # Recreate a new thread to start again 269 | # raise RuntimeError("threads can only be started once") 270 | self.irc_chat = ThreadChat( 271 | self.irc_chat.username, 272 | self.irc_chat.token, 273 | self.username, 274 | ) 275 | 276 | def __join_chat(self): 277 | if self.irc_chat is not None: 278 | if self.irc_chat.is_alive() is False: 279 | self.irc_chat.start() 280 | 281 | def toggle_chat(self): 282 | if self.settings.chat == ChatPresence.ALWAYS: 283 | self.__join_chat() 284 | elif self.settings.chat != ChatPresence.NEVER: 285 | if self.is_online is True: 286 | if self.settings.chat == ChatPresence.ONLINE: 287 | self.__join_chat() 288 | elif self.settings.chat == ChatPresence.OFFLINE: 289 | self.leave_chat() 290 | else: 291 | if self.settings.chat == ChatPresence.ONLINE: 292 | self.leave_chat() 293 | elif self.settings.chat == ChatPresence.OFFLINE: 294 | self.__join_chat() 295 | 296 | def update_community_goal(self, community_goal): 297 | self.community_goals[community_goal.goal_id] = community_goal 298 | 299 | def delete_community_goal(self, goal_id): 300 | self.community_goals.pop(goal_id) 301 | -------------------------------------------------------------------------------- /assets/charts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Twitch-Channel-Points-Miner-v2 8 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 105 | 106 |
107 | 141 |
142 | 143 |
144 |
145 | 146 |
147 |
148 | 149 | 153 | 157 | 161 | 165 |
166 |
167 |
168 |
169 |
170 |
    171 |
    172 |
    173 |
    174 |
    175 |
    176 |
    177 |
    178 |
    179 | 182 |
    183 |
    184 |
    185 | 186 | 187 | 207 | 208 | 209 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | from colorama import Fore 5 | from TwitchChannelPointsMiner import TwitchChannelPointsMiner 6 | from TwitchChannelPointsMiner.logger import LoggerSettings, ColorPalette 7 | from TwitchChannelPointsMiner.classes.Chat import ChatPresence 8 | from TwitchChannelPointsMiner.classes.Discord import Discord 9 | from TwitchChannelPointsMiner.classes.Webhook import Webhook 10 | from TwitchChannelPointsMiner.classes.Telegram import Telegram 11 | from TwitchChannelPointsMiner.classes.Matrix import Matrix 12 | from TwitchChannelPointsMiner.classes.Pushover import Pushover 13 | from TwitchChannelPointsMiner.classes.Gotify import Gotify 14 | from TwitchChannelPointsMiner.classes.Settings import Priority, Events, FollowersOrder 15 | from TwitchChannelPointsMiner.classes.entities.Bet import Strategy, BetSettings, Condition, OutcomeKeys, FilterCondition, DelayMode 16 | from TwitchChannelPointsMiner.classes.entities.Streamer import Streamer, StreamerSettings 17 | 18 | twitch_miner = TwitchChannelPointsMiner( 19 | username="your-twitch-username", 20 | password="write-your-secure-psw", # If no password will be provided, the script will ask interactively 21 | claim_drops_startup=False, # If you want to auto claim all drops from Twitch inventory on the startup 22 | priority=[ # Custom priority in this case for example: 23 | Priority.STREAK, # - We want first of all to catch all watch streak from all streamers 24 | Priority.DROPS, # - When we don't have anymore watch streak to catch, wait until all drops are collected over the streamers 25 | Priority.ORDER # - When we have all of the drops claimed and no watch-streak available, use the order priority (POINTS_ASCENDING, POINTS_DESCENDING) 26 | ], 27 | enable_analytics=False, # Disables Analytics if False. Disabling it significantly reduces memory consumption 28 | disable_ssl_cert_verification=False, # Set to True at your own risk and only to fix SSL: CERTIFICATE_VERIFY_FAILED error 29 | disable_at_in_nickname=False, # Set to True if you want to check for your nickname mentions in the chat even without @ sign 30 | logger_settings=LoggerSettings( 31 | save=True, # If you want to save logs in a file (suggested) 32 | console_level=logging.INFO, # Level of logs - use logging.DEBUG for more info 33 | console_username=False, # Adds a username to every console log line if True. Also adds it to Telegram, Discord, etc. Useful when you have several accounts 34 | auto_clear=True, # Create a file rotation handler with interval = 1D and backupCount = 7 if True (default) 35 | time_zone="", # Set a specific time zone for console and file loggers. Use tz database names. Example: "America/Denver" 36 | file_level=logging.DEBUG, # Level of logs - If you think the log file it's too big, use logging.INFO 37 | emoji=True, # On Windows, we have a problem printing emoji. Set to false if you have a problem 38 | less=False, # If you think that the logs are too verbose, set this to True 39 | colored=True, # If you want to print colored text 40 | color_palette=ColorPalette( # You can also create a custom palette color (for the common message). 41 | STREAMER_online="GREEN", # Don't worry about lower/upper case. The script will parse all the values. 42 | streamer_offline="red", # Read more in README.md 43 | BET_wiN=Fore.MAGENTA # Color allowed are: [BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, RESET]. 44 | ), 45 | telegram=Telegram( # You can omit or set to None if you don't want to receive updates on Telegram 46 | chat_id=123456789, # Chat ID to send messages @getmyid_bot 47 | token="123456789:shfuihreuifheuifhiu34578347", # Telegram API token @BotFather 48 | events=[Events.STREAMER_ONLINE, Events.STREAMER_OFFLINE, 49 | Events.BET_LOSE, Events.CHAT_MENTION], # Only these events will be sent to the chat 50 | disable_notification=True, # Revoke the notification (sound/vibration) 51 | ), 52 | discord=Discord( 53 | webhook_api="https://discord.com/api/webhooks/0123456789/0a1B2c3D4e5F6g7H8i9J", # Discord Webhook URL 54 | events=[Events.STREAMER_ONLINE, Events.STREAMER_OFFLINE, 55 | Events.BET_LOSE, Events.CHAT_MENTION], # Only these events will be sent to the chat 56 | ), 57 | webhook=Webhook( 58 | endpoint="https://example.com/webhook", # Webhook URL 59 | method="GET", # GET or POST 60 | events=[Events.STREAMER_ONLINE, Events.STREAMER_OFFLINE, 61 | Events.BET_LOSE, Events.CHAT_MENTION], # Only these events will be sent to the endpoint 62 | ), 63 | matrix=Matrix( 64 | username="twitch_miner", # Matrix username (without homeserver) 65 | password="...", # Matrix password 66 | homeserver="matrix.org", # Matrix homeserver 67 | room_id="...", # Room ID 68 | events=[Events.STREAMER_ONLINE, Events.STREAMER_OFFLINE, Events.BET_LOSE], # Only these events will be sent 69 | ), 70 | pushover=Pushover( 71 | userkey="YOUR-ACCOUNT-TOKEN", # Login to https://pushover.net/, the user token is on the main page 72 | token="YOUR-APPLICATION-TOKEN", # Create a application on the website, and use the token shown in your application 73 | priority=0, # Read more about priority here: https://pushover.net/api#priority 74 | sound="pushover", # A list of sounds can be found here: https://pushover.net/api#sounds 75 | events=[Events.CHAT_MENTION, Events.DROP_CLAIM], # Only these events will be sent 76 | ), 77 | gotify=Gotify( 78 | endpoint="https://example.com/message?token=TOKEN", 79 | priority=8, 80 | events=[Events.STREAMER_ONLINE, Events.STREAMER_OFFLINE, 81 | Events.BET_LOSE, Events.CHAT_MENTION], 82 | ) 83 | ), 84 | streamer_settings=StreamerSettings( 85 | make_predictions=True, # If you want to Bet / Make prediction 86 | follow_raid=True, # Follow raid to obtain more points 87 | claim_drops=True, # We can't filter rewards base on stream. Set to False for skip viewing counter increase and you will never obtain a drop reward from this script. Issue #21 88 | claim_moments=True, # If set to True, https://help.twitch.tv/s/article/moments will be claimed when available 89 | watch_streak=True, # If a streamer go online change the priority of streamers array and catch the watch screak. Issue #11 90 | community_goals=False, # If True, contributes the max channel points per stream to the streamers' community challenge goals 91 | chat=ChatPresence.ONLINE, # Join irc chat to increase watch-time [ALWAYS, NEVER, ONLINE, OFFLINE] 92 | bet=BetSettings( 93 | strategy=Strategy.SMART, # Choose you strategy! 94 | percentage=5, # Place the x% of your channel points 95 | percentage_gap=20, # Gap difference between outcomesA and outcomesB (for SMART strategy) 96 | max_points=50000, # If the x percentage of your channel points is gt bet_max_points set this value 97 | stealth_mode=True, # If the calculated amount of channel points is GT the highest bet, place the highest value minus 1-2 points Issue #33 98 | delay_mode=DelayMode.FROM_END, # When placing a bet, we will wait until `delay` seconds before the end of the timer 99 | delay=6, 100 | minimum_points=20000, # Place the bet only if we have at least 20k points. Issue #113 101 | filter_condition=FilterCondition( 102 | by=OutcomeKeys.TOTAL_USERS, # Where apply the filter. Allowed [PERCENTAGE_USERS, ODDS_PERCENTAGE, ODDS, TOP_POINTS, TOTAL_USERS, TOTAL_POINTS] 103 | where=Condition.LTE, # 'by' must be [GT, LT, GTE, LTE] than value 104 | value=800 105 | ) 106 | ) 107 | ) 108 | ) 109 | 110 | # You can customize the settings for each streamer. If not settings were provided, the script would use the streamer_settings from TwitchChannelPointsMiner. 111 | # If no streamer_settings are provided in TwitchChannelPointsMiner the script will use default settings. 112 | # The streamers array can be a String -> username or Streamer instance. 113 | 114 | # The settings priority are: settings in mine function, settings in TwitchChannelPointsMiner instance, default settings. 115 | # For example, if in the mine function you don't provide any value for 'make_prediction' but you have set it on TwitchChannelPointsMiner instance, the script will take the value from here. 116 | # If you haven't set any value even in the instance the default one will be used 117 | 118 | #twitch_miner.analytics(host="127.0.0.1", port=5000, refresh=5, days_ago=7) # Start the Analytics web-server 119 | 120 | twitch_miner.mine( 121 | [ 122 | Streamer("streamer-username01", settings=StreamerSettings(make_predictions=True , follow_raid=False , claim_drops=True , watch_streak=True , community_goals=False , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=True, percentage_gap=20 , max_points=234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_USERS, where=Condition.LTE, value=800 ) ) )), 123 | Streamer("streamer-username02", settings=StreamerSettings(make_predictions=False , follow_raid=True , claim_drops=False , bet=BetSettings(strategy=Strategy.PERCENTAGE , percentage=5 , stealth_mode=False, percentage_gap=20 , max_points=1234 , filter_condition=FilterCondition(by=OutcomeKeys.TOTAL_POINTS, where=Condition.GTE, value=250 ) ) )), 124 | Streamer("streamer-username03", settings=StreamerSettings(make_predictions=True , follow_raid=False , watch_streak=True , community_goals=True , bet=BetSettings(strategy=Strategy.SMART , percentage=5 , stealth_mode=False, percentage_gap=30 , max_points=50000 , filter_condition=FilterCondition(by=OutcomeKeys.ODDS, where=Condition.LT, value=300 ) ) )), 125 | Streamer("streamer-username04", settings=StreamerSettings(make_predictions=False , follow_raid=True , watch_streak=True , )), 126 | Streamer("streamer-username05", settings=StreamerSettings(make_predictions=True , follow_raid=True , claim_drops=True , watch_streak=True , community_goals=True , bet=BetSettings(strategy=Strategy.HIGH_ODDS , percentage=7 , stealth_mode=True, percentage_gap=20 , max_points=90 , filter_condition=FilterCondition(by=OutcomeKeys.PERCENTAGE_USERS, where=Condition.GTE, value=300 ) ) )), 127 | Streamer("streamer-username06"), 128 | Streamer("streamer-username07"), 129 | Streamer("streamer-username08"), 130 | "streamer-username09", 131 | "streamer-username10", 132 | "streamer-username11" 133 | ], # Array of streamers (order = priority) 134 | followers=False, # Automatic download the list of your followers 135 | followers_order=FollowersOrder.ASC # Sort the followers list by follow date. ASC or DESC 136 | ) 137 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import platform 4 | import queue 5 | import pytz 6 | import sys 7 | from datetime import datetime 8 | from logging.handlers import QueueHandler, QueueListener, TimedRotatingFileHandler 9 | from pathlib import Path 10 | 11 | import emoji 12 | from colorama import Fore, init 13 | 14 | from TwitchChannelPointsMiner.classes.Discord import Discord 15 | from TwitchChannelPointsMiner.classes.Webhook import Webhook 16 | from TwitchChannelPointsMiner.classes.Matrix import Matrix 17 | from TwitchChannelPointsMiner.classes.Settings import Events 18 | from TwitchChannelPointsMiner.classes.Telegram import Telegram 19 | from TwitchChannelPointsMiner.classes.Pushover import Pushover 20 | from TwitchChannelPointsMiner.classes.Gotify import Gotify 21 | from TwitchChannelPointsMiner.utils import remove_emoji 22 | 23 | 24 | # Fore: BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, RESET. 25 | class ColorPalette(object): 26 | def __init__(self, **kwargs): 27 | # Init with default values RESET for all and GREEN and RED only for WIN and LOSE bet 28 | # Then set args from kwargs 29 | for k in Events: 30 | setattr(self, str(k), Fore.RESET) 31 | setattr(self, "BET_WIN", Fore.GREEN) 32 | setattr(self, "BET_LOSE", Fore.RED) 33 | 34 | for k in kwargs: 35 | if k.upper() in dir(self) and getattr(self, k.upper()) is not None: 36 | if kwargs[k] in [ 37 | Fore.BLACK, 38 | Fore.RED, 39 | Fore.GREEN, 40 | Fore.YELLOW, 41 | Fore.BLUE, 42 | Fore.MAGENTA, 43 | Fore.CYAN, 44 | Fore.WHITE, 45 | Fore.RESET, 46 | ]: 47 | setattr(self, k.upper(), kwargs[k]) 48 | elif kwargs[k].upper() in [ 49 | "BLACK", 50 | "RED", 51 | "GREEN", 52 | "YELLOW", 53 | "BLUE", 54 | "MAGENTA", 55 | "CYAN", 56 | "WHITE", 57 | "RESET", 58 | ]: 59 | setattr(self, k.upper(), getattr(Fore, kwargs[k].upper())) 60 | 61 | def get(self, key): 62 | color = getattr(self, str(key)) if str(key) in dir(self) else None 63 | return Fore.RESET if color is None else color 64 | 65 | 66 | class LoggerSettings: 67 | __slots__ = [ 68 | "save", 69 | "less", 70 | "console_level", 71 | "console_username", 72 | "time_zone", 73 | "file_level", 74 | "emoji", 75 | "colored", 76 | "color_palette", 77 | "auto_clear", 78 | "telegram", 79 | "discord", 80 | "webhook", 81 | "matrix", 82 | "pushover", 83 | "gotify", 84 | "username" 85 | ] 86 | 87 | def __init__( 88 | self, 89 | save: bool = True, 90 | less: bool = False, 91 | console_level: int = logging.INFO, 92 | console_username: bool = False, 93 | time_zone: str or None = None, 94 | file_level: int = logging.DEBUG, 95 | emoji: bool = platform.system() != "Windows", 96 | colored: bool = False, 97 | color_palette: ColorPalette = ColorPalette(), 98 | auto_clear: bool = True, 99 | telegram: Telegram or None = None, 100 | discord: Discord or None = None, 101 | webhook: Webhook or None = None, 102 | matrix: Matrix or None = None, 103 | pushover: Pushover or None = None, 104 | gotify: Gotify or None = None, 105 | username: str or None = None 106 | ): 107 | self.save = save 108 | self.less = less 109 | self.console_level = console_level 110 | self.console_username = console_username 111 | self.time_zone = time_zone 112 | self.file_level = file_level 113 | self.emoji = emoji 114 | self.colored = colored 115 | self.color_palette = color_palette 116 | self.auto_clear = auto_clear 117 | self.telegram = telegram 118 | self.discord = discord 119 | self.webhook = webhook 120 | self.matrix = matrix 121 | self.pushover = pushover 122 | self.gotify = gotify 123 | self.username = username 124 | 125 | 126 | class FileFormatter(logging.Formatter): 127 | def __init__(self, *, fmt, settings: LoggerSettings, datefmt=None): 128 | self.settings = settings 129 | self.timezone = None 130 | if settings.time_zone: 131 | try: 132 | self.timezone = pytz.timezone(settings.time_zone) 133 | logging.info(f"File logger time zone set to: {self.timezone}") 134 | except pytz.UnknownTimeZoneError: 135 | logging.error( 136 | f"File logger: invalid time zone: {settings.time_zone}") 137 | logging.Formatter.__init__(self, fmt=fmt, datefmt=datefmt) 138 | 139 | def formatTime(self, record, datefmt=None): 140 | if self.timezone: 141 | dt = datetime.fromtimestamp(record.created, self.timezone) 142 | else: 143 | dt = datetime.fromtimestamp(record.created) 144 | return dt.strftime(datefmt or self.default_time_format) 145 | 146 | 147 | class GlobalFormatter(logging.Formatter): 148 | def __init__(self, *, fmt, settings: LoggerSettings, datefmt=None): 149 | self.settings = settings 150 | self.timezone = None 151 | if settings.time_zone: 152 | try: 153 | self.timezone = pytz.timezone(settings.time_zone) 154 | logging.info( 155 | f"Console logger time zone set to: {self.timezone}") 156 | except pytz.UnknownTimeZoneError: 157 | logging.error( 158 | f"Console logger: invalid time zone: {settings.time_zone}") 159 | logging.Formatter.__init__(self, fmt=fmt, datefmt=datefmt) 160 | 161 | def formatTime(self, record, datefmt=None): 162 | if self.timezone: 163 | dt = datetime.fromtimestamp(record.created, self.timezone) 164 | else: 165 | dt = datetime.fromtimestamp(record.created) 166 | return dt.strftime(datefmt or self.default_time_format) 167 | 168 | def format(self, record): 169 | record.emoji_is_present = ( 170 | record.emoji_is_present if hasattr( 171 | record, "emoji_is_present") else False 172 | ) 173 | if ( 174 | hasattr(record, "emoji") 175 | and self.settings.emoji is True 176 | and record.emoji_is_present is False 177 | ): 178 | record.msg = emoji.emojize( 179 | f"{record.emoji} {record.msg.strip()}", language="alias" 180 | ) 181 | record.emoji_is_present = True 182 | 183 | if self.settings.emoji is False: 184 | if "\u2192" in record.msg: 185 | record.msg = record.msg.replace("\u2192", "-->") 186 | 187 | # With the update of Stream class, the Stream Title may contain emoji 188 | # Full remove using a method from utils. 189 | record.msg = remove_emoji(record.msg) 190 | 191 | record.msg = self.settings.username + record.msg 192 | 193 | if hasattr(record, "event"): 194 | self.telegram(record) 195 | self.discord(record) 196 | self.webhook(record) 197 | self.matrix(record) 198 | self.pushover(record) 199 | self.gotify(record) 200 | 201 | if self.settings.colored is True: 202 | record.msg = ( 203 | f"{self.settings.color_palette.get(record.event)}{record.msg}" 204 | ) 205 | 206 | return super().format(record) 207 | 208 | def telegram(self, record): 209 | skip_telegram = False if hasattr( 210 | record, "skip_telegram") is False else True 211 | 212 | if ( 213 | self.settings.telegram is not None 214 | and skip_telegram is False 215 | and self.settings.telegram.chat_id != 123456789 216 | ): 217 | self.settings.telegram.send(record.msg, record.event) 218 | 219 | def discord(self, record): 220 | skip_discord = False if hasattr( 221 | record, "skip_discord") is False else True 222 | 223 | if ( 224 | self.settings.discord is not None 225 | and skip_discord is False 226 | and self.settings.discord.webhook_api 227 | != "https://discord.com/api/webhooks/0123456789/0a1B2c3D4e5F6g7H8i9J" 228 | ): 229 | self.settings.discord.send(record.msg, record.event) 230 | 231 | def webhook(self, record): 232 | skip_webhook = False if hasattr( 233 | record, "skip_webhook") is False else True 234 | 235 | if ( 236 | self.settings.webhook is not None 237 | and skip_webhook is False 238 | and self.settings.webhook.endpoint 239 | != "https://example.com/webhook" 240 | ): 241 | self.settings.webhook.send(record.msg, record.event) 242 | 243 | def matrix(self, record): 244 | skip_matrix = False if hasattr( 245 | record, "skip_matrix") is False else True 246 | 247 | if ( 248 | self.settings.matrix is not None 249 | and skip_matrix is False 250 | and self.settings.matrix.room_id != "..." 251 | and self.settings.matrix.access_token 252 | ): 253 | self.settings.matrix.send(record.msg, record.event) 254 | 255 | def pushover(self, record): 256 | skip_pushover = False if hasattr( 257 | record, "skip_pushover") is False else True 258 | 259 | if ( 260 | self.settings.pushover is not None 261 | and skip_pushover is False 262 | and self.settings.pushover.userkey != "YOUR-ACCOUNT-TOKEN" 263 | and self.settings.pushover.token != "YOUR-APPLICATION-TOKEN" 264 | ): 265 | self.settings.pushover.send(record.msg, record.event) 266 | 267 | def gotify(self, record): 268 | skip_gotify = False if hasattr( 269 | record, "skip_gotify") is False else True 270 | 271 | if ( 272 | self.settings.gotify is not None 273 | and skip_gotify is False 274 | and self.settings.gotify.endpoint 275 | != "https://example.com/message?token=TOKEN" 276 | ): 277 | self.settings.gotify.send(record.msg, record.event) 278 | 279 | 280 | def configure_loggers(username, settings): 281 | if settings.colored is True: 282 | init(autoreset=True) 283 | 284 | # Queue handler that will handle the logger queue 285 | logger_queue = queue.Queue(-1) 286 | queue_handler = QueueHandler(logger_queue) 287 | root_logger = logging.getLogger() 288 | root_logger.setLevel(logging.DEBUG) 289 | # Add the queue handler to the root logger 290 | # Send log messages to another thread through the queue 291 | root_logger.addHandler(queue_handler) 292 | 293 | # Adding a username to the format based on settings 294 | console_username = "" if settings.console_username is False else f"[{username}] " 295 | 296 | settings.username = console_username 297 | 298 | console_handler = logging.StreamHandler(sys.stdout) 299 | console_handler.setLevel(settings.console_level) 300 | console_handler.setFormatter( 301 | GlobalFormatter( 302 | fmt=( 303 | "%(asctime)s - %(levelname)s - [%(funcName)s]: %(message)s" 304 | if settings.less is False 305 | else "%(asctime)s - %(message)s" 306 | ), 307 | datefmt=( 308 | "%d/%m/%y %H:%M:%S" if settings.less is False else "%d/%m %H:%M:%S" 309 | ), 310 | settings=settings, 311 | ) 312 | ) 313 | 314 | if settings.save is True: 315 | logs_path = os.path.join(Path().absolute(), "logs") 316 | Path(logs_path).mkdir(parents=True, exist_ok=True) 317 | if settings.auto_clear is True: 318 | logs_file = os.path.join( 319 | logs_path, 320 | f"{username}.log", 321 | ) 322 | file_handler = TimedRotatingFileHandler( 323 | logs_file, 324 | when="D", 325 | interval=1, 326 | backupCount=7, 327 | encoding="utf-8", 328 | delay=False, 329 | ) 330 | else: 331 | # Getting time zone from the console_handler's formatter since they are the same 332 | tz = "" if console_handler.formatter.timezone is False else console_handler.formatter.timezone 333 | logs_file = os.path.join( 334 | logs_path, 335 | f"{username}.{datetime.now(tz).strftime('%Y%m%d-%H%M%S')}.log", 336 | ) 337 | file_handler = logging.FileHandler(logs_file, "w", "utf-8") 338 | 339 | file_handler.setFormatter( 340 | FileFormatter( 341 | fmt="%(asctime)s - %(levelname)s - %(name)s - [%(funcName)s]: %(message)s", 342 | datefmt="%d/%m/%y %H:%M:%S", 343 | settings=settings 344 | ) 345 | ) 346 | file_handler.setLevel(settings.file_level) 347 | 348 | # Add logger handlers to the logger queue and start the process 349 | queue_listener = QueueListener( 350 | logger_queue, file_handler, console_handler, respect_handler_level=True 351 | ) 352 | queue_listener.start() 353 | return logs_file, queue_listener 354 | else: 355 | queue_listener = QueueListener( 356 | logger_queue, console_handler, respect_handler_level=True 357 | ) 358 | queue_listener.start() 359 | return None, queue_listener 360 | -------------------------------------------------------------------------------- /assets/script.js: -------------------------------------------------------------------------------- 1 | // https://apexcharts.com/javascript-chart-demos/line-charts/zoomable-timeseries/ 2 | var options = { 3 | series: [], 4 | chart: { 5 | type: 'area', 6 | stacked: false, 7 | height: 490, 8 | zoom: { 9 | type: 'x', 10 | enabled: true, 11 | autoScaleYaxis: true 12 | }, 13 | // background: '#2B2D3E', 14 | foreColor: '#fff' 15 | }, 16 | dataLabels: { 17 | enabled: false 18 | }, 19 | stroke: { 20 | curve: 'smooth', 21 | }, 22 | markers: { 23 | size: 0, 24 | }, 25 | title: { 26 | text: 'Channel points (dates are displayed in UTC)', 27 | align: 'left' 28 | }, 29 | colors: ["#f9826c"], 30 | fill: { 31 | type: 'gradient', 32 | gradient: { 33 | shadeIntensity: 1, 34 | inverseColors: false, 35 | opacityFrom: 0.5, 36 | opacityTo: 0, 37 | stops: [0, 90, 100] 38 | }, 39 | }, 40 | yaxis: { 41 | title: { 42 | text: 'Channel points' 43 | }, 44 | }, 45 | xaxis: { 46 | type: 'datetime', 47 | labels: { 48 | datetimeUTC: false 49 | } 50 | }, 51 | tooltip: { 52 | theme: 'dark', 53 | shared: false, 54 | x: { 55 | show: true, 56 | format: 'HH:mm:ss dd MMM', 57 | }, 58 | custom: ({ 59 | series, 60 | seriesIndex, 61 | dataPointIndex, 62 | w 63 | }) => { 64 | return (`
    65 |
    ${w.globals.seriesNames[seriesIndex]}
    66 |
    67 |
    68 |
    69 | Points: ${series[seriesIndex][dataPointIndex]}
    70 | Reason: ${w.globals.seriesZ[seriesIndex][dataPointIndex] ? w.globals.seriesZ[seriesIndex][dataPointIndex] : ''} 71 |
    72 |
    73 |
    74 |
    `) 75 | } 76 | }, 77 | noData: { 78 | text: 'Loading...' 79 | } 80 | }; 81 | 82 | var chart = new ApexCharts(document.querySelector("#chart"), options); 83 | var currentStreamer = null; 84 | var annotations = []; 85 | 86 | var streamersList = []; 87 | var sortBy = "Name ascending"; 88 | var sortField = 'name'; 89 | 90 | var startDate = new Date(); 91 | startDate.setDate(startDate.getDate() - daysAgo); 92 | var endDate = new Date(); 93 | 94 | $(document).ready(function () { 95 | // Variable to keep track of whether log checkbox is checked 96 | var isLogCheckboxChecked = $('#log').prop('checked'); 97 | 98 | // Variable to keep track of whether auto-update log is active 99 | var autoUpdateLog = true; 100 | 101 | // Variable to keep track of the last received log index 102 | var lastReceivedLogIndex = 0; 103 | 104 | $('#auto-update-log').click(() => { 105 | autoUpdateLog = !autoUpdateLog; 106 | $('#auto-update-log').text(autoUpdateLog ? '⏸️' : '▶️'); 107 | 108 | if (autoUpdateLog) { 109 | getLog(); 110 | } 111 | }); 112 | 113 | // Function to get the full log content 114 | function getLog() { 115 | if (isLogCheckboxChecked) { 116 | $.get(`/log?lastIndex=${lastReceivedLogIndex}`, function (data) { 117 | // Process and display the new log entries received 118 | $("#log-content").append(data); 119 | // Scroll to the bottom of the log content 120 | $("#log-content").scrollTop($("#log-content")[0].scrollHeight); 121 | 122 | // Update the last received log index 123 | lastReceivedLogIndex += data.length; 124 | 125 | if (autoUpdateLog) { 126 | // Call getLog() again after a certain interval (e.g., 1 second) 127 | setTimeout(getLog, 1000); 128 | } 129 | }); 130 | } 131 | } 132 | 133 | // Retrieve the saved header visibility preference from localStorage 134 | var headerVisibility = localStorage.getItem('headerVisibility'); 135 | 136 | // Set the initial header visibility based on the saved preference or default to 'visible' 137 | if (headerVisibility === 'hidden') { 138 | $('#toggle-header').prop('checked', false); 139 | $('#header').hide(); 140 | } else { 141 | $('#toggle-header').prop('checked', true); 142 | $('#header').show(); 143 | } 144 | 145 | // Handle the toggle header change event 146 | $('#toggle-header').change(function () { 147 | if (this.checked) { 148 | $('#header').show(); 149 | // Save the header visibility preference as 'visible' in localStorage 150 | localStorage.setItem('headerVisibility', 'visible'); 151 | } else { 152 | $('#header').hide(); 153 | // Save the header visibility preference as 'hidden' in localStorage 154 | localStorage.setItem('headerVisibility', 'hidden'); 155 | } 156 | }); 157 | 158 | chart.render(); 159 | 160 | if (!localStorage.getItem("annotations")) localStorage.setItem("annotations", true); 161 | if (!localStorage.getItem("dark-mode")) localStorage.setItem("dark-mode", true); 162 | if (!localStorage.getItem("sort-by")) localStorage.setItem("sort-by", "Name ascending"); 163 | 164 | // Restore settings from localStorage on page load 165 | $('#annotations').prop("checked", localStorage.getItem("annotations") === "true"); 166 | $('#dark-mode').prop("checked", localStorage.getItem("dark-mode") === "true"); 167 | 168 | // Handle the annotation toggle click event 169 | $('#annotations').click(() => { 170 | var isChecked = $('#annotations').prop("checked"); 171 | localStorage.setItem("annotations", isChecked); 172 | updateAnnotations(); 173 | }); 174 | 175 | // Handle the dark mode toggle click event 176 | $('#dark-mode').click(() => { 177 | var isChecked = $('#dark-mode').prop("checked"); 178 | localStorage.setItem("dark-mode", isChecked); 179 | toggleDarkMode(); 180 | }); 181 | 182 | $('#startDate').val(formatDate(startDate)); 183 | $('#endDate').val(formatDate(endDate)); 184 | 185 | sortBy = localStorage.getItem("sort-by"); 186 | if (sortBy.includes("Points")) sortField = 'points'; 187 | else if (sortBy.includes("Last activity")) sortField = 'last_activity'; 188 | else sortField = 'name'; 189 | $('#sorting-by').text(sortBy); 190 | getStreamers(); 191 | 192 | updateAnnotations(); 193 | toggleDarkMode(); 194 | 195 | // Retrieve log checkbox state from localStorage and update UI accordingly 196 | var logCheckboxState = localStorage.getItem('logCheckboxState'); 197 | $('#log').prop('checked', logCheckboxState === 'true'); 198 | if (logCheckboxState === 'true') { 199 | isLogCheckboxChecked = true; 200 | $('#auto-update-log').show(); 201 | $('#log-box').show(); 202 | // Start continuously updating the log content 203 | getLog(); 204 | } 205 | 206 | // Handle the log checkbox change event 207 | $('#log').change(function () { 208 | isLogCheckboxChecked = $(this).prop('checked'); 209 | localStorage.setItem('logCheckboxState', isLogCheckboxChecked); 210 | 211 | if (isLogCheckboxChecked) { 212 | $('#log-box').show(); 213 | $('#auto-update-log').show(); 214 | getLog(); 215 | $('html, body').scrollTop($(document).height()); 216 | } else { 217 | $('#log-box').hide(); 218 | $('#auto-update-log').hide(); 219 | // Clear log content when checkbox is unchecked 220 | // $("#log-content").text(''); 221 | } 222 | }); 223 | }); 224 | 225 | function formatDate(date) { 226 | var d = new Date(date), 227 | month = '' + (d.getMonth() + 1), 228 | day = '' + d.getDate(), 229 | year = d.getFullYear(); 230 | 231 | if (month.length < 2) month = '0' + month; 232 | if (day.length < 2) day = '0' + day; 233 | 234 | return [year, month, day].join('-'); 235 | } 236 | 237 | function changeStreamer(streamer, index) { 238 | $("li").removeClass("is-active") 239 | $("li").eq(index - 1).addClass('is-active'); 240 | currentStreamer = streamer; 241 | 242 | // Update the chart title with the current streamer's name 243 | options.title.text = `${streamer.replace(".json", "")}'s channel points (dates are displayed in UTC)`; 244 | chart.updateOptions(options); 245 | 246 | // Save the selected streamer in localStorage 247 | localStorage.setItem("selectedStreamer", currentStreamer); 248 | 249 | getStreamerData(streamer); 250 | } 251 | 252 | function getStreamerData(streamer) { 253 | if (currentStreamer == streamer) { 254 | $.getJSON(`./json/${streamer}`, { 255 | startDate: formatDate(startDate), 256 | endDate: formatDate(endDate) 257 | }, function (response) { 258 | chart.updateSeries([{ 259 | name: streamer.replace(".json", ""), 260 | data: response["series"] 261 | }], true) 262 | clearAnnotations(); 263 | annotations = response["annotations"]; 264 | updateAnnotations(); 265 | setTimeout(function () { 266 | getStreamerData(streamer); 267 | }, 300000); // 5 minutes 268 | }); 269 | } 270 | } 271 | 272 | function getAllStreamersData() { 273 | $.getJSON(`./json_all`, function (response) { 274 | for (var i in response) { 275 | chart.appendSeries({ 276 | name: response[i]["name"].replace(".json", ""), 277 | data: response[i]["data"]["series"] 278 | }, true) 279 | } 280 | }); 281 | } 282 | 283 | function getStreamers() { 284 | $.getJSON('streamers', function (response) { 285 | streamersList = response; 286 | sortStreamers(); 287 | 288 | // Restore the selected streamer from localStorage on page load 289 | var selectedStreamer = localStorage.getItem("selectedStreamer"); 290 | 291 | if (selectedStreamer) { 292 | currentStreamer = selectedStreamer; 293 | } else { 294 | // If no selected streamer is found, default to the first streamer in the list 295 | currentStreamer = streamersList.length > 0 ? streamersList[0].name : null; 296 | } 297 | 298 | // Ensure the selected streamer is still active and scrolled into view 299 | renderStreamers(); 300 | }); 301 | } 302 | 303 | function renderStreamers() { 304 | $("#streamers-list").empty(); 305 | var promised = new Promise((resolve, reject) => { 306 | streamersList.forEach((streamer, index, array) => { 307 | displayname = streamer.name.replace(".json", ""); 308 | if (sortField == 'points') displayname = "" + streamer['points'] + " " + displayname; 309 | else if (sortField == 'last_activity') displayname = "" + formatDate(streamer['last_activity']) + " " + displayname; 310 | var isActive = currentStreamer === streamer.name; 311 | if (!isActive && localStorage.getItem("selectedStreamer") === null && index === 0) { 312 | isActive = true; 313 | currentStreamer = streamer.name; 314 | } 315 | var activeClass = isActive ? 'is-active' : ''; 316 | var listItem = `
  • ${displayname}
  • `; 317 | $("#streamers-list").append(listItem); 318 | if (isActive) { 319 | // Scroll the selected streamer into view 320 | document.getElementById(`streamer-${streamer.name}`).scrollIntoView({ 321 | behavior: 'smooth', 322 | block: 'center' 323 | }); 324 | } 325 | if (index === array.length - 1) resolve(); 326 | }); 327 | }); 328 | promised.then(() => { 329 | changeStreamer(currentStreamer, streamersList.findIndex(streamer => streamer.name === currentStreamer) + 1); 330 | }); 331 | } 332 | 333 | function sortStreamers() { 334 | streamersList = streamersList.sort((a, b) => { 335 | return (a[sortField] > b[sortField] ? 1 : -1) * (sortBy.includes("ascending") ? 1 : -1); 336 | }); 337 | } 338 | 339 | function changeSortBy(option) { 340 | sortBy = option.innerText.trim(); 341 | if (sortBy.includes("Points")) sortField = 'points' 342 | else if (sortBy.includes("Last activity")) sortField = 'last_activity' 343 | else sortField = 'name'; 344 | sortStreamers(); 345 | renderStreamers(); 346 | $('#sorting-by').text(sortBy); 347 | localStorage.setItem("sort-by", sortBy); 348 | } 349 | 350 | function updateAnnotations() { 351 | if ($('#annotations').prop("checked") === true) { 352 | clearAnnotations() 353 | if (annotations && annotations.length > 0) 354 | annotations.forEach((annotation, index) => { 355 | annotations[index]['id'] = `id-${index}` 356 | chart.addXaxisAnnotation(annotation, true) 357 | }) 358 | } else clearAnnotations() 359 | } 360 | 361 | function clearAnnotations() { 362 | if (annotations && annotations.length > 0) 363 | annotations.forEach((annotation, index) => { 364 | chart.removeAnnotation(annotation['id']) 365 | }) 366 | chart.clearAnnotations(); 367 | } 368 | 369 | // Toggle 370 | $('#annotations').click(() => { 371 | updateAnnotations(); 372 | }); 373 | $('#dark-mode').click(() => { 374 | toggleDarkMode(); 375 | }); 376 | 377 | $('.dropdown').click(() => { 378 | $('.dropdown').toggleClass('is-active'); 379 | }); 380 | 381 | // Input date 382 | $('#startDate').change(() => { 383 | startDate = new Date($('#startDate').val()); 384 | getStreamerData(currentStreamer); 385 | }); 386 | $('#endDate').change(() => { 387 | endDate = new Date($('#endDate').val()); 388 | getStreamerData(currentStreamer); 389 | }); 390 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/entities/Bet.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from enum import Enum, auto 3 | from random import uniform 4 | 5 | from millify import millify 6 | 7 | #from TwitchChannelPointsMiner.utils import char_decision_as_index, float_round 8 | from TwitchChannelPointsMiner.utils import float_round 9 | 10 | 11 | class Strategy(Enum): 12 | MOST_VOTED = auto() 13 | HIGH_ODDS = auto() 14 | PERCENTAGE = auto() 15 | SMART_MONEY = auto() 16 | SMART = auto() 17 | NUMBER_1 = auto() 18 | NUMBER_2 = auto() 19 | NUMBER_3 = auto() 20 | NUMBER_4 = auto() 21 | NUMBER_5 = auto() 22 | NUMBER_6 = auto() 23 | NUMBER_7 = auto() 24 | NUMBER_8 = auto() 25 | 26 | def __str__(self): 27 | return self.name 28 | 29 | 30 | class Condition(Enum): 31 | GT = auto() 32 | LT = auto() 33 | GTE = auto() 34 | LTE = auto() 35 | 36 | def __str__(self): 37 | return self.name 38 | 39 | 40 | class OutcomeKeys(object): 41 | # Real key on Bet dict [''] 42 | PERCENTAGE_USERS = "percentage_users" 43 | ODDS_PERCENTAGE = "odds_percentage" 44 | ODDS = "odds" 45 | TOP_POINTS = "top_points" 46 | # Real key on Bet dict [''] - Sum() 47 | TOTAL_USERS = "total_users" 48 | TOTAL_POINTS = "total_points" 49 | # This key does not exist 50 | DECISION_USERS = "decision_users" 51 | DECISION_POINTS = "decision_points" 52 | 53 | 54 | class DelayMode(Enum): 55 | FROM_START = auto() 56 | FROM_END = auto() 57 | PERCENTAGE = auto() 58 | 59 | def __str__(self): 60 | return self.name 61 | 62 | 63 | class FilterCondition(object): 64 | __slots__ = [ 65 | "by", 66 | "where", 67 | "value", 68 | ] 69 | 70 | def __init__(self, by=None, where=None, value=None, decision=None): 71 | self.by = by 72 | self.where = where 73 | self.value = value 74 | 75 | def __repr__(self): 76 | return f"FilterCondition(by={self.by.upper()}, where={self.where}, value={self.value})" 77 | 78 | 79 | class BetSettings(object): 80 | __slots__ = [ 81 | "strategy", 82 | "percentage", 83 | "percentage_gap", 84 | "max_points", 85 | "minimum_points", 86 | "stealth_mode", 87 | "filter_condition", 88 | "delay", 89 | "delay_mode", 90 | ] 91 | 92 | def __init__( 93 | self, 94 | strategy: Strategy = None, 95 | percentage: int = None, 96 | percentage_gap: int = None, 97 | max_points: int = None, 98 | minimum_points: int = None, 99 | stealth_mode: bool = None, 100 | filter_condition: FilterCondition = None, 101 | delay: float = None, 102 | delay_mode: DelayMode = None, 103 | ): 104 | self.strategy = strategy 105 | self.percentage = percentage 106 | self.percentage_gap = percentage_gap 107 | self.max_points = max_points 108 | self.minimum_points = minimum_points 109 | self.stealth_mode = stealth_mode 110 | self.filter_condition = filter_condition 111 | self.delay = delay 112 | self.delay_mode = delay_mode 113 | 114 | def default(self): 115 | self.strategy = self.strategy if self.strategy is not None else Strategy.SMART 116 | self.percentage = self.percentage if self.percentage is not None else 5 117 | self.percentage_gap = ( 118 | self.percentage_gap if self.percentage_gap is not None else 20 119 | ) 120 | self.max_points = self.max_points if self.max_points is not None else 50000 121 | self.minimum_points = ( 122 | self.minimum_points if self.minimum_points is not None else 0 123 | ) 124 | self.stealth_mode = ( 125 | self.stealth_mode if self.stealth_mode is not None else False 126 | ) 127 | self.delay = self.delay if self.delay is not None else 6 128 | self.delay_mode = ( 129 | self.delay_mode if self.delay_mode is not None else DelayMode.FROM_END 130 | ) 131 | 132 | def __repr__(self): 133 | return f"BetSettings(strategy={self.strategy}, percentage={self.percentage}, percentage_gap={self.percentage_gap}, max_points={self.max_points}, minimum_points={self.minimum_points}, stealth_mode={self.stealth_mode})" 134 | 135 | 136 | class Bet(object): 137 | __slots__ = ["outcomes", "decision", "total_users", "total_points", "settings"] 138 | 139 | def __init__(self, outcomes: list, settings: BetSettings): 140 | self.outcomes = outcomes 141 | self.__clear_outcomes() 142 | self.decision: dict = {} 143 | self.total_users = 0 144 | self.total_points = 0 145 | self.settings = settings 146 | 147 | def update_outcomes(self, outcomes): 148 | for index in range(0, len(self.outcomes)): 149 | self.outcomes[index][OutcomeKeys.TOTAL_USERS] = int( 150 | outcomes[index][OutcomeKeys.TOTAL_USERS] 151 | ) 152 | self.outcomes[index][OutcomeKeys.TOTAL_POINTS] = int( 153 | outcomes[index][OutcomeKeys.TOTAL_POINTS] 154 | ) 155 | if outcomes[index]["top_predictors"] != []: 156 | # Sort by points placed by other users 157 | outcomes[index]["top_predictors"] = sorted( 158 | outcomes[index]["top_predictors"], 159 | key=lambda x: x["points"], 160 | reverse=True, 161 | ) 162 | # Get the first elements (most placed) 163 | top_points = outcomes[index]["top_predictors"][0]["points"] 164 | self.outcomes[index][OutcomeKeys.TOP_POINTS] = top_points 165 | 166 | # Inefficient, but otherwise outcomekeys are represented wrong 167 | self.total_points = 0 168 | self.total_users = 0 169 | for index in range(0, len(self.outcomes)): 170 | self.total_users += self.outcomes[index][OutcomeKeys.TOTAL_USERS] 171 | self.total_points += self.outcomes[index][OutcomeKeys.TOTAL_POINTS] 172 | 173 | if ( 174 | self.total_users > 0 175 | and self.total_points > 0 176 | ): 177 | for index in range(0, len(self.outcomes)): 178 | self.outcomes[index][OutcomeKeys.PERCENTAGE_USERS] = float_round( 179 | (100 * self.outcomes[index][OutcomeKeys.TOTAL_USERS]) / self.total_users 180 | ) 181 | self.outcomes[index][OutcomeKeys.ODDS] = float_round( 182 | #self.total_points / max(self.outcomes[index][OutcomeKeys.TOTAL_POINTS], 1) 183 | 0 184 | if self.outcomes[index][OutcomeKeys.TOTAL_POINTS] == 0 185 | else self.total_points / self.outcomes[index][OutcomeKeys.TOTAL_POINTS] 186 | ) 187 | self.outcomes[index][OutcomeKeys.ODDS_PERCENTAGE] = float_round( 188 | #100 / max(self.outcomes[index][OutcomeKeys.ODDS], 1) 189 | 0 190 | if self.outcomes[index][OutcomeKeys.ODDS] == 0 191 | else 100 / self.outcomes[index][OutcomeKeys.ODDS] 192 | ) 193 | 194 | self.__clear_outcomes() 195 | 196 | def __repr__(self): 197 | return f"Bet(total_users={millify(self.total_users)}, total_points={millify(self.total_points)}), decision={self.decision})\n\t\tOutcome A({self.get_outcome(0)})\n\t\tOutcome B({self.get_outcome(1)})" 198 | 199 | def get_decision(self, parsed=False): 200 | #decision = self.outcomes[0 if self.decision["choice"] == "A" else 1] 201 | decision = self.outcomes[self.decision["choice"]] 202 | return decision if parsed is False else Bet.__parse_outcome(decision) 203 | 204 | @staticmethod 205 | def __parse_outcome(outcome): 206 | return f"{outcome['title']} ({outcome['color']}), Points: {millify(outcome[OutcomeKeys.TOTAL_POINTS])}, Users: {millify(outcome[OutcomeKeys.TOTAL_USERS])} ({outcome[OutcomeKeys.PERCENTAGE_USERS]}%), Odds: {outcome[OutcomeKeys.ODDS]} ({outcome[OutcomeKeys.ODDS_PERCENTAGE]}%)" 207 | 208 | def get_outcome(self, index): 209 | return Bet.__parse_outcome(self.outcomes[index]) 210 | 211 | def __clear_outcomes(self): 212 | for index in range(0, len(self.outcomes)): 213 | keys = copy.deepcopy(list(self.outcomes[index].keys())) 214 | for key in keys: 215 | if key not in [ 216 | OutcomeKeys.TOTAL_USERS, 217 | OutcomeKeys.TOTAL_POINTS, 218 | OutcomeKeys.TOP_POINTS, 219 | OutcomeKeys.PERCENTAGE_USERS, 220 | OutcomeKeys.ODDS, 221 | OutcomeKeys.ODDS_PERCENTAGE, 222 | "title", 223 | "color", 224 | "id", 225 | ]: 226 | del self.outcomes[index][key] 227 | for key in [ 228 | OutcomeKeys.PERCENTAGE_USERS, 229 | OutcomeKeys.ODDS, 230 | OutcomeKeys.ODDS_PERCENTAGE, 231 | OutcomeKeys.TOP_POINTS, 232 | ]: 233 | if key not in self.outcomes[index]: 234 | self.outcomes[index][key] = 0 235 | 236 | '''def __return_choice(self, key) -> str: 237 | return "A" if self.outcomes[0][key] > self.outcomes[1][key] else "B"''' 238 | 239 | def __return_choice(self, key) -> int: 240 | largest=0 241 | for index in range(0, len(self.outcomes)): 242 | if self.outcomes[index][key] > self.outcomes[largest][key]: 243 | largest = index 244 | return largest 245 | 246 | def __return_number_choice(self, number) -> int: 247 | if (len(self.outcomes) > number): 248 | return number 249 | else: 250 | return 0 251 | 252 | def skip(self) -> bool: 253 | if self.settings.filter_condition is not None: 254 | # key == by , condition == where 255 | key = self.settings.filter_condition.by 256 | condition = self.settings.filter_condition.where 257 | value = self.settings.filter_condition.value 258 | 259 | fixed_key = ( 260 | key 261 | if key not in [OutcomeKeys.DECISION_USERS, OutcomeKeys.DECISION_POINTS] 262 | else key.replace("decision", "total") 263 | ) 264 | if key in [OutcomeKeys.TOTAL_USERS, OutcomeKeys.TOTAL_POINTS]: 265 | compared_value = ( 266 | self.outcomes[0][fixed_key] + self.outcomes[1][fixed_key] 267 | ) 268 | else: 269 | #outcome_index = char_decision_as_index(self.decision["choice"]) 270 | outcome_index = self.decision["choice"] 271 | compared_value = self.outcomes[outcome_index][fixed_key] 272 | 273 | # Check if condition is satisfied 274 | if condition == Condition.GT: 275 | if compared_value > value: 276 | return False, compared_value 277 | elif condition == Condition.LT: 278 | if compared_value < value: 279 | return False, compared_value 280 | elif condition == Condition.GTE: 281 | if compared_value >= value: 282 | return False, compared_value 283 | elif condition == Condition.LTE: 284 | if compared_value <= value: 285 | return False, compared_value 286 | return True, compared_value # Else skip the bet 287 | else: 288 | return False, 0 # Default don't skip the bet 289 | 290 | def calculate(self, balance: int) -> dict: 291 | self.decision = {"choice": None, "amount": 0, "id": None} 292 | if self.settings.strategy == Strategy.MOST_VOTED: 293 | self.decision["choice"] = self.__return_choice(OutcomeKeys.TOTAL_USERS) 294 | elif self.settings.strategy == Strategy.HIGH_ODDS: 295 | self.decision["choice"] = self.__return_choice(OutcomeKeys.ODDS) 296 | elif self.settings.strategy == Strategy.PERCENTAGE: 297 | self.decision["choice"] = self.__return_choice(OutcomeKeys.ODDS_PERCENTAGE) 298 | elif self.settings.strategy == Strategy.SMART_MONEY: 299 | self.decision["choice"] = self.__return_choice(OutcomeKeys.TOP_POINTS) 300 | elif self.settings.strategy == Strategy.NUMBER_1: 301 | self.decision["choice"] = self.__return_number_choice(0) 302 | elif self.settings.strategy == Strategy.NUMBER_2: 303 | self.decision["choice"] = self.__return_number_choice(1) 304 | elif self.settings.strategy == Strategy.NUMBER_3: 305 | self.decision["choice"] = self.__return_number_choice(2) 306 | elif self.settings.strategy == Strategy.NUMBER_4: 307 | self.decision["choice"] = self.__return_number_choice(3) 308 | elif self.settings.strategy == Strategy.NUMBER_5: 309 | self.decision["choice"] = self.__return_number_choice(4) 310 | elif self.settings.strategy == Strategy.NUMBER_6: 311 | self.decision["choice"] = self.__return_number_choice(5) 312 | elif self.settings.strategy == Strategy.NUMBER_7: 313 | self.decision["choice"] = self.__return_number_choice(6) 314 | elif self.settings.strategy == Strategy.NUMBER_8: 315 | self.decision["choice"] = self.__return_number_choice(7) 316 | elif self.settings.strategy == Strategy.SMART: 317 | difference = abs( 318 | self.outcomes[0][OutcomeKeys.PERCENTAGE_USERS] 319 | - self.outcomes[1][OutcomeKeys.PERCENTAGE_USERS] 320 | ) 321 | self.decision["choice"] = ( 322 | self.__return_choice(OutcomeKeys.ODDS) 323 | if difference < self.settings.percentage_gap 324 | else self.__return_choice(OutcomeKeys.TOTAL_USERS) 325 | ) 326 | 327 | if self.decision["choice"] is not None: 328 | #index = char_decision_as_index(self.decision["choice"]) 329 | index = self.decision["choice"] 330 | self.decision["id"] = self.outcomes[index]["id"] 331 | self.decision["amount"] = min( 332 | int(balance * (self.settings.percentage / 100)), 333 | self.settings.max_points, 334 | ) 335 | if ( 336 | self.settings.stealth_mode is True 337 | and self.decision["amount"] 338 | >= self.outcomes[index][OutcomeKeys.TOP_POINTS] 339 | ): 340 | reduce_amount = uniform(1, 5) 341 | self.decision["amount"] = ( 342 | self.outcomes[index][OutcomeKeys.TOP_POINTS] - reduce_amount 343 | ) 344 | self.decision["amount"] = int(self.decision["amount"]) 345 | return self.decision 346 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/classes/TwitchLogin.py: -------------------------------------------------------------------------------- 1 | # Based on https://github.com/derrod/twl.py 2 | # Original Copyright (c) 2020 Rodney 3 | # The MIT License (MIT) 4 | 5 | import copy 6 | # import getpass 7 | import logging 8 | import os 9 | import pickle 10 | 11 | # import webbrowser 12 | # import browser_cookie3 13 | 14 | import requests 15 | 16 | from TwitchChannelPointsMiner.classes.Exceptions import ( 17 | BadCredentialsException, 18 | WrongCookiesException, 19 | ) 20 | from TwitchChannelPointsMiner.constants import CLIENT_ID, GQLOperations, USER_AGENTS 21 | 22 | from datetime import datetime, timedelta, timezone 23 | from time import sleep 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | """def interceptor(request) -> str: 28 | if ( 29 | request.method == 'POST' 30 | and request.url == 'https://passport.twitch.tv/protected_login' 31 | ): 32 | import json 33 | body = request.body.decode('utf-8') 34 | data = json.loads(body) 35 | data['client_id'] = CLIENT_ID 36 | request.body = json.dumps(data).encode('utf-8') 37 | del request.headers['Content-Length'] 38 | request.headers['Content-Length'] = str(len(request.body))""" 39 | 40 | 41 | class TwitchLogin(object): 42 | __slots__ = [ 43 | "client_id", 44 | "device_id", 45 | "token", 46 | "login_check_result", 47 | "session", 48 | "session", 49 | "username", 50 | "password", 51 | "user_id", 52 | "email", 53 | "cookies", 54 | "shared_cookies" 55 | ] 56 | 57 | def __init__(self, client_id, device_id, username, user_agent, password=None): 58 | self.client_id = client_id 59 | self.device_id = device_id 60 | self.token = None 61 | self.login_check_result = False 62 | self.session = requests.session() 63 | self.session.headers.update( 64 | {"Client-ID": self.client_id, 65 | "X-Device-Id": self.device_id, "User-Agent": user_agent} 66 | ) 67 | self.username = username 68 | self.password = password 69 | self.user_id = None 70 | self.email = None 71 | 72 | self.cookies = [] 73 | self.shared_cookies = [] 74 | 75 | def login_flow(self): 76 | logger.info("You'll have to login to Twitch!") 77 | 78 | post_data = { 79 | "client_id": self.client_id, 80 | "scopes": ( 81 | "channel_read chat:read user_blocks_edit " 82 | "user_blocks_read user_follows_edit user_read" 83 | ) 84 | } 85 | # login-fix 86 | use_backup_flow = False 87 | # use_backup_flow = True 88 | while True: 89 | logger.info("Trying the TV login method..") 90 | 91 | login_response = self.send_oauth_request( 92 | "https://id.twitch.tv/oauth2/device", post_data) 93 | 94 | # { 95 | # "device_code": "40 chars [A-Za-z0-9]", 96 | # "expires_in": 1800, 97 | # "interval": 5, 98 | # "user_code": "8 chars [A-Z]", 99 | # "verification_uri": "https://www.twitch.tv/activate" 100 | # } 101 | 102 | if login_response.status_code != 200: 103 | logger.error("TV login response is not 200. Try again") 104 | break 105 | 106 | login_response_json = login_response.json() 107 | 108 | if "user_code" in login_response_json: 109 | user_code: str = login_response_json["user_code"] 110 | now = datetime.now(timezone.utc) 111 | device_code: str = login_response_json["device_code"] 112 | interval: int = login_response_json["interval"] 113 | expires_at = now + \ 114 | timedelta(seconds=login_response_json["expires_in"]) 115 | logger.info( 116 | "Open https://www.twitch.tv/activate" 117 | ) 118 | logger.info( 119 | f"and enter this code: {user_code}" 120 | ) 121 | logger.info( 122 | f"Hurry up! It will expire in {int(login_response_json['expires_in'] / 60)} minutes!" 123 | ) 124 | # twofa = input("2FA token: ") 125 | # webbrowser.open_new_tab("https://www.twitch.tv/activate") 126 | 127 | post_data = { 128 | "client_id": CLIENT_ID, 129 | "device_code": device_code, 130 | "grant_type": "urn:ietf:params:oauth:grant-type:device_code", 131 | } 132 | 133 | while True: 134 | # sleep first, not like the user is gonna enter the code *that* fast 135 | sleep(interval) 136 | login_response = self.send_oauth_request( 137 | "https://id.twitch.tv/oauth2/token", post_data) 138 | if now == expires_at: 139 | logger.error("Code expired. Try again") 140 | break 141 | # 200 means success, 400 means the user haven't entered the code yet 142 | if login_response.status_code != 200: 143 | continue 144 | # { 145 | # "access_token": "40 chars [A-Za-z0-9]", 146 | # "refresh_token": "40 chars [A-Za-z0-9]", 147 | # "scope": [...], 148 | # "token_type": "bearer" 149 | # } 150 | login_response_json = login_response.json() 151 | if "access_token" in login_response_json: 152 | self.set_token(login_response_json["access_token"]) 153 | return self.check_login() 154 | # except RequestInvalid: 155 | # the device_code has expired, request a new code 156 | # continue 157 | # invalidate_after is not None 158 | # account for the expiration landing during the request 159 | # and datetime.now(timezone.utc) >= (invalidate_after - session_timeout) 160 | # ): 161 | # raise RequestInvalid() 162 | else: 163 | if "error_code" in login_response: 164 | err_code = login_response["error_code"] 165 | 166 | logger.error(f"Unknown error: {login_response}") 167 | raise NotImplementedError( 168 | f"Unknown TwitchAPI error code: {err_code}" 169 | ) 170 | 171 | if use_backup_flow: 172 | break 173 | 174 | if use_backup_flow: 175 | # self.set_token(self.login_flow_backup(password)) 176 | self.set_token(self.login_flow_backup()) 177 | return self.check_login() 178 | 179 | return False 180 | 181 | def set_token(self, new_token): 182 | self.token = new_token 183 | self.session.headers.update({"Authorization": f"Bearer {self.token}"}) 184 | 185 | # def send_login_request(self, json_data): 186 | def send_oauth_request(self, url, json_data): 187 | # response = self.session.post("https://passport.twitch.tv/protected_login", json=json_data) 188 | """response = self.session.post("https://passport.twitch.tv/login", json=json_data, headers={ 189 | 'Accept': 'application/vnd.twitchtv.v3+json', 190 | 'Accept-Encoding': 'gzip', 191 | 'Accept-Language': 'en-US', 192 | 'Content-Type': 'application/json; charset=UTF-8', 193 | 'Host': 'passport.twitch.tv' 194 | },)""" 195 | response = self.session.post(url, data=json_data, headers={ 196 | 'Accept': 'application/json', 197 | 'Accept-Encoding': 'gzip', 198 | 'Accept-Language': 'en-US', 199 | "Cache-Control": "no-cache", 200 | "Client-Id": CLIENT_ID, 201 | "Host": "id.twitch.tv", 202 | "Origin": "https://android.tv.twitch.tv", 203 | "Pragma": "no-cache", 204 | "Referer": "https://android.tv.twitch.tv/", 205 | "User-Agent": USER_AGENTS["Android"]["TV"], 206 | "X-Device-Id": self.device_id 207 | },) 208 | return response 209 | 210 | def login_flow_backup(self, password=None): 211 | """Backup OAuth Selenium login 212 | from undetected_chromedriver import ChromeOptions 213 | import seleniumwire.undetected_chromedriver.v2 as uc 214 | from selenium.webdriver.common.by import By 215 | from time import sleep 216 | 217 | HEADLESS = False 218 | 219 | options = uc.ChromeOptions() 220 | if HEADLESS is True: 221 | options.add_argument('--headless') 222 | options.add_argument('--log-level=3') 223 | options.add_argument('--disable-web-security') 224 | options.add_argument('--allow-running-insecure-content') 225 | options.add_argument('--lang=en') 226 | options.add_argument('--no-sandbox') 227 | options.add_argument('--disable-gpu') 228 | # options.add_argument("--user-agent=\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36\"") 229 | # options.add_argument("--window-size=1920,1080") 230 | # options.set_capability("detach", True) 231 | 232 | logger.info( 233 | 'Now a browser window will open, it will login with your data.') 234 | driver = uc.Chrome( 235 | options=options, use_subprocess=True # , executable_path=EXECUTABLE_PATH 236 | ) 237 | driver.request_interceptor = interceptor 238 | driver.get('https://www.twitch.tv/login') 239 | 240 | driver.find_element(By.ID, 'login-username').send_keys(self.username) 241 | driver.find_element(By.ID, 'password-input').send_keys(password) 242 | sleep(0.3) 243 | driver.execute_script( 244 | 'document.querySelector("#root > div > div.scrollable-area > div.simplebar-scroll-content > div > div > div > div.Layout-sc-nxg1ff-0.gZaqky > form > div > div:nth-child(3) > button > div > div").click()' 245 | ) 246 | 247 | logger.info( 248 | 'Enter your verification code in the browser and wait for the Twitch website to load, then press Enter here.' 249 | ) 250 | input() 251 | 252 | logger.info("Extracting cookies...") 253 | self.cookies = driver.get_cookies() 254 | # print(self.cookies) 255 | # driver.close() 256 | driver.quit() 257 | self.username = self.get_cookie_value("login") 258 | # print(f"self.username: {self.username}") 259 | 260 | if not self.username: 261 | logger.error("Couldn't extract login, probably bad cookies.") 262 | return False 263 | 264 | return self.get_cookie_value("auth-token")""" 265 | 266 | # logger.error("Backup login flow is not available. Use a VPN or wait a while to avoid the CAPTCHA.") 267 | # return False 268 | 269 | """Backup OAuth login flow in case manual captcha solving is required""" 270 | browser = input( 271 | "What browser do you use? Chrome (1), Firefox (2), Other (3): " 272 | ).strip() 273 | if browser not in ("1", "2"): 274 | logger.info("Your browser is unsupported, sorry.") 275 | return None 276 | 277 | input( 278 | "Please login inside your browser of choice (NOT incognito mode) and press Enter..." 279 | ) 280 | logger.info("Loading cookies saved on your computer...") 281 | twitch_domain = ".twitch.tv" 282 | if browser == "1": # chrome 283 | cookie_jar = browser_cookie3.chrome(domain_name=twitch_domain) 284 | else: 285 | cookie_jar = browser_cookie3.firefox(domain_name=twitch_domain) 286 | # logger.info(f"cookie_jar: {cookie_jar}") 287 | cookies_dict = requests.utils.dict_from_cookiejar(cookie_jar) 288 | # logger.info(f"cookies_dict: {cookies_dict}") 289 | self.username = cookies_dict.get("login") 290 | self.shared_cookies = cookies_dict 291 | return cookies_dict.get("auth-token") 292 | 293 | def check_login(self): 294 | if self.login_check_result: 295 | return self.login_check_result 296 | if self.token is None: 297 | return False 298 | 299 | self.login_check_result = self.__set_user_id() 300 | return self.login_check_result 301 | 302 | def save_cookies(self, cookies_file): 303 | logger.info("Saving cookies to your computer..") 304 | cookies_dict = self.session.cookies.get_dict() 305 | # print(f"cookies_dict2pickle: {cookies_dict}") 306 | cookies_dict["auth-token"] = self.token 307 | if "persistent" not in cookies_dict: # saving user id cookies 308 | cookies_dict["persistent"] = self.user_id 309 | 310 | # old way saves only 'auth-token' and 'persistent' 311 | self.cookies = [] 312 | # cookies_dict = self.shared_cookies 313 | # print(f"cookies_dict2pickle: {cookies_dict}") 314 | for cookie_name, value in cookies_dict.items(): 315 | self.cookies.append({"name": cookie_name, "value": value}) 316 | # print(f"cookies2pickle: {self.cookies}") 317 | pickle.dump(self.cookies, open(cookies_file, "wb")) 318 | 319 | def get_cookie_value(self, key): 320 | for cookie in self.cookies: 321 | if cookie["name"] == key: 322 | if cookie["value"] is not None: 323 | return cookie["value"] 324 | return None 325 | 326 | def load_cookies(self, cookies_file): 327 | if os.path.isfile(cookies_file): 328 | self.cookies = pickle.load(open(cookies_file, "rb")) 329 | else: 330 | raise WrongCookiesException("There must be a cookies file!") 331 | 332 | def get_user_id(self): 333 | persistent = self.get_cookie_value("persistent") 334 | user_id = ( 335 | int(persistent.split("%")[ 336 | 0]) if persistent is not None else self.user_id 337 | ) 338 | if user_id is None: 339 | if self.__set_user_id() is True: 340 | return self.user_id 341 | return user_id 342 | 343 | def __set_user_id(self): 344 | json_data = copy.deepcopy(GQLOperations.ReportMenuItem) 345 | json_data["variables"] = {"channelLogin": self.username} 346 | response = self.session.post(GQLOperations.url, json=json_data) 347 | 348 | if response.status_code == 200: 349 | json_response = response.json() 350 | if ( 351 | "data" in json_response 352 | and "user" in json_response["data"] 353 | and json_response["data"]["user"]["id"] is not None 354 | ): 355 | self.user_id = json_response["data"]["user"]["id"] 356 | return True 357 | return False 358 | 359 | def get_auth_token(self): 360 | return self.get_cookie_value("auth-token") 361 | -------------------------------------------------------------------------------- /TwitchChannelPointsMiner/TwitchChannelPointsMiner.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import os 5 | import random 6 | import signal 7 | import sys 8 | import threading 9 | import time 10 | import uuid 11 | from datetime import datetime 12 | from pathlib import Path 13 | 14 | from TwitchChannelPointsMiner.classes.Chat import ChatPresence, ThreadChat 15 | from TwitchChannelPointsMiner.classes.entities.PubsubTopic import PubsubTopic 16 | from TwitchChannelPointsMiner.classes.entities.Streamer import ( 17 | Streamer, 18 | StreamerSettings, 19 | ) 20 | from TwitchChannelPointsMiner.classes.Exceptions import StreamerDoesNotExistException 21 | from TwitchChannelPointsMiner.classes.Settings import FollowersOrder, Priority, Settings 22 | from TwitchChannelPointsMiner.classes.Twitch import Twitch 23 | from TwitchChannelPointsMiner.classes.WebSocketsPool import WebSocketsPool 24 | from TwitchChannelPointsMiner.logger import LoggerSettings, configure_loggers 25 | from TwitchChannelPointsMiner.utils import ( 26 | _millify, 27 | at_least_one_value_in_settings_is, 28 | check_versions, 29 | get_user_agent, 30 | internet_connection_available, 31 | set_default_settings, 32 | ) 33 | 34 | # Suppress: 35 | # - chardet.charsetprober - [feed] 36 | # - chardet.charsetprober - [get_confidence] 37 | # - requests - [Starting new HTTPS connection (1)] 38 | # - Flask (werkzeug) logs 39 | # - irc.client - [process_data] 40 | # - irc.client - [_dispatcher] 41 | # - irc.client - [_handle_message] 42 | logging.getLogger("chardet.charsetprober").setLevel(logging.ERROR) 43 | logging.getLogger("requests").setLevel(logging.ERROR) 44 | logging.getLogger("werkzeug").setLevel(logging.ERROR) 45 | logging.getLogger("irc.client").setLevel(logging.ERROR) 46 | logging.getLogger("seleniumwire").setLevel(logging.ERROR) 47 | logging.getLogger("websocket").setLevel(logging.ERROR) 48 | 49 | logger = logging.getLogger(__name__) 50 | 51 | 52 | class TwitchChannelPointsMiner: 53 | __slots__ = [ 54 | "username", 55 | "twitch", 56 | "claim_drops_startup", 57 | "enable_analytics", 58 | "disable_ssl_cert_verification", 59 | "disable_at_in_nickname", 60 | "priority", 61 | "streamers", 62 | "events_predictions", 63 | "minute_watcher_thread", 64 | "sync_campaigns_thread", 65 | "ws_pool", 66 | "session_id", 67 | "running", 68 | "start_datetime", 69 | "original_streamers", 70 | "logs_file", 71 | "queue_listener", 72 | ] 73 | 74 | def __init__( 75 | self, 76 | username: str, 77 | password: str = None, 78 | claim_drops_startup: bool = False, 79 | enable_analytics: bool = False, 80 | disable_ssl_cert_verification: bool = False, 81 | disable_at_in_nickname: bool = False, 82 | # Settings for logging and selenium as you can see. 83 | priority: list = [Priority.STREAK, Priority.DROPS, Priority.ORDER], 84 | # This settings will be global shared trought Settings class 85 | logger_settings: LoggerSettings = LoggerSettings(), 86 | # Default values for all streamers 87 | streamer_settings: StreamerSettings = StreamerSettings(), 88 | ): 89 | # Fixes TypeError: 'NoneType' object is not subscriptable 90 | if not username or username == "your-twitch-username": 91 | logger.error("Please edit your runner file (usually run.py) and try again.") 92 | logger.error("No username, exiting...") 93 | sys.exit(0) 94 | 95 | # This disables certificate verification and allows the connection to proceed, but also makes it vulnerable to man-in-the-middle (MITM) attacks. 96 | Settings.disable_ssl_cert_verification = disable_ssl_cert_verification 97 | 98 | Settings.disable_at_in_nickname = disable_at_in_nickname 99 | 100 | import socket 101 | 102 | def is_connected(): 103 | try: 104 | # resolve the IP address of the Twitch.tv domain name 105 | socket.gethostbyname("twitch.tv") 106 | return True 107 | except OSError: 108 | pass 109 | return False 110 | 111 | # check for Twitch.tv connectivity every 5 seconds 112 | error_printed = False 113 | while not is_connected(): 114 | if not error_printed: 115 | logger.error("Waiting for Twitch.tv connectivity...") 116 | error_printed = True 117 | time.sleep(5) 118 | 119 | # Analytics switch 120 | Settings.enable_analytics = enable_analytics 121 | 122 | if enable_analytics is True: 123 | Settings.analytics_path = os.path.join( 124 | Path().absolute(), "analytics", username 125 | ) 126 | Path(Settings.analytics_path).mkdir(parents=True, exist_ok=True) 127 | 128 | self.username = username 129 | 130 | # Set as global config 131 | Settings.logger = logger_settings 132 | 133 | # Init as default all the missing values 134 | streamer_settings.default() 135 | streamer_settings.bet.default() 136 | Settings.streamer_settings = streamer_settings 137 | 138 | # user_agent = get_user_agent("FIREFOX") 139 | user_agent = get_user_agent("CHROME") 140 | self.twitch = Twitch(self.username, user_agent, password) 141 | 142 | self.claim_drops_startup = claim_drops_startup 143 | self.priority = priority if isinstance(priority, list) else [priority] 144 | 145 | self.streamers: list[Streamer] = [] 146 | self.events_predictions = {} 147 | self.minute_watcher_thread = None 148 | self.sync_campaigns_thread = None 149 | self.ws_pool = None 150 | 151 | self.session_id = str(uuid.uuid4()) 152 | self.running = False 153 | self.start_datetime = None 154 | self.original_streamers = [] 155 | 156 | self.logs_file, self.queue_listener = configure_loggers( 157 | self.username, logger_settings 158 | ) 159 | 160 | # Check for the latest version of the script 161 | current_version, github_version = check_versions() 162 | 163 | logger.info( 164 | f"Twitch Channel Points Miner v2-{current_version} (fork by rdavydov)" 165 | ) 166 | logger.info("https://github.com/rdavydov/Twitch-Channel-Points-Miner-v2") 167 | 168 | if github_version == "0.0.0": 169 | logger.error( 170 | "Unable to detect if you have the latest version of this script" 171 | ) 172 | elif current_version != github_version: 173 | logger.info(f"You are running version {current_version} of this script") 174 | logger.info(f"The latest version on GitHub is {github_version}") 175 | 176 | for sign in [signal.SIGINT, signal.SIGSEGV, signal.SIGTERM]: 177 | signal.signal(sign, self.end) 178 | 179 | def analytics( 180 | self, 181 | host: str = "127.0.0.1", 182 | port: int = 5000, 183 | refresh: int = 5, 184 | days_ago: int = 7, 185 | ): 186 | # Analytics switch 187 | if Settings.enable_analytics is True: 188 | from TwitchChannelPointsMiner.classes.AnalyticsServer import AnalyticsServer 189 | 190 | days_ago = days_ago if days_ago <= 365 * 15 else 365 * 15 191 | http_server = AnalyticsServer( 192 | host=host, 193 | port=port, 194 | refresh=refresh, 195 | days_ago=days_ago, 196 | username=self.username, 197 | ) 198 | http_server.daemon = True 199 | http_server.name = "Analytics Thread" 200 | http_server.start() 201 | else: 202 | logger.error("Can't start analytics(), please set enable_analytics=True") 203 | 204 | def mine( 205 | self, 206 | streamers: list = [], 207 | blacklist: list = [], 208 | followers: bool = False, 209 | followers_order: FollowersOrder = FollowersOrder.ASC, 210 | ): 211 | self.run(streamers=streamers, blacklist=blacklist, followers=followers) 212 | 213 | def run( 214 | self, 215 | streamers: list = [], 216 | blacklist: list = [], 217 | followers: bool = False, 218 | followers_order: FollowersOrder = FollowersOrder.ASC, 219 | ): 220 | if self.running: 221 | logger.error("You can't start multiple sessions of this instance!") 222 | else: 223 | logger.info( 224 | f"Start session: '{self.session_id}'", extra={"emoji": ":bomb:"} 225 | ) 226 | self.running = True 227 | self.start_datetime = datetime.now() 228 | 229 | self.twitch.login() 230 | 231 | if self.claim_drops_startup is True: 232 | self.twitch.claim_all_drops_from_inventory() 233 | 234 | streamers_name: list = [] 235 | streamers_dict: dict = {} 236 | 237 | for streamer in streamers: 238 | username = ( 239 | streamer.username 240 | if isinstance(streamer, Streamer) 241 | else streamer.lower().strip() 242 | ) 243 | if username not in blacklist: 244 | streamers_name.append(username) 245 | streamers_dict[username] = streamer 246 | 247 | if followers is True: 248 | followers_array = self.twitch.get_followers(order=followers_order) 249 | logger.info( 250 | f"Load {len(followers_array)} followers from your profile!", 251 | extra={"emoji": ":clipboard:"}, 252 | ) 253 | for username in followers_array: 254 | if username not in streamers_dict and username not in blacklist: 255 | streamers_name.append(username) 256 | streamers_dict[username] = username.lower().strip() 257 | 258 | logger.info( 259 | f"Loading data for {len(streamers_name)} streamers. Please wait...", 260 | extra={"emoji": ":nerd_face:"}, 261 | ) 262 | for username in streamers_name: 263 | if username in streamers_name: 264 | time.sleep(random.uniform(0.3, 0.7)) 265 | try: 266 | streamer = ( 267 | streamers_dict[username] 268 | if isinstance(streamers_dict[username], Streamer) is True 269 | else Streamer(username) 270 | ) 271 | streamer.channel_id = self.twitch.get_channel_id(username) 272 | streamer.settings = set_default_settings( 273 | streamer.settings, Settings.streamer_settings 274 | ) 275 | streamer.settings.bet = set_default_settings( 276 | streamer.settings.bet, Settings.streamer_settings.bet 277 | ) 278 | if streamer.settings.chat != ChatPresence.NEVER: 279 | streamer.irc_chat = ThreadChat( 280 | self.username, 281 | self.twitch.twitch_login.get_auth_token(), 282 | streamer.username, 283 | ) 284 | self.streamers.append(streamer) 285 | except StreamerDoesNotExistException: 286 | logger.info( 287 | f"Streamer {username} does not exist", 288 | extra={"emoji": ":cry:"}, 289 | ) 290 | 291 | # Populate the streamers with default values. 292 | # 1. Load channel points and auto-claim bonus 293 | # 2. Check if streamers are online 294 | # 3. DEACTIVATED: Check if the user is a moderator. (was used before the 5th of April 2021 to deactivate predictions) 295 | for streamer in self.streamers: 296 | time.sleep(random.uniform(0.3, 0.7)) 297 | self.twitch.load_channel_points_context(streamer) 298 | self.twitch.check_streamer_online(streamer) 299 | # self.twitch.viewer_is_mod(streamer) 300 | 301 | self.original_streamers = [ 302 | streamer.channel_points for streamer in self.streamers 303 | ] 304 | 305 | # If we have at least one streamer with settings = make_predictions True 306 | make_predictions = at_least_one_value_in_settings_is( 307 | self.streamers, "make_predictions", True 308 | ) 309 | 310 | # If we have at least one streamer with settings = claim_drops True 311 | # Spawn a thread for sync inventory and dashboard 312 | if ( 313 | at_least_one_value_in_settings_is(self.streamers, "claim_drops", True) 314 | is True 315 | ): 316 | self.sync_campaigns_thread = threading.Thread( 317 | target=self.twitch.sync_campaigns, 318 | args=(self.streamers,), 319 | ) 320 | self.sync_campaigns_thread.name = "Sync campaigns/inventory" 321 | self.sync_campaigns_thread.start() 322 | time.sleep(30) 323 | 324 | self.minute_watcher_thread = threading.Thread( 325 | target=self.twitch.send_minute_watched_events, 326 | args=(self.streamers, self.priority), 327 | ) 328 | self.minute_watcher_thread.name = "Minute watcher" 329 | self.minute_watcher_thread.start() 330 | 331 | self.ws_pool = WebSocketsPool( 332 | twitch=self.twitch, 333 | streamers=self.streamers, 334 | events_predictions=self.events_predictions, 335 | ) 336 | 337 | # Subscribe to community-points-user. Get update for points spent or gains 338 | user_id = self.twitch.twitch_login.get_user_id() 339 | # print(f"!!!!!!!!!!!!!! USER_ID: {user_id}") 340 | 341 | # Fixes 'ERR_BADAUTH' 342 | if not user_id: 343 | logger.error("No user_id, exiting...") 344 | self.end(0, 0) 345 | 346 | self.ws_pool.submit( 347 | PubsubTopic( 348 | "community-points-user-v1", 349 | user_id=user_id, 350 | ) 351 | ) 352 | 353 | # Going to subscribe to predictions-user-v1. Get update when we place a new prediction (confirm) 354 | if make_predictions is True: 355 | self.ws_pool.submit( 356 | PubsubTopic( 357 | "predictions-user-v1", 358 | user_id=user_id, 359 | ) 360 | ) 361 | 362 | for streamer in self.streamers: 363 | self.ws_pool.submit( 364 | PubsubTopic("video-playback-by-id", streamer=streamer) 365 | ) 366 | 367 | if streamer.settings.follow_raid is True: 368 | self.ws_pool.submit(PubsubTopic("raid", streamer=streamer)) 369 | 370 | if streamer.settings.make_predictions is True: 371 | self.ws_pool.submit( 372 | PubsubTopic("predictions-channel-v1", streamer=streamer) 373 | ) 374 | 375 | if streamer.settings.claim_moments is True: 376 | self.ws_pool.submit( 377 | PubsubTopic("community-moments-channel-v1", streamer=streamer) 378 | ) 379 | 380 | if streamer.settings.community_goals is True: 381 | self.ws_pool.submit( 382 | PubsubTopic("community-points-channel-v1", streamer=streamer) 383 | ) 384 | 385 | refresh_context = time.time() 386 | while self.running: 387 | time.sleep(random.uniform(20, 60)) 388 | # Do an external control for WebSocket. Check if the thread is running 389 | # Check if is not None because maybe we have already created a new connection on array+1 and now index is None 390 | for index in range(0, len(self.ws_pool.ws)): 391 | if ( 392 | self.ws_pool.ws[index].is_reconnecting is False 393 | and self.ws_pool.ws[index].elapsed_last_ping() > 10 394 | and internet_connection_available() is True 395 | ): 396 | logger.info( 397 | f"#{index} - The last PING was sent more than 10 minutes ago. Reconnecting to the WebSocket..." 398 | ) 399 | WebSocketsPool.handle_reconnection(self.ws_pool.ws[index]) 400 | 401 | if ((time.time() - refresh_context) // 60) >= 30: 402 | refresh_context = time.time() 403 | for index in range(0, len(self.streamers)): 404 | if self.streamers[index].is_online: 405 | self.twitch.load_channel_points_context( 406 | self.streamers[index] 407 | ) 408 | 409 | def end(self, signum, frame): 410 | if not self.running: 411 | return 412 | 413 | logger.info("CTRL+C Detected! Please wait just a moment!") 414 | 415 | for streamer in self.streamers: 416 | if ( 417 | streamer.irc_chat is not None 418 | and streamer.settings.chat != ChatPresence.NEVER 419 | ): 420 | streamer.leave_chat() 421 | if streamer.irc_chat.is_alive() is True: 422 | streamer.irc_chat.join() 423 | 424 | self.running = self.twitch.running = False 425 | if self.ws_pool is not None: 426 | self.ws_pool.end() 427 | 428 | if self.minute_watcher_thread is not None: 429 | self.minute_watcher_thread.join() 430 | 431 | if self.sync_campaigns_thread is not None: 432 | self.sync_campaigns_thread.join() 433 | 434 | # Check if all the mutex are unlocked. 435 | # Prevent breaks of .json file 436 | for streamer in self.streamers: 437 | if streamer.mutex.locked(): 438 | streamer.mutex.acquire() 439 | streamer.mutex.release() 440 | 441 | self.__print_report() 442 | 443 | # Stop the queue listener to make sure all messages have been logged 444 | self.queue_listener.stop() 445 | 446 | sys.exit(0) 447 | 448 | def __print_report(self): 449 | print("\n") 450 | logger.info( 451 | f"Ending session: '{self.session_id}'", extra={"emoji": ":stop_sign:"} 452 | ) 453 | if self.logs_file is not None: 454 | logger.info( 455 | f"Logs file: {self.logs_file}", extra={"emoji": ":page_facing_up:"} 456 | ) 457 | logger.info( 458 | f"Duration {datetime.now() - self.start_datetime}", 459 | extra={"emoji": ":hourglass:"}, 460 | ) 461 | 462 | if not Settings.logger.less and self.events_predictions != {}: 463 | print("") 464 | for event_id in self.events_predictions: 465 | event = self.events_predictions[event_id] 466 | if ( 467 | event.bet_confirmed is True 468 | and event.streamer.settings.make_predictions is True 469 | ): 470 | logger.info( 471 | f"{event.streamer.settings.bet}", 472 | extra={"emoji": ":wrench:"}, 473 | ) 474 | if event.streamer.settings.bet.filter_condition is not None: 475 | logger.info( 476 | f"{event.streamer.settings.bet.filter_condition}", 477 | extra={"emoji": ":pushpin:"}, 478 | ) 479 | logger.info( 480 | f"{event.print_recap()}", 481 | extra={"emoji": ":bar_chart:"}, 482 | ) 483 | 484 | print("") 485 | for streamer_index in range(0, len(self.streamers)): 486 | if self.streamers[streamer_index].history != {}: 487 | gained = ( 488 | self.streamers[streamer_index].channel_points 489 | - self.original_streamers[streamer_index] 490 | ) 491 | 492 | from colorama import Fore 493 | streamer_highlight = Fore.YELLOW 494 | 495 | streamer_gain = ( 496 | f"{streamer_highlight}{self.streamers[streamer_index]}{Fore.RESET}, Total Points Gained: {_millify(gained)}" 497 | if Settings.logger.less 498 | else f"{streamer_highlight}{repr(self.streamers[streamer_index])}{Fore.RESET}, Total Points Gained (after farming - before farming): {_millify(gained)}" 499 | ) 500 | 501 | indent = ' ' * 25 502 | streamer_history = '\n'.join(f"{indent}{history}" for history in self.streamers[streamer_index].print_history().split('; ')) 503 | 504 | logger.info( 505 | f"{streamer_gain}\n{streamer_history}", 506 | extra={"emoji": ":moneybag:"}, 507 | ) --------------------------------------------------------------------------------