├── logs └── .gitignore ├── migrations ├── README ├── script.py.mako ├── alembic.ini ├── versions │ ├── 00d31c3cee48_todoist_id_added_state_modified.py │ ├── 8b0b3bbd0cac_state_is_active_last_active_at_fields_.py │ └── 8ca2c1eef1b2_.py └── env.py ├── logo.png ├── scripts ├── ngrok.sh ├── runserver.sh ├── notebook.sh ├── get_updates.sh ├── freeze.sh ├── virtualenv.sh ├── update.sh └── install_requirements.sh ├── logo_square.png ├── settings ├── __init__.py └── global_settings.py ├── app ├── telegram │ ├── emoji.py │ ├── __init__.py │ ├── command.py │ ├── models.py │ └── handlers.py ├── utils.py ├── __init__.py └── todoist │ └── __init__.py ├── .gitignore ├── commands.txt ├── requirements-to-freeze.txt ├── run.py ├── requirements.txt ├── README.md └── LICENSE /logs/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihoru/todoist_bot/HEAD/logo.png -------------------------------------------------------------------------------- /scripts/ngrok.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ngrok http todoist-l.iho.su:8100 4 | -------------------------------------------------------------------------------- /logo_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihoru/todoist_bot/HEAD/logo_square.png -------------------------------------------------------------------------------- /settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .global_settings import * 2 | from .local_settings import * 3 | -------------------------------------------------------------------------------- /app/telegram/emoji.py: -------------------------------------------------------------------------------- 1 | GREY_EXCLAMATION = '❕' 2 | RED_EXCLAMATION = '❗️' 3 | DOUBLE_RED_EXCLAMATION = '‼️' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | ENV/ 3 | __pycache__/ 4 | app.db 5 | /settings/local_settings.py 6 | sync/ 7 | *.ipynb 8 | *.log 9 | /update.sh 10 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | 4 | def md5(data: str): 5 | m = hashlib.md5() 6 | m.update(data.encode()) 7 | return m.hexdigest() 8 | -------------------------------------------------------------------------------- /commands.txt: -------------------------------------------------------------------------------- 1 | start - Begin dialog with bot 2 | labels - Send list of your labels 3 | projects - Send list of your projects 4 | stop - Deny bot to send anything you 5 | -------------------------------------------------------------------------------- /scripts/runserver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR=`dirname $0`; 4 | cd ${DIR}/..; 5 | source $PWD/ENV/bin/activate; 6 | python run.py runserver --host todoist-l.iho.su --port 8100; 7 | -------------------------------------------------------------------------------- /requirements-to-freeze.txt: -------------------------------------------------------------------------------- 1 | ipython==7.10.2 2 | Flask==0.12.4 3 | Flask-Migrate==2.0.4 4 | Flask-SQLAlchemy==2.2 5 | python-telegram-bot==6.1.0 6 | requests==2.22.0 7 | todoist-python==8.1.1 8 | -------------------------------------------------------------------------------- /scripts/notebook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Calling this script: 4 | # ./scripts/notebook.sh 5 | 6 | DIR=`dirname $0`; 7 | cd ${DIR}/..; 8 | source $PWD/ENV/bin/activate; 9 | jupyter notebook $@; 10 | -------------------------------------------------------------------------------- /scripts/get_updates.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR=`dirname $0`; 4 | cd ${DIR}/..; 5 | source $PWD/ENV/bin/activate; 6 | pkill -f "tail \-n0 \-f $PWD/logs/" 7 | tail -n0 -f $PWD/logs/*/debug.log & 8 | python run.py bot get_updates; 9 | -------------------------------------------------------------------------------- /scripts/freeze.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Calling this script: 4 | # ./scripts/freeze.sh 5 | 6 | DIR=`dirname $0`; 7 | cd ${DIR}/..; 8 | source $PWD/ENV/bin/activate; 9 | pip freeze --local -r $PWD/requirements-to-freeze.txt | grep -v pkg-resources==0.0.0 | tee $PWD/requirements.txt; -------------------------------------------------------------------------------- /scripts/virtualenv.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Calling this script: 4 | # ./scripts/virtualenv.sh 5 | 6 | DIR=`dirname $0`; 7 | cd ${DIR}/..; 8 | virtualenv -p python3.6 --system-site-packages ENV; 9 | # --system-site-packages is needed if you want to be able to use jupyter and ipython if they are installed system-wide -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from flask_migrate import MigrateCommand 4 | from flask_script import Manager 5 | 6 | from app import app 7 | from app.telegram.command import BotCommand 8 | 9 | manager = Manager(app) 10 | manager.add_command('db', MigrateCommand) 11 | manager.add_command('bot', BotCommand) 12 | 13 | if __name__ == '__main__': 14 | manager.run() 15 | -------------------------------------------------------------------------------- /settings/global_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '') 4 | SQLALCHEMY_DATABASE_URI = 'sqlite:///' + BASE_DIR + 'app.db' 5 | SQLALCHEMY_TRACK_MODIFICATIONS = True 6 | 7 | PROJECT_REPOSITORY_LINK = 'https://github.com/ihoru/todoist_bot' 8 | 9 | TODOIST = { 10 | 'URL': 'https://todoist.com/oauth/access_token', 11 | 'CLIENT_ID': '', 12 | 'CLIENT_SECRET': '', 13 | 'CACHE': os.path.join(BASE_DIR, 'sync', ''), 14 | } 15 | 16 | BOT_TOKEN = '' 17 | -------------------------------------------------------------------------------- /scripts/update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DIR=`dirname $0`; 4 | cd ${DIR}; 5 | if [[ ! -d ENV ]]; 6 | then 7 | # you can make this link in the root dir: 8 | # ln -s scripts/update.sh 9 | # just because it is faster to call it 10 | cd ..; 11 | fi; 12 | source $PWD/ENV/bin/activate; 13 | 14 | git fetch; 15 | STASH=$([[ `git stash` = 'No local changes to save' ]]); 16 | git pull; 17 | if [[ $STASH -eq 0 ]]; 18 | then 19 | git stash pop >/dev/null; 20 | fi; 21 | $PWD/scripts/install_requirements.sh; 22 | 23 | supervisorctl restart todoist_web -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ipython==7.10.2 2 | Flask==0.12.4 3 | Flask-Migrate==2.0.4 4 | Flask-SQLAlchemy==2.2 5 | python-telegram-bot==6.1.0 6 | requests==2.22.0 7 | todoist-python==8.1.1 8 | ## The following requirements were added by pip freeze: 9 | alembic==0.9.3 10 | backcall==0.1.0 11 | certifi==2017.4.17 12 | click==6.7 13 | decorator==4.1.1 14 | Flask-Script==2.0.5 15 | idna==2.5 16 | itsdangerous==0.24 17 | jedi==0.10.2 18 | Jinja2==2.9.6 19 | prompt-toolkit==2.0.10 20 | ptyprocess==0.5.2 21 | python-editor==1.0.3 22 | six==1.10.0 23 | SQLAlchemy==1.1.11 24 | tornado==6.0.3 25 | urllib3==1.24.2 26 | Werkzeug==0.15.3 27 | -------------------------------------------------------------------------------- /app/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, abort, request 2 | from flask import current_app as app 3 | from telegram import Update 4 | 5 | telegram = Blueprint('telegram', __name__) 6 | 7 | 8 | @telegram.route('/') 9 | def main(): 10 | return 'Go to the bot!'.format(app.bot.link()) 11 | 12 | 13 | @telegram.route('/bot/', methods=['POST']) 14 | def webhook(hashsum): 15 | if hashsum != app.config['BOT_HASHSUM']: 16 | abort(403) 17 | from app.telegram.handlers import bot 18 | update = Update.de_json(request.json, bot()) 19 | bot().dispatcher.process_update(update) 20 | return 'Ok' 21 | -------------------------------------------------------------------------------- /scripts/install_requirements.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Calling this script: 4 | # ./scripts/install_requirements.sh # good for production 5 | # or 6 | # ./scripts/install_requirements.sh 1 # to install from freeze-file (the top-level requirements, that app use). Good for development 7 | # ! it will automatically create ENV directory for installing dependencies there ! 8 | 9 | DIR=`dirname $0`; 10 | cd ${DIR}/..; 11 | if [[ ! -d $PWD/ENV ]]; 12 | then 13 | $PWD/scripts/virtualenv.sh; 14 | fi; 15 | F=''; 16 | if [[ -n $1 ]]; 17 | then 18 | F='-to-freeze'; 19 | fi; 20 | source $PWD/ENV/bin/activate; 21 | pip install --compile -r $PWD/requirements$F.txt; 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Todoist bot fro Telegram 2 | @Todoist_bot for Telegram (UNofficial). 3 | 4 | ![Bot logo](https://raw.githubusercontent.com/ihoru/todoist_bot/master/logo.png) 5 | 6 | # Safety of you personal data 7 | I promise, that I don't use your personal infomation and don't make it public. 8 | 9 | # Bot's functionality 10 | * authorization in Todoist.com 11 | * creation new tasks 12 | * getting notifications about reminders 13 | * getting list of labels 14 | * getting list of projects 15 | 16 | # Possibly to implement 17 | * deletion of an accidetally added task (or with incorrect parsed date/time) 18 | * completing a task that was notified 19 | * ability to erase all your private information from bot's database 20 | 21 | # Contribution 22 | Any kind of contribution would be highly appreciated! 23 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import Flask 4 | from flask_migrate import Migrate 5 | from flask_sqlalchemy import SQLAlchemy 6 | 7 | from app.utils import md5 8 | 9 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO) 10 | logger = logging.getLogger() 11 | logger.addHandler(logging.FileHandler('logs/debug.log')) 12 | 13 | app = Flask(__name__) 14 | 15 | app.config.from_object('settings') 16 | app.config['BOT_HASHSUM'] = md5(app.config['BOT_TOKEN']) 17 | 18 | db = SQLAlchemy(app) 19 | 20 | from app.telegram import telegram 21 | from app.telegram import handlers 22 | from app.todoist import todoist 23 | 24 | app.register_blueprint(telegram) 25 | app.register_blueprint(handlers.handlers) 26 | app.register_blueprint(todoist) 27 | app.bot = handlers.MyBot(app.config['BOT_TOKEN']) 28 | 29 | migrate = Migrate(app, db) 30 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ihor Polyakov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /migrations/versions/00d31c3cee48_todoist_id_added_state_modified.py: -------------------------------------------------------------------------------- 1 | """todoist_id added, state modified 2 | 3 | Revision ID: 00d31c3cee48 4 | Revises: 8b0b3bbd0cac 5 | Create Date: 2017-07-21 09:26:06.930874 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '00d31c3cee48' 13 | down_revision = '8b0b3bbd0cac' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column('user', sa.Column('todoist_id', sa.BigInteger(), nullable=True)) 21 | op.create_index(op.f('ix_user_state'), 'user', ['state'], unique=False) 22 | op.create_index(op.f('ix_user_todoist_id'), 'user', ['todoist_id'], unique=False) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_index(op.f('ix_user_todoist_id'), table_name='user') 29 | op.drop_index(op.f('ix_user_state'), table_name='user') 30 | op.drop_column('user', 'todoist_id') 31 | # ### end Alembic commands ### 32 | -------------------------------------------------------------------------------- /migrations/versions/8b0b3bbd0cac_state_is_active_last_active_at_fields_.py: -------------------------------------------------------------------------------- 1 | """state, is_active, last_active_at fields added 2 | 3 | Revision ID: 8b0b3bbd0cac 4 | Revises: 8ca2c1eef1b2 5 | Create Date: 2017-07-21 05:04:58.336792 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '8b0b3bbd0cac' 13 | down_revision = '8ca2c1eef1b2' 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column('user', sa.Column('is_active', sa.Boolean(), nullable=True)) 21 | op.add_column('user', sa.Column('last_active_at', sa.DateTime(), nullable=True)) 22 | op.add_column('user', sa.Column('state', sa.String(length=64), nullable=True)) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | with op.batch_alter_table('user') as batch_op: 29 | batch_op.drop_column('state') 30 | batch_op.drop_column('last_active_at') 31 | batch_op.drop_column('is_active') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/8ca2c1eef1b2_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 8ca2c1eef1b2 4 | Revises: 5 | Create Date: 2017-07-21 01:38:29.276014 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '8ca2c1eef1b2' 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table('user', 21 | sa.Column('id', sa.Integer(), nullable=False), 22 | sa.Column('tg_id', sa.BigInteger(), nullable=True), 23 | sa.Column('first_name', sa.String(length=255), nullable=True), 24 | sa.Column('last_name', sa.String(length=255), nullable=True), 25 | sa.Column('username', sa.String(length=100), nullable=True), 26 | sa.Column('auth', sa.String(length=255), nullable=True), 27 | sa.Column('created_at', sa.DateTime(), nullable=False), 28 | sa.PrimaryKeyConstraint('id'), 29 | sa.UniqueConstraint('tg_id') 30 | ) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_table('user') 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /app/telegram/command.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, url_for 2 | from flask import current_app as app 3 | from flask_script import Manager 4 | from telegram.ext import Updater 5 | 6 | from app.telegram.handlers import bot 7 | from app.telegram.models import User 8 | 9 | allowed_updates = ['message'] 10 | BotCommand = Manager(usage='Bot manipulation commands') 11 | 12 | bot_command = Blueprint('bot_command', __name__) 13 | 14 | 15 | @BotCommand.command 16 | def get_updates(): 17 | updater = Updater(bot=bot(), workers=1) 18 | for attr in ('handlers', 'groups', 'error_handlers'): 19 | setattr(updater.dispatcher, attr, getattr(bot().dispatcher, attr)) 20 | updater.start_polling(timeout=60, allowed_updates=allowed_updates) 21 | updater.idle() 22 | 23 | 24 | @BotCommand.command 25 | def set_webhook(): 26 | url = url_for('telegram.webhook', hashsum=app.config['BOT_HASHSUM'], _external=True, _scheme='https') 27 | return bot().set_webhook(url, allowed_updates=allowed_updates) 28 | 29 | 30 | @BotCommand.command 31 | def delete_webhook(): 32 | return bot().delete_webhook() 33 | 34 | 35 | @BotCommand.command 36 | def get_webhook_info(): 37 | return bot().get_webhook_info() 38 | 39 | 40 | @BotCommand.command 41 | def send_all(text): 42 | users = User.query.all() 43 | for user in users: 44 | print(user.id) 45 | result = user.send_message(text) 46 | print(bool(result)) 47 | return 'ok' 48 | -------------------------------------------------------------------------------- /app/telegram/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from flask import current_app as app 4 | from telegram import Message, Update 5 | from telegram.error import Unauthorized 6 | from todoist import TodoistAPI 7 | from todoist.api import SyncError 8 | 9 | from app import db 10 | 11 | 12 | class User(db.Model): 13 | id = db.Column(db.Integer, primary_key=True) 14 | tg_id = db.Column(db.BigInteger, unique=True) 15 | first_name = db.Column(db.String(255)) 16 | last_name = db.Column(db.String(255)) 17 | username = db.Column(db.String(100)) 18 | todoist_id = db.Column(db.BigInteger, nullable=True, index=True) 19 | state = db.Column(db.String(36), default='', index=True) 20 | auth = db.Column(db.String(255), default='') 21 | is_active = db.Column(db.Boolean, default=True) 22 | created_at = db.Column(db.DateTime, nullable=False) 23 | last_active_at = db.Column(db.DateTime, nullable=True) 24 | 25 | # noinspection PyTypeChecker 26 | def __init__(self, *args, **kwargs): 27 | super().__init__(*args, **kwargs) 28 | self.update = None # type: Update 29 | self.message = None # type: Message 30 | self.api = None # type: TodoistAPI 31 | self.now = None # type: datetime 32 | 33 | def is_authorized(self): 34 | return bool(self.auth) 35 | 36 | def first_init_api(self): 37 | self.init_api() 38 | if not self.is_authorized(): 39 | return 40 | from app.telegram.handlers import bot 41 | bot().base_welcome(self) 42 | 43 | def init_api(self): 44 | if not self.is_authorized(): 45 | return 46 | try: 47 | with app.app_context(): 48 | self.api = TodoistAPI(self.auth, cache=app.config['TODOIST']['CACHE']) 49 | result = self.api.sync() 50 | if 'error' in result: 51 | raise SyncError 52 | if 'user' in result and not self.todoist_id: 53 | self.todoist_id = result['user']['id'] 54 | except SyncError: 55 | self.auth = '' 56 | self.todoist_id = None 57 | self.api = None 58 | 59 | def send_message(self, text, **kwargs): 60 | from app.telegram.handlers import bot 61 | try: 62 | return bot().send_message(self.tg_id, text, **kwargs) 63 | except Unauthorized: 64 | self.is_active = False 65 | db.session.add(self) 66 | db.session.commit() 67 | return False 68 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from alembic import context 7 | from sqlalchemy import engine_from_config, pool 8 | 9 | # this is the Alembic Config object, which provides 10 | # access to the values within the .ini file in use. 11 | config = context.config 12 | 13 | # Interpret the config file for Python logging. 14 | # This line sets up loggers basically. 15 | fileConfig(config.config_file_name) 16 | logger = logging.getLogger('alembic.env') 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | # from myapp import mymodel 21 | # target_metadata = mymodel.Base.metadata 22 | from flask import current_app 23 | 24 | config.set_main_option('sqlalchemy.url', 25 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 26 | target_metadata = current_app.extensions['migrate'].db.metadata 27 | 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def run_migrations_offline(): 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure(url=url) 49 | 50 | with context.begin_transaction(): 51 | context.run_migrations() 52 | 53 | 54 | def run_migrations_online(): 55 | """Run migrations in 'online' mode. 56 | 57 | In this scenario we need to create an Engine 58 | and associate a connection with the context. 59 | 60 | """ 61 | 62 | # this callback is used to prevent an auto-migration from being generated 63 | # when there are no changes to the schema 64 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 65 | def process_revision_directives(context, revision, directives): 66 | if getattr(config.cmd_opts, 'autogenerate', False): 67 | script = directives[0] 68 | if script.upgrade_ops.is_empty(): 69 | directives[:] = [] 70 | logger.info('No changes in schema detected.') 71 | 72 | engine = engine_from_config(config.get_section(config.config_ini_section), 73 | prefix='sqlalchemy.', 74 | poolclass=pool.NullPool) 75 | 76 | connection = engine.connect() 77 | context.configure(connection=connection, 78 | target_metadata=target_metadata, 79 | process_revision_directives=process_revision_directives, 80 | **current_app.extensions['migrate'].configure_args) 81 | 82 | try: 83 | with context.begin_transaction(): 84 | context.run_migrations() 85 | finally: 86 | connection.close() 87 | 88 | 89 | if context.is_offline_mode(): 90 | run_migrations_offline() 91 | else: 92 | run_migrations_online() 93 | -------------------------------------------------------------------------------- /app/todoist/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import hmac 4 | import logging 5 | 6 | import requests 7 | from flask import Blueprint, redirect, request 8 | from flask import current_app as app 9 | from todoist import TodoistAPI 10 | 11 | from app import db 12 | from app.telegram.models import User 13 | 14 | todoist = Blueprint('todoist', __name__) 15 | 16 | 17 | @todoist.route('/need_auth/') 18 | def need_auth(state): 19 | scope = 'data:read_write,data:delete' 20 | url = 'https://todoist.com/oauth/authorize?client_id={CLIENT_ID}&scope={scope}&state={state}'.format(**app.config['TODOIST'], scope=scope, state=state) 21 | return redirect(url) 22 | 23 | 24 | @todoist.route('/auth') 25 | def auth(): 26 | from app.telegram.handlers import bot 27 | error = request.args.get('error') 28 | if error: 29 | if error in ('access_denied', 'invalid_scope'): 30 | return redirect(bot().link('error_' + error)) 31 | return error 32 | code = request.args.get('code') 33 | state = request.args.get('state') 34 | if not code: 35 | return 'code was not passed' 36 | if not state: 37 | return 'state was not passed' 38 | user = User.query.filter(User.state == state, User.is_active == True).one_or_none() 39 | if not user: 40 | return 'user was not found' 41 | data = dict( 42 | client_id=app.config['TODOIST']['CLIENT_ID'], 43 | client_secret=app.config['TODOIST']['CLIENT_SECRET'], 44 | code=code, 45 | ) 46 | response = requests.post(app.config['TODOIST']['URL'], data=data) 47 | if response.status_code != 200: 48 | return response.text 49 | result = response.json() 50 | if 'error' in result and result['error']: 51 | return result['error'] 52 | user.auth = result['access_token'] 53 | user.state = '' 54 | user.first_init_api() 55 | db.session.add(user) 56 | db.session.commit() 57 | return redirect(bot().link()) 58 | 59 | 60 | @todoist.route('/callback/', methods=['POST']) 61 | def callback(client_id): 62 | if client_id != app.config['TODOIST']['CLIENT_ID']: 63 | return 'wrong client_id' 64 | if 'X-Todoist-Hmac-SHA256' not in request.headers: 65 | return 'no signature' 66 | signature = request.headers['X-Todoist-Hmac-SHA256'] 67 | salt = app.config['TODOIST']['CLIENT_SECRET'].encode() 68 | my_signature = base64.b64encode(hmac.new(salt, request.data, hashlib.sha256).digest()).decode() 69 | if signature != my_signature: 70 | logging.warning('Wrong signature my: {} got: {} data: {}'.format(my_signature, signature, request.data)) 71 | return 'wrong signature' 72 | data = request.json 73 | if not data: 74 | return 'empty json' 75 | if data['event_name'] != 'reminder:fired': 76 | return 'wrong event_name' 77 | user = User.query.filter(User.todoist_id == data['user_id'], User.is_active == True).one_or_none() 78 | if not user: 79 | # TODO: we should revoke authorization for user and delete all his data in Todoist's sync directory 80 | return 'user was not found' 81 | user.init_api() 82 | assert isinstance(user.api, TodoistAPI) 83 | item = user.api.items.get_by_id(data['event_data']['item_id']) 84 | if not item: 85 | return 'item not found' 86 | from app.telegram.handlers import bot 87 | result = bot().notification(user, item) 88 | if not result: 89 | return 'bot was blocked' 90 | return 'Ok!' 91 | -------------------------------------------------------------------------------- /app/telegram/handlers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from uuid import uuid4 4 | 5 | from flask import Blueprint, url_for 6 | from sqlalchemy.orm import Query 7 | from telegram import Bot, Message 8 | from telegram import User as TgUser 9 | from telegram.ext import * 10 | from todoist.models import Item 11 | 12 | from app.telegram import emoji 13 | from app.telegram.models import User, db 14 | 15 | app = None 16 | handlers = Blueprint('handlers', __name__) 17 | 18 | 19 | @handlers.record 20 | def init_app(state): 21 | global app 22 | app = state.app 23 | 24 | 25 | def handler_wrapper(func): 26 | def wrap(self, _, update, *args, **kwargs): 27 | with app.app_context(): 28 | assert isinstance(User.query, Query) 29 | assert isinstance(update.message, Message) 30 | tguser = update.message.from_user 31 | assert isinstance(tguser, TgUser) 32 | user = User.query.filter(User.tg_id == tguser.id).one_or_none() 33 | now = datetime.now() 34 | if not user: 35 | user = User( 36 | tg_id=tguser.id, 37 | first_name=tguser.first_name, 38 | last_name=tguser.last_name or '', 39 | username=tguser.username, 40 | created_at=now, 41 | last_active_at=now, 42 | ) 43 | db.session.add(user) 44 | db.session.commit() 45 | else: 46 | user.first_name = tguser.first_name 47 | user.last_name = tguser.last_name or '' 48 | user.username = tguser.username 49 | user.last_active_at = now 50 | user.is_active = True 51 | user.update = update 52 | user.message = update.message 53 | try: 54 | func(self, user, *args, **kwargs) 55 | except Flow: 56 | pass 57 | db.session.add(user) 58 | db.session.commit() 59 | 60 | return wrap 61 | 62 | 63 | def check_auth(func): 64 | def wrap(self, user: User): 65 | user.init_api() 66 | if not user.is_authorized(): 67 | assert isinstance(self, MyBot), self 68 | user.state = str(uuid4()) 69 | with app.app_context(): 70 | url = url_for('todoist.need_auth', state=user.state, _external=True) 71 | user.send_message( 72 | 'You need to authorize: {}\n'.format(url) + 73 | 'We promise, that we will not share you private information with anyone, or look at it by ourselves.\n' 74 | 'You can check it open source code of this bot: {}'.format(app.config['PROJECT_REPOSITORY_LINK']), 75 | disable_web_page_preview=True, 76 | ) 77 | raise Stop 78 | func(self, user) 79 | 80 | return wrap 81 | 82 | 83 | def check_start_param(func): 84 | def wrap(self, user: User): 85 | while True: 86 | if not user.message or not user.message.text or not str(user.message.text).startswith('/start '): 87 | break 88 | param = str(user.message.text).split(' ', 1)[1] 89 | if not param.startswith('error_'): 90 | break 91 | with app.app_context(): 92 | if not user.state: 93 | user.state = str(uuid4()) 94 | url = url_for('todoist.need_auth', state=user.state, _external=True) 95 | if param == 'error_access_denied': 96 | user.send_message( 97 | 'If you want to use bot you have to grant us access to your Todoist account: {}\n'.format(url) + 98 | 'We care about your privacy!' 99 | ) 100 | elif param == 'error_invalid_scope': 101 | user.send_message( 102 | 'Shit happens...\n' 103 | 'Please, try again: {}\n'.format(url) 104 | ) 105 | raise Stop 106 | break 107 | func(self, user) 108 | 109 | return wrap 110 | 111 | 112 | class Flow(Exception): 113 | pass 114 | 115 | 116 | class Stop(Flow): 117 | pass 118 | 119 | 120 | class MyBot(Bot): 121 | def __init__(self, token): 122 | super().__init__(token) 123 | # noinspection PyTypeChecker 124 | self.dispatcher = Dispatcher(self, None) 125 | self.process_dispatcher() 126 | 127 | def link(self, start=''): 128 | if start: 129 | start = '?start={}'.format(start) 130 | return 'https://t.me/{}{}'.format(self.username, start) 131 | 132 | @handler_wrapper 133 | @check_start_param 134 | @check_auth 135 | def start(self, user: User): 136 | user.send_message( 137 | 'You are authorized! Everything is good!\n' 138 | 'Now you can create new task just by writing it to me...' 139 | ) 140 | 141 | @handler_wrapper 142 | def stop(self, user: User): 143 | user.send_message('We will not send you anything again until you send a message me.') 144 | user.is_active = False 145 | 146 | @handler_wrapper 147 | @check_auth 148 | def labels(self, user: User): 149 | labels = ' '.join(['@' + label['name'] for label in user.api.labels.all()]) 150 | if labels: 151 | user.send_message('Your labels: ' + labels) 152 | else: 153 | user.send_message('You don\'t have any labels!') 154 | 155 | @handler_wrapper 156 | @check_auth 157 | def projects(self, user: User): 158 | projects = ' '.join(['#' + project['name'] for project in user.api.projects.all()]) 159 | if projects: 160 | user.send_message('Your projects: ' + projects) 161 | else: 162 | user.send_message('You don\'t have any projects!') 163 | 164 | @handler_wrapper 165 | @check_auth 166 | def any_text(self, user: User): 167 | item = user.api.quick.add(user.message.text) 168 | if not item: 169 | user.send_message('Could not create task') 170 | return 171 | labels = item['labels'] 172 | if labels: 173 | labels = ['@' + label.data['name'] for label in user.api.labels.all() if label.data['id'] in labels] 174 | answer = 'Task added:\n{} {} {}'.format(item['content'], ' '.join(labels), item['due'] or '') 175 | user.send_message(answer) 176 | 177 | @handler_wrapper 178 | def any_other(self, user: User): 179 | user.send_message('This type of content is not supported.') 180 | 181 | @handler_wrapper 182 | def group(self, user: User): 183 | user.message.reply_text( 184 | 'Bot is not available in group chat yet!\n' 185 | 'You can talk to me in private dialog: @{username}'.format(username=self.username) 186 | ) 187 | 188 | @handler_wrapper 189 | def error(self, user: User, error): 190 | user.send_message('Error occurred') 191 | logging.warning('Error occurred: {}'.format(error)) 192 | 193 | @handler_wrapper 194 | @check_auth 195 | def welcome(self, user: User): 196 | self.base_welcome(user) 197 | 198 | def base_welcome(self, user: User): 199 | projects = ' '.join(['#' + project['name'] for project in user.api.projects.all()]) 200 | labels = ' '.join(['@' + label['name'] for label in user.api.labels.all()]) 201 | text = 'You were successfully authorized!' 202 | if projects or labels: 203 | text += '\n' 204 | if projects: 205 | text += '\nProjects: ' + projects 206 | if labels: 207 | text += '\nLabels: ' + labels 208 | text += '\n\nNow you can send me a task that you want to save for later...' 209 | user.send_message(text) 210 | 211 | @handler_wrapper 212 | @check_auth 213 | def test_notification(self, user: User): 214 | import random 215 | items = user.api.items.all() 216 | item = random.choice(items) 217 | self.notification(user, item) 218 | 219 | def notification(self, user: User, item: Item): 220 | text = '{} {}'.format(emoji.DOUBLE_RED_EXCLAMATION, item.data['content']) 221 | return user.send_message(text) 222 | 223 | def process_dispatcher(self): 224 | self.dispatcher.add_handler(MessageHandler(Filters.group, self.group)) 225 | self.dispatcher.add_handler(CommandHandler('start', self.start)) 226 | self.dispatcher.add_handler(CommandHandler(['stop', 'off'], self.stop)) 227 | self.dispatcher.add_handler(CommandHandler('labels', self.labels)) 228 | self.dispatcher.add_handler(CommandHandler('projects', self.projects)) 229 | self.dispatcher.add_handler(CommandHandler('welcome', self.welcome)) # for debug 230 | self.dispatcher.add_handler(CommandHandler('test_notification', self.test_notification)) # for debug 231 | self.dispatcher.add_handler(MessageHandler(Filters.text, self.any_text)) 232 | self.dispatcher.add_handler(MessageHandler(Filters.all, self.any_other)) 233 | self.dispatcher.add_error_handler(self.error) 234 | 235 | 236 | def bot() -> MyBot: 237 | return app.bot 238 | --------------------------------------------------------------------------------