From 7b12a491e86742f756af94046f9083fb12ec417b Mon Sep 17 00:00:00 2001 From: tonyrewin Date: Sun, 23 Apr 2023 19:54:58 +0300 Subject: [PATCH] 0.0.8-newage --- CHANGELOG.md | 13 +- api/webhook.py | 71 ++++++--- requirements.txt | 2 +- tgbot/api.py | 80 ++++++++-- tgbot/graph.py | 64 -------- tgbot/handlers.py | 185 ------------------------ tgbot/handlers/callback_unlink.py | 23 +++ tgbot/handlers/callback_vouch.py | 38 +++++ tgbot/handlers/command_graph.py | 29 ++++ tgbot/handlers/command_my.py | 40 +++++ tgbot/handlers/handle_default.py | 47 ++++++ tgbot/handlers/handle_feedback.py | 29 ++++ tgbot/handlers/handle_join_request.py | 26 ++++ tgbot/handlers/handle_members_change.py | 57 ++++++++ tgbot/handlers/send_button.py | 27 ++++ tgbot/profile.py | 33 ----- tgbot/storage/__init__.py | 9 ++ tgbot/storage/profile.py | 44 ++++++ tgbot/utils/graph.py | 67 +++++++++ tgbot/utils/mention.py | 9 ++ 20 files changed, 572 insertions(+), 321 deletions(-) delete mode 100644 tgbot/graph.py delete mode 100644 tgbot/handlers.py create mode 100644 tgbot/handlers/callback_unlink.py create mode 100644 tgbot/handlers/callback_vouch.py create mode 100644 tgbot/handlers/command_graph.py create mode 100644 tgbot/handlers/command_my.py create mode 100644 tgbot/handlers/handle_default.py create mode 100644 tgbot/handlers/handle_feedback.py create mode 100644 tgbot/handlers/handle_join_request.py create mode 100644 tgbot/handlers/handle_members_change.py create mode 100644 tgbot/handlers/send_button.py delete mode 100644 tgbot/profile.py create mode 100644 tgbot/storage/__init__.py create mode 100644 tgbot/storage/profile.py create mode 100644 tgbot/utils/graph.py create mode 100644 tgbot/utils/mention.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a61cd88..b37721f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,16 @@ +## [0.0.8] + +- генерация древовидного графа, с опорой на одного участника +- /my для просмотра и изменения связей +- возможность отмены поручительства +- рефакторинг + + ## [0.0.7] -- исправления -- команда, генерирующая граф связей +- мьют на входе, там где заявки не включены +- одобрение заявки: любой участник может поручиться +- за всех кто уже в чате и пишет сообщения ## [0.0.6] diff --git a/api/webhook.py b/api/webhook.py index 15f2b84..7e5acd8 100644 --- a/api/webhook.py +++ b/api/webhook.py @@ -1,11 +1,19 @@ -from tgbot.config import WEBHOOK, FEEDBACK_CHAT_ID # init storage there -from tgbot.handlers import handle_feedback, handle_answer, \ - handle_join, handle_left, handle_button, handle_join_request, \ - handle_graph -from tgbot.api import register_webhook from sanic import Sanic from sanic.response import text +from tgbot.config import WEBHOOK, FEEDBACK_CHAT_ID + +from tgbot.handlers.handle_feedback import handle_feedback, handle_answer +from tgbot.handlers.handle_members_change import handle_join, handle_left +from tgbot.handlers.handle_join_request import handle_join_request +from tgbot.handlers.handle_default import handle_default +from tgbot.handlers.command_my import handle_command_my +from tgbot.handlers.command_graph import handle_command_graph +from tgbot.handlers.callback_vouch import handle_button +from tgbot.handlers.callback_unlink import handle_unlink + +from tgbot.api import register_webhook + app = Sanic() app.config.REGISTERED = False @@ -13,14 +21,15 @@ app.config.REGISTERED = False @app.route('/', methods=["GET"]) async def register(req): + res = 'skipped' if not app.config.REGISTERED: r = register_webhook(WEBHOOK) - print(f'\n\t\t\tWEBHOOK REGISTERED:\n{r.json()}') + print(f'\n\t\t\tWEBHOOK REGISTERED:\n{r}') app.config.REGISTERED = True - print(r.json()) - return text('ok') - return text('skipped') - + print(r) + res = 'ok' + return text(res) + @app.post('/') async def handle(req): @@ -28,25 +37,45 @@ async def handle(req): try: update = req.json print(update) - if 'message' in update: - msg = update.get('message', update.get('edited_message')) - if msg['chat']['type'] == 'private': - handle_feedback(msg) + + # видимые сообщения + msg = update.get('message', update.get('edited_message')) + if msg: + if msg['chat']['id'] == msg['from']['id']: + if msg['text'] == '/my': + handle_command_my(msg) + else: + handle_feedback(msg) elif str(msg['chat']['id']) == FEEDBACK_CHAT_ID: if 'reply_to_message' in msg: handle_answer(msg) elif 'text' in msg and msg['text'] == '/graph': - handle_graph(msg) + await handle_command_graph(msg) + elif 'new_chat_member' in msg: + handle_join(msg) + elif 'left_chat_member' in msg: + handle_left(msg) else: - if 'new_chat_member' in msg: - handle_join(msg) - elif 'left_chat_member' in msg: - handle_left(msg) + handle_default(msg) + + # кнопки elif 'callback_query' in update: - handle_button(update['callback_query']) + data = update['callback_query']['data'] + if data.startswith(BUTTON_VOUCH): + handle_button(update['callback_query']) + elif data.startswith('unlink'): + handle_unlink(update['callback_query']) + + # заявки elif 'chat_join_request' in update: print('chat join request') - handle_join_request(update) + handle_join_request(update['chat_join_request']) + + # wtf + else: + print('UNHANDLED EVENT') + + except Exception: import traceback traceback.print_exc() diff --git a/requirements.txt b/requirements.txt index db1d882..9d3ddd8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ sanic==19.6.0 requests redis -cairosvg \ No newline at end of file +aiohttp \ No newline at end of file diff --git a/tgbot/api.py b/tgbot/api.py index 58aeb49..045757d 100644 --- a/tgbot/api.py +++ b/tgbot/api.py @@ -1,4 +1,5 @@ import requests +import aiohttp import json import os @@ -10,15 +11,16 @@ def register_webhook(url): r = requests.get( apiBase + f'setWebhook?url={url}' ) - return r + return r.json() def delete_message(cid: str, mid: str): url = apiBase + f"deleteMessage?chat_id={cid}&message_id={mid}" r = requests.post(url) - return r + return r.json() +# https://core.telegram.org/bots/api#sendmessage def send_message(cid: str, body, reply_to=None, reply_markup=None): url = apiBase + f"sendMessage?chat_id={cid}&text={body}" if reply_to: @@ -27,9 +29,10 @@ def send_message(cid: str, body, reply_to=None, reply_markup=None): reply_markup = json.dumps(reply_markup) reply_markup = requests.utils.quote(reply_markup) url += f'&reply_markup={reply_markup}' + url += f'&parse_mode=HTML' r = requests.post(url) print(f'{url}') - return r + return r.json() # https://core.telegram.org/bots/api#banchatmember @@ -38,27 +41,27 @@ def ban_member(chat_id, user_id, until_date=None): if until_date: url += f'&until_data={until_date}' r = requests.post(url) - return r + return r.json() # https://core.telegram.org/bots/api#unbanchatmember def unban_member(chat_id, user_id): url = apiBase + f"unbanChatMember?chat_id={chat_id}&user_id={user_id}&only_if_banned=1" r = requests.post(url) - return r + return r.json() # https://core.telegram.org/bots/api#addchatmember def add_member(chat_id, user_id): url = apiBase + f"addChatMember?chat_id={chat_id}&user_id={user_id}" r = requests.post(url) - return r + return r.json() def forward_message(cid, mid, to_chat_id): url = apiBase + f"forwardMessage?chat_id={to_chat_id}" + \ f"&from_chat_id={cid}&message_id={mid}" r = requests.post(url) - return r + return r.json() # https://core.telegram.org/bots/api#restrictchatmember @@ -66,9 +69,9 @@ def mute_member(chat_id, member_id): chat_permissions = json.dumps({ "can_send_messages": False }) chat_permissions = requests.utils.quote(chat_permissions) url = apiBase + f'restrictChatMember?chat_id={chat_id}' + \ - f'&user_id={user_id}&permissions={chat_permissions}' + f'&user_id={member_id}&permissions={chat_permissions}' r = requests.post(url) - return r + return r.json() # https://core.telegram.org/bots/api#restrictchatmember @@ -76,22 +79,69 @@ def unmute_member(chat_id, member_id): chat_permissions = json.dumps({ "can_send_messages": True }) chat_permissions = requests.utils.quote(chat_permissions) url = apiBase + f'restrictChatMember?chat_id={chat_id}' + \ - f'&user_id={user_id}&permissions={chat_permissions}' + f'&user_id={member_id}&permissions={chat_permissions}' r = requests.post(url) - return r + return r.json() + # https://core.telegram.org/bots/api#approvechatjoinrequest def approve_chat_join_request(chat_id, user_id): url = apiBase + f"approveChatJoinRequest?chat_id={chat_id}" + \ f'&user_id={user_id}' r = requests.post(url) - return r + return r.json() -def send_graph(png_data, chat_id): +# https://core.telegram.org/bots/api#senddocument +async def send_document(chat_id, data='', filename='chart.svg'): + url = apiBase + "sendDocument" + params = { "chat_id": chat_id } + files = { "document": data } + + async with aiohttp.ClientSession(headers=headers) as session: + async with session.post(url, params=params, data=filedata) as response: + if response.status != 200: + error_text = await response.text() + print(f"Error sending document: {response.status} - {error_text}") + return None + + try: + return await response.json() + except ValueError as e: + print(f"Error decoding JSON: {e}") + return None + + + +# https://core.telegram.org/bots/api#sendphoto +async def send_photo(png_data, chat_id): url = apiBase + f"sendPhoto" headers = {"Content-Type": "multipart/form-data"} files = {"photo": ("chart.png", png_data)} params = {"chat_id": chat_id} - response = requests.post(url, headers=headers, files=files, params=params) - return response.json() \ No newline at end of file + + async with aiohttp.ClientSession(headers=headers) as session: + async with session.post(url, params=params, data=filedata) as response: + if response.status != 200: + print(f"Error sending photo: {response.status}") + return None + + try: + return await response.json() + except ValueError as e: + print(f"Error decoding JSON: {e}") + return None + + +# https://core.telegram.org/bots/api#getchatadministrators +def get_chat_administrators(chat_id): + url = apiBase + f"getChatAdministrators?chat_id={chat_id}" + r = requests.get(url) + return r.json() + + +# # https://core.telegram.org/bots/api#getchatmember +def get_member(chat_id, member_id): + url = apiBase + f"getChatMember?chat_id={chat_id}&user_id={member_id}" + r = requests.get(url) + return r.json() \ No newline at end of file diff --git a/tgbot/graph.py b/tgbot/graph.py deleted file mode 100644 index bb165b4..0000000 --- a/tgbot/graph.py +++ /dev/null @@ -1,64 +0,0 @@ -import cairosvg - -def generate_chart(members): - # Размеры прямоугольника узла - node_width = 150 - node_height = 50 - - # Размеры холста - canvas_width = 800 - canvas_height = 600 - - # Радиус узла (для закругленных прямоугольников) - node_radius = 10 - - # Цвета - background_color = "#F2F2F2" - node_color = "#EFEFEF" - node_stroke_color = "#999" - node_text_color = "#333" - line_color = "#CCC" - - # Список строк SVG-кода - svg_lines = [] - - # Рассчитываем координаты для каждого узла - coordinates = {} - for member in members: - x = member['x'] - y = member['y'] - coordinates[member['id']] = {'x': x, 'y': y} - - # Рисуем линии-связи между узлами - for member in members: - member_id = member['id'] - x1 = coordinates[member_id]['x'] * node_width + node_width / 2 - y1 = coordinates[member_id]['y'] * node_height + node_height / 2 - for parent_id in member['parents']: - x2 = coordinates[parent_id]['x'] * node_width + node_width / 2 - y2 = coordinates[parent_id]['y'] * node_height + node_height / 2 - svg_lines.append(f'') - - # Рисуем узлы - for member in members: - member_id = member['id'] - x = coordinates[member_id]['x'] * node_width - y = coordinates[member_id]['y'] * node_height - - # Рисуем фоновый прямоугольник - svg_lines.append(f'') - - # Добавляем текст в центр узла - member_name = member['name'][:16] - text_x = x + node_width / 2 - text_y = y + node_height / 2 - svg_lines.append(f'{member_name}') - - # Создаем SVG-код - svg = f'' - for line in svg_lines: - svg += line - svg += '' - # конвертировать SVG в PNG - png_data = cairosvg.svg2png(bytestring=svg_data) - return png_data diff --git a/tgbot/handlers.py b/tgbot/handlers.py deleted file mode 100644 index a39f518..0000000 --- a/tgbot/handlers.py +++ /dev/null @@ -1,185 +0,0 @@ -from tgbot.api import send_message, forward_message, delete_message, \ - ban_member, unban_member, mute_member, unmute_member, \ - approve_chat_join_request, send_graph -from tgbot.graph import generate_chart -from tgbot.config import REDIS_URL, FEEDBACK_CHAT_ID, BUTTON_VOUCH, NEWCOMER_MSG -import json -import redis -from tgbot.profile import Profile as ProfileObj - - -# сохраняет сессии и пересылаемые сообщения между перезагрузками -storage = redis.from_url(REDIS_URL) - -# хранение необходимой информации о пользователях -Profile = ProfileObj(storage) - -def newcomer_show(msg): - reply_markup = { - "inline_keyboard": [ - [ - { - "text": BUTTON_VOUCH, - "callback_data": BUTTON_VOUCH + str(msg['from']['id']) - } - ] - ] - } - identity = f"{msg['from']['first_name']} {msg['from'].get('last_name', '')}" - if 'username' in msg['from']: - identity += f" @{msg['from']['username']}" - r = send_message( - msg['chat']['id'], - NEWCOMER_MSG + identity, - reply_to=msg['message_id'], - reply_markup=reply_markup - ) - welcome_msg_id = r.json()['result']['message_id'] - print(r.json()) - print(f'welcome message id: {welcome_msg_id}') - return welcome_msg_id - - -def handle_feedback(msg): - mid = msg['message_id'] - cid = msg['chat']['id'] - r = forward_message(cid, mid, FEEDBACK_CHAT_ID).json() - support_msg_id = r['result']['message_id'] - # сохранение айди сообщения в приватной переписке с ботом - storage.set(f'fbk-{support_msg_id}', json.dumps({ - "author_id": msg["from"]["id"], - "message_id": mid, - "chat_id": cid - })) - - -def handle_answer(msg): - print(f'handle answer from support') - support_msg_id = str(msg['reply_to_message']['message_id']) - # получение сохраненного айди сообщения из личной переписки с ботом - stored_feedback = storage.get(f'fbk-{support_msg_id}') - stored_feedback = json.loads(stored_feedback) - r = send_message(f'{stored_feedback["chat_id"]}', msg['text'], reply_to=stored_feedback["message_id"]) # notice 'u' before private chat ID - print(r.json()) - - -def handle_join(msg): - chat_id = str(msg['chat']['id']) - from_id = str(msg['from']['id']) - actor = Profile.get(from_id) - - actor["name"] = msg['from']['first_name'] + msg['from'].get('last_name', '') - actor["mention"] = msg['from'].get('username', '') - newcomer_id = str(msg['new_chat_member']['id']) - if from_id == newcomer_id: - if len(actor['parents']) == 0: - # показываем сообщение с кнопкой "поручиться" - welcome_msg_id = newcomer_show(msg) - - # до одобрения - мьют - r = mute_member(chat_id, newcomer_id) - print(r.json()) - - # обновляем профиль присоединившегося - actor['welcome_id'] = welcome_msg_id - Profile.save(actor) - else: - # за пользователя поручились ранее - r = delete_message(chat_id, actor['welcome_id']) - print(r.json()) - else: - # пользователи приглашены другим участником - print(f'{len(msg["new_chat_members"])} members were invited by {from_id}') - for m in msg['new_chat_members']: - newcomer = Profile.get(m['id']) - newcomer['parents'].append(from_id) - Profile.save(newcomer) - actor['children'].append(m['id']) - r = unmute_member(chat_id, newcomer['id']) - print(r.json()) - - # обновляем профиль пригласившего - Profile.save(actor) - - -def handle_left(msg): - print(f'handling member leaving') - member_id = msg["left_chat_member"]["id"] - - # профиль покидающего чат - leaver = Profile.get(member_id) - - # удаление сообщения с кнопкой - r = delete_message(msg['chat']['id'], leaver['welcome_id']) - print(r.json()) - - Profile.leaving(leaver) - - -def handle_button(callback_query): - # получаем профиль нажавшего кнопку - actor_id = str(callback_query['from']['id']) - actor = Profile.get(actor_id) - - callback_data = callback_query['data'] - if callback_data.startswith(BUTTON_VOUCH): - print(f'vouch button pressed by {actor_id}') - - newcomer_id = callback_data[len(BUTTON_VOUCH):] - newcomer = Profile.get(newcomer_id) - if newcomer_id not in actor['children'] and \ - actor_id not in newcomer['parents']: - newcomer['parents'].append(newcomer_id) - actor['children'].append(actor_id) - Profile.save(newcomer) - Profile.save(actor) - try: - chat_id = str(callback_query['message']['chat']['id']) - - print('unmute newcomer') - r = unmute_member(chat_id, newcomer_id) - print(r.json()) - - print('accept join request') - r = approve_chat_join_request(chat_id, newcomer_id) - print(r.json()) - - except: - pass - - -def handle_join_request(update): - print(f'handle join request') - chat_id = str(update['message']['chat']['id']) - from_id = str(update['message']['from']['id']) - actor = Profile.get(from_id) - - actor["name"] = update['message']['from']['first_name'] + update['message']['from'].get('last_name', '') - actor["mention"] = update['message']['from'].get('username', '') - - if from_id == str(update['message']['new_chat_member']['id']): - if len(actor['parents']) == 0: - # показываем сообщение с кнопкой "поручиться" - welcome_msg_id = show_request_msg(update) - - -def handle_graph(_msg): - cursor = 0 - keys = [] - while True: - # Scan for keys starting with 'urs-*' in batches of 100 - cursor, batch_keys = r.scan(cursor=cursor, match='urs-*', count=100) - keys += batch_keys - # If the cursor is 0, then we've reached the end of the keys - if cursor == 0: - break - # Get the values of all the keys - values = r.mget(keys) - # Parse the JSON data from each value - members = [] - for value in values: - member = json.loads(value) - members.append(member) - png_data = generate_chart(values) - r = send_graph(png_data, chat_id) - print(r.json()) diff --git a/tgbot/handlers/callback_unlink.py b/tgbot/handlers/callback_unlink.py new file mode 100644 index 0000000..cf87eb4 --- /dev/null +++ b/tgbot/handlers/callback_unlink.py @@ -0,0 +1,23 @@ +from tgbot.api import send_message, delete_message +from tgbot.storage import Profile + +# remove link of callback sender +# from member vouched before +def handle_unlink(callback_query): + print('handle unlink button pressed, private chat only') + + from_id = str(callback_query['from']['id']) + reply_msg_id = callback_query['message']['message_id'] + + actor = Profile.get(from_id, callback_query) + actor['parents'].remove(from_id) + + # удаляем старое сообщение с кнопками + r = delete_message(from_id, reply_msg_id) + print(r) + + # если ещё есть связи - посылаем новое сообщение + if len(actor['parents']) > 0: + body = construct_unlink_buttons(actor) + r = send_message(from_id, body) + print(r) \ No newline at end of file diff --git a/tgbot/handlers/callback_vouch.py b/tgbot/handlers/callback_vouch.py new file mode 100644 index 0000000..91feaa6 --- /dev/null +++ b/tgbot/handlers/callback_vouch.py @@ -0,0 +1,38 @@ +from tgbot.api import send_message, forward_message, delete_message +from tgbot.storage import Profile + + +def handle_button(callback_query): + # получаем профиль нажавшего кнопку + actor_id = str(callback_query['from']['id']) + actor = Profile.get(actor_id, callback_query) + + callback_data = callback_query['data'] + if callback_data.startswith(BUTTON_VOUCH): + print(f'vouch button pressed by {actor_id}') + + newcomer_id = callback_data[len(BUTTON_VOUCH):] + newcomer = Profile.get(newcomer_id) + if newcomer_id == actor_id: + # нажал сам, не реагируем, прописываем данные + newcomer = Profile.get(newcomer_id, callback_query) + elif newcomer_id not in actor['children'] and \ + actor_id not in newcomer['parents']: + newcomer['parents'].append(actor_id) + actor['children'].append(newcomer_id) + Profile.save(newcomer) + Profile.save(actor) + try: + chat_id = str(callback_query['message']['chat']['id']) + + print('unmute newcomer') + r = unmute_member(chat_id, newcomer_id) + print(r) + + print('accept join request') + r = approve_chat_join_request(chat_id, newcomer_id) + print(r) + + except: + pass + \ No newline at end of file diff --git a/tgbot/handlers/command_graph.py b/tgbot/handlers/command_graph.py new file mode 100644 index 0000000..d757cfb --- /dev/null +++ b/tgbot/handlers/command_graph.py @@ -0,0 +1,29 @@ +from tgbot.utils.graph import generate_chart +from tgbot.api import send_document +from tgbot.storage import storage +import json + + +async def handle_command_graph(msg): + cursor = 0 + keys = [] + r = storage + while True: + # Scan for keys starting with 'usr-*' in batches of 100 + cursor, batch_keys = r.scan(cursor=cursor, match='usr-*', count=100) + keys += batch_keys + # If the cursor is 0, then we've reached the end of the keys + if cursor == 0: + break + # Get the values of all the keys + values = r.mget(keys) + # Parse the JSON data from each value + members = [] + for value in values: + value_str = value.decode('utf-8') + member = json.loads(value_str) + members.append(member) + print(f'found {len(members)} members') + data = generate_chart(members) + r = await send_document(msg['chat']['id'], data, 'chart.svg') + print(r) diff --git a/tgbot/handlers/command_my.py b/tgbot/handlers/command_my.py new file mode 100644 index 0000000..25fe850 --- /dev/null +++ b/tgbot/handlers/command_my.py @@ -0,0 +1,40 @@ +from tgbot.storage import Profile +from tgbot.api import get_member + +def construct_unlink_buttons(actor): + buttons = [] + for vouch in actor['children']: + vouch_added = False + for chat_id in actor['chats']: + if not vouch_added: + r = get_member(chat_id, vouch) + member = r.get('result') + if member: + try: + buttons.append({ + 'text': mention(r['result']), + 'callback_data': 'unlink' + vouch + }) + vouch_added = True + except: + print('member result error') + print(member) + else: + print(r) + return { "inline_keyboard": [ buttons, ] } + +def handle_command_my(msg): + print(f'handle my command') + from_id = str(msg['from']['id']) + sender = Profile.get(from_id, msg) + + # генерируем кнопки для всех, за кого поручились + reply_markup = construct_unlink_buttons(sender) + + if msg['from'].get('language_code', 'ru') == 'ru': + body = 'Нажмите кнопки ниже, чтобы удалить ваши связи' + else: + body = 'Unlink your connections pressing the buttons below' + + r = send_message(from_id, body, reply_markup=reply_markup) + print(r) \ No newline at end of file diff --git a/tgbot/handlers/handle_default.py b/tgbot/handlers/handle_default.py new file mode 100644 index 0000000..9dca22b --- /dev/null +++ b/tgbot/handlers/handle_default.py @@ -0,0 +1,47 @@ +from tgbot.api import send_message, delete_message, get_chat_administrators +from tgbot.storage import Profile +from tgbot.handlers.send_button import show_request_msg + + +def handle_default(msg): + print(f'default handler for all messages') + chat_id = str(msg['chat']['id']) + from_id = str(msg['from']['id']) + sender = Profile.get(from_id, msg) + + if msg['text'].startswith('/my'): + # команда в групповом чате + print(f'remove some messages in group chat') + + # удалить сообщение с командой /my + r = delete_message(chat_id, msg['message_id']) + print(r) + + # удалить предыдушее сообщение с кнопкой в этом чате + if sender['request_msg_id'].startswith(chat_id): + chat_id, rmid = sender['request_msg_id'].split(':') + r = delete_message(chat_id, rmid) + print(r) + + # показать новое сообщение с кнопкой + request_msg_id = show_request_msg(msg) + sender['request_msg_id'] = f'{chat_id}:{request_msg_id}' + else: + # любое другое сообщение + if len(sender['parents']) == 0: + # владелец чата автоматически ручается + print(f'setting owner as parent for {from_id}') + r = get_chat_administrators(chat_id) + print(r) + + owner_id = r['result'][0]['id'] # DEBUG!! + + sender['parents'].append(owner_id) + + # обновляем профиль владельца + owner = Profile.get(owner_id) + owner['children'].append(from_id) + Profile.save(owner) + + # сохранить профиль отправителя + Profile.save(sender) \ No newline at end of file diff --git a/tgbot/handlers/handle_feedback.py b/tgbot/handlers/handle_feedback.py new file mode 100644 index 0000000..3b8a79e --- /dev/null +++ b/tgbot/handlers/handle_feedback.py @@ -0,0 +1,29 @@ +import json + +from tgbot.api import send_message, forward_message, delete_message +from tgbot.storage import storage, Profile +from tgbot.config import FEEDBACK_CHAT_ID + + +def handle_feedback(msg): + mid = msg['message_id'] + cid = msg['chat']['id'] + r = forward_message(cid, mid, FEEDBACK_CHAT_ID) + support_msg_id = r['result']['message_id'] + # сохранение айди сообщения в приватной переписке с ботом + storage.set(f'fbk-{support_msg_id}', json.dumps({ + "author_id": msg["from"]["id"], + "message_id": mid, + "chat_id": cid + })) + + +def handle_answer(msg): + print(f'handle answer from support') + support_msg_id = str(msg['reply_to_message']['message_id']) + # получение сохраненного айди сообщения из личной переписки с ботом + stored_feedback = storage.get(f'fbk-{support_msg_id}') + stored_feedback = json.loads(stored_feedback) + r = send_message(f'{stored_feedback["chat_id"]}', msg['text'], reply_to=stored_feedback["message_id"]) + print(r) + diff --git a/tgbot/handlers/handle_join_request.py b/tgbot/handlers/handle_join_request.py new file mode 100644 index 0000000..ca60650 --- /dev/null +++ b/tgbot/handlers/handle_join_request.py @@ -0,0 +1,26 @@ +from tgbot.api import approve_chat_join_request, delete_message +from tgbot.handlers.send_button import show_request_msg +from tgbot.storage import Profile + + +def handle_join_request(msg): + print(f'handle join request {msg}') + chat_id = str(msg['chat']['id']) + from_id = str(msg['from']['id']) + actor = Profile.get(from_id, msg) + + if len(actor['parents']) == 0: + # показываем сообщение с кнопкой "поручиться" + request_msg_id = show_request_msg(msg) + # удаляем предыдущее сообщение с кнопкой в этом чате + if actor['request_msg_id'].startswith(chat_id): + chat_id, rmid = actor['request_msg_id'].split(':') + r = delete_message(chat_id, rmid) + print(r) + actor['request_msg_id'] = f'{chat_id}:{request_msg_id}' + else: + # за пользователя поручились ранее + r = approve_chat_join_request(chat_id, from_id) + print(r) + Profile.save(actor) + diff --git a/tgbot/handlers/handle_members_change.py b/tgbot/handlers/handle_members_change.py new file mode 100644 index 0000000..85f0412 --- /dev/null +++ b/tgbot/handlers/handle_members_change.py @@ -0,0 +1,57 @@ +from tgbot.handlers.send_button import show_request_msg +from tgbot.api import unmute_member, mute_member, delete_message +from tgbot.storage import Profile + +def handle_join(msg): + chat_id = str(msg['chat']['id']) + from_id = str(msg['from']['id']) + + actor = Profile.get(from_id, msg) + + newcomer_id = str(msg['new_chat_member']['id']) + if from_id == newcomer_id: + if len(actor['parents']) == 0: + # показываем сообщение с кнопкой "поручиться" + request_msg_id = show_request_msg(msg) + + # до одобрения - мьют + r = mute_member(chat_id, newcomer_id) + print(r) + + # обновляем профиль присоединившегося + actor['request_msg_id'] = f'{chat_id}:{request_msg_id}' + Profile.save(actor) + else: + # за пользователя поручились ранее + pass + else: + # пользователи приглашены другим участником + print(f'{len(msg["new_chat_members"])} members were invited by {from_id}') + for m in msg['new_chat_members']: + newcomer = Profile.get(m['id']) + newcomer['parents'].append(from_id) + Profile.save(newcomer) + actor['children'].append(m['id']) + r = unmute_member(chat_id, newcomer['id']) + print(r) + + # обновляем профиль пригласившего + Profile.save(actor) + + +def handle_left(msg): + print(f'handling member leaving') + member_id = msg["left_chat_member"]["id"] + chat_id = msg['chat']['id'] + + # профиль покидающего чат + leaver = Profile.get(member_id) + + # удаление сообщения с кнопкой в этом чате + if leaver['request_msg_id'].startswith(chat_id): + chat_id, rmid = leaver['request_msg_id'].split(':') + r = delete_message(chat_id, rmid) + print(r) + + Profile.leaving(leaver) + diff --git a/tgbot/handlers/send_button.py b/tgbot/handlers/send_button.py new file mode 100644 index 0000000..a6a20ac --- /dev/null +++ b/tgbot/handlers/send_button.py @@ -0,0 +1,27 @@ +from tgbot.api import send_message +from tgbot.config import BUTTON_VOUCH, NEWCOMER_MSG +from tgbot.utils import mention + +def show_request_msg(msg): + reply_markup = { + "inline_keyboard": [ + [ + { + "text": BUTTON_VOUCH, + "callback_data": BUTTON_VOUCH + str(msg['from']['id']) + } + ] + ] + } + + r = send_message( + msg['chat']['id'], + NEWCOMER_MSG + mention(msg['from']), + reply_to=msg.get('message_id', ''), + reply_markup=reply_markup + ) + request_msg_id = r['result']['message_id'] + chat_id = msg['chat']['id'] + print(r) + print(f'request message id: {chat_id}:{request_msg_id}') + return request_msg_id diff --git a/tgbot/profile.py b/tgbot/profile.py deleted file mode 100644 index 0339bf8..0000000 --- a/tgbot/profile.py +++ /dev/null @@ -1,33 +0,0 @@ -import json - - -class Profile: - - def __init__(self, storage): - self.storage = storage - - def create(self, member_id): - s = { - "id": member_id, - "name": "newcomer", - "mention": "", - "welcome_id": 0, - "parents": [], - "children": [] - } - self.storage.set(f'usr-{member_id}', json.dumps(s)) - return s - - def save(self, s): - self.storage.set(f'usr-{s["id"]}', json.dumps(s)) - - def get(self, member_id): - data = self.storage.get(f'usr-{member_id}') - if data is None: - return self.create(member_id) - else: - return json.loads(data) - - def leaving(self, s): - if len(s['parents']) == 0: - self.storage.delete(f'usr-{s["id"]}') diff --git a/tgbot/storage/__init__.py b/tgbot/storage/__init__.py new file mode 100644 index 0000000..9aba69f --- /dev/null +++ b/tgbot/storage/__init__.py @@ -0,0 +1,9 @@ +import redis +from tgbot.storage.profile import Profile as ProfileObj +from tgbot.config import REDIS_URL + +# сохраняет сессии и пересылаемые сообщения между перезагрузками +storage = redis.from_url(REDIS_URL) + +# хранение необходимой информации о пользователях +Profile = ProfileObj(storage) diff --git a/tgbot/storage/profile.py b/tgbot/storage/profile.py new file mode 100644 index 0000000..5b0fc77 --- /dev/null +++ b/tgbot/storage/profile.py @@ -0,0 +1,44 @@ +import json + + +class Profile: + + def __init__(self, storage): + self.storage = storage + + def create(self, member_id, msg=None): + s = { + "id": member_id, + "request_msg_id": 0, + "parents": [], + "children": [], + "chats": [] + } + + if msg.get('from'): + sender = msg.get('from') + s["mention"] = sender.get('username') + s["name"] = f"{sender['first_name']} {sender.get('last_name', '')}".strip() + + if msg.get('chat'): + chat_id = str(msg['chat']['id']) + if chat_id not in s['chats']: + s["chats"].append(chat_id) + + self.storage.set(f'usr-{member_id}', json.dumps(s)) + return s + + def save(self, s): + self.storage.set(f'usr-{s["id"]}', json.dumps(s)) + + def get(self, member_id, msg=None): + data = self.storage.get(f'usr-{member_id}') + if data is None: + r = self.create(member_id, msg) + else: + r = json.loads(data) + return r + + def leaving(self, s): + if len(s['parents']) == 0: + self.storage.delete(f'usr-{s["id"]}') diff --git a/tgbot/utils/graph.py b/tgbot/utils/graph.py new file mode 100644 index 0000000..866bd67 --- /dev/null +++ b/tgbot/utils/graph.py @@ -0,0 +1,67 @@ +# Define SVG code generation function with member_id parameter +def generate_chart(members, member_id = None): + if not member_id: + member_id = members[0]['id'] + # Define some constants for layout + node_radius = 30 + node_spacing = 80 + node_y = 100 + parent_y_offset = 50 + child_y_offset = 150 + + # Find the specified member and its descendants + member = None + descendants = set() + for m in members: + if m["id"] == member_id: + member = m + descendants.add(member_id) + break + + stack = member["children"].copy() + while stack: + child_id = stack.pop() + descendants.add(child_id) + for m in members: + if m["id"] == child_id: + stack.extend(m["children"]) + break + + # Define the x position for each member + x_positions = {} + x_positions[member_id] = 0 + for i, m in enumerate(members): + if m["id"] in descendants: + x_positions[m["id"]] = (i * node_spacing) + node_radius + + # Start building the SVG code + svg_width = (len(descendants) * node_spacing) + (2 * node_radius) + svg_height = 200 + svg_code = f'' + + # Generate nodes and names for each descendant + for m in members: + if m["id"] in descendants: + node_x = x_positions[m["id"]] + node_code = f'' + name_code = f'{m["name"]}' + svg_code += node_code + name_code + + # Generate links to parent nodes + for parent_id in m["parents"]: + if parent_id in descendants: + parent_x = x_positions[parent_id] + link_code = f'' + svg_code += link_code + + # Generate links to child nodes + for child_id in m["children"]: + if child_id in descendants: + child_x = x_positions[child_id] + link_code = f'' + svg_code += link_code + + # Finish the SVG code + svg_code += '' + + return svg_code.encode('utf-8') \ No newline at end of file diff --git a/tgbot/utils/mention.py b/tgbot/utils/mention.py new file mode 100644 index 0000000..4e85c1e --- /dev/null +++ b/tgbot/utils/mention.py @@ -0,0 +1,9 @@ +# generates a mention from standard telegram web json 'from' field +# using HTML markup +def mention(user): + identity = f"{user['first_name']} {user.get('last_name', '')}".strip() + uid = user['id'] + username = user.get('username', '') + if username: + username = f'(@{username})' + return f'{identity}{username}'