├── 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 | 
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 |
--------------------------------------------------------------------------------