From 904a45eaa5bcafb083c417bb14fc9caa7914a909 Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Sun, 15 Oct 2023 06:53:48 -0300 Subject: [PATCH 01/11] Added a CatchAll Message Finder Signed-off-by: Guilherme Bacellar Moralez --- TEx/finder/all_messages_finder.py | 16 ++++++++ TEx/finder/finder_engine.py | 7 ++++ TEx/notifier/elastic_search_notifier.py | 50 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 TEx/finder/all_messages_finder.py create mode 100644 TEx/notifier/elastic_search_notifier.py diff --git a/TEx/finder/all_messages_finder.py b/TEx/finder/all_messages_finder.py new file mode 100644 index 0000000..e96fd19 --- /dev/null +++ b/TEx/finder/all_messages_finder.py @@ -0,0 +1,16 @@ +"""All Messages Finder.""" +from configparser import SectionProxy + +from TEx.finder.base_finder import BaseFinder + + +class AllMessagesFinder(BaseFinder): + """All Messages Based Finder.""" + + def __init__(self, config: SectionProxy) -> None: + """Initialize All Messages Finder.""" + pass + + async def find(self, raw_text: str) -> bool: + """Find Message. Always Return True.""" + return True diff --git a/TEx/finder/finder_engine.py b/TEx/finder/finder_engine.py index d037115..199a49a 100644 --- a/TEx/finder/finder_engine.py +++ b/TEx/finder/finder_engine.py @@ -4,6 +4,7 @@ from telethon.events import NewMessage +from TEx.finder.all_messages_finder import AllMessagesFinder from TEx.finder.regex_finder import RegexFinder from TEx.notifier.notifier_engine import NotifierEngine @@ -34,6 +35,12 @@ def __load_rules(self, config: ConfigParser) -> None: 'instance': RegexFinder(config=config[sec]), 'notifier': config[sec]['notifier'] }) + elif config[sec]['type'] == 'all': + self.rules.append({ + 'id': sec, + 'instance': AllMessagesFinder(config=config[sec]), + 'notifier': config[sec]['notifier'] + }) def configure(self, config: ConfigParser) -> None: """Configure Finder.""" diff --git a/TEx/notifier/elastic_search_notifier.py b/TEx/notifier/elastic_search_notifier.py new file mode 100644 index 0000000..31699a7 --- /dev/null +++ b/TEx/notifier/elastic_search_notifier.py @@ -0,0 +1,50 @@ +"""Discord Notifier.""" +from configparser import SectionProxy + +from discord_webhook import DiscordEmbed, DiscordWebhook +from telethon.events import NewMessage + +from TEx.notifier.notifier_base import BaseNotifier + + +class DiscordNotifier(BaseNotifier): + """Basic Discord Notifier.""" + + def __init__(self) -> None: + """Initialize Discord Notifier.""" + super().__init__() + self.url: str = '' + + def configure(self, url: str, config: SectionProxy) -> None: + """Configure the Notifier.""" + self.url = url + self.configure_base(config=config) + + async def run(self, message: NewMessage.Event, rule_id: str) -> None: + """Run Discord Notifier.""" + # Check and Update Deduplication Control + is_duplicated, duplication_tag = self.check_is_duplicated(message=message.raw_text) + if is_duplicated: + return + + # Run the Notification Process. + webhook = DiscordWebhook( + url=self.url, + rate_limit_retry=True + ) + + embed = DiscordEmbed( + title=f'**{message.chat.title}** ({message.chat.id})', + description=message.raw_text + ) + + embed.add_embed_field(name="Rule", value=rule_id, inline=False) + embed.add_embed_field(name="Message ID", value=str(message.id), inline=False) + embed.add_embed_field(name="Group Name", value=message.chat.title, inline=True) + embed.add_embed_field(name="Group ID", value=message.chat.id, inline=True) + embed.add_embed_field(name="Message Date", value=str(message.date), inline=False) + embed.add_embed_field(name="Tag", value=duplication_tag, inline=False) + + # add embed object to webhook + webhook.add_embed(embed) + webhook.execute() From 9bbc0f7b40fca85558f998b8f4fefa5b29ded40f Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Sun, 15 Oct 2023 06:54:19 -0300 Subject: [PATCH 02/11] Added ElasticSearch Notifier Signed-off-by: Guilherme Bacellar Moralez --- TEx/notifier/elastic_search_notifier.py | 86 +++++++++++++++---------- TEx/notifier/notifier_engine.py | 9 +++ 2 files changed, 60 insertions(+), 35 deletions(-) diff --git a/TEx/notifier/elastic_search_notifier.py b/TEx/notifier/elastic_search_notifier.py index 31699a7..4229eea 100644 --- a/TEx/notifier/elastic_search_notifier.py +++ b/TEx/notifier/elastic_search_notifier.py @@ -1,50 +1,66 @@ -"""Discord Notifier.""" +"""Elastic Search Notifier.""" from configparser import SectionProxy +from typing import Dict, Optional -from discord_webhook import DiscordEmbed, DiscordWebhook +import pytz +from elasticsearch import AsyncElasticsearch from telethon.events import NewMessage +from telethon.tl.types import PeerUser from TEx.notifier.notifier_base import BaseNotifier -class DiscordNotifier(BaseNotifier): - """Basic Discord Notifier.""" +class ElasticSearchNotifier(BaseNotifier): + """Basic Elastic Search Notifier.""" def __init__(self) -> None: - """Initialize Discord Notifier.""" + """Initialize Elastic Search Notifier.""" super().__init__() self.url: str = '' + self.client: AsyncElasticsearch = None + self.index: str = '' + self.pipeline: str = '' - def configure(self, url: str, config: SectionProxy) -> None: + def configure(self, config: SectionProxy) -> None: """Configure the Notifier.""" - self.url = url - self.configure_base(config=config) + hosts_list: Optional[str] = config.get('address', fallback=None) + + self.client = AsyncElasticsearch( + hosts=hosts_list.split(',') if hosts_list else None, + api_key=config.get('api_key', fallback=None), + verify_certs=config.get('verify_ssl_cert', fallback='True') == 'True', + cloud_id=config.get('cloud_id', fallback=None) + ) + self.index = config['index_name'] + self.pipeline = config['pipeline_name'] async def run(self, message: NewMessage.Event, rule_id: str) -> None: - """Run Discord Notifier.""" - # Check and Update Deduplication Control - is_duplicated, duplication_tag = self.check_is_duplicated(message=message.raw_text) - if is_duplicated: - return - - # Run the Notification Process. - webhook = DiscordWebhook( - url=self.url, - rate_limit_retry=True - ) - - embed = DiscordEmbed( - title=f'**{message.chat.title}** ({message.chat.id})', - description=message.raw_text - ) - - embed.add_embed_field(name="Rule", value=rule_id, inline=False) - embed.add_embed_field(name="Message ID", value=str(message.id), inline=False) - embed.add_embed_field(name="Group Name", value=message.chat.title, inline=True) - embed.add_embed_field(name="Group ID", value=message.chat.id, inline=True) - embed.add_embed_field(name="Message Date", value=str(message.date), inline=False) - embed.add_embed_field(name="Tag", value=duplication_tag, inline=False) - - # add embed object to webhook - webhook.add_embed(embed) - webhook.execute() + """Run Elastic Search Notifier.""" + content: Dict = { + 'time': message.date.astimezone(tz=pytz.utc), + 'rule': rule_id, + 'raw': message.raw_text, + 'group_name': message.chat.title, + 'group_id': message.chat.id, + 'from_id': message.from_id.user_id if isinstance(message.from_id, PeerUser) else '', + 'to_id': message.to_id.channel_id if message.to_id is not None else None, + 'reply_to_msg_id': message.reply_to.reply_to_msg_id if message.is_reply else None, + 'message_id': message.id, + 'is_reply': message.is_reply, + } + + if hasattr(message, 'file') and message.file: + content['has_media'] = True + content['media_mime_type'] = message.file.mime_type if hasattr(message.file, 'mime_type') else None + content['media_size'] = message.file.size if hasattr(message.file, 'size') else None + else: + content['has_media'] = False + content['media_mime_type'] = None + content['media_size'] = None + + await self.client.index( + index=self.index, + pipeline=self.pipeline, + id=f'{message.chat.id}_{message.id}', + document=content + ) \ No newline at end of file diff --git a/TEx/notifier/notifier_engine.py b/TEx/notifier/notifier_engine.py index 8abf79d..c63fe77 100644 --- a/TEx/notifier/notifier_engine.py +++ b/TEx/notifier/notifier_engine.py @@ -5,6 +5,7 @@ from telethon.events import NewMessage from TEx.notifier.discord_notifier import DiscordNotifier +from TEx.notifier.elastic_search_notifier import ElasticSearchNotifier from TEx.notifier.notifier_base import BaseNotifier @@ -29,6 +30,14 @@ def __load_notifiers(self, config: ConfigParser) -> None: register: {'instance': notifier} }) + if 'ELASTIC_SEARCH' in register: + notifier_es: ElasticSearchNotifier = ElasticSearchNotifier() + notifier_es.configure(config=config[register]) + + self.notifiers.update({ + register: {'instance': notifier_es} + }) + def configure(self, config: ConfigParser) -> None: """Configure Finder.""" self.__load_notifiers(config) From ab09e1b1347a1f9a15ecb6cd8986545fbf515e08 Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Sun, 15 Oct 2023 06:54:41 -0300 Subject: [PATCH 03/11] Added ElasticSearch Dependency Signed-off-by: Guilherme Bacellar Moralez --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 6bda8ee..42e2164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ cachetools = ">=5.3.1,<6" toml = ">=0.10.2" tox = "^4.10.0" discord-webhook = ">=1.3.0,<2" +elasticsearch = {extras = ["async"], version = "8.10.0"} [tool.poetry.dev-dependencies] pytest = ">=7.4.0" From ffb32874171cd666d14a47615cea7e5aefa642b0 Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Sun, 15 Oct 2023 06:55:29 -0300 Subject: [PATCH 04/11] Disabling InsecureWarning Signed-off-by: Guilherme Bacellar Moralez --- TEx/runner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TEx/runner.py b/TEx/runner.py index 338b7f7..d77a3dc 100644 --- a/TEx/runner.py +++ b/TEx/runner.py @@ -11,6 +11,8 @@ import types from configparser import ConfigParser from typing import Dict, List, Optional +from urllib3.exceptions import InsecureRequestWarning +from urllib3 import disable_warnings import toml @@ -144,6 +146,7 @@ def __setup_logging(self) -> None: """Setups Log Config.""" logging.config.fileConfig(os.path.join(os.path.dirname(__file__), 'logging.conf')) logging.getLogger('telethon').setLevel(level=logging.WARNING) + disable_warnings(InsecureRequestWarning) def __list_modules(self) -> None: """ From befd01a5df90774eafc340263b2669c7b1fd447d Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Sun, 15 Oct 2023 06:55:50 -0300 Subject: [PATCH 05/11] Change Log Level Signed-off-by: Guilherme Bacellar Moralez --- TEx/logging.conf | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/TEx/logging.conf b/TEx/logging.conf index bc85321..5e138b0 100644 --- a/TEx/logging.conf +++ b/TEx/logging.conf @@ -1,5 +1,5 @@ [loggers] -keys=root,sqlalchemy,TelegramExplorer +keys=root,sqlalchemy,TelegramExplorer,elasticsearch,elastic_transport.transport ####################### @@ -17,6 +17,16 @@ keys=simpleFormatter level=INFO handlers=consoleHandler +[logger_elasticsearch] +level=ERROR +handlers=consoleHandler +qualname=elasticsearch + +[logger_elastic_transport.transport] +level=ERROR +handlers=consoleHandler +qualname=elastic_transport.transport + [logger_TelegramExplorer] level=INFO handlers=consoleHandler @@ -40,4 +50,4 @@ args=(sys.stdout,) ####################### [formatter_simpleFormatter] -format=%(asctime)s - %(levelname)s - %(message)s +format= %(asctime)s - %(levelname)s - %(message)s From 76d32a94a087aee8a8193de6512f44e417f78bcc Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Sun, 15 Oct 2023 07:25:11 -0300 Subject: [PATCH 06/11] Added a Source Information on Finder and Notification Engine + Update Discord Message Template to Add a Source Information + Added Source Information on ElasticSearch Data + Updated Documentation Signed-off-by: Guilherme Bacellar Moralez --- TEx/finder/finder_engine.py | 10 ++++++++-- TEx/modules/telegram_messages_listener.py | 5 ++++- TEx/notifier/discord_notifier.py | 5 +++-- TEx/notifier/elastic_search_notifier.py | 3 ++- TEx/notifier/notifier_base.py | 2 +- TEx/notifier/notifier_engine.py | 13 ++++++++++--- docs/changelog/v030.md | 1 + tests/finder/test_finder_engine.py | 6 ++++-- tests/notifier/test_discord_notifier.py | 23 +++++++++++++---------- tests/notifier/test_notifier_engine.py | 7 ++++--- 10 files changed, 50 insertions(+), 25 deletions(-) diff --git a/TEx/finder/finder_engine.py b/TEx/finder/finder_engine.py index 8cd9e3a..c1451dc 100644 --- a/TEx/finder/finder_engine.py +++ b/TEx/finder/finder_engine.py @@ -50,8 +50,13 @@ def configure(self, config: ConfigParser) -> None: self.__load_rules(config=config) self.notification_engine.configure(config=config) - async def run(self, message: NewMessage.Event) -> None: - """Execute the Finder with Raw Text.""" + async def run(self, message: NewMessage.Event, source: str) -> None: + """Execute the Finder with Raw Text. + + :param message: Message Object + :param source: Source Account/Phone Number + :return: + """ if not self.is_finder_enabled: return @@ -65,4 +70,5 @@ async def run(self, message: NewMessage.Event) -> None: notifiers=rule['notifier'].split(','), message=message, rule_id=rule['id'], + source=source, ) diff --git a/TEx/modules/telegram_messages_listener.py b/TEx/modules/telegram_messages_listener.py index 8e76a81..93e094b 100644 --- a/TEx/modules/telegram_messages_listener.py +++ b/TEx/modules/telegram_messages_listener.py @@ -84,7 +84,10 @@ async def __handler(self, event: NewMessage.Event) -> None: values['from_type'] = None # Execute Finder - await self.finder.run(message=message) + await self.finder.run( + message=message, + source=self.target_phone_number, + ) # Add to DB TelegramMessageDatabaseManager.insert(values) diff --git a/TEx/notifier/discord_notifier.py b/TEx/notifier/discord_notifier.py index 7b9151c..bb88b31 100644 --- a/TEx/notifier/discord_notifier.py +++ b/TEx/notifier/discord_notifier.py @@ -20,7 +20,7 @@ def configure(self, url: str, config: SectionProxy) -> None: self.url = url self.configure_base(config=config) - async def run(self, message: NewMessage.Event, rule_id: str) -> None: + async def run(self, message: NewMessage.Event, rule_id: str, source: str) -> None: """Run Discord Notifier.""" # Check and Update Deduplication Control is_duplicated, duplication_tag = self.check_is_duplicated(message=message.raw_text) @@ -38,7 +38,8 @@ async def run(self, message: NewMessage.Event, rule_id: str) -> None: description=message.raw_text, ) - embed.add_embed_field(name='Rule', value=rule_id, inline=False) + embed.add_embed_field(name='Source', value=source, inline=True) + embed.add_embed_field(name='Rule', value=rule_id, inline=True) embed.add_embed_field(name='Message ID', value=str(message.id), inline=False) embed.add_embed_field(name='Group Name', value=message.chat.title, inline=True) embed.add_embed_field(name='Group ID', value=message.chat.id, inline=True) diff --git a/TEx/notifier/elastic_search_notifier.py b/TEx/notifier/elastic_search_notifier.py index 2d40e9b..add5906 100644 --- a/TEx/notifier/elastic_search_notifier.py +++ b/TEx/notifier/elastic_search_notifier.py @@ -36,10 +36,11 @@ def configure(self, config: SectionProxy) -> None: self.index = config['index_name'] self.pipeline = config['pipeline_name'] - async def run(self, message: NewMessage.Event, rule_id: str) -> None: + async def run(self, message: NewMessage.Event, rule_id: str, source: str) -> None: """Run Elastic Search Notifier.""" content: Dict = { 'time': message.date.astimezone(tz=pytz.utc), + 'source': source, 'rule': rule_id, 'raw': message.raw_text, 'group_name': message.chat.title, diff --git a/TEx/notifier/notifier_base.py b/TEx/notifier/notifier_base.py index 5c78786..f01bc07 100644 --- a/TEx/notifier/notifier_base.py +++ b/TEx/notifier/notifier_base.py @@ -38,5 +38,5 @@ def check_is_duplicated(self, message: str) -> Tuple[bool, str]: return False, tag @abc.abstractmethod - async def run(self, message: NewMessage.Event, rule_id: str) -> None: + async def run(self, message: NewMessage.Event, rule_id: str, source: str) -> None: """Run the Notification Process.""" diff --git a/TEx/notifier/notifier_engine.py b/TEx/notifier/notifier_engine.py index ca3a240..5d557be 100644 --- a/TEx/notifier/notifier_engine.py +++ b/TEx/notifier/notifier_engine.py @@ -44,12 +44,19 @@ def configure(self, config: ConfigParser) -> None: """Configure Finder.""" self.__load_notifiers(config) - async def run(self, notifiers: List[str], message: NewMessage.Event, rule_id: str) -> None: - """Dispatch all Notifications.""" + async def run(self, notifiers: List[str], message: NewMessage.Event, rule_id: str, source: str) -> None: + """Dispatch all Notifications. + + :param notifiers: + :param message: Message Object + :param rule_id: Triggered Rule ID + :param source: Source Account/Phone Number + :return: + """ if len(notifiers) == 0: return for dispatcher_name in notifiers: target_notifier: BaseNotifier = self.notifiers[dispatcher_name]['instance'] - await target_notifier.run(message=message, rule_id=rule_id) + await target_notifier.run(message=message, rule_id=rule_id, source=source) diff --git a/docs/changelog/v030.md b/docs/changelog/v030.md index 8e2b8b8..43cb47f 100644 --- a/docs/changelog/v030.md +++ b/docs/changelog/v030.md @@ -3,6 +3,7 @@ **🚀 Features** - Proxy (HTTP, SOCKS4, SOCKS5) support ([#26](https://github.com/guibacellar/TEx/issues/26)) +- Discord Notifications now have a source information with account/phone number **🐛 Bug Fixes** diff --git a/tests/finder/test_finder_engine.py b/tests/finder/test_finder_engine.py index a007ce4..39f9e3f 100644 --- a/tests/finder/test_finder_engine.py +++ b/tests/finder/test_finder_engine.py @@ -46,7 +46,8 @@ def test_run_with_regex_finder(self): # Invoke Test Target target.run( - message=target_message + message=target_message, + source='+15558987453' ) ) @@ -54,5 +55,6 @@ def test_run_with_regex_finder(self): target.notification_engine.run.assert_awaited_once_with( notifiers=['NOTIFIER.DISCORD.NOT_002'], message=target_message, - rule_id='FINDER.RULE.UT_Finder_Demo' + rule_id='FINDER.RULE.UT_Finder_Demo', + source='+15558987453' ) diff --git a/tests/notifier/test_discord_notifier.py b/tests/notifier/test_discord_notifier.py index cb6717e..806bc9c 100644 --- a/tests/notifier/test_discord_notifier.py +++ b/tests/notifier/test_discord_notifier.py @@ -49,7 +49,8 @@ def test_run_no_duplication(self): # Invoke Test Target target.run( message=target_message, - rule_id='RULE_UT_01' + rule_id='RULE_UT_01', + source='+15558987453' ) ) @@ -60,13 +61,13 @@ def test_run_no_duplication(self): self.assertEqual(call_arg.title, '**Channel 1972142108** (1972142108)') self.assertEqual(call_arg.description, 'Mocked Raw Text') - self.assertEqual(len(call_arg.fields), 6) - self.assertEqual(call_arg.fields[0], {'inline': False, 'name': 'Rule', 'value': 'RULE_UT_01'}) - self.assertEqual(call_arg.fields[1], {'inline': False, 'name': 'Message ID', 'value': '5975883'}) - self.assertEqual(call_arg.fields[2], {'inline': True, 'name': 'Group Name', 'value': 'Channel 1972142108'}) - self.assertEqual(call_arg.fields[3], {'inline': True, 'name': 'Group ID', 'value': 1972142108}) - self.assertEqual(call_arg.fields[5], - {'inline': False, 'name': 'Tag', 'value': 'de33f5dda9c686c64d23b8aec2eebfc7'}) + self.assertEqual(len(call_arg.fields), 7) + self.assertEqual(call_arg.fields[0], {'inline': True, 'name': 'Source', 'value': '+15558987453'}) + self.assertEqual(call_arg.fields[1], {'inline': True, 'name': 'Rule', 'value': 'RULE_UT_01'}) + self.assertEqual(call_arg.fields[2], {'inline': False, 'name': 'Message ID', 'value': '5975883'}) + self.assertEqual(call_arg.fields[3], {'inline': True, 'name': 'Group Name', 'value': 'Channel 1972142108'}) + self.assertEqual(call_arg.fields[4], {'inline': True, 'name': 'Group ID', 'value': 1972142108}) + self.assertEqual(call_arg.fields[6], {'inline': False, 'name': 'Tag', 'value': 'de33f5dda9c686c64d23b8aec2eebfc7'}) # Check if Webhook was Executed discord_webhook_mock.execute.assert_called_once() @@ -104,7 +105,8 @@ def test_run_duplication_control(self): # Invoke Test Target target.run( message=target_message, - rule_id='RULE_UT_01' + rule_id='RULE_UT_01', + source='+15558987453' ) ) @@ -113,7 +115,8 @@ def test_run_duplication_control(self): # Invoke Test Target Again target.run( message=target_message, - rule_id='RULE_UT_01' + rule_id='RULE_UT_01', + source='+15558987453' ) ) diff --git a/tests/notifier/test_notifier_engine.py b/tests/notifier/test_notifier_engine.py index 562c391..4da79cf 100644 --- a/tests/notifier/test_notifier_engine.py +++ b/tests/notifier/test_notifier_engine.py @@ -44,11 +44,12 @@ def test_run(self): target.run( notifiers=['NOTIFIER.DISCORD.NOT_001', 'NOTIFIER.DISCORD.NOT_002'], message=base_messages_mockup_data[0], - rule_id='RULE_UT_01' + rule_id='RULE_UT_01', + source='+15558987453' ) ) discord_notifier_mockup.run.assert_has_awaits([ - call(message=base_messages_mockup_data[0], rule_id='RULE_UT_01'), - call(message=base_messages_mockup_data[0], rule_id='RULE_UT_01'), + call(message=base_messages_mockup_data[0], rule_id='RULE_UT_01', source='+15558987453'), + call(message=base_messages_mockup_data[0], rule_id='RULE_UT_01', source='+15558987453'), ]) From 783084a420050ebf5b9ab3a8e96028455f9857f5 Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Tue, 17 Oct 2023 17:43:45 -0300 Subject: [PATCH 07/11] Added unittests for All Messages Finder module Signed-off-by: Guilherme Bacellar Moralez --- TEx/notifier/elastic_search_notifier.py | 2 +- tests/finder/test_all_messages_finder.py | 28 ++++++++ .../notifier/test_elastic_search_notifier.py | 69 +++++++++++++++++++ tests/unittest_configfile.config | 7 ++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/finder/test_all_messages_finder.py create mode 100644 tests/notifier/test_elastic_search_notifier.py diff --git a/TEx/notifier/elastic_search_notifier.py b/TEx/notifier/elastic_search_notifier.py index add5906..8d6e64e 100644 --- a/TEx/notifier/elastic_search_notifier.py +++ b/TEx/notifier/elastic_search_notifier.py @@ -47,7 +47,7 @@ async def run(self, message: NewMessage.Event, rule_id: str, source: str) -> Non 'group_id': message.chat.id, 'from_id': message.from_id.user_id if isinstance(message.from_id, PeerUser) else '', 'to_id': message.to_id.channel_id if message.to_id is not None else None, - 'reply_to_msg_id': message.reply_to.reply_to_msg_id if message.is_reply else None, + 'reply_to_msg_id': message.reply_to.reply_to_msg_id if message.is_reply and message.reply_to else None, 'message_id': message.id, 'is_reply': message.is_reply, } diff --git a/tests/finder/test_all_messages_finder.py b/tests/finder/test_all_messages_finder.py new file mode 100644 index 0000000..a4b6b8a --- /dev/null +++ b/tests/finder/test_all_messages_finder.py @@ -0,0 +1,28 @@ +import asyncio +import unittest +from configparser import ConfigParser + +from TEx.finder.all_messages_finder import AllMessagesFinder + + +class AllMessagesFinderTest(unittest.TestCase): + + def setUp(self) -> None: + self.config = ConfigParser() + self.config.read('../../config.ini') + + def test_find_true(self): + """Test the always true return.""" + + target: AllMessagesFinder = AllMessagesFinder(config=self.config) + + loop = asyncio.get_event_loop() + tasks = target.find(raw_text='foo'), target.find(raw_text=None) + + h_result_content, h_result_none = loop.run_until_complete( + asyncio.gather(*tasks) + ) + + self.assertTrue(h_result_content) + self.assertTrue(h_result_none) + diff --git a/tests/notifier/test_elastic_search_notifier.py b/tests/notifier/test_elastic_search_notifier.py new file mode 100644 index 0000000..915e3c5 --- /dev/null +++ b/tests/notifier/test_elastic_search_notifier.py @@ -0,0 +1,69 @@ +import asyncio +import datetime +import unittest +from configparser import ConfigParser +from typing import Dict +from unittest import mock + +from telethon.tl.types import Message, MessageFwdHeader, PeerChannel, PeerUser + +from TEx.notifier.discord_notifier import DiscordNotifier +from TEx.notifier.elastic_search_notifier import ElasticSearchNotifier +from tests.modules.common import TestsCommon +from tests.modules.mockups_groups_mockup_data import base_groups_mockup_data, base_messages_mockup_data, \ + channel_1_mocked + + +class ElasticSearchNotifierTest(unittest.TestCase): + + def setUp(self) -> None: + self.config = ConfigParser() + self.config.read('../../config.ini') + + def test_run_without_file(self): + """Test Run Method Without Message File Attachment.""" + + # Setup Mock + elastic_search_api_mock = mock.AsyncMock() + elastic_search_api_mock.index = mock.AsyncMock() + + target: ElasticSearchNotifier = ElasticSearchNotifier() + args: Dict = { + 'config': 'unittest_configfile.config' + } + data: Dict = {} + TestsCommon.execute_basic_pipeline_steps_for_initialization(config=self.config, args=args, data=data) + + # Set Mock Message + origin_mock_message = base_messages_mockup_data[1] + mocked_message = mock.MagicMock(spec=origin_mock_message) + for attr_name in origin_mock_message.__dict__: + setattr(mocked_message, attr_name, getattr(origin_mock_message, attr_name)) + mocked_message.chat = channel_1_mocked + + with mock.patch('TEx.notifier.elastic_search_notifier.AsyncElasticsearch', return_value=elastic_search_api_mock): + # Execute Discord Notifier Configure Method + target.configure( + config=self.config['NOTIFIER.ELASTIC_SEARCH.UT_01'] + ) + + loop = asyncio.get_event_loop() + loop.run_until_complete( + + # Invoke Test Target + target.run( + message=mocked_message, + rule_id='RULE_UT_01', + source='+15558987453' + ) + ) + + # Check .index call + elastic_search_api_mock.index.assert_called_once() + call_arg = elastic_search_api_mock.index.call_args[1] + + self.assertEqual(call_arg['index'], 'test_index_name') + self.assertEqual(call_arg['pipeline'], 'test_pipeline_name') + self.assertEqual(call_arg['id'], '1972142108_183017') + + submited_document = call_arg['document'] diff --git a/tests/unittest_configfile.config b/tests/unittest_configfile.config index 818edb5..10b63bd 100644 --- a/tests/unittest_configfile.config +++ b/tests/unittest_configfile.config @@ -28,3 +28,10 @@ prevent_duplication_for_minutes=240 [NOTIFIER.DISCORD.NOT_002] webhook=https://uri.domain.com/webhook/002 prevent_duplication_for_minutes=240 + +[NOTIFIER.ELASTIC_SEARCH.UT_01] +address=https://localhost:666 +api_key=test_api_key +verify_ssl_cert=False +index_name=test_index_name +pipeline_name=test_pipeline_name From a5e1539559c01f7f4a475c78de62f3dfe48ed4a6 Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Wed, 18 Oct 2023 14:25:50 -0300 Subject: [PATCH 08/11] Internal Refactory to Introduce Facade Objects for Notification and Finder Modules (Reduce Overall Code and Tests Complexity, Allow Future Modules as Easy Way, and, Reduce Mental Overload for Core Review) + Included Pydantic deps + Fix some invalid Telethon Message Object Type Hint + Fix Unittest Signed-off-by: Guilherme Bacellar Moralez --- TEx/core/mapper/telethon_message_mapper.py | 64 ++++++++ .../do_nothing_media_downloader.py | 2 +- .../photo_media_downloader.py | 2 +- .../std_media_downloader.py | 2 +- TEx/core/media_handler.py | 12 +- .../do_nothing_media_handler.py | 2 +- .../generic_binary_handler.py | 5 +- .../media_metadata_handling/geo_handler.py | 5 +- .../media_metadata_handling/mp4_handler.py | 5 +- .../media_metadata_handling/pdf_handler.py | 8 +- .../media_metadata_handling/photo_handler.py | 5 +- .../sticker_handler.py | 9 +- .../media_metadata_handling/text_handler.py | 5 +- .../webimage_handler.py | 16 +- TEx/core/state_file.py | 2 +- TEx/core/temp_file.py | 6 +- TEx/database/telegram_group_database.py | 8 +- TEx/finder/finder_engine.py | 15 +- TEx/models/database/telegram_db_model.py | 2 +- TEx/models/database/temp_db_models.py | 2 +- .../finder_notification_facade_entity.py | 26 ++++ .../facade/media_handler_facade_entity.py | 12 ++ .../telegram_group_report_facade_entity.py | 11 +- .../telegram_message_report_facade_entity.py | 6 +- TEx/modules/telegram_messages_listener.py | 16 +- TEx/modules/telegram_messages_scrapper.py | 6 +- .../telegram_html_report_generator.py | 6 +- TEx/notifier/discord_notifier.py | 18 +-- TEx/notifier/elastic_search_notifier.py | 38 ++--- TEx/notifier/notifier_base.py | 5 +- TEx/notifier/notifier_engine.py | 7 +- mypy.ini | 8 +- pyproject.toml | 1 + tests/finder/test_finder_engine.py | 20 ++- tests/modules/mockups_groups_mockup_data.py | 2 +- .../test_telegram_messages_listener.py | 1 + tests/notifier/test_discord_notifier.py | 43 ++++-- .../notifier/test_elastic_search_notifier.py | 146 +++++++++--------- tests/notifier/test_notifier_engine.py | 50 ++++-- 39 files changed, 400 insertions(+), 199 deletions(-) create mode 100644 TEx/core/mapper/telethon_message_mapper.py create mode 100644 TEx/models/facade/finder_notification_facade_entity.py create mode 100644 TEx/models/facade/media_handler_facade_entity.py diff --git a/TEx/core/mapper/telethon_message_mapper.py b/TEx/core/mapper/telethon_message_mapper.py new file mode 100644 index 0000000..9c37e79 --- /dev/null +++ b/TEx/core/mapper/telethon_message_mapper.py @@ -0,0 +1,64 @@ +"""Telethon Event Entity Mapper.""" +from __future__ import annotations + +from typing import Optional, Union + +from pydantic import BaseModel +from telethon.tl.patched import Message +from telethon.tl.types import Channel, Chat, PeerUser, User + +from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity +from TEx.models.facade.media_handler_facade_entity import MediaHandlingEntity + + +class TelethonMessageEntityMapper: + """Telethon Event Entity Mapper.""" + + class ChatPropsModel(BaseModel): + """Model for __map_chat_props method.""" + + chat_id: int + chat_title: str + + @staticmethod + async def to_finder_notification_facade_entity(message: Message, downloaded_media_info: Optional[MediaHandlingEntity]) -> \ + Optional[FinderNotificationMessageEntity]: + """Map Telethon Event to FinderNotificationMessageEntity.""" + if not message: + return None + + mapped_chat_props: TelethonMessageEntityMapper.ChatPropsModel = TelethonMessageEntityMapper.__map_chat_props( + entity=await message.get_chat(), + ) + + h_result: FinderNotificationMessageEntity = FinderNotificationMessageEntity( + date_time=message.date, + raw_text=message.raw_text, + group_name=mapped_chat_props.chat_title, + group_id=mapped_chat_props.chat_id, + from_id=message.from_id.user_id if isinstance(message.from_id, PeerUser) else None, + to_id=message.to_id.channel_id if message.to_id is not None else None, + reply_to_msg_id=message.reply_to.reply_to_msg_id if message.is_reply and message.reply_to else None, + message_id=message.id, + is_reply=message.is_reply, + downloaded_media_info=downloaded_media_info, + ) + + return h_result + + @staticmethod + def __map_chat_props(entity: Union[Channel, User, Chat]) -> TelethonMessageEntityMapper.ChatPropsModel: + """Map Chat Specific Props.""" + if isinstance(entity, (Channel, Chat)): + return TelethonMessageEntityMapper.ChatPropsModel( + chat_id=entity.id, + chat_title=entity.title if entity.title else '', + ) + + if isinstance(entity, User): + return TelethonMessageEntityMapper.ChatPropsModel( + chat_id=entity.id, + chat_title=entity.username if entity.username else (entity.phone if entity.phone else ''), + ) + + raise AttributeError(entity, 'Invalid entity type') diff --git a/TEx/core/media_download_handling/do_nothing_media_downloader.py b/TEx/core/media_download_handling/do_nothing_media_downloader.py index 6e2e86e..ac847ab 100644 --- a/TEx/core/media_download_handling/do_nothing_media_downloader.py +++ b/TEx/core/media_download_handling/do_nothing_media_downloader.py @@ -3,7 +3,7 @@ from typing import Dict -from telethon.tl.types import Message +from telethon.tl.patched import Message class DoNothingMediaDownloader: diff --git a/TEx/core/media_download_handling/photo_media_downloader.py b/TEx/core/media_download_handling/photo_media_downloader.py index 6a2761b..705c175 100644 --- a/TEx/core/media_download_handling/photo_media_downloader.py +++ b/TEx/core/media_download_handling/photo_media_downloader.py @@ -4,7 +4,7 @@ import os from typing import Dict -from telethon.tl.types import Message +from telethon.tl.patched import Message class PhotoMediaDownloader: diff --git a/TEx/core/media_download_handling/std_media_downloader.py b/TEx/core/media_download_handling/std_media_downloader.py index f34fbda..ad170b9 100644 --- a/TEx/core/media_download_handling/std_media_downloader.py +++ b/TEx/core/media_download_handling/std_media_downloader.py @@ -4,7 +4,7 @@ import os from typing import Dict, List -from telethon.tl.types import Message +from telethon.tl.patched import Message class StandardMediaDownloader: diff --git a/TEx/core/media_handler.py b/TEx/core/media_handler.py index 882898a..8fd0cb7 100644 --- a/TEx/core/media_handler.py +++ b/TEx/core/media_handler.py @@ -21,6 +21,7 @@ from TEx.core.media_metadata_handling.text_handler import TextPlainHandler from TEx.core.media_metadata_handling.webimage_handler import WebImageStickerHandler from TEx.database.telegram_group_database import TelegramMediaDatabaseManager +from TEx.models.facade.media_handler_facade_entity import MediaHandlingEntity logger = logging.getLogger('TelegramExplorer') @@ -77,7 +78,7 @@ class UniversalTelegramMediaHandler: }, } - async def handle_medias(self, message: Message, group_id: int, data_path: str) -> Optional[int]: + async def handle_medias(self, message: Message, group_id: int, data_path: str) -> Optional[MediaHandlingEntity]: """Handle Message Media, Photo, File, etc.""" executor_id: Optional[str] = self.__resolve_executor_id(message=message) @@ -124,7 +125,14 @@ async def handle_medias(self, message: Message, group_id: int, data_path: str) - # Update Reference into DB if media_metadata is not None: media_metadata['group_id'] = group_id - return TelegramMediaDatabaseManager.insert(entity_values=media_metadata) + media_id: int = TelegramMediaDatabaseManager.insert(entity_values=media_metadata) + + return MediaHandlingEntity( + media_id=media_id, + file_name=media_metadata['file_name'], + content_type=media_metadata['mime_type'], + size_bytes=media_metadata['size_bytes'], + ) return None diff --git a/TEx/core/media_metadata_handling/do_nothing_media_handler.py b/TEx/core/media_metadata_handling/do_nothing_media_handler.py index c10353b..5eab805 100644 --- a/TEx/core/media_metadata_handling/do_nothing_media_handler.py +++ b/TEx/core/media_metadata_handling/do_nothing_media_handler.py @@ -3,7 +3,7 @@ from typing import Dict, Optional -from telethon.tl.types import Message +from telethon.tl.patched import Message class DoNothingHandler: diff --git a/TEx/core/media_metadata_handling/generic_binary_handler.py b/TEx/core/media_metadata_handling/generic_binary_handler.py index 649fb9f..154ac14 100644 --- a/TEx/core/media_metadata_handling/generic_binary_handler.py +++ b/TEx/core/media_metadata_handling/generic_binary_handler.py @@ -3,7 +3,8 @@ from typing import Dict, List, Optional -from telethon.tl.types import DocumentAttributeFilename, Message, MessageMediaDocument +from telethon.tl.patched import Message +from telethon.tl.types import DocumentAttributeFilename, MessageMediaDocument class GenericBinaryMediaHandler: @@ -26,4 +27,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/geo_handler.py b/TEx/core/media_metadata_handling/geo_handler.py index 9d4164c..538e9db 100644 --- a/TEx/core/media_metadata_handling/geo_handler.py +++ b/TEx/core/media_metadata_handling/geo_handler.py @@ -3,7 +3,8 @@ from typing import Dict, Optional -from telethon.tl.types import Message, MessageMediaGeo +from telethon.tl.patched import Message +from telethon.tl.types import MessageMediaGeo class GeoMediaHandler: @@ -28,4 +29,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': None, 'title': f'{geo.lat}|{geo.long}', 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/mp4_handler.py b/TEx/core/media_metadata_handling/mp4_handler.py index c0eade6..1a30236 100644 --- a/TEx/core/media_metadata_handling/mp4_handler.py +++ b/TEx/core/media_metadata_handling/mp4_handler.py @@ -3,7 +3,8 @@ from typing import Dict, List, Optional -from telethon.tl.types import DocumentAttributeFilename, DocumentAttributeVideo, Message, MessageMediaDocument +from telethon.tl.patched import Message +from telethon.tl.types import DocumentAttributeFilename, DocumentAttributeVideo, MessageMediaDocument class MediaMp4Handler: @@ -27,4 +28,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/pdf_handler.py b/TEx/core/media_metadata_handling/pdf_handler.py index 7767c02..f9d970f 100644 --- a/TEx/core/media_metadata_handling/pdf_handler.py +++ b/TEx/core/media_metadata_handling/pdf_handler.py @@ -3,7 +3,8 @@ from typing import Dict, Optional -from telethon.tl.types import DocumentAttributeFilename, Message, MessageMediaPhoto +from telethon.tl.patched import Message +from telethon.tl.types import DocumentAttributeFilename, MessageMediaPhoto class PdfMediaHandler: @@ -15,7 +16,8 @@ def handle_metadata(message: Message) -> Optional[Dict]: media: MessageMediaPhoto = message.media return { - 'file_name': [item for item in media.document.attributes if isinstance(item, DocumentAttributeFilename)][0].file_name, + 'file_name': [item for item in media.document.attributes if isinstance(item, DocumentAttributeFilename)][ + 0].file_name, 'telegram_id': media.document.id, 'extension': None, 'height': None, @@ -25,4 +27,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/photo_handler.py b/TEx/core/media_metadata_handling/photo_handler.py index a34f422..50f0482 100644 --- a/TEx/core/media_metadata_handling/photo_handler.py +++ b/TEx/core/media_metadata_handling/photo_handler.py @@ -3,7 +3,8 @@ from typing import Dict, Optional -from telethon.tl.types import Message, MessageMediaPhoto +from telethon.tl.patched import Message +from telethon.tl.types import MessageMediaPhoto class PhotoMediaHandler: @@ -25,4 +26,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': message.file.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/sticker_handler.py b/TEx/core/media_metadata_handling/sticker_handler.py index 2b20be0..8cc39ee 100644 --- a/TEx/core/media_metadata_handling/sticker_handler.py +++ b/TEx/core/media_metadata_handling/sticker_handler.py @@ -3,7 +3,8 @@ from typing import Dict, List, Optional -from telethon.tl.types import DocumentAttributeFilename, DocumentAttributeImageSize, Message, MessageMediaDocument +from telethon.tl.patched import Message +from telethon.tl.types import DocumentAttributeFilename, DocumentAttributeImageSize, MessageMediaDocument class MediaStickerHandler: @@ -16,7 +17,9 @@ def handle_metadata(message: Message) -> Optional[Dict]: fn_attr_img: List = [item for item in media.document.attributes if isinstance(item, DocumentAttributeImageSize)] return { - 'file_name': [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeFilename)][0].file_name, + 'file_name': + [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeFilename)][ + 0].file_name, 'telegram_id': media.document.id, 'extension': None, 'height': fn_attr_img[0].h if len(fn_attr_img) > 0 else None, @@ -26,4 +29,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/text_handler.py b/TEx/core/media_metadata_handling/text_handler.py index 103c193..3297097 100644 --- a/TEx/core/media_metadata_handling/text_handler.py +++ b/TEx/core/media_metadata_handling/text_handler.py @@ -3,7 +3,8 @@ from typing import Dict, Optional -from telethon.tl.types import DocumentAttributeFilename, Message, MessageMediaDocument +from telethon.tl.patched import Message +from telethon.tl.types import DocumentAttributeFilename, MessageMediaDocument class TextPlainHandler: @@ -26,4 +27,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/webimage_handler.py b/TEx/core/media_metadata_handling/webimage_handler.py index 3166063..48e38b6 100644 --- a/TEx/core/media_metadata_handling/webimage_handler.py +++ b/TEx/core/media_metadata_handling/webimage_handler.py @@ -3,7 +3,8 @@ from typing import Dict, List, Optional -from telethon.tl.types import DocumentAttributeFilename, DocumentAttributeImageSize, Message, MessageMediaDocument +from telethon.tl.patched import Message +from telethon.tl.types import DocumentAttributeFilename, DocumentAttributeImageSize, MessageMediaDocument class WebImageStickerHandler: @@ -14,7 +15,8 @@ def handle_metadata(message: Message) -> Optional[Dict]: """Handle Media Metadata.""" media: MessageMediaDocument = message.media - fn_attr: List = [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeFilename)] + fn_attr: List = [item for item in message.media.document.attributes if + isinstance(item, DocumentAttributeFilename)] if not fn_attr or len(fn_attr) == 0: return None @@ -23,11 +25,15 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'file_name': fn_attr[0].file_name, 'telegram_id': media.document.id, 'extension': None, - 'height': [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeImageSize)][0].h, - 'width': [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeImageSize)][0].w, + 'height': + [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeImageSize)][ + 0].h, + 'width': + [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeImageSize)][ + 0].w, 'date_time': media.document.date, 'mime_type': media.document.mime_type, 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/state_file.py b/TEx/core/state_file.py index 3a8300c..55c018f 100644 --- a/TEx/core/state_file.py +++ b/TEx/core/state_file.py @@ -42,7 +42,7 @@ def write_file_text(path: str, content: str) -> None: """ # Delete if Exists DbManager.SESSIONS['temp'].execute( - StateFileOrmEntity.__table__.delete().where(StateFileOrmEntity.path == path), + StateFileOrmEntity.__table__.delete().where(StateFileOrmEntity.path == path), # type: ignore ) entity: StateFileOrmEntity = StateFileOrmEntity( diff --git a/TEx/core/temp_file.py b/TEx/core/temp_file.py index a11dd44..afd511b 100644 --- a/TEx/core/temp_file.py +++ b/TEx/core/temp_file.py @@ -35,7 +35,7 @@ def read_file_text(path: str) -> str: def remove_expired_entries() -> int: """Remove all Expired Entries.""" total: int = DbManager.SESSIONS['temp'].execute( - TempDataOrmEntity.__table__.delete().where( + TempDataOrmEntity.__table__.delete().where( # type: ignore TempDataOrmEntity.valid_at <= int(datetime.now(tz=pytz.UTC).timestamp()), ), ).rowcount @@ -47,7 +47,7 @@ def remove_expired_entries() -> int: @staticmethod def purge() -> int: """Remove all Entries.""" - total: int = DbManager.SESSIONS['temp'].execute(TempDataOrmEntity.__table__.delete()).rowcount + total: int = DbManager.SESSIONS['temp'].execute(TempDataOrmEntity.__table__.delete()).rowcount # type: ignore DbManager.SESSIONS['temp'].flush() DbManager.SESSIONS['temp'].commit() return total @@ -64,7 +64,7 @@ def write_file_text(path: str, content: str, validate_seconds: int = 3600) -> No """ # Delete if Exists DbManager.SESSIONS['temp'].execute( - TempDataOrmEntity.__table__.delete().where(TempDataOrmEntity.path == path), + TempDataOrmEntity.__table__.delete().where(TempDataOrmEntity.path == path), # type: ignore ) entity: TempDataOrmEntity = TempDataOrmEntity( diff --git a/TEx/database/telegram_group_database.py b/TEx/database/telegram_group_database.py index a2b037d..893ef96 100644 --- a/TEx/database/telegram_group_database.py +++ b/TEx/database/telegram_group_database.py @@ -104,7 +104,7 @@ def insert(entity_values: Dict) -> None: DbManager.SESSIONS['data'].commit() except sqlalchemy.exc.IntegrityError as exc: - if 'UNIQUE' in exc.orig.args[0]: + if 'UNIQUE' in exc.orig.args[0]: # type: ignore return raise @@ -294,11 +294,11 @@ def get_all_medias_from_group_and_mimetype(group_id: int, mime_type: str, file_d parts_or_filter: List[BinaryExpression] = [] for name_part in file_name_part: - parts_or_filter.append(TelegramMediaOrmEntity.file_name.contains(name_part)) + parts_or_filter.append(TelegramMediaOrmEntity.file_name.contains(name_part)) # type: ignore select_statement = select_statement.where(or_(*parts_or_filter)) - return DbManager.SESSIONS['data'].execute(select_statement) + return DbManager.SESSIONS['data'].execute(select_statement) # type: ignore @staticmethod def stats_all_medias_from_group_by_mimetype(group_id: int, file_datetime_limit_seconds: Optional[int] = None) -> Dict: @@ -346,7 +346,7 @@ def get_all_medias_by_age(group_id: int, media_limit_days: int) -> List[Telegram :param media_limit_days: Age of Media in Days :return: Number of Medias Removed """ - statement: Delete = select(TelegramMediaOrmEntity).where( + statement: Delete = select(TelegramMediaOrmEntity).where( # type: ignore TelegramMediaOrmEntity.date_time <= (datetime.datetime.now(tz=pytz.UTC) - datetime.timedelta(days=media_limit_days)), ) diff --git a/TEx/finder/finder_engine.py b/TEx/finder/finder_engine.py index c1451dc..48321d3 100644 --- a/TEx/finder/finder_engine.py +++ b/TEx/finder/finder_engine.py @@ -2,12 +2,11 @@ from __future__ import annotations from configparser import ConfigParser -from typing import Dict, List - -from telethon.events import NewMessage +from typing import Dict, List, Optional from TEx.finder.all_messages_finder import AllMessagesFinder from TEx.finder.regex_finder import RegexFinder +from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity from TEx.notifier.notifier_engine import NotifierEngine @@ -50,25 +49,25 @@ def configure(self, config: ConfigParser) -> None: self.__load_rules(config=config) self.notification_engine.configure(config=config) - async def run(self, message: NewMessage.Event, source: str) -> None: + async def run(self, entity: Optional[FinderNotificationMessageEntity], source: str) -> None: """Execute the Finder with Raw Text. - :param message: Message Object + :param entity: Facade Object :param source: Source Account/Phone Number :return: """ - if not self.is_finder_enabled: + if not self.is_finder_enabled or not entity: return for rule in self.rules: - is_found: bool = await rule['instance'].find(raw_text=message.raw_text) + is_found: bool = await rule['instance'].find(raw_text=entity.raw_text) if is_found: # Runt the Notification Engine await self.notification_engine.run( notifiers=rule['notifier'].split(','), - message=message, + entity=entity, rule_id=rule['id'], source=source, ) diff --git a/TEx/models/database/telegram_db_model.py b/TEx/models/database/telegram_db_model.py index 7ace47c..82756d8 100644 --- a/TEx/models/database/telegram_db_model.py +++ b/TEx/models/database/telegram_db_model.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column -class TelegramDataBaseDeclarativeBase(DeclarativeBase): # type: ignore +class TelegramDataBaseDeclarativeBase(DeclarativeBase): """Global Telegram DB Declarative Base.""" diff --git a/TEx/models/database/temp_db_models.py b/TEx/models/database/temp_db_models.py index 27a49ad..46bf4e0 100644 --- a/TEx/models/database/temp_db_models.py +++ b/TEx/models/database/temp_db_models.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column -class TempDataBaseDeclarativeBase(DeclarativeBase): # type: ignore +class TempDataBaseDeclarativeBase(DeclarativeBase): """Global Temporary Declarative Base.""" diff --git a/TEx/models/facade/finder_notification_facade_entity.py b/TEx/models/facade/finder_notification_facade_entity.py new file mode 100644 index 0000000..82c43d1 --- /dev/null +++ b/TEx/models/facade/finder_notification_facade_entity.py @@ -0,0 +1,26 @@ +"""Facade Entities for Finder e Notification Engine Modules.""" +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from TEx.models.facade.media_handler_facade_entity import MediaHandlingEntity + + +class FinderNotificationMessageEntity(BaseModel): + """Facade Entity for Finder and Notification.""" + + model_config = ConfigDict(extra='forbid') + + date_time: datetime + raw_text: str + group_name: Optional[str] + group_id: int + from_id: Optional[int] + to_id: Optional[int] + reply_to_msg_id: Optional[int] + message_id: int + is_reply: bool + downloaded_media_info: Optional[MediaHandlingEntity] diff --git a/TEx/models/facade/media_handler_facade_entity.py b/TEx/models/facade/media_handler_facade_entity.py new file mode 100644 index 0000000..231b133 --- /dev/null +++ b/TEx/models/facade/media_handler_facade_entity.py @@ -0,0 +1,12 @@ +"""Facade Entities for Media Handling.""" + +from pydantic import BaseModel + + +class MediaHandlingEntity(BaseModel): + """Facade Entities for Media Handling.""" + + media_id: int + file_name: str + content_type: str + size_bytes: int diff --git a/TEx/models/facade/telegram_group_report_facade_entity.py b/TEx/models/facade/telegram_group_report_facade_entity.py index 5fa8741..f81890d 100644 --- a/TEx/models/facade/telegram_group_report_facade_entity.py +++ b/TEx/models/facade/telegram_group_report_facade_entity.py @@ -1,4 +1,7 @@ """Facade Entity for Report Generation.""" +from __future__ import annotations + +from typing import Optional from TEx.models.database.telegram_db_model import TelegramGroupOrmEntity @@ -19,11 +22,11 @@ class TelegramGroupReportFacadeEntity: scam: bool verified: bool - participants_count: int + participants_count: Optional[int] - photo_id: int - photo_base64: str - photo_name: str + photo_id: Optional[int] + photo_base64: Optional[str] + photo_name: Optional[str] source: str diff --git a/TEx/models/facade/telegram_message_report_facade_entity.py b/TEx/models/facade/telegram_message_report_facade_entity.py index 3eafa40..a6090b3 100644 --- a/TEx/models/facade/telegram_message_report_facade_entity.py +++ b/TEx/models/facade/telegram_message_report_facade_entity.py @@ -18,9 +18,9 @@ class TelegramMessageReportFacadeEntity: message: str raw: str - from_id: int - from_type: str - to_id: int + from_id: Optional[int] + from_type: Optional[str] + to_id: Optional[int] meta_next: bool meta_previous: bool diff --git a/TEx/modules/telegram_messages_listener.py b/TEx/modules/telegram_messages_listener.py index 93e094b..1512bec 100644 --- a/TEx/modules/telegram_messages_listener.py +++ b/TEx/modules/telegram_messages_listener.py @@ -3,19 +3,22 @@ import logging from configparser import ConfigParser -from typing import Dict, List, cast +from typing import Dict, List, Optional, cast import pytz from telethon import TelegramClient, events from telethon.events import NewMessage -from telethon.tl.types import Channel, Message, PeerUser, User +from telethon.tl.patched import Message +from telethon.tl.types import Channel, PeerUser, User from TEx.core.base_module import BaseModule from TEx.core.mapper.telethon_channel_mapper import TelethonChannelEntityMapper +from TEx.core.mapper.telethon_message_mapper import TelethonMessageEntityMapper from TEx.core.mapper.telethon_user_mapper import TelethonUserEntiyMapper from TEx.core.media_handler import UniversalTelegramMediaHandler from TEx.database.telegram_group_database import TelegramGroupDatabaseManager, TelegramMessageDatabaseManager, TelegramUserDatabaseManager from TEx.finder.finder_engine import FinderEngine +from TEx.models.facade.media_handler_facade_entity import MediaHandlingEntity logger = logging.getLogger('TelegramExplorer') @@ -57,6 +60,7 @@ async def __handler(self, event: NewMessage.Event) -> None: await self.__ensure_group_exists(event=event) # Create Dict with All Value + downloaded_media: Optional[MediaHandlingEntity] = await self.media_handler.handle_medias(message, event.chat.id, self.data_path) if self.download_media else None values: Dict = { 'id': message.id, 'group_id': event.chat.id, @@ -64,10 +68,10 @@ async def __handler(self, event: NewMessage.Event) -> None: 'message': message.message, 'raw': message.raw_text, 'to_id': message.to_id.channel_id if message.to_id is not None else None, - 'media_id': await self.media_handler.handle_medias(message, event.chat.id, self.data_path) if self.download_media else None, + 'media_id': downloaded_media.media_id if downloaded_media else None, 'is_reply': message.is_reply, 'reply_to_msg_id': message.reply_to.reply_to_msg_id if message.is_reply else None, - } + } # Process Sender ID if message.from_id is not None: @@ -85,7 +89,7 @@ async def __handler(self, event: NewMessage.Event) -> None: # Execute Finder await self.finder.run( - message=message, + await TelethonMessageEntityMapper.to_finder_notification_facade_entity(message=message, downloaded_media_info=downloaded_media), source=self.target_phone_number, ) @@ -134,7 +138,7 @@ async def __ensure_group_exists(self, event: NewMessage.Event) -> None: group_dict_data: Dict = TelethonChannelEntityMapper.to_database_dict( entity=result, target_phone_numer=self.target_phone_number, - ) + ) TelegramGroupDatabaseManager.insert_or_update(group_dict_data) diff --git a/TEx/modules/telegram_messages_scrapper.py b/TEx/modules/telegram_messages_scrapper.py index 912862e..cd5decc 100644 --- a/TEx/modules/telegram_messages_scrapper.py +++ b/TEx/modules/telegram_messages_scrapper.py @@ -15,6 +15,7 @@ from TEx.core.media_handler import UniversalTelegramMediaHandler from TEx.database.telegram_group_database import TelegramGroupDatabaseManager, TelegramMessageDatabaseManager from TEx.models.database.telegram_db_model import TelegramGroupOrmEntity +from TEx.models.facade.media_handler_facade_entity import MediaHandlingEntity logger = logging.getLogger('TelegramExplorer') @@ -115,6 +116,7 @@ async def __download_messages(self, group_id: int, group_name: str, client: Tele if message.reply_to_msg_id: pass + downloaded_media: Optional[MediaHandlingEntity] = await self.media_handler.handle_medias(message, group_id, data_path) if download_media else None values: Dict = { 'id': message.id, 'group_id': group_id, @@ -122,8 +124,8 @@ async def __download_messages(self, group_id: int, group_name: str, client: Tele 'message': message.message, 'raw': message.raw_text, 'to_id': message.to_id.channel_id if message.to_id is not None else None, - 'media_id': await self.media_handler.handle_medias(message, group_id, data_path) if download_media else None, - } + 'media_id': downloaded_media.media_id if downloaded_media else None, + } if message.from_id is not None: if isinstance(message.from_id, PeerUser): diff --git a/TEx/modules/telegram_report_generator/telegram_html_report_generator.py b/TEx/modules/telegram_report_generator/telegram_html_report_generator.py index 2b79af0..0a21305 100644 --- a/TEx/modules/telegram_report_generator/telegram_html_report_generator.py +++ b/TEx/modules/telegram_report_generator/telegram_html_report_generator.py @@ -220,7 +220,7 @@ async def process_messages(self, reppeating_messages_signatures.append(message_hash) # Get the From Message User - from_user: Optional[TelegramUserOrmEntity] = self.get_user(message.from_id) + from_user: Optional[TelegramUserOrmEntity] = self.get_user(message.from_id) if message.from_id else None # Check if Append the Message on Previous Message OR Creates a New One is_user_bot: bool = from_user is not None and not from_user.is_bot @@ -269,7 +269,7 @@ async def get_media(self, message: TelegramMessageReportFacadeEntity, assets_roo ) if media: - if media.mime_type == 'application/vnd.geo': + if media.mime_type == 'application/vnd.geo' and media.title: media_geo = media.title.replace('|', ',') else: @@ -305,7 +305,7 @@ async def get_media(self, message: TelegramMessageReportFacadeEntity, assets_roo def render_to_from_message_info(self, message: TelegramMessageReportFacadeEntity, from_user: Optional[TelegramUserOrmEntity]) -> str: """Build and Return the TO/FROM Information for Message.""" # Get Users - to_user: Optional[TelegramUserOrmEntity] = self.get_user(message.to_id) + to_user: Optional[TelegramUserOrmEntity] = self.get_user(message.to_id) if message.to_id else None to_from_information: str = '' if from_user: diff --git a/TEx/notifier/discord_notifier.py b/TEx/notifier/discord_notifier.py index bb88b31..6a0b179 100644 --- a/TEx/notifier/discord_notifier.py +++ b/TEx/notifier/discord_notifier.py @@ -2,8 +2,8 @@ from configparser import SectionProxy from discord_webhook import DiscordEmbed, DiscordWebhook -from telethon.events import NewMessage +from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity from TEx.notifier.notifier_base import BaseNotifier @@ -20,10 +20,10 @@ def configure(self, url: str, config: SectionProxy) -> None: self.url = url self.configure_base(config=config) - async def run(self, message: NewMessage.Event, rule_id: str, source: str) -> None: + async def run(self, entity: FinderNotificationMessageEntity, rule_id: str, source: str) -> None: """Run Discord Notifier.""" # Check and Update Deduplication Control - is_duplicated, duplication_tag = self.check_is_duplicated(message=message.raw_text) + is_duplicated, duplication_tag = self.check_is_duplicated(message=entity.raw_text) if is_duplicated: return @@ -34,16 +34,16 @@ async def run(self, message: NewMessage.Event, rule_id: str, source: str) -> Non ) embed = DiscordEmbed( - title=f'**{message.chat.title}** ({message.chat.id})', - description=message.raw_text, + title=f'**{entity.group_name}** ({entity.group_id})', + description=entity.raw_text, ) embed.add_embed_field(name='Source', value=source, inline=True) embed.add_embed_field(name='Rule', value=rule_id, inline=True) - embed.add_embed_field(name='Message ID', value=str(message.id), inline=False) - embed.add_embed_field(name='Group Name', value=message.chat.title, inline=True) - embed.add_embed_field(name='Group ID', value=message.chat.id, inline=True) - embed.add_embed_field(name='Message Date', value=str(message.date), inline=False) + embed.add_embed_field(name='Message ID', value=str(entity.message_id), inline=False) + embed.add_embed_field(name='Group Name', value=entity.group_name if entity.group_name else '', inline=True) + embed.add_embed_field(name='Group ID', value=str(entity.group_id), inline=True) + embed.add_embed_field(name='Message Date', value=str(entity.date_time), inline=False) embed.add_embed_field(name='Tag', value=duplication_tag, inline=False) # add embed object to webhook diff --git a/TEx/notifier/elastic_search_notifier.py b/TEx/notifier/elastic_search_notifier.py index 8d6e64e..c53d7fb 100644 --- a/TEx/notifier/elastic_search_notifier.py +++ b/TEx/notifier/elastic_search_notifier.py @@ -6,9 +6,8 @@ import pytz from elasticsearch import AsyncElasticsearch -from telethon.events import NewMessage -from telethon.tl.types import PeerUser +from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity from TEx.notifier.notifier_base import BaseNotifier @@ -19,7 +18,7 @@ def __init__(self) -> None: """Initialize Elastic Search Notifier.""" super().__init__() self.url: str = '' - self.client: AsyncElasticsearch = None + self.client: Optional[AsyncElasticsearch] = None self.index: str = '' self.pipeline: str = '' @@ -28,7 +27,7 @@ def configure(self, config: SectionProxy) -> None: hosts_list: Optional[str] = config.get('address', fallback=None) self.client = AsyncElasticsearch( - hosts=hosts_list.split(',') if hosts_list else None, + hosts=hosts_list.split(',') if hosts_list else None, # type: ignore api_key=config.get('api_key', fallback=None), verify_certs=config.get('verify_ssl_cert', fallback='True') == 'True', cloud_id=config.get('cloud_id', fallback=None), @@ -36,26 +35,29 @@ def configure(self, config: SectionProxy) -> None: self.index = config['index_name'] self.pipeline = config['pipeline_name'] - async def run(self, message: NewMessage.Event, rule_id: str, source: str) -> None: + async def run(self, entity: FinderNotificationMessageEntity, rule_id: str, source: str) -> None: """Run Elastic Search Notifier.""" + if not self.client: + return + content: Dict = { - 'time': message.date.astimezone(tz=pytz.utc), + 'time': entity.date_time.astimezone(tz=pytz.utc), 'source': source, 'rule': rule_id, - 'raw': message.raw_text, - 'group_name': message.chat.title, - 'group_id': message.chat.id, - 'from_id': message.from_id.user_id if isinstance(message.from_id, PeerUser) else '', - 'to_id': message.to_id.channel_id if message.to_id is not None else None, - 'reply_to_msg_id': message.reply_to.reply_to_msg_id if message.is_reply and message.reply_to else None, - 'message_id': message.id, - 'is_reply': message.is_reply, + 'raw': entity.raw_text, + 'group_name': entity.group_name, + 'group_id': entity.group_id, + 'from_id': entity.from_id, + 'to_id': entity.to_id, + 'reply_to_msg_id': entity.reply_to_msg_id, + 'message_id': entity.message_id, + 'is_reply': entity.is_reply, } - if hasattr(message, 'file') and message.file: + if entity.downloaded_media_info: content['has_media'] = True - content['media_mime_type'] = message.file.mime_type if hasattr(message.file, 'mime_type') else None - content['media_size'] = message.file.size if hasattr(message.file, 'size') else None + content['media_mime_type'] = entity.downloaded_media_info.content_type + content['media_size'] = entity.downloaded_media_info.size_bytes else: content['has_media'] = False content['media_mime_type'] = None @@ -64,6 +66,6 @@ async def run(self, message: NewMessage.Event, rule_id: str, source: str) -> Non await self.client.index( index=self.index, pipeline=self.pipeline, - id=f'{message.chat.id}_{message.id}', + id=f'{str(entity.group_id)}_{str(entity.message_id)}', document=content, ) diff --git a/TEx/notifier/notifier_base.py b/TEx/notifier/notifier_base.py index f01bc07..f557e11 100644 --- a/TEx/notifier/notifier_base.py +++ b/TEx/notifier/notifier_base.py @@ -7,7 +7,8 @@ from typing import Optional, Tuple from cachetools import TTLCache -from telethon.events import NewMessage + +from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity class BaseNotifier: @@ -38,5 +39,5 @@ def check_is_duplicated(self, message: str) -> Tuple[bool, str]: return False, tag @abc.abstractmethod - async def run(self, message: NewMessage.Event, rule_id: str, source: str) -> None: + async def run(self, entity: FinderNotificationMessageEntity, rule_id: str, source: str) -> None: """Run the Notification Process.""" diff --git a/TEx/notifier/notifier_engine.py b/TEx/notifier/notifier_engine.py index 5d557be..f4308bd 100644 --- a/TEx/notifier/notifier_engine.py +++ b/TEx/notifier/notifier_engine.py @@ -4,8 +4,7 @@ from configparser import ConfigParser from typing import Dict, List -from telethon.events import NewMessage - +from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity from TEx.notifier.discord_notifier import DiscordNotifier from TEx.notifier.elastic_search_notifier import ElasticSearchNotifier from TEx.notifier.notifier_base import BaseNotifier @@ -44,7 +43,7 @@ def configure(self, config: ConfigParser) -> None: """Configure Finder.""" self.__load_notifiers(config) - async def run(self, notifiers: List[str], message: NewMessage.Event, rule_id: str, source: str) -> None: + async def run(self, notifiers: List[str], entity: FinderNotificationMessageEntity, rule_id: str, source: str) -> None: """Dispatch all Notifications. :param notifiers: @@ -59,4 +58,4 @@ async def run(self, notifiers: List[str], message: NewMessage.Event, rule_id: st for dispatcher_name in notifiers: target_notifier: BaseNotifier = self.notifiers[dispatcher_name]['instance'] - await target_notifier.run(message=message, rule_id=rule_id, source=source) + await target_notifier.run(entity=entity, rule_id=rule_id, source=source) diff --git a/mypy.ini b/mypy.ini index 366e6ef..e40e72d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -plugins = sqlalchemy.ext.mypy.plugin +plugins = sqlalchemy.ext.mypy.plugin, pydantic.mypy ignore_missing_imports = True @@ -24,7 +24,11 @@ warn_unreachable = True namespace_packages = True -follow_imports = skip +follow_imports = normal files = TEx/**/*.py +[pydantic-mypy] +init_forbid_extra = True +init_typed = True +warn_required_dynamic_aliases = True diff --git a/pyproject.toml b/pyproject.toml index 66c090c..a68c134 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ types-aiofiles = "23.2.0.0" python-socks = "2.4.3" async-timeout = "4.0.3" elasticsearch = {extras = ["async"], version = "8.10.0"} +pydantic = "2.4.2" [tool.poetry.dev-dependencies] pytest = ">=7.4.0" diff --git a/tests/finder/test_finder_engine.py b/tests/finder/test_finder_engine.py index 39f9e3f..273f5ee 100644 --- a/tests/finder/test_finder_engine.py +++ b/tests/finder/test_finder_engine.py @@ -6,7 +6,9 @@ from unittest import mock from unittest.mock import ANY, call +from TEx.core.mapper.telethon_message_mapper import TelethonMessageEntityMapper from TEx.finder.finder_engine import FinderEngine +from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity from tests.modules.common import TestsCommon from tests.modules.mockups_groups_mockup_data import channel_1_mocked @@ -23,8 +25,18 @@ def test_run_with_regex_finder(self): # Setup Mock notifier_engine_mock = mock.AsyncMock() - target_message = mock.MagicMock() - target_message.raw_text = "Mocked term3 Raw Text" + message_entity: FinderNotificationMessageEntity = FinderNotificationMessageEntity( + date_time=datetime.datetime.utcnow(), + raw_text="Mocked term3 Raw Text", + group_name="Group 001", + group_id=123456, + from_id="1234", + to_id=9876, + reply_to_msg_id=5544, + message_id=969696, + is_reply=False, + downloaded_media_info=None, + ) args: Dict = { 'config': 'unittest_configfile.config' @@ -46,7 +58,7 @@ def test_run_with_regex_finder(self): # Invoke Test Target target.run( - message=target_message, + entity=message_entity, source='+15558987453' ) ) @@ -54,7 +66,7 @@ def test_run_with_regex_finder(self): # Check if Webhook was Executed target.notification_engine.run.assert_awaited_once_with( notifiers=['NOTIFIER.DISCORD.NOT_002'], - message=target_message, + entity=message_entity, rule_id='FINDER.RULE.UT_Finder_Demo', source='+15558987453' ) diff --git a/tests/modules/mockups_groups_mockup_data.py b/tests/modules/mockups_groups_mockup_data.py index d69b408..36066a9 100644 --- a/tests/modules/mockups_groups_mockup_data.py +++ b/tests/modules/mockups_groups_mockup_data.py @@ -4,7 +4,6 @@ from telethon.tl.types import Chat, Channel, ChatBannedRights, ChatForbidden, ChatPhoto, Document, DocumentAttributeFilename, \ DocumentAttributeImageSize, DocumentAttributeSticker, DocumentAttributeVideo, InputStickerSetID, \ KeyboardButtonCallback, KeyboardButtonRow, \ - Message, \ MessageEntityBold, MessageEntityBotCommand, MessageEntityCode, MessageEntityMention, MessageEntityMentionName, \ MessageEntityUrl, \ MessageFwdHeader, \ @@ -12,6 +11,7 @@ MessageMediaPhoto, MessageMediaWebPage, MessageReplies, MessageReplyHeader, PeerChannel, \ PeerUser, Photo, PhotoPathSize, PhotoSize, PhotoStrippedSize, ReplyInlineMarkup, RestrictionReason, User, \ UserProfilePhoto, UserStatusOffline, UserStatusRecently, WebPage +from telethon.tl.patched import Message from telethon.tl.types.messages import Dialogs b64_group_pic_image = 'data:image/jpeg;base64,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' diff --git a/tests/modules/test_telegram_messages_listener.py b/tests/modules/test_telegram_messages_listener.py index 2e02863..6249de7 100644 --- a/tests/modules/test_telegram_messages_listener.py +++ b/tests/modules/test_telegram_messages_listener.py @@ -113,6 +113,7 @@ async def async_generator_side_effect(items): mocked_event = mock.AsyncMock() mocked_event.chat = mocked_channel mocked_event.get_chat = mock.AsyncMock(return_value=mocked_channel) + message.get_chat = mock.AsyncMock(return_value=mocked_channel) if message.from_id: mocked_event.from_id = mock.MagicMock() diff --git a/tests/notifier/test_discord_notifier.py b/tests/notifier/test_discord_notifier.py index 806bc9c..f9f95a3 100644 --- a/tests/notifier/test_discord_notifier.py +++ b/tests/notifier/test_discord_notifier.py @@ -5,6 +5,7 @@ from typing import Dict from unittest import mock +from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity from TEx.notifier.discord_notifier import DiscordNotifier from tests.modules.common import TestsCommon from tests.modules.mockups_groups_mockup_data import channel_1_mocked @@ -23,11 +24,18 @@ def test_run_no_duplication(self): discord_webhook_mock = mock.AsyncMock() discord_webhook_mock.add_embed = mock.MagicMock() - target_message = mock.MagicMock() - target_message.raw_text = "Mocked Raw Text" - target_message.id = 5975883 - target_message.data = datetime.datetime(2023, 10, 1, 9, 58, 22) - target_message.chat = channel_1_mocked + message_entity: FinderNotificationMessageEntity = FinderNotificationMessageEntity( + date_time=datetime.datetime(2023, 10, 1, 9, 58, 22), + raw_text="Mocked Raw Text", + group_name="Channel 1972142108", + group_id=1972142108, + from_id="1234", + to_id=9876, + reply_to_msg_id=5544, + message_id=5975883, + is_reply=False, + downloaded_media_info=None, + ) target: DiscordNotifier = DiscordNotifier() args: Dict = { @@ -48,7 +56,7 @@ def test_run_no_duplication(self): # Invoke Test Target target.run( - message=target_message, + entity=message_entity, rule_id='RULE_UT_01', source='+15558987453' ) @@ -66,7 +74,7 @@ def test_run_no_duplication(self): self.assertEqual(call_arg.fields[1], {'inline': True, 'name': 'Rule', 'value': 'RULE_UT_01'}) self.assertEqual(call_arg.fields[2], {'inline': False, 'name': 'Message ID', 'value': '5975883'}) self.assertEqual(call_arg.fields[3], {'inline': True, 'name': 'Group Name', 'value': 'Channel 1972142108'}) - self.assertEqual(call_arg.fields[4], {'inline': True, 'name': 'Group ID', 'value': 1972142108}) + self.assertEqual(call_arg.fields[4], {'inline': True, 'name': 'Group ID', 'value': '1972142108'}) self.assertEqual(call_arg.fields[6], {'inline': False, 'name': 'Tag', 'value': 'de33f5dda9c686c64d23b8aec2eebfc7'}) # Check if Webhook was Executed @@ -79,11 +87,18 @@ def test_run_duplication_control(self): discord_webhook_mock = mock.AsyncMock() discord_webhook_mock.add_embed = mock.MagicMock() - target_message = mock.MagicMock() - target_message.raw_text = "Mocked Raw Text 2" - target_message.id = 5975883 - target_message.data = datetime.datetime(2023, 10, 1, 9, 58, 22) - target_message.chat = channel_1_mocked + message_entity: FinderNotificationMessageEntity = FinderNotificationMessageEntity( + date_time=datetime.datetime(2023, 10, 1, 9, 58, 22), + raw_text="Mocked Raw Text 2", + group_name="Channel 1972142108", + group_id=1972142108, + from_id="1234", + to_id=9876, + reply_to_msg_id=5544, + message_id=5975883, + is_reply=False, + downloaded_media_info=None, + ) target: DiscordNotifier = DiscordNotifier() args: Dict = { @@ -104,7 +119,7 @@ def test_run_duplication_control(self): # Invoke Test Target target.run( - message=target_message, + entity=message_entity, rule_id='RULE_UT_01', source='+15558987453' ) @@ -114,7 +129,7 @@ def test_run_duplication_control(self): # Invoke Test Target Again target.run( - message=target_message, + entity=message_entity, rule_id='RULE_UT_01', source='+15558987453' ) diff --git a/tests/notifier/test_elastic_search_notifier.py b/tests/notifier/test_elastic_search_notifier.py index 915e3c5..50bcf24 100644 --- a/tests/notifier/test_elastic_search_notifier.py +++ b/tests/notifier/test_elastic_search_notifier.py @@ -1,69 +1,77 @@ -import asyncio -import datetime -import unittest -from configparser import ConfigParser -from typing import Dict -from unittest import mock - -from telethon.tl.types import Message, MessageFwdHeader, PeerChannel, PeerUser - -from TEx.notifier.discord_notifier import DiscordNotifier -from TEx.notifier.elastic_search_notifier import ElasticSearchNotifier -from tests.modules.common import TestsCommon -from tests.modules.mockups_groups_mockup_data import base_groups_mockup_data, base_messages_mockup_data, \ - channel_1_mocked - - -class ElasticSearchNotifierTest(unittest.TestCase): - - def setUp(self) -> None: - self.config = ConfigParser() - self.config.read('../../config.ini') - - def test_run_without_file(self): - """Test Run Method Without Message File Attachment.""" - - # Setup Mock - elastic_search_api_mock = mock.AsyncMock() - elastic_search_api_mock.index = mock.AsyncMock() - - target: ElasticSearchNotifier = ElasticSearchNotifier() - args: Dict = { - 'config': 'unittest_configfile.config' - } - data: Dict = {} - TestsCommon.execute_basic_pipeline_steps_for_initialization(config=self.config, args=args, data=data) - - # Set Mock Message - origin_mock_message = base_messages_mockup_data[1] - mocked_message = mock.MagicMock(spec=origin_mock_message) - for attr_name in origin_mock_message.__dict__: - setattr(mocked_message, attr_name, getattr(origin_mock_message, attr_name)) - mocked_message.chat = channel_1_mocked - - with mock.patch('TEx.notifier.elastic_search_notifier.AsyncElasticsearch', return_value=elastic_search_api_mock): - # Execute Discord Notifier Configure Method - target.configure( - config=self.config['NOTIFIER.ELASTIC_SEARCH.UT_01'] - ) - - loop = asyncio.get_event_loop() - loop.run_until_complete( - - # Invoke Test Target - target.run( - message=mocked_message, - rule_id='RULE_UT_01', - source='+15558987453' - ) - ) - - # Check .index call - elastic_search_api_mock.index.assert_called_once() - call_arg = elastic_search_api_mock.index.call_args[1] - - self.assertEqual(call_arg['index'], 'test_index_name') - self.assertEqual(call_arg['pipeline'], 'test_pipeline_name') - self.assertEqual(call_arg['id'], '1972142108_183017') - - submited_document = call_arg['document'] +# import asyncio +# import datetime +# import unittest +# from configparser import ConfigParser +# from typing import Dict +# from unittest import mock +# +# from telethon.tl.types import Message, MessageFwdHeader, PeerChannel, PeerUser +# +# from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity +# from TEx.notifier.discord_notifier import DiscordNotifier +# from TEx.notifier.elastic_search_notifier import ElasticSearchNotifier +# from tests.modules.common import TestsCommon +# from tests.modules.mockups_groups_mockup_data import base_groups_mockup_data, base_messages_mockup_data, \ +# channel_1_mocked +# +# +# class ElasticSearchNotifierTest(unittest.TestCase): +# +# def setUp(self) -> None: +# self.config = ConfigParser() +# self.config.read('../../config.ini') +# +# def test_run_without_file(self): +# """Test Run Method Without Message File Attachment.""" +# +# # Setup Mock +# elastic_search_api_mock = mock.AsyncMock() +# elastic_search_api_mock.index = mock.AsyncMock() +# +# target: ElasticSearchNotifier = ElasticSearchNotifier() +# args: Dict = { +# 'config': 'unittest_configfile.config' +# } +# data: Dict = {} +# TestsCommon.execute_basic_pipeline_steps_for_initialization(config=self.config, args=args, data=data) +# +# # Set Message +# message_entity: FinderNotificationMessageEntity = FinderNotificationMessageEntity( +# date_time=datetime.datetime(2023, 10, 1, 9, 58, 22), +# raw_text="Mocked Raw Text", +# group_name="Channel 1972142108", +# group_id=1972142108, +# from_id="1234", +# to_id=9876, +# reply_to_msg_id=5544, +# message_id=5975883, +# is_reply=False, +# downloaded_media_info=None, +# ) +# +# with mock.patch('TEx.notifier.elastic_search_notifier.AsyncElasticsearch', return_value=elastic_search_api_mock): +# # Execute Discord Notifier Configure Method +# target.configure( +# config=self.config['NOTIFIER.ELASTIC_SEARCH.UT_01'] +# ) +# +# loop = asyncio.get_event_loop() +# loop.run_until_complete( +# +# # Invoke Test Target +# target.run( +# message=mocked_message, +# rule_id='RULE_UT_01', +# source='+15558987453' +# ) +# ) +# +# # Check .index call +# elastic_search_api_mock.index.assert_called_once() +# call_arg = elastic_search_api_mock.index.call_args[1] +# +# self.assertEqual(call_arg['index'], 'test_index_name') +# self.assertEqual(call_arg['pipeline'], 'test_pipeline_name') +# self.assertEqual(call_arg['id'], '1972142108_183017') +# +# submited_document = call_arg['document'] diff --git a/tests/notifier/test_notifier_engine.py b/tests/notifier/test_notifier_engine.py index 4da79cf..81549fc 100644 --- a/tests/notifier/test_notifier_engine.py +++ b/tests/notifier/test_notifier_engine.py @@ -1,10 +1,12 @@ import asyncio import unittest from configparser import ConfigParser +from datetime import datetime from typing import Dict from unittest import mock from unittest.mock import call +from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity from TEx.notifier.notifier_engine import NotifierEngine from tests.modules.common import TestsCommon from tests.modules.mockups_groups_mockup_data import base_messages_mockup_data @@ -23,6 +25,9 @@ def test_run(self): discord_notifier_mockup = mock.AsyncMock() discord_notifier_mockup.run = mock.AsyncMock() + elastic_notifier_mockup = mock.AsyncMock() + elastic_notifier_mockup.run = mock.AsyncMock() + target: NotifierEngine = NotifierEngine() args: Dict = { 'export_text': True, @@ -37,19 +42,38 @@ def test_run(self): data: Dict = {} TestsCommon.execute_basic_pipeline_steps_for_initialization(config=self.config, args=args, data=data) + # Set Message + message_entity: FinderNotificationMessageEntity = FinderNotificationMessageEntity( + date_time=datetime(2023, 10, 1, 9, 58, 22), + raw_text="Mocked Raw Text", + group_name="Channel 1972142108", + group_id=1972142108, + from_id="1234", + to_id=9876, + reply_to_msg_id=5544, + message_id=55, + is_reply=False, + downloaded_media_info=None, + ) + with mock.patch('TEx.notifier.notifier_engine.DiscordNotifier', return_value=discord_notifier_mockup): - target.configure(config=self.config) - loop = asyncio.get_event_loop() - loop.run_until_complete( - target.run( - notifiers=['NOTIFIER.DISCORD.NOT_001', 'NOTIFIER.DISCORD.NOT_002'], - message=base_messages_mockup_data[0], - rule_id='RULE_UT_01', - source='+15558987453' + with mock.patch('TEx.notifier.notifier_engine.ElasticSearchNotifier', return_value=elastic_notifier_mockup): + target.configure(config=self.config) + loop = asyncio.get_event_loop() + loop.run_until_complete( + target.run( + notifiers=['NOTIFIER.DISCORD.NOT_001', 'NOTIFIER.DISCORD.NOT_002', 'NOTIFIER.ELASTIC_SEARCH.UT_01'], + entity=message_entity, + rule_id='RULE_UT_01', + source='+15558987453' + ) ) - ) - discord_notifier_mockup.run.assert_has_awaits([ - call(message=base_messages_mockup_data[0], rule_id='RULE_UT_01', source='+15558987453'), - call(message=base_messages_mockup_data[0], rule_id='RULE_UT_01', source='+15558987453'), - ]) + discord_notifier_mockup.run.assert_has_awaits([ + call(entity=message_entity, rule_id='RULE_UT_01', source='+15558987453'), + call(entity=message_entity, rule_id='RULE_UT_01', source='+15558987453') + ]) + + elastic_notifier_mockup.run.assert_has_awaits([ + call(entity=message_entity, rule_id='RULE_UT_01', source='+15558987453') + ]) From 2ee8ee86fc843d5e074178fae999f71b01346e95 Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Fri, 20 Oct 2023 08:26:23 -0300 Subject: [PATCH 09/11] Added Unittests for Elastic Search Connector Signed-off-by: Guilherme Bacellar Moralez --- .../media_metadata_handling/geo_handler.py | 2 +- .../media_metadata_handling/mp4_handler.py | 2 +- .../media_metadata_handling/pdf_handler.py | 5 +- .../media_metadata_handling/photo_handler.py | 2 +- .../sticker_handler.py | 6 +- .../media_metadata_handling/text_handler.py | 2 +- .../webimage_handler.py | 13 +- .../notifier/test_elastic_search_notifier.py | 311 +++++++++++++----- 8 files changed, 246 insertions(+), 97 deletions(-) diff --git a/TEx/core/media_metadata_handling/geo_handler.py b/TEx/core/media_metadata_handling/geo_handler.py index 538e9db..1b3e59a 100644 --- a/TEx/core/media_metadata_handling/geo_handler.py +++ b/TEx/core/media_metadata_handling/geo_handler.py @@ -29,4 +29,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': None, 'title': f'{geo.lat}|{geo.long}', 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/mp4_handler.py b/TEx/core/media_metadata_handling/mp4_handler.py index 1a30236..ea23371 100644 --- a/TEx/core/media_metadata_handling/mp4_handler.py +++ b/TEx/core/media_metadata_handling/mp4_handler.py @@ -28,4 +28,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/pdf_handler.py b/TEx/core/media_metadata_handling/pdf_handler.py index f9d970f..efd1afb 100644 --- a/TEx/core/media_metadata_handling/pdf_handler.py +++ b/TEx/core/media_metadata_handling/pdf_handler.py @@ -16,8 +16,7 @@ def handle_metadata(message: Message) -> Optional[Dict]: media: MessageMediaPhoto = message.media return { - 'file_name': [item for item in media.document.attributes if isinstance(item, DocumentAttributeFilename)][ - 0].file_name, + 'file_name': [item for item in media.document.attributes if isinstance(item, DocumentAttributeFilename)][0].file_name, 'telegram_id': media.document.id, 'extension': None, 'height': None, @@ -27,4 +26,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/photo_handler.py b/TEx/core/media_metadata_handling/photo_handler.py index 50f0482..4dfe0cc 100644 --- a/TEx/core/media_metadata_handling/photo_handler.py +++ b/TEx/core/media_metadata_handling/photo_handler.py @@ -26,4 +26,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': message.file.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/sticker_handler.py b/TEx/core/media_metadata_handling/sticker_handler.py index 8cc39ee..cdf98ca 100644 --- a/TEx/core/media_metadata_handling/sticker_handler.py +++ b/TEx/core/media_metadata_handling/sticker_handler.py @@ -17,9 +17,7 @@ def handle_metadata(message: Message) -> Optional[Dict]: fn_attr_img: List = [item for item in media.document.attributes if isinstance(item, DocumentAttributeImageSize)] return { - 'file_name': - [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeFilename)][ - 0].file_name, + 'file_name': [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeFilename)][0].file_name, 'telegram_id': media.document.id, 'extension': None, 'height': fn_attr_img[0].h if len(fn_attr_img) > 0 else None, @@ -29,4 +27,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/text_handler.py b/TEx/core/media_metadata_handling/text_handler.py index 3297097..0f0a2ed 100644 --- a/TEx/core/media_metadata_handling/text_handler.py +++ b/TEx/core/media_metadata_handling/text_handler.py @@ -27,4 +27,4 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/TEx/core/media_metadata_handling/webimage_handler.py b/TEx/core/media_metadata_handling/webimage_handler.py index 48e38b6..07c9e05 100644 --- a/TEx/core/media_metadata_handling/webimage_handler.py +++ b/TEx/core/media_metadata_handling/webimage_handler.py @@ -15,8 +15,7 @@ def handle_metadata(message: Message) -> Optional[Dict]: """Handle Media Metadata.""" media: MessageMediaDocument = message.media - fn_attr: List = [item for item in message.media.document.attributes if - isinstance(item, DocumentAttributeFilename)] + fn_attr: List = [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeFilename)] if not fn_attr or len(fn_attr) == 0: return None @@ -25,15 +24,11 @@ def handle_metadata(message: Message) -> Optional[Dict]: 'file_name': fn_attr[0].file_name, 'telegram_id': media.document.id, 'extension': None, - 'height': - [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeImageSize)][ - 0].h, - 'width': - [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeImageSize)][ - 0].w, + 'height': [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeImageSize)][0].h, + 'width': [item for item in message.media.document.attributes if isinstance(item, DocumentAttributeImageSize)][0].w, 'date_time': media.document.date, 'mime_type': media.document.mime_type, 'size_bytes': media.document.size, 'title': None, 'name': None, - } + } diff --git a/tests/notifier/test_elastic_search_notifier.py b/tests/notifier/test_elastic_search_notifier.py index 50bcf24..6c9e8a0 100644 --- a/tests/notifier/test_elastic_search_notifier.py +++ b/tests/notifier/test_elastic_search_notifier.py @@ -1,77 +1,234 @@ -# import asyncio -# import datetime -# import unittest -# from configparser import ConfigParser -# from typing import Dict -# from unittest import mock -# -# from telethon.tl.types import Message, MessageFwdHeader, PeerChannel, PeerUser -# -# from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity -# from TEx.notifier.discord_notifier import DiscordNotifier -# from TEx.notifier.elastic_search_notifier import ElasticSearchNotifier -# from tests.modules.common import TestsCommon -# from tests.modules.mockups_groups_mockup_data import base_groups_mockup_data, base_messages_mockup_data, \ -# channel_1_mocked -# -# -# class ElasticSearchNotifierTest(unittest.TestCase): -# -# def setUp(self) -> None: -# self.config = ConfigParser() -# self.config.read('../../config.ini') -# -# def test_run_without_file(self): -# """Test Run Method Without Message File Attachment.""" -# -# # Setup Mock -# elastic_search_api_mock = mock.AsyncMock() -# elastic_search_api_mock.index = mock.AsyncMock() -# -# target: ElasticSearchNotifier = ElasticSearchNotifier() -# args: Dict = { -# 'config': 'unittest_configfile.config' -# } -# data: Dict = {} -# TestsCommon.execute_basic_pipeline_steps_for_initialization(config=self.config, args=args, data=data) -# -# # Set Message -# message_entity: FinderNotificationMessageEntity = FinderNotificationMessageEntity( -# date_time=datetime.datetime(2023, 10, 1, 9, 58, 22), -# raw_text="Mocked Raw Text", -# group_name="Channel 1972142108", -# group_id=1972142108, -# from_id="1234", -# to_id=9876, -# reply_to_msg_id=5544, -# message_id=5975883, -# is_reply=False, -# downloaded_media_info=None, -# ) -# -# with mock.patch('TEx.notifier.elastic_search_notifier.AsyncElasticsearch', return_value=elastic_search_api_mock): -# # Execute Discord Notifier Configure Method -# target.configure( -# config=self.config['NOTIFIER.ELASTIC_SEARCH.UT_01'] -# ) -# -# loop = asyncio.get_event_loop() -# loop.run_until_complete( -# -# # Invoke Test Target -# target.run( -# message=mocked_message, -# rule_id='RULE_UT_01', -# source='+15558987453' -# ) -# ) -# -# # Check .index call -# elastic_search_api_mock.index.assert_called_once() -# call_arg = elastic_search_api_mock.index.call_args[1] -# -# self.assertEqual(call_arg['index'], 'test_index_name') -# self.assertEqual(call_arg['pipeline'], 'test_pipeline_name') -# self.assertEqual(call_arg['id'], '1972142108_183017') -# -# submited_document = call_arg['document'] +import asyncio +import datetime +import unittest +from configparser import ConfigParser +from typing import Dict +from unittest import mock + +import pytz + +from TEx.models.facade.finder_notification_facade_entity import FinderNotificationMessageEntity +from TEx.models.facade.media_handler_facade_entity import MediaHandlingEntity +from TEx.notifier.elastic_search_notifier import ElasticSearchNotifier +from tests.modules.common import TestsCommon + + +class ElasticSearchNotifierTest(unittest.TestCase): + + def setUp(self) -> None: + self.config = ConfigParser() + self.config.read('../../config.ini') + + def test_configure_with_hosts(self): + """Test configure method with hosts.""" + # Setup Mock + elastic_search_api_mock = mock.MagicMock() + + target: ElasticSearchNotifier = ElasticSearchNotifier() + args: Dict = { + 'config': 'unittest_configfile.config' + } + data: Dict = {} + TestsCommon.execute_basic_pipeline_steps_for_initialization(config=self.config, args=args, data=data) + + # Change Config Map + self.config['NOTIFIER.ELASTIC_SEARCH.UT_01'].clear() + self.config['NOTIFIER.ELASTIC_SEARCH.UT_01']['address'] = 'http://localhost:1,http://localhost:2' + self.config['NOTIFIER.ELASTIC_SEARCH.UT_01']['api_key'] = 'MyApiKey003' + self.config['NOTIFIER.ELASTIC_SEARCH.UT_01']['verify_ssl_cert'] = 'False' + self.config['NOTIFIER.ELASTIC_SEARCH.UT_01']['index_name'] = 'UT_IndexName_004' + self.config['NOTIFIER.ELASTIC_SEARCH.UT_01']['pipeline_name'] = 'UT_PipelineName_005' + + # Set Message + with mock.patch('TEx.notifier.elastic_search_notifier.AsyncElasticsearch', return_value=elastic_search_api_mock) as patched_ctor: + # Execute Discord Notifier Configure Method + target.configure( + config=self.config['NOTIFIER.ELASTIC_SEARCH.UT_01'] + ) + + # Check Call Args + call_arg = patched_ctor.call_args[1] + self.assertEqual('http://localhost:1', call_arg['hosts'][0]) + self.assertEqual('http://localhost:2', call_arg['hosts'][1]) + self.assertEqual('MyApiKey003', call_arg['api_key']) + self.assertEqual(False, call_arg['verify_certs']) + self.assertIsNone(call_arg['cloud_id']) + + def test_configure_with_cloud_id(self): + """Test configure method with Cloud ID Info.""" + # Setup Mock + elastic_search_api_mock = mock.MagicMock() + + target: ElasticSearchNotifier = ElasticSearchNotifier() + args: Dict = { + 'config': 'unittest_configfile.config' + } + data: Dict = {} + TestsCommon.execute_basic_pipeline_steps_for_initialization(config=self.config, args=args, data=data) + + # Change Config Map + self.config['NOTIFIER.ELASTIC_SEARCH.UT_01'].clear() + self.config['NOTIFIER.ELASTIC_SEARCH.UT_01']['cloud_id'] = 'deployment-name:dXMtZWFzdDQuZ2Nw' + self.config['NOTIFIER.ELASTIC_SEARCH.UT_01']['index_name'] = 'UT_IndexName_007' + self.config['NOTIFIER.ELASTIC_SEARCH.UT_01']['pipeline_name'] = 'UT_PipelineName_008' + + # Set Message + with mock.patch('TEx.notifier.elastic_search_notifier.AsyncElasticsearch', + return_value=elastic_search_api_mock) as patched_ctor: + # Execute Discord Notifier Configure Method + target.configure( + config=self.config['NOTIFIER.ELASTIC_SEARCH.UT_01'] + ) + + # Check Call Args + call_arg = patched_ctor.call_args[1] + self.assertEqual('deployment-name:dXMtZWFzdDQuZ2Nw', call_arg['cloud_id']) + self.assertEqual(True, call_arg['verify_certs']) + self.assertIsNone(call_arg['hosts']) + self.assertIsNone(call_arg['api_key']) + + def test_run_without_downloaded_file(self): + """Test Run Method Without Message File Attachment.""" + + # Setup Mock + elastic_search_api_mock = mock.AsyncMock() + elastic_search_api_mock.index = mock.AsyncMock() + + target: ElasticSearchNotifier = ElasticSearchNotifier() + args: Dict = { + 'config': 'unittest_configfile.config' + } + data: Dict = {} + TestsCommon.execute_basic_pipeline_steps_for_initialization(config=self.config, args=args, data=data) + + # Set Message + message_entity: FinderNotificationMessageEntity = FinderNotificationMessageEntity( + date_time=datetime.datetime(2023, 10, 1, 9, 58, 22), + raw_text="Mocked Raw Text", + group_name="Channel 1972142108", + group_id=1972142108, + from_id="1234", + to_id=9876, + reply_to_msg_id=5544, + message_id=5975883, + is_reply=False, + downloaded_media_info=None, + ) + + with mock.patch('TEx.notifier.elastic_search_notifier.AsyncElasticsearch', return_value=elastic_search_api_mock): + # Execute Discord Notifier Configure Method + target.configure( + config=self.config['NOTIFIER.ELASTIC_SEARCH.UT_01'] + ) + + loop = asyncio.get_event_loop() + loop.run_until_complete( + + # Invoke Test Target + target.run( + entity=message_entity, + rule_id='RULE_UT_01', + source='+15558987453' + ) + ) + + # Check .index call + elastic_search_api_mock.index.assert_called_once() + call_arg = elastic_search_api_mock.index.call_args[1] + + self.assertEqual(call_arg['index'], 'test_index_name') + self.assertEqual(call_arg['pipeline'], 'test_pipeline_name') + self.assertEqual(call_arg['id'], '1972142108_5975883') + + submited_document = call_arg['document'] + expected_document = { + 'time': datetime.datetime(2023, 10, 1, 12, 58, 22, tzinfo=pytz.UTC), + 'source': '+15558987453', + 'rule': 'RULE_UT_01', + 'raw': 'Mocked Raw Text', + 'group_name': 'Channel 1972142108', + 'group_id': 1972142108, + 'from_id': 1234, + 'to_id': 9876, + 'reply_to_msg_id': 5544, + 'message_id': 5975883, + 'is_reply': False, + 'has_media': False, + 'media_mime_type': None, + 'media_size': None + } + + self.assertEqual(submited_document, expected_document) + + def test_run_with_downloaded_file(self): + """Test Run Method With Message File Attachment.""" + + # Setup Mock + elastic_search_api_mock = mock.AsyncMock() + elastic_search_api_mock.index = mock.AsyncMock() + + target: ElasticSearchNotifier = ElasticSearchNotifier() + args: Dict = { + 'config': 'unittest_configfile.config' + } + data: Dict = {} + TestsCommon.execute_basic_pipeline_steps_for_initialization(config=self.config, args=args, data=data) + + # Set Message + message_entity: FinderNotificationMessageEntity = FinderNotificationMessageEntity( + date_time=datetime.datetime(2023, 10, 1, 9, 58, 22), + raw_text="Mocked Raw Text 2", + group_name="Channel 1972142101", + group_id=1972142108, + from_id="1234", + to_id=9876, + reply_to_msg_id=5544, + message_id=5975883, + is_reply=False, + downloaded_media_info=MediaHandlingEntity(media_id=99, file_name='utfile.pdf', content_type='application/pdf', size_bytes=5858), + ) + + with mock.patch('TEx.notifier.elastic_search_notifier.AsyncElasticsearch', return_value=elastic_search_api_mock): + # Execute Discord Notifier Configure Method + target.configure( + config=self.config['NOTIFIER.ELASTIC_SEARCH.UT_01'] + ) + + loop = asyncio.get_event_loop() + loop.run_until_complete( + + # Invoke Test Target + target.run( + entity=message_entity, + rule_id='RULE_UT_01', + source='+15558987453' + ) + ) + + # Check .index call + elastic_search_api_mock.index.assert_called_once() + call_arg = elastic_search_api_mock.index.call_args[1] + + self.assertEqual(call_arg['index'], 'test_index_name') + self.assertEqual(call_arg['pipeline'], 'test_pipeline_name') + self.assertEqual(call_arg['id'], '1972142108_5975883') + + submited_document = call_arg['document'] + expected_document = { + 'time': datetime.datetime(2023, 10, 1, 12, 58, 22, tzinfo=pytz.UTC), + 'source': '+15558987453', + 'rule': 'RULE_UT_01', + 'raw': 'Mocked Raw Text 2', + 'group_name': 'Channel 1972142101', + 'group_id': 1972142108, + 'from_id': 1234, + 'to_id': 9876, + 'reply_to_msg_id': 5544, + 'message_id': 5975883, + 'is_reply': False, + 'has_media': True, + 'media_mime_type': 'application/pdf', + 'media_size': 5858 + } + + self.assertEqual(submited_document, expected_document) From da1e56e9fc36434f5e8d196afcc7f1f5c832b417 Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Fri, 20 Oct 2023 08:54:13 -0300 Subject: [PATCH 10/11] Change Documentation Signed-off-by: Guilherme Bacellar Moralez --- docs/changelog/v030.md | 6 +- .../complete_configuration_file_example.md | 15 ++- docs/finder/finder_catchall.md | 24 ++++ docs/{ => finder}/finder_regex.md | 0 .../notification_discord.md | 0 .../notification_elasticsearch.md | 45 ++++++++ ...tification_elasticsearch_index_template.md | 107 ++++++++++++++++++ mkdocs.yml | 8 +- 8 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 docs/finder/finder_catchall.md rename docs/{ => finder}/finder_regex.md (100%) rename docs/{ => notification}/notification_discord.md (100%) create mode 100644 docs/notification/notification_elasticsearch.md create mode 100644 docs/notification/notification_elasticsearch_index_template.md diff --git a/docs/changelog/v030.md b/docs/changelog/v030.md index 43cb47f..759e67a 100644 --- a/docs/changelog/v030.md +++ b/docs/changelog/v030.md @@ -4,6 +4,8 @@ - Proxy (HTTP, SOCKS4, SOCKS5) support ([#26](https://github.com/guibacellar/TEx/issues/26)) - Discord Notifications now have a source information with account/phone number +- New Message Finder Rule to Catch All Messages +- New Notification connector for Elastic Search ([#12](https://github.com/guibacellar/TEx/issues/12)) **🐛 Bug Fixes** @@ -11,4 +13,6 @@ **⚙️ Internal Improvements** -- Replace Pylint, PyDocStyle and Flake8 code quality tools for Ruff ([#22](https://github.com/guibacellar/TEx/issues/22)) \ No newline at end of file +- Replace Pylint, PyDocStyle and Flake8 code quality tools for Ruff ([#22](https://github.com/guibacellar/TEx/issues/22)) +- Fix Invalid TypeHint for Message Object from Telethon +- Changes in message finder and notification system to use a facade objects with Pydantic to reduce cognitive complexity and allow the construction of new connectors more easily diff --git a/docs/configuration/complete_configuration_file_example.md b/docs/configuration/complete_configuration_file_example.md index 7ea68ad..62c095f 100644 --- a/docs/configuration/complete_configuration_file_example.md +++ b/docs/configuration/complete_configuration_file_example.md @@ -1,6 +1,6 @@ # Complete Configuration File Example -This is an example of a complete configuration file with three finder rules using two discord hooks. +This is an example of a complete configuration file with four finder rules using two discord hooks and one elastic search connector. ```ini [CONFIGURATION] @@ -29,13 +29,17 @@ notifier=NOTIFIER.DISCORD.MY_HOOK_1 [FINDER.RULE.FindMessagesWithCreditCard] type=regex regex=(^4[0-9]{12}(?:[0-9]{3})?$)|(^(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}$)|(3[47][0-9]{13})|(^3(?:0[0-5]|[68][0-9])[0-9]{11}$)|(^6(?:011|5[0-9]{2})[0-9]{12}$)|(^(?:2131|1800|35\d{3})\d{11}$) -notifier=NOTIFIER.DISCORD.MY_HOOK_2 +notifier=NOTIFIER.DISCORD.MY_HOOK_2,NOTIFIER.ELASTIC_SEARCH.GENERAL [FINDER.RULE.FindMessagesWithEmail] type=regex regex=^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$ notifier=NOTIFIER.DISCORD.MY_HOOK_1,NOTIFIER.DISCORD.MY_HOOK_2 +[FINDER.RULE.CatchAll] +type=all +notifier=NOTIFIER.ELASTIC_SEARCH.GENERAL + [NOTIFIER.DISCORD.MY_HOOK_1] webhook=https://discord.com/api/webhooks/1157896186751897357/o7foobar4txvAvKSdeadHiI-9XYeXaGlQtd-5PtrrX_eCE0XElWktpPqjrZ0KbeefPtQC prevent_duplication_for_minutes=240 @@ -43,4 +47,11 @@ prevent_duplication_for_minutes=240 [NOTIFIER.DISCORD.MY_HOOK_2] webhook=https://discord.com/api/webhooks/1128765187657681875/foobarqOMFp_4tM2ic2mbeefNPOZqJnBZZdfaubQv2vJgbYzfdeadZd5aqGX6FmCmbNjX prevent_duplication_for_minutes=240 + +[NOTIFIER.ELASTIC_SEARCH.GENERAL] +address=https://localhost:9200 +api_key=bHJtVEg0c0JnNkwwTnYtFFDEADlo6NS1rXzd6NVFSUmEtQ21mQldiUjEwUQ== +verify_ssl_cert=False +index_name=index-name +pipeline_name=ent-search-generic-ingestion ``` diff --git a/docs/finder/finder_catchall.md b/docs/finder/finder_catchall.md new file mode 100644 index 0000000..3302ab0 --- /dev/null +++ b/docs/finder/finder_catchall.md @@ -0,0 +1,24 @@ +# Message Finder System - Catch All Messages + +**Compatibility:** Message Listener Command + +Telegram Explorer allows to catch all messages and redirect to one or more notifications connectior. + +**Configuration Spec:** + +For each rule to be used, you must set a configuration using the default name schema *FINDER.RULE.* + +**Parameters:** + + * **type** > Required - Fixed Value 'all' + * **notifier** > Required - Name of notifiers to be used to notify the triggered message (comma separated). + +**Changes on Configuration File** +```ini +[FINDER] +enabled=true + +[FINDER.RULE.CatchAll] +type=all +notifier=NOTIFIER.ELASTIC_SEARCH.GENERAL +``` \ No newline at end of file diff --git a/docs/finder_regex.md b/docs/finder/finder_regex.md similarity index 100% rename from docs/finder_regex.md rename to docs/finder/finder_regex.md diff --git a/docs/notification_discord.md b/docs/notification/notification_discord.md similarity index 100% rename from docs/notification_discord.md rename to docs/notification/notification_discord.md diff --git a/docs/notification/notification_elasticsearch.md b/docs/notification/notification_elasticsearch.md new file mode 100644 index 0000000..733a21c --- /dev/null +++ b/docs/notification/notification_elasticsearch.md @@ -0,0 +1,45 @@ +# Notification System - Elastic Search Connector + +Telegram Explorer allows to send notifications to Elastic Search through ingestion API. + +Every Notification is defined in the configuration files. + +!!! info "Elastic Search Compatibility" + + Tested on Elastic Search 8+ + +!!! warning "Index Template" + + If you want, and we recommend, create a new Index Template before create your indexes. Please, check on "Notification System" > "Elastic Search Connector" > "Index Template" for more information. + +**Configuration Spec:** + +For each connector you must set a configuration using the default name schema *NOTIFIER.ELASTIC_SEARCH.* + +**Parameters:** + + * **address** > Optional - Elastic Search Address. Multiple values comma separated. + * **api_key** > Required - Elastic Search API Key. + * **cloud_id** > Optional - Elastic Search Cloud ID. + * **verify_ssl_cert** > Optional - Configure if the connector checks the SSL cert. Default=True + * **index_name** > Required - Elastic Search Index Name. + * **pipeline_name** > Required - Elastic Search Ingestion Pipeline Name. + + +**Changes on Configuration File (with Address)** +```ini +address=https://elastic_search_url_1:9200,https://elastic_search_url_2:9200 +api_key=bHJtVEg0c0JnNkwwTnYtYTFdeadbeefrXzd6NVFSUmEtQ21mQldiUjEwUQ== +verify_ssl_cert=False +index_name=search-telegram_explorer +pipeline_name=ent-search-generic-ingestion +``` + +**Changes on Configuration File (with Cloud ID)** +```ini +cloud_id=deployment-name:dXMtZWFzdDQuZ2Nw +api_key=bHJtVEg0c0JnNkwwTnYtYTFdeadbeefrXzd6NVFSUmEtQ21mQldiUjEwUQ== +verify_ssl_cert=True +index_name=search-telegram_explorer +pipeline_name=ent-search-generic-ingestion +``` diff --git a/docs/notification/notification_elasticsearch_index_template.md b/docs/notification/notification_elasticsearch_index_template.md new file mode 100644 index 0000000..702b8fe --- /dev/null +++ b/docs/notification/notification_elasticsearch_index_template.md @@ -0,0 +1,107 @@ +# Notification System - Elastic Search Connector - Index Template + +If you want, create a new Index Template before create all Telegram Explorer indexes. + +This will help you to get the best of all data provided and allow's to extract many more value and informations from the data. + +**Index Template JSON** +```json +{ + "settings": { + "index": { + "routing": { + "allocation": { + "include": { + "_tier_preference": "data_content" + } + } + } + } + }, + "mappings": { + "dynamic": "true", + "dynamic_date_formats": [ + "strict_date_optional_time", + "yyyy/MM/dd HH:mm:ss Z||yyyy/MM/dd Z" + ], + "dynamic_templates": [], + "date_detection": true, + "numeric_detection": false, + "properties": { + "from_id": { + "type": "long" + }, + "group_id": { + "type": "long" + }, + "group_name": { + "type": "text", + "fielddata": true, + "fielddata_frequency_filter": { + "min": 0.01, + "max": 1, + "min_segment_size": 50 + } + }, + "has_media": { + "type": "boolean" + }, + "is_reply": { + "type": "boolean" + }, + "media_mime_type": { + "type": "text", + "fielddata": true, + "fielddata_frequency_filter": { + "min": 0.01, + "max": 1, + "min_segment_size": 50 + } + }, + "media_size": { + "type": "long" + }, + "message_id": { + "type": "text" + }, + "raw": { + "type": "text", + "fielddata": true, + "fielddata_frequency_filter": { + "min": 0.01, + "max": 1, + "min_segment_size": 50 + } + }, + "reply_to_msg_id": { + "type": "long" + }, + "rule": { + "type": "text", + "fielddata": true, + "fielddata_frequency_filter": { + "min": 0.01, + "max": 1, + "min_segment_size": 50 + } + }, + "source": { + "type": "text", + "fielddata": true, + "fielddata_frequency_filter": { + "min": 0.01, + "max": 1, + "min_segment_size": 50 + } + }, + "time": { + "type": "date" + }, + "to_id": { + "type": "long" + } + } + }, + "aliases": {} +} +``` diff --git a/mkdocs.yml b/mkdocs.yml index 0f3fc15..89bba85 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,9 +31,13 @@ nav: - 'Listen Messages': 'how_use/usage_message_listener.md' - 'Download Messages': 'how_use/usage_download_messages.md' - 'Notification System': - - 'Discord Notification Hook': 'notification_discord.md' + - 'Discord Notification Hook': 'notification/notification_discord.md' + - 'Elastic Search Connector': + - 'Configuration': 'notification/notification_elasticsearch.md' + - 'Index Template': 'notification/notification_elasticsearch_index_template.md' - 'Message Finder System': - - 'RegEx Finder': 'finder_regex.md' + - 'Catch All': 'finder/finder_catchall.md' + - 'RegEx Finder': 'finder/finder_regex.md' - 'Reports': - 'Export Files': 'report/report_export_files.md' - 'HTML Report': 'report/report_html.md' From 6b7094a7a99378bee29f0a65cbd48d7fcb146e6a Mon Sep 17 00:00:00 2001 From: Guilherme Bacellar Moralez Date: Fri, 20 Oct 2023 09:02:27 -0300 Subject: [PATCH 11/11] Added UTC Timezone on Tests that fails on UTC Machines but works on GTM-3 machines. Signed-off-by: Guilherme Bacellar Moralez --- tests/notifier/test_elastic_search_notifier.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/notifier/test_elastic_search_notifier.py b/tests/notifier/test_elastic_search_notifier.py index 6c9e8a0..7dc3077 100644 --- a/tests/notifier/test_elastic_search_notifier.py +++ b/tests/notifier/test_elastic_search_notifier.py @@ -103,7 +103,7 @@ def test_run_without_downloaded_file(self): # Set Message message_entity: FinderNotificationMessageEntity = FinderNotificationMessageEntity( - date_time=datetime.datetime(2023, 10, 1, 9, 58, 22), + date_time=datetime.datetime(2023, 10, 1, 9, 58, 22, tzinfo=pytz.UTC), raw_text="Mocked Raw Text", group_name="Channel 1972142108", group_id=1972142108, @@ -142,7 +142,7 @@ def test_run_without_downloaded_file(self): submited_document = call_arg['document'] expected_document = { - 'time': datetime.datetime(2023, 10, 1, 12, 58, 22, tzinfo=pytz.UTC), + 'time': datetime.datetime(2023, 10, 1, 9, 58, 22, tzinfo=pytz.UTC), 'source': '+15558987453', 'rule': 'RULE_UT_01', 'raw': 'Mocked Raw Text', @@ -176,7 +176,7 @@ def test_run_with_downloaded_file(self): # Set Message message_entity: FinderNotificationMessageEntity = FinderNotificationMessageEntity( - date_time=datetime.datetime(2023, 10, 1, 9, 58, 22), + date_time=datetime.datetime(2023, 10, 1, 9, 58, 22, tzinfo=pytz.UTC), raw_text="Mocked Raw Text 2", group_name="Channel 1972142101", group_id=1972142108, @@ -215,7 +215,7 @@ def test_run_with_downloaded_file(self): submited_document = call_arg['document'] expected_document = { - 'time': datetime.datetime(2023, 10, 1, 12, 58, 22, tzinfo=pytz.UTC), + 'time': datetime.datetime(2023, 10, 1, 9, 58, 22, tzinfo=pytz.UTC), 'source': '+15558987453', 'rule': 'RULE_UT_01', 'raw': 'Mocked Raw Text 2',