diff --git a/.vscode/launch.json b/.vscode/launch.json index 26f900b..724dd63 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,14 +5,14 @@ "version": "0.2.0", "configurations": [ { - "name": "bot", + "name": "Bot", "type": "python", "request": "launch", "program": "${workspaceFolder}/bot/app/__main__.py", "cwd": "${workspaceFolder}/bot/app/", "console": "integratedTerminal", - "envFile": "${workspaceFolder}/bot.env", - "justMyCode": true + "justMyCode": true, + "envFile": "${workspaceFolder}/bot.env" } ] } \ No newline at end of file diff --git a/bot.env b/bot.env index 2be47ef..2c26da4 100644 --- a/bot.env +++ b/bot.env @@ -1,5 +1,8 @@ tg_token="" admin_chat="" -db_name="adm" +prometheus_rules_url="http://localhost:9090/api/v1/rules" +prometheus_alert_groups="maas-rules" +db_name="maas" db_user="adm" db_pass="adm" +db_port="5432" \ No newline at end of file diff --git a/bot/app/__main__.py b/bot/app/__main__.py index da1d049..a9eace1 100644 --- a/bot/app/__main__.py +++ b/bot/app/__main__.py @@ -7,10 +7,10 @@ from message_handlers.setup import setup_message_handler from web_apps.setup import setup_web_app from forms.setup import setup_message_form -from callback_data.main import Cb +from callback_data.main import CbData from aiohttp import web from utils.db import DB -import time +from utils import subscriptions logging.basicConfig(level=logging.INFO, stream=sys.stdout) @@ -24,29 +24,34 @@ db_host = os.environ['db_host'] db_port = os.environ['db_port'] - run_mode = os.environ.get('run_mode', 'standalone') + grafana_url = os.environ.get('grafana_url', 'http://127.0.0.1:3000/d/fDrj0_EGz/p2p-org-polkadot-kusama-dashboard?orgId=1') + prometheus_rules_url = os.environ.get('prometheus_rules_url', 'http://localhost:9090/api/v1/rules') + prometheus_alert_groups = os.environ.get('prometheus_alert_groups', []) + if isinstance(prometheus_alert_groups, str): + prometheus_alert_groups = prometheus_alert_groups.split(',') + web_app = web.Application() db = DB(db_name,db_user,db_pass,db_host,db_port) - + subs = subscriptions.Subscriptions(db, prometheus_rules_url, prometheus_alert_groups) bot = Bot(token=tg_token, parse_mode="HTML") storage = MemoryStorage() dp = Dispatcher(storage=storage) - + router = Router() dp.include_router(router) - cb = Cb + cb = CbData setup_message_handler('start') -# setup_message_handler('support') -# setup_message_form('support') + setup_message_form('sub_filter') + setup_message_form('support') setup_web_app('ping') setup_web_app('prom_alert') - from callback_query_handlers import promalert,main_menu,grafana,support + from callback_query_handlers import promalert,main_menu,support,subscriptions web_runner = web.AppRunner(web_app) loop = asyncio.get_event_loop() diff --git a/bot/app/callback_data/main.py b/bot/app/callback_data/main.py index 3a51ebc..e7532f5 100644 --- a/bot/app/callback_data/main.py +++ b/bot/app/callback_data/main.py @@ -1,5 +1,5 @@ from aiogram.filters.callback_data import CallbackData -class Cb(CallbackData, prefix="main"): +class CbData(CallbackData, prefix="main"): dst: str data: str diff --git a/bot/app/callback_query_handlers/grafana.py b/bot/app/callback_query_handlers/grafana.py deleted file mode 100644 index 4874628..0000000 --- a/bot/app/callback_query_handlers/grafana.py +++ /dev/null @@ -1,69 +0,0 @@ -from __main__ import router,db,bot,cb,admin_chat -from aiogram.types import CallbackQuery,InlineKeyboardButton,InlineKeyboardMarkup -from aiogram import F -from utils.menu_builder import MenuBuilder -from utils.functions import deploy,destroy -from datetime import datetime, timezone - -@router.callback_query(cb.filter(F.dst == 'grafana')) -async def menu_prom_cb_handler(query: CallbackQuery,callback_data: cb): - await query.answer(query.id) - - chat_id = query.message.chat.id - message_id = query.message.message_id - - menu_builder = MenuBuilder() - - grafana_status = db.get_records('grafana_status','id',chat_id) - - if grafana_status == 'on': - keyboard = menu_builder.build(callback_data=cb,preset='grafana_off',button_back='main_menu') - await bot.send_message(chat_id,"According to our database you already have an instance 🤷\n\n",reply_markup=keyboard.as_markup()) - else: - keyboard = menu_builder.build(callback_data=cb,preset='grafana_on',button_back='main_menu') - await bot.send_message(chat_id,"Here you can setup your own grafana instance.\n\n",reply_markup=keyboard.as_markup()) - - await bot.delete_message(chat_id,message_id) - -@router.callback_query(cb.filter(F.dst == 'grafana_on')) -async def menu_prom_cb_handler(query: CallbackQuery,callback_data: cb): - await query.answer(query.id) - - username = query.message.chat.username - chat_id = query.message.chat.id - message_id = query.message.message_id - - deploy(chat_id,'./values.yml') - - db.update_record(chat_id,'grafana_status','on') - db.update_record(chat_id,'grafana_deploy_time',datetime.now(timezone.utc)) - - - menu_builder = MenuBuilder() - keyboard = menu_builder.build(callback_data=cb,button_back='grafana',button_main_menu=True) - - await bot.send_message(admin_chat, "Someone initialized of deploy grafana.\nUsername: @{username} ID: {chat_id}".format(username=username,chat_id=chat_id)) - await bot.send_message(chat_id,"Alright 👍\n\nOur robots started cooking your personal dashboard. Usually, this process takes around 5 minutes.\nWe will send all necessary data as soon as dashboard will be ready!\n\n",reply_markup=keyboard.as_markup()) - - await bot.delete_message(chat_id,message_id) - -@router.callback_query(cb.filter(F.dst == 'grafana_off')) -async def menu_prom_cb_handler(query: CallbackQuery,callback_data: cb): - await query.answer(query.id) - - username = query.message.chat.username - chat_id = query.message.chat.id - message_id = query.message.message_id - - destroy(chat_id,'./values.yml') - - db.update_record(chat_id,'grafana_status','off') - #db.update_record(chat_id,'grafana_deploy_time',datetime.now(timezone.utc)) - - menu_builder = MenuBuilder() - keyboard = menu_builder.build(callback_data=cb,button_back='grafana',button_main_menu=True) - - await bot.send_message(admin_chat, "Someone initialized of destroy grafana.\nUsername: @{username} ID: {chat_id}".format(username=username,chat_id=chat_id)) - await bot.send_message(chat_id,"Alright 👍\n\nWe revoked your grafana instance",reply_markup=keyboard.as_markup()) - - await bot.delete_message(chat_id,message_id) diff --git a/bot/app/callback_query_handlers/main_menu.py b/bot/app/callback_query_handlers/main_menu.py index 9135c09..2b30f26 100644 --- a/bot/app/callback_query_handlers/main_menu.py +++ b/bot/app/callback_query_handlers/main_menu.py @@ -1,17 +1,17 @@ -from __main__ import router,dp,db,bot,cb -from aiogram.types import CallbackQuery,InlineKeyboardButton,InlineKeyboardMarkup +from callback_data.main import CbData +from __main__ import router +from aiogram.types import CallbackQuery from utils.menu_builder import MenuBuilder from aiogram import F -@router.callback_query(cb.filter(F.dst == 'main_menu')) -async def menu_prom_cb_handler(query: CallbackQuery,callback_data: cb): - chat_id = query.message.chat.id - message_id = query.message.message_id +def main_menu(): + return 'Please select', MenuBuilder().build(preset='main_menu') - menu = MenuBuilder() - menu = menu.build(callback_data=cb,preset='main_menu') - - await bot.send_message(chat_id,"Here is a main menu.",reply_markup=menu.as_markup()) - await bot.delete_message(chat_id,message_id) - await query.answer(query.id) +@router.callback_query(CbData.filter(F.dst == 'main_menu')) +async def menu_prom_cb_handler(query: CallbackQuery): + text, keyboard = main_menu() + + call = query.message.edit_text(text=text, reply_markup=keyboard.as_markup()) + await query.bot(call) + await query.answer('main menu') diff --git a/bot/app/callback_query_handlers/promalert.py b/bot/app/callback_query_handlers/promalert.py index 6a93f91..23ad61f 100644 --- a/bot/app/callback_query_handlers/promalert.py +++ b/bot/app/callback_query_handlers/promalert.py @@ -1,19 +1,55 @@ -from __main__ import router,dp,db,bot,cb -from aiogram.types import CallbackQuery,InlineKeyboardButton,InlineKeyboardMarkup +from callback_data.main import CbData +from __main__ import router, db, subs +from aiogram.types import CallbackQuery +from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram import F from utils.menu_builder import MenuBuilder +from utils.db import DB +from utils.subscriptions import Subscriptions +from aiogram.fsm.context import FSMContext +assert isinstance(db, DB) +assert isinstance(subs, Subscriptions) -@router.callback_query(cb.filter(F.dst == 'promalert')) -async def menu_prom_cb_handler(query: CallbackQuery,callback_data: cb): - await query.answer(query.id) - - username = query.message.chat.username - chat_id = query.message.chat.id - message_id = query.message.message_id +def promalert_dialog(promalert_status: str = '', chat_id: int = 0) -> (str, InlineKeyboardBuilder): + s = subs.get_subscriptions(chat_id) + if promalert_status == '': + promalert_status = db.get_records('promalert_status', 'id', chat_id) + text = [] + if promalert_status != 'on': + promalert_status = 'off' + text.append(f'Notifications: {promalert_status}') + text.append(f'Subscriptions count: {len(s)}') menu = MenuBuilder() - menu = menu.build(callback_data=cb,preset='promalert',button_back='main_menu') + if promalert_status == 'off': + menu.add(preset='promalert_on') + else: + menu.add(preset='promalert_off') + menu.add(preset='sub_rules') + if len(s) > 0: + menu.add(preset='sub_list') + return '\n'.join(text), menu.build(button_back='main_menu') - await bot.send_message(chat_id,"You can subscribe or deactivate subsription hete.",reply_markup=menu.as_markup()) - await bot.delete_message(chat_id,message_id) +@router.callback_query(CbData.filter(F.dst == 'promalert')) +async def handle_promalert(query: CallbackQuery): + text, keyboard = promalert_dialog(chat_id=query.message.chat.id) + call = query.message.edit_text(text=text, reply_markup=keyboard.as_markup()) + await query.bot(call) + await query.answer('alerts menu') + +@router.callback_query(CbData.filter(F.dst == 'promalert_on')) +async def handle_promalert_on(query: CallbackQuery): + db.update_record(query.message.chat.id, 'promalert_status', 'on') + text, keyboard = promalert_dialog(chat_id=query.message.chat.id, promalert_status='on') + call = query.message.edit_text(text=text, reply_markup=keyboard.as_markup()) + await query.bot(call) + await query.answer('notifications enabled') + +@router.callback_query(CbData.filter(F.dst == 'promalert_off')) +async def handle_promalert_off(query: CallbackQuery): + db.update_record(query.message.chat.id, 'promalert_status', 'off') + text, keyboard = promalert_dialog(chat_id=query.message.chat.id, promalert_status='off') + call = query.message.edit_text(text=text, reply_markup=keyboard.as_markup()) + await query.bot(call) + await query.answer('notifications disabled') diff --git a/bot/app/callback_query_handlers/subscriptions.py b/bot/app/callback_query_handlers/subscriptions.py new file mode 100644 index 0000000..c071db6 --- /dev/null +++ b/bot/app/callback_query_handlers/subscriptions.py @@ -0,0 +1,112 @@ +from aiogram.types import CallbackQuery +from callback_data.main import CbData +from __main__ import router, subs +from utils.subscriptions import Subscriptions +from utils.menu_builder import MenuBuilder +from utils.msg_text import dict2text, text2dict +from aiogram.fsm.context import FSMContext +from aiogram import F +from forms.sub_filter import Form, sub_filter_input_validate +from callback_query_handlers.promalert import promalert_dialog +assert isinstance(subs, Subscriptions) + +# Show rules list after Add subscription click +@router.callback_query(CbData.filter(F.dst == 'sub_rules')) +async def handle_sub_rules(query: CallbackQuery, callback_data: CbData): + r = await subs.get_rules() + rules = r.list() + keyboard = MenuBuilder().add(preset='rules_list', data=rules, navigation=callback_data.data) + call = query.message.edit_text(text='Please select alert you would like to subscribe to.', + reply_markup=keyboard.build().as_markup()) + await query.bot(call) + await query.answer('Ok') + +# Show single rule for subscription and edit buttons +@router.callback_query(CbData.filter(F.dst == 'sub_rule')) +async def handle_sub_rule(query: CallbackQuery, callback_data: CbData): + r = await subs.get_rule_by_name(callback_data.data) + rule = r.dict() + keyboard = MenuBuilder() + keyboard.add(preset='sub_filter_edit', data=rule) + keyboard.add(preset='sub_save', data=r.alertname) + text = 'Use buttons to configure subscription "Edit ..." to specify filters, Save to subscribe or Back to cancel\n' + call = query.message.edit_text(text=text+dict2text(rule), reply_markup=keyboard.build(button_back='promalert').as_markup()) + await query.bot(call) + await query.answer('Ok') + +# Show existing subscriptions +@router.callback_query(CbData.filter(F.dst == 'sub_list')) +async def handle_sub_list(query: CallbackQuery, callback_data: CbData): + if callback_data.data == '': + new_page = 0 + else: + new_page = int(callback_data.data.split('/')[0]) + s, total = subs.get_subscription_by_index(query.from_user.id, new_page) + + keyboard = MenuBuilder() + keyboard.add(preset='sub_filter_edit', data=s.keys()) + keyboard.add(preset='sub_del', data=s['alertname'][0]) + keyboard.add(preset='sub_scroll', navigation=f'{new_page}/{total}') + call = query.message.edit_text(text=dict2text(s), reply_markup=keyboard.build().as_markup()) + await query.bot(call) + await query.answer('Ok') + +# Turn on form.sub_filter and wait for field value +@router.callback_query(CbData.filter(F.dst == 'sub_edit')) +async def handle_sub_edit(query: CallbackQuery, callback_data: CbData, state: FSMContext): + d = text2dict(query.message.text) + await state.set_data({'expect': callback_data.data, 'current': d, 'message_id': query.message.message_id}) + await state.set_state(Form.sub_filter) + text = f'Please send list of values for field {callback_data.data}\n' + keyboard = MenuBuilder() + call = query.message.edit_text(text=text, reply_markup=keyboard.build(button_back='sub_edit_cancel').as_markup()) + await query.bot(call) + await query.answer(f'Please send values for: {callback_data.data}') + +# Cancel form.sub_filter go promalert +@router.callback_query(CbData.filter(F.dst == 'sub_edit_cancel')) +async def handle_sub_edit(query: CallbackQuery, state: FSMContext): + await state.clear() + text, keyboard = promalert_dialog(chat_id=query.from_user.id) + call = query.message.edit_text(text=text, reply_markup=keyboard.as_markup()) + await query.bot(call) + await query.answer('Canceled') + +# Save alert from text message to database +@router.callback_query(CbData.filter(F.dst == 'sub_save')) +async def handle_sub_save(query: CallbackQuery, callback_data: CbData, state: FSMContext): + await state.clear() + if callback_data.data == '': + await query.answer('Something went wrong. Please try later.') + sub = text2dict(query.message.text) + for key, val in sub.items(): + valid, reason = sub_filter_input_validate(expected=key, got=val) + if not valid: + keyboard = MenuBuilder() + keyboard.add(preset='sub_filter_edit', data=sub) + keyboard.add(preset='sub_save', data=sub['alertname'][0]) + text = f'Unable to subscribe: {reason}. Please edit {key} field.\n' + await query.bot.send_message(text=text+dict2text(sub), chat_id=query.from_user.id, + reply_markup=keyboard.build(button_back='promalert').as_markup()) + await query.answer(f'Please update {key}') + return + n = subs.user_subscribe(chat_id=query.from_user.id, subscription_name=callback_data.data, lvs=sub) + if n == 0: + await query.answer('Something went wrong. Please try later.') + text, keyboard = promalert_dialog(chat_id=query.from_user.id) + call = query.message.edit_text(text=text, reply_markup=keyboard.as_markup()) + await query.bot(call) + await query.answer('Saved') + +# Delete subscription +@router.callback_query(CbData.filter(F.dst == 'sub_del')) +async def handle_sub_del(query: CallbackQuery, callback_data: CbData, state: FSMContext): + await state.clear() + if callback_data.data == '': + await query.answer('Something went wrong. Please try later.') + subs.user_unsubscribe(chat_id=query.from_user.id, subscription_name=callback_data.data) + text, keyboard = promalert_dialog(chat_id=query.from_user.id) + call = query.message.edit_text(text=text, reply_markup=keyboard.as_markup()) + await query.bot(call) + await query.answer('Deleted') + diff --git a/bot/app/callback_query_handlers/support.py b/bot/app/callback_query_handlers/support.py index 4646bd7..f456196 100644 --- a/bot/app/callback_query_handlers/support.py +++ b/bot/app/callback_query_handlers/support.py @@ -1,39 +1,112 @@ -from __main__ import router,db,bot,cb +from __main__ import router, db, bot +from callback_data.main import CbData from aiogram.types import CallbackQuery from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State,StatesGroup +from callback_query_handlers.main_menu import main_menu from forms.support import Form from aiogram import F from utils.menu_builder import MenuBuilder -@router.callback_query(cb.filter(F.dst == 'support')) -async def menu_prom_cb_handler(query: CallbackQuery,callback_data: cb): - await query.answer(query.id) - username = query.message.chat.username +### User actions +@router.callback_query(CbData.filter(F.dst == 'support')) +async def handle_support_menu(query: CallbackQuery): chat_id = query.message.chat.id message_id = query.message.message_id + keyboard = MenuBuilder() + # don't even show support button to banned users + if db.get_records('account_status','id',chat_id) == 'on': + keyboard.add(preset='support_request') - menu = MenuBuilder() - keyboard = menu.build(callback_data=cb,button_back='main_menu') - - await bot.send_message(chat_id,"Useful things like wiki or chat will be later.\n\n",reply_markup=keyboard.as_markup()) + await bot.send_message(chat_id,'Useful things like wiki or chat will be later.\n\n',reply_markup=keyboard.build(button_back='main_menu').as_markup()) await bot.delete_message(chat_id,message_id) + await query.answer('help menu') -@router.callback_query(cb.filter(F.dst == 'support_on')) -async def menu_support_cb_handler(query: CallbackQuery, state: FSMContext): - await query.answer(query.id) - - username = query.message.chat.username +@router.callback_query(CbData.filter(F.dst == 'support_request')) +async def handle_support_request(query: CallbackQuery, state: FSMContext): chat_id = query.message.chat.id - message_id = query.message.message_id - support_status = db.get_records('support_status','id',chat_id) - if support_status == 'on': - await bot.send_message(chat_id,"Please wait until we answer") - await state.reset_state() + await bot.send_message(chat_id,'Please wait until we answer') + await state.clear() + await query.answer('Ok') return - + + keyboard = MenuBuilder() await state.set_state(Form.support) - await bot.send_message(chat_id, "Please enter your message.") + call = query.message.answer(text= + 'Enter your question or describe the issue in one message.\n'+ + 'Press Back if you changed your mind', reply_markup=keyboard.build(button_back='support_cancel').as_markup()) + + await query.bot(call) + db.update_record(chat_id,'support_status','on') + await query.answer('Ok') + +@router.callback_query(CbData.filter(F.dst == 'support_cancel')) +async def handle_support_cancel(query: CallbackQuery, state: FSMContext): + await state.clear() + text, keyboard = main_menu() + call = query.message.edit_text(text=text, reply_markup=keyboard.as_markup()) + await query.bot(call) + await query.answer('Got it. Hope you are okay') + +### Admin actions +@router.callback_query(CbData.filter(F.dst == 'support_reply_cancel')) +async def handle_support_reply_cancel(query: CallbackQuery, callback_data: CbData, state: FSMContext): + #db.update_record(int(callback_data.data), 'support_status', 'off') + await state.clear() + call = query.message.delete() + await query.answer('Response canceled') + await query.bot(call) + +@router.callback_query(CbData.filter(F.dst == 'support_reply_start')) +async def handle_support_reply_start(query: CallbackQuery, callback_data: CbData, state: FSMContext): + try: + client_chat_id = int(callback_data.data) + except: + await query.answer('Something went wrong. chat_id not found.') + return + + keyboard = MenuBuilder() + keyboard.add(preset='support_reply_cancel', data=str(client_chat_id)) + call = query.message.reply(text=f'@{query.from_user.username} initiated conversation with {callback_data.data}. Please enter response text', + reply_markup=keyboard.build().as_markup()) + + await state.set_state(Form.admin_send_msg) + await state.set_data({'client_chat_id': client_chat_id}) + await query.bot(call) + await query.answer('Ok') + +@router.callback_query(CbData.filter(F.dst == 'support_reply_submit')) +async def handle_support_reply_submit(query: CallbackQuery, state: FSMContext): + d = await state.get_data() + try: + d['client_chat_id'] + d['response'] + except KeyError: + await query.answer('Outdated') + await query.bot.delete_message(chat_id=query.from_user.id, message_id=query.message.message_id) + return + await query.bot.send_message(chat_id=d['client_chat_id'], text=f'P2P support team:\n{d["response"]}') + db.update_record(d['client_chat_id'], 'support_status', 'off') + await state.clear() + await query.answer('Sent') + +@router.callback_query(CbData.filter(F.dst == 'support_off')) +async def handle_support_off(query: CallbackQuery, callback_data: CbData): + db.update_record(int(callback_data.data), 'support_status', 'off') + await query.answer('Ok') + +@router.callback_query(CbData.filter(F.dst == 'toggle_ban')) +async def handle_toggle_ban(query: CallbackQuery, callback_data: CbData): + chat_id = int(callback_data.data) + if db.get_records('account_status', 'id', chat_id) == 'off': + db.update_record(chat_id, 'account_status', 'on') + await bot.send_message(chat_id, 'Your account has been activated🤷\nHave a good day.') + await query.answer('Unbanned') + else: + db.update_record(chat_id, 'account_status', 'off') + db.update_record(chat_id, 'support_status', 'off') + await bot.send_message(chat_id, 'Your account has been disabled 🤷\nSorry and have a good day.') + await query.answer('Banned') + \ No newline at end of file diff --git a/bot/app/forms/admin_support_reply.py b/bot/app/forms/admin_support_reply.py deleted file mode 100644 index 7c17728..0000000 --- a/bot/app/forms/admin_support_reply.py +++ /dev/null @@ -1,28 +0,0 @@ -from __main__ import dp, db, bot -from aiogram.types import KeyboardButton, Message, ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton -from aiogram.dispatcher import FSMContext -from aiogram.dispatcher.filters.state import State, StatesGroup -from callback_query_handlers import admin_buttons - -class Form(StatesGroup): - admin_send_msg = State() - -@dp.message_handler(state=Form.admin_send_msg) -async def process_support(message: Message, state: FSMContext) -> None: - username = message.chat.username - chat_id = message.from_user.id - - if '/' in message.text: - await message.answer("Unexpected charaster in your message.",reply_markup=ReplyKeyboardRemove()) - await state.reset_state() - return - - menu = InlineKeyboardMarkup().add(InlineKeyboardButton(text="Continue", callback_data=admin_buttons.cb.new(action="support_open")), - InlineKeyboardButton(text="Close", callback_data=admin_buttons.cb.new(action="support_close"))) - - - await bot.send_message(chat_id, "Message:\n{}".format(message.text),reply_markup=menu) - - await state.reset_state() - db.update_record(chat_id,'support_status','off') - return diff --git a/bot/app/forms/sub_filter.py b/bot/app/forms/sub_filter.py new file mode 100644 index 0000000..848e5d0 --- /dev/null +++ b/bot/app/forms/sub_filter.py @@ -0,0 +1,42 @@ +from __main__ import router, db +from utils.db import DB +from aiogram.types import Message +from aiogram.fsm.context import FSMContext +from aiogram.filters.state import State, StatesGroup +from utils.menu_builder import MenuBuilder +from utils.msg_text import dict2text + +assert isinstance(db, DB) + +class Form(StatesGroup): + sub_filter = State() + +@router.message(Form.sub_filter) +async def handle_sub_filter_input(message: Message, state: FSMContext) -> None: + chat_id = message.from_user.id + d = await state.get_data() + await state.clear() + rule_name = d.get("rule") + if message.text and message.text != 'Back': + matchers = [] + if rule_name != 'any': + matchers.append(('alertname', rule_name)) + m = message.text.strip().replace(',', '\n') + d['current'][d['expect']] = [] + for line in m.splitlines(): + if line != '': + d['current'][d['expect']].append(line) + ###### + keyboard = MenuBuilder() + keyboard.add(preset='sub_filter_edit', data=d['current']) + keyboard.add(preset='sub_save', data=d['current']['alertname'][0]) + text = f'{d["expect"]} updated. Review changes and press Save to subscribe or Back to cancel.\n' + await message.bot.send_message(text=text+dict2text(d['current']), chat_id=chat_id, reply_markup=keyboard.build(button_back='promalert').as_markup()) + else: + pass + +def sub_filter_input_validate(expected: str, got: [str]) -> (bool, str): + if expected == 'account': + if got == ['any']: + return False, 'Filter "any" for "account" is to open it will cause too many notifications.' + return True, '' \ No newline at end of file diff --git a/bot/app/forms/support.py b/bot/app/forms/support.py index ee50d15..64f62de 100644 --- a/bot/app/forms/support.py +++ b/bot/app/forms/support.py @@ -1,4 +1,46 @@ +from __main__ import router, db, admin_chat +from utils.db import DB +from aiogram.types import Message +from aiogram.fsm.context import FSMContext from aiogram.filters.state import State, StatesGroup +from utils.menu_builder import MenuBuilder +assert isinstance(db, DB) class Form(StatesGroup): support = State() + admin_send_msg = State() + +@router.message(Form.support) +async def handle_client_input(message: Message, state: FSMContext) -> None: + await state.clear() + keyboard = MenuBuilder() + keyboard.add(preset='support_reply_start',data=str(message.from_user.id)) + keyboard.add(preset='support_off',data=str(message.from_user.id)) + keyboard.add(preset='toggle_ban',data=str(message.from_user.id)) + username = message.chat.username + if message.text: + await message.bot.send_message(admin_chat, f'Username: @{username}\nMessage:\n{message.text}\n', + reply_markup=keyboard.build().as_markup()) + elif message.photo: + await message.bot.send_photo(admin_chat,message.photo[0].file_id) + await message.bot.send_message(admin_chat, f'Username: @{username} + \nCaption:\n{message.caption}\n', + reply_markup=keyboard.build().as_markup()) + + await message.answer('Please wait for the answer') + +@router.message(Form.admin_send_msg) +async def handle_admin_input(message: Message, state: FSMContext) -> None: + keyboard = MenuBuilder() + keyboard.add(preset='support_reply_submit', data=str(message.from_user.id)) + keyboard.add(preset='support_reply_cancel', data=str(message.from_user.id)) + chat_id = message.from_user.id + try: + d = await state.get_data() + except: + message.answer("Could not read client chat_id") + return + + d['response'] = message.text + await state.set_data(d) + await message.bot.send_message(chat_id, f'Are you sure you want to send following message to {d["client_chat_id"]}?:\n{message.text}', + reply_markup=keyboard.build().as_markup()) diff --git a/bot/app/message_handlers/start.py b/bot/app/message_handlers/start.py index 706c36a..32c081a 100644 --- a/bot/app/message_handlers/start.py +++ b/bot/app/message_handlers/start.py @@ -1,9 +1,9 @@ -from __main__ import router,dp,db,bot,admin_chat,cb +from __main__ import router,dp,db,admin_chat from utils.menu_builder import MenuBuilder from aiogram.types import Message from aiogram import F -@router.message(F.content_type.in_({'text', 'sticker','photo'})) +@router.message(F.text == '/start') async def command_start(message: Message) -> None: if str(message.chat.id).startswith('-'): await message.answer("🧑🤝🧑 Group chats are not allowed.\nSorry and have a good day.") @@ -14,14 +14,16 @@ async def command_start(message: Message) -> None: account_status = db.get_records('account_status','id',chat_id) menu = MenuBuilder() - menu = menu.build(callback_data=cb,preset='main_menu') + menu = menu.build(preset='main_menu') if account_status and account_status == 'off': await message.answer(chat_id,"Your account has been disabled 🤷\nSorry and have a good day.") return if not account_status: - await bot.send_message(admin_chat, "Username: @{} ID: {}\nHas just PRE-registered.".format(username,chat_id)) + keyboard = MenuBuilder() + keyboard.add(preset='toggle_ban',data=str(message.from_user.id)) + await message.bot.send_message(admin_chat, text="Username: @{} ID: {}\nHas just PRE-registered.".format(username,chat_id), reply_markup=keyboard.build().as_markup()) db.add_account(chat_id,username) await message.answer("Hi there 👋\n\n\nWelcome to a validator monitoring bot by P2P.org\n\n\n\n",reply_markup=menu.as_markup()) diff --git a/bot/app/message_handlers/support.py b/bot/app/message_handlers/support.py deleted file mode 100644 index cc1aaaf..0000000 --- a/bot/app/message_handlers/support.py +++ /dev/null @@ -1,28 +0,0 @@ -from __main__ import router,db,bot,cb,admin_chat -from aiogram.types import Message -from aiogram.fsm.context import FSMContext -from aiogram.fsm.state import State,StatesGroup -from forms.support import Form -from aiogram import F -from utils.menu_builder import MenuBuilder - -@router.message(state=Form.support) -async def process_support(message: Message) -> None: - username = message.chat.username - chat_id = message.from_user.id - - menu = MenuBuilder() - user_keyboard = menu.build(callback_data=cb,preset='support_text',button_back='support',button_main_meny=True) - admin_keyboard = menu.build(callback_data=cb,preset='support_reply') - - if message.text: - await bot.send_message(admin_chat, "Username: @{}\nMessage:\n{}\n\n".format(username,message.text),reply_markup=admin_keyboard.as_markup()) - elif message.photo: - await bot.send_photo(admin_chat,message.photo[0].file_id) - await bot.send_message(admin_chat, "Username: @{} + \nCaption: {}\n\n".format(username,message.caption),reply_markup=admin_keyboard.as_markup()) - - await message.answer("Got it!!!\nYou will get an answer from our team soon.\n\n",reply_markup=user_keyboard.as_markup()) - - await state.reset_state() - - db.update_record(chat_id,'support_status','on') diff --git a/bot/app/utils/db.py b/bot/app/utils/db.py index 9124461..24f943f 100644 --- a/bot/app/utils/db.py +++ b/bot/app/utils/db.py @@ -6,7 +6,17 @@ from psycopg import Error from psycopg.sql import Identifier, Literal, SQL + class DB(): + + table_bot = Identifier('maas_bot_v1') + table_subscription = Identifier('maas_subscription') + col_id = Identifier('id') + col_chat_id = Identifier('chat_id') + col_subscription_name = Identifier('subscription_name') + col_key = Identifier('key_name') + col_value = Identifier('key_value') + def __init__(self, db_name, db_user, db_pass, db_host, db_port): self.db_user = db_user self.db_pass = db_pass @@ -17,7 +27,7 @@ def __init__(self, db_name, db_user, db_pass, db_host, db_port): self._cursor = None self.init() - def connect(self,retry_counter=0,sleep=5,attempts=5): + def connect(self, retry_counter=0, sleep=5, attempts=5): if not self._connection: try: self._connection = psycopg.connect(dbname=self.db_name, @@ -36,7 +46,7 @@ def connect(self,retry_counter=0,sleep=5,attempts=5): retry_counter += 1 logging.warning('Could not conect to DB retry ' + str(retry_counter)) time.sleep(sleep) - self.connect(retry_counter,sleep,attempts) + self.connect(retry_counter, sleep, attempts) except (Exception, psycopg.Error) as error: logging.error(error) traceback.print_exc() @@ -50,14 +60,14 @@ def cursor(self): return self._cursor - def query(self, query): + def query(self, query, compact=True): result = [] - + if self._cursor: if self._cursor.closed: self.reset() logging.error('Unable connect to DB') - + try: self._cursor.execute(query) except (psycopg.errors.UniqueViolation, psycopg.errors.UndefinedColumn, psycopg.errors.InFailedSqlTransaction) as e: @@ -70,7 +80,7 @@ def query(self, query): colnames = [desc[0] for desc in self._cursor.description] rows = self._cursor.fetchall() - if len(list(rows)) == 1: + if len(list(rows)) == 1 and compact: return rows[0][0] for row in rows: @@ -83,7 +93,7 @@ def query(self, query): else: self.reset() logging.error('Unable connect to DB') - + return result def commit(self): @@ -99,7 +109,7 @@ def init(self): def reset(self): self.close() - if self.connect(0,1,2): + if self.connect(0, 1, 2): self.cursor() def close(self): @@ -110,24 +120,96 @@ def close(self): self._connection = None self._cursor = None - def get_records(self,field,key,value): + def get_records(self, field, key, value): try: - result = self.query(SQL('SELECT {} FROM {} WHERE {} = {}').format(Identifier(field),Identifier('maas_bot'),Identifier(key),Literal(value))) - except (IndexError,KeyError): + result = self.query(SQL('SELECT {} FROM {} WHERE {} = {}').format( + Identifier(field), + DB.table_bot, + Identifier(key), + Literal(value))) + except (IndexError, KeyError): result = None return result - def add_account(self,chat_id,username): - values = SQL(', ').join(Literal(i) for i in [chat_id,username]) - self.query(SQL('INSERT INTO {} VALUES ({})').format(Identifier('maas_bot'),values)) + def add_account(self, chat_id, username): + self.query(SQL('INSERT INTO {} VALUES ({})').format( + DB.table_bot, + SQL(', ').join([Literal(chat_id), Literal(username)]))) + + self.commit() + def update_record(self, chat_id, field, value): + self.query(SQL('UPDATE {} set {} = {} WHERE {} = {}').format( + DB.table_bot, + Identifier(field), + Literal(value), + DB.col_id, + Literal(chat_id))) self.commit() - def update_record(self,chat_id,field,value): - self.query(SQL('UPDATE {} set {} = {} WHERE {} = {}').format(Identifier('maas_bot'),Identifier(field),Literal(value),Identifier('id'),Literal(chat_id))) + def delete_account(self, chat_id): + self.query(SQL('DELETE FROM {} WHERE {} = {}').format( + DB.table_bot, + DB.col_id, + Literal(chat_id))) self.commit() - def delete_account(self,chat_id): - self.query(SQL('DELETE FROM {} WHERE {} = {}').format(Identifier('maas_bot'),Identifier('id'),Literal(chat_id))) + def get_subscriptions(self, chat_id): + try: + result = self.query(SQL('SELECT {subscription_name}, {label}, {match} FROM {table} WHERE {chat_id} = {val1}').format( + subscription_name=DB.col_subscription_name, + label=DB.col_key, + match=DB.col_value, + table=DB.table_subscription, + chat_id=DB.col_chat_id, + val1=Literal(chat_id)), compact=False) + except Exception as e: + result = None + return result + + def add_or_update_subscription(self, chat_id, subscription_name, matchers): + if not isinstance(matchers, dict): + return 0 + + columns = [DB.col_chat_id, DB.col_subscription_name, DB.col_key, DB.col_value] + values = [] + + for key, vals in matchers.items(): + for val in vals: + try: + values.append(SQL('({chat_id}, {subscription_name}, {key}, {value})').format( + chat_id=Literal(chat_id), + subscription_name=Literal(subscription_name), + key=Literal(key), + value=Literal(val))) + except IndexError: + return 0 + + try: + self.query(SQL('DELETE FROM {table} WHERE {chat_id} = {val1} AND {subscription_name} = {val2}').format( + table=DB.table_subscription, + chat_id=DB.col_chat_id, + val1=Literal(chat_id), + subscription_name=DB.col_subscription_name, + val2=Literal(subscription_name))) + self.query(SQL('INSERT INTO {table} ({columns}) VALUES {values}').format( + table=DB.table_subscription, + columns=SQL(',').join(columns), + values=SQL(',').join(values))) + self._connection.commit() + return len(values) + except Exception as e: + logging.error("unable to add/update subscription: {}".format(str(e))) + self._connection.rollback() + return 0 + + def delete_subscription(self, chat_id, subscription_name): + self.query(SQL('DELETE FROM {table} WHERE {chat_id} = {val1} AND {subscription_name} = {val2}').format( + table=DB.table_subscription, + chat_id=DB.col_chat_id, + val1=Literal(chat_id), + subscription_name=DB.col_subscription_name, + val2=Literal(subscription_name) + )) self.commit() diff --git a/bot/app/utils/functions.py b/bot/app/utils/functions.py deleted file mode 100644 index 572b6d2..0000000 --- a/bot/app/utils/functions.py +++ /dev/null @@ -1,24 +0,0 @@ -import yaml - -def deploy(chat_id,values_file): - with open(values_file, 'r') as f: - maas_apps = yaml.safe_load(f) - - if not next((app for app in maas_apps["applications"] if int(app) == int(chat_id)), None): - maas_apps["applications"].append(chat_id) - else: - raise Exception("Aborting attempt to copy existing app. Chat ID: {}".format(chat_id)) - - with open(values_file, "w") as f: - yaml.dump(maas_apps, f) - -def destroy(chat_id,values_file): - with open(values_file, 'r') as f: - maas_apps = yaml.safe_load(f) - - for row in maas_apps["applications"]: - if int(row) == int(chat_id): - maas_apps['applications'].remove(row) - - with open(values_file, "w") as f: - yaml.dump(maas_apps, f) diff --git a/bot/app/utils/menu_builder.py b/bot/app/utils/menu_builder.py index 7d27074..6c38f58 100644 --- a/bot/app/utils/menu_builder.py +++ b/bot/app/utils/menu_builder.py @@ -1,56 +1,134 @@ -from __main__ import bot,db -from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton +from callback_data.main import CbData +from __main__ import grafana_url class MenuBuilder(): - def _preset_main_menu(self,callback_data): - self.menu.button(text="Operate over Grafana instances", callback_data=callback_data(dst="grafana",data="").pack()) - self.menu.button(text="Operate over Prometheus alerts", callback_data=callback_data(dst="promalert",data="").pack()) - self.menu.button(text="Contact us(support)", callback_data=callback_data(dst='support',data="").pack()) + def __init__(self) -> None: + self.menu = InlineKeyboardBuilder() + + def _preset_main_menu(self): + self.menu.button(text="Open Grafana", url=grafana_url) + self.menu.button(text="Manage Prometheus alerts", callback_data=CbData(dst="promalert",data="").pack()) + self.menu.button(text="Contact us(support)", callback_data=CbData(dst='support',data="").pack()) self.menu.adjust(1,1,1) - def _preset_promalert(self,callback_data): + def _preset_promalert_on(self): + self.menu.button(text="Enable notifications", callback_data=CbData(dst="promalert_on",data="").pack()) + self.menu.adjust(1) - return self.menu + def _preset_promalert_off(self): + self.menu.button(text="Disable notifications", callback_data=CbData(dst="promalert_off",data="").pack()) + self.menu.adjust(1) - def _preset_grafana_on(self,callback_data): - self.menu.button(text="Setup grafana", callback_data=callback_data(dst="grafana_on",data="").pack()) - self.menu.adjust(1) + def _preset_sub_rules(self): + self.menu.button(text="Add subscription", callback_data=CbData(dst="sub_rules",data="0").pack()) + self.menu.adjust(1) + + def _preset_sub_list(self): + self.menu.button(text="My subscriptions", callback_data=CbData(dst="sub_list",data="").pack()) + self.menu.adjust(1) - def _preset_grafana_off(self,callback_data): - self.menu.button(text="Delete grafana", callback_data=callback_data(dst="grafana_off",data="").pack()) - self.menu.adjust(1) + def _preset_sub_save(self, data: str = ''): + self.menu.button(text="Save", callback_data=CbData(dst="sub_save",data=data).pack()) + self.menu.adjust(1) - def _preset_subscribtions(self,callback_data): + def _preset_sub_del(self, data: str = ''): + self.menu.button(text="Delete", callback_data=CbData(dst="sub_del",data=data).pack()) + self.menu.adjust(1) - return self.menu + def _preset_sub_filter_edit(self, data: [str] = []): + for k in data: + if k != 'alertname': + self.menu.row(InlineKeyboardButton(text=f"Edit {k}", callback_data=CbData(dst="sub_edit", data=k).pack(), width=1)) + + def _preset_rules_list(self, data: [str] = [], navigation: str = '0'): + skip = int(navigation) + take = 7 + + take = min(len(data) - skip, take) + for i in range(skip, skip+take): + self.menu.row(InlineKeyboardButton(text=data[i].alertname, callback_data=CbData(dst="sub_rule", data=data[i].alertname).pack()), width=1) + if len(data) > take: + prev_page = skip-take if skip-take >= 0 else len(data)-take + next_page = skip+take if skip+take < len(data) else 0 + self.menu.row(*( + InlineKeyboardButton(text="<<", callback_data=CbData(dst="sub_rules", data=str(prev_page)).pack()), + InlineKeyboardButton(text=">>", callback_data=CbData(dst="sub_rules", data=str(next_page)).pack()), + InlineKeyboardButton(text="Back", callback_data=CbData(dst="promalert", data="").pack()) + )) + else: + self.menu.row(InlineKeyboardButton(text="Back", callback_data=CbData(dst="promalert", data="").pack())) + + def _preset_sub_scroll(self, navigation: str = '0/0'): + current_page, total_pages = [int(p) for p in navigation.split("/")] + if total_pages > 1: + next_page = current_page + 1 if current_page + 1 < total_pages else 0 + prev_page = current_page - 1 if current_page - 1 >= 0 else total_pages - 1 + self.menu.row(*( + InlineKeyboardButton(text="<<", callback_data=CbData(dst="sub_list", data=f"{prev_page}/{total_pages}").pack()), + InlineKeyboardButton(text=">>", callback_data=CbData(dst="sub_list", data=f"{next_page}/{total_pages}").pack()), + InlineKeyboardButton(text="Back", callback_data=CbData(dst="promalert", data="").pack()) + )) + else: + self.menu.row( + InlineKeyboardButton(text="Back", callback_data=CbData(dst="promalert", data="").pack()) + ) + + def _preset_support_request(self): + self.menu.button(text="Ask for help", callback_data=CbData(dst='support_request',data="").pack()) + self.menu.adjust(1) - def _preset_support_on(self,callback_data): - self.menu.button(text="Contact us", callback_data=callback_data(dst='support_on',data="").pack()) + def _preset_support_reply_start(self, data=""): + self.menu.button(text="Start reply", callback_data=CbData(dst='support_reply_start',data=data).pack()) self.menu.adjust(1) - def _support_reply(self,callback_data): - self.menu.button(text="Reply to client", callback_data=callback_data(dst='support_reply',data="").pack()) + def _preset_support_reply_cancel(self, data=""): + self.menu.button(text="Cancel", callback_data=CbData(dst='support_reply_cancel',data=data).pack()) self.menu.adjust(1) - def _button_main_menu(self,callback_data): - self.menu.button(text="Main menu", callback_data=callback_data(dst='main_menu',data="").pack()) + def _preset_support_reply_submit(self, data=""): + self.menu.button(text="Submit reply", callback_data=CbData(dst='support_reply_submit',data=data).pack()) self.menu.adjust(1) - def _button_back(self,callback_data,dst): - self.menu.button(text="Back", callback_data=callback_data(dst=dst,data="").pack()) + def _preset_support_off(self, data=""): + self.menu.button(text="Support off", callback_data=CbData(dst='support_off',data=data).pack()) self.menu.adjust(1) - def build(self,callback_data=None,preset: str = None, button_back: str = None, button_main_menu: bool = False): - self.menu = InlineKeyboardBuilder() + def _preset_toggle_ban(self, data=""): + self.menu.button(text="Ban/Unban", callback_data=CbData(dst='toggle_ban',data=data).pack()) + self.menu.adjust(1) + + def _button_main_menu(self): + self.menu.button(text="Main menu", callback_data=CbData(dst='main_menu',data="").pack()) + self.menu.adjust(1) + def _button_back(self,dst): + self.menu.button(text="Back", callback_data=CbData(dst=dst,data="").pack()) + self.menu.adjust(1) + + def build(self, preset: str = None, button_back: str = None, button_main_menu: bool = False): if preset: - eval('self._preset_' + preset)(callback_data) + eval('self._preset_' + preset)() if button_main_menu == True: - self._button_main_menu(callback_data) + self._button_main_menu() if button_back: - self._button_back(callback_data,button_back) + self._button_back(button_back) return self.menu + + def add(self, preset: str = None, data: any = None, navigation: str = ''): + if preset: + if data != None and navigation != '': + eval('self._preset_' + preset)(data=data, navigation=navigation) + return self + if data != None: + eval('self._preset_' + preset)(data=data) + return self + if navigation != '': + eval('self._preset_' + preset)(navigation=navigation) + return self + eval('self._preset_' + preset)() + return self diff --git a/bot/app/utils/msg_text.py b/bot/app/utils/msg_text.py new file mode 100644 index 0000000..b3f7413 --- /dev/null +++ b/bot/app/utils/msg_text.py @@ -0,0 +1,21 @@ + +def text2dict(text: str): + if '---\n' in text: + text = text[text.index('---\n') + 4:] + d = dict() + key = "" + for l in text.strip('\n').splitlines(): + if l == '': + continue + if l.endswith(":"): + key = l[:-1] + d[key] = [] + else: + d[key].append(l) + return d + +def dict2text(d: dict): + t = '---' + for k in d.keys(): + t = "{}\n{}:\n{}".format(t,k,'\n'.join(d[k])) + return t \ No newline at end of file diff --git a/bot/app/utils/subscriptions.py b/bot/app/utils/subscriptions.py new file mode 100644 index 0000000..b19247e --- /dev/null +++ b/bot/app/utils/subscriptions.py @@ -0,0 +1,143 @@ + +from utils.db import DB +import asyncio, aiohttp + +class Alert(): + def __init__(self, prom_json: dict) -> None: + alert = prom_json.get('alerts', [{}])[0] + + labels = alert.get('labels', {}) + annotations = alert.get('annotations', {}) + + self.alertname = labels.get('alertname', '') + self.severity = labels.get('severity', '') + self.description = annotations.get('description', '') + + self.kv = {'alertname': self.alertname} + for k, v in annotations.items(): + if k not in ['description', 'summary']: + self.kv[k] = v + +class AlertRule: + def __init__(self, prom_rule: dict) -> None: + self.severity = prom_rule.get("labels", {}).get("severity", "info") + self.alertname = prom_rule.get("name") + annotations = prom_rule.get('annotations', {}) + # save all possible annotation names exluding desc and summary + # eg: account, event_type, etc... + self.keys = [k for k in annotations if k not in ['description', 'summary']] + + + def __gt__(self, other): + return self.alertname > other.alertname + + def dict(self): + d = dict() + d['alertname'] = [self.alertname] + for k in self.keys: + d[k] = ['any'] + return d + +class AlertRules: + def __init__(self, prom_resp: dict, only_groups: [str] = []) -> None: + self.rules = dict() + self.only_groups = only_groups + + # # successful resp sample + # status: "success" + # data: + # - groups: + # - name: "" + # rules: + # - name: "" + # annotations: {} + # labels: {} + if prom_resp.get("status", "") != "success": + raise Exception("unable to get 200 response from Prometheus API") + for group in prom_resp.get("data", {}).get("groups", []): + if self.only_groups == [] or group.get('name', '') in self.only_groups: + for rule in group.get("rules", []): + self.add(rule) + + def add(self, prom_rule: dict) -> AlertRule: + r = AlertRule(prom_rule) + self.rules[r.alertname] = r + return r + + def list(self) -> list[AlertRule]: + return sorted(self.rules.values()) + + def by_name(self, name) -> AlertRule: + try: + return self.rules[name] + except KeyError: + return None + +class Subscriptions: + def __init__(self, db: DB, rules_url: str, only_groups: list[str]=[]): + self.db = db + self.rules_url = rules_url + self.only_groups = only_groups + + async def get_rule_by_name(self, name: str) -> AlertRule: + rules = await self.get_rules() + return rules.by_name(name) + + async def get_rules(self) -> AlertRules: + e = None + for i in range(1, 10): + try: + async with aiohttp.ClientSession() as session: + async with session.get(self.rules_url) as r: + resp = await r.json() + break + except Exception as err: + e = err + await asyncio.sleep(10) + + if e != None: + raise(Exception("unable to read data from Prometheus API {0}, reason: {1}".format(self.rules_url, str(e)))) + + return AlertRules(prom_resp=resp, only_groups=self.only_groups) + + def user_subscribe(self, chat_id: int, subscription_name: str, lvs: dict[str, any]): + return self.db.add_or_update_subscription(chat_id, subscription_name, lvs) + + def must_notify(self, chat_id, a: Alert): + subs = self.get_subscriptions(chat_id) + + for filters in subs.values(): + matched = [] + for key, values in filters.items(): + for val in values: + if a.kv.get(key, '') == val or val == 'any': + matched.append(True) + if len(matched) == len(filters): + return True + return False + + def user_unsubscribe(self, chat_id: int, subscription_name: str): + self.db.delete_subscription(chat_id, subscription_name) + + def get_subscriptions(self, chat_id: int): + if chat_id == 0: + return {} + db_subs = self.db.get_subscriptions(chat_id) + subs = {} + for sub in db_subs: + subscription_name = sub.get('subscription_name') + if subs.get(subscription_name) == None: + subs[subscription_name] = {} + key = sub.get('key_name') + value = sub.get('key_value') + if subs[subscription_name].get(key) == None: + subs[subscription_name][key] = [value] + else: + subs[subscription_name][key].append(value) + return subs + + def get_subscription_by_index(self, chat_id: int, index: int): + subs = self.get_subscriptions(chat_id=chat_id) + keys = sorted(subs.keys()) + if index >= 0 and index < len(keys) and len(keys) >= 1: + return subs[keys[index]], len(keys) diff --git a/bot/app/values.yml b/bot/app/values.yml deleted file mode 100644 index 76901dc..0000000 --- a/bot/app/values.yml +++ /dev/null @@ -1 +0,0 @@ -applications: [] diff --git a/bot/app/web_apps/prom_alert.py b/bot/app/web_apps/prom_alert.py index 0c112cd..503efc1 100644 --- a/bot/app/web_apps/prom_alert.py +++ b/bot/app/web_apps/prom_alert.py @@ -1,36 +1,27 @@ -from __main__ import db, bot, web, web_app +from __main__ import db, bot, web, web_app, subs from aiohttp.web_request import Request from aiohttp.web_response import json_response from aiogram.types import WebAppInfo from aiogram.types import ReplyKeyboardRemove +import logging +from utils.subscriptions import Alert async def handler(request: Request): - alert = await request.json() + a = await request.json() + alert = Alert(a) - try: - severity = alert['alerts'][0]['labels']['severity'] - except (IndexError,KeyError): - severity = None - - try: - alertname = alert['alerts'][0]['labels']['alertname'] - except (IndexError,KeyError): - alertname = None - - try: - message = alert['alerts'][0]['annotations']['description'] - except (IndexError,KeyError): - message = None - - if severity and alertname: + if alert.severity and alert.alertname: ids = db.get_records('id','promalert_status','on') if isinstance(ids, int): ids = [{'id':ids}] - for i in ids: - await bot.send_message(i['id'], "Prometheus alerting\n\nSeverity: {}\nAlert name: {}\nMessage: {}\n\nYou can always disable notification such this by calling /promalert\n\nFeel free to contact us /support if any questions.".format(severity,alertname,message),reply_markup=ReplyKeyboardRemove()) + if subs.must_notify(i['id'], alert): + await bot.send_message(i['id'], '\n'.join([ + f'Alert: {alert.alertname}', + f'Severity: {alert.severity}', + f'Description: {alert.description}'])) return web.json_response({'status':'ok'}) ## To be reviewed diff --git a/bot/db_scheme.sql b/bot/db_scheme.sql index 61f814d..d50eff7 100644 --- a/bot/db_scheme.sql +++ b/bot/db_scheme.sql @@ -23,19 +23,29 @@ SET default_table_access_method = heap; -- -- Name: maas_bot; Type: TABLE; Schema: public; Owner: adm -- -CREATE TABLE public.maas_bot ( +CREATE TABLE public.maas_bot_v1 ( id bigint NOT NULL, username text, account_status text DEFAULT 'on', - grafana_status text DEFAULT 'off', promalert_status text DEFAULT 'off', support_status text DEFAULT 'off', - creation_time timestamp without time zone DEFAULT now(), - grafana_deploy_time timestamp without time zone + creation_time timestamp without time zone DEFAULT now() ); -ALTER TABLE public.maas_bot OWNER TO adm; -ALTER TABLE ONLY public.maas_bot - ADD CONSTRAINT maas_bot_pkey PRIMARY KEY (id); +ALTER TABLE public.maas_bot_v1 OWNER TO adm; +ALTER TABLE ONLY public.maas_bot_v1 + ADD CONSTRAINT maas_bot_v1_pkey PRIMARY KEY (id); + +CREATE TABLE public.maas_subscription ( + id SERIAL PRIMARY KEY, + chat_id bigint NOT NULL, + subscription_name text NOT NULL, + key_name text NOT NULL, + key_value text NOT NULL, + CONSTRAINT fk_chat_id + FOREIGN KEY(chat_id) + REFERENCES public.maas_bot_v1(id) +); +ALTER TABLE public.maas_subscription OWNER TO adm; -- -- Name: SCHEMA public; Type: ACL; Schema: -; Owner: adm -- diff --git a/docker-compose.yml b/docker-compose.yml index 1291160..1a62f9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: networks: - exporters - monitoring + - bot grafana: image: grafana/grafana:latest @@ -52,15 +53,16 @@ services: environment: - "db_host=postgres" - "db_port=5432" + - "prometheus_rules_url=http://prometheus:9090/api/v1/rules" + - "prometheus_alert_groups=maas-rules" env_file: - ./bot.env - volumes: - - ./bot/app/values.yml:/app/values.yml ports: - "8080:8080" networks: - bot - postgres + - monitoring postgres: container_name: postgres diff --git a/polkadot.yml b/polkadot.yml index 89e8a1d..cce489b 100644 --- a/polkadot.yml +++ b/polkadot.yml @@ -15,7 +15,7 @@ services: networks: - exporters restart: on-failure - + polkadot_finality_exporter: build: context: ./exporters/common @@ -30,7 +30,7 @@ services: networks: - exporters restart: on-failure - + polkadot_events_exporter: build: context: ./exporters/events diff --git a/prometheus/alerts.yml b/prometheus/alerts.yml index f93c09a..b821386 100644 --- a/prometheus/alerts.yml +++ b/prometheus/alerts.yml @@ -1,30 +1,37 @@ --- +## Please never rename alert names without corresponding migration in DB groups: -- name: maas rules +- name: maas-rules rules: - - alert: Kusama GRANDPA precommits ratio(critical) - expr: (polkadot_finality_precommits{chain="kusama"} / on (chain) group_left() polkadot_finality_roundsProcessed * 100) < 10 and on(instance) polkadot_session_validators == 1 and on (chain) polkadot_session_sessionProgress >= 18 + - alert: Validator left set + expr: polkadot_session_disabledValidators == 1 + for: 1m + labels: + severity: "critical" + annotations: + summary: "{{ $labels.account }}: Validator has been kicked from the active set." + description: "Validator {{ $labels.account }} has been kicked from the active set." + chain: "{{ $labels.chain }}" + account: "{{ $labels.account }}" + + - alert: Low GRANDPA precommits ratio + expr: (polkadot_finality_precommits{chain="polkadot"} / on (chain) group_left() polkadot_finality_roundsProcessed * 100) < 10 and on(node) polkadot_session_validators == 1 and on (chain) polkadot_session_sessionProgress >= 3 for: 2m labels: severity: critical annotations: - summary: "{{ $labels.account }}: Validator has problem with block finality(precommits) less than 33%" - description: "Validator {{ $labels.account }} has problem with block finality(precommits) less than 33%" + summary: "{{ $labels.account }}: Validator has problem with block finality(precommits) less than 10%" + description: "Validator {{ $labels.account }} has problem with block finality(precommits) less than 10%" + chain: "{{ $labels.chain }}" + account: "{{ $labels.account }}" - - alert: Kusama GRANDPA prevotes ratio - expr: (polkadot_finality_prevotes{chain="kusama"} / on (chain) group_left() polkadot_finality_roundsProcessed * 100) < 80 and on(instance) polkadot_session_validators == 1 and on (chain) polkadot_session_sessionProgress >= 18 + - alert: Low GRANDPA prevotes ratio + expr: (polkadot_finality_prevotes{chain="polkadot"} / on (chain) group_left() polkadot_finality_roundsProcessed * 100) < 80 and on(node) polkadot_session_validators == 1 and on (chain) polkadot_session_sessionProgress >= 3 for: 2m labels: severity: high annotations: summary: "{{ $labels.account }}: Validator has problem with block finality(prevotes) less than 80%" description: "Validator {{ $labels.account }} has problem with block finality(prevotes) less than 80%" - - - alert: Earned points(Total rate) - expr: sum by (account)(rate(polkadot_staking_eraPoints[1m])) == 0 - for: 10m - labels: - severity: "critical" - annotations: - summary: "{{ $labels.account }}: not producing blocks for long time." - description: "Validator {{ $labels.account }} has a problem with blocks producing." \ No newline at end of file + chain: "{{ $labels.chain }}" + account: "{{ $labels.account }}"