commit f93ae32896df24881306e0ce7559392bae60e55d Author: kawaiiDango <1066519+kawaiiDango@users.noreply.github.com> Date: Mon Oct 4 23:09:26 2021 +0530 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0abc7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +db/ +config.py \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4cc255 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +Saves deleted and edited messages to another (private) chat. +Saves deleted media by pickling the media metadata to the db. + +Rename `config.py.example` to `config.py` and fill in the stuff. +The chat ids there, are bot api style ids. + +- Logs a limited amount of messages at a time to reduce chances of getting a FloodWaitError. + You probably don't want to see that spam anyways. +- Telegram doesn’t always notify the clients that a message was deleted, so it will miss some + [telethon docs](C:\Users\arn\Dropbox\stuffs\pyweeb\telethon\tg-delete-logger) +- Telegram may not have the media after it was deleted diff --git a/config.py.example b/config.py.example new file mode 100644 index 0000000..de637cf --- /dev/null +++ b/config.py.example @@ -0,0 +1,22 @@ +API_ID = 0 +API_HASH = '0' + +DEBUG_MODE = False + +LISTEN_OUTGOING_MESSAGES = True +SAVE_EDITED_MESSAGES = False +DELETE_SENT_GIFS_FROM_SAVED = True +DELETE_SENT_STICKERS_FROM_SAVED = True + +PERSIST_TIME_IN_DAYS_BOT = 1 +PERSIST_TIME_IN_DAYS_USER = 1 +PERSIST_TIME_IN_DAYS_CHANNEl = 1 +PERSIST_TIME_IN_DAYS_GROUP = 1 + +RATE_LIMIT_NUM_MESSAGES = 5 + +LOG_CHAT_ID = -10000 + +IGNORED_IDS = { + -10000 +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce3f09a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +telethon +cryptg \ No newline at end of file diff --git a/tg_delete_logger.py b/tg_delete_logger.py new file mode 100644 index 0000000..72de9b4 --- /dev/null +++ b/tg_delete_logger.py @@ -0,0 +1,331 @@ +import logging +import pickle +import sqlite3 +import asyncio +from datetime import datetime, timedelta +from typing import List, Union +import os +from asyncio.locks import Event + +from telethon import events + +import config +from telethon.events import NewMessage, MessageDeleted, MessageEdited +from telethon import TelegramClient +from telethon.hints import Entity +from telethon.tl.functions.messages import SaveGifRequest, SaveRecentStickerRequest +from telethon.tl.types import ( + Message, + PeerUser, + PeerChat, + PeerChannel, + DocumentAttributeSticker, + DocumentAttributeVideo, + MessageMediaDice, + MessageMediaWebPage, + MessageMediaGame, + Document, + InputDocument, + DocumentAttributeAnimated +) + +TYPE_USER = 1 +TYPE_CHANNEL = 2 +TYPE_GROUP = 3 +TYPE_BOT = 4 +TYPE_UNKNOWN = 0 + + +def init_db(): + if not os.path.exists('db'): + os.mkdir('db') + + connection = sqlite3.connect("db/messages.db") + cursor = connection.cursor() + cursor.execute("""CREATE TABLE IF NOT EXISTS messages + (id INTEGER, from_id INTEGER, chat_id INTEGER, + type INTEGER, msg_text TEXT, media BLOB, created_time TIMESTAMP, edited_time TIMESTAMP, + PRIMARY KEY (chat_id, id, edited_time))""") + + cursor.execute( + "CREATE INDEX IF NOT EXISTS messages_created_index ON messages (created_time DESC)") + + connection.commit() + + return cursor, connection + + +async def get_chat_type(event: Event) -> int: + chat_type = TYPE_UNKNOWN + if event.is_group: # chats and megagroups + chat_type = TYPE_GROUP + elif event.is_channel: # megagroups and channels + chat_type = TYPE_CHANNEL + elif event.is_private: + if (await event.get_sender()).bot: + chat_type = TYPE_BOT + else: + chat_type = TYPE_USER + return chat_type + + +async def new_message_handler(event: Union[NewMessage.Event, MessageEdited.Event]): + from_id = 0 + chat_id = event.chat_id + if isinstance(event.message.peer_id, PeerUser): + from_id = my_id if event.message.out else event.message.peer_id.user_id + chat_id = event.message.peer_id.user_id + elif isinstance(event.message.peer_id, PeerChannel): + if isinstance(event.message.from_id, PeerUser): + from_id = event.message.from_id.user_id + elif isinstance(event.message.peer_id, PeerChat): + if isinstance(event.message.from_id, PeerUser): + from_id = event.message.from_id.user_id + + if from_id in config.IGNORED_IDS or chat_id in config.IGNORED_IDS: + return + + edited_time = 0 + + async with asyncio.Lock(): + if isinstance(event, MessageEdited.Event): + edited_time = datetime.now() # event.message.edit_date + await edited_deleted_handler(event) + + sqlite_cursor.execute( + "INSERT INTO messages (id, from_id, chat_id, edited_time, type, msg_text, media, created_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + event.message.id, + from_id, + chat_id, + edited_time, + await get_chat_type(event), + event.message.message, + sqlite3.Binary(pickle.dumps(event.message.media)), + datetime.now())) + sqlite_connection.commit() + + +def load_messages_from_event(event: Union[MessageDeleted.Event, MessageEdited.Event]) -> List[Message]: + if isinstance(event, MessageDeleted.Event): + ids = event.deleted_ids[:config.RATE_LIMIT_NUM_MESSAGES] + elif isinstance(event, MessageEdited.Event): + ids = [event.message.id] + + sql_message_ids = ",".join(str(deleted_id) for deleted_id in ids) + if event.chat_id: + where_clause = f"WHERE chat_id = {event.chat_id} and id IN ({sql_message_ids})" + else: + where_clause = f"WHERE chat_id not like \"-100%\" and id IN ({sql_message_ids})" + query = f"""SELECT * FROM (SELECT id, from_id, chat_id, msg_text, media, + created_time FROM messages {where_clause} ORDER BY edited_time DESC) + GROUP BY chat_id, id ORDER BY created_time ASC""" + + db_results = sqlite_cursor.execute(query).fetchall() + + messages = [] + for db_result in db_results: + messages.append({ + "id": db_result[0], + "from_id": db_result[1], + "chat_id": db_result[2], + "msg_text": db_result[3], + "media": pickle.loads(db_result[4]) + }) + + return messages + + +async def create_mention(user: Entity): + if user.first_name or user.last_name: + mention = \ + (user.first_name + " " if user.first_name else "") + \ + (user.last_name if user.last_name else "") + elif user.username: + mention = user.username + elif user.phone: + mention = user.phone + else: + mention = user.id + + return mention + + +async def edited_deleted_handler(event: Union[MessageDeleted.Event, MessageEdited.Event]): + if isinstance(event, MessageEdited.Event) and not config.SAVE_EDITED_MESSAGES: + return + + messages = load_messages_from_event(event) + + log_deleted_usernames = [] + + for message in messages: + if message['from_id'] in config.IGNORED_IDS or message['chat_id'] in config.IGNORED_IDS: + return + + try: + user = await client.get_entity(message['from_id']) + mention_user = await create_mention(user) + except: + user = None + mention_user = "Unknown" + + log_deleted_usernames.append(mention_user) + + try: + chat = await client.get_entity(message['chat_id']) + try: + mention_chat = chat.title + is_pm = False + except AttributeError: + mention_chat = await create_mention(chat) + is_pm = True + except Exception as e: + mention_chat = "Unknown chat" + chat_id = str(message['chat_id']).replace("-100", "") + + if isinstance(event, MessageDeleted.Event): + if user: + text = f"**Deleted message from: **[{mention_user}](tg://user?id={user.id})\n" + else: + text = f"**Deleted message from: **{mention_user}\n" + + text += f"in [{mention_chat}](t.me/c/{chat_id}/{message['id']})" + if is_pm: + text += " #pm" + text += '\n' + if message['msg_text']: + text += "**Message:** \n" + message['msg_text'] + elif isinstance(event, MessageEdited.Event): + if user: + text = f"**✏Edited message from: **[{mention_user}](tg://user?id={user.id})\n" + else: + text = f"**✏Edited message from: **{mention_user}\n" + + text += f"in [{mention_chat}](t.me/c/{chat_id}/{message['id']})\n" + + if message['msg_text']: + text += f"**Original message:**\n{message['msg_text']}\n\n" + if event.message.text: + text += f"**Edited message:**\n{event.message.text}" + + is_sticker = hasattr(message['media'], "document") and \ + message['media'].document.attributes and \ + any(isinstance(attr, DocumentAttributeSticker) + for attr in message['media'].document.attributes) + is_gif = hasattr(message['media'], "document") and \ + message['media'].document.attributes and \ + any(isinstance(attr, DocumentAttributeAnimated) + for attr in message['media'].document.attributes) + is_round_video = hasattr(message['media'], "document") and \ + message['media'].document.attributes and \ + any((isinstance(attr, DocumentAttributeVideo) and attr.round_message == True) + for attr in message['media'].document.attributes) + is_dice = isinstance(message['media'], MessageMediaDice) + is_instant_view = isinstance(message['media'], MessageMediaWebPage) + is_game = isinstance(message['media'], MessageMediaGame) + + if is_sticker or is_round_video or is_dice or is_game: + sent_msg = await client.send_message(config.LOG_CHAT_ID, file=message['media']) + await sent_msg.reply(text) + elif is_instant_view: + await client.send_message(config.LOG_CHAT_ID, text) + else: + await client.send_message(config.LOG_CHAT_ID, text, file=message['media']) + + if is_gif and config.DELETE_SENT_GIFS_FROM_SAVED: + await delete_from_saved_gifs(message['media'].document) + + if is_sticker and config.DELETE_SENT_STICKERS_FROM_SAVED: + await delete_from_saved_stickers(message['media'].document) + + if isinstance(event, MessageDeleted.Event): + if len(event.deleted_ids) > config.RATE_LIMIT_NUM_MESSAGES and len(log_deleted_usernames): + await client.send_message(config.LOG_CHAT_ID, f"{len(event.deleted_ids)} messages deleted. Logged {config.RATE_LIMIT_NUM_MESSAGES}.") + logging.info( + f"Got {len(event.deleted_ids)} deleted messages. DB has {len(messages)}. Users: {', '.join(log_deleted_usernames)}" + ) + elif isinstance(event, MessageEdited.Event): + logging.info( + f"Got 1 edited message. DB has {len(messages)}. Users: {', '.join(log_deleted_usernames)}" + ) + + +async def delete_from_saved_gifs(gif: Document): + await client(SaveGifRequest( + id=InputDocument( + id=gif.id, + access_hash=gif.access_hash, + file_reference=gif.file_reference + ), + unsave=True + )) + + +async def delete_from_saved_stickers(sticker: Document): + await client(SaveRecentStickerRequest( + id=InputDocument( + id=sticker.id, + access_hash=sticker.access_hash, + file_reference=sticker.file_reference + ), + unsave=True + )) + + +async def delete_expired_messages(): + while True: + now = datetime.now() + time_user = now - timedelta(days=config.PERSIST_TIME_IN_DAYS_USER) + time_channel = now - \ + timedelta(days=config.PERSIST_TIME_IN_DAYS_CHANNEl) + time_group = now - timedelta(days=config.PERSIST_TIME_IN_DAYS_GROUP) + time_bot = now - timedelta(days=config.PERSIST_TIME_IN_DAYS_BOT) + time_unknown = now - timedelta(days=config.PERSIST_TIME_IN_DAYS_GROUP) + + sqlite_cursor.execute( + """DELETE FROM messages WHERE (type = ? and created_time < ?) OR + (type = ? and created_time < ?) OR + (type = ? and created_time < ?) OR + (type = ? and created_time < ?) OR + (type = ? and created_time < ?)""", + (TYPE_USER, time_user, + TYPE_CHANNEL, time_channel, + TYPE_GROUP, time_group, + TYPE_BOT, time_bot, + TYPE_UNKNOWN, time_unknown, + )) + + logging.info( + f"Deleted {sqlite_cursor.rowcount} expired messages from DB" + ) + + await asyncio.sleep(300) + + +async def init(clientp): + global client, my_id + + client = clientp + + if config.DEBUG_MODE: + logging.basicConfig(level="INFO") + else: + logging.basicConfig(level="WARNING") + + config.IGNORED_IDS.add(config.LOG_CHAT_ID) + + my_id = (await client.get_me()).id + + client.add_event_handler(new_message_handler, events.NewMessage( + incoming=True, outgoing=config.LISTEN_OUTGOING_MESSAGES)) + client.add_event_handler(new_message_handler, events.MessageEdited()) + client.add_event_handler(edited_deleted_handler, events.MessageDeleted()) + + await delete_expired_messages() + +if __name__ == "__main__": + sqlite_cursor, sqlite_connection = init_db() + + with TelegramClient('db/user', config.API_ID, config.API_HASH) as client: + client.loop.run_until_complete(init(client))