├── mis_bot ├── misbot │ ├── __init__.py │ ├── analytics.py │ ├── states.py │ ├── until_func.py │ ├── message_strings.py │ ├── decorators.py │ ├── push_notifications.py │ ├── spider_functions.py │ ├── attendance_target.py │ ├── bunk.py │ ├── mis_utils.py │ ├── general.py │ └── admin.py ├── scraper │ ├── __init__.py │ ├── spiders │ │ ├── __init__.py │ │ ├── profile_spider.py │ │ ├── results_spider.py │ │ ├── itinerary_spider.py │ │ └── attendance_spider.py │ ├── items.py │ ├── database.py │ ├── middlewares.py │ ├── settings.py │ ├── models.py │ └── pipelines.py ├── example.env ├── scrapy.cfg └── telegram_bot.py ├── docs ├── requirements-docs.txt ├── source │ ├── admin.rst │ ├── bunk.rst │ ├── decorators.rst │ ├── mis_utils.rst │ ├── general.rst │ ├── attendance_target.rst │ ├── spider_results.rst │ ├── push_notifications.rst │ ├── spider_itinerary.rst │ ├── spider_attendance.rst │ ├── until_func.rst │ ├── spider_functions.rst │ ├── index.rst │ ├── installation.rst │ └── conf.py └── Makefile ├── media ├── avatar.png ├── bunk_func.png ├── avatar-ico.ico └── until80_func.png ├── .dockerignore ├── .readthedocs.yml ├── Dockerfile ├── requirements.txt ├── docker-compose.yml ├── LICENSE.md ├── .gitignore └── README.md /mis_bot/misbot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mis_bot/scraper/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements-docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=1.8.4 2 | sphinx-rtd-theme==0.4.3 -------------------------------------------------------------------------------- /media/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArionMiles/MIS-Bot/HEAD/media/avatar.png -------------------------------------------------------------------------------- /docs/source/admin.rst: -------------------------------------------------------------------------------- 1 | Admin 2 | ===== 3 | 4 | .. automodule:: misbot.admin 5 | :members: -------------------------------------------------------------------------------- /media/bunk_func.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArionMiles/MIS-Bot/HEAD/media/bunk_func.png -------------------------------------------------------------------------------- /media/avatar-ico.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArionMiles/MIS-Bot/HEAD/media/avatar-ico.ico -------------------------------------------------------------------------------- /media/until80_func.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArionMiles/MIS-Bot/HEAD/media/until80_func.png -------------------------------------------------------------------------------- /docs/source/bunk.rst: -------------------------------------------------------------------------------- 1 | Bunk Command 2 | ============ 3 | 4 | .. automodule:: misbot.bunk 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /docs/source/decorators.rst: -------------------------------------------------------------------------------- 1 | Decorators 2 | ========== 3 | 4 | .. automodule:: misbot.decorators 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /docs/source/mis_utils.rst: -------------------------------------------------------------------------------- 1 | MIS Functions 2 | ============= 3 | 4 | .. automodule:: misbot.mis_utils 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | __pycache__ 3 | venv/ 4 | venvwsl/ 5 | media/ 6 | .vscode 7 | chats_migrations/ 8 | docs/ 9 | example.env 10 | -------------------------------------------------------------------------------- /docs/source/general.rst: -------------------------------------------------------------------------------- 1 | General Functions 2 | ================= 3 | 4 | .. automodule:: misbot.general 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # syntax: https://docs.readthedocs.io/en/latest/yaml-config.html 2 | 3 | formats: 4 | - pdf 5 | 6 | python: 7 | version: 3.6 8 | -------------------------------------------------------------------------------- /docs/source/attendance_target.rst: -------------------------------------------------------------------------------- 1 | Attendance Target 2 | ================= 3 | 4 | .. automodule:: misbot.attendance_target 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /docs/source/spider_results.rst: -------------------------------------------------------------------------------- 1 | Results Spider 2 | ============== 3 | 4 | .. automodule:: scraper.spiders.results_spider 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /mis_bot/example.env: -------------------------------------------------------------------------------- 1 | TOKEN= 2 | SPLASH_INSTANCE=http://splash:8050 3 | URL= 4 | ADMIN_CHAT_ID= 5 | MIXPANEL_TOKEN= 6 | PAYMENT_LINK= 7 | GPAY_REFERRAL_CODE= 8 | -------------------------------------------------------------------------------- /docs/source/push_notifications.rst: -------------------------------------------------------------------------------- 1 | Push Notifications 2 | ================== 3 | 4 | .. automodule:: misbot.push_notifications 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /docs/source/spider_itinerary.rst: -------------------------------------------------------------------------------- 1 | Itinerary Spider 2 | ================ 3 | 4 | .. automodule:: scraper.spiders.itinerary_spider 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /docs/source/spider_attendance.rst: -------------------------------------------------------------------------------- 1 | Attendance Spider 2 | ================= 3 | 4 | .. automodule:: scraper.spiders.attendance_spider 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /docs/source/until_func.rst: -------------------------------------------------------------------------------- 1 | Until80 and similar functions 2 | ============================= 3 | 4 | .. automodule:: misbot.until_func 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /docs/source/spider_functions.rst: -------------------------------------------------------------------------------- 1 | Attendance, Itinerary, Result Functions 2 | ======================================= 3 | 4 | .. automodule:: misbot.spider_functions 5 | :members: 6 | :undoc-members: -------------------------------------------------------------------------------- /mis_bot/misbot/analytics.py: -------------------------------------------------------------------------------- 1 | """Mixpanel analytics dashboard object. Used to track events.""" 2 | from os import environ 3 | from mixpanel import Mixpanel 4 | 5 | mp = Mixpanel(environ.get('MIXPANEL_TOKEN')) 6 | -------------------------------------------------------------------------------- /mis_bot/scraper/spiders/__init__.py: -------------------------------------------------------------------------------- 1 | # This package will contain the spiders of your Scrapy project 2 | # 3 | # Please refer to the documentation for information on how to create and manage 4 | # your spiders. 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6.8-stretch 2 | 3 | RUN mkdir /app/ 4 | 5 | COPY requirements.txt /app 6 | 7 | WORKDIR /app 8 | 9 | # Install dependencies 10 | RUN pip install -r requirements.txt 11 | 12 | # Start the bot 13 | CMD ["python", "telegram_bot.py"] 14 | -------------------------------------------------------------------------------- /mis_bot/scrapy.cfg: -------------------------------------------------------------------------------- 1 | # Automatically created by: scrapy startproject 2 | # 3 | # For more information about the [deploy] section see: 4 | # https://scrapyd.readthedocs.org/en/latest/deploy.html 5 | 6 | [settings] 7 | default = scraper.settings 8 | 9 | [deploy] 10 | #url = http://localhost:6800/ 11 | project = MIS 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-telegram-bot==11.1.0 2 | Scrapy==1.4.0 3 | scrapy-splash==0.7.2 4 | SQLAlchemy==1.3.0 5 | sympy==1.1.1 6 | requests==2.20.0 7 | Pillow==5.0.0 8 | python-dotenv==0.10.1 9 | https://github.com/sampritipanda/securimage_solver/archive/pip_package.zip 10 | SQLAlchemy-Utils==0.33.11 11 | mixpanel==4.5.0 -------------------------------------------------------------------------------- /mis_bot/scraper/items.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Define here the models for your scraped items 4 | # 5 | # See documentation in: 6 | # http://doc.scrapy.org/en/latest/topics/items.html 7 | 8 | from scrapy import Item, Field 9 | 10 | class Lectures(Item): 11 | subject = Field() 12 | conducted = Field() 13 | attended = Field() 14 | 15 | class Practicals(Item): 16 | subject = Field() 17 | conducted = Field() 18 | attended = Field() 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | splash: 4 | image: "scrapinghub/splash:latest" 5 | ports: 6 | - "8050:8050" 7 | 8 | misbot: 9 | build: 10 | context: . 11 | dockerfile: ./Dockerfile 12 | image: misbot.v4.2 13 | ports: 14 | - "8443:8443" 15 | links: 16 | - "splash:splash" 17 | depends_on: 18 | - splash 19 | volumes: 20 | - ./mis_bot/:/app 21 | - "/etc/localtime:/etc/localtime:ro" 22 | -------------------------------------------------------------------------------- /mis_bot/misbot/states.py: -------------------------------------------------------------------------------- 1 | """States for ConversationHandlers""" 2 | 3 | # general.py 4 | CREDENTIALS, PARENT_LGN = range(2) 5 | 6 | # admin.py 7 | NOTIF_MESSAGE, NOTIF_CONFIRM = range(2) 8 | ASK_UUID, CONFIRM_REVERT = range(2) 9 | 10 | # bunk.py 11 | CHOOSING, INPUT, CALCULATING = range(3) 12 | 13 | # attendance_target.py 14 | SELECT_YN, INPUT_TARGET = range(2) 15 | UPDATE_TARGET = 0 16 | 17 | # make_premium 18 | ASK_USERNAME, CONFIRM_USER, INPUT_TIER, INPUT_VALIDITY, CONFIRM_OTP = range(5) 19 | 20 | # extend_premium 21 | EXTEND_ASK_USERNAME, EXTEND_CONFIRM_USER, EXTEND_INPUT_DAYS = range(3) 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. MIS Bot documentation master file, created by 2 | sphinx-quickstart on Thu Feb 14 19:30:18 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | MIS Bot Documentation 7 | ===================== 8 | 9 | .. toctree:: 10 | :maxdepth: 3 11 | :caption: Installation 12 | 13 | installation 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | :caption: Bot: 18 | 19 | admin 20 | attendance_target 21 | bunk 22 | decorators 23 | general 24 | mis_utils 25 | push_notifications 26 | spider_functions 27 | until_func 28 | 29 | .. toctree:: 30 | :maxdepth: 2 31 | :caption: Spiders: 32 | 33 | spider_attendance 34 | spider_itinerary 35 | spider_results 36 | 37 | 38 | Indices and tables 39 | ================== 40 | 41 | * :ref:`genindex` 42 | * :ref:`modindex` 43 | * :ref:`search` 44 | -------------------------------------------------------------------------------- /mis_bot/scraper/database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import scoped_session, sessionmaker 3 | from sqlalchemy.ext.declarative import declarative_base 4 | from sqlalchemy.pool import StaticPool 5 | 6 | # Database 7 | engine = create_engine('sqlite:///files/chats.db', convert_unicode=True, 8 | connect_args= {'check_same_thread': False}, 9 | poolclass=StaticPool) 10 | db_session = scoped_session(sessionmaker(autocommit=False, 11 | autoflush=False, 12 | bind=engine)) 13 | Base = declarative_base() 14 | Base.query = db_session.query_property() 15 | 16 | def init_db(): 17 | # import all modules here that might define models so that 18 | # they will be registered properly on the metadata. Otherwise 19 | # you will have to import them first before calling init_db() 20 | import scraper.models 21 | Base.metadata.create_all(bind=engine) -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2017 Kanishk Singh 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /mis_bot/scraper/middlewares.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Define here the models for your spider middleware 4 | # 5 | # See documentation in: 6 | # http://doc.scrapy.org/en/latest/topics/spider-middleware.html 7 | 8 | from scrapy import signals 9 | 10 | 11 | class MisSpiderMiddleware(object): 12 | # Not all methods need to be defined. If a method is not defined, 13 | # scrapy acts as if the spider middleware does not modify the 14 | # passed objects. 15 | 16 | @classmethod 17 | def from_crawler(cls, crawler): 18 | # This method is used by Scrapy to create your spiders. 19 | s = cls() 20 | crawler.signals.connect(s.spider_opened, signal=signals.spider_opened) 21 | return s 22 | 23 | def process_spider_input(self, response, spider): 24 | # Called for each response that goes through the spider 25 | # middleware and into the spider. 26 | 27 | # Should return None or raise an exception. 28 | return None 29 | 30 | def process_spider_output(self, response, result, spider): 31 | # Called with the results returned from the Spider, after 32 | # it has processed the response. 33 | 34 | # Must return an iterable of Request, dict or Item objects. 35 | for i in result: 36 | yield i 37 | 38 | def process_spider_exception(self, response, exception, spider): 39 | # Called when a spider or process_spider_input() method 40 | # (from other spider middleware) raises an exception. 41 | 42 | # Should return either None or an iterable of Response, dict 43 | # or Item objects. 44 | pass 45 | 46 | def process_start_requests(self, start_requests, spider): 47 | # Called with the start requests of the spider, and works 48 | # similarly to the process_spider_output() method, except 49 | # that it doesn’t have a response associated. 50 | 51 | # Must return only requests (not items). 52 | for r in start_requests: 53 | yield r 54 | 55 | def spider_opened(self, spider): 56 | spider.logger.info('Spider opened: %s' % spider.name) 57 | -------------------------------------------------------------------------------- /mis_bot/misbot/until_func.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | from misbot.decorators import signed_up, premium 4 | from misbot.mis_utils import until_x 5 | 6 | @signed_up 7 | @premium(tier=1) 8 | def until_eighty(bot, update): 9 | """Calculate number of lectures you must consecutively attend before you attendance is 80% 10 | 11 | If :py:func:`misbot.mis_utils.until_x` returns a negative number, attendance is already over 80% 12 | 13 | :param bot: Telegram Bot object 14 | :type bot: telegram.bot.Bot 15 | :param update: Telegram Update object 16 | :type update: telegram.update.Update 17 | """ 18 | bot.send_chat_action(chat_id=update.message.chat_id, action='typing') 19 | no_of_lectures = int(until_x(update.message.chat_id, 80)) 20 | if no_of_lectures < 0: 21 | bot.sendMessage(chat_id=update.message.chat_id, text="Your attendance is already over 80%. Relax.") 22 | else: 23 | messageContent = "No. of lectures to attend: {}".format(no_of_lectures) 24 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent) 25 | 26 | @signed_up 27 | @premium(tier=1) 28 | def until(bot, update, args): 29 | """Like :py:func:`until_eighty` but user supplies the number. 30 | 31 | :param bot: Telegram Bot object 32 | :type bot: telegram.bot.Bot 33 | :param update: Telegram Update object 34 | :type update: telegram.update.Update 35 | :param args: User supplied arguments 36 | :type args: tuple 37 | :return: None 38 | :rtype: None 39 | """ 40 | if len(args) == 0: 41 | messageContent = textwrap.dedent(""" 42 | You must specify a number after the command to use this feature. 43 | 44 | E.g: `/until 75` 45 | """) 46 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, parse_mode='markdown') 47 | return 48 | 49 | try: 50 | figure = float(args[0]) 51 | except (ValueError, IndexError): 52 | bot.sendMessage(chat_id=update.message.chat_id, text="You must send a number between 1-99.") 53 | return 54 | 55 | if figure > 99: 56 | bot.sendMessage(chat_id=update.message.chat_id, text="You must send a number between 1-99.") 57 | return 58 | 59 | no_of_lectures = int(until_x(update.message.chat_id, figure)) 60 | if no_of_lectures < 0: 61 | bot.sendMessage(chat_id=update.message.chat_id, text="Your attendance is already over {}%. Relax.".format(figure)) 62 | else: 63 | messageContent = "No. of lectures to attend: {}".format(no_of_lectures) 64 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent) 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | MIS_venv/ 61 | 62 | MIS/attendance\.json 63 | 64 | MIS/Xpath_Attendance\.txt 65 | 66 | MIS/MIS/spiders/creds\.ini 67 | 68 | MIS/attendance2\.json 69 | 70 | MIS/attendance\.csv 71 | 72 | tree\.txt 73 | 74 | MIS/MIS/test2\.py 75 | 76 | MIS/MIS/test\.py 77 | 78 | MIS/MIS/Notes\.txt 79 | 80 | MIS/attendance_report\.json 81 | 82 | data_utf8\.json 83 | 84 | MIS/attendance_output\.json 85 | 86 | MIS/test3\.py 87 | 88 | MIS/test2\.py 89 | 90 | MIS\.zip 91 | 92 | MIS/old_report\.json 93 | 94 | MIS/MIS/test3\.py 95 | 96 | MIS/MIS/telegram_bot\.py 97 | 98 | attendance_output\.json 99 | 100 | MIS/Notes\.txt 101 | 102 | MIS/spiders/creds\.ini 103 | 104 | old_report\.json 105 | 106 | MIS/spiders/test\.txt 107 | 108 | test\.bat 109 | 110 | *.json 111 | 112 | *.db 113 | 114 | /misbot/ 115 | Untitled Diagram\.xml 116 | 117 | venv/ 118 | 119 | scrapyd-client/ 120 | 121 | setup\.py 122 | 123 | mis-bot/models\.bak\.py 124 | 125 | *.png 126 | 127 | mis-bot/table_scraper\.py 128 | 129 | mis-bot/scraper/captcha\.py 130 | 131 | *.sublime-workspace 132 | 133 | *.sublime-project 134 | 135 | mis-bot/Untitled\.ipynb 136 | 137 | mis-bot/testCaptcha\.py 138 | 139 | mis-bot/scraper/\.ipynb_checkpoints/ 140 | 141 | mis-bot/files/crop\.py 142 | 143 | venvwsl/ 144 | 145 | chats_migrations/ 146 | 147 | mis-bot/files/cert\.pem 148 | 149 | mis-bot/files/private\.key 150 | 151 | mis-bot/\.env 152 | 153 | mis_bot/\.env 154 | 155 | mis_bot/files/cert\.pem 156 | 157 | mis_bot/files/private\.key 158 | 159 | mis_bot/push_alerts\.py 160 | 161 | mis_bot/scraper/captcha\.py 162 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MIS Bot 2 | 3 | [![Telegram Bot](https://img.shields.io/badge/Telegram-Bot-blue.svg)](https://t.me/SJCET_MIS_BOT) 4 | [![Telegram Channel](https://img.shields.io/badge/Telegram-Channel-blue.svg)](https://t.me/joinchat/AAAAAEzdjHzLCzMiKpUw6w) 5 | 6 | # About 7 | I created this bot as a means to avoid Defaulter's List, and I hope this bot can help others avoid it, too. The bot is hosted on the Google Cloud Platform (GCP). 8 | 9 | ## Features 10 | * **CAPTCHA Bypass** 11 | 12 | The bot can automatically answer the captcha code for logging in. Uses [securimage_solver](https://github.com/sampritipanda/securimage_solver) library. 13 | * **Bunk Calculator** 14 | 15 | Calculate rise/drop in your overall percentage. 16 | 17 | [![Bunk calculator formula](media/bunk_func.png)]() 18 | 19 | where 20 | 21 | a = Total lectures attended 22 | 23 | b = Total lectures conducted on the day of bunk 24 | 25 | c = Total lectures conducted so far 26 | 27 | n = number of lectures to bunk 28 | * **Until80** 29 | 30 | Shows the number of lectures one must consecutively attend in order to get their Overall Attendance to 80%. It is the minimum percentage of overall lectures one must attend to avoid the defaulter's list. 31 | 32 | [![Until80 formula](media/until80_func.png)]() 33 | 34 | where 35 | 36 | a = Total lectures attended 37 | 38 | c = Total lectures conducted so far 39 | 40 | x = number of lectures to attend 41 | **Note:** We calculate `x` from this equation. Value of `x` can be negative too, when your attendance is already over 80. 42 | * **Until X** 43 | 44 | Like Until80 but you specify the percentage. 45 | 46 | * **Target Attendance** 47 | 48 | Set a target of attendance percentage for yourself and we'll remind you how long you've left to go before fulfilling your target. 49 | 50 | * **Results** 51 | 52 | Fetch results of Class Tests. Uses scrapy-splash library. 53 | 54 | 55 | 56 | ## Installation and Documentation 57 | Read the documentation at mis-bot.readthedocs.io on getting this bot up and running for yourself. 58 | 59 | # Roadmap 60 | * ~Attendance scraper~ 61 | * ~Bunk/Until80 functions~ 62 | * ~Allow registration~ 63 | * ~Results scraper~ 64 | * ~Store attendance data in a database~ 65 | 66 | # Contributors 67 | * [Arush Ahuja (arush15june)](https://github.com/arush15june) 68 | * [Vikas Yadav (v1k45)](https://github.com/v1k45) 69 | * [Sampriti Panda](https://github.com/sampritipanda) 70 | * [Sabine Wieluch (bleeptrack)](https://github.com/bleeptrack) (Gave us a really cute [Profile Photo](media/avatar.png)!) 71 | * [Amey Khale (Noctis0712)](https://github.com/Noctis0712) 72 | 73 | # License 74 | MIT License. Please see [License](LICENSE.md) file for more information. 75 | -------------------------------------------------------------------------------- /mis_bot/misbot/message_strings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains all message string templates 3 | """ 4 | from os import environ 5 | import textwrap 6 | 7 | SUBSCRIPTION_MSG = textwrap.dedent(""" 8 | There is a one-time fee of ₹50/- per semester. 9 | 10 | Payment is accepted via UPI. All UPI apps work (PayTM/PhonePe/GPay/etc.) 11 | 12 | *TIP:* If you sign up on Google Pay and enter my referral code *{}*, you get ₹21 back on your first payment, so you save money! 13 | [Use this link](https://g.co/payinvite/{}) to signup using my referral code! 14 | 15 | To upgrade, make a payment at this link: 16 | [PAYMENT LINK]({}) 17 | 18 | After payment, [send me a message](t.me/Arion_Miles) and 19 | I'll upgrade you to premium after confirmation. 20 | """.format(environ['GPAY_REFERRAL_CODE'], environ['GPAY_REFERRAL_CODE'], environ['PAYMENT_LINK'])) 21 | 22 | ADMIN_COMMANDS_TXT = textwrap.dedent(""" 23 | 1. /push - Send a push notification 24 | 2. /revert - Delete a sent push notification 25 | 3. /clean - Clean Lecture & Practical records 26 | 4. /elevate - Make a user premium 27 | 5. /extend - Extend a user's premium period 28 | """) 29 | 30 | REPLY_UNKNOWN = [ 31 | "Seems like I'm not programmed to understand this yet.", 32 | "I'm not a fully functional A.I. ya know?", 33 | "The creator didn't prepare me for this.", 34 | "I'm not sentient...yet! 🤖", 35 | "Damn you're dumb.", 36 | "42", 37 | "We cannot afford machine learning to make this bot smart!", 38 | "We don't use NLP.", 39 | "I really wish we had a neural network.", 40 | "Sorry, did you say something? I wasn't listening.", 41 | ] 42 | 43 | TIPS = [ 44 | "Always use /attendance command before using /until80 or /bunk to get latest figures.", 45 | "The Aldel MIS gets updated at 6PM everyday.", 46 | "The /until80 function gives you the number of lectures you must attend *consecutively* before you attendance is 80%.",\ 47 | "The bunk calculator's figures are subject to differ from actual values depending upon a number of factors such as:\ 48 | \nMIS not being updated.\ 49 | \nCancellation of lectures.\ 50 | \nMass bunks. 😝", 51 | "`/itinerary all` gives complete detailed attendance report since the start of semester." 52 | ] 53 | 54 | HELP = textwrap.dedent(""" 55 | 1. /register - Register yourself 56 | 2. /attendance - Fetch attendance from the MIS website 57 | 3. /itinerary - Fetch detailed attendance 58 | 4. /profile - See your student profile 59 | 5. /results - Fetch unit test results 60 | 6. /bunk - Calculate % \drop/rise 61 | 7. /until80 - No. of lectures to attend consecutively until total attendance is 80% 62 | 8. /until - No. of lectures until X% 63 | 9. /target - Set a attendance percentage target 64 | 10. /edit_target - Edit your attendance target 65 | 11. /cancel - Cancel registration 66 | 12. /delete - Delete your credentials 67 | 13. /help - See this help message 68 | 14. /tips - Random tips 69 | 15. /subscription - See subscription details 70 | """) 71 | 72 | GIFS = [ 73 | "https://media.giphy.com/media/uSJF1fS5c3fQA/giphy.gif", 74 | "https://media.giphy.com/media/lRmjNrQZkKVuE/giphy.gif", 75 | "https://media.giphy.com/media/1zSz5MVw4zKg0/giphy.gif", 76 | "https://media.giphy.com/media/jWOLrt5JSNyXS/giphy.gif", 77 | "https://media.giphy.com/media/27tE5WpzjK0QEEm0WC/giphy.gif", 78 | "https://media.giphy.com/media/46itMIe0bkQeY/giphy.gif", 79 | "https://i.imgur.com/CoWZ05t.gif", 80 | "https://media.giphy.com/media/48YKCwrp4Kt8I/giphy.gif" 81 | ] -------------------------------------------------------------------------------- /mis_bot/scraper/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Scrapy settings for MIS project 4 | # 5 | # For simplicity, this file contains only settings considered important or 6 | # commonly used. You can find more settings consulting the documentation: 7 | # 8 | # http://doc.scrapy.org/en/latest/topics/settings.html 9 | # http://scrapy.readthedocs.org/en/latest/topics/downloader-middleware.html 10 | # http://scrapy.readthedocs.org/en/latest/topics/spider-middleware.html 11 | from os import environ 12 | 13 | BOT_NAME = 'MIS' 14 | 15 | SPIDER_MODULES = ['scraper.spiders'] 16 | NEWSPIDER_MODULE = 'scraper.spiders' 17 | 18 | # Obey robots.txt rules 19 | ROBOTSTXT_OBEY = False 20 | 21 | DOWNLOADER_MIDDLEWARES = { 22 | 'scrapy_splash.SplashCookiesMiddleware': 723, 23 | 'scrapy_splash.SplashMiddleware': 725, 24 | 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810, 25 | } 26 | 27 | SPLASH_URL = environ['SPLASH_INSTANCE'] 28 | 29 | SPIDER_MIDDLEWARES = { 30 | 'scrapy_splash.SplashDeduplicateArgsMiddleware': 100, 31 | } 32 | 33 | DUPEFILTER_CLASS = 'scrapy_splash.SplashAwareDupeFilter' 34 | 35 | ITEM_PIPELINES = { 36 | 'scraper.pipelines.LecturePipeline': 300, 37 | 'scraper.pipelines.PracticalPipeline': 400, 38 | } 39 | 40 | # Disable Telnet Console (enabled by default) 41 | TELNETCONSOLE_ENABLED = False 42 | 43 | # Crawl responsibly by identifying yourself (and your website) on the user-agent 44 | USER_AGENT = 'Mozilla/5.0 (compatible; MISBOT/3.1.1; +https://arionmiles.me/)' 45 | 46 | # Configure maximum concurrent requests performed by Scrapy (default: 16) 47 | #CONCURRENT_REQUESTS = 32 48 | 49 | # Configure a delay for requests for the same website (default: 0) 50 | # See http://scrapy.readthedocs.org/en/latest/topics/settings.html#download-delay 51 | # See also autothrottle settings and docs 52 | #DOWNLOAD_DELAY = 3 53 | # The download delay setting will honor only one of: 54 | #CONCURRENT_REQUESTS_PER_DOMAIN = 16 55 | #CONCURRENT_REQUESTS_PER_IP = 16 56 | 57 | # Disable cookies (enabled by default) 58 | #COOKIES_ENABLED = False 59 | 60 | # Override the default request headers: 61 | #DEFAULT_REQUEST_HEADERS = { 62 | # 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 63 | # 'Accept-Language': 'en', 64 | #} 65 | 66 | # Enable or disable spider middlewares 67 | # See http://scrapy.readthedocs.org/en/latest/topics/spider-middleware.html 68 | #SPIDER_MIDDLEWARES = { 69 | # 'MIS.middlewares.MisSpiderMiddleware': 543, 70 | #} 71 | 72 | # Enable or disable downloader middlewares 73 | # See http://scrapy.readthedocs.org/en/latest/topics/downloader-middleware.html 74 | #DOWNLOADER_MIDDLEWARES = { 75 | # 'MIS.middlewares.MyCustomDownloaderMiddleware': 543, 76 | #} 77 | 78 | 79 | # Enable or disable extensions 80 | # See http://scrapy.readthedocs.org/en/latest/topics/extensions.html 81 | #EXTENSIONS = { 82 | # 'scrapy.extensions.telnet.TelnetConsole': None, 83 | #} 84 | 85 | # Configure item pipelines 86 | # See http://scrapy.readthedocs.org/en/latest/topics/item-pipeline.html 87 | 88 | # Enable and configure the AutoThrottle extension (disabled by default) 89 | # See http://doc.scrapy.org/en/latest/topics/autothrottle.html 90 | #AUTOTHROTTLE_ENABLED = True 91 | # The initial download delay 92 | #AUTOTHROTTLE_START_DELAY = 5 93 | # The maximum download delay to be set in case of high latencies 94 | #AUTOTHROTTLE_MAX_DELAY = 60 95 | # The average number of requests Scrapy should be sending in parallel to 96 | # each remote server 97 | #AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0 98 | # Enable showing throttling stats for every response received: 99 | #AUTOTHROTTLE_DEBUG = False 100 | 101 | # Enable and configure HTTP caching (disabled by default) 102 | # See http://scrapy.readthedocs.org/en/latest/topics/downloader-middleware.html#httpcache-middleware-settings 103 | #HTTPCACHE_ENABLED = True 104 | #HTTPCACHE_EXPIRATION_SECS = 0 105 | #HTTPCACHE_DIR = 'httpcache' 106 | #HTTPCACHE_IGNORE_HTTP_CODES = [] 107 | #HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.FilesystemCacheStorage' 108 | -------------------------------------------------------------------------------- /mis_bot/misbot/decorators.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import wraps 3 | import logging 4 | from datetime import datetime, timedelta 5 | 6 | from scraper.models import Chat 7 | from scraper.database import db_session 8 | from misbot.mis_utils import get_user_info, get_misc_record 9 | from misbot.analytics import mp 10 | 11 | # Enable logging 12 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 13 | level=logging.INFO) 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | def signed_up(func): 18 | """Checks if user is signed up or not. 19 | If user isn't signed up, sends a message asking them to register. 20 | 21 | :param func: A telegram bot function 22 | :type func: func 23 | :return: None if user isn't registered or the function which decorator is applied on. 24 | :rtype: None or func 25 | """ 26 | @wraps(func) 27 | def wrapped(bot, update, *args, **kwargs): 28 | chatID = update.message.chat_id 29 | if not Chat.query.filter(Chat.chatID == chatID).first(): 30 | bot.sendMessage(chat_id=update.message.chat_id, text="Unregistered! Use the /register command to sign up!") 31 | return 32 | return func(bot, update, *args, **kwargs) 33 | return wrapped 34 | 35 | 36 | def admin(func): 37 | """Checks if the user sending the command/message is an admin or not. 38 | If user isn't an admin, their username (or chat ID, if unregistered) is logged 39 | and a message is sent saying that the incident has been reported. 40 | 41 | :param func: A telegram bot function 42 | :type func: func 43 | :return: None if user isn't registered or the function which decorator is applied on. 44 | :rtype: None or func 45 | """ 46 | @wraps(func) 47 | def wrapped(bot, update, *args, **kwargs): 48 | chatID = update.message.chat_id 49 | if not str(chatID) == os.environ['ADMIN_CHAT_ID']: 50 | messageContent = "You are not authorized to use this command. This incident has been reported." 51 | bot.sendMessage(chat_id=chatID, text=messageContent) 52 | user = get_user_info(chatID) 53 | if user: 54 | mp.track(user['PID'], 'Admin Function Access Attempt', {'pid':user['PID'], 55 | 'link': update.message.from_user.link, 56 | }) 57 | logger.warning("Unauthorized Access attempt by {}".format(user['PID'])) 58 | else: 59 | mp.track(user['PID'], 'Admin Function Access Attempt', {'chat_id':chatID, 60 | 'link': update.message.from_user.link, 61 | }) 62 | logger.warning("Unauthorized Access attempt by {}".format(chatID)) 63 | return 64 | return func(bot, update, *args, **kwargs) 65 | return wrapped 66 | 67 | 68 | def premium(tier=1): 69 | """Checks if the user sending the command/message is a premium member or not. 70 | 71 | :param tier: The tier level of the user permitted to use the feature 72 | :type tier: int 73 | :return: None if user doesn't satisfy tier level or the function which decorator is applied on. 74 | :rtype: None or func 75 | """ 76 | 77 | def decorator(func): 78 | @wraps(func) 79 | def command_func(bot, update, *args, **kwargs): 80 | chat_id = update.message.chat_id 81 | misc_record = get_misc_record(chat_id) 82 | 83 | if misc_record.premium_user is False: 84 | messageContent = "You must upgrade to premium to use this feature. See /subscription" 85 | bot.sendMessage(chat_id=chat_id, text=messageContent) 86 | return 87 | 88 | if misc_record.premium_tier < tier: 89 | messageContent = "This feature is not included in your subscription plan." 90 | bot.sendMessage(chat_id=chat_id, text=messageContent) 91 | return 92 | 93 | if datetime.now() > misc_record.premium_till: 94 | misc_record.premium_user = False 95 | db_session.commit() 96 | messageContent = "Your /premium subscription has expired! Kindly renew your subscription." 97 | bot.sendMessage(chat_id=chat_id, text=messageContent) 98 | return 99 | return func(bot, update, *args, **kwargs) 100 | return command_func 101 | return decorator 102 | -------------------------------------------------------------------------------- /mis_bot/scraper/spiders/profile_spider.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | import base64 3 | from multiprocessing import Process, Queue 4 | 5 | from scrapy.spiders.init import InitSpider 6 | from scrapy.http import Request, FormRequest 7 | from scrapy_splash import SplashRequest 8 | import scrapy.crawler as crawler 9 | from twisted.internet import reactor 10 | 11 | from misbot.mis_utils import solve_captcha 12 | 13 | class ProfileSpider(InitSpider): 14 | """Take screenshot of ``http://report.aldel.org/student/test_marks_report.php`` 15 | and send it to the user via :py:class:`scraper.pipelines.ProfileScreenshotPipeline` 16 | 17 | :param InitSpider: Base Spider with initialization facilities 18 | :type InitSpider: Spider 19 | """ 20 | name = 'profile' 21 | allowed_domains = ['report.aldel.org'] 22 | login_page = 'http://report.aldel.org/student_page.php' 23 | start_urls = ['http://report.aldel.org/student/view_profile.php'] 24 | 25 | def __init__(self, username, password, chatID, *args, **kwargs): 26 | super(ProfileSpider, self).__init__(*args, **kwargs) 27 | self.username = username 28 | self.password = password 29 | self.chatID = chatID 30 | 31 | def init_request(self): 32 | """This function is called before crawling starts.""" 33 | return Request(url=self.login_page, callback=self.login) 34 | 35 | def login(self, response): 36 | """Generate a login request.""" 37 | session_id = str(response.headers.getlist('Set-Cookie')[0].decode().split(';')[0].split("=")[1]) 38 | captcha_answer = solve_captcha(session_id) 39 | self.logger.info("Captcha Answer: {}".format(captcha_answer)) 40 | return FormRequest.from_response(response, 41 | formdata={'studentid': self.username, 'studentpwd': self.password, 'captcha_code':captcha_answer}, 42 | callback=self.check_login_response) 43 | 44 | def check_login_response(self, response): 45 | """Check the response returned by a login request to see if we are 46 | successfully logged in.""" 47 | if self.username in response.body.decode(): 48 | self.logger.info("Login Successful!") 49 | # Now the crawling can begin.. 50 | return self.initialized() 51 | else: 52 | self.logger.warning("Login failed! Check site status and credentials.") 53 | # Something went wrong, we couldn't log in, so nothing happens. 54 | def parse(self, response): 55 | """Send a SplashRequest and forward the response to :py:func:`parse_result`""" 56 | url = self.start_urls[0] 57 | splash_args = { 58 | 'html': 1, 59 | 'png': 1, 60 | 'wait':0.1, 61 | 'render_all':1 62 | } 63 | self.logger.info("Taking snapshot of Test Report for {}...".format(self.username)) 64 | yield SplashRequest(url, self.parse_result, endpoint='render.json', args=splash_args) 65 | 66 | def parse_result(self, response): 67 | """Downloads and saves the attendance report in ``files/_profile.png`` 68 | format. 69 | """ 70 | imgdata = base64.b64decode(response.data['png']) 71 | filename = 'files/{}_profile.png'.format(self.username) 72 | with open(filename, 'wb') as f: 73 | f.write(imgdata) 74 | self.logger.info("Saved student profile as: {}_profile.png".format(self.username)) 75 | 76 | def scrape_profile(username, password, chatID): 77 | """Run the spider multiple times, without hitting ``ReactorNotRestartable`` exception. Forks own process. 78 | 79 | :param username: student's PID (format: XXXNameXXXX) 80 | where X - integers 81 | :type username: str 82 | :param password: student's password for student portal 83 | :type password: str 84 | :param chatID: 9-Digit unique user ID 85 | :type chatID: str 86 | """ 87 | def f(q): 88 | try: 89 | runner = crawler.CrawlerRunner({ 90 | 'ITEM_PIPELINES': {'scraper.pipelines.ProfileScreenshotPipeline':300,}, 91 | 92 | 'DOWNLOADER_MIDDLEWARES': {'scrapy_splash.SplashCookiesMiddleware': 723, 93 | 'scrapy_splash.SplashMiddleware': 725, 94 | 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,}, 95 | 96 | 'SPLASH_URL':environ['SPLASH_INSTANCE'], 97 | 'SPIDER_MIDDLEWARES':{'scrapy_splash.SplashDeduplicateArgsMiddleware': 100,}, 98 | 'DUPEFILTER_CLASS':'scrapy_splash.SplashAwareDupeFilter', 99 | 'USER_AGENT': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36', 100 | }) 101 | deferred = runner.crawl(ProfileSpider, username=username, password=password, chatID=chatID) 102 | deferred.addBoth(lambda _: reactor.stop()) 103 | reactor.run() 104 | q.put(None) 105 | except Exception as e: 106 | q.put(e) 107 | 108 | q = Queue() 109 | p = Process(target=f, args=(q,)) 110 | p.start() 111 | result = q.get() 112 | p.join() 113 | 114 | if result is not None: 115 | raise result 116 | -------------------------------------------------------------------------------- /mis_bot/scraper/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime 5 | from sqlalchemy_utils.types.choice import ChoiceType 6 | 7 | from scraper.database import Base 8 | 9 | class Lecture(Base): 10 | __tablename__ = 'lectures' 11 | id = Column(Integer, primary_key=True) 12 | chatID = Column(Integer) 13 | name = Column(String(100)) 14 | attended = Column(Integer, default=0) 15 | conducted = Column(Integer, default=0) 16 | 17 | def __init__(self, name, chatID, conducted, attended): 18 | self.name = name 19 | self.chatID = chatID 20 | self.conducted = conducted 21 | self.attended = attended 22 | 23 | def __repr__(self): 24 | return ''.format(self.name, self.conducted, self.attended, self.chatID) 25 | 26 | class Practical(Base): 27 | __tablename__ = 'practicals' 28 | id = Column(Integer, primary_key=True) 29 | chatID = Column(Integer) 30 | name = Column(String(100)) 31 | attended = Column(Integer, default=0) 32 | conducted = Column(Integer, default=0) 33 | 34 | def __init__(self, name, chatID, conducted, attended): 35 | self.name = name 36 | self.chatID = chatID 37 | self.conducted = conducted 38 | self.attended = attended 39 | 40 | def __repr__(self): 41 | return ''.format(self.name, self.conducted, self.attended, self.chatID) 42 | 43 | 44 | class Chat(Base): 45 | __tablename__ = 'chats' 46 | id = Column(Integer, primary_key=True) 47 | PID = Column(String(100)) 48 | password = Column(String(50)) 49 | DOB = Column(String(100)) 50 | chatID = Column(String(512)) 51 | 52 | def __init__(self, PID=None, password=None, DOB=None, chatID=None): 53 | self.PID = PID 54 | self.password = password 55 | self.DOB = DOB 56 | self.chatID = chatID 57 | 58 | def __repr__(self): 59 | return ''.format(self.PID, self.chatID) 60 | 61 | class Misc(Base): 62 | __tablename__ = 'misc' 63 | id = Column(Integer, primary_key=True) 64 | chatID = Column(String(512)) 65 | attendance_target = Column(Float) 66 | premium_user = Column(Boolean, default=False) 67 | premium_tier = Column(Integer, default=0) 68 | premium_till = Column(DateTime, default=None) 69 | 70 | def __init__(self, chatID=chatID, attendance_target=None, premium_user=False, premium_tier=0, premium_till=None): 71 | self.chatID = chatID 72 | self.attendance_target = attendance_target 73 | self.premium_user = premium_user 74 | self.premium_tier = premium_tier 75 | self.premium_till = premium_till 76 | 77 | def __repr__(self): 78 | return ''.format(self.chatId) 79 | 80 | 81 | def generate_uuid(): 82 | return str(uuid.uuid4()) 83 | 84 | class PushMessage(Base): 85 | __tablename__ = 'pushmessages' 86 | uuid = Column(String(512), primary_key=True, default=generate_uuid) 87 | text = Column(String(4096)) 88 | deleted = Column(Boolean, default=False) 89 | created_at = Column(DateTime, default=datetime.now) 90 | 91 | def __init__(self, text=text, created_at=None): 92 | self.text = text 93 | self.created_at = created_at 94 | 95 | def __repr__(self): 96 | return '<{} | {}>'.format(self.uuid, self.text) 97 | 98 | class PushNotification(Base): 99 | __tablename__ = 'pushnotifications' 100 | id = Column(Integer, primary_key=True) 101 | message_uuid = Column(String(512)) 102 | chatID = Column(String(512)) 103 | message_id = Column(Integer, default=0) 104 | sent = Column(Boolean, default=False) 105 | failure_reason = Column(String(512)) 106 | 107 | def __init__(self, message_uuid=message_uuid, chatID=chatID, message_id=message_id, sent=sent, failure_reason=None): 108 | self.message_uuid = message_uuid 109 | self.chatID = chatID 110 | self.message_id = message_id 111 | self.sent = sent 112 | self.failure_reason = failure_reason 113 | 114 | def __repr__(self): 115 | return ''.format(self.chatID, self.message_id) 116 | 117 | class RateLimit(Base): 118 | STAGES = [ 119 | ('new', 'New'), 120 | ('failed', 'Failed'), 121 | ('completed', 'Completed'), 122 | ] 123 | 124 | COMMANDS = [ 125 | ('attendance', 'Attendance'), 126 | ('itinerary', 'Itinerary'), 127 | ('results', 'Results'), 128 | ('profile', 'Profile'), 129 | ] 130 | 131 | __tablename__ = 'ratelimit' 132 | id = Column(Integer, primary_key=True) 133 | chatID = Column(String(512)) 134 | requested_at = Column(DateTime, default=datetime.now) 135 | status = Column(ChoiceType(STAGES)) 136 | command = Column(ChoiceType(COMMANDS)) 137 | count = Column(Integer) 138 | 139 | def __init__(self, chatID, status, command, count, requested_at=None): 140 | self.chatID = chatID 141 | self.requested_at = requested_at 142 | self.status = status 143 | self.command = command 144 | self.count = count 145 | 146 | def __repr__(self): 147 | return ''.format(self.chatID, self.command) 148 | -------------------------------------------------------------------------------- /mis_bot/scraper/spiders/results_spider.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | import base64 3 | from multiprocessing import Process, Queue 4 | 5 | from scrapy.spiders.init import InitSpider 6 | from scrapy.http import Request, FormRequest 7 | from scrapy_splash import SplashRequest 8 | import scrapy.crawler as crawler 9 | from twisted.internet import reactor 10 | 11 | from misbot.mis_utils import solve_captcha 12 | 13 | class ResultsSpider(InitSpider): 14 | """Take screenshot of ``http://report.aldel.org/student/test_marks_report.php`` 15 | and send it to the user via :py:class:`scraper.pipelines.ResultsScreenshotPipeline` 16 | 17 | :param InitSpider: Base Spider with initialization facilities 18 | :type InitSpider: Spider 19 | """ 20 | name = 'results' 21 | allowed_domains = ['report.aldel.org'] 22 | login_page = 'http://report.aldel.org/student_page.php' 23 | start_urls = ['http://report.aldel.org/student/test_marks_report.php'] 24 | 25 | def __init__(self, username, password, chatID, *args, **kwargs): 26 | super(ResultsSpider, self).__init__(*args, **kwargs) 27 | self.username = username 28 | self.password = password 29 | self.chatID = chatID 30 | 31 | def init_request(self): 32 | """This function is called before crawling starts.""" 33 | return Request(url=self.login_page, callback=self.login) 34 | 35 | def login(self, response): 36 | """Generate a login request.""" 37 | session_id = str(response.headers.getlist('Set-Cookie')[0].decode().split(';')[0].split("=")[1]) 38 | captcha_answer = solve_captcha(session_id) 39 | self.logger.info("Captcha Answer: {}".format(captcha_answer)) 40 | return FormRequest.from_response(response, 41 | formdata={'studentid': self.username, 'studentpwd': self.password, 'captcha_code':captcha_answer}, 42 | callback=self.check_login_response) 43 | 44 | def check_login_response(self, response): 45 | """Check the response returned by a login request to see if we are 46 | successfully logged in.""" 47 | if self.username in response.body.decode(): 48 | self.logger.info("Login Successful!") 49 | # Now the crawling can begin.. 50 | return self.initialized() 51 | else: 52 | self.logger.warning("Login failed! Check site status and credentials.") 53 | # Something went wrong, we couldn't log in, so nothing happens. 54 | def parse(self, response): 55 | """Send a SplashRequest and forward the response to :py:func:`parse_result`""" 56 | url = 'http://report.aldel.org/student/test_marks_report.php' 57 | splash_args = { 58 | 'html': 1, 59 | 'png': 1, 60 | 'wait':0.1, 61 | 'render_all':1 62 | } 63 | self.logger.info("Taking snapshot of Test Report for {}...".format(self.username)) 64 | yield SplashRequest(url, self.parse_result, endpoint='render.json', args=splash_args) 65 | 66 | def parse_result(self, response): 67 | """Downloads and saves the attendance report in ``files/_tests.png`` 68 | format. 69 | """ 70 | imgdata = base64.b64decode(response.data['png']) 71 | filename = 'files/{}_tests.png'.format(self.username) 72 | with open(filename, 'wb') as f: 73 | f.write(imgdata) 74 | self.logger.info("Saved test report as: {}_tests.png".format(self.username)) 75 | 76 | def scrape_results(username, password, chatID): 77 | """Run the spider multiple times, without hitting ``ReactorNotRestartable`` exception. Forks own process. 78 | 79 | :param username: student's PID (format: XXXNameXXXX) 80 | where X - integers 81 | :type username: str 82 | :param password: student's password for student portal 83 | :type password: str 84 | :param chatID: 9-Digit unique user ID 85 | :type chatID: str 86 | """ 87 | def f(q): 88 | try: 89 | runner = crawler.CrawlerRunner({ 90 | 'ITEM_PIPELINES': {'scraper.pipelines.ResultsScreenshotPipeline':300,}, 91 | 92 | 'DOWNLOADER_MIDDLEWARES': {'scrapy_splash.SplashCookiesMiddleware': 723, 93 | 'scrapy_splash.SplashMiddleware': 725, 94 | 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,}, 95 | 96 | 'SPLASH_URL':environ['SPLASH_INSTANCE'], 97 | 'SPIDER_MIDDLEWARES':{'scrapy_splash.SplashDeduplicateArgsMiddleware': 100,}, 98 | 'DUPEFILTER_CLASS':'scrapy_splash.SplashAwareDupeFilter', 99 | 'USER_AGENT': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36', 100 | }) 101 | deferred = runner.crawl(ResultsSpider, username=username, password=password, chatID=chatID) 102 | deferred.addBoth(lambda _: reactor.stop()) 103 | reactor.run() 104 | q.put(None) 105 | except Exception as e: 106 | q.put(e) 107 | 108 | q = Queue() 109 | p = Process(target=f, args=(q,)) 110 | p.start() 111 | result = q.get() 112 | p.join() 113 | 114 | if result is not None: 115 | raise result 116 | -------------------------------------------------------------------------------- /mis_bot/misbot/push_notifications.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import concurrent.futures 4 | import threading 5 | from functools import partial 6 | 7 | import requests 8 | from telegram.bot import Bot 9 | from telegram.utils.request import Request 10 | 11 | from scraper.database import db_session 12 | from scraper.models import Chat, PushNotification, PushMessage 13 | from misbot.mis_utils import get_user_info 14 | 15 | API_KEY_TOKEN = os.environ["TOKEN"] 16 | list_of_objs = [] 17 | inactive_users = [] 18 | 19 | def get_user_list(): 20 | """Retrieves the ``chatID`` of all users from ``Chat`` table. A tuple is returned 21 | which is then unpacked into a list by iterating over the tuple. 22 | 23 | :return: List of user chat IDs 24 | :rtype: list 25 | """ 26 | users_tuple = db_session.query(Chat.chatID).all() 27 | users_list = [user for user, in users_tuple] 28 | return users_list 29 | 30 | def get_bot(): 31 | """Create an instance of the :py:class:`telegram.bot.Bot` object. 32 | 33 | :return: Telegram bot object. 34 | :rtype: telegram.bot.Bot 35 | """ 36 | 37 | request = Request(con_pool_size=30) # Increasing default connection pool from 1 38 | bot = Bot(API_KEY_TOKEN, request=request) 39 | return bot 40 | 41 | def push_message_threaded(message, user_list): 42 | """Use ``ThreadPoolExecutor`` to send notification message asynchronously to all 43 | users. 44 | Before sending the message, we create a record of the sent message in the ``PushMessage`` DB model. 45 | 46 | We pass the ``message_uuid`` generated from created the record of the message previously and pass it 47 | to the :py:func:`push_t`. 48 | 49 | After messages have been sent, we bulk commit all the ``PushNotification`` records created within 50 | :py:func:`push_t` to our database. 51 | 52 | :param message: Notification message 53 | :type message: str 54 | :param user_list: List of all bot users 55 | :type user_list: list 56 | :return: Time taken to send messages and the ``message_uuid`` 57 | :rtype: tuple 58 | """ 59 | 60 | push_message = PushMessage(text=message) 61 | db_session.add(push_message) 62 | db_session.commit() 63 | 64 | message_uuid = push_message.uuid 65 | push = partial(push_t, get_bot(), message, message_uuid) # Adding message string and uuid as function parameter 66 | 67 | start = time.time() 68 | with concurrent.futures.ThreadPoolExecutor(max_workers=30) as executor: 69 | executor.map(push, user_list) 70 | 71 | elapsed = time.time() - start 72 | 73 | db_session.bulk_save_objects(list_of_objs) 74 | db_session.commit() 75 | 76 | delete_users = Chat.__table__.delete().where(Chat.chatID.in_(inactive_users)) 77 | db_session.execute(delete_users) 78 | db_session.commit() 79 | 80 | 81 | list_of_objs.clear() 82 | inactive_users.clear() 83 | 84 | return elapsed, message_uuid 85 | 86 | def push_t(bot, message, message_uuid, chat_id): 87 | """Sends the message to the specified ``chat_id``. 88 | Records the ``message_id`` received into ``PushNotification`` DB Model. 89 | Appends the record object to ``list_of_objs`` to be bulk committed after all messages have been 90 | sent through ``ThreadPoolExecutor`` 91 | 92 | :param bot: Telegram Bot object 93 | :type bot: telegram.bot.Bot 94 | :param message: Notification message 95 | :type message: str 96 | :param message_uuid: UUID of the notification message created in ``push_message_threaded`` 97 | :type message_uuid: str 98 | :param chat_id: 9-Digit unique user ID 99 | :type chat_id: int|str 100 | """ 101 | 102 | username = get_user_info(chat_id)['PID'][3:-4].title() 103 | message = "Hey {0}!\n{1}".format(username, message) 104 | try: 105 | response = bot.sendMessage(chat_id=chat_id, text=message, parse_mode='markdown') 106 | push_message_record = PushNotification(message_uuid=message_uuid, chatID=chat_id, message_id=response.message_id, sent=True) 107 | list_of_objs.append(push_message_record) 108 | except Exception as e: 109 | push_message_record = PushNotification(message_uuid=message_uuid, chatID=chat_id, failure_reason=str(e)) 110 | list_of_objs.append(push_message_record) 111 | inactive_users.append(chat_id) 112 | 113 | def delete_threaded(message_id_list, user_list): 114 | """Use ``ThreadPoolExecutor`` to delete notification message asynchronously for all 115 | users. 116 | 117 | :param message_id_list: List of message ids stored in our DB. 118 | :type message_id_list: list 119 | :param user_list: List of all bot users 120 | :type user_list: list 121 | :return: Time taken to send all delete requests 122 | :rtype: float 123 | """ 124 | 125 | delete_func = partial(delete, get_bot()) 126 | start = time.time() 127 | with concurrent.futures.ThreadPoolExecutor(max_workers=30) as executor: 128 | executor.map(delete_func, message_id_list, user_list) 129 | elapsed = time.time() - start 130 | return elapsed 131 | 132 | def delete(bot, message_id, chat_id): 133 | """Sends a delete request for a particular ``chat_id`` and ``message_id`` 134 | 135 | :param bot: Telegram Bot object 136 | :type bot: telegram.bot.Bot 137 | :param message_id: The message ID for the notification message 138 | :type message_id: int 139 | :param chat_id: 9-Digit unique user ID 140 | :type chat_id: int | str 141 | """ 142 | 143 | bot.delete_message(chat_id, message_id) 144 | -------------------------------------------------------------------------------- /mis_bot/misbot/spider_functions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from sqlalchemy import and_ 5 | 6 | from scraper.spiders.attendance_spider import scrape_attendance 7 | from scraper.spiders.results_spider import scrape_results 8 | from scraper.spiders.itinerary_spider import scrape_itinerary 9 | from scraper.spiders.profile_spider import scrape_profile 10 | from scraper.models import Chat, RateLimit 11 | from scraper.database import db_session 12 | from misbot.decorators import signed_up, premium 13 | from misbot.mis_utils import until_x, crop_image, get_user_info, rate_limited 14 | 15 | # Enable logging 16 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 17 | level=logging.INFO) 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | @signed_up 22 | @premium(tier=1) 23 | def attendance(bot, update): 24 | """Core function. Fetch attendance figures from Aldel's MIS. 25 | Runs AttendanceSpider for registered users and passes it their ``Student_ID`` (PID), 26 | ``Password``, & ``ChatID`` (necessary for ``AttendancePipeline``) 27 | 28 | ``AttendanceSpider`` creates a image file of the format: ``_attendance.png`` 29 | File is deleted after being sent to the user. 30 | If the file is unavailable, error message is sent to the user. 31 | 32 | :param bot: Telegram Bot object 33 | :type bot: telegram.bot.Bot 34 | :param update: Telegram Update object 35 | :type update: telegram.update.Update 36 | """ 37 | # Get chatID and user details based on chatID 38 | chatID = update.message.chat_id 39 | user_info = get_user_info(chatID) 40 | Student_ID = user_info['PID'] 41 | password = user_info['password'] 42 | 43 | if not rate_limited(bot, chatID, "attendance"): 44 | bot.send_chat_action(chat_id=chatID, action='upload_photo') 45 | scrape_attendance(Student_ID, password, chatID) 46 | 47 | 48 | @signed_up 49 | @premium(tier=1) 50 | def results(bot, update): 51 | """Fetch Unit Test results from the Aldel MIS. 52 | Core function. Fetch Test Reports from Aldel's MIS. 53 | Runs ``ResultsSpider`` for registered users and passes it their ``Student_ID`` (PID) & 54 | ``Password``. 55 | 56 | ResultsSpider creates a image file of the format: ``_tests.png`` 57 | File is deleted after being sent to the user. 58 | If the file is unavailable, error message is sent to the user. 59 | 60 | :param bot: Telegram Bot object 61 | :type bot: telegram.bot.Bot 62 | :param update: Telegram Update object 63 | :type update: telegram.update.Update 64 | """ 65 | # Get chatID and user details based on chatID 66 | chatID = update.message.chat_id 67 | user_info = get_user_info(chatID) 68 | Student_ID = user_info['PID'] 69 | password = user_info['password'] 70 | 71 | #Run ResultsSpider 72 | if not rate_limited(bot, chatID, "results"): 73 | bot.send_chat_action(chat_id=chatID, action='upload_photo') 74 | scrape_results(Student_ID, password, chatID) 75 | 76 | 77 | @signed_up 78 | @premium(tier=1) 79 | def itinerary(bot, update, args): 80 | """Core function. Fetch detailed attendance reports from Aldel's MIS (Parent's Portal). 81 | Runs ``ItinerarySpider`` for registered users and passes it their ``Student_ID`` (PID) & 82 | ``Password``. 83 | 84 | ``AttendanceSpider`` creates a image file of the format: ``_itinerary.png`` 85 | If ``args`` are present, full report is sent in the form of a document. Otherwise, it 86 | is cropped to the past 7 days using :py:func:`misbot.mis_utils.crop_image` and this function stores the 87 | resultant image as: ``_itinerary_cropped.png`` and returns True. 88 | 89 | File is deleted after sent to the user. 90 | If the file is unavailable, error message is sent to the user. 91 | 92 | :param bot: Telegram Bot object 93 | :type bot: telegram.bot.Bot 94 | :param update: Telegram Update object 95 | :type update: telegram.update.Update 96 | :param args: User supplied arguments 97 | :type args: tuple 98 | """ 99 | chatID = update.message.chat_id 100 | 101 | #If registered, but DOB is absent from the DB 102 | if Chat.query.filter(and_(Chat.chatID == chatID, Chat.DOB == None)).first(): 103 | bot.sendMessage(chat_id=update.message.chat_id, text="📋 Your DOB is missing! Please use /register to start.") 104 | return 105 | 106 | user_info = get_user_info(chatID) 107 | Student_ID = user_info['PID'] 108 | DOB = user_info['DOB'] 109 | 110 | if not rate_limited(bot, chatID, "itinerary"): 111 | if args: 112 | bot.send_chat_action(chat_id=update.message.chat_id, action='upload_document') 113 | scrape_itinerary(Student_ID, DOB, chatID, uncropped=True) 114 | return 115 | else: 116 | bot.send_chat_action(chat_id=update.message.chat_id, action='upload_photo') 117 | scrape_itinerary(Student_ID, DOB, chatID) 118 | return 119 | 120 | 121 | @signed_up 122 | @premium(tier=1) 123 | def profile(bot, update): 124 | """Fetch profile info from the Aldel MIS. Core function. 125 | Runs ``ProfileSpider`` for registered users and passes it their ``Student_ID`` (PID) & 126 | ``Password``. 127 | 128 | ProfileSpider creates a image file of the format: ``_profile.png`` 129 | File is deleted after being sent to the user. 130 | If the file is unavailable, error message is sent to the user. 131 | 132 | :param bot: Telegram Bot object 133 | :type bot: telegram.bot.Bot 134 | :param update: Telegram Update object 135 | :type update: telegram.update.Update 136 | """ 137 | # Get chatID and user details based on chatID 138 | chatID = update.message.chat_id 139 | user_info = get_user_info(chatID) 140 | Student_ID = user_info['PID'] 141 | password = user_info['password'] 142 | 143 | #Run ProfileSpider 144 | if not rate_limited(bot, chatID, "profile"): 145 | bot.send_chat_action(chat_id=chatID, action='upload_document') 146 | scrape_profile(Student_ID, password, chatID) 147 | -------------------------------------------------------------------------------- /mis_bot/scraper/spiders/itinerary_spider.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/python3 2 | from os import environ 3 | import base64 4 | from scrapy.spiders.init import InitSpider 5 | from scrapy.http import Request, FormRequest 6 | import scrapy.crawler as crawler 7 | from twisted.internet import reactor 8 | from multiprocessing import Process, Queue 9 | from scrapy_splash import SplashRequest 10 | 11 | from ..items import Lectures, Practicals 12 | from misbot.mis_utils import solve_captcha 13 | 14 | class ItinerarySpider(InitSpider): 15 | """Take screenshot of ``http://report.aldel.org/parent/itinenary_attendance_report.php`` 16 | and send it to the user via :py:class:`scraper.pipelines.ItineraryScreenshotPipeline` 17 | 18 | :param InitSpider: Base Spider with initialization facilities 19 | :type InitSpider: Spider 20 | """ 21 | name = 'itinerary' 22 | allowed_domains = ['report.aldel.org'] 23 | login_page = 'http://report.aldel.org/parent_page.php' 24 | start_urls = ['http://report.aldel.org/parent/itinenary_attendance_report.php'] 25 | 26 | def __init__(self, username, dob, chatID, uncropped=False, *args, **kwargs): 27 | super(ItinerarySpider, self).__init__(*args, **kwargs) 28 | self.username = username 29 | self.dob = dob 30 | self.chatID = chatID 31 | self.uncropped = uncropped 32 | 33 | def init_request(self): 34 | """This function is called before crawling starts.""" 35 | return Request(url=self.login_page, callback=self.login) 36 | 37 | def login(self, response): 38 | """Generate a login request.""" 39 | try: 40 | date, month, year = self.dob.split('/') 41 | except ValueError: 42 | self.logger.warning("Incorrect dob details. Terminating operation.") 43 | 44 | session_id = str(response.headers.getlist('Set-Cookie')[0].decode().split(';')[0].split("=")[1]) 45 | captcha_answer = solve_captcha(session_id) 46 | self.logger.info("Captcha Answer: {}".format(captcha_answer)) 47 | return FormRequest.from_response(response, formdata={'studentid': self.username, 48 | 'date_of_birth': date, 49 | 'month_of_birth': month, 50 | 'year_of_birth': year, 51 | 'captcha_code':captcha_answer}, 52 | callback=self.check_login_response) 53 | 54 | def check_login_response(self, response): 55 | """Check the response returned by a login request to see if we are 56 | successfully logged in.""" 57 | if self.username in response.body.decode(): 58 | self.logger.info("Login Successful!") 59 | # Now the crawling can begin.. 60 | return self.initialized() 61 | else: 62 | self.logger.warning("Login failed! Check site status and credentials.") 63 | # Something went wrong, we couldn't log in, so nothing happens. 64 | 65 | def parse(self, response): 66 | """Send a SplashRequest and forward the response to :py:func:`parse_result`""" 67 | url = 'http://report.aldel.org/parent/itinenary_attendance_report.php' 68 | splash_args = { 69 | 'html': 1, 70 | 'png': 1, 71 | 'wait':0.1, 72 | 'render_all':1 73 | } 74 | self.logger.info("Taking snapshot of Itinerary Attendance Report for {}...".format(self.username)) 75 | yield SplashRequest(url, self.parse_result, endpoint='render.json', args=splash_args) 76 | 77 | def parse_result(self, response): 78 | """Downloads and saves the attendance report in ``files/_itinerary.png`` 79 | format. 80 | """ 81 | imgdata = base64.b64decode(response.data['png']) 82 | filename = 'files/{}_itinerary.png'.format(self.username) 83 | with open(filename, 'wb') as f: 84 | f.write(imgdata) 85 | self.logger.info("Saved itinerary attendance report as: {}_itinerary.png".format(self.username)) 86 | 87 | 88 | def scrape_itinerary(username, dob, chatID, uncropped=False): 89 | """Run the spider multiple times, without hitting ``ReactorNotRestartable`` exception. Forks own process. 90 | 91 | :param username: student's PID (format: XXXNameXXXX) 92 | where X - integers 93 | :type username: str 94 | :param dob: User's Date of Birth 95 | :type dob: str 96 | :param chatID: 9-Digit unique user ID 97 | :type chatID: str 98 | :param uncropped: Whether the user wants full report or for last 7-8 days 99 | :type uncropped: bool 100 | """ 101 | def f(q): 102 | try: 103 | runner = crawler.CrawlerRunner({ 104 | 'ITEM_PIPELINES': {'scraper.pipelines.ItineraryScreenshotPipeline':300,}, 105 | 106 | 'DOWNLOADER_MIDDLEWARES': {'scrapy_splash.SplashCookiesMiddleware': 723, 107 | 'scrapy_splash.SplashMiddleware': 725, 108 | 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,}, 109 | 110 | 'SPLASH_URL':environ['SPLASH_INSTANCE'], 111 | 'SPIDER_MIDDLEWARES':{'scrapy_splash.SplashDeduplicateArgsMiddleware': 100,}, 112 | 'DUPEFILTER_CLASS':'scrapy_splash.SplashAwareDupeFilter', 113 | 'USER_AGENT': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36', 114 | }) 115 | deferred = runner.crawl(ItinerarySpider, username=username, dob=dob, chatID=chatID, uncropped=uncropped) 116 | deferred.addBoth(lambda _: reactor.stop()) 117 | reactor.run() 118 | q.put(None) 119 | except Exception as e: 120 | q.put(e) 121 | 122 | q = Queue() 123 | p = Process(target=f, args=(q,)) 124 | p.start() 125 | result = q.get() 126 | p.join() 127 | 128 | if result is not None: 129 | raise result 130 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | .. _pre-reqs: 5 | 6 | Pre-requisites 7 | -------------- 8 | 9 | Telegram 10 | ^^^^^^^^ 11 | You need a Telegram Bot Token, which you can get by messaging `Botfather 12 | `_ on Telegram. You can get your bot token by asking BotFather anytime, but for now, save your bot token somewhere because we will require it in the next step. 13 | 14 | Since you're gonna run the bot, you are the administrator of the bot. 15 | You require your telegram chat ID to use admin commands like ``/push``, ``/revert``, etc. 16 | To get your chat ID, message `FalconGate 17 | `_ with the command ``/get_my_id`` and save the 9-digit number that comes back, 18 | we will require this in the next step. 19 | 20 | 21 | Docker 22 | ^^^^^^ 23 | 24 | Linux 25 | ~~~~~ 26 | 27 | `Learn how to install docker here. 28 | `_ 29 | 30 | `Learn how to install docker-compose here. 31 | `_ 32 | 33 | Windows 34 | ~~~~~~~ 35 | 36 | On Windows 10 Home, I found using Docker-Toolbox to be less than ideal since the VM docker-toolbox uses doesn't expose the container's ports to the host machine, which wouldn't let my bot's container talk to the Splash container (you'll know what it is in next step). 37 | 38 | So I chose to run docker inside a Ubuntu VM which I run through Vagrant. 39 | If you're on Windows 10 Pro or Enterprise, just using Docker Desktop will work out fine. 40 | `Download it here. 41 | `_ 42 | 43 | However, if you're on Windows 10 Home edition, follow below steps. 44 | 45 | `Download & install Vagrant from here. 46 | `_ 47 | 48 | After installation is done run this command in the folder where you'll be downloading or cloning this repository (e.g. ``Documents``): 49 | .. code-block:: none 50 | vagrant init kwilczynski/ubuntu-16.04-docker 51 | vagrant up 52 | 53 | A new ``Vagrantfile`` will be created inside the directory where you run the above command (``Documents`` in our case). 54 | Open it and add this line before the last line (which says ``end``) 55 | .. code-block:: none 56 | config.vm.synced_folder "MIS-Bot/", "/srv/MIS-Bot" 57 | 58 | This will sync the cloned/downloaded project folder with the ubuntu virtual machine. 59 | 60 | Now you can run this command to get inside your Ubuntu VM with docker installed: 61 | .. code-block:: none 62 | vagrant ssh 63 | 64 | When you're done doing docker stuff, you can type ``exit`` to come out of the Ubuntu VM and then run ``vagrant halt`` to shutdown the VM. 65 | 66 | Development Setup 67 | ----------------- 68 | 69 | Cloning the repository 70 | ^^^^^^^^^^^^^^^^^^^^^^ 71 | Clone the repository by running (in ``Documents`` or some other directory, whichever you created the vagrant VM in the previous step): 72 | 73 | .. code-block:: none 74 | git clone https://github.com/ArionMiles/MIS-Bot/ 75 | 76 | Open the project directory in terminal with 77 | 78 | .. code-block:: none 79 | cd MIS-Bot/ 80 | 81 | Run the below command to create folders which will be required by our bot: 82 | 83 | .. code-block:: none 84 | mkdir -p files/captcha/ 85 | 86 | Setting environment variables 87 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 88 | 89 | Rename ``example.env`` to ``.env``: 90 | 91 | ``mv example.env .env`` 92 | 93 | Edit ``.env`` and enter your ``Bot Token``, your ``Chat ID`` (See :ref:`pre-reqs`), and if you are gonna run it with webhooks, 94 | you'll need to enter your server address as ``URL`` (without HTTP:// or trailing /, like this: ``X.X.X.X``). 95 | 96 | Enter ``SPLASH_INSTANCE`` as ``http://splash:8050`` 97 | 98 | Running with Long Polling 99 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 100 | 101 | If you're testing this locally, change DEBUG value to ``True`` in 102 | `this file 103 | `_ 104 | which allows you to use long-polling instead of webhooks and makes development easier. 105 | 106 | Running with Webhooks 107 | ^^^^^^^^^^^^^^^^^^^^^ 108 | 109 | If you're gonna deploy this on a remote server, and are expecting lots of users, it's better to use webhooks rather than long-polling. 110 | 111 | You need SSL certificates in order to use webhooks. Telegram servers communicate only via HTTPS, with long polling, 112 | the telegram servers take care of it, but since we're using webhooks, we need to take care of it. 113 | We'll be using a self-signed certificate. To create a self-signed SSL certificate using openssl, run the following command: 114 | 115 | ``openssl req -newkey rsa:2048 -sha256 -nodes -keyout private.key -x509 -days 3650 -out cert.pem`` 116 | 117 | The openssl utility will ask you a few details. Make sure you enter the correct FQDN! If your server has a domain, 118 | enter the full domain name here (eg. sub.example.com). If your server only has an IP address, enter that instead. 119 | If you enter an invalid FQDN (Fully Qualified Domain Name), you won't receive any updates from Telegram but also won't see any errors! 120 | 121 | -`Source 122 | `_ 123 | 124 | Move the ``private.key`` and ``cert.pem`` generated to the ``files/`` directory so that they're picked up by ``telegram_bot.py``: 125 | .. code-block:: none 126 | mv private.key cert.pem files/ 127 | 128 | Running the docker container 129 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 130 | 131 | On the first run, docker will build an image for our container, it can take significant amount of time depending on your 132 | internet connection, so wait while docker downloads the python, splash images and installs all the dependencies. 133 | 134 | Start the container by running this from the root directory of the project: 135 | .. code-block:: none 136 | docker-compose up 137 | 138 | and after everything is installed, the bot should be up. 139 | 140 | Cool! Now you've got the bot running, start experimenting, create new features, the possibilities are endless! 141 | 142 | ON GCP: Switch to your project, go to Compute Instance > VPC Network > Firewall Rules 143 | and change "default-http" rule's Protocol/ports value from "tcp:80" to all to allow tg webhook to work -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('../../mis_bot')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'MIS Bot' 23 | copyright = '2019, Kanishk Singh' 24 | author = 'Kanishk Singh' 25 | 26 | # The short X.Y version 27 | version = '' 28 | # The full version, including alpha/beta/rc tags 29 | release = '4.0.0' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['ntemplates'] 47 | autodoc_member_order = 'bysource' 48 | # The suffix(es) of source filenames. 49 | # You can specify multiple suffix as a list of string: 50 | # 51 | # source_suffix = ['.rst', '.md'] 52 | source_suffix = '.rst' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | # 60 | # This is also used if you do content translation via gettext catalogs. 61 | # Usually you set "language" from the command line for these cases. 62 | language = None 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | # This pattern also affects html_static_path and html_extra_path. 67 | exclude_patterns = [] 68 | 69 | # The name of the Pygments (syntax highlighting) style to use. 70 | pygments_style = None 71 | 72 | 73 | # -- Options for HTML output ------------------------------------------------- 74 | 75 | # The theme to use for HTML and HTML Help pages. See the documentation for 76 | # a list of builtin themes. 77 | # 78 | html_theme = 'sphinx_rtd_theme' 79 | 80 | # Theme options are theme-specific and customize the look and feel of a theme 81 | # further. For a list of options available for each theme, see the 82 | # documentation. 83 | # 84 | # html_theme_options = {} 85 | 86 | # The name of an image file (relative to this directory) to place at the top 87 | # of the sidebar. 88 | html_logo = '../../media/avatar.png' 89 | 90 | # The name of an image file (within the static path) to use as favicon of the 91 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 92 | # pixels large. 93 | html_favicon = '../../media/avatar-ico.ico' 94 | 95 | # Add any paths that contain custom static files (such as style sheets) here, 96 | # relative to this directory. They are copied after the builtin static files, 97 | # so a file named "default.css" will overwrite the builtin "default.css". 98 | html_static_path = ['nstatic'] 99 | 100 | # Custom sidebar templates, must be a dictionary that maps document names 101 | # to template names. 102 | # 103 | # The default sidebars (for documents that don't match any pattern) are 104 | # defined by theme itself. Builtin themes are using these templates by 105 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 106 | # 'searchbox.html']``. 107 | # 108 | # html_sidebars = {} 109 | 110 | 111 | # -- Options for HTMLHelp output --------------------------------------------- 112 | 113 | # Output file base name for HTML help builder. 114 | htmlhelp_basename = 'MISBotdoc' 115 | 116 | 117 | # -- Options for LaTeX output ------------------------------------------------ 118 | 119 | latex_elements = { 120 | # The paper size ('letterpaper' or 'a4paper'). 121 | # 122 | # 'papersize': 'letterpaper', 123 | 124 | # The font size ('10pt', '11pt' or '12pt'). 125 | # 126 | # 'pointsize': '10pt', 127 | 128 | # Additional stuff for the LaTeX preamble. 129 | # 130 | # 'preamble': '', 131 | 132 | # Latex figure (float) alignment 133 | # 134 | # 'figure_align': 'htbp', 135 | } 136 | 137 | # Grouping the document tree into LaTeX files. List of tuples 138 | # (source start file, target name, title, 139 | # author, documentclass [howto, manual, or own class]). 140 | latex_documents = [ 141 | (master_doc, 'MISBot.tex', 'MIS Bot Documentation', 142 | 'Kanishk Singh', 'manual'), 143 | ] 144 | 145 | 146 | # -- Options for manual page output ------------------------------------------ 147 | 148 | # One entry per manual page. List of tuples 149 | # (source start file, name, description, authors, manual section). 150 | man_pages = [ 151 | (master_doc, 'misbot', 'MIS Bot Documentation', 152 | [author], 1) 153 | ] 154 | 155 | 156 | # -- Options for Texinfo output ---------------------------------------------- 157 | 158 | # Grouping the document tree into Texinfo files. List of tuples 159 | # (source start file, target name, title, author, 160 | # dir menu entry, description, category) 161 | texinfo_documents = [ 162 | (master_doc, 'MISBot', 'MIS Bot Documentation', 163 | author, 'MISBot', 'One line description of project.', 164 | 'Miscellaneous'), 165 | ] 166 | 167 | 168 | # -- Options for Epub output ------------------------------------------------- 169 | 170 | # Bibliographic Dublin Core info. 171 | epub_title = project 172 | 173 | # The unique identifier of the text. This can be a ISBN number 174 | # or the project homepage. 175 | # 176 | # epub_identifier = '' 177 | 178 | # A unique identification for the text. 179 | # 180 | # epub_uid = '' 181 | 182 | # A list of files that should not be packed into the epub file. 183 | epub_exclude_files = ['search.html'] 184 | 185 | 186 | # -- Extension configuration ------------------------------------------------- 187 | -------------------------------------------------------------------------------- /mis_bot/scraper/spiders/attendance_spider.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/python3 2 | from os import environ 3 | import base64 4 | 5 | from scrapy.spiders.init import InitSpider 6 | from scrapy.http import Request, FormRequest 7 | import scrapy.crawler as crawler 8 | from twisted.internet import reactor 9 | from multiprocessing import Process, Queue 10 | from scrapy_splash import SplashRequest 11 | 12 | from scraper.items import Lectures, Practicals 13 | from misbot.mis_utils import solve_captcha 14 | 15 | class AttendanceSpider(InitSpider): 16 | """Scrape attendance figures from ``http://report.aldel.org/student/attendance_report.php`` 17 | and store the figures in database with :py:class:`scraper.pipelines.LecturePipeline` 18 | and :py:class:`scraper.pipelines.PracticalPipeline` 19 | 20 | :param InitSpider: Base Spider with initialization facilities 21 | :type InitSpider: Spider 22 | """ 23 | 24 | name = 'attendance' 25 | allowed_domains = ['report.aldel.org'] 26 | login_page = 'http://report.aldel.org/student_page.php' 27 | start_urls = ['http://report.aldel.org/student/attendance_report.php'] 28 | 29 | def __init__(self, username, password, chatID, *args, **kwargs): 30 | super(AttendanceSpider, self).__init__(*args, **kwargs) 31 | self.username = username 32 | self.password = password 33 | self.chatID = chatID 34 | 35 | def init_request(self): 36 | """This function is called before crawling starts.""" 37 | return Request(url=self.login_page, callback=self.login) 38 | 39 | def login(self, response): 40 | """Generate a login request.""" 41 | session_id = str(response.headers.getlist('Set-Cookie')[0].decode().split(';')[0].split("=")[1]) 42 | captcha_answer = solve_captcha(session_id) 43 | self.logger.info("Captcha Answer: {}".format(captcha_answer)) 44 | return FormRequest.from_response(response, 45 | formdata={'studentid': self.username, 'studentpwd': self.password, 'captcha_code':captcha_answer}, 46 | callback=self.check_login_response) 47 | 48 | def check_login_response(self, response): 49 | """Check the response returned by a login request to see if we are 50 | successfully logged in.""" 51 | if self.username in response.body.decode(): 52 | self.logger.info("Login Successful!") 53 | # Now the crawling can begin.. 54 | return self.initialized() 55 | else: 56 | self.logger.warning("Login failed! Check site status and credentials.") 57 | # Something went wrong, we couldn't log in, so nothing happens. 58 | 59 | def parse(self, response): 60 | """Send a SplashRequest and forward the response to :py:func:`parse_result`""" 61 | 62 | url = 'http://report.aldel.org/student/attendance_report.php' 63 | splash_args = { 64 | 'html': 1, 65 | 'png': 1, 66 | 'wait':0.1, 67 | 'render_all':1 68 | } 69 | self.logger.info("Taking snapshot of Attendance Report for {}...".format(self.username)) 70 | yield SplashRequest(url, self.parse_result, endpoint='render.json', args=splash_args) 71 | 72 | def parse_result(self, response): 73 | """Downloads and saves the attendance report in ``files/_attendance.png`` 74 | format. 75 | 76 | Also scrapes every attendance record from the webpage and passes it to 77 | ``LecturePipeline`` and ``PracticalPipeline``. 78 | """ 79 | imgdata = base64.b64decode(response.data['png']) 80 | filename = 'files/{}_attendance.png'.format(self.username) 81 | with open(filename, 'wb') as f: 82 | f.write(imgdata) 83 | self.logger.info("Saved attendance report as: {}_attendance.png".format(self.username)) 84 | 85 | """CODE FOR SCRAPING EVERY ELEMENT FROM THE TABLE""" 86 | lecturesItems = response.xpath('//table[1]/tbody/tr') 87 | for item in lecturesItems[2:]: # starting from 2nd element since 1st row is Student Name 88 | lec_item = Lectures() 89 | 90 | lec_item['subject'] = "".join([x.strip('\n').strip(' ') for x in item.xpath('.//td[1]//text()').extract()]) 91 | lec_item['conducted'] = "".join([x.strip('\n').strip(' ') for x in item.xpath('.//td[2]//text()').extract()]) 92 | lec_item['attended'] = "".join([x.strip('\n').strip(' ') for x in item.xpath('.//td[3]//text()').extract()]) 93 | yield lec_item 94 | 95 | practicalsItems = response.xpath('//table[2]/tbody/tr') 96 | for item in practicalsItems[1:]: 97 | prac_item = Practicals() 98 | 99 | prac_item['subject'] = "".join([x.strip('\n').strip(' ') for x in item.xpath('.//td[1]//text()').extract()]) 100 | prac_item['conducted'] = "".join([x.strip('\n').strip(' ') for x in item.xpath('.//td[2]//text()').extract()]) 101 | prac_item['attended'] = "".join([x.strip('\n').strip(' ') for x in item.xpath('.//td[3]//text()').extract()]) 102 | yield prac_item 103 | 104 | def scrape_attendance(username, password, chatID): 105 | """Run the spider multiple times, without hitting ``ReactorNotRestartable`` exception. Forks own process. 106 | 107 | :param username: student's PID (format: XXXNameXXXX) 108 | where X - integers 109 | :type username: str 110 | :param password: student's password for student portal 111 | :type password: str 112 | :param chatID: 9-Digit unique user ID 113 | :type chatID: str 114 | """ 115 | def f(q): 116 | try: 117 | runner = crawler.CrawlerRunner({ 118 | 'ITEM_PIPELINES': {'scraper.pipelines.LecturePipeline': 300, 119 | 'scraper.pipelines.PracticalPipeline': 400, 120 | 'scraper.pipelines.AttendanceScreenshotPipeline': 500,}, 121 | 122 | 'DOWNLOADER_MIDDLEWARES': {'scrapy_splash.SplashCookiesMiddleware': 723, 123 | 'scrapy_splash.SplashMiddleware': 725, 124 | 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,}, 125 | 126 | 'SPLASH_URL':environ['SPLASH_INSTANCE'], 127 | 'SPIDER_MIDDLEWARES':{'scrapy_splash.SplashDeduplicateArgsMiddleware': 100,}, 128 | 'DUPEFILTER_CLASS':'scrapy_splash.SplashAwareDupeFilter', 129 | 'USER_AGENT': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36', 130 | }) 131 | deferred = runner.crawl(AttendanceSpider, username=username, password=password, chatID=chatID) 132 | deferred.addBoth(lambda _: reactor.stop()) 133 | reactor.run() 134 | q.put(None) 135 | except Exception as e: 136 | q.put(e) 137 | 138 | q = Queue() 139 | p = Process(target=f, args=(q,)) 140 | p.start() 141 | result = q.get() 142 | p.join() 143 | 144 | if result is not None: 145 | raise result 146 | -------------------------------------------------------------------------------- /mis_bot/misbot/attendance_target.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import textwrap 3 | 4 | from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove 5 | from telegram.ext import ConversationHandler 6 | 7 | from scraper.models import Misc 8 | from scraper.database import db_session 9 | from misbot.decorators import signed_up, premium 10 | from misbot.states import SELECT_YN, INPUT_TARGET, UPDATE_TARGET 11 | from misbot.mis_utils import until_x, get_user_info, get_misc_record 12 | from misbot.analytics import mp 13 | 14 | # Enable logging 15 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 16 | level=logging.INFO) 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | @signed_up 21 | @premium(tier=1) 22 | def attendance_target(bot, update): 23 | """Like :func:`until_eighty`, but with user specified target attendance percentage 24 | which is stored in the ``Misc`` table. 25 | If target isn't set, asks users whether they'd like to and passes control to 26 | :py:func:`select_yn` 27 | 28 | :param bot: Telegram Bot object 29 | :type bot: telegram.bot.Bot 30 | :param update: Telegram Update object 31 | :type update: telegram.update.Update 32 | :return: SELECT_YN 33 | :rtype: int 34 | """ 35 | 36 | bot.send_chat_action(chat_id=update.message.chat_id, action='typing') 37 | 38 | student_misc = get_misc_record(update.message.chat_id) 39 | 40 | target = student_misc.attendance_target 41 | 42 | if target is None: 43 | messageContent = textwrap.dedent(""" 44 | You have not set a target yet. Would you like to set it now? 45 | You can change it anytime using /edit_target 46 | """) 47 | keyboard = [['Yes'], ['No']] 48 | reply_markup = ReplyKeyboardMarkup(keyboard) 49 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, reply_markup=reply_markup) 50 | return SELECT_YN 51 | 52 | no_of_lectures = int(until_x(update.message.chat_id, target)) 53 | if no_of_lectures < 0: 54 | messageContent = "Your attendance is already over {}%. Maybe set it higher? Use /edit_target to change it.".format(target) 55 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent) 56 | return ConversationHandler.END 57 | 58 | messageContent = "You need to attend {} lectures to meet your target of {}%".format(no_of_lectures, target) 59 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent) 60 | 61 | username = get_user_info(update.message.chat_id)['PID'] 62 | mp.track(username, 'Check Attendance Target') 63 | return ConversationHandler.END 64 | 65 | 66 | def select_yn(bot, update): 67 | """If user replies no, ends the conversation, 68 | otherwise transfers control to :py:func:`input_target`. 69 | 70 | :param bot: Telegram Bot object 71 | :type bot: telegram.bot.Bot 72 | :param update: Telegram Update object 73 | :type update: telegram.update.Update 74 | :return: INPUT_TARGET 75 | :rtype: int 76 | """ 77 | reply_markup = ReplyKeyboardRemove() 78 | 79 | if update.message.text == 'No': 80 | messageContent = "Maybe next time!" 81 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, reply_markup=reply_markup) 82 | return ConversationHandler.END 83 | messageContent = textwrap.dedent(""" 84 | Okay, give me a figure! 85 | 86 | e.g: If you want a target of 80%, send `80` 87 | """) 88 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, parse_mode='markdown', reply_markup=reply_markup) 89 | return INPUT_TARGET 90 | 91 | 92 | def input_target(bot, update): 93 | """If the user reply is a int/float and between 1-99, stores the figure 94 | as the new attendance target. 95 | 96 | :param bot: Telegram Bot object 97 | :type bot: telegram.bot.Bot 98 | :param update: Telegram Update object 99 | :type update: telegram.update.Update 100 | :return: ConversationHandler.END 101 | :rtype: int 102 | """ 103 | 104 | try: 105 | target_figure = float(update.message.text) 106 | except ValueError: 107 | bot.sendMessage(chat_id=update.message.chat_id, text="You must send a number between 1-99.") 108 | return 109 | 110 | if not 1 <= target_figure <= 99: 111 | bot.sendMessage(chat_id=update.message.chat_id, text="You must send a number between 1-99.") 112 | return 113 | 114 | db_session.query(Misc).filter(Misc.chatID == update.message.chat_id).update({'attendance_target': target_figure}) 115 | db_session.commit() 116 | messageContent = "Your attendance target has been set to {}%.".format(target_figure) 117 | username = get_user_info(update.message.chat_id)['PID'] 118 | logger.info("Set attendance target for {} to {}%".format(username, target_figure)) 119 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent) 120 | 121 | mp.track(username, 'Set Attendance Target', {'target': target_figure}) 122 | return ConversationHandler.END 123 | 124 | 125 | @signed_up 126 | @premium(tier=1) 127 | def edit_attendance_target(bot, update): 128 | """Edit existing attendance target. Shows current target and transfers 129 | control to :py:func:`update_target` 130 | 131 | :param bot: Telegram Bot object 132 | :type bot: telegram.bot.Bot 133 | :param update: Telegram Update object 134 | :type update: telegram.update.Update 135 | :return: UPDATE_TARGET 136 | :rtype: int 137 | """ 138 | student_misc_model = get_misc_record(update.message.chat_id) 139 | messageContent = "You do not have any target records. To create one, use /target" 140 | if student_misc_model is None: 141 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent) 142 | return ConversationHandler.END 143 | 144 | current_target = student_misc_model.attendance_target 145 | 146 | if current_target is None: 147 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent) 148 | return ConversationHandler.END 149 | 150 | edit_message = textwrap.dedent(""" 151 | Your current attendance target is {}%. 152 | Send a new figure to update or /cancel to cancel this operation 153 | """.format(current_target)) 154 | 155 | bot.sendMessage(chat_id=update.message.chat_id, text=edit_message) 156 | return UPDATE_TARGET 157 | 158 | 159 | def update_target(bot, update): 160 | """Takes the sent figure and sets it as new attendance target. 161 | 162 | :param bot: Telegram Bot object 163 | :type bot: telegram.bot.Bot 164 | :param update: Telegram Update object 165 | :type update: telegram.update.Update 166 | :return: ConversationHandler.END 167 | :rtype: int 168 | """ 169 | 170 | user_reply = update.message.text 171 | 172 | if user_reply == '/cancel': 173 | bot.sendMessage(chat_id=update.message.chat_id, text="As you wish! The operation is cancelled!") 174 | return ConversationHandler.END 175 | 176 | try: 177 | new_target = int(user_reply) 178 | except ValueError: 179 | bot.sendMessage(chat_id=update.message.chat_id, text="You must send a number between 1-99.") 180 | return 181 | 182 | if not 1 <= new_target <= 99: 183 | bot.sendMessage(chat_id=update.message.chat_id, text="You must send a number between 1-99.") 184 | return 185 | 186 | old_target = get_misc_record(update.message.chat_id).attendance_target 187 | db_session.query(Misc).filter(Misc.chatID == update.message.chat_id).update({'attendance_target': new_target}) 188 | db_session.commit() 189 | username = get_user_info(update.message.chat_id)['PID'] 190 | logger.info("Modified attendance target for {} to {}%".format(username, new_target)) 191 | new_target_message = "Your attendance target has been updated to {}%!".format(new_target) 192 | bot.sendMessage(chat_id=update.message.chat_id, text=new_target_message) 193 | 194 | mp.track(username, 'Edit Attendance Target', {'new_target': new_target, 'old_target': old_target }) 195 | return ConversationHandler.END 196 | -------------------------------------------------------------------------------- /mis_bot/misbot/bunk.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove 4 | from telegram.ext import ConversationHandler 5 | 6 | from scraper.models import Lecture, Practical 7 | from scraper.database import db_session 8 | from misbot.mis_utils import bunk_lecture, get_subject_name, build_menu, get_user_info 9 | from misbot.decorators import signed_up, premium 10 | from misbot.states import CHOOSING, INPUT, CALCULATING 11 | from misbot.analytics import mp 12 | 13 | @signed_up 14 | @premium(tier=1) 15 | def bunk(bot, update): 16 | """ 17 | Starting point of bunk_handler. 18 | Sends a KeyboardMarkup (https://core.telegram.org/bots#keyboards) 19 | Passes control to :py:func:`bunk_choose` 20 | 21 | :param bot: Telegram Bot object 22 | :type bot: telegram.bot.Bot 23 | :param update: Telegram Update object 24 | :type update: telegram.update.Update 25 | :return: CHOOSING 26 | :rtype: int 27 | """ 28 | bot.send_chat_action(chat_id=update.message.chat_id, action='typing') 29 | keyboard = [['Lectures'], ['Practicals']] 30 | reply_markup = ReplyKeyboardMarkup(keyboard) 31 | messageContent = "Select type!" 32 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, reply_markup=reply_markup) 33 | 34 | return CHOOSING 35 | 36 | 37 | def bunk_choose(bot, update, user_data): 38 | """Removes keyboardMarkup sent in previous handler. 39 | 40 | Stores the response (for Lectures/Practicals message sent in previous handler) in a ``user_data`` 41 | dictionary with the key `"stype"`. 42 | ``user_data`` is a user relative dictionary which holds data between different handlers/functions 43 | in a ConversationHandler. 44 | 45 | Selects the appropriate table (Lecture or Practical) based on ``stype`` value. 46 | Checks if records exist in the table for a user and sends a warning message or proceeds 47 | to list names of all subjects in the table. 48 | 49 | Passes control to :py:func:`bunk_input` 50 | 51 | :param bot: Telegram Bot object 52 | :type bot: telegram.bot.Bot 53 | :param update: Telegram Update object 54 | :type update: telegram.update.Update 55 | :param user_data: User data dictionary 56 | :type user_data: dict 57 | :return: ConversationHandler.END if no records else INPUT 58 | :rtype: int 59 | """ 60 | chat_id = update.message.chat_id 61 | 62 | if update.message.text == "Lectures": 63 | user_data['type'] = "Lectures" 64 | elif update.message.text == "Practicals": 65 | user_data['type'] = "Practicals" 66 | else: 67 | bot.sendMessage(chat_id=chat_id, text="Please choose out of the two options.") 68 | return 69 | 70 | stype = user_data['type'] 71 | reply_markup = ReplyKeyboardRemove() 72 | reply_text = "{}\nChoose `Cancel` to exit.".format(stype) 73 | bot.sendMessage(chat_id=chat_id, text=reply_text, reply_markup=reply_markup, parse_mode='markdown') 74 | 75 | if stype == "Lectures": 76 | subject_data = Lecture.query.filter(Lecture.chatID == chat_id).all() 77 | else: 78 | subject_data = Practical.query.filter(Practical.chatID == chat_id).all() 79 | 80 | if not subject_data: #If list is empty 81 | messageContent = textwrap.dedent(""" 82 | No records found! 83 | Please use /attendance to pull your attendance from the website first. 84 | """) 85 | bot.sendMessage(chat_id=chat_id, text=messageContent) 86 | return ConversationHandler.END 87 | 88 | messageContent = "" 89 | 90 | for digit, subject in enumerate(subject_data): 91 | subject_name = subject.name 92 | messageContent += "{digit}. {subject_name}\n".format(digit=digit+1, subject_name=subject_name) 93 | 94 | keyboard = build_menu(subject_data, 3, footer_buttons='Cancel') 95 | reply_markup = ReplyKeyboardMarkup(keyboard) 96 | user_data['reply_markup'] = reply_markup 97 | bot.sendMessage(chat_id=chat_id, text=messageContent, reply_markup=reply_markup) 98 | return INPUT 99 | 100 | 101 | def bunk_input(bot, update, user_data): 102 | """Stores index of the chosen subject in ``user_data['index']`` from ``update.message.text``. 103 | Passes control to :py:func:`bunk_calc` 104 | 105 | :param bot: Telegram Bot object 106 | :type bot: telegram.bot.Bot 107 | :param update: Telegram Update object 108 | :type update: telegram.update.Update 109 | :param user_data: User data dictionary 110 | :type user_data: dict 111 | :return: ConversationHandler.END if message is "Cancel" else CALCULATING 112 | :rtype: int 113 | """ 114 | reply_markup = ReplyKeyboardRemove() 115 | if update.message.text == "Cancel": 116 | # Terminate bunk operation since fallback commands do not work with 117 | # 2 conversation handlers present for some reason 118 | # if you figure it out, I'll buy you coffee 119 | bot.sendMessage(chat_id=update.message.chat_id, text="Bunk operation cancelled! 😊", reply_markup=reply_markup) 120 | return ConversationHandler.END 121 | 122 | try: 123 | user_data['index'] = int(update.message.text) 124 | except ValueError: 125 | bot.sendMessage(chat_id=update.message.chat_id, text="Please select a number from the menu.") 126 | return 127 | 128 | messageContent = textwrap.dedent(""" 129 | Send number of lectures you wish to bunk and total lectures conducted for that subject on that day, 130 | separated by a space. 131 | 132 | e.g: If you wish to bunk 1 out of 5 lectures (total or per subject) conducted today, send 133 | `1 5` 134 | Use /cancel to exit. 135 | """) 136 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, parse_mode='markdown', reply_markup=reply_markup) 137 | return CALCULATING 138 | 139 | 140 | def bunk_calc(bot, update, user_data): 141 | """Calculate the % drop/rise with the previously given values from the user 142 | and send the response to the user. 143 | 144 | ``user_data`` contains: ``type``, ``index``, ``figures``. 145 | 146 | Incorrect no. of arguments resets the state and user is asked for input again. 147 | 148 | :param bot: Telegram Bot object 149 | :type bot: telegram.bot.Bot 150 | :param update: Telegram Update object 151 | :type update: telegram.update.Update 152 | :param user_data: User data dictionary 153 | :type user_data: dict 154 | :return: None if incorrect values else INPUT 155 | :rtype: None or int 156 | """ 157 | user_data['figures'] = update.message.text 158 | chat_id = update.message.chat_id 159 | 160 | if user_data['figures'] == '/cancel': 161 | bot.sendMessage(chat_id=chat_id, text="Bunk operation cancelled! 😊") 162 | return ConversationHandler.END 163 | 164 | stype = user_data['type'] 165 | index = user_data['index'] 166 | args = user_data['figures'].split(' ') 167 | 168 | bot.send_chat_action(chat_id=chat_id, action='typing') 169 | 170 | if len(args) == 2: 171 | current = bunk_lecture(0, 0, chat_id, stype, index) 172 | predicted = bunk_lecture(int(args[0]), int(args[1]), chat_id, stype, index) 173 | no_bunk = bunk_lecture(0, int(args[1]), chat_id, stype, index) 174 | loss = round((current - predicted), 2) 175 | gain = round((no_bunk - current), 2) 176 | 177 | messageContent = textwrap.dedent(""" 178 | Subject: {subject} 179 | Current: {current}% 180 | If you bunk: {predicted}% 181 | If you attend: {no_bunk}% 182 | 183 | Loss: {loss}% 184 | Gain: {gain}% 185 | 186 | If you wish to check for another subject, select the respective number or press `Cancel` to cancel 187 | this operation. 188 | """).format(current=current, predicted=predicted, no_bunk=no_bunk, loss=loss, gain=gain, 189 | subject=get_subject_name(chat_id, index, stype)) 190 | bot.sendMessage(chat_id=chat_id, text=messageContent, reply_markup=user_data['reply_markup'], parse_mode='markdown') 191 | 192 | username = get_user_info(chat_id)['PID'] 193 | mp.track(username, 'Bunk', {'category': stype }) 194 | return INPUT 195 | else: 196 | messageContent = textwrap.dedent(""" 197 | This command expects 2 arguments. 198 | 199 | e.g: If you wish to bunk 1 out of 5 total lectures conducted today, send 200 | `1 5` 201 | Or, send /cancel to quit. 202 | """) 203 | bot.sendMessage(chat_id=chat_id, text=messageContent, parse_mode='markdown') 204 | return 205 | return ConversationHandler.END 206 | -------------------------------------------------------------------------------- /mis_bot/telegram_bot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import logging 4 | 5 | from telegram.ext import Updater, CommandHandler, MessageHandler, Filters, ConversationHandler 6 | from dotenv import load_dotenv 7 | 8 | load_dotenv(verbose=True) 9 | 10 | from misbot.admin import (push_notification, notification_message, notification_confirm, revert_notification, 11 | ask_uuid, confirm_revert, clean_all_attendance_records, make_premium, ask_username, 12 | confirm_user, input_tier, input_validity, confirm_otp, extend_premium, extend_ask_username, 13 | extend_confirm_user, extend_input_days, admin_commands_list) 14 | from misbot.attendance_target import attendance_target, select_yn, input_target, edit_attendance_target, update_target 15 | from misbot.bunk import bunk, bunk_choose, bunk_input, bunk_calc 16 | from misbot.decorators import signed_up, admin 17 | from misbot.general import (start, register, credentials, parent_login, delete, cancel, unknown, help_text, 18 | tips, error_callback, subscription) 19 | from misbot.mis_utils import bunk_lecture, until_x, check_login, check_parent_login, crop_image 20 | from misbot.push_notifications import push_message_threaded, get_user_list, delete_threaded 21 | from misbot.spider_functions import attendance, results, itinerary, profile 22 | from misbot.states import * 23 | from misbot.until_func import until, until_eighty 24 | 25 | from scraper.database import db_session, init_db 26 | from scraper.models import Chat, Lecture, Practical, Misc, PushNotification, PushMessage 27 | 28 | TOKEN = os.environ['TOKEN'] 29 | updater = Updater(TOKEN) 30 | 31 | # Enable logging 32 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 33 | level=logging.INFO) 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | def main(): 38 | """Start the bot and use webhook to detect and respond to new messages.""" 39 | init_db() 40 | dispatcher = updater.dispatcher 41 | 42 | # Handlers 43 | conv_handler = ConversationHandler( 44 | entry_points=[CommandHandler('start', start), CommandHandler('register', register, pass_user_data=True)], 45 | 46 | states={ 47 | CREDENTIALS: [MessageHandler(Filters.text, credentials, pass_user_data=True)], 48 | PARENT_LGN: [MessageHandler(Filters.text, parent_login, pass_user_data=True)] 49 | }, 50 | 51 | fallbacks=[CommandHandler('cancel', cancel)] 52 | ) 53 | 54 | bunk_handler = ConversationHandler( 55 | entry_points=[CommandHandler('bunk', bunk)], 56 | 57 | states={ 58 | CHOOSING: [MessageHandler(Filters.text | Filters.command, bunk_choose, pass_user_data=True)], 59 | INPUT: [MessageHandler(Filters.text, bunk_input, pass_user_data=True)], 60 | CALCULATING: [MessageHandler(Filters.text | Filters.command, bunk_calc, pass_user_data=True)] 61 | }, 62 | 63 | fallbacks=[CommandHandler('cancel', cancel)] 64 | ) 65 | 66 | attendance_target_handler = ConversationHandler( 67 | entry_points=[CommandHandler('target', attendance_target)], 68 | states={ 69 | SELECT_YN: [MessageHandler(Filters.text, select_yn)], 70 | INPUT_TARGET: [MessageHandler(Filters.text, input_target)] 71 | }, 72 | 73 | fallbacks=[CommandHandler('cancel', cancel)] 74 | ) 75 | 76 | edit_attendance_target_handler = ConversationHandler( 77 | entry_points=[CommandHandler('edit_target', edit_attendance_target)], 78 | states={ 79 | UPDATE_TARGET: [MessageHandler(Filters.text | Filters.command, update_target)], 80 | }, 81 | 82 | fallbacks=[CommandHandler('cancel', cancel)] 83 | ) 84 | 85 | push_notification_handler = ConversationHandler( 86 | entry_points=[CommandHandler('push', push_notification)], 87 | 88 | states={ 89 | NOTIF_MESSAGE: [MessageHandler(Filters.text, notification_message, pass_user_data=True)], 90 | NOTIF_CONFIRM: [MessageHandler(Filters.text, notification_confirm, pass_user_data=True)], 91 | }, 92 | 93 | fallbacks=[CommandHandler('cancel', cancel)] 94 | ) 95 | 96 | delete_notification_handler = ConversationHandler( 97 | entry_points=[CommandHandler('revert', revert_notification)], 98 | 99 | states={ 100 | ASK_UUID: [MessageHandler(Filters.text, ask_uuid, pass_user_data=True)], 101 | CONFIRM_REVERT: [MessageHandler(Filters.text | Filters.command, confirm_revert, pass_user_data=True)], 102 | }, 103 | 104 | fallbacks=[CommandHandler('cancel', cancel)] 105 | ) 106 | 107 | make_premium_handler = ConversationHandler( 108 | entry_points=[CommandHandler('elevate', make_premium)], 109 | 110 | states = { 111 | ASK_USERNAME: [MessageHandler(Filters.regex(r'^\d{3}\w+\d{4}$'), ask_username, pass_user_data=True)], 112 | CONFIRM_USER: [MessageHandler(Filters.text, confirm_user, pass_user_data=True)], 113 | INPUT_TIER: [MessageHandler(Filters.text, input_tier, pass_user_data=True)], 114 | INPUT_VALIDITY: [MessageHandler(Filters.text, input_validity, pass_user_data=True)], 115 | CONFIRM_OTP: [MessageHandler(Filters.text, confirm_otp, pass_user_data=True)] 116 | }, 117 | 118 | fallbacks=[CommandHandler('cancel', cancel)] 119 | 120 | ) 121 | 122 | extend_premium_handler = ConversationHandler( 123 | entry_points=[CommandHandler('extend', extend_premium)], 124 | 125 | states={ 126 | EXTEND_ASK_USERNAME: [MessageHandler(Filters.regex(r'^\d{3}\w+\d{4}$'), extend_ask_username, pass_user_data=True)], 127 | EXTEND_CONFIRM_USER: [MessageHandler(Filters.text, extend_confirm_user, pass_user_data=True)], 128 | EXTEND_INPUT_DAYS: [MessageHandler(Filters.text, extend_input_days, pass_user_data=True)] 129 | }, 130 | 131 | fallbacks=[CommandHandler('cancel', cancel)] 132 | ) 133 | 134 | clean_records_handler = CommandHandler('clean', clean_all_attendance_records) 135 | admin_cmds_handler = CommandHandler('admin', admin_commands_list) 136 | 137 | attendance_handler = CommandHandler('attendance', attendance) 138 | results_handler = CommandHandler('results', results) 139 | itinerary_handler = CommandHandler('itinerary', itinerary, pass_args=True) 140 | profile_handler = CommandHandler('profile', profile) 141 | eighty_handler = CommandHandler('until80', until_eighty) 142 | until_handler = CommandHandler('until', until, pass_args=True) 143 | delete_handler = CommandHandler('delete', delete) 144 | help_handler = CommandHandler('help', help_text) 145 | tips_handler = CommandHandler('tips', tips) 146 | subscription_handler = CommandHandler('subscription', subscription) 147 | unknown_message = MessageHandler(Filters.text | Filters.command, unknown) 148 | 149 | # Dispatchers 150 | 151 | # User Commands 152 | dispatcher.add_handler(conv_handler) 153 | dispatcher.add_handler(delete_handler) 154 | dispatcher.add_handler(attendance_handler) 155 | dispatcher.add_handler(results_handler) 156 | dispatcher.add_handler(profile_handler) 157 | dispatcher.add_handler(itinerary_handler) 158 | dispatcher.add_handler(bunk_handler) 159 | dispatcher.add_handler(eighty_handler) 160 | dispatcher.add_handler(until_handler) 161 | dispatcher.add_handler(attendance_target_handler) 162 | dispatcher.add_handler(edit_attendance_target_handler) 163 | dispatcher.add_handler(help_handler) 164 | dispatcher.add_handler(tips_handler) 165 | dispatcher.add_handler(subscription_handler) 166 | 167 | # Admin Commands 168 | dispatcher.add_handler(push_notification_handler) 169 | dispatcher.add_handler(delete_notification_handler) 170 | dispatcher.add_handler(make_premium_handler) 171 | dispatcher.add_handler(clean_records_handler) 172 | dispatcher.add_handler(extend_premium_handler) 173 | dispatcher.add_handler(admin_cmds_handler) 174 | 175 | # Miscellaneous 176 | dispatcher.add_handler(unknown_message) 177 | dispatcher.add_error_handler(error_callback) 178 | 179 | if DEBUG: 180 | updater.start_polling(clean=True) 181 | updater.idle() 182 | else: 183 | webhook_url = 'https://{0}:8443/{1}'.format(os.environ['URL'], TOKEN) 184 | updater.start_webhook(listen='0.0.0.0', 185 | port=8443, 186 | url_path=TOKEN, 187 | key='files/private.key', 188 | cert='files/cert.pem', 189 | webhook_url=webhook_url, 190 | clean=True) 191 | updater.idle() 192 | 193 | if __name__ == '__main__': 194 | DEBUG = False # Do not have this variable as True in production 195 | main() 196 | -------------------------------------------------------------------------------- /mis_bot/scraper/pipelines.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from os import remove, environ 3 | import logging 4 | 5 | from telegram.bot import Bot 6 | from telegram.error import TelegramError 7 | from sqlalchemy import and_ 8 | 9 | from scraper.database import db_session 10 | from scraper.models import Lecture, Practical, Chat, Misc 11 | from scraper.items import Lectures, Practicals 12 | 13 | from misbot.mis_utils import until_x, crop_image 14 | from misbot.analytics import mp 15 | 16 | bot = Bot(environ['TOKEN']) 17 | 18 | # Enable logging 19 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 20 | level=logging.INFO) 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | class LecturePipeline(object): 25 | def process_item(self, item, spider): 26 | if not isinstance(item, Lectures): 27 | return item #Do nothing for Practical Items (NOT Lectures) 28 | 29 | if not Lecture.query.filter(and_(Lecture.chatID == spider.chatID, Lecture.name == item['subject'])).first(): 30 | record = Lecture(name=item['subject'], 31 | chatID=spider.chatID, 32 | attended=item['attended'], 33 | conducted=item['conducted']) 34 | db_session.add(record) 35 | db_session.commit() 36 | 37 | else: 38 | db_session.query(Lecture).filter(and_(Lecture.chatID == spider.chatID, Lecture.name == item['subject'])).\ 39 | update({'attended': item['attended'], 40 | 'conducted':item['conducted']}) 41 | db_session.commit() 42 | return item 43 | 44 | class PracticalPipeline(object): 45 | def process_item(self, item, spider): 46 | if not isinstance(item, Practicals): 47 | return item #Do nothing for Lectures Items (NOT Practicals) 48 | 49 | if not Practical.query.filter(and_(Practical.chatID == spider.chatID, Practical.name == item['subject'])).first(): 50 | record = Practical(name=item['subject'], 51 | chatID=spider.chatID, 52 | attended=item['attended'], 53 | conducted=item['conducted']) 54 | db_session.add(record) 55 | db_session.commit() 56 | 57 | else: 58 | db_session.query(Practical).filter(and_(Practical.chatID == spider.chatID, Practical.name == item['subject'])).\ 59 | update({'attended': item['attended'], 60 | 'conducted':item['conducted']}) 61 | db_session.commit() 62 | return item 63 | 64 | class AttendanceScreenshotPipeline(object): 65 | def close_spider(self, spider): 66 | student_misc = Misc.query.filter(Misc.chatID == spider.chatID).first() 67 | try: 68 | bot.send_photo(chat_id=spider.chatID, photo=open("files/{}_attendance.png".format(spider.username), 'rb'), 69 | caption='Attendance Report for {}'.format(spider.username)) 70 | if student_misc is not None and student_misc.attendance_target is not None: 71 | target = student_misc.attendance_target 72 | no_of_lectures = int(until_x(spider.chatID, target)) 73 | if no_of_lectures > 0: 74 | messageContent = "You need to attend {} lectures to meet your target of {}%".format(no_of_lectures, target) 75 | bot.sendMessage(chat_id=spider.chatID, text=messageContent) 76 | 77 | remove('files/{}_attendance.png'.format(spider.username)) #Delete saved image 78 | mp.track(spider.username, 'Attendance') 79 | except IOError: 80 | bot.sendMessage(chat_id=spider.chatID, text='The bot is experiencing some issues. Please try again later.') 81 | logger.warning("Attendance screenshot failed! Check if site is blocking us or if Splash is up.") 82 | mp.track(spider.username, 'Error', {'type': 'Site down?' }) 83 | except TelegramError as te: 84 | logger.warning("TelegramError: {}".format(str(te))) 85 | mp.track(spider.username, 'Error', {'type': 'TelegramError', 'error': str(te) }) 86 | 87 | class ItineraryScreenshotPipeline(object): 88 | def close_spider(self, spider): 89 | try: 90 | with open("files/{}_itinerary.png".format(spider.username), "rb") as f: 91 | pass 92 | except IOError: 93 | bot.sendMessage(chat_id=spider.chatID, text='The bot is experiencing some issues. Please try again later.') 94 | logger.warning("Itinerary screenshot failed! Check if site is blocking us or if Splash is up.") 95 | mp.track(spider.username, 'Error', {'type': 'Site down?' }) 96 | return 97 | except TelegramError as te: 98 | logger.warning("TelegramError: {}".format(str(te))) 99 | mp.track(spider.username, 'Error', {'type': 'TelegramError', 'error': str(te) }) 100 | return 101 | 102 | if spider.uncropped: 103 | try: 104 | # arguments supplied, sending full screenshot 105 | bot.send_document(chat_id=spider.chatID, document=open("files/{}_itinerary.png".format(spider.username), 'rb'), 106 | caption='Full Itinerary Report for {}'.format(spider.username)) 107 | remove('files/{}_itinerary.png'.format(spider.username)) #Delete original downloaded image 108 | mp.track(spider.username, 'Itinerary', {'cropped': False }) 109 | return 110 | except TelegramError as te: 111 | logger.warning("TelegramError: {}".format(str(te))) 112 | mp.track(spider.username, 'Error', {'type': 'TelegramError', 'error': str(te) }) 113 | return 114 | 115 | try: 116 | if crop_image("files/{}_itinerary.png".format(spider.username)): 117 | # greater than 800px. cropping and sending.. 118 | bot.send_photo(chat_id=spider.chatID, photo=open("files/{}_itinerary_cropped.png".format(spider.username), 'rb'), 119 | caption='Itinerary Report for {}'.format(spider.username)) 120 | remove('files/{}_itinerary_cropped.png'.format(spider.username)) #Delete cropped image 121 | remove('files/{}_itinerary.png'.format(spider.username)) #Delete original downloaded image 122 | else: 123 | # less than 800px, sending as it is.. 124 | bot.send_photo(chat_id=spider.chatID, photo=open("files/{}_itinerary.png".format(spider.username), 'rb'), 125 | caption='Itinerary Report for {}'.format(spider.username)) 126 | remove('files/{}_itinerary.png'.format(spider.username)) #Delete original downloaded image 127 | mp.track(spider.username, 'Itinerary', {'cropped': True }) 128 | except TelegramError as te: 129 | logger.warning("TelegramError: {}".format(str(te))) 130 | mp.track(spider.username, 'Error', {'type': 'TelegramError', 'error': str(te) }) 131 | 132 | class ResultsScreenshotPipeline(object): 133 | def close_spider(self, spider): 134 | try: 135 | bot.send_photo(chat_id=spider.chatID, photo=open("files/{}_tests.png".format(spider.username), 'rb'), 136 | caption='Test Report for {}'.format(spider.username)) 137 | remove('files/{}_tests.png'.format(spider.username)) #Delete saved image 138 | mp.track(spider.username, 'Results') 139 | except IOError: 140 | bot.sendMessage(chat_id=spider.chatID, text='The bot is experiencing some issues. Please try again later.') 141 | logger.warning("Results screenshot failed! Check if site is blocking us or if Splash is up.") 142 | mp.track(spider.username, 'Error', {'type': 'Site down?' }) 143 | except TelegramError as te: 144 | logger.warning("TelegramError: {}".format(str(te))) 145 | mp.track(spider.username, 'Error', {'type': 'TelegramError', 'error': str(te) }) 146 | 147 | class ProfileScreenshotPipeline(object): 148 | def close_spider(self, spider): 149 | try: 150 | bot.send_document(chat_id=spider.chatID, document=open("files/{}_profile.png".format(spider.username), 'rb'), 151 | caption='Student profile for {}'.format(spider.username)) 152 | remove('files/{}_profile.png'.format(spider.username)) #Delete saved image 153 | mp.track(spider.username, 'Profile') 154 | except IOError: 155 | bot.sendMessage(chat_id=spider.chatID, text='The bot is experiencing some issues. Please try again later.') 156 | logger.warning("Profile screenshot failed! Check if site is blocking us or if Splash is up.") 157 | mp.track(spider.username, 'Error', {'type': 'Site down?' }) 158 | except TelegramError as te: 159 | logger.warning("TelegramError: {}".format(str(te))) 160 | mp.track(spider.username, 'Error', {'type': 'TelegramError', 'error': str(te) }) 161 | -------------------------------------------------------------------------------- /mis_bot/misbot/mis_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | import shutil 3 | from datetime import datetime, timedelta 4 | import random 5 | 6 | from PIL import Image 7 | from sympy.solvers import solve 8 | from sympy import Symbol, Eq, solveset 9 | from sympy.sets.sets import EmptySet 10 | import requests 11 | from sqlalchemy import and_ 12 | from securimage_solver import CaptchaApi 13 | 14 | from scraper.database import db_session 15 | from scraper.models import Chat, Lecture, Practical, RateLimit, Misc 16 | from misbot.message_strings import GIFS 17 | 18 | SECURIMAGE_ENDPOINT = "http://report.aldel.org/securimage/securimage_show.php" 19 | 20 | 21 | 22 | def bunk_lecture(n, tot_lec, chatID, stype, index): 23 | """Calculates % drop/rise if one chooses to bunk certain lectures. 24 | 25 | :param n: number of lectures for a subject to bunk 26 | :type n: int 27 | :param tot_lec: total lectures conducted for that subject 28 | :type tot_lec: int 29 | :param chatID: user's unique 9-digit ChatID from telegram 30 | :type chatID: int | str 31 | :param stype: Subject type (Lectures or Practicals) 32 | :type stype: str 33 | :param index: Index of the user-selected subject from list of subjects 34 | :type index: int 35 | :return: Percentage drop/rise 36 | :rtype: float 37 | """ 38 | if stype == "Lectures": 39 | subject_data = Lecture.query.filter(Lecture.chatID == chatID).all() 40 | else: 41 | subject_data = Practical.query.filter(Practical.chatID == chatID).all() 42 | index -= 1 # DB Tables are Zero-Index 43 | attended = subject_data[index].attended 44 | conducted = subject_data[index].conducted 45 | 46 | result = (((int(attended) + int(tot_lec)) - int(n))/(int(conducted) + tot_lec)) * 100 47 | return round(result, 2) #Round up to 2 decimals. 48 | 49 | 50 | def until_x(chatID, target): 51 | """Calculates the no. of lectures user must attend in order 52 | to get overall attendance to their specified target. 53 | 54 | :param chatID: user's unique 9-digit ChatID from telegram 55 | :type chatID: int | str 56 | :param target: attendance percentage target 57 | :type target: float 58 | :return: Number of lectures to attend 59 | :rtype: int 60 | """ 61 | subject_data = Lecture.query.filter(and_(Lecture.chatID == chatID, Lecture.name == "Total")).first() 62 | attended = subject_data.attended 63 | conducted = subject_data.conducted 64 | x = Symbol('x') 65 | expr = Eq((((int(attended) + x)/(int(conducted) + x))*100), target) 66 | soln = solveset(expr, x) 67 | if isinstance(soln, EmptySet): # If attendance is 100%, soln evaluates to an EmptySet 68 | return -1 69 | return next(iter(soln)) # Extracting the integer from singleton set soln. 70 | 71 | 72 | def check_login(username, password): 73 | """Checks if user input for their credentials is correct 74 | for the student portal. 75 | 76 | :param username: student's PID (format: XXXNameXXXX) 77 | where X - integers 78 | :type username: str 79 | :param password: student's password for student portal 80 | :type password: str 81 | :return: True for correct credentials, false otherwise. 82 | :rtype: bool 83 | """ 84 | base_url = 'http://report.aldel.org/student_page.php' 85 | with requests.session() as s: 86 | r = s.get(base_url) 87 | session_id = str(r.cookies.get('PHPSESSID')) #Get SessionID 88 | captcha_answer = solve_captcha(session_id) #Solve the CAPTCHA 89 | payload = { 90 | 'studentid':username, 91 | 'studentpwd':password, 92 | 'captcha_code':captcha_answer, 93 | 'student_submit':'' 94 | } 95 | s.post(base_url, data=payload) 96 | r = s.get('http://report.aldel.org/student/attendance_report.php') 97 | return username in r.text 98 | 99 | 100 | def check_parent_login(username, dob): 101 | """Checks if user input for their credentials is correct 102 | for parent's portal. 103 | 104 | :param username: student's PID (format: XXXNameXXXX) 105 | where X - integers 106 | :type username: str 107 | :param dob: User's Date of Birth 108 | :type dob: str 109 | :return: True for correct credentials, false otherwise. 110 | :rtype: bool 111 | """ 112 | base_url = 'http://report.aldel.org/parent_page.php' 113 | try: 114 | date, month, year = dob.split('/') 115 | except ValueError: 116 | return False 117 | 118 | with requests.session() as s: 119 | r = s.get(base_url) 120 | session_id = str(r.cookies.get('PHPSESSID')) 121 | captcha_answer = solve_captcha(session_id) 122 | payload = { 123 | 'studentid':username, 124 | 'date_of_birth': date, 125 | 'month_of_birth': month, 126 | 'year_of_birth': year, 127 | 'captcha_code':captcha_answer, 128 | 'parent_submit':'' 129 | } 130 | s.post(base_url, data=payload) 131 | r = s.get('http://report.aldel.org/student/attendance_report.php') 132 | return username in r.text 133 | 134 | 135 | def crop_image(path): 136 | """Crop image if the image height is > 800px. 137 | 138 | :param path: image file path 139 | :type path: str 140 | :return: True for image height larger than 800px in length. 141 | :rtype: bool 142 | """ 143 | img = Image.open(path) 144 | w, h = img.size 145 | 146 | if h>800: 147 | new_path = path[:-4] + "_cropped.png" 148 | img.crop((0, h-700, w, h)).save(new_path) #crop((left, upper, right, lower)) 149 | return True 150 | 151 | 152 | def clean_attendance_records(): 153 | """Delete all lectures and practical records from the DB. 154 | To be used at the beginning of a new semester so that ``/bunk`` command 155 | doesn't display lectures of previous semester(s). 156 | 157 | :return: Number of records deleted from Lecture and Practical tables. 158 | :rtype: tuple 159 | """ 160 | lecture_records = db_session.query(Lecture).delete() 161 | practical_records = db_session.query(Practical).delete() 162 | db_session.commit() 163 | return lecture_records, practical_records 164 | 165 | 166 | def get_user_info(chat_id): 167 | """Give user data. 168 | 169 | :param chat_id: 9-Digit unique user ID 170 | :type chat_id: str 171 | :return: Dictionary of all user data 172 | :rtype: dict 173 | """ 174 | userChat = Chat.query.filter(Chat.chatID == chat_id).first() 175 | if userChat is None: 176 | return None 177 | 178 | return {'PID': userChat.PID, 179 | 'password': userChat.password, 180 | 'DOB': userChat.DOB} 181 | 182 | 183 | def solve_captcha(session_id): 184 | """Solve captcha using ``securimage_solver`` library. 185 | Downloads the image from the securimage_endpoint and 186 | feeds it to securimage_solver lib. 187 | 188 | :param session_id: Session cookie 189 | :type session_id: str 190 | :return: Captcha answer 191 | :rtype: str 192 | """ 193 | 194 | cookie = {'PHPSESSID': session_id} 195 | response = requests.get(SECURIMAGE_ENDPOINT, cookies=cookie, stream=True) 196 | path = "files/captcha/{}.png".format(session_id) 197 | if response.status_code == 200: 198 | with open(path, 'wb') as f: 199 | response.raw.decode_content = True 200 | shutil.copyfileobj(response.raw, f) 201 | 202 | c = CaptchaApi() 203 | captcha_answer = c.predict(path) 204 | return captcha_answer 205 | 206 | 207 | def rate_limited(bot, chat_id, command): 208 | """Checks if user has made a request in the past 5 minutes. 209 | 210 | :param bot: Telegram Bot object 211 | :type bot: telegram.bot.Bot 212 | :param chat_id: 9-Digit unique user ID 213 | :type chat_id: str 214 | :param command: Telegram command 215 | :type command: str 216 | :return: True if user has made a request in past 5 mins, else False 217 | :rtype: bool 218 | """ 219 | rate_limit = RateLimit.query.filter(and_(RateLimit.chatID == chat_id, RateLimit.command == command)).first() 220 | 221 | if rate_limit is None: 222 | new_rate_limit_record = RateLimit(chatID=chat_id, status='new', command=command, count=0) 223 | db_session.add(new_rate_limit_record) 224 | db_session.commit() 225 | rate_limit = RateLimit.query.filter(and_(RateLimit.chatID == chat_id, RateLimit.command == command)).first() 226 | 227 | if abs(datetime.now() - rate_limit.requested_at) < timedelta(minutes=5): 228 | if rate_limit.count < 1: 229 | RateLimit.query.filter(and_(RateLimit.chatID == chat_id, RateLimit.command == command))\ 230 | .update({'count': rate_limit.count + 1}) 231 | db_session.commit() 232 | return False 233 | elif rate_limit.count < 2: 234 | RateLimit.query.filter(and_(RateLimit.chatID == chat_id, RateLimit.command == command))\ 235 | .update({'count': rate_limit.count + 1}) 236 | db_session.commit() 237 | message_content = "You've already used this command in the past 5 minutes. Please wait 5 minutes before sending another request." 238 | bot.send_message(chat_id=chat_id, text=message_content) 239 | return True 240 | 241 | elif rate_limit.count in range(2, 1000): 242 | RateLimit.query.filter(and_(RateLimit.chatID == chat_id, RateLimit.command == command))\ 243 | .update({'count': rate_limit.count + 1}) 244 | db_session.commit() 245 | bot.send_animation(chat_id=chat_id, animation=random.choice(GIFS)) 246 | return True 247 | else: 248 | RateLimit.query.filter(and_(RateLimit.chatID == chat_id, RateLimit.command == command))\ 249 | .update({'count': 1, 'requested_at': datetime.now()}) 250 | db_session.commit() 251 | return False 252 | 253 | def get_subject_name(chat_id, index, stype): 254 | """Return name of subject for a given user 255 | 256 | :param chat_id: 9-Digit unique user ID 257 | :type chat_id: str 258 | :param index: Index of the user-selected subject from list of subjects 259 | :type index: int 260 | :param stype: Subject type (Lectures or Practicals) 261 | :type stype: str 262 | :return: Subject name 263 | :rtype: str 264 | """ 265 | if stype == "Lectures": 266 | subject_data = Lecture.query.filter(Lecture.chatID == chat_id).all() 267 | else: 268 | subject_data = Practical.query.filter(Practical.chatID == chat_id).all() 269 | index -= 1 # DB Tables are Zero-Index 270 | return subject_data[index].name 271 | 272 | 273 | def build_menu(buttons, n_cols, header_buttons=None, footer_buttons=None): 274 | menu = [] 275 | digit = 1 276 | for i in range(0, len(buttons), n_cols): 277 | row = [] 278 | for _ in buttons[i:i +n_cols]: 279 | row.append(str(digit)) 280 | digit += 1 281 | menu.append(row) 282 | 283 | if header_buttons: 284 | menu.insert(0, [header_buttons]) 285 | if footer_buttons: 286 | menu.append([footer_buttons]) 287 | return menu 288 | 289 | def get_misc_record(chat_id): 290 | """Returns Misc record for a user 291 | 292 | :param chat_id: 9-Digit unique user ID 293 | :type chat_id: str 294 | """ 295 | misc_record = Misc.query.filter(Misc.chatID == chat_id).first() 296 | 297 | if misc_record is None: 298 | new_misc_record = Misc(chatID=chat_id) 299 | db_session.add(new_misc_record) 300 | db_session.commit() 301 | misc_record = Misc.query.filter(Misc.chatID == chat_id).first() 302 | return misc_record -------------------------------------------------------------------------------- /mis_bot/misbot/general.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | import logging 3 | import random 4 | import textwrap 5 | 6 | from telegram.ext import ConversationHandler 7 | from telegram.error import TelegramError, Unauthorized, BadRequest, TimedOut, ChatMigrated, NetworkError 8 | from sqlalchemy import and_ 9 | 10 | from scraper.models import Chat, Misc 11 | from scraper.database import db_session 12 | from misbot.mis_utils import check_login, check_parent_login, get_user_info 13 | from misbot.decorators import signed_up 14 | from misbot.states import CREDENTIALS, PARENT_LGN 15 | from misbot.analytics import mp 16 | from misbot.message_strings import SUBSCRIPTION_MSG, REPLY_UNKNOWN, TIPS, HELP 17 | 18 | # Enable logging 19 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 20 | level=logging.INFO) 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def start(bot, update): 26 | """Initial message sent to all users. 27 | Starts registration conversation, passes control to :py:func:`credentials` 28 | """ 29 | intro_message = textwrap.dedent(""" 30 | Hi! I'm a Telegram Bot for Aldel MIS. 31 | My source code lives at [Github.](https://github.com/ArionMiles/MIS-Bot) 👨‍💻 32 | To start using my services, please send me your MIS credentials in this format: 33 | `Student-ID password` 34 | (in a single line, separated by a space) 35 | 36 | Use /cancel to abort. 37 | Use /help to learn more. 38 | Join the [Channel](https://t.me/joinchat/AAAAAEzdjHzLCzMiKpUw6w) to get updates about the bot's status. 39 | """) 40 | bot.sendMessage(chat_id=update.message.chat_id, text=intro_message, parse_mode='markdown',\ 41 | disable_web_page_preview=True) 42 | return CREDENTIALS 43 | 44 | 45 | def register(bot, update, user_data): 46 | """Let all users register with their credentials. 47 | Similar to :py:func:`start` but this function can be invoked by ``/register`` command. 48 | 49 | If user's ``chatID`` & ``DOB`` are already present in database then ends the conversation. 50 | Otherwise, if only ``chatID`` is present, then stores ``StudentID`` (PID) in ``user_data`` dict & 51 | gives control to :py:func:`parent_login` function. 52 | 53 | If both conditions are false, then asks user to input Student details (PID & Password) 54 | and gives control to :py:func:`credentials` 55 | """ 56 | if Chat.query.filter(Chat.chatID == update.message.chat_id).first(): 57 | if Chat.query.filter(and_(Chat.chatID == update.message.chat_id, Chat.DOB != None)).first(): 58 | messageContent = "Already registered!" 59 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent) 60 | return ConversationHandler.END 61 | 62 | student_data = Chat.query.filter(Chat.chatID == update.message.chat_id).first() 63 | user_data['Student_ID'] = student_data.PID 64 | 65 | messageContent = textwrap.dedent(""" 66 | Now enter your Date of Birth (DOB) in the following format: 67 | `DD/MM/YYYY` 68 | """) 69 | update.message.reply_text(messageContent, parse_mode='markdown') 70 | return PARENT_LGN 71 | 72 | messageContent = textwrap.dedent(""" 73 | Okay, send me your MIS credentials in this format: 74 | `Student-ID password` 75 | (in a single line, separated by a space) 76 | 77 | Use /cancel to abort. 78 | """) 79 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, parse_mode='markdown') 80 | return CREDENTIALS 81 | 82 | 83 | def credentials(bot, update, user_data): 84 | """ 85 | Store user credentials in a database. 86 | Takes student info (PID & password) from ``update.message.text`` and splits it into Student_ID & 87 | Password and checks if they are correct with :py:func:`misbot.mis_utils.check_login` and stores them in the ``Chat`` table. 88 | Finally, sends message asking users to enter DOB and gives control to :func:`parent_login` after 89 | storing ``Student_ID`` (PID) in user_data dict. 90 | """ 91 | chat_id = update.message.chat_id 92 | # If message contains less or more than 2 arguments, send message and stop. 93 | try: 94 | Student_ID, password = update.message.text.split() 95 | except ValueError: 96 | messageContent = textwrap.dedent(""" 97 | Oops, you made a mistake! 98 | You must send the Student_ID and password in a single line, separated by a space. 99 | This is what valid login credentials look like: 100 | `123name4567 password` 101 | """) 102 | bot.send_chat_action(chat_id=chat_id, action='typing') 103 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, parse_mode='markdown') 104 | return 105 | 106 | if not check_login(Student_ID, password): 107 | messageContent = textwrap.dedent(""" 108 | Looks like your credentials are incorrect! Give it one more shot. 109 | This is what valid login credentials look like: 110 | `123name4567 password` 111 | """) 112 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, parse_mode='markdown') 113 | return 114 | 115 | # Create an object of Class and store Student_ID, password, and Telegram 116 | # User ID, Add it to the database, commit it to the database. 117 | 118 | userChat = Chat(PID=Student_ID, password=password, chatID=chat_id) 119 | db_session.add(userChat) 120 | db_session.commit() 121 | 122 | 123 | messageContent = textwrap.dedent(""" 124 | Now enter your Date of Birth (DOB) in the following format: 125 | `DD/MM/YYYY` 126 | """) 127 | update.message.reply_text(messageContent, parse_mode='markdown') 128 | user_data['Student_ID'] = Student_ID 129 | return PARENT_LGN 130 | 131 | 132 | def parent_login(bot, update, user_data): 133 | """ 134 | user_data dict contains ``Student_ID`` key from :py:func:`credentials`. 135 | Extracts DOB from ``update.message.text`` and checks validity using :py:func:`misbot.mis_utils.check_parent_login` 136 | before adding it to database. 137 | Finally, sends a message to the user requesting them to start using ``/attendance`` or 138 | ``/itinerary`` commands. 139 | """ 140 | DOB = update.message.text 141 | Student_ID = user_data['Student_ID'] 142 | chatID = update.message.chat_id 143 | 144 | if not check_parent_login(Student_ID, DOB): 145 | messageContent = textwrap.dedent(""" 146 | Looks like your Date of Birth details are incorrect! Give it one more shot. 147 | Send DOB in the below format: 148 | `DD/MM/YYYY` 149 | """) 150 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, parse_mode='markdown') 151 | return 152 | new_user = Student_ID[3:-4].title() 153 | 154 | db_session.query(Chat).filter(Chat.chatID == chatID).update({'DOB': DOB}) 155 | db_session.commit() 156 | logger.info("New Registration! Username: {}".format((Student_ID))) 157 | messageContent = "Welcome {}!\nStart by checking your /attendance or /itinerary".format(new_user) 158 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, parse_mode='markdown') 159 | 160 | mp.track(Student_ID, 'New User') 161 | mp.people_set(Student_ID, { 162 | 'pid': Student_ID, 163 | 'first_name': update.message.from_user.first_name, 164 | 'last_name': update.message.from_user.last_name, 165 | 'username': update.message.from_user.username, 166 | 'link': update.message.from_user.link, 167 | 'active': True, 168 | }) 169 | 170 | return ConversationHandler.END 171 | 172 | 173 | @signed_up 174 | def delete(bot, update): 175 | """Delete a user's credentials if they wish to stop using the bot or update them.""" 176 | chatID = update.message.chat_id 177 | username = get_user_info(chatID)['PID'] 178 | logger.info("Deleting user credentials for {}!".format(username)) 179 | Chat.query.filter(Chat.chatID == chatID).delete() # Delete the user's record referenced by their ChatID 180 | Misc.query.filter(Misc.chatID == chatID).delete() 181 | db_session.commit() 182 | messageContent = "Your credentials have been deleted, {}\nHope to see you back soon!".format(username[3:-4].title()) 183 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent) 184 | 185 | mp.track(username, 'User Left') 186 | mp.people_set(username, {'active': False }) 187 | 188 | 189 | def cancel(bot, update): 190 | """Cancel registration operation (terminates conv_handler)""" 191 | bot.sendMessage(chat_id=update.message.chat_id, text="As you wish, the operation has been cancelled! 😊") 192 | return ConversationHandler.END 193 | 194 | 195 | def unknown(bot, update): 196 | """Respond to incomprehensible messages/commands with some canned responses.""" 197 | messageContent = random.choice(REPLY_UNKNOWN) 198 | bot.send_chat_action(chat_id=update.message.chat_id, action='typing') 199 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent) 200 | 201 | 202 | def help_text(bot, update): 203 | """Display help text.""" 204 | bot.sendMessage(chat_id=update.message.chat_id, text=HELP) 205 | 206 | 207 | def tips(bot, update): 208 | """Send a random tip about the bot.""" 209 | messageContent = random.choice(TIPS) 210 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, parse_mode='markdown') 211 | 212 | def error_callback(bot, update, error): 213 | """Simple error handling function. Handles PTB lib errors""" 214 | user = get_user_info(update.message.chat_id) 215 | username = update.message.chat_id if user is None else user['PID'] 216 | 217 | try: 218 | raise error 219 | except Unauthorized: 220 | # remove update.message.chat_id from conversation list 221 | mp.track(username, 'Error', {'type': 'Unauthorized' }) 222 | logger.warning("TelegramError: Unauthorized user. User probably blocked the bot.") 223 | except BadRequest as br: 224 | # handle malformed requests 225 | mp.track(username, 'Error', {'type': 'BadRequest', 'text': update.message.text, 'error': str(br) }) 226 | logger.warning("TelegramError: {} | Text: {} | From: {}".format(str(br), update.message.text, update.message.from_user)) 227 | except TimedOut as time_out: 228 | # handle slow connection problems 229 | mp.track(username, 'Error', {'type': 'TimedOut', 'text': update.message.text, 'error': str(time_out) }) 230 | logger.warning("TelegramError: {} | Text: {} | From: {}".format(str(time_out), update.message.text, update.message.from_user)) 231 | except NetworkError as ne: 232 | # handle other connection problems 233 | mp.track(username, 'Error', {'type': 'NetworkError', 'text': update.message.text, 'error': str(ne) }) 234 | logger.warning("TelegramError: {} | Text: {} | From: {}".format(str(ne), update.message.text, update.message.from_user)) 235 | except ChatMigrated as cm: 236 | # the chat_id of a group has changed, use e.new_chat_id instead 237 | mp.track(username, 'Error', {'type': 'ChatMigrated' }) 238 | logger.warning("TelegramError: {} | Text: {} | From: {}".format(str(cm), update.message.text, update.message.from_user)) 239 | except TelegramError as e: 240 | # handle all other telegram related errors 241 | mp.track(username, 'Error', {'type': 'TelegramError', 'text': update.message.text, 'error': str(e) }) 242 | logger.warning("TelegramError: {} | Text: {} | From: {}".format(str(e), update.message.text, update.message.from_user)) 243 | 244 | @signed_up 245 | def subscription(bot, update): 246 | """Sends text detailing the subscription model""" 247 | chat_id = update.message.chat_id 248 | bot.sendMessage(chat_id=chat_id, text=SUBSCRIPTION_MSG, parse_mode='markdown', 249 | disable_web_page_preview=True) 250 | 251 | mp.track(get_user_info(chat_id)['PID'], 'Checked Subscription') 252 | -------------------------------------------------------------------------------- /mis_bot/misbot/admin.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from datetime import datetime, timedelta 3 | from random import randint 4 | 5 | from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove 6 | from telegram.ext import ConversationHandler 7 | from sqlalchemy import and_ 8 | 9 | from misbot.decorators import admin 10 | from misbot.push_notifications import push_message_threaded, delete_threaded, get_user_list 11 | from misbot.states import * 12 | from misbot.mis_utils import clean_attendance_records, build_menu, get_misc_record 13 | from misbot.message_strings import ADMIN_COMMANDS_TXT 14 | from scraper.models import PushMessage, PushNotification, Misc, Chat 15 | from scraper.database import db_session 16 | 17 | 18 | @admin 19 | def push_notification(bot, update): 20 | """Starts Push notification conversation. Asks for message and 21 | transfers control to :py:func:`notification_message` 22 | 23 | :param bot: Telegram Bot object 24 | :type bot: telegram.bot.Bot 25 | :param update: Telegram Update object 26 | :type update: telegram.update.Update 27 | :return: NOTIF_MESSAGE 28 | :rtype: int 29 | """ 30 | 31 | bot.sendMessage(chat_id=update.message.chat_id, text="Send me the text") 32 | return NOTIF_MESSAGE 33 | 34 | 35 | def notification_message(bot, update, user_data): 36 | """Ask for confirmation, stores the message in ``user_data`` dictionary, 37 | transfer control to :py:func:`notification_confirm` 38 | 39 | :param bot: Telegram Bot object 40 | :type bot: telegram.bot.Bot 41 | :param update: Telegram Update object 42 | :type update: telegram.update.Update 43 | :param user_data: User data dictionary 44 | :type user_data: dict 45 | :return: NOTIF_CONFIRM 46 | :rtype: int 47 | """ 48 | 49 | 50 | user_data['notif_message']= update.message.text 51 | keyboard = [['Yes'], ['No']] 52 | reply_markup = ReplyKeyboardMarkup(keyboard) 53 | messageContent = "Targeting {} users. Requesting confirmation...".format(len(get_user_list())) 54 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, reply_markup=reply_markup) 55 | return NOTIF_CONFIRM 56 | 57 | 58 | def notification_confirm(bot, update, user_data): 59 | """Sends message if "Yes" is sent. Aborts if "No" is sent. 60 | Sends a message with statistics like users reached, time taken after sending 61 | push notification. 62 | 63 | :param bot: Telegram Bot object 64 | :type bot: telegram.bot.Bot 65 | :param update: Telegram Update object 66 | :type update: telegram.update.Update 67 | :param user_data: User data dictionary 68 | :type user_data: dict 69 | :return: ConversationHandler.END 70 | :rtype: int 71 | """ 72 | 73 | reply_markup = ReplyKeyboardRemove() 74 | if update.message.text == "Yes": 75 | users = get_user_list() 76 | bot.sendMessage(chat_id=update.message.chat_id, text="Sending push message...", reply_markup=reply_markup) 77 | time_taken, message_uuid = push_message_threaded(user_data['notif_message'], users) 78 | stats_message = textwrap.dedent(""" 79 | Sent to {} users in {:.2f}secs. 80 | Here's your unique notification ID: `{}` 81 | """.format(len(users), time_taken, message_uuid)) 82 | bot.sendMessage(chat_id=update.message.chat_id, text=stats_message, parse_mode='markdown') 83 | return ConversationHandler.END 84 | elif update.message.text == "No": 85 | bot.sendMessage(chat_id=update.message.chat_id, text="Aborted!", reply_markup=reply_markup) 86 | return ConversationHandler.END 87 | return 88 | 89 | 90 | @admin 91 | def revert_notification(bot, update): 92 | """Delete a previously sent push notification. 93 | Ask for ``uuid`` of message to delete and pass control to :func:`ask_uuid` 94 | 95 | :param bot: Telegram Bot object 96 | :type bot: telegram.bot.Bot 97 | :param update: Telegram Update object 98 | :type update: telegram.update.Update 99 | """ 100 | messageContent = "Send the UUID of the message you wish to revert." 101 | update.message.reply_text(messageContent) 102 | return ASK_UUID 103 | 104 | 105 | def ask_uuid(bot, update, user_data): 106 | """Store the ``uuid``, send confirmation message 107 | and pass control to :func:`confirm_revert` 108 | 109 | :param bot: Telegram Bot object 110 | :type bot: telegram.bot.Bot 111 | :param update: Telegram Update object 112 | :type update: telegram.update.Update 113 | :param user_data: Conversation data 114 | :type user_data: dict 115 | """ 116 | user_data['uuid'] = update.message.text 117 | keyboard = [['Yes'], ['No']] 118 | reply_markup = ReplyKeyboardMarkup(keyboard) 119 | messageContent = "Are you sure you want to revert?" 120 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, reply_markup=reply_markup) 121 | return CONFIRM_REVERT 122 | 123 | 124 | def confirm_revert(bot, update, user_data): 125 | """If Yes, revert the specified message for all 126 | users and send a summary of the operation to admin. 127 | 128 | :param bot: Telegram Bot object 129 | :type bot: telegram.bot.Bot 130 | :param update: Telegram Update object 131 | :type update: telegram.update.Update 132 | :param user_data: Conversation data 133 | :type user_data: dict 134 | """ 135 | reply_markup = ReplyKeyboardRemove() 136 | 137 | if update.message.text == "Yes": 138 | try: 139 | notification_message = PushMessage.query.filter(PushMessage.uuid == user_data['uuid']).first().text 140 | except AttributeError: 141 | bot.sendMessage(chat_id=update.message.chat_id, text="Unknown UUID. Try again.", reply_markup=reply_markup) 142 | return ConversationHandler.END 143 | notifications = PushNotification.query.filter(and_(PushNotification.message_uuid == user_data['uuid'],\ 144 | PushNotification.sent == True)) 145 | user_list = [notification.chatID for notification in notifications] 146 | message_ids = [notification.message_id for notification in notifications] 147 | 148 | time_taken = delete_threaded(message_ids, user_list) 149 | 150 | 151 | notification_message_short = textwrap.shorten(notification_message, width=20, placeholder='...') 152 | 153 | stats_message = textwrap.dedent(""" 154 | Deleted the notification: 155 | ``` 156 | {} 157 | ``` 158 | {} messages deleted in {:.2f}secs. 159 | """.format(notification_message_short, len(message_ids), time_taken)) 160 | 161 | bot.sendMessage(chat_id=update.message.chat_id, text=stats_message, parse_mode='markdown', reply_markup=reply_markup) 162 | 163 | db_session.query(PushMessage).filter(PushMessage.uuid == user_data['uuid']).update({'deleted': True}) 164 | db_session.commit() 165 | return ConversationHandler.END 166 | elif update.message.text == "No" or update.message.text == "/cancel": 167 | bot.sendMessage(chat_id=update.message.chat_id, text="Revert Aborted!", reply_markup=reply_markup) 168 | return ConversationHandler.END 169 | return 170 | 171 | @admin 172 | def clean_all_attendance_records(bot, update): 173 | """Activated by ``/clean`` command. 174 | See :py:func:`misbot.mis_utils.clean_attendance_records` to 175 | see what this does. 176 | 177 | :param bot: Telegram Bot object 178 | :type bot: telegram.bot.Bot 179 | :param update: Telegram Update object 180 | :type update: telegram.update.Update 181 | """ 182 | lectures, practicals = clean_attendance_records() 183 | message_content = "{} Lecture and {} Practical records cleared.".format(lectures, practicals) 184 | update.message.reply_text(message_content) 185 | 186 | @admin 187 | def make_premium(bot, update): 188 | bot.sendMessage(chat_id=update.message.chat_id, text="Please send the username!") 189 | return ASK_USERNAME 190 | 191 | def ask_username(bot, update, user_data): 192 | user_data['username'] = update.message.text 193 | user_records = Chat.query.filter(Chat.PID == user_data['username']).all() 194 | messageContent = "Records of student {}\n".format(user_data['username']) 195 | for index, user in enumerate(user_records): 196 | chat_id = user.chatID 197 | messageContent += "{index}. {chat_id}\n".format(index=index+1, chat_id=chat_id) 198 | 199 | keyboard = build_menu(user_records, 3, footer_buttons='Cancel') 200 | reply_markup = ReplyKeyboardMarkup(keyboard) 201 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, reply_markup=reply_markup) 202 | return CONFIRM_USER 203 | 204 | def confirm_user(bot, update, user_data): 205 | reply_markup = ReplyKeyboardRemove() 206 | if update.message.text == "Cancel": 207 | bot.sendMessage(chat_id=update.message.chat_id, text="Operation cancelled! 😊", reply_markup=reply_markup) 208 | return ConversationHandler.END 209 | 210 | try: 211 | user_data['index'] = int(update.message.text) - 1 # Zero-based indexing 212 | except ValueError: 213 | bot.sendMessage(chat_id=update.message.chat_id, text="Please select a number from the menu.") 214 | return 215 | 216 | bot.sendMessage(chat_id=update.message.chat_id, text="What's the subscription tier for this user?", reply_markup=reply_markup) 217 | return INPUT_TIER 218 | 219 | def input_tier(bot, update, user_data): 220 | try: 221 | user_data['tier'] = int(update.message.text) 222 | except ValueError: 223 | bot.sendMessage(chat_id=update.message.chat_id, text="Please send a number.") 224 | return 225 | 226 | bot.sendMessage(chat_id=update.message.chat_id, text="This user is premium for how many days?") 227 | return INPUT_VALIDITY 228 | 229 | def input_validity(bot, update, user_data): 230 | try: 231 | user_data['validity_days'] = int(update.message.text) 232 | except ValueError: 233 | bot.sendMessage(chat_id=update.message.chat_id, text="Please send a number.") 234 | return 235 | 236 | try: 237 | user_data['user_record'] = Chat.query.filter(Chat.PID == user_data['username'])[user_data['index']] 238 | except IndexError: 239 | bot.sendMessage(chat_id=update.message.chat_id, text="The selected user does not exist! Bye!") 240 | return ConversationHandler.END 241 | 242 | otp = randint(1111, 9999) 243 | otp_message_user = "Your OTP is *{}*. Kindly share it with the administrator.".format(otp) 244 | otp_message_admin = "The OTP is *{}*. Confirm with the user.".format(otp) 245 | 246 | bot.sendMessage(chat_id=user_data['user_record'].chatID, text=otp_message_user, parse_mode='markdown') 247 | 248 | keyboard = [["Confirm"], ["Abort"]] 249 | reply_markup = ReplyKeyboardMarkup(keyboard) 250 | bot.sendMessage(chat_id=update.message.chat_id, text=otp_message_admin, reply_markup=reply_markup, parse_mode='markdown') 251 | return CONFIRM_OTP 252 | 253 | def confirm_otp(bot, update, user_data): 254 | reply_markup = ReplyKeyboardRemove() 255 | admin_response = update.message.text 256 | if admin_response == "Confirm": 257 | pass 258 | elif admin_response == "Abort": 259 | bot.sendMessage(chat_id=update.message.chat_id, text="Operation Aborted!", reply_markup=reply_markup) 260 | return ConversationHandler.END 261 | else: 262 | bot.sendMessage(chat_id=update.message.chat_id, text="Choose one of the two options.") 263 | return 264 | 265 | misc_record = get_misc_record(user_data['user_record'].chatID) 266 | misc_record.premium_user = True 267 | misc_record.premium_tier = user_data['tier'] 268 | misc_record.premium_till = datetime.now() + timedelta(days=user_data['validity_days']) 269 | db_session.commit() 270 | 271 | date_beautified = misc_record.premium_till.strftime("%B %d, %Y") 272 | 273 | message_content = "Your premium subscription is active and valid till: {}!".format(date_beautified) 274 | bot.sendMessage(chat_id=user_data['user_record'].chatID, text=message_content) 275 | 276 | admin_message = "{} has been elevated to premium!\n Valid till: {}".format(user_data['username'], date_beautified) 277 | bot.sendMessage(chat_id=update.message.chat_id, text=admin_message, reply_markup=reply_markup) 278 | return ConversationHandler.END 279 | 280 | 281 | @admin 282 | def extend_premium(bot, update): 283 | bot.sendMessage(chat_id=update.message.chat_id, text="Please send the username!") 284 | return EXTEND_ASK_USERNAME 285 | 286 | def extend_ask_username(bot, update, user_data): 287 | user_data['username'] = update.message.text 288 | user_records = Chat.query.filter(Chat.PID == user_data['username']).all() 289 | messageContent = "Records of student {}\n".format(user_data['username']) 290 | for index, user in enumerate(user_records): 291 | chat_id = user.chatID 292 | messageContent += "{index}. {chat_id}\n".format(index=index+1, chat_id=chat_id) 293 | 294 | keyboard = build_menu(user_records, 3, footer_buttons='Cancel') 295 | reply_markup = ReplyKeyboardMarkup(keyboard) 296 | bot.sendMessage(chat_id=update.message.chat_id, text=messageContent, reply_markup=reply_markup) 297 | return EXTEND_CONFIRM_USER 298 | 299 | 300 | def extend_confirm_user(bot, update, user_data): 301 | reply_markup = ReplyKeyboardRemove() 302 | if update.message.text == "Cancel": 303 | bot.sendMessage(chat_id=update.message.chat_id, text="Operation cancelled! 😊", reply_markup=reply_markup) 304 | return ConversationHandler.END 305 | 306 | try: 307 | user_data['index'] = int(update.message.text) - 1 # Zero-based indexing 308 | except ValueError: 309 | bot.sendMessage(chat_id=update.message.chat_id, text="Please select a number from the menu.") 310 | return 311 | 312 | bot.sendMessage(chat_id=update.message.chat_id, text="Extend this user's premium by how many days?", reply_markup=reply_markup) 313 | return EXTEND_INPUT_DAYS 314 | 315 | 316 | def extend_input_days(bot, update, user_data): 317 | try: 318 | user_data['extension_period'] = int(update.message.text) 319 | except ValueError: 320 | bot.sendMessage(chat_id=update.message.chat_id, text="Please send a number.") 321 | return 322 | user_record = Chat.query.filter(Chat.PID == user_data['username'])[user_data['index']] 323 | misc_record = get_misc_record(user_record.chatID) 324 | misc_record.premium_till += timedelta(days=user_data['extension_period']) 325 | db_session.commit() 326 | 327 | date_beautified = misc_record.premium_till.strftime("%B %d, %Y") 328 | message_content = "Your subscription has been extended till {}. Cheers!".format(date_beautified) 329 | bot.sendMessage(chat_id=user_record.chatID, text=message_content) 330 | admin_message = "Subscription for {} extended by {} day(s) ({})".format(user_record.PID, 331 | user_data['extension_period'], 332 | date_beautified) 333 | bot.sendMessage(chat_id=update.message.chat_id, text=admin_message) 334 | return ConversationHandler.END 335 | 336 | @admin 337 | def admin_commands_list(bot, update): 338 | bot.sendMessage(chat_id=update.message.chat_id, text=ADMIN_COMMANDS_TXT) 339 | --------------------------------------------------------------------------------