first commit
This commit is contained in:
commit
7bc14784e1
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
TELEGRAM_TOKEN=YOUR_TELEGRAM_TOKEN
|
||||
MONO_TOKEN=YOUR_MONOBANK_TOKEN
|
||||
ALLOWED_USERS=UserID
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
venv/
|
||||
.idea/
|
||||
.env
|
||||
last_time.txt
|
||||
logs.txt
|
||||
__pycache__/
|
||||
16
README.md
Normal file
16
README.md
Normal file
@ -0,0 +1,16 @@
|
||||
|
||||
# Monobank Telegram Bot (Async)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone ...
|
||||
cd MonoTG
|
||||
python -m venv venv
|
||||
source venv/bin/activate # or venv\Scripts\activate on Windows
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env # add your tokens
|
||||
python monobank_bot.py
|
||||
```
|
||||
|
||||
The bot periodically checks your Monobank account every 5 minutes and pushes new transactions to the Telegram users listed in `ALLOWED_USERS`.
|
||||
189
monobank_bot.py
Normal file
189
monobank_bot.py
Normal file
@ -0,0 +1,189 @@
|
||||
|
||||
#!/usr/bin/env python3
|
||||
"""Monobank Telegram Bot (Async)
|
||||
|
||||
Quick start:
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
python monobank_bot_refactored.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import asyncio, logging, os, re, time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import aiohttp
|
||||
import html
|
||||
from dotenv import load_dotenv
|
||||
from filelock import FileLock
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from telegram import Update, BotCommand
|
||||
from telegram.request import HTTPXRequest
|
||||
from telegram.constants import ParseMode
|
||||
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes, JobQueue
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ✦ Environment & config
|
||||
# ---------------------------------------------------------------------------
|
||||
load_dotenv()
|
||||
try:
|
||||
from tokens import TOKEN as _TG_TOKEN_FILE, MONO_TOKEN as _MONO_TOKEN_FILE, ALLOWED_USERS as _ALLOWED_USERS_FILE
|
||||
except ImportError:
|
||||
_TG_TOKEN_FILE = _MONO_TOKEN_FILE = None
|
||||
_ALLOWED_USERS_FILE = []
|
||||
|
||||
TOKEN: str | None = os.getenv('TELEGRAM_TOKEN', _TG_TOKEN_FILE) # type: ignore
|
||||
MONO_TOKEN: str | None = os.getenv('MONO_TOKEN', _MONO_TOKEN_FILE) # type: ignore
|
||||
ALLOWED_USERS: set[int] = {int(x) for x in os.getenv('ALLOWED_USERS', ','.join(map(str, _ALLOWED_USERS_FILE))).split(',') if x}
|
||||
|
||||
if not (TOKEN and MONO_TOKEN and ALLOWED_USERS):
|
||||
raise RuntimeError('Provide TELEGRAM_TOKEN, MONO_TOKEN and ALLOWED_USERS')
|
||||
|
||||
DATA_DIR = Path.cwd()
|
||||
LOG_FILE = DATA_DIR / 'logs.txt'
|
||||
LAST_TIME_FILE = DATA_DIR / 'last_time.txt'
|
||||
LOCK_FILE = DATA_DIR / 'last_time.lock'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
logger = logging.getLogger('mono_bot')
|
||||
logger.setLevel(logging.INFO)
|
||||
handler = RotatingFileHandler(LOG_FILE, maxBytes=2_000_000, backupCount=5, encoding='utf-8')
|
||||
handler.setFormatter(logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s'))
|
||||
logger.addHandler(handler)
|
||||
def log(msg: str, lvl=logging.INFO): logger.log(lvl, msg)
|
||||
|
||||
def escape_md(text: str) -> str:
|
||||
return re.sub(r'([_!*`\\[\\]()~>#+\-=|{}\.])', r'\\\1', text) if text else ''
|
||||
|
||||
currency_symbols: Dict[int, str] = {980:'₴', 840:'$', 978:'€', 985:'zł'}
|
||||
mcc_emojis: Dict[int, str] = {
|
||||
5411:'🛒',5499:'🛒',5441:'🍰',5422:'🥩',5812:'🍽️',5813:'🍽️',5814:'☕️',
|
||||
5912:'💊',5977:'💊',4112:'🚌️',4131:'🚌️',4111:'🚌️',5541:'⛽️',5542:'⛽️',
|
||||
4121:'🚕',5993:'🚬',5211:'👷',7832:'🎮',7922:'🎮',5732:'🖥',4814:'📱',
|
||||
4900:'💡',4816:'🌐',5817:'▶️',8398:'🎗️',6011:'🏦',6012:'🏦',4829:'💳→'
|
||||
}
|
||||
|
||||
class MonoClient:
|
||||
BASE = 'https://api.monobank.ua'
|
||||
def __init__(self, token: str, session: aiohttp.ClientSession):
|
||||
self.h = {'X-Token': token}; self.s = session
|
||||
async def _get(self, path:str):
|
||||
async with self.s.get(f'{self.BASE}{path}', headers=self.h) as r:
|
||||
if r.status==429: raise RuntimeError('Rate-limit')
|
||||
r.raise_for_status(); return await r.json()
|
||||
async def client_info(self): return await self._get('/personal/client-info')
|
||||
async def statement(self, acc:str, frm:int, to:int|None=None):
|
||||
return await self._get(f'/personal/statement/{acc}/{frm}'+(f'/{to}' if to else ''))
|
||||
async def currency(self): return await self._get('/bank/currency')
|
||||
|
||||
def load_last()->int:
|
||||
try: return int(LAST_TIME_FILE.read_text())
|
||||
except Exception: log('last_time missing',logging.WARNING); return int(time.time())
|
||||
def save_last(ts:int):
|
||||
ts=min(ts,int(time.time()))
|
||||
tmp=LAST_TIME_FILE.with_suffix('.tmp')
|
||||
with FileLock(str(LOCK_FILE)):
|
||||
tmp.write_text(str(ts)); tmp.replace(LAST_TIME_FILE)
|
||||
log(f'saved last_time {datetime.fromtimestamp(ts)}')
|
||||
|
||||
def fmt(tx:Dict[str,Any], accounts:List[Dict[str,Any]], owner:str)->str:
|
||||
amt=tx['amount']/100; op=tx['operationAmount']/100; code=tx['currencyCode']
|
||||
acc_cur=next((a['currencyCode'] for a in accounts if a['id']==tx.get('account')), code)
|
||||
sym=currency_symbols.get(code,f'({code})'); sym_acc=currency_symbols.get(acc_cur,f'({acc_cur})')
|
||||
emoji='👈💳' if(tx.get('mcc')==4829 and amt<0)else mcc_emojis.get(tx.get('mcc'),'💳')
|
||||
line=f"{emoji} {amt:.2f}{sym}" if code==acc_cur else f"{emoji} {op:.2f}{sym} ({amt:.2f}{sym_acc})"
|
||||
if tx.get('cashbackAmount'): line+=f", кешбек {tx['cashbackAmount']/100:.2f}{sym}"
|
||||
desc = tx.get("description", "")
|
||||
mcc = tx.get("mcc")
|
||||
mcc_part = f" (MCC: {mcc})" if mcc else ""
|
||||
balance = tx["balance"] / 100
|
||||
|
||||
return (
|
||||
f"<b>{html.escape(owner)}</b>\n"
|
||||
f"{html.escape(line)}\n"
|
||||
f"{html.escape(desc + mcc_part)}\n"
|
||||
f"<b>Баланс:</b> {balance:.2f}{sym_acc}"
|
||||
)
|
||||
|
||||
async def auth(update:Update):
|
||||
if update.effective_user.id not in ALLOWED_USERS:
|
||||
await update.message.reply_text('⛔️'); return False
|
||||
return True
|
||||
|
||||
async def cmd_start(u:Update,c):
|
||||
if not await auth(u):return
|
||||
await u.message.reply_text('👋 /status /balance /currency /history [d]')
|
||||
|
||||
async def cmd_status(u:Update,c):
|
||||
if not await auth(u):return
|
||||
await u.message.reply_text(f'🕒 {datetime.fromtimestamp(load_last()):%Y-%m-%d %H:%M:%S}')
|
||||
|
||||
async def cmd_balance(u:Update,c):
|
||||
if not await auth(u):return
|
||||
mono=c.bot_data['mono']; d=await mono.client_info()
|
||||
await u.message.reply_text('💰 Баланс:\n'+ '\n'.join(
|
||||
f"{a.get('type','Card').capitalize()}: {a['balance']/100:.2f}{currency_symbols.get(a['currencyCode'],a['currencyCode'])}" for a in d['accounts']), parse_mode=ParseMode.HTML)
|
||||
|
||||
async def cmd_currency(u:Update,c):
|
||||
if not await auth(u):return
|
||||
mono=c.bot_data['mono']; data=await mono.currency()
|
||||
tgt={840:'USD',978:'EUR',985:'PLN'}; out=[]
|
||||
for r in data:
|
||||
if r.get('currencyCodeA') in tgt and r.get('currencyCodeB')==980:
|
||||
code=tgt[r['currencyCodeA']]; b,s,cr=r.get('rateBuy'),r.get('rateSell'),r.get('rateCross')
|
||||
out.append(f"{code}: {'🟢 %.2f / 🔴 %.2f'%(b,s) if b and s else '🔄 %.2f'%cr if cr else '—'}")
|
||||
await u.message.reply_text('💱:\n'+'\n'.join(out), parse_mode=ParseMode.HTML)
|
||||
|
||||
async def cmd_history(u:Update,c):
|
||||
if not await auth(u):return
|
||||
days=int(c.args[0]) if c.args and c.args[0].isdigit() else 1
|
||||
now=int(time.time()); frm=now-days*86400
|
||||
mono=c.bot_data['mono']; info=await mono.client_info()
|
||||
txs=await mono.statement(info['accounts'][0]['id'],frm,now)
|
||||
if not txs: await u.message.reply_text('Немає транзакцій');return
|
||||
txs.sort(key=lambda x:x['time'])
|
||||
for tx in txs:
|
||||
await u.message.reply_text(fmt(tx,info['accounts'],info['name']),parse_mode=ParseMode.HTML)
|
||||
await asyncio.sleep(1)
|
||||
await u.message.reply_text(f'✅ {len(txs)} транзакцій')
|
||||
|
||||
async def check(ctx):
|
||||
last=load_last(); now=int(time.time())
|
||||
mono:MonoClient=ctx.bot_data['mono']; info=await mono.client_info()
|
||||
txs=await mono.statement(info['accounts'][0]['id'],last)
|
||||
if not txs: save_last(now+1);return
|
||||
txs.sort(key=lambda x:x['time'])
|
||||
for tx in txs:
|
||||
if tx['time']<=last:continue
|
||||
msg=fmt(tx,info['accounts'],info['name'])
|
||||
for uid in ALLOWED_USERS:
|
||||
await ctx.bot.send_message(uid,msg,parse_mode=ParseMode.HTML); await asyncio.sleep(1)
|
||||
save_last(max(t['time'] for t in txs)+1)
|
||||
|
||||
async def setup(app):
|
||||
await app.bot.set_my_commands([
|
||||
BotCommand('start','Запустити бота'),BotCommand('status','Статус'),
|
||||
BotCommand('balance','Баланс'),BotCommand('currency','Курс'),
|
||||
BotCommand('history','Історія')
|
||||
])
|
||||
|
||||
async def run():
|
||||
async with aiohttp.ClientSession() as sess:
|
||||
mono=MonoClient(MONO_TOKEN,sess)
|
||||
req = HTTPXRequest(read_timeout=30.0, write_timeout=30.0, connect_timeout=5.0)
|
||||
app=(ApplicationBuilder().token(TOKEN).post_init(setup).request(req) .build())
|
||||
app.bot_data['mono']=mono
|
||||
app.add_handler(CommandHandler('start',cmd_start))
|
||||
app.add_handler(CommandHandler('status',cmd_status))
|
||||
app.add_handler(CommandHandler('balance',cmd_balance))
|
||||
app.add_handler(CommandHandler('currency',cmd_currency))
|
||||
app.add_handler(CommandHandler('history',cmd_history))
|
||||
app.job_queue.run_repeating(check,300,first=5)
|
||||
await app.initialize(); await app.start(); await app.updater.start_polling()
|
||||
try: await asyncio.Future()
|
||||
finally:
|
||||
await app.updater.stop(); await app.stop(); await app.shutdown()
|
||||
if __name__=='__main__':
|
||||
asyncio.run(run())
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
python-telegram-bot[job-queue]>=20.0
|
||||
aiohttp>=3.9
|
||||
python-dotenv>=1.0
|
||||
filelock>=3.12
|
||||
Loading…
x
Reference in New Issue
Block a user