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