├── db └── .keep ├── src ├── __init__.py ├── migrations │ ├── __init__.py │ └── migrate_all.py ├── actions │ ├── start.py │ ├── __init__.py │ ├── where.py │ └── base.py ├── Dockerfile ├── messages.py ├── bot.py ├── requirements.txt ├── models.py └── scrap_my_ass.py ├── .env.example ├── .flake8 ├── .isort.cfg ├── README.md ├── LICENSE └── .gitignore /db/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TELEGRAM_BOT_TOKEN=1234:secret 2 | DATABASE_URL=../db/data.db 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | exclude = 4 | venv, 5 | .git, 6 | __pycache__ 7 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | line_length = 88 3 | use_parentheses = True 4 | include_trailing_comma = True 5 | multi_line_output = 3 -------------------------------------------------------------------------------- /src/actions/start.py: -------------------------------------------------------------------------------- 1 | from .base import Action 2 | 3 | 4 | class StartAction(Action): 5 | def do(self): 6 | self.common_reply("general_help") 7 | -------------------------------------------------------------------------------- /src/actions/__init__.py: -------------------------------------------------------------------------------- 1 | from .start import StartAction 2 | from .where import WhereAction 3 | 4 | __all__ = [ 5 | StartAction, 6 | WhereAction, 7 | ] 8 | -------------------------------------------------------------------------------- /src/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | COPY requirements.txt / 4 | 5 | RUN pip install --no-cache-dir -r /requirements.txt 6 | 7 | COPY . /app 8 | 9 | WORKDIR /app 10 | -------------------------------------------------------------------------------- /src/migrations/migrate_all.py: -------------------------------------------------------------------------------- 1 | from models import app_models, db 2 | 3 | 4 | def migrate_db(): 5 | 6 | db.connect() 7 | db.create_tables(app_models) 8 | db.close() 9 | 10 | 11 | if __name__ == "__main__": 12 | migrate_db() 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ГДЕ НАЛ, ТИНЕК? 2 | 3 | В связи с тяжелым специально военно операционным положением отслеживаем пополнения валюты в банкоматах Тинькофф Банка. 4 | 5 | Данные берутся из открытого API Тинька. 6 | 7 | Пока что тут только Хабаровск. 8 | 9 | http://t.me/where_is_the_cash_tinkoffski_bot 10 | 11 | ## Real time updates 12 | ![image](https://user-images.githubusercontent.com/20068601/160285960-80057c88-0923-4b99-9421-8e4535135868.png) 13 | -------------------------------------------------------------------------------- /src/messages.py: -------------------------------------------------------------------------------- 1 | _registry = { 2 | "general_help": { 3 | "ru": ( 4 | "Бот собирает данные по банкоматам Тинькофф банка из открытых API.\n" 5 | "Сообщает в реальном времени изменения остатков.\n" 6 | "Набери /where для отображения актуальных остатков.\n\n" 7 | "Работает пока что в г. Хабаровск.\n" 8 | ), 9 | }, 10 | } 11 | 12 | 13 | class CommonMessages: 14 | """Simple registry with all common replies.""" 15 | 16 | def __init__(self, language_code: str = "ru"): 17 | self.language_code = language_code 18 | 19 | def get_message(self, slug: str) -> str: 20 | messages = _registry[slug] 21 | return messages.get(self.language_code) 22 | -------------------------------------------------------------------------------- /src/bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import sentry_sdk 4 | from envparse import env 5 | from telegram.ext import CommandHandler, Updater 6 | 7 | from actions import StartAction, WhereAction 8 | 9 | logging.basicConfig( 10 | level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 11 | ) 12 | 13 | 14 | env.read_envfile() 15 | 16 | sentry_sdk.init(env("SENTRY_DSN", default=None)) 17 | 18 | updater = Updater(token=env("TELEGRAM_BOT_TOKEN"), use_context=True) 19 | 20 | 21 | def define_routes(): 22 | add_handler = updater.dispatcher.add_handler 23 | 24 | add_handler(CommandHandler("start", StartAction.run_as_callback)) 25 | add_handler(CommandHandler("help", StartAction.run_as_callback)) 26 | add_handler(CommandHandler("where", WhereAction.run_as_callback)) 27 | 28 | 29 | if __name__ == "__main__": 30 | define_routes() 31 | updater.start_polling() 32 | -------------------------------------------------------------------------------- /src/actions/where.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from models import CashRemain 4 | 5 | from .base import Action 6 | 7 | 8 | class WhereAction(Action): 9 | def do(self): 10 | remains = list(CashRemain.select().order_by(CashRemain.amount.desc())) 11 | 12 | if len(remains) == 0: 13 | self.reply("Валюты нигде нет. Добро пожаловать в Союз.") 14 | return 15 | 16 | availability_date = remains[0].created 17 | availability_date += timedelta(hours=10) # Vladivostok time 18 | availability_date = availability_date.strftime("%Y-%m-%d %H:%M:%S") 19 | 20 | self.reply(f"Вот, все что есть на {availability_date}\n\n") 21 | self.reply( 22 | "\n".join( 23 | [ 24 | f"{remain.amount} {remain.currency}: {remain.address}" 25 | for remain in remains 26 | ] 27 | ) 28 | ) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexey Chudin 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 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | APScheduler==3.6.3 2 | asttokens==2.0.5 3 | attrs==21.4.0 4 | backcall==0.2.0 5 | black==22.1.0 6 | cachetools==4.2.2 7 | certifi==2021.10.8 8 | charset-normalizer==2.0.12 9 | click==8.0.4 10 | decorator==5.1.1 11 | envparse==0.2.0 12 | eradicate==2.0.0 13 | executing==0.8.3 14 | flake8==4.0.1 15 | flake8-black==0.3.2 16 | flake8-bugbear==22.1.11 17 | flake8-eradicate==1.2.0 18 | flake8-isort==4.1.1 19 | flake8-print==4.0.0 20 | idna==3.3 21 | iniconfig==1.1.1 22 | ipython==8.1.1 23 | isort==5.10.1 24 | jedi==0.18.1 25 | matplotlib-inline==0.1.3 26 | mccabe==0.6.1 27 | mypy-extensions==0.4.3 28 | parso==0.8.3 29 | pathspec==0.9.0 30 | peewee==3.14.10 31 | pexpect==4.8.0 32 | pickleshare==0.7.5 33 | platformdirs==2.5.1 34 | prompt-toolkit==3.0.28 35 | ptyprocess==0.7.0 36 | pure-eval==0.2.2 37 | pycodestyle==2.8.0 38 | pyflakes==2.4.0 39 | Pygments==2.11.2 40 | python-telegram-bot==13.11 41 | pytz==2021.3 42 | pytz-deprecation-shim==0.1.0.post0 43 | requests==2.27.1 44 | sentry-sdk==1.5.7 45 | six==1.16.0 46 | stack-data==0.2.0 47 | testfixtures==6.18.5 48 | tomli==2.0.1 49 | tornado==6.1 50 | traitlets==5.1.1 51 | typing_extensions==4.1.1 52 | tzdata==2021.5 53 | tzlocal==4.1 54 | urllib3==1.26.8 55 | wcwidth==0.2.5 56 | -------------------------------------------------------------------------------- /src/actions/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from telegram import Bot, ParseMode 4 | from telegram.ext.callbackcontext import CallbackContext 5 | from telegram.update import Update 6 | 7 | from messages import CommonMessages 8 | from models import Message 9 | 10 | 11 | class Action(ABC): 12 | """ 13 | Base action. 14 | 15 | Saves message and chat and implements `do` method for your logic. 16 | """ 17 | 18 | def __init__(self, message: Message, bot: Bot): 19 | self.bot = bot 20 | self.chat = message.chat 21 | self.message = message 22 | self.common_messages = CommonMessages() 23 | 24 | @abstractmethod 25 | def do(self): 26 | """Do some work, add replies to replier...""" 27 | 28 | def reply(self, text: str): 29 | """ 30 | Reply to message chat. 31 | 32 | Call it from your `do` method. 33 | """ 34 | self.bot.send_message( 35 | chat_id=self.message.chat.chat_id, 36 | text=text, 37 | parse_mode=ParseMode.HTML, 38 | ) 39 | 40 | def common_reply(self, message_slug: str): 41 | """Reply with common message.""" 42 | self.reply(self.common_messages.get_message(message_slug)) 43 | 44 | @classmethod 45 | def run_as_callback(action_cls, update: Update, context: CallbackContext): 46 | """Assign this class method as telegram dispatcher callback.""" 47 | message = Message.create_from_update(update) 48 | 49 | action_cls(message=message, bot=context.bot).do() 50 | -------------------------------------------------------------------------------- /src/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import peewee as pw 4 | from envparse import env 5 | from telegram.update import Update 6 | 7 | env.read_envfile() 8 | 9 | db = pw.SqliteDatabase(env("DATABASE_URL")) 10 | 11 | 12 | def _utcnow(): 13 | return datetime.utcnow() 14 | 15 | 16 | class BaseModel(pw.Model): 17 | created = pw.DateTimeField(default=_utcnow) 18 | modified = pw.DateTimeField(null=True) 19 | 20 | class Meta: 21 | database = db 22 | 23 | def save(self, *args, **kwargs): 24 | if self.id is not None: 25 | self.modified = _utcnow() 26 | 27 | return super().save(*args, **kwargs) 28 | 29 | 30 | class CashRemain(BaseModel): 31 | address = pw.CharField(unique=True) 32 | currency = pw.CharField() 33 | amount = pw.IntegerField() 34 | 35 | 36 | class Chat(BaseModel): 37 | chat_id = pw.BigIntegerField(unique=True) 38 | chat_type = pw.CharField(null=True) 39 | username = pw.CharField(null=True) 40 | first_name = pw.CharField(null=True) 41 | last_name = pw.CharField(null=True) 42 | 43 | @classmethod 44 | def get_or_create_from_update(cls, update: Update) -> "Chat": 45 | chat_id = update.effective_chat.id 46 | defaults = dict( 47 | chat_type=update.effective_chat.type, 48 | username=update.effective_chat.username, 49 | first_name=update.effective_chat.first_name, 50 | last_name=update.effective_chat.last_name, 51 | ) 52 | return cls.get_or_create(chat_id=chat_id, defaults=defaults)[0] 53 | 54 | 55 | class Message(BaseModel): 56 | message_id = pw.IntegerField(null=True) 57 | chat = pw.ForeignKeyField(Chat, backref="messages") 58 | date = pw.DateTimeField(null=True) 59 | text = pw.CharField(null=True) 60 | 61 | @classmethod 62 | def create_from_update(cls, update: Update) -> "Message": 63 | return cls.create( 64 | chat=Chat.get_or_create_from_update(update), 65 | message_id=update.effective_message.message_id, 66 | date=update.effective_message.date, 67 | text=update.effective_message.text, 68 | ) 69 | 70 | 71 | app_models = BaseModel.__subclasses__() 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Some devs 132 | 133 | .idea 134 | 135 | # Databases 136 | 137 | *.db 138 | -------------------------------------------------------------------------------- /src/scrap_my_ass.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | import requests 5 | import sentry_sdk 6 | from envparse import env 7 | from telegram import Bot, ParseMode, TelegramError 8 | 9 | from models import CashRemain, Chat, db 10 | 11 | env.read_envfile() 12 | 13 | logging.basicConfig( 14 | level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 15 | ) 16 | 17 | sentry_sdk.init(env("SENTRY_DSN", default=None)) 18 | 19 | bot = Bot(token=env("TELEGRAM_BOT_TOKEN")) 20 | currency = "USD" 21 | 22 | 23 | def get_clusters(): 24 | 25 | # Thats a msk location for testing 26 | # !msk = { 27 | # ! "bottomLeft": {"lat": 55.77128972923776, "lng": 37.09503560017758}, 28 | # ! "topRight": {"lat": 55.960486265455394, "lng": 37.380680131427575}, 29 | # !} 30 | 31 | khv = { 32 | "bottomLeft": {"lat": 48.35559027141748, "lng": 134.91545706263585}, 33 | "topRight": {"lat": 48.57933649214029, "lng": 135.20110159388588}, 34 | } 35 | 36 | response = requests.post( 37 | "https://api.tinkoff.ru/geo/withdraw/clusters", 38 | headers={"Content-Type": "application/json"}, 39 | json={ 40 | "bounds": khv, 41 | "filters": { 42 | "banks": ["tcs"], 43 | "showUnavailable": False, 44 | "currencies": ["USD"], 45 | }, 46 | "zoom": 12, 47 | }, 48 | ) 49 | response.raise_for_status() 50 | data = response.json() 51 | return data["payload"]["clusters"] 52 | 53 | 54 | @db.atomic() 55 | def refill(): 56 | CashRemain.delete().execute() 57 | 58 | for cluster in get_clusters(): 59 | for point in cluster["points"]: 60 | address = point["address"] 61 | amount = None 62 | for limit in point["limits"]: 63 | if limit["currency"] == currency: 64 | amount = limit["amount"] 65 | break 66 | 67 | if amount: 68 | logging.log(logging.DEBUG, f"Got {address}: {amount} {currency}") 69 | CashRemain.create( 70 | address=address, 71 | amount=amount, 72 | currency=currency, 73 | ) 74 | 75 | 76 | def get_remains() -> dict: 77 | return dict( 78 | CashRemain.select(CashRemain.address, CashRemain.amount) 79 | .order_by(CashRemain.amount.desc()) 80 | .tuples() 81 | ) 82 | 83 | 84 | def make_diff_message(before: dict, after: dict) -> str: 85 | messages = [] 86 | 87 | for atm in before: 88 | if atm not in after: 89 | after[atm] = 0 90 | 91 | for atm, amount in after.items(): 92 | if atm not in before or before[atm] < after[atm]: 93 | messages.append(f"🆙 Oстаток {amount} {currency} на {atm}") 94 | elif before[atm] > after[atm]: 95 | messages.append(f"🆘 Oстаток: {amount} {currency} на {atm}") 96 | 97 | return "\n".join(messages) 98 | 99 | 100 | def broadcast(message: str): 101 | for (chat_id,) in Chat.select(Chat.chat_id).tuples(): 102 | try: 103 | bot.send_message(chat_id=chat_id, text=message, parse_mode=ParseMode.HTML) 104 | except TelegramError: 105 | logging.error(f"Error while sending message to {chat_id}: {message}") 106 | 107 | 108 | if __name__ == "__main__": 109 | 110 | while True: 111 | 112 | before = get_remains() 113 | refill() 114 | after = get_remains() 115 | 116 | if message := make_diff_message(before, after): 117 | logging.log(logging.DEBUG, f"Broadcasting: {message}") 118 | broadcast(message) 119 | else: 120 | logging.log(logging.DEBUG, "Nothing to broadcast") 121 | 122 | time.sleep(120) 123 | --------------------------------------------------------------------------------