Repository: MrPowerScripts/reddit-karma-farming-bot Branch: master Commit: 4dd8373063de Files: 44 Total size: 100.7 KB Directory structure: gitextract_wcx03rw_/ ├── .circleci/ │ └── config.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Pipfile ├── README.md ├── deps/ │ └── windows/ │ ├── PyStemmer-2.0.1-cp39-cp39-win_amd64.whl │ └── windows.ps1 ├── docs/ │ ├── 1-getting-started.md │ ├── 2-linux-macos.md │ ├── 3-windows.md │ └── 4-docker-guide.md ├── run_linux.sh ├── run_windows.bat └── src/ ├── __init__.py ├── apis/ │ ├── __init__.py │ ├── pushshift.py │ └── reddit.py ├── bot.py ├── bots/ │ └── reddit/ │ ├── __init__.py │ ├── actions/ │ │ ├── cleanup_actions.py │ │ ├── comments/ │ │ │ ├── comment_actions.py │ │ │ └── sources/ │ │ │ └── cobe.py │ │ ├── post_actions.py │ │ └── utils.py │ ├── bot.py │ └── utils.py ├── config/ │ ├── cobe_config.py │ ├── common_config.py │ ├── config_menu.py │ ├── reddit/ │ │ ├── config_gen.py │ │ ├── reddit_avoid_subs.txt │ │ ├── reddit_avoid_words.txt │ │ └── reddit_sub_lists.py │ ├── reddit_config.py │ └── test.yml ├── init.py ├── libs/ │ └── urwide.py ├── logs/ │ ├── log_utils.py │ └── logger.py ├── menu.py ├── tests/ │ ├── __init__.py │ └── test_utils.py └── utils.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .circleci/config.yml ================================================ version: 2.1 orbs: python: circleci/python@1.0.0 jobs: build: executor: name: python/default tag: "3.9" steps: - checkout - python/install-packages: args: pytest pkg-manager: pipenv # NEED TO FIX THE STUPID TESTS # - run: pipenv run coverage run --source=./src -m pytest --junitxml=./junit/junit.xml # - run: pipenv run coverage report # - run: pipenv run coverage html - store_artifacts: path: ./htmlcov destination: htmlcov - store_test_results: path: ./junit workflows: main: jobs: - build ================================================ FILE: .gitignore ================================================ *.pyc *.db* *.log* *.info venv settings.py .DS_Store macos.sh .vscode src/db.json node_modules .pytest_cache .venv .env __pycache__ .coverage htmlcov junit brainss config.json .envv ================================================ FILE: Dockerfile ================================================ FROM python:3.8 COPY . /app WORKDIR /app RUN apt update && apt install -yqq g++ gcc libc6-dev make pkg-config libffi-dev python3-dev git RUN pip3 install pipenv RUN pipenv install --system --deploy --ignore-pipfile RUN chmod +x /app/run_linux.sh ENTRYPOINT /app/run_linux.sh ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 MrPowerScripts Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Pipfile ================================================ [[source]] name = "pypi" url = "https://pypi.org/simple" verify_ssl = true [dev-packages] [packages] psaw = "*" praw = "*" pytest = "*" coverage = "*" cobe = "*" urwide = "*" pyfiglet = "*" urwid = "*" requests = "*" ================================================ FILE: README.md ================================================ **This project is no longer in active development. I'll consider merging pull requests as time is available, but there will not be further significant updates to it.** And one final message to cap off the project: This is a fun bot that may help you learn about Python. There are plenty of things that need to be fixed, and I hope the logging is good enough to help guide you in solving any issues that still exist. The purpose of this project was to demonstrate and bring awareness to the widespread practice of botting on social media sites. Reddit in this case, but it happens on all other social media platforms too. Bots are used for the purposes of [astrofurfing](https://www.merriam-webster.com/dictionary/astroturfing), and many other practices to program your beliefs by controlling the things you encounter while surfing the internet. It's not unique to social media. It happens to anything that people view as an authentic source of truth. TV before the internet, and [newspapers before that](https://en.wikipedia.org/wiki/Propaganda_of_the_Spanish%E2%80%93American_War#:~:text=Pulitzer%20owned%20the%20New%20York,into%20the%20Spanish%E2%80%93American%20War.). People [build entire businesses](https://www.inc.com/alyssa-satara/if-you-dont-fully-understand-cambridge-analytica-scandal-read-this-simplified-version.html) with the intent to persuade you, or cause you to feel a certain way about subjects. Usually, not for serving your own best interests or the interests of others around you. If you're reading this you probably don't have the money or power to use those services. So, that puts you at a pretty big disadvantage of getting your voice heard, doesn't it? Even if a thousand people are screaming it's not hard to drown those voices out with tens of thousands of bots. It [happens every day](https://www.wired.com/story/bots-broke-fcc-public-comment-system/). Hopefully, with more awareness around these practices it will provoke people to think more critically about where they source truths from. With greater trust and shared understanding it can to more equitable outcomes for people regardless of who they are or where they're from. Well, at least for people [outside of the club](https://www.youtube.com/watch?v=Nyvxt1svxso) who can't afford bot farms. If you aren't questioning where you get your truths from, today is a great day to start! You can avoid being the pawn of election fraud scehems, or causing [far more serious damage](https://www.reddit.com/r/4chan/comments/2gup17/4chan_does_it_again_microwave_chargin_with_ios_8/). Although microwaving your phone may be a solution in this case. # Reddit Karma Farming Bot ## Videos and links This bot is probably the reason you saw that post again on Reddit. Need help with the bot? Join us on Discord https://bit.ly/mrps-discord ![farm karma 1](https://user-images.githubusercontent.com/1307942/86540032-7e1a2c00-bef9-11ea-9266-16830c5b9dfa.png) ![farm karma bot](https://user-images.githubusercontent.com/1307942/86153469-a40a8f80-baf9-11ea-80b5-d86dd31108d6.png) ### Video install guides [Windows](https://youtu.be/6ICjZUHO2_I) [Linux/macOS](https://youtu.be/ga0OC6lYSRs) ### 2020 update videos [Definitely Watch This One](https://www.youtube.com/watch?v=nWYRGXesb3I) [2020 Bot 3.0 Code Walkthrough](https://www.youtube.com/watch?v=83zWIz3b7o0) ### Older videos [Karma Farming Bot 2.0 Video](https://www.youtube.com/watch?v=CCMGHepPBso) [Karma Farming on Reddit Video](https://www.youtube.com/watch?v=8DrOERA5FGc) [Karma Farming Bot 1.0 Video](https://www.youtube.com/watch?v=KgWsqKkDEtI) Subscribe: http://bit.ly/mrps-yt-sub Website: https://bit.ly/mrps-site ## Getting Started 1. Follow the [getting started guide](docs/1-getting-started.md) to create your Reddit app and learn how to configure the bot. 2. Then follow the [macOS/Linux](docs/2-linux-macos.md), or [Windows](docs/3-windows.md) or [docker](docs/4-docker-guide.md) guides to start the bot after everything is set up. ## Features - Run on Linux, MacOS, or Windows. - Automatically reposts popular posts from the past to earn post karma. - Automatically generates unique (somewhat) contextually relevant comments using [cobe](https://github.com/pteichman/cobe). - Automatically deletes poor performing comments and posts. - Configurable frequency of posting, commenting, and other actions. - Filter the bot from learning certain words, or avoid certain subreddits. - Schedule when the bot wakes up and sleeps to run actions. - Auto detects if the account is shadowbanned. ## Warnings ### Reddit New Reddit accounts will likely get banned with the bot. Let an account sit for a few days before using it. Do not use an account that you love, as it's possible to be permanently banned. ### Heroku The bot used to have a Heroku option - till they found out and now using the bot on heroku will get your account banned. ================================================ FILE: deps/windows/windows.ps1 ================================================ $install_help = "Read windows installation guide https://github.com/MrPowerScripts/reddit-karma-farming-bot/blob/master/docs/3-windows.md" if (!(get-command python)) { write-host "Python not found" write-host $install_help exit 1 } else {write-host "Python found!"} # make sure visual studio C++ build tools if (Test-Path -Path "C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools") { Write-Host "Found VS 2019 Build Tools - Excellent" } else { Write-Host "VS 2019 Build Tools not found" Write-Host $install_help exit 1 } #check for PyStemmer if (Test-Path -Path "./deps/windows/PyStemmer-2.0.1-cp39-cp39-win_amd64.whl") { Write-Host "Found PyStemmer" } else { Invoke-WebRequest -Uri https://download.lfd.uci.edu/pythonlibs/z4tqcw5k/PyStemmer-2.0.1-cp39-cp39-win_amd64.whl -OutFile ./deps/windows/PyStemmer-2.0.1-cp39-cp39-win_amd64.whl } #check if pipenv is installed if (!(get-command pipenv)) { write-host "Pipenv not found - installing" & pip3 install pipenv exit 1 } else {write-host "Pipenv found!"} #check for pipenv dependencies if (Test-Path -Path "./.venv") { Write-Host "Pipenv deps installed" } else { & pip3 install ./deps/windows/PyStemmer-2.0.1-cp39-cp39-win_amd64.whl & pipenv install } ================================================ FILE: docs/1-getting-started.md ================================================ # Getting Started ## Creating the Reddit app In your browser, when you are logged in with the Reddit account you want to use, go to this URL: https://www.reddit.com/prefs/apps Once there, click the “are you a developer? create an app...” button at the bottom. Name the app anything you want, I recommend not naming it Reddit Karma bot. Instead, go for something generic like “Test script”. Then **CLICK THE SCRIPT OPTION**, this is important. You can leave the description and about URL empty, but you need to put a value for the redirect URI. This can be anything as long as it is a valid URL. I recommend doing something similar to http://example.com or http://nourl.com. But like I said, it can be anything. Then click Create App. You will now be presented with this screen: ![app_example](https://user-images.githubusercontent.com/29954899/103455850-f8810880-4cf0-11eb-9002-64c2f1e5a44e.png) In this image, you will find your client id and secret. The red highlight is your client id, and cyan is your secret key. Now we are ready to get the bot up and running! ## Using a proxy The bot uses the Python `requests` library behind the scenes. Python `requests` library [has some enviroment variables you can set](https://stackoverflow.com/a/8287752) to have it automatically use a proxy server. ## Reddit Configuration ### How to configure the Reddit bot The bot has many configuration options, and some are enabled/disabled by default. View all of the config options in the [src/config](/src/config) folder. #### Limit to specific subreddits Add subreddits to the `REDDIT_APPROVED_SUBS` variable [reddit_sub_lists.py](/src/config/reddit/reddit_sub_lists.py) file. This will limit the bot to only repost/learn/comment to these subreddits. #### Avoid specific subreddits Add the subreddits the bot should avoid to [reddit_avoid_subs.txt](/src/config/reddit/reddit_avoid_subs.txt) file, and the bot will ignore posting/commenting to these subreddits. Do not include `/r/`, just the clean subreddit name on each line of the file. #### Avoid specific words Add words the the bot should avoid to [reddit_avoid_words.txt](/src/config/reddit/reddit_avoid_words.txt) file, and the bot will ignore learning from comments, or reposting posts that include these words. Add a words on separate lines. #### Configure what actions the Reddit bot performs The reddit bot actions can be configured in [reddit_config.py](/src/config/reddit_config.py). If you don't want it to perform an action set the chance value to `0`. For instance, to disable commenting set `"reddit_comment_chance": 0.005,` to `"reddit_comment_chance": 0,`. Increasing the chance values will increase the chance the action is performed. The defualt values are fine, but you can experiment. ##### Sleep schedule The bot has a sleep schedule enabled by default, otherwise it will comment/post 24/7 and likely get banned. You can disable the sleep schedule by removing all schedule values. Like `"reddit_sleep_schedule": [2, 4]` to `"reddit_sleep_schedule": []`. #### Configure Cobe Cobe is the library the bot uses to generate comments. You may want to conifgure how big the comment databse needs to be before it starts commeting. You can adjust the values in [cobe_config.py](/src/config/cobe_config.py). ================================================ FILE: docs/2-linux-macos.md ================================================ # Running the bot on Linux and Macos Run the `run_linux.sh` script in the root of the repo. This script was designed and tested on Ubuntu 20, so that's what you should be using. It should also work on the latest version of macOS. The script will install all OS level dependencies required by the bot, as well as installing python dependencies into a vitual enviroment. After everything has been installed it will automatically run the bot. ================================================ FILE: docs/3-windows.md ================================================ # Running the bot on Windows 10 1. Download and install Python for Windows. You can find [all the releases here](https://www.python.org/downloads/windows/). Make sure you have at least v3.7. You can [click here](https://www.python.org/ftp/python/3.9.1/python-3.9.1-amd64.exe) to download the installer for 3.9.1 directly. **Make sure to check the "add python to PATH" option** or the script won't be able to find it. 2. Download the Visual Studio 2019 Build tools [from this link](https://visualstudio.microsoft.com/thank-you-downloading-visual-studio/?sku=BuildTools&rel=16). Then install the C++ build tools workload. You can disable all optional packages except for the `windows 10 SDK`, `C++ CMake tools for Windows`, and `MSVC v142 VS2019 C++ x64/x86 build tools`. See the screenshot below, and looked at the options checked on the right side. That should be all you need. ![image](https://user-images.githubusercontent.com/1307942/104216961-a77cbd00-5432-11eb-9aec-c56fcef58d2f.png) 3. Run `run_windows.bat` ================================================ FILE: docs/4-docker-guide.md ================================================ # Running the bot in Docker ## Update env file with your credentials update and rename .env.example to .env ## Build docker image From the root of the project, run this docker build command: `docker build -t reddit_karma_bot:latest . --no-cache` ## Run Docker Image `docker run -d --name=reddit-bot reddit_karma_bot:latest` ## View Logs `docker logs -f reddit-bot` ================================================ FILE: run_linux.sh ================================================ #!/usr/bin/env bash DEBUG_FILE="./run_linux.log" export PIPENV_VENV_IN_PROJECT=1 date '+%d/%m/%Y %H:%M:%S' | tee $DEBUG_FILE unameOut="$(uname -s)" case "${unameOut}" in Linux*) machine=Linux;; Darwin*) machine=Mac;; CYGWIN*) machine=Cygwin;; MINGW*) machine=MinGw;; *) machine="UNKNOWN:${unameOut}" esac echo "system is ${machine}" | tee -a $DEBUG_FILE DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" if [ ! -d "$DIR/.venv" ]; then echo "no virtualenv detected doing setup before running" | tee -a $DEBUG_FILE echo "need to install dependencies" | tee -a $DEBUG_FILE if [ "${machine}" = "Linux" ]; then echo "this is linux - install linux deps" apt-get update || { echo 'apt-get failed failed' | tee -a $DEBUG_FILE ; exit 1; } apt-get install -y --no-install-recommends \ g++ \ gcc \ libc6-dev \ make \ pkg-config \ libffi-dev \ python3.6 \ python3-pip \ python3-setuptools \ python3-dev \ git || { echo 'Installing dependencies failed' | tee -a $DEBUG_FILE ; exit 1; } elif [ "${machine}" = "Mac" ]; then $(xcode-select -p) || xcode-select --install else echo "No suitable linux version!" | tee -a $DEBUG_FILE fi if [ "${machine}" = "Linux" ] || [ "${machine}" = "Mac" ]; then pip3 install pipenv || { echo 'Installing virtualenv failed' | tee -a $DEBUG_FILE ; exit 1; } pipenv install || { echo 'Installing python dependencies failed' | tee -a $DEBUG_FILE ; exit 1; } fi fi echo "Trying to run the bot" | tee -a $DEBUG_FILE # start bot directly if nomenu passed in to script if [[ $1 == *"menu"* ]]; then echo "Running with menu" | tee -a $DEBUG_FILE pipenv run python3 ./src/menu.py "$@" else echo "Running without menu" | tee -a $DEBUG_FILE pipenv run python3 ./src/init.py "$@" fi ================================================ FILE: run_windows.bat ================================================ @ECHO OFF Powershell.exe -executionpolicy bypass -File ./deps/windows/windows.ps1 if errorlevel 1 pause & exit if "%1"=="" ( echo running without menu pipenv run python ./src/init.py ) if "%1"=="menu" ( echo running with menu pipenv run python ./src/menu.py ) echo exiting... pause ================================================ FILE: src/__init__.py ================================================ ================================================ FILE: src/apis/__init__.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- from config import reddit_config from .reddit import RedditAPI from .pushshift import PS reddit_api = RedditAPI(**reddit_config.AUTH).api pushshift_api = PS() ================================================ FILE: src/apis/pushshift.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- # data sources for comment learning from psaw import PushshiftAPI from logs.logger import log from utils import DAY, YEAR import requests import time class PS(): def __init__(self): self.api = PushshiftAPI() def get_posts(self, subreddit, **kwargs): post = self._ps_search(subreddit, **kwargs) # log.info(f"post: {post}") return post def get_comments(self, subreddit): return self.api.search_comments(q='', subreddit=subreddit) def _ps_search(self, subreddit, before=None, after=None, score=None, limit=1): cur_time = int(time.time()) after=(cur_time - YEAR) if after is None else None before=(cur_time - (YEAR - DAY)) if before is None else None score = 5000 if score is None else None url = f"https://api.pushshift.io/reddit/search/submission/?subreddit={subreddit}" url = url + (f"&before={before}" if before else "") url = url + (f"&after={after}" if after else "") url = url + (f"&score>={score}" if score else "") url = url + (f"&limit={limit}" if limit else "") url = url + (f"&author!=[deleted]&selftext:not=[deleted]") # avoids deleted posts log.info(f"pushshift-url: {url}") try: response = requests.get(url).json().get("data", []) return response except Exception as e: # unable to get data from pushshift return None ================================================ FILE: src/apis/reddit.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- from praw import Reddit from utils import random_string class RedditAPI(): def __init__(self, reddit_client_id, reddit_client_secret, reddit_password, reddit_username): self.client_id = reddit_client_id self.client_secret = reddit_client_secret self.password = reddit_password self.username = reddit_username self.user_agent = random_string(10) self.api = Reddit( client_id=self.client_id, client_secret=self.client_secret, password=self.password, user_agent=self.user_agent, username=self.username, ) ================================================ FILE: src/bot.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- from bots.reddit import RedditBot from utils import countdown from logs.logger import log def run(): reddit = RedditBot() while True: reddit.run() countdown(1) ================================================ FILE: src/bots/reddit/__init__.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- from .bot import RedditBot ================================================ FILE: src/bots/reddit/actions/cleanup_actions.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import praw import requests from apis import pushshift_api, reddit_api from config import reddit_config from utils import chance from logs.logger import log import sys class Cleanup(): def __init__(self): self.psapi = pushshift_api self.rapi = reddit_api self.username = None def init(self): self.me = self.rapi.user.me self.username = self.me().name def shadow_check(self, roll=1): if chance(roll): log.info("performing a shadowban check") response = requests.get(f"https://www.reddit.com/user/{self.username}/about.json", headers = {'User-agent': f"hiiii its {self.username}"}).json() if "error" in response: if response["error"] == 404: log.info(f"account {self.username} is shadowbanned. poor bot :( shutting down the script...") sys.exit() else: log.info(response) else: log.info(f"{self.username} is not shadowbanned! We think..") def remove_low_scores(self, roll=1): comment_count = 0 post_count = 0 if chance(roll): log.info("checking for low score content to remove") for i in self.rapi.redditor(self.username).new(limit=500): if i.score <= reddit_config.CONFIG["reddit_low_score_threshold"]: if isinstance(i, praw.models.Comment): log.info(f"deleting comment(id={i.id}, body={i.body}, score={i.score}, subreddit={i.subreddit_name_prefixed}|{i.subreddit_id})") try: i.delete() except Exception as e: log.info(f"unable to delete comment(id={i.id}), skip...\n{e.message}") comment_count += 1 else: log.info(f"deleting post(id={i.id}, score={i.score}, subreddit={i.subreddit_name_prefixed}|{i.subreddit_id})") try: i.delete() except Exception as e: log.info(f"unable to delete post(id={i.id}), skip...\n{e.message}") post_count += 1 log.info(f'removed {comment_count + post_count} item(s). removed {comment_count} comment(s), {post_count} post(s) with less than {reddit_config.CONFIG["reddit_low_score_threshold"]} score') # GOOD BOT if (comment_count + post_count) == 0: log.info("no low score content to clean up. I'm a good bot! :^)") def karma_limit(self): # current karma limit ckl = reddit_config.CONFIG["reddit_comment_karma_limit"] # current post karma cck = self.me().comment_karma # post karma limit pkl = reddit_config.CONFIG["reddit_post_karma_limit"] # current post karma cpk = self.me().link_karma if ckl: if ckl < cck: log.info(f"Comment karma limit ({ckl}) exceeded! Your current comment karma: {cck}. Shutting down the script.") sys.exit() else: log.info(f"Comment karma limit ({ckl}) not reached. Current comment karma: {cck}") return if pkl: if pkl < cpk: log.info(f"Post karma limit ({pkl}) exceeded! Your current post karma: {cpk}. Shutting down the script.") sys.exit() else: log.info(f"Post karma limit ({pkl}) not reached. Current post karma: {cpk}") return log.info(f"No limits - ignoring.") ================================================ FILE: src/bots/reddit/actions/comments/comment_actions.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- from .sources.cobe import Cobe from logs.logger import log from collections import namedtuple from utils import chance from apis import reddit_api from config import reddit_config from ..utils import get_subreddit, AVOID_WORDS import random from praw.exceptions import APIException Source = namedtuple('Source', ['name', 'api']) class Comments(): def __init__(self, source='cobe'): self.ready = False self.config = reddit_config.CONFIG self.rapi = reddit_api self.source_name = source self.sources = { "cobe": Source('cobe', Cobe) } self.comments = self.sources.get(self.source_name).api() def init(self): log.info("intiializing comments") self.ready = False self.comments.init() self.ready = True log.info("commenting ready") def comment(self, roll=1): if not self.ready: log.info("comments need to be initialized") self.init() if chance(roll): log.info("going to make a comment") # keep searching posts until we find one with comments post_with_comments = False while not post_with_comments: # pick a subreddit to comment on subreddit = get_subreddit(getsubclass=True) # get a random hot post from the subreddit post = random.choice(list(subreddit.hot())) # replace the "MoreReplies" with all of the submission replies post.comments.replace_more(limit=0) if len(post.comments.list()) > 0: post_with_comments = True try: # choose if we're replying to the post or to a comment if chance(self.config.get('reddit_reply_to_comment')): # reply to the post with a response based on the post title log.info('replying directly to post') post.reply(self.comments.get_reply(post.title)) else: # get a random comment from the post comment = random.choice(post.comments.list()) # reply to the comment log.info('replying to comment') comment.reply(self.comments.get_reply(comment.body)) except APIException as e: log.info(f"error commenting: {e}") ================================================ FILE: src/bots/reddit/actions/comments/sources/cobe.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- from cobe.brain import Brain from config.cobe_config import CONFIG from apis import pushshift_api, reddit_api from logs.logger import log from utils import bytesto, tobytes from ...utils import AVOID_WORDS, get_subreddit import os, sys class Cobe(): def __init__(self, config=CONFIG): self.ready = False self.psapi = pushshift_api self.rapi = reddit_api self.config = CONFIG self.brain = Brain(self.config.get("cobe_main_db")) self.size = 0 def get_reply(self, replyto: str=''): if self.ready: return self.brain.reply(replyto) else: log.info(f"cobe not initialized, run init") def init(self): log.info("using cobe to generate comments") main_db = self.config.get("cobe_main_db") # make sure db was initialized correctly if os.path.isfile(main_db): # set the initial size self.size = os.path.getsize(main_db) else: log.info(f"cobe db failed to initialize. exiting") sys.exit() log.debug('filling cobe database for commenting') # loop through learning comments until we reach the min db size while self.size <= tobytes(self.config.get("cobe_min_db_size")): log.info(f"cobe db size is: {str(bytesto(self.size, 'm'))}mb, need {self.config.get('cobe_min_db_size')} - learning...") # just learn from random subreddits for now subreddit = get_subreddit(getsubclass=True) log.info(f"learning from /r/{subreddit}") # get the comment generator function from pushshift comments = self.psapi.get_comments(subreddit) # go through 500 comments per subreddit for x in range(500): # get the comment from the generator function try: comment = next(comments) except StopIteration as e: log.info(f"end of comments") # bot responses are better when it learns from short comments if len(comment.body) < 240: log.debug(f"learning comment: {comment.body.encode('utf8')}") # only learn comments that don't contain an avoid word if not any(word in comment.body for word in AVOID_WORDS): self.brain.learn(comment.body.encode("utf8")) # update the class size variable so the while loop # knows when to break self.size = os.path.getsize(main_db) log.info(f"database min size ({self.config.get('cobe_min_db_size')}) reached") self.ready = True ================================================ FILE: src/bots/reddit/actions/post_actions.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import random, requests, re from time import sleep as s from apis import pushshift_api, reddit_api from utils import chance from .utils import get_subreddit, AVOID_WORDS from config.reddit_config import CONFIG from config.reddit.reddit_sub_lists import CROSSPOST_SUBS from logs.logger import log from praw.exceptions import APIException wordgroup = [['last','recent'],['in','by','at'],['looking at','watching'],['took','attended'],['arrives','comes'],['approach','procedure'],['order','buy','purchase'],['recommended','suggested','endorsed','proposed'],['approved','accepted'],['employees','workers'],['amazing','incredible','unbelievable','wonderful','fantastic','extraordinary'],['anger','enrage','infuriate'],['angry','mad','furious','enraged'],['answer','reply','response'],['ask','question','request','query'],['awful','dreadful','terrible','abominable','bad','poor','unpleasant'],['rotten','contaminated','spoiled','tainted'],['faulty','improper','inappropriate','unsuitable','disagreeable','unpleasant'],['bad','evil','immoral','wicked','corrupt','harmful','deplorable','gross','heinous','obnoxious','despicable'],['beautiful','pretty','lovely','handsome','attractive','gorgeous','dazzling','splendid','magnificent','comely','fair','ravishing','graceful','elegant','fine','exquisite','aesthetic','pleasing','shapely','delicate','stunning','glorious','heavenly','resplendent','radiant','glowing','blooming','sparkling'],['begin','start','open','launch','initiate','commence','inaugurate','originate'],['big','enormous','huge','immense','gigantic','vast','colossal','gargantuan','large','sizable','grand','great','tall','substantial','mammoth','astronomical','ample','broad','expansive','spacious','stout','tremendous','titanic','mountainous'],['brave','courageous','fearless','dauntless','intrepid','plucky','daring','heroic','valorous','audacious','bold','gallant','valiant','doughty','mettlesome'],['break','fracture','rupture','shatter','smash','wreck','crash','demolish'],['bright','shiny','intellectual'],['calm','quiet','peaceful','still','collected'],['come','approach'],['cool','cold','frosty','icy'],['crooked','bent','twisted','curved','hooked'],['shout','yell','scream'],['cry','sob'],['cut','slice','slit','chop','crop'],['dangerous','risky','unsafe'],['dark','shadowy','dim','shaded'],['decide','determine','settle','choose','resolve'],['definite','certain','sure','positive','determined','clear','distinct','obvious'],['delicious','appetizing','scrumptious','delightful','enjoyable','toothsome','exquisite'],['describe','portray','characterize','picture','narrate','relate','recount','represent','report','record'],['destroy','ruin','demolish','kill','slay'],['difference','disagreement','inequity','contrast','dissimilarity','incompatibility'],['do','execute','finish','conclude','accomplish','achieve','attain'],['boring','tiring','tiresome','uninteresting'],['slow','dumb','stupid','unimaginative'],['eager','enthusiastic','involve','interest'],['enjoy','appreciate','like'],['explain','elaborate','clarify','define','interpret','justify','account for'],['fair','unbiased','objective','unprejudiced','honest'],['fall','drop','descend','tumble'],['false','untrue','deceptive','fallacious'],['famous','well-known','celebrated','famed','illustrious','distinguished','noted','notorious'],['fast','quick','rapid','speedy','hasty'],['fat','corpulent','beefy','plump','chubby','chunky','bulky'],['fear','anxiety','panic'],['funny','humorous','amusing','comical','laughable'],['get','acquire','obtain','secure','gather'],['go','recede','depart','fade','disappear','move','travel','proceed'],['good','excellent','fine','superior','marvelous','suitable','proper','pleasant','satisfactory','reliable','helpful','valid','genuine','great','respectable','edifying'],['great','noteworthy','worthy','distinguished','remarkable','grand','considerable','powerful','much','mighty'],['gross','rude','vulgar'],['happy','pleased','contented','satisfied','joyful','cheerful','glad','blissful'],['hate','despise','disfavor','dislike'],['have','possess','own'],['help','aid','assist','support','encourage'],['hide','cover'],['hurry','rush','run','speed','race','hasten','urge','accelerate','bustle'],['hurt','damage','harm','injure','wound','pain'],['idea','thought','concept','conception','plan'],['important','necessary','vital','critical','indispensable','valuable','essential','significant'],['interesting','fascinating','engaging','thought-provoking','curious','appealing'],['keep','hold','retain','withhold','preserve','maintain','sustain'],['kill','slay','execute','assassinate','murder','destroy','abolish'],['lazy','inactive','sluggish'],['little','tiny','small','petite'],['look','gaze','see','glance','watch','peek','stare','observe','view','spy','sight','discover','notice','recognize','peer','eye'],['love','like','admire','esteem','fancy','care for','cherish','adore','treasure','worship','appreciate','savor'],['make','create','form','construct','design','fabricate','manufacture','produce','develop','do','execute','compose','perform','acquire'],['mark','label','tag','price','ticket','sign','note','notice'],['mischievous','prankish','playful'],['move','go','walk','jog','run','sprint','hurry','wander','roam'],['moody','temperamental','short-tempered'],['neat','clean','orderly','elegant','well-organized','super','desirable','well-kept','shapely'],['new','fresh','modern','recent'],['old','ancient','aged','used','worn','faded','broken-down','old-fashioned'],['part','portion','share','piece','allotment','section','fraction','fragment'],['place','area','spot','region','location','position','residence','set','state'],['plan','plot','scheme','design','map','diagram','procedure','arrangement','intention','device','contrivance','method','way','blueprint'],['popular','well-liked','celebrate','common'],['predicament','quandary','dilemma','problem'],['put','place','set','attach','set aside','effect','achieve','do'],['quiet','silent','still','soundless','mute','peaceful','calm','restful'],['right','correct','good','honest','moral','proper','suitable'],['run','race','hurry','sprint','rush'],['say','tell','inform','notify','advise','narrate','explain','reveal','disclose','remark','converse','speak','affirm','suppose','utter','negate','express','verbalize','articulate','pronounce','convey','impart','state','announce'],['scared','afraid','frightened','terrified','fearful','worried','horrified','shocked'],['show','display','present','note','reveal','demonstrate'],['slow','unhurried','tedious'],['stop','end','finish','quit'],['story','myth','legend','fable','narrative','chronicle','anecdote','memoir'],['strange','odd','unusual','unfamiliar','uncommon','weird','outlandish','curious','unique','exclusive','irregular'],['remove','steal','lift','rob',],['purchase','buy'],['tell','disclose','reveal','narrate','talk','explain'],['think','reflect'],['trouble','disaster','misfortune','inconvenience'],['true','accurate','right','proper','precise','exact','valid','genuine'],['ugly','unpleasant','terrifying','gross','unsightly'],['unhappy','miserable','uncomfortable','unfortunate','depressed','sad'],['use','utilize'],['wrong','incorrect','inaccurate','mistaken'],['know','acknowledge','recognise','recognize'],['people','citizenry','masses','mass'],['now','forthwith','nowadays','instantly'],['first','beginning','initiatory','initiative','firstly'],] def find_synonyms(keyword): keyword=keyword.lower() for sub_list in wordgroup: if keyword in sub_list: while True: word = random.choice(sub_list) if word != keyword: # print('found "'+keyword+'"; chosen synonym "'+word+'"') return word title_chars=['!','.',';','?'] invisible_chars = ['‍ ',' ‏‏‎ ','‏‏‎‏‏‎‏‏‎‏‏‎­',' ⠀'] def edit_text(var, mode): if mode == 'body': mychars=[] if ' ' in var: for index, x in enumerate(var): if x == ' ':mychars.append(index) editedtext = list(var) editedtext[random.choice(mychars)] = random.choice(invisible_chars) editedtext = ''.join(editedtext) for x in editedtext.split(): synonym=find_synonyms(x) if synonym != None: words = editedtext.split() words[words.index(x)] = synonym editedtext = " ".join(words) return editedtext else:return var elif mode == 'title': if any(not c.isalnum() for c in var[-2:]):return var.replace(var[-2:], var[-2]+' '+random.choice(title_chars)) else:return var+random.choice(title_chars) class Posts(): def __init__(self): self.psapi = pushshift_api self.rapi = reddit_api def get_post(self, subreddit=None): log.info(f"finding a post to re-post") got_post = False attempts = 0 while not got_post: # use the supplied subreddit # otherwise choose one randomly if subreddit: log.info(f"searching post in sub: {subreddit}") sub = self.rapi.subreddit(subreddit) else: # if there are subreddits in the subreddit list pull randomly from that # otherwise pull a totally random subreddit sub = self.rapi.subreddit(random.choice(CONFIG['reddit_sub_list'])) if CONFIG['reddit_sub_list'] else get_subreddit(getsubclass=True) log.info(f"searching post in sub: {sub.display_name}") try: post_id = self.psapi.get_posts(sub.display_name)[0]['id'] # don't use posts that have avoid words in title if not any(word in comment.body for word in AVOID_WORDS): got_post = True except Exception as e: log.info(f"couldn't find post in {sub}") # sub = self.rapi.random_subreddit(nsfw=False) # log.info(f"trying in: {subreddit}") attempts += 1 log.info(f"repost attempts: {attempts}") if attempts > 3: log.info(f"couldn't find any posts - skipping reposting for now") return return self.rapi.submission(id=post_id) def crosspost(self, subreddit): for idx, subs in enumerate(CROSSPOST_SUBS): if subs[0] == subreddit: return random.choice(subs[idx]) # why do my eyes hurt def repost(self, roll=1, subreddit=None): if chance(roll): log.info("running repost") # log.info("running _repost") post = self.get_post(subreddit=subreddit) if not post: return api_call=requests.get(post.url).status_code if api_call != 200: if api_call == 429: print('too many requests to pushshift') s(random.uniform(3,8)) else: print('pushshift http error: '+str(api_call)) return else: log.info(f"reposting post: {post.id}") if post.is_self: if post.selftext not in ('[removed]','[deleted]') and bool(re.findall(r'20[0-9][0-9]|v.redd.it', post.selftext)) == False: params = {"title": edit_text(post.title, 'title'), "selftext": edit_text(post.selftext, 'body')} else: print('Info: skipping post; it was malformed or date indicated') # print(post.selftext) else:params = {"title": edit_text(post.title, 'title'), "url": post.url} sub = post.subreddit # randomly choose a potential subreddit to cross post if CONFIG['reddit_crosspost_enabled']: sub = self.rapi.subreddit(self.crosspost(sub.display_name)) try: self.rapi.subreddit(sub.display_name).submit(**params) return except (UnboundLocalError, TypeError):pass except APIException as e: log.info(f"REPOST ERROR: {e}") return else: pass # log.info("not running repost") # log.info("not running _repost") ## to do: add flairs compability or a way to avoid flairs ================================================ FILE: src/bots/reddit/actions/utils.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import random from apis import reddit_api from logs.logger import log from config.reddit.reddit_sub_lists import REDDIT_APPROVED_SUBS from config.common_config import CONFIG_ROOT with open(f"{CONFIG_ROOT}/reddit/reddit_avoid_subs.txt", "r") as subfile: AVOID_SUBS = subfile.read().splitlines() subfile.close() with open(f"{CONFIG_ROOT}/reddit/reddit_avoid_words.txt", "r") as wordfile: AVOID_WORDS = wordfile.read().splitlines() wordfile.close() log.debug(f"avoiding subs: {AVOID_SUBS}") def get_subreddit(nsfw=False, getsubclass=False): # if the subreddit list is being used jut return one from there if REDDIT_APPROVED_SUBS: log.info(f"picking subreddit from approved list") subreddit = reddit_api.subreddit(random.choice(REDDIT_APPROVED_SUBS).strip()) log.info(f"using subreddit: {subreddit.display_name}") else: log.info(f"picking a random subreddit") # otherwise we'll do some logic to get a random subreddit subreddit_ok = False while not subreddit_ok: subreddit = reddit_api.random_subreddit(nsfw=nsfw) log.info(f"checking subreddit: {subreddit.display_name}") # make sure the radom sub isn't in the avoid sub list # keep searching for a subreddit until it meets this condition if subreddit.display_name not in AVOID_SUBS: subreddit_ok = True if getsubclass: return subreddit else: return subreddit.display_name ================================================ FILE: src/bots/reddit/bot.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- from apis import reddit_api from config import reddit_config from utils import chance from bots.reddit.actions.post_actions import Posts from bots.reddit.actions.comments.comment_actions import Comments from bots.reddit.actions.cleanup_actions import Cleanup from logs.logger import log from logs.log_utils import log_json import time, sys, random from collections import namedtuple from .utils import should_we_sleep, parse_user BotAction = namedtuple("BotAction", 'name call') class RedditBot(): def __init__(self, config=reddit_config.CONFIG): self.api = reddit_api self.ready = False self.config = config self.user = None self.posts = Posts() self.comments = Comments() self.cleanup = Cleanup() self.actions = [ BotAction('reddit_post_chance', self.posts.repost), BotAction('reddit_comment_chance', self.comments.comment), BotAction('reddit_shadowban_check', self.cleanup.shadow_check), BotAction('reddit_remove_low_scores', self.cleanup.remove_low_scores), BotAction('reddit_karma_limit_check', self.cleanup.karma_limit), ] def _init(self): # check if account is set user = self.api.user.me() if user is None: log.info("User auth failed, Reddit bot shutting down") sys.exit() else: log.info(f"running as user: {user}") # check if account is shadowbanned self.cleanup.init() self.cleanup.shadow_check() self.user = parse_user(user) log.info(f"account info:\n{log_json(self.user)}") self.ready = True log.info("The bot is now running. It has a chance to perform an action every second. Be patient") def tick(self): if not should_we_sleep(): report = f"" for action in self.actions: roll = random.random() result = roll < self.config[action.name] print(f"{roll} < {self.config[action.name]} = {result} ", end="\r") if result: log.info(f"\nrunning action: {action.name}") action.call() def run(self): if self.ready: self.tick() else: self._init() self.run() # log.info("not running reddit bot - not ready") ================================================ FILE: src/bots/reddit/utils.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import datetime from logs.logger import log from config.reddit_config import CONFIG import time from praw.models.redditors import Redditors ## USER UTILS def parse_user(user: Redditors): i = {} i['comment_karma'] = user.comment_karma i['link_karma'] = user.link_karma i['username'] = user.name i['created_utc'] = user.created_utc i['created_utc_human'] = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(user.created_utc)) return i ## SCHEDULE UTILS EASY_SCHEDULES = { 1: ((7,00),(10,00)), 2: ((10,00),(14,00)), 3: ((14,00),(18,00)), 4: ((18,00),(22,00)), 5: ((22,00),(2,00)), } # convert the easy schedules to the tuple values BOT_SCHEDULE = [EASY_SCHEDULES.get(schedule) for schedule in CONFIG['reddit_sleep_schedule']] log.info(f"using schedules: {BOT_SCHEDULE}") # transform the schedule with datetime formatting updated_schedules = [((datetime.time(schedule[0][0], schedule[0][1])), (datetime.time(schedule[1][0], schedule[1][1]))) for schedule in BOT_SCHEDULE] BOT_SCHEDULE = updated_schedules def is_time_between(begin_time, end_time, check_time=None): # If check time is not given, default to current UTC time check_time = check_time or datetime.datetime.utcnow().time() if begin_time < end_time: return check_time >= begin_time and check_time <= end_time else: # crosses midnight return check_time >= begin_time or check_time <= end_time def should_we_sleep(): CHECKS = [True for schedule in BOT_SCHEDULE if is_time_between(schedule[0], schedule[1])] # check if any of the time between checks returned true. # if there's a True in the list, it means we're between one of the scheduled times # and so this function returns False so the bot doesn't sleep if True in CHECKS or not CONFIG.get('reddit_sleep_schedule'): # no need to sleep - the bot is within one of the time ranges return False else: log.info("it's sleepy time.. zzzzz :snore: zzzz") whats_left = [] TIME_LEFT = [schedule[0] for schedule in BOT_SCHEDULE] for time_stamp in TIME_LEFT: # log.info(time_stamp) next_start = datetime.datetime.combine(datetime.date.today(), time_stamp) # log.info(f"next start: {next_start}") ts = int(next_start.timestamp()) # if this goes negative then the next start is probably tomorrow if ts < int(time.time()): next_start = datetime.datetime.combine((datetime.date.today() + datetime.timedelta(days=1)), time_stamp) ts = next_start.timestamp() # collect all the seconds left for each time schedule to start # log.info(f"ts: {ts}") # log.info(f"time: {int(time.time())}") whats_left.append(ts - int(time.time())) #remove negative values and # get the shortest duration of time left before starting # log.info(whats_left) whats_left = [item for item in whats_left if item >= 0] # log.info(whats_left) time_left = int(min(whats_left)) if time_left > 600: log.info(f"waking up in: {datetime.timedelta(seconds=time_left)} at {next_start}") sleep_time = int(time_left / 3) # have the bot sleep for a short while instead of tons of messages every second time.sleep(sleep_time) return True ================================================ FILE: src/config/cobe_config.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- from utils import prefer_envar from pathlib import Path from logs.logger import log from logs.log_utils import log_json from .common_config import SRC_ROOT import os BASE_DIR = os.path.join(SRC_ROOT, 'bots/reddit/actions/comments') DB_DIR = os.path.join(BASE_DIR, "brains") MAIN_DB = os.path.join(DB_DIR, "brain.db") if not os.path.exists(DB_DIR): os.makedirs(DB_DIR, exist_ok=True) CONFIG = prefer_envar({ # cobe config "cobe_base_dir": BASE_DIR, "cobe_db_dir": DB_DIR, "cobe_main_db": MAIN_DB, "cobe_min_db_size":"50mb", "cobe_max_db_size":"300mb", }) log.info(f"COBE CONFIG:\n {log_json(CONFIG)}") ================================================ FILE: src/config/common_config.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import os from logs.logger import log from pathlib import Path # Prefix that the bot uses to discover envars settings for the bots ENVAR_PREFIX="BOT_" CONFIG_ROOT = os.path.dirname(os.path.abspath(__file__)) config_root = Path(CONFIG_ROOT) REPO_ROOT = config_root.parents[1].absolute() SRC_ROOT = os.path.join(REPO_ROOT, "src") ENV_FILE= os.path.join(REPO_ROOT, ".env") log.info(f"config root: {CONFIG_ROOT}") log.info(f"repo root: {REPO_ROOT}") log.info(f"src root: {SRC_ROOT}") # Common Values DAY = 86400 # POSIX day (exact value in seconds) MINUTE = 60 # seconds in a minute ================================================ FILE: src/config/config_menu.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import os import sys import json import menu import pathlib from utils import prefer_envar from libs import urwide from .common_config import SRC_ROOT CONFIG_JSON_FILE = os.path.join(SRC_ROOT, "config/config.json") if os.path.isfile(CONFIG_JSON_FILE): with open(CONFIG_JSON_FILE, "r") as config_json: config_data = prefer_envar(json.load(config_json)) else: config_data = prefer_envar({ "reddit_client_id":"", "reddit_client_secret":"", "reddit_username":"", "reddit_password":"", }) CONSOLE_STYLE = """""" CONSOLE_UI = f'''\ Hdr Reddit Karma Bot Settings --- Edt Client ID [{config_data["reddit_client_id"]}] #clientid Edt Secret [{config_data["reddit_client_secret"]}] #secret --- Edt Username [{config_data["reddit_username"]}] #user Edt Password [{config_data["reddit_password"]}] #password === GFl Btn [Cancel] #btn_cancel &press=cancel Btn [Save] #btn_save &press=save End ''' # Event handler class Handler(urwide.Handler): def onSave( self, button ): self.ui.info("Saving") fields = self.ui.widgets config_data["reddit_client_id"] = fields.clientid.edit_text config_data["reddit_client_secret"] = fields.secret.edit_text config_data["reddit_username"] = fields.user.edit_text config_data["reddit_password"] = fields.password.edit_text with open(CONFIG_JSON_FILE, "w+") as config_file: config_file.write(json.dumps(config_data, indent=4, sort_keys=True)) config_file.close() menu.run() def onCancel( self, button ): self.ui.info("Cancel") menu.run() ui = urwide.Console() ui.create(CONSOLE_STYLE, CONSOLE_UI, Handler()) # Main def run(): ui.main() if __name__ == "__main__": run() # EOF ================================================ FILE: src/config/reddit/config_gen.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import praw import sys import os from logs.logger import log from prawcore import ResponseException from ..common_config import ENV_FILE def config_gen(): # ASK FOR CREDENTIALS CLIENT_ID = input('please input your account client id :') CLIENT_SECRET = input('please input your account client secret :') PASSWORD = input('please input your account password :') USERNAME = input('please input your account username :') reddit = praw.Reddit( client_id=CLIENT_ID, client_secret=CLIENT_SECRET, user_agent="my user agent", username=USERNAME, password=PASSWORD ) # CHECK IF CREDENTIALS ARE CORRECT def authenticated(reddit): try: reddit.user.me() except ResponseException: return False else: return True # SAVE CONFIG FILE if authenticated(reddit): with open(ENV_FILE, "w") as file_object: file_object.write(f'bot_reddit_client_id="{CLIENT_ID}"\n') file_object.write(f'bot_reddit_client_secret="{CLIENT_SECRET}"\n') file_object.write(f'bot_reddit_password="{PASSWORD}"\n') file_object.write(f'bot_reddit_username="{USERNAME}"\n') print("Config file '.env' created. Please re-run the bot") sys.exit() else: print('WRONG CREDENTIALS!! TRY AGAIN') config_gen() ================================================ FILE: src/config/reddit/reddit_avoid_subs.txt ================================================ Agoraphobia Anxiety Assistance AtheistTwelveSteppers BipolarReddit CBTpractice DPDR Depression Diagnosed DomesticViolence HardShipMates HelpingHands IHaveIssues LGBT LostALovedOne NeedAFriend OffMyChest OpiatesRecovery Petloss RandomKindness RapeCounseling SFTS SMARTRecovery SelfHelp SingleParents StopGaming StopSelfHarm SuicideBereavement SuicideWatch TalkTherapy abuse abusiverelationships addiction addictionprevention adhd alcoholism anarchism animetitties anxiety anxietyhelp anythinggoesnews aspergers babyloss behaviortherapy benzorecovery bidenpro bipolar bipolar2 bipolarreddit bodyacceptance bodydysmorphia borderlinepdisorder buylling chapotraphouse childrenofdeadparents comingout communism completeanarchy confession conservative cptsd cripplingalcoholism cutters dbtselfhelp deadredditors democrats depressed depression depression_help depression_memes depressionandPTSD depressionregimens downsyndrome drugs dysmorphicdisorder eatingdisorders energy enoughtrumpspam esist feminism foreveralone fuckthealtright full_news fullnews gamernews geopolitics griefsupport healthanxiety healthproject helpmecope humantrafficking impeach_trump inmemoryof inthenews introvert itgetsbetter keep_track kindvoice leaves lgbteens liberal libertarian lonely marchagainsttrump mensrights mentalhealth mentalillness mixednuts neutralnews news obits obituaries ocd ocpd offbeat outhere pandys petioles political_revolution politics problemgambling ptsd qualitynews questioning quittingkratom raisedbynarcissists rant rants rape rapecounseling recoverywithoutAA redditorsinrecovery relationship_advice relationships sad safespace sandersforpresident schizophrenia secondary_survivors secularsobriety selfHarmScars selfharm selfhelp sextrafficking sfts shizoaffective socialanxiety socialism stopdrinking stopselfharm stopsmoking stopspeeding suicidewatch survivorsofabuse teenrelationships thanksobama the_mueller thefallen thenews therapy tinytrumps tourettes traumatoolbox trumpcriticizestrump trumpgret twoxchromosomes ukpolitics upliftingnews usnews vent widowers wordpolitics worldnews ================================================ FILE: src/config/reddit/reddit_avoid_words.txt ================================================ ================================================ FILE: src/config/reddit/reddit_sub_lists.py ================================================ # The karma bot will only use # the subs in the list below # EXAMPLE # REDDIT_APPROVED_SUBS = [ # "aww", # "pics", # "pcmasterrace", # ] # The bot will only use the subs defined in this list # if this list is empty it will choose subreddits randomly REDDIT_APPROVED_SUBS = [ ] # array of arrays with subreddits # where content can be crossposted # the first array item is the source, # and the rest are where it could be re-posted to CROSSPOST_SUBS = [ ["aww", "pics", "animals"], ["catpictures", "aww"] ] ================================================ FILE: src/config/reddit_config.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- from utils import prefer_envar from logs.logger import log from logs.log_utils import log_json from config.reddit.reddit_sub_lists import REDDIT_APPROVED_SUBS from config.reddit.config_gen import config_gen import sys import json import os AUTH = prefer_envar({ # app creds "reddit_client_id":"", "reddit_client_secret":"", # reddit account creds "reddit_username":"", "reddit_password":"", }) for key in AUTH: if AUTH[key] == "": # reddit auth not configured correctly. # instruct user to generate a .env file config_gen() log.info(f"REDDIT AUTH CONFIG:\n {log_json(AUTH)}") CONFIG = prefer_envar({ "reddit_crosspost_enabled": False, # the chance the bot will repost a post "reddit_post_chance": 0.005, # the chance the bot will make a comment "reddit_comment_chance": 0.005, # the chance the bot will reply to a comment # otherwise it will reply to a post "reddit_reply_to_comment": 0.002, # chance the bot will remove poor performing # posts and comments "reddit_remove_low_scores": 0.002, # posts/comments that get downvoted to this score will be deleted "reddit_low_score_threshold": 0, # chance to check if the bot is shadowbanned, # and shut down the script automatically "reddit_shadowban_check": 0.002, # list of subreddits for the bot to use "reddit_sub_list": REDDIT_APPROVED_SUBS, # bot schedules. all times are UTC # add the schedule number to the array # and the bot will run within that time range # leave the array empty for no schedule: [] # 1 - 7am-10am ((7,00),(10,00)) # 2 - 10am-2pm ((10,00),(14,00)) # 3 - 2pm-6pm ((14,00),(18,00)) # 4 - 6pm-10pm ((18,00),(22,00)) # 5 - 10pm-2am ((22,00),(2,00)) "reddit_sleep_schedule": [2, 4], # Frequency to check if the bot hit karma limits "reddit_karma_limit_check": 0.002, # Set to integer with the max comment karma # before the bot shuts down. Set as None to ignore "reddit_comment_karma_limit": None, # Set to integer with the max post/submission karma # before the bot shuts down. Set as None to ignore "reddit_post_karma_limit": None, }) log.info(f"REDDIT CONNFIG:\n {log_json(CONFIG)}") ================================================ FILE: src/config/test.yml ================================================ loaded: true ================================================ FILE: src/init.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import sys from logs.logger import log from utils import check_internet , get_public_ip import bot if __name__ == "__main__": if check_internet() is True: try: log.info(f'Internet connection found : {get_public_ip()}') bot.run() except KeyboardInterrupt: # quit sys.exit() else: log.info('Please check your internet connection') sys.exit() ================================================ FILE: src/libs/urwide.py ================================================ #!/usr/bin/env python # encoding: utf8 # ----------------------------------------------------------------------------- # Project : URWIDE - Extended URWID # ----------------------------------------------------------------------------- # Author : Sébastien Pierre # License : Lesser GNU Public License http://www.gnu.org/licenses/lgpl.html> # ----------------------------------------------------------------------------- # Creation : 14-07-2006 # Last mod : 15-12-2016 # ----------------------------------------------------------------------------- import sys, string, re, curses import urwid, urwid.raw_display, urwid.curses_display from urwid.widget import FLOW, FIXED, PACK, BOX, GIVEN, WEIGHT, LEFT, RIGHT, RELATIVE, TOP, BOTTOM, CLIP, RELATIVE_100 __version__ = "0.2.1" __doc__ = """\ URWIDE provides a nice wrapper around the awesome URWID Python library. It enables the creation of complex console user-interfaces, using an easy to use API . URWIDE provides a simple notation to describe text-based UIs, and also provides extensions to support events, tooltips, dialogs as well as other goodies for every URWID widget. URWID can be downloaded at . """ COLORS = { # Colors "WH": "white", "BL": "black", "YL": "yellow", "BR": "brown", "LR": "light red", "LG": "light green", "LB": "light blue", "LC": "light cyan", "LM": "light magenta", "Lg": "light gray", "DR": "dark red", "DG": "dark green", "DB": "dark blue", "DC": "dark cyan", "DM": "dark magenta", "Dg": "dark gray", # Font attributes "BO": "bold", "SO": "standout", "UL": "underline", "_" : "default" } RIGHT = "right" LEFT = "left" CENTER = "center" IS_PYTHON3 = sys.version_info[0] > 2 if IS_PYTHON3: # Python3 only defines str unicode = str long = int else: unicode = unicode def isString( t ): return isinstance(t, (unicode, str)) def ensureString( t, encoding="utf8" ): if IS_PYTHON3: return t if isinstance(t, str) else str(t, encoding) else: return t.encode("utf8") if isinstance (t, unicode) else str(t) def safeEnsureString( t, encoding="utf8" ): if IS_PYTHON3: return ensureString(t, encoding) else: return t.encode("utf8", "ignore") if isinstance (t, unicode) else str(t) def ensureUnicode( t, encoding="utf8" ): if IS_PYTHON3: return t if isinstance(t, str) else str(t, encoding) else: return t if isinstance(t, unicode) else str(t).decode(encoding) def ensureBytes( t, encoding="utf8" ): if IS_PYTHON3: return t if isinstance(t, bytes) else bytes(t, encoding) else: return t def add_widget( container, widget, options=None ): w = widget if isinstance(container, urwid.Pile): # See: urwid.container.py Pile.__init__ w = widget if not isinstance(w, tuple): container.contents.append((w, (WEIGHT, 1))) elif w[0] in (FLOW, PACK): f, w = w containe.contents.append((w, (PACK, None))) elif len(w) == 2: height, w = w container.contents.append((w, (GIVEN, height))) elif w[0] == FIXED: # backwards compatibility _ignore, height, w = w container.contents.append((w, (GIVEN, height))) elif w[0] == WEIGHT: f, height, w = w container.contents.append((w, (f, height))) else: raise ValueError("Widget not as expected: {0}".format(widet)) else: container.contents.append(widget) def remove_widgets( container ): w = [_ for _ in container.contents] for _ in w: container.contents.remove(_) def original_widgets( widget ): if not widget: return [] stack = [widget] if stack: while hasattr(stack[0], "original_widget"): original = stack[0].original_widget if original not in stack: stack.insert(0,original) else: break return stack def original_widget(widget): r = original_widgets(widget) return r[0] if r else widget def original_focus(widget): w = original_widgets(widget) for _ in w: if hasattr(_, "focus"): return _.focus return w[0] # ------------------------------------------------------------------------------ # # URWID Patching # # ------------------------------------------------------------------------------ class PatchedListBox(urwid.ListBox): _parent = None def __init__( self, *args, **kwargs ): PatchedListBox._parent.__init__(self, *args, **kwargs) def remove_widgets( self ): """Remove all widgets from the body.""" if isinstance(self.body, SimpleListWalker): self.body = SimpleListWalker([]) else: raise Exception("Method only supported for SimpleListWalker") def add_widget( self, widget ): """Adds a widget to the body of this list box.""" if isinstance(self.body, SimpleListWalker): self.body.contents.append(widget) else: raise Exception("Method only supported for SimpleListWalker") class PatchedPile(urwid.Pile): _parent = None def __init__(self, widget_list, focus_item=None): # No need to call the constructor #super(PatchedPile, self).__init__(widget_list, focus_item) self.__super.__init__(widget_list, focus_item) self.widget_list = [] self.item_types = [] for _ in widget_list: add_widget(self, _) if focus_item: self.set_focus(focus_item) self.pref_col = None def add_widget( self, widget ): """Adds a widget to this pile""" w = widget self.widget_list.append(widget) if type(w) != type(()): self.item_types.append(('weight',1)) elif w[0] == 'flow': f, widget = w self.widget_list[i] = widget self.item_types.append((f,None)) elif w[0] in ('fixed', 'weight'): f, height, widget = w self.widget_list[i] = widget self.item_types.append((f,height)) else: raise PileError("widget list item invalid %s" % (w)) def remove_widget( self, widget ): """Removes a widget from this pile""" if type(widget) != type(()): widget = widget[1] i = self.widget_list.index(widget) del self.widget_list[i] del self.item_types[i] def remove_widgets( self ): """Removes all widgets from this pile""" self.widget_list = [] self.item_types = [] class PatchedColumns(urwid.Columns): _parent = None def set_focus(self, widget): """Set the column in focus with a widget in self.widget_list.""" position = self.widget_list.index(widget) if type(widget) != int else widget self.focus_col = position PatchedPile._parent = urwid.Pile PatchedListBox._parent = urwid.ListBox PatchedColumns._parent = urwid.Columns # urwid.Pile = PatchedPile # urwid.ListBox = PatchedListBox # urwid.Columns = PatchedColumns # ------------------------------------------------------------------------------ # # UI CLASS # # ------------------------------------------------------------------------------ class UISyntaxError(Exception): pass class UIRuntimeError(Exception): pass class UI: """The UI class allows to build an URWID user-interface from a simple set of string definitions. Instanciation of this class, may raise syntax error if the given text data is not formatted as expected, but you can easily get detailed information on what the problem was.""" BLANK = urwid.Text("") EMPTY = urwid.Text("") NOP = lambda self:self class Collection(object): """Keys of the given collection are recognized as attributes.""" def __init__( self, collection=None ): object.__init__(self) if collection is None: collection = {} self.w_w_content = collection def __getattr__( self, name ): if name.startswith("w_w_"): return super(UI.Collection, self).__getattribute__(name) w = self.w_w_content if name not in w: raise UIRuntimeError("No widget with name: " + name ) return w[name] def __setattr__( self, name, value): if name.startswith("w_w_"): return super(UI.Collection, self).__setattr__(name, value) if name in self.w_w_content: raise SyntaxError("Item name already used: " + name) self.w_w_content[name] = value def __init__( self ): """Creates a new user interface object from the given text description.""" self._content = None self._stack = None self._currentLine = None self._ui = None self._palette = None self._header = None self._currentSize = None self._widgets = {} self._groups = {} self._strings = {} self._data = {} self._handlers = [] self.widgets = UI.Collection(self._widgets) self.groups = UI.Collection(self._groups) self.strings = UI.Collection(self._strings) self.data = UI.Collection(self._data) def id( self, widget ): """Returns the id for the given widget.""" if hasattr(widget, "_urwideId"): return widget._urwideId else: return None def new( self, widgetClass, *args, **kwargs ): """Creates the given widget by instanciating @widgetClass with the given args and kwargs. Basically, this is equivalent to > return widgetClass(*kwargs['args'], **kwargs['kwargs']) Excepted that the widget is wrapped in an `urwid.AttrWrap` object, with the proper attributes. Also, the given @kwargs are preprocessed before being forwarded to the widget: - `data` is the text data describing ui attributes, constructor args and kwargs (in the same format as the text UI description) - `ui`, `args` and `kwargs` allow to pass preprocessed data to the constructor. In all cases, if you want to pass args and kwargs, you should explicitely use the `args` and `kwargs` arguments. I know that this is a bit confusing...""" return self._createWidget( widgetClass, *args, **kwargs ) def wrap( self, widget, properties ): """Wraps the given in the given properties.""" _ui, _, _ = self._parseAttributes(properties) return self._wrapWidget( widget, _ui ) def unwrap( self, widget ): """Unwraps the widget (see `new` method).""" if isinstance(widget, urwid.AttrWrap) and widget.w: widget = widget.w return widget # EVENT HANDLERS # ------------------------------------------------------------------------- def handler( self, handler = None ): """Sets/Gets the current event handler. This modifies the 'handler.ui' and sets it to this ui.""" if handler is None: if not self._handlers: raise UIRuntimeError("No handler defined for: %s" % (self)) return self._handlers[-1][0] else: old_ui = handler.ui handler.ui = self if not self._handlers: self._handlers.append((handler, old_ui)) else: self._handlers[-1] = (handler, old_ui) def responder( self, event ): """Returns the function that responds to the given event.""" return self.handler().responder(event) def pushHandler( self, handler ): """Push a new handler on the list of handlers. This handler will handle events until it is popped out or replaced.""" self._handlers.append((handler, handler.ui)) handler.ui = self def popHandler( self ): """Pops the current handler of the list of handlers. The handler will not handle events anymore, while the previous handler will start to handle events.""" handler, ui = self._handlers.pop() handler.ui = ui def _handle( self, event_name, widget, *args, **kwargs ): """Handle the given given event name.""" # If the event is an event name, we use the handler mechanism if type(event_name) in (str, unicode): handler = self.handler() if handler.responds(event_name): return handler.respond(event_name, widget, *args, **kwargs) elif hasattr(widget, event_name): getattr(widget, event_name, *args, **kwargs) else: raise UIRuntimeError("No handler for event: %s in %s" % (event_name, widget)) # Otherwise we assume it is a callback else: return event_name(widget, *args, **kwargs) def setTooltip( self, widget, tooltip ): widget._urwideTooltip = tooltip def setInfo( self, widget, info ): widget._urwideInfo = info def onKey( self, widget, callback ): """Sets a callback to the given widget for the 'key' event""" widget = self.unwrap(widget) widget._urwideOnKey = callback def onFocus( self, widget, callback ): """Sets a callback to the given widget for the 'focus' event""" widget = self.unwrap(widget) widget._urwideOnFocus = callback def onEdit( self, widget, callback ): """Sets a callback to the given widget for the 'edit' event""" widget = self.unwrap(widget) widget._urwideOnEdit = callback def onPress( self, widget, callback ): """Sets a callback to the given widget for the 'edit' event""" widget = self.unwrap(widget) widget._urwideOnPress = callback def _doPress( self, button, *args ): if hasattr(button, "_urwideOnPress"): event_name = button._urwideOnPress self._handle(event_name, button, *args) elif isinstance(button, urwid.RadioButton): return False else: raise UIRuntimeError("Widget does not respond to press event: %s" % (button)) def _doFocus( self, widget, ensure=True ): if hasattr(widget, "_urwideOnFocus"): event_name = widget._urwideOnFocus self._handle(event_name, widget) elif ensure: raise UIRuntimeError("Widget does not respond to focus event: %s" % (widget)) def _doEdit( self, widget, before, after, ensure=True ): if hasattr(widget, "_urwideOnEdit"): event_name = widget._urwideOnEdit self._handle(event_name, widget, before, after) elif ensure: raise UIRuntimeError("Widget does not respond to focus edit: %s" % (widget)) def _doKeyPress( self, widget, key ): # THE RULES # --------- # # 1) Widget defines an onKey event handler, it is triggered # 2) If the handler returned False, or was not existent, we # forward to the top widget # 3) The onKeyPress event is handled by the keyPress handler if the # focused widget is not editable # 4) If no keyPresss handler is defined, the default key_press event is # handled topwidget = self.getToplevel() current_widget = widget # We traverse the `original_widget` in case the widgets are nested. # This allows to get the deepest widget. stack = original_widgets(widget) # FIXME: Dialogs should prevent processing of events at a lower level if stack: for widget in stack: if hasattr(widget, "_urwideOnKey"): event_name = widget._urwideOnKey if self._handle(event_name, widget, key): return if current_widget != topwidget and current_widget not in stack: self._doKeyPress(topwidget, key) else: self._doKeyPress(None, key) elif widget and widget != topwidget: self._doKeyPress(topwidget, key) else: if key == "tab": self.focusNext() elif key == "shift tab": self.focusPrevious() if self.isEditable(self.getFocused()): res = False else: try: res = self._handle("keyPress", topwidget, key) except UIRuntimeError: res = False if not res: topwidget.keypress(self._currentSize, key) def getFocused( self ): raise Exception("Must be implemented by subclasses") def focusNext( self ): raise Exception("Must be implemented by subclasses") def focusPrevious( self ): raise Exception("Must be implemented by subclasses") def getToplevel( self ): raise Exception("Must be implemented by subclasses") def isEditable( self, widget ): return isinstance(widget, (urwid.Edit, urwid.IntEdit)) def isFocusable( self, widget ): if isinstance(widget, urwid.Edit): return True elif isinstance(widget, urwid.IntEdit): return True elif isinstance(widget, urwid.Button): return True elif isinstance(widget, urwid.CheckBox): return True elif isinstance(widget, urwid.RadioButton): return True else: return False # PARSING WIDGETS STACK MANAGEMENT # ------------------------------------------------------------------------- def _add( self, widget ): """Adds the given widget to the @_content list. This list will be added to the current parent widget when the UI is finished or when an `End` block is encountered (see @_push and @_pop)""" # Piles cannot be created with [] as content, so we fill them with the # EMPTY widget, which is replaced whenever we add something if self._content == [self.EMPTY]: self._content[0] = widget self._content.append(widget) def _push( self, endCallback, ui=None, args=(), kwargs={} ): """Pushes the given arguments (@ui, @args, @kwargs) on the stack, together with the @endCallback which will be invoked with the given arguments when an `End` block will be encountered (and that a @_pop is triggered).""" self._stack.append((self._content, endCallback, ui, args, kwargs)) self._content = [] return self._content def _pop( self ): """Pops out the widget on the top of the stack and invokes the _callback_ previously associated with it (using @_push).""" previous_content = self._content self._content, end_callback, end_ui, end_args, end_kwargs = self._stack.pop() return previous_content, end_callback, end_ui, end_args, end_kwargs # GENERIC PARSING METHODS # ------------------------------------------------------------------------- def create( self, style, ui, handler=None ): self.parseStyle(style) self.parseUI(ui) if handler: self.handler(handler) return self def parseUI( self, text ): """Parses the given text and initializes this user interface object.""" text = string.Template(text).substitute(self._strings) self._content = [] self._stack = [] self._currentLine = 0 for line in text.split("\n"): line = line.strip() if not line.startswith("#"): self._parseLine(line) self._currentLine += 1 self._listbox = self._createWidget(urwid.ListBox,self._content) return self._content def parseStyle( self, data ): """Parses the given style.""" res = [] for line in data.split("\n"): if not line.strip(): continue line = line.replace("\t", " ").replace(" ", " ") name, attributes = [_.strip() for _ in line.split(":")] res_line = [name] for attribute in attributes.split(","): attribute = attribute.strip() color = COLORS.get(attribute) if not color: raise UISyntaxError("Unsupported color: " + attribute) res_line.append(color) if len(res_line) != 4: raise UISyntaxError("Expected NAME: FOREGROUND BACKGROUND FONT") res.append(tuple(res_line)) self._palette = res return res RE_LINE = re.compile("^\s*(...)\s?") def _parseLine( self, line ): """Parses a line of the UI definition file. This automatically invokes the specialized parsers.""" if not line: self._add( self.BLANK ) return match = self.RE_LINE.match(line) if not match: raise UISyntaxError("Unrecognized line: " + line) name = match.group(1) data = line[match.end():] if hasattr(self, "_parse" + name ): getattr(self, "_parse" + name)(data) elif name[0] == name[1] == name[2]: self._parseDvd(name + data) else: raise UISyntaxError("Unrecognized widget: `" + name + "`") def _parseAttributes( self, data ): assert type(data) in (str, unicode) ui_attrs, data = self._parseUIAttributes(data) args, kwargs = self._parseArguments(data) return ui_attrs, args, kwargs RE_UI_ATTRIBUTE = re.compile("\s*([#@\?\:]|\&[\w]+\=)([\w\d_\-]+)\s*") def _parseUIAttributes( self, data ): """Parses the given UI attributes from the data and returns the rest of the data (which corresponds to something else thatn the UI attributes.""" assert type(data) in (str, unicode) ui = {"events":{}} while True: match = self.RE_UI_ATTRIBUTE.match(data) if not match: break ui_type, ui_value = match.groups() assert type(ui_value) in (str, unicode) if ui_type == "#": ui["id"] = ui_value elif ui_type == "@": ui["style"] = ui_value elif ui_type == "?": ui["info"] = ui_value elif ui_type == "!": ui["tooltip"] = ui_value elif ui_type[0] == "&": ui["events"][ui_type[1:-1]]=ui_value data = data[match.end():] return ui, data def _parseArguments( self, data ): """Parses the given text data which should be a list of attributes. This returns a dict with the attributes.""" assert type(data) in (str, unicode) def as_dict(*args, **kwargs): return args, kwargs res = eval("as_dict(%s)" % (data)) try: res = eval("as_dict(%s)" % (data)) except: raise SyntaxError("Malformed arguments: " + repr(data)) return res def hasStyle( self, *styles ): for s in styles: for r in self._palette: if r[0] == s: return s return False def _styleWidget( self, widget, ui ): """Wraps the given widget so that it belongs to the given style.""" styles = [] if "id" in ui: styles.append("#" + ui["id"]) if "style" in ui: s = ui["style"] if type(s) in (tuple, list): styles.extend(s) else: styles.append(s) styles.append( widget.__class__.__name__ ) unf_styles = [_ for _ in styles if self.hasStyle(_)] foc_styles = [_ + "*" for _ in styles if self.hasStyle(_ + "*")] if unf_styles: if foc_styles: return urwid.AttrWrap(widget, unf_styles[0], foc_styles[0]) else: return urwid.AttrWrap(widget, unf_styles[0]) else: return widget def _createWidget( self, widgetClass, *args, **kwargs ): """Creates the given widget by instanciating @widgetClass with the given args and kwargs. Basically, this is equivalent to > return widgetClass(*kwargs['args'], **kwargs['kwargs']) Excepted that the widget is wrapped in an `urwid.AttrWrap` object, with the proper attributes. Also, the given @kwargs are preprocessed before being forwarded to the widget: - `data` is the text data describing ui attributes, constructor args and kwargs (in the same format as the text UI description) - `ui`, `args` and `kwargs` allow to pass preprocessed data to the constructor. In all cases, if you want to pass args and kwargs, you should explicitely use the `args` and `kwargs` arguments. I know that this is a bit confusing...""" _data = _ui = _args = _kwargs = None for arg, value in kwargs.items(): if arg == "data": _data = value elif arg == "ui": _ui = value elif arg == "args": _args = value elif arg == "kwargs": _kwargs = value else: raise Exception("Unrecognized optional argument: " + arg) if _data: _ui, _args, _kwargs = self._parseAttributes(_data) args = list(args) if _args: args.extend(_args) kwargs = _kwargs or {} widget = widgetClass(*args, **kwargs) return self._wrapWidget(widget, _ui) def _wrapWidget( self, widget, _ui ): """Wraps the given widget into anotger widget, and applies the various properties listed in the '_ui' (internal structure).""" # And now we process the ui information if not _ui: _ui = {} if "id" in _ui: setattr(self.widgets, _ui["id"], widget) widget._urwideId = _ui["id"] if _ui.get("events"): for event, handler in _ui["events"].items(): if event == "press": if not isinstance(widget, urwid.Button)\ and not isinstance(widget, urwid.RadioButton): raise UISyntaxError("Press event only applicable to Button: " + repr(widget)) widget._urwideOnPress = handler elif event == "edit": if not isinstance(widget, urwid.Edit): raise UISyntaxError("Edit event only applicable to Edit: " + repr(widget)) widget._urwideOnEdit = handler elif event == "focus": widget._urwideOnFocus = handler elif event == "key": widget._urwideOnKey = handler else: raise UISyntaxError("Unknown event type: " + event) if _ui.get("info"): widget._urwideInfo = _ui["info"] if _ui.get("tooltip"): widget._urwideTooltip = _ui["tooltip"] return self._styleWidget( widget, _ui ) # WIDGET-SPECIFIC METHODS # ------------------------------------------------------------------------- def _argsFind( self, data ): args = data.find("args:") if args == -1: attr = "" else: attr = data[args+5:] data = data[:args] return attr, data def _parseTxt( self, data ): attr, data = self._argsFind(data) ui, args, kwargs = self._parseAttributes(attr) self._add(self._createWidget(urwid.Text,data, ui=ui, args=args, kwargs=kwargs)) def _parseHdr( self, data ): if self._header is not None: raise UISyntaxError("Header can occur only once") attr, data = self._argsFind(data) ui, args, kwargs = self._parseAttributes(attr) ui.setdefault("style", "header") self._header = self._createWidget(urwid.Text, data, ui=ui, args=args, kwargs=kwargs) RE_BTN = re.compile("\s*\[([^\]]+)\]") def _parseBtn( self, data ): match = self.RE_BTN.match(data) if not match: raise SyntaxError("Malformed button: " + repr(data)) data = data[match.end():] self._add(self._createWidget(urwid.Button, match.group(1), self._doPress, data=data)) RE_CHC = re.compile("\s*\[([xX ])\:(\w+)\](.+)") def _parseChc( self, data ): attr, data = self._argsFind(data) # Parses the declaration match = self.RE_CHC.match(data) if not match: raise SyntaxError("Malformed choice: " + repr(data)) state = match.group(1) != " " group = group_name = match.group(2).strip() group = self._groups.setdefault(group,[]) assert self._groups[group_name] == group assert getattr(self.groups,group_name) == group label = match.group(3) # Parses the attributes ui, args, kwargs = self._parseAttributes(attr) # Creates the widget self._add(self._createWidget(urwid.RadioButton, group, label, state, self._doPress, ui=ui, args=args, kwargs=kwargs)) def _parseDvd( self, data ): ui, args, kwargs = self._parseAttributes(data[3:]) self._add(self._createWidget(urwid.Divider, data, ui=ui, args=args, kwargs=kwargs)) def _parseBox( self, data ): def end( content, ui=None, **kwargs ): if not content: content = [self.EMPTY] if len(content) == 1: w = content[0] else: w = self._createWidget(urwid.Pile, content) border = kwargs.get('border') or 1 w = self._createWidget(urwid.Padding, w, ('fixed left', border), ('fixed right', border) ) # TODO: Filler does not work # w = self._createWidget(urwid.Filler, w, ('fixed top', border), ('fixed bottom', border) ) # w = urwid.Filler(w, ('fixed top', 1), ('fixed bottom',1)) self._add(w) ui, args, kwargs = self._parseAttributes(data) self._push(end, ui=ui, args=args, kwargs=kwargs) RE_EDT = re.compile("([^\[]*)\[([^\]]*)\]") def _parseEdt( self, data ): match = self.RE_EDT.match(data) data = data[match.end():] label, text = match.groups() ui, args, kwargs = self._parseAttributes(data) if label and self.hasStyle('label'): label = ('label', label) self._add(self._createWidget(urwid.Edit, label, text, ui=ui, args=args, kwargs=kwargs)) def _parsePle( self, data ): def end( content, ui=None, **kwargs ): if not content: content = [self.EMPTY] self._add(self._createWidget(urwid.Pile, content, ui=ui, kwargs=kwargs)) ui, args, kwargs = self._parseAttributes(data) self._push(end, ui=ui, args=args, kwargs=kwargs) def _parseCol( self, data ): def end( content, ui=None, **kwargs ): if not content: content = [self.EMPTY] self._add(self._createWidget(urwid.Columns, content, ui=ui, kwargs=kwargs)) ui, args, kwargs = self._parseAttributes(data) self._push(end, ui=ui, args=args, kwargs=kwargs) def _parseGFl( self, data ): def end( content, ui=None, **kwargs ): max_width = 0 # Gets the maximum width for the content for widget in content: if hasattr(widget, "get_text"): max_width = max(len(widget.get_text()), max_width) if hasattr(widget, "get_label"): max_width = max(len(widget.get_label()), max_width) kwargs.setdefault("cell_width", max_width + 4) kwargs.setdefault("h_sep", 1) kwargs.setdefault("v_sep", 1) kwargs.setdefault("align", "center") self._add(self._createWidget(urwid.GridFlow, content, ui=ui, kwargs=kwargs)) ui, args, kwargs = self._parseAttributes(data) self._push(end, ui=ui, args=args, kwargs=kwargs) def _parseLBx( self, data ): def end( content, ui=None, **kwargs ): self._add(self._createWidget(urwid.ListBox, content, ui=ui, kwargs=kwargs)) ui, args, kwargs = self._parseAttributes(data) self._push(end, ui=ui, args=args, kwargs=kwargs) def _parseEnd( self, data ): if data.strip(): raise UISyntaxError("End takes no argument: " + repr(data)) # We get the end callback that will instanciate the widget and add it to # the content. if not self._stack: raise SyntaxError("End called without container widget") end_content, end_callback, end_ui, end_args, end_kwargs = self._pop() end_callback(end_content, end_ui, *end_args, **end_kwargs) # ------------------------------------------------------------------------------ # # CONSOLE CLASS # # ------------------------------------------------------------------------------ class Console(UI): """The console class allows to create console applications that work 'full screen' within a terminal.""" def __init__( self ): UI.__init__(self) self._ui = None self._frame = None self._header = None self._footer = None self._listbox = None self._dialog = None self._tooltiptext = "" self._infotext = "" self._footertext = "" self.isRunning = False self.endMessage = "" self.endStatus = 1 # USER INTERACTION API # ------------------------------------------------------------------------- def tooltip( self, text=-1 ): """Sets/Gets the current tooltip text.""" if text == -1: return self._tooltiptext else: self._tooltiptext = ensureUnicode(text) def info( self, text=-1 ): """Sets/Gets the current info text.""" if text == -1: return self._infotext else: self._infotext = ensureUnicode(text) def footer( self, text=-1 ): """Sets/Gets the current footer text.""" if text == -1: return self._footertext else: self._footertext = ensureUnicode(text) def dialog( self, dialog ): """Sets the dialog as this UI dialog. All events will be forwarded to the dialog until exit.""" self._dialog = dialog # WIDGET INFORMATION # ------------------------------------------------------------------------- def getFocused( self ): """Gets the focused widget""" # We get the original widget to focus on focused = original_widget(self._listbox.get_focus()[0]) old_focused = None while focused != old_focused: old_focused = focused # There are some types that are not focuable if isinstance(focused, urwid.AttrWrap): if focused.w: focused = focused.w elif isinstance(focused, urwid.Padding): if focused.min_width: focused = focused.min_width elif isinstance(focused, urwid.Filler): if focused.w: focused = focused.w elif hasattr(focused, "get_focus"): if focused.get_focus(): focused = focused.get_focus() return focused def focusNext( self ): focused = self._listbox.get_focus()[1] + 1 self._listbox.set_focus(focused) while True: if not self.isFocusable(self.getFocused()) \ and self._listbox.body.get_next(focused)[0] is not None: focused += 1 self._listbox.set_focus(focused) else: break def focusPrevious( self ): focused = max(self._listbox.get_focus()[1] - 1, 0) self._listbox.set_focus(focused) while True: if not self.isFocusable(self.getFocused()) \ and focused > 0: focused -= 1 self._listbox.set_focus(focused) else: break def getToplevel( self ): """Returns the toplevel widget, which may be a dialog's view, if there was a dialog.""" if self._dialog: return self._dialog.view() else: return self._frame def getCurrentSize( self ): """Returns the current size for this UI as a couple.""" return self._currentSize # URWID EVENT-LOOP # ------------------------------------------------------------------------- def main( self ): """This is the main event-loop. That is what you should invoke to start your application.""" #self._ui = urwid.curses_display.Screen() self._ui = urwid.raw_display.Screen() self._ui.clear() if self._palette: self._ui.register_palette(self._palette) self._ui.run_wrapper( self.run ) # We clear the screen (I know, I should use URWID, but that was the # quickest way I found) curses.setupterm() sys.stdout.write(curses.tigetstr('clear').decode()) if self.endMessage: print (self.endMessage) return self.endStatus def run( self ): """Run function to be used by URWID. You should not call it directly, use the 'main' function instead.""" #self._ui.set_mouse_tracking() self._currentSize = self._ui.get_cols_rows() self.isRunning = True while self.isRunning: self._currentSize = self._ui.get_cols_rows() self.loop() def end( self, msg=None, status=1 ): """Ends the application, registering the given 'msg' as end message, and returning the given 'status' ('1' by default).""" self.isRunning = False self.endMessage = msg self.endStatus = status def loop( self ): """This is the main URWID loop, where the event processing and dispatching is done.""" # We get the focused element, and update the info and and tooltip if self._dialog: focused = self._dialog.view() else: focused = self.getFocused() or self._frame # We trigger the on focus event self._doFocus(focused, ensure=False) # We update the tooltip and info in the footer if hasattr(focused, "_urwideInfo"): self.info(self._strings.get(focused._urwideInfo) or focused._urwideInfo) if hasattr(focused, "_urwideTooltip"): self.tooltip(self._strings.get(focused._urwideTooltip) or focused._urwideTooltip) # We draw the screen self._updateFooter() self.draw() self.tooltip("") self.info("") # And process keys if not self.isRunning: return keys = self._ui.get_input() if isinstance(focused, urwid.Edit): old_text = focused.get_edit_text() # We handle keys for key in keys: #if urwid.is_mouse_event(key): # event, button, col, row = key # self.view.mouse_event( self._currentSize, event, button, col, row, focus=True ) #pass # NOTE: The key press might actually be send not to the focused # widget but to its original_widget if key == "window resize": self._currentSize = self._ui.get_cols_rows() elif self._dialog: self._doKeyPress(self._dialog.view(), key) else: self._doKeyPress(focused, key) # We check if there was a change in the edit, and we fire and event if isinstance(focused, urwid.Edit): self._doEdit( focused, old_text, focused.get_edit_text(), ensure=False) def draw( self ): """Main loop to draw the console. This takes into account the fact that there may be a dialog to display.""" if self._dialog is not None: o = urwid.Overlay( self._dialog.view(), self._frame, "center", self._dialog.width(), "middle", self._dialog.height() ) canvas = o.render( self._currentSize, focus=True ) else: canvas = self._frame.render( self._currentSize, focus=True ) self._ui.draw_screen( self._currentSize, canvas ) def _updateFooter(self): """Updates the frame footer according to info and tooltip""" remove_widgets(self._footer) footer = [] if self.tooltip(): footer.append(self._styleWidget(urwid.Text(self.tooltip()), {'style':'tooltip'})) if self.info(): footer.append(self._styleWidget(urwid.Text(self.info()), {'style':'info'})) if self.footer(): footer.append(self._styleWidget(urwid.Text(self.footer()), {'style':'footer'})) if footer: for _ in footer: add_widget(self._footer, _) self._footer.set_focus(0) def parseUI( self, text ): """Parses the given text and initializes this user interface object.""" UI.parseUI(self, text) self._listbox = self._createWidget(urwid.ListBox,self._content) self._footer = urwid.Pile([self.EMPTY]) self._frame = self._createWidget(urwid.Frame, self._listbox, self._header, self._footer ) return self._content def _parseFtr( self, data ): self.footer(data) # ------------------------------------------------------------------------------ # # DIALOG CLASSES # # ------------------------------------------------------------------------------ class Dialog(UI): """Utility class to create dialogs that will fit within a console application. See the constructor documentation for more information.""" PALETTE = """ dialog : BL, Lg, SO dialog.shadow : DB, BL, SO dialog.border : Lg, DB, SO """ def __init__( self, parent, ui, width=40, height=-1, style="dialog", header="", palette=""): """Creates a new dialog that will be attached to the given 'parent'. The user interface is described by the 'ui' string. The dialog 'width' and 'height' will indicate the dialog size, when 'height' is '-1', it will be automatically computed from the given 'ui'.""" UI.__init__(self) if height == -1: height = ui.count("\n") + 1 self._width = width self._height = height self._style = style self._view = None self._headertext = header self._parent = parent self._startCallback = lambda x:x self._endCallback = lambda x:x self._palette = None self.make(ui, palette) def width( self ): """Returns the dialog width""" return self._width def height( self ): """Returns the dialog height""" return self._height def view( self ): """Returns the view attached to this 'Dialog'. The _view_ is created by the 'make' method, and is an 'urwid.Frame' instance.""" assert self._view return self._view def make( self, uitext, palui=None ): """Makes the dialog using a UI description ('uitext') and a style definition for the palette ('palui'), which can be 'None', in which case the value will be 'Dialog.PALETTE'.""" if not palui: palui = self.PALETTE self.parseStyle(palui) style = self._styleWidget assert self._view is None content = [] if self._headertext: content.append(style(urwid.Text(self._headertext), {'style':(self._style +'.header', "dialog.header", 'header')})) content.append(urwid.Text("")) content.append(urwid.Divider("_")) content.extend(self.parseUI(uitext)) w = style(urwid.ListBox(content), {'style':(self._style +'.content', "dialog.content", self._style)}) # We wrap the dialog into a box w = urwid.Padding(w, ('fixed left', 1), ('fixed right', 1)) #w = urwid.Filler(w, ('fixed top', 1), ('fixed bottom',1)) w = style(w, {'style':(self._style+".body", "dialog.body", self._style)} ) w = style( w, {'style':(self._style, "dialog")} ) # Shadow shadow = self.hasStyle( self._style + ".shadow", "dialog.shadow", "shadow") border = self.hasStyle( self._style + ".border", "dialog.border", "border") if shadow: border = (border, ' ') if border else ' ' w = urwid.Columns([w,('fixed', 2, urwid.AttrWrap(urwid.Filler(urwid.Text(border), "top") ,shadow))]) w = urwid.Frame( w, footer = urwid.AttrWrap(urwid.Text(border),shadow)) self._view = w self._startCallback(self) w._urwideOnKey = self.doKeyPress def onStart( self, callback ): """Registers the callback that will be triggered on dialog start.""" self._startCallback = callback def onEnd( self, callback ): """Registers the callback that will be triggered on dialog end.""" self._endCallback = callback def doKeyPress( self, widget, key ): self._handle("keyPress", widget, key) def end( self ): """Call this to close the dialog.""" self._endCallback(self) self._parent._dialog = None def _parseHdr( self, data ): if self._header is not None: raise UISyntaxError("Header can occur only once") attr, data = self._argsFind(data) ui, args, kwargs = self._parseAttributes(attr) ui.setdefault("style", ("dialog.header", "header") ) self._content.append( self._createWidget(urwid.Text, data, ui=ui, args=args, kwargs=kwargs)) # ------------------------------------------------------------------------------ # # HANDLER CLASS # # ------------------------------------------------------------------------------ FORWARD = False class Handler(object): """A handler can be subclassed an can be plugged into a UI to react to a specific set of events. The interest of handlers is that they can be dynamically switched, then making "modal UI" implementation easier. For instance, you could have a handler for your UI in "normal mode", and have another handler when a dialog box is displayed.""" def __init__( self ): self.ui = None def respond( self, event, *args, **kwargs ): """Responds to the given event name. An exception must be raised if the event cannot be responded to. False is returned if the handler does not want to handle the event, True if the event was handled.""" responder = self.responder(event) return responder(*args, **kwargs) != FORWARD def responds( self, event ): """Tells if the handler responds to the given event.""" _event_name = "on" + event[0].upper() + event[1:] if hasattr(self, _event_name): return _event_name else: return None def responder( self, event ): """Returns the function that responds to the given event.""" _event_name = "on" + event[0].upper() + event[1:] if not hasattr(self, _event_name): raise UIRuntimeError("Event not implemented: " + event) res = getattr(self, _event_name) assert res return res # EOF - vim: tw=80 ts=4 sw=4 noet ================================================ FILE: src/logs/log_utils.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import logging import json from logging.handlers import RotatingFileHandler def log_json(tojson: object) -> str: return json.dumps(tojson, indent=2, sort_keys=True) class NewLineFileHandler(RotatingFileHandler): """Handler that controls the writing of the newline character""" special_code = '[!n]' def emit(self, record) -> None: if self.special_code in record.msg: record.msg = record.msg.replace( self.special_code, '' ) self.terminator = '' else: self.terminator = '\n' return super().emit(record) class NewLineStreamHandler(logging.StreamHandler): """Handler that controls the writing of the newline character""" special_code = '[!n]' def emit(self, record) -> None: if self.special_code in record.msg: record.msg = record.msg.replace( self.special_code, '' ) self.terminator = '' else: self.terminator = '\n' return super().emit(record) ================================================ FILE: src/logs/logger.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import logging from logging.handlers import RotatingFileHandler import os from logs.log_utils import NewLineFileHandler, NewLineStreamHandler file_log_format = logging.Formatter("%(asctime)s %(levelname)s %(funcName)s(%(lineno)d) %(message)s") stream_log_format = logging.Formatter("%(asctime)s %(message)s", "%H:%M:%S") logFile = "info.log" file_handler = RotatingFileHandler(logFile, mode="a", maxBytes=15 * 1024 * 1024, backupCount=2, encoding=None, delay=0) file_handler.setFormatter(file_log_format) stream_handler = logging.StreamHandler() stream_handler.setFormatter(stream_log_format) log = logging.getLogger(__name__) if "DEBUG" in os.environ: log.setLevel(logging.DEBUG) print("debug logging") else: log.setLevel(logging.INFO) print("info loggin") log.addHandler(stream_handler) log.addHandler(file_handler) ================================================ FILE: src/menu.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import pyfiglet import sys from logs.logger import log from config import config_menu from libs import urwide import bot # This is the description of the actual interface CONSOLE_STYLE = """""" CONSOLE_UI = """\ Hdr Header args:#header --- Txt Status: stopped args:#status === GFl Btn [Start] #start &press=started Btn [Config] &press=config Btn [Exit] &press=exit End """ # add when working: Btn [Log] &press=log # This is the handling code, providing the logic class Handler(urwide.Handler): def onStarted( self, button ): if self.ui.widgets.status.text == "Status: stopped": bot.run() self.ui.widgets.status.set_text("Status: started") else: self.ui.widgets.status.set_text("Status: stopped") def onConfig( self, button ): config_menu.run() def onExit( self, button ): self.ui.end("Exit") log.info("Exiting Karma Bot Menu: Bye! :D") sys.exit() # We create a console application ui = urwide.Console() ui.create(CONSOLE_STYLE, CONSOLE_UI, Handler()) ui.widgets.header.set_text("Reddit Karma Farming Bot") # bring this back later pyfiglet.figlet_format("Reddit Karma Farming Bot", font="slant") def run(): ui.main() if __name__ == "__main__": run() ================================================ FILE: src/tests/__init__.py ================================================ ================================================ FILE: src/tests/test_utils.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- from .. import utils def test_random_string(): string = utils.random_string(5) assert type(string) is str assert len(string) == 5 ================================================ FILE: src/utils.py ================================================ #!/usr/bin/env python3 # -*- coding: UTF-8 -*- import os import collections import random import time import string from config.common_config import ENVAR_PREFIX from logs.logger import log import urllib.request ## HELPER VARS DAY = 86400 MONTH = 2678400 YEAR = 31536000 def random_string(length: int) -> str: letters = string.ascii_lowercase return ''.join(random.choice(letters) for i in range(length)) def prefer_envar(configs: dict) -> dict: for config in list(configs): config_envar = f"{ENVAR_PREFIX}{config}".lower() if os.environ.get(config_envar): configs[config]=os.environ.get(config_envar) log.info(f"loading {config_envar} from envar. Value: {configs.get(config)}") else: log.debug(f"no environment config for: {config_envar}") return configs # Checks if the machine has internet and also can connect to reddit def check_internet(host="https://reddit.com", timeout=5): try: urllib.request.urlopen(host, None, timeout) return True except Exception as ex: log.error(ex) return False def get_public_ip(): try: external_ip = urllib.request.urlopen("https://api.ipify.org") if external_ip: return external_ip.read().decode("utf-8") except Exception as e: log.error("could not check external ip") def bytesto(bytes, to, bsize=1024): """convert bytes to megabytes, etc. sample code: print('mb= ' + str(bytesto(314575262000000, 'm'))) sample output: mb= 300002347.946 """ a = {"k": 1, "m": 2, "g": 3, "t": 4, "p": 5, "e": 6} r = float(bytes) for i in range(a[to]): r = r / bsize return round(r) def is_past_one_day(time_to_compare): return int(time.time()) - time_to_compare >= DAY def countdown(seconds=1): # log.info("sleeping: " + str(seconds) + " seconds") # for i in range(seconds, 0, -1): # # print("\x1b[2K\r" + str(i) + " ") # time.sleep(3) # log.info("waking up") time.sleep(seconds) def chance(value=.20): rando = random.random() # log.info("prob: " + str(value) + " rolled: " + str(rando)) return rando < value def tobytes(size_str): """Convert human filesizes to bytes. https://stackoverflow.com/questions/44307480/convert-size-notation-with-units-100kb-32mb-to-number-of-bytes-in-python Special cases: - singular units, e.g., "1 byte" - byte vs b - yottabytes, zetabytes, etc. - with & without spaces between & around units. - floats ("5.2 mb") To reverse this, see hurry.filesize or the Django filesizeformat template filter. :param size_str: A human-readable string representing a file size, e.g., "22 megabytes". :return: The number of bytes represented by the string. """ multipliers = { 'kilobyte': 1024, 'megabyte': 1024 ** 2, 'gigabyte': 1024 ** 3, 'kb': 1024, 'mb': 1024**2, 'gb': 1024**3, } for suffix in multipliers: size_str = size_str.lower().strip().strip('s') if size_str.lower().endswith(suffix): return int(float(size_str[0:-len(suffix)]) * multipliers[suffix]) else: if size_str.endswith('b'): size_str = size_str[0:-1] elif size_str.endswith('byte'): size_str = size_str[0:-4] return int(size_str)