├── README.md ├── message_broker ├── __init__.py └── rabbitmq_service.py ├── telegram_service ├── __init__.py ├── telegram_listener.py └── telegram_sender.py ├── instagram_service ├── __init__.py ├── instagram_listener.py ├── instagram_sender.py └── instagram_handler_service.py ├── redis.conf ├── requirements.txt ├── dockerfile ├── utils └── singleton.py ├── docker-compose.yml ├── config └── config.py ├── run_all.py ├── logger └── log.py └── .gitignore /README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /message_broker/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /telegram_service/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /instagram_service/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /redis.conf: -------------------------------------------------------------------------------- 1 | appendonly yes 2 | appendfsync everysec -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/revisto/instagrapi.git 2 | python-telegram-bot 3 | pika 4 | redis 5 | python-dotenv 6 | Pillow 7 | loguru -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt . 6 | RUN pip install -r requirements.txt 7 | 8 | COPY . . 9 | 10 | CMD ["python", "run_all.py"] -------------------------------------------------------------------------------- /utils/singleton.py: -------------------------------------------------------------------------------- 1 | class Singleton(type): 2 | _instances = {} 3 | def __call__(cls, *args, **kwargs): 4 | if cls not in cls._instances: 5 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 6 | return cls._instances[cls] -------------------------------------------------------------------------------- /instagram_service/instagram_listener.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from random import randint 3 | 4 | from config.config import Config 5 | from instagram_service.instagram_handler_service import InstagramService 6 | from logger.log import Logger 7 | 8 | logger = Logger("InstagramListener") 9 | instagram_listener = InstagramService(Config) 10 | 11 | logger.log_info("Service started") 12 | 13 | while True: 14 | message = instagram_listener.listen() 15 | sleep_time = randint(0.7 * 60 * 60, 1 * 60 * 60) 16 | logger.log_info(f"Sleeping for {sleep_time} seconds") 17 | sleep(sleep_time) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | services: 3 | app: 4 | build: . 5 | env_file: 6 | - .env 7 | depends_on: 8 | - redis 9 | - rabbitmq 10 | volumes: 11 | - .:/app 12 | - ./config:/app/config 13 | 14 | redis: 15 | image: redis:latest 16 | command: redis-server --requirepass ${REDIS_PASSWORD} 17 | environment: 18 | REDIS_PORT: ${REDIS_PORT} 19 | REDIS_PASSWORD: ${REDIS_PASSWORD} 20 | volumes: 21 | - ./redis-data:/data 22 | - ./redis.conf:/usr/local/etc/redis/redis.conf 23 | 24 | rabbitmq: 25 | image: rabbitmq:3-management 26 | environment: 27 | RABBITMQ_PORT: ${RABBITMQ_PORT} 28 | RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME} 29 | RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} -------------------------------------------------------------------------------- /config/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | class Config: 7 | INSTAGRAM_USERNAME = os.getenv('INSTAGRAM_USERNAME') 8 | INSTAGRAM_PASSWORD = os.getenv('INSTAGRAM_PASSWORD') 9 | INSTAGRAM_TARGET_USERNAME = os.getenv('INSTAGRAM_TARGET_USERNAME') 10 | REDIS_HOST = os.getenv('REDIS_HOST') 11 | REDIS_PORT = int(os.getenv('REDIS_PORT')) 12 | REDIS_PASSWORD = os.getenv('REDIS_PASSWORD') 13 | RABBITMQ_HOST = os.getenv('RABBITMQ_HOST') 14 | RABBITMQ_PORT = int(os.getenv('RABBITMQ_PORT')) 15 | RABBITMQ_USERNAME = os.getenv('RABBITMQ_USERNAME') 16 | RABBITMQ_PASSWORD = os.getenv('RABBITMQ_PASSWORD') 17 | TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') 18 | TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID') 19 | TELEGRAM_LOG_CHAT_ID = os.getenv('TELEGRAM_LOG_CHAT_ID') -------------------------------------------------------------------------------- /run_all.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from time import sleep 4 | 5 | python_files = ["instagram_service/instagram_listener.py", "instagram_service/instagram_sender.py", "telegram_service/telegram_sender.py", "telegram_service/telegram_listener.py"] 6 | 7 | def run_all_main_files(): 8 | processes = [] 9 | try: 10 | for python_file in python_files: 11 | env = os.environ.copy() 12 | env["PYTHONPATH"] = os.getcwd() + ":" + env.get("PYTHONPATH", "") 13 | process = subprocess.Popen(["python", python_file], env=env) 14 | processes.append(process) 15 | while True: # Keep the script running 16 | pass 17 | except KeyboardInterrupt: 18 | print("\nCtrl+C received, terminating all processes") 19 | for process in processes: 20 | process.terminate() 21 | for process in processes: 22 | process.wait() 23 | 24 | if __name__ == "__main__": 25 | sleep(15) # wait for RabbitMQ to start 26 | run_all_main_files() -------------------------------------------------------------------------------- /instagram_service/instagram_sender.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from config.config import Config 4 | from message_broker.rabbitmq_service import RabbitMQService 5 | from instagram_service.instagram_handler_service import InstagramService, ReplyToMessage 6 | from logger.log import Logger 7 | 8 | logger = Logger("InstagramSender") 9 | 10 | def callback(ch, method, properties, body): 11 | logger.log_info("Received a message") 12 | message = json.loads(body) 13 | if message.get("action") == "reply": 14 | reply_to_message = ReplyToMessage(message["id"], message["client_context"]) 15 | InstagramService(Config).reply_in_direct(message["text"], reply_to_message) 16 | logger.log_info(f"Replied to message: {message['text']}") 17 | if message.get("action") == "listen": 18 | logger.log_info("Listening to messages on command") 19 | InstagramService(Config).listen() 20 | 21 | rabbitmq_service = RabbitMQService(Config) 22 | logger.log_info("Service started") 23 | rabbitmq_service.start_consuming({'telegram_to_instagram': callback}) -------------------------------------------------------------------------------- /logger/log.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from loguru import logger 3 | 4 | from utils.singleton import Singleton 5 | from message_broker.rabbitmq_service import RabbitMQService 6 | from config.config import Config 7 | 8 | class Logger(metaclass=Singleton): 9 | def __init__(self, service): 10 | self.service = service 11 | # Configure the logger 12 | logger.add(sys.stderr, format="{time:YYYY-MM-DD HH:mm:ss} - {level} - {extra[service]} - {message}", level="INFO") 13 | logger.add("log.txt", format="{time:YYYY-MM-DD HH:mm:ss} - {level} - {extra[service]} - {message}", level="INFO") 14 | self.logger = logger.bind(service=service) 15 | 16 | def log_and_send(self, level, message): 17 | # Log the message 18 | getattr(self.logger, level)(message) 19 | 20 | # Send the log message to RabbitMQ 21 | self.rabbitmq_service = RabbitMQService(Config) 22 | self.rabbitmq_service.send_message_logs(f"{self.service} - {level.upper()} - {message}") 23 | self.rabbitmq_service.close_connection() 24 | 25 | def log_debug(self, message): 26 | self.log_and_send('debug', message) 27 | 28 | def log_info(self, message): 29 | self.log_and_send('info', message) 30 | 31 | def log_warning(self, message): 32 | self.log_and_send('warning', message) 33 | 34 | def log_error(self, message): 35 | self.log_and_send('error', message) 36 | 37 | def log_critical(self, message): 38 | self.log_and_send('critical', message) -------------------------------------------------------------------------------- /message_broker/rabbitmq_service.py: -------------------------------------------------------------------------------- 1 | import pika 2 | 3 | class RabbitMQService: 4 | def __init__(self, config): 5 | self.rabbitmq_username = config.RABBITMQ_USERNAME 6 | self.rabbitmq_password = config.RABBITMQ_PASSWORD 7 | self.rabbitmq_host = config.RABBITMQ_HOST 8 | self.rabbitmq_port = config.RABBITMQ_PORT 9 | 10 | # Connect to RabbitMQ 11 | credentials = pika.PlainCredentials(self.rabbitmq_username, self.rabbitmq_password) 12 | self.connection = pika.BlockingConnection(pika.ConnectionParameters(host=self.rabbitmq_host, port=self.rabbitmq_port, credentials=credentials)) 13 | self.channel = self.connection.channel() 14 | 15 | # Declare the exchanges and queues 16 | self.exchanges_queues = { 17 | 'instagram_to_telegram': { 18 | 'exchange': 'instagram_to_telegram_exchange', 19 | 'queue': 'instagram_to_telegram_queue' 20 | }, 21 | 'telegram_to_instagram': { 22 | 'exchange': 'telegram_to_instagram_exchange', 23 | 'queue': 'telegram_to_instagram_queue' 24 | }, 25 | 'logs': { 26 | 'exchange': 'logs_exchange', 27 | 'queue': 'logs_queue' 28 | } 29 | } 30 | 31 | for key, value in self.exchanges_queues.items(): 32 | self.channel.exchange_declare(exchange=value['exchange'], exchange_type='direct') 33 | self.channel.queue_declare(queue=value['queue']) 34 | self.channel.queue_bind(exchange=value['exchange'], queue=value['queue'], routing_key=key) 35 | 36 | def start_consuming(self, callbacks): 37 | for key, value in self.exchanges_queues.items(): 38 | if key in callbacks: 39 | self.channel.basic_consume(queue=value['queue'], on_message_callback=callbacks[key], auto_ack=True) 40 | self.channel.start_consuming() 41 | 42 | def send_message_instagram_to_telegram(self, message): 43 | self.channel.basic_publish(exchange=self.exchanges_queues['instagram_to_telegram']['exchange'], routing_key='instagram_to_telegram', body=message) 44 | 45 | def send_message_telegram_to_instagram(self, message): 46 | self.channel.basic_publish(exchange=self.exchanges_queues['telegram_to_instagram']['exchange'], routing_key='telegram_to_instagram', body=message) 47 | 48 | def send_message_logs(self, message): 49 | self.channel.basic_publish(exchange=self.exchanges_queues['logs']['exchange'], routing_key='logs', body=message) 50 | 51 | def stop_consuming(self): 52 | self.channel.stop_consuming() 53 | 54 | def close_connection(self): 55 | self.connection.close() -------------------------------------------------------------------------------- /telegram_service/telegram_listener.py: -------------------------------------------------------------------------------- 1 | from telegram import ForceReply, Update 2 | from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters 3 | from redis import Redis 4 | import json 5 | 6 | from message_broker.rabbitmq_service import RabbitMQService 7 | from config.config import Config 8 | from logger.log import Logger 9 | 10 | logger = Logger("TelegramListener") 11 | redis_telegram_client = Redis(host=Config.REDIS_HOST, port=Config.REDIS_PORT, password=Config.REDIS_PASSWORD, db=1) 12 | 13 | async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 14 | """Send a message when the command /start is issued.""" 15 | user = update.effective_user 16 | await update.message.reply_html( 17 | rf"Hi {user.mention_html()}!" 18 | ) 19 | logger.log_info(f"Start command issued by {user.mention_html()}") 20 | 21 | async def listen(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 22 | rabbitmq_service = RabbitMQService(Config) 23 | rabbitmq_service.send_message_telegram_to_instagram(json.dumps({"action": "listen"})) 24 | rabbitmq_service.close_connection() 25 | await update.message.set_reaction("👍") 26 | logger.log_info(f"Listen command issued by {update.effective_user.mention_html()}") 27 | 28 | 29 | async def send_direct(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 30 | if update.message.reply_to_message is not None: 31 | replied_message_id = update.message.reply_to_message.message_id 32 | instagram_data = redis_telegram_client.get(replied_message_id) 33 | if instagram_data is not None: 34 | instagram_data = json.loads(instagram_data) 35 | instagram_data["text"] = update.message.text 36 | instagram_data["action"] = "reply" 37 | rabbitmq_service = RabbitMQService(Config) 38 | rabbitmq_service.send_message_telegram_to_instagram(json.dumps(instagram_data)) 39 | rabbitmq_service.close_connection() 40 | await update.message.set_reaction("👍") 41 | logger.log_info(f"Message sent: {update.message.text}") 42 | return 43 | 44 | await update.message.set_reaction("👎") 45 | logger.log_info("Message not sent") 46 | 47 | def main() -> None: 48 | """Start the bot.""" 49 | # Create the Application and pass it your bot's token. 50 | application = Application.builder().token(Config.TELEGRAM_BOT_TOKEN).build() 51 | 52 | # on different commands - answer in Telegram 53 | application.add_handler(CommandHandler("start", start)) 54 | application.add_handler(CommandHandler("listen", listen)) 55 | 56 | # on non command i.e message - echo the message on Telegram 57 | application.add_handler(MessageHandler(filters.TEXT, send_direct)) 58 | 59 | # Run the bot until the user presses Ctrl-C 60 | application.run_polling(allowed_updates=Update.ALL_TYPES) 61 | logger.log_info("Service started") 62 | 63 | if __name__ == "__main__": 64 | main() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | session.json 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ -------------------------------------------------------------------------------- /telegram_service/telegram_sender.py: -------------------------------------------------------------------------------- 1 | from redis import Redis 2 | import json 3 | import requests 4 | import io 5 | from time import sleep 6 | 7 | from message_broker.rabbitmq_service import RabbitMQService 8 | from config.config import Config 9 | from logger.log import Logger 10 | 11 | # Create an instance of Logger 12 | logger = Logger("TelegramSender") 13 | 14 | redis_instagram_client = Redis( 15 | host=Config.REDIS_HOST, port=Config.REDIS_PORT, password=Config.REDIS_PASSWORD, db=0 16 | ) 17 | redis_telegram_client = Redis( 18 | host=Config.REDIS_HOST, port=Config.REDIS_PORT, password=Config.REDIS_PASSWORD, db=1 19 | ) 20 | 21 | 22 | class TelegramSender: 23 | def __init__(self, config): 24 | self.TELEGRAM_BOT_TOKEN = config.TELEGRAM_BOT_TOKEN 25 | self.CHAT_ID = config.TELEGRAM_CHAT_ID 26 | 27 | def send_text_message(self, text, chat_id=None): 28 | if chat_id is None: 29 | chat_id = self.CHAT_ID 30 | 31 | url = f"https://api.telegram.org/bot{self.TELEGRAM_BOT_TOKEN}/sendMessage" 32 | data = {"chat_id": chat_id, "text": text} 33 | req = requests.post(url, data=data) 34 | if req.status_code == 200: 35 | message_id = json.loads(req.text)["result"]["message_id"] 36 | return message_id 37 | else: 38 | if "retry after" in json.loads(req.text)["description"]: 39 | sleep(30) 40 | else: 41 | logger.log_critical(f"Failed to send text message: {json.loads(req.text)['description']}") 42 | return None 43 | 44 | def send_video_message(self, video_url, caption): 45 | url = f"https://api.telegram.org/bot{self.TELEGRAM_BOT_TOKEN}/sendVideo" 46 | data = {"parse_mode": "HTML", "chat_id": self.CHAT_ID, "caption": caption} 47 | video = requests.get(video_url) 48 | video_stream = io.BytesIO(video.content) 49 | video_stream.name = "video.mp4" 50 | files = {"video": video_stream} 51 | req = requests.post(url, data=data, files=files) 52 | response = json.loads(req.text) 53 | if req.status_code == 200: 54 | message_id = response["result"]["message_id"] 55 | return message_id 56 | else: 57 | logger.log_critical(f"Failed to send video message: {response['description']}") 58 | return None 59 | 60 | def send_photo_message(self, photo_url, caption): 61 | url = f"https://api.telegram.org/bot{self.TELEGRAM_BOT_TOKEN}/sendPhoto" 62 | data = {"parse_mode": "HTML", "chat_id": self.CHAT_ID, "caption": caption} 63 | photo = requests.get(photo_url) 64 | photo_stream = io.BytesIO(photo.content) 65 | photo_stream.name = "photo.jpg" 66 | files = {"photo": photo_stream} 67 | req = requests.post(url, data=data, files=files) 68 | response = json.loads(req.text) 69 | if req.status_code == 200: 70 | message_id = response["result"]["message_id"] 71 | return message_id 72 | else: 73 | logger.log_critical(f"Failed to send photo message: {response['description']}") 74 | return None 75 | 76 | 77 | telegram_sender = TelegramSender(Config) 78 | 79 | 80 | def instagram_to_telegram_callback(ch, method, properties, body): 81 | message = json.loads(body) 82 | message_id = None 83 | if message.get("id") is not None: 84 | redis_instagram_client.set(message["id"], 1) 85 | logger.log_info(f"Message ID: {message['id']} saved to Redis") 86 | 87 | if message.get("type") == "text": 88 | message_id = telegram_sender.send_text_message(message["text"]) 89 | logger.log_info(f"Text message sent with ID: {message_id}") 90 | 91 | if message.get("type") == "reel": 92 | caption = f'{message["caption"]}\n\nLink' 93 | message_id = telegram_sender.send_video_message( 94 | message["download_link"], caption 95 | ) 96 | logger.log_info(f"Reel message sent with ID: {message_id}") 97 | 98 | if message.get("type") == "post": 99 | caption = f'{message["caption"]}\n\nLink' 100 | message_id = telegram_sender.send_photo_message( 101 | message["download_link"], caption 102 | ) 103 | logger.log_info(f"Post message sent with ID: {message_id}") 104 | 105 | if message.get("type") == "unknown": 106 | telegram_sender.send_text_message("Unknown message type") 107 | logger.log_info("Unknown message type sent to Telegram") 108 | 109 | if message_id is not None: 110 | redis_telegram_client.set( 111 | message_id, 112 | json.dumps( 113 | {"id": message["id"], "client_context": message["client_context"]} 114 | ), 115 | ) 116 | logger.log_info(f"Message ID: {message_id} saved to Redis") 117 | 118 | 119 | def log_in_telegram_callback(ch, method, properties, body): 120 | message = body.decode("utf-8") 121 | telegram_sender.send_text_message(message, Config.TELEGRAM_LOG_CHAT_ID) 122 | 123 | 124 | rabbitmq_service = RabbitMQService(Config) 125 | rabbitmq_service.start_consuming( 126 | { 127 | "instagram_to_telegram": instagram_to_telegram_callback, 128 | "logs": log_in_telegram_callback, 129 | } 130 | ) 131 | logger.log_info("Service started") 132 | -------------------------------------------------------------------------------- /instagram_service/instagram_handler_service.py: -------------------------------------------------------------------------------- 1 | import json 2 | from instagrapi import Client 3 | from instagrapi.exceptions import LoginRequired 4 | from redis import Redis 5 | from functools import wraps 6 | import os 7 | from time import sleep 8 | import traceback 9 | 10 | from message_broker.rabbitmq_service import RabbitMQService 11 | from logger.log import Logger 12 | from utils.singleton import Singleton 13 | 14 | def handle_login_required(func): 15 | @wraps(func) 16 | def wrapper(self, *args, **kwargs): 17 | for i in range(3): 18 | try: 19 | return func(self, *args, **kwargs) 20 | except LoginRequired: 21 | self.logger.log_info("Session is invalid, need to login via username and password") 22 | self.logger.log_error(traceback.format_exc()) 23 | if i == 1: 24 | self.logger.log_error("Couldn't login user with either password or session") 25 | self.logger.log_info("Waiting 3 minutes before trying to login again") 26 | sleep(3*60) 27 | self.login() 28 | self.logger.log_error("Couldn't login user with either password or session") 29 | raise Exception("Couldn't login user with either password or session") 30 | 31 | return wrapper 32 | 33 | class ReplyToMessage: 34 | def __init__(self, message_id, client_context): 35 | self.id = message_id 36 | self.client_context = client_context 37 | 38 | class InstagramService(metaclass=Singleton): 39 | 40 | def __init__(self, config): 41 | self.config = config 42 | self.logger = Logger("InstagramService") 43 | self.client = Client() 44 | self.client.delay_range = [1, 3] 45 | self.session_path = "config/session.json" 46 | self.thread_id_path = "config/thread_id.txt" 47 | self.login() 48 | self.INSTAGRAM_TARGET_USERNAME = config.INSTAGRAM_TARGET_USERNAME 49 | self.thread_id = self.load_thread_id() 50 | if not self.thread_id: 51 | self.thread_id = self.fetch_and_save_thread_id() 52 | self.redis_client = Redis(host=config.REDIS_HOST, port=config.REDIS_PORT, password=config.REDIS_PASSWORD, db=0) 53 | self.logger.log_info("Service started") 54 | 55 | def load_thread_id(self): 56 | try: 57 | with open(self.thread_id_path, 'r') as file: 58 | thread_id = file.read().strip() 59 | self.logger.log_info("Loaded thread_id from file.") 60 | return thread_id 61 | except FileNotFoundError: 62 | self.logger.log_info("Thread_id file not found. Will fetch from Instagram.") 63 | return None 64 | 65 | def fetch_and_save_thread_id(self): 66 | self.user_id = self.client.user_id_from_username(self.INSTAGRAM_TARGET_USERNAME) 67 | thread_id = self.client.direct_thread_by_participants([self.user_id])["thread"]["thread_id"] 68 | with open(self.thread_id_path, 'w') as file: 69 | file.write(thread_id) 70 | self.logger.log_info("Fetched and saved thread_id to file.") 71 | return thread_id 72 | 73 | def login(self): 74 | if os.path.exists(self.session_path): 75 | self.session = self.client.load_settings(self.session_path) 76 | else: 77 | self.session = None 78 | 79 | login_via_session = False 80 | login_via_pw = False 81 | 82 | if self.session: 83 | try: 84 | self.client.set_settings(self.session) 85 | self.client.login(self.config.INSTAGRAM_USERNAME, self.config.INSTAGRAM_PASSWORD) 86 | 87 | # check if session is valid 88 | try: 89 | self.client.get_timeline_feed() 90 | except LoginRequired: 91 | self.logger.log_info("Session is invalid, need to login via username and password") 92 | 93 | old_session = self.client.get_settings() 94 | 95 | # use the same device uuids across logins 96 | self.client.set_settings({}) 97 | self.client.set_uuids(old_session["uuids"]) 98 | 99 | self.client.login(self.config.INSTAGRAM_USERNAME, self.config.INSTAGRAM_PASSWORD) 100 | login_via_session = True 101 | except Exception as e: 102 | self.logger.log_info("Couldn't login user using session information: %s" % e) 103 | 104 | if not login_via_session: 105 | try: 106 | self.logger.log_info("Attempting to login via username and password. username: %s" % self.config.INSTAGRAM_USERNAME) 107 | if self.client.login(self.config.INSTAGRAM_USERNAME, self.config.INSTAGRAM_PASSWORD): 108 | login_via_pw = True 109 | except Exception as e: 110 | self.logger.log_info("Couldn't login user using username and password: %s" % e) 111 | 112 | if not login_via_pw and not login_via_session: 113 | self.logger.log_error("Couldn't login user with either password or session") 114 | raise Exception("Couldn't login user with either password or session") 115 | 116 | self.client.dump_settings(self.session_path) 117 | self.logger.log_info("Logged in successfully") 118 | 119 | 120 | @handle_login_required 121 | def listen(self): 122 | is_rabbitmq_connected = False 123 | self.logger.log_info("Listening for messages") 124 | messages = self.client.direct_messages(self.thread_id, amount=20) 125 | messages.reverse() 126 | for message in messages: 127 | if int(message.user_id) == int(self.client.user_id): 128 | continue 129 | if self.redis_client.get(message.id) is not None: 130 | continue 131 | if not is_rabbitmq_connected: 132 | rabbitmq_service = RabbitMQService(self.config) 133 | self.logger.log_info("Connected to RabbitMQ to send messages to Telegram") 134 | is_rabbitmq_connected = True 135 | 136 | if message.text is not None: 137 | text = message.text 138 | clean_message = { 139 | "id": message.id, 140 | "client_context": message.client_context, 141 | 'type': 'text', 142 | 'text': text 143 | } 144 | elif message.clip is not None: 145 | download_link = str(message.clip.video_url) 146 | caption = message.clip.caption_text 147 | link = "https://www.instagram.com/p/" + message.clip.code 148 | clean_message = { 149 | "id": message.id, 150 | "client_context": message.client_context, 151 | 'type': 'reel', 152 | 'download_link': download_link, 153 | 'caption': caption, 154 | 'link': link 155 | } 156 | elif message.xma_share is not None: 157 | download_link = str(message.xma_share.preview_url) 158 | caption = message.xma_share.title 159 | link = str(message.xma_share.video_url) 160 | clean_message = { 161 | "id": message.id, 162 | "client_context": message.client_context, 163 | 'type': 'post', 164 | 'download_link': download_link, 165 | 'caption': caption, 166 | 'link': link 167 | } 168 | else: 169 | clean_message = { 170 | 'type': 'unknown' 171 | } 172 | 173 | rabbitmq_service.send_message_instagram_to_telegram(json.dumps(clean_message)) 174 | self.logger.log_info(f"Sent message: {clean_message}") 175 | 176 | if is_rabbitmq_connected: 177 | rabbitmq_service.close_connection() 178 | self.logger.log_info("Disconnected from RabbitMQ") 179 | 180 | @handle_login_required 181 | def reply_in_direct(self, text, reply_to_message): 182 | self.client.direct_send(text, thread_ids=[self.thread_id], reply_to_message=reply_to_message) 183 | self.logger.log_info(f"Replied to message: {text}") --------------------------------------------------------------------------------