diff --git a/.github/workflows/dev-deploy.yaml b/.github/workflows/dev-deploy.yaml new file mode 100644 index 0000000..710637f --- /dev/null +++ b/.github/workflows/dev-deploy.yaml @@ -0,0 +1,28 @@ +name: Dev Polybot Service Deployment + +on: + push: + branches: + - dev + +env: + EC2_PUBLIC_IP: 13.51.158.13 + TOKEN: ${{ secrets.DEV_TELGRAM_TOKEN}} + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + DOMAIN: dev-bot.abdullahfursa.click:8443 + +jobs: + Deploy: + name: Deploy in EC2 + runs-on: ubuntu-latest + + steps: + - name: Checkout the app code + uses: actions/checkout@v2 + + - name: SSH to EC2 instance + run: | + echo "$SSH_PRIVATE_KEY" > mykey.pem + chmod 400 mykey.pem + ssh -o StrictHostKeyChecking=no -i mykey.pem ubuntu@$EC2_PUBLIC_IP "bash ~/dev_deploy.sh &" + diff --git a/.github/workflows/prod-deploy.yaml b/.github/workflows/prod-deploy.yaml new file mode 100644 index 0000000..b0d51c8 --- /dev/null +++ b/.github/workflows/prod-deploy.yaml @@ -0,0 +1,28 @@ +name: Prod Polybot Service Deployment + +on: + push: + branches: + - main + +env: + EC2_PUBLIC_IP: 16.170.218.102 + TOKEN: ${{ secrets.PROD_TELGRAM_TOKEN}} + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + DOMAIN: prod-bot.abdullahfursa.click:8443 + +jobs: + Deploy: + name: Deploy in EC2 + runs-on: ubuntu-latest + + steps: + - name: Checkout the app code + uses: actions/checkout@v2 + + - name: SSH to EC2 instance + run: | + echo "$SSH_PRIVATE_KEY" > mykey.pem + chmod 400 mykey.pem + ssh -o StrictHostKeyChecking=no -i mykey.pem ubuntu@$EC2_PUBLIC_IP "~/prod_deploy.sh" + diff --git a/.gitignore b/.gitignore index 2dc53ca..aced32c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ eggs/ lib/ lib64/ parts/ +YOURPUBLIC.pem +YOURPRIVATE.key sdist/ var/ wheels/ diff --git a/README.md b/README.md index fa90cfd..333c186 100644 --- a/README.md +++ b/README.md @@ -1,199 +1,32 @@ +# Image Processing Telegram Bot -# The Polybot Service: Python Project [![][autotest_badge]][autotest_workflow] +This Telegram bot allows users to process images with various filters and transformations. -## Background +## Getting Started -In this project, you develop a Python chatbot application which applies filters to images send by users to a Telegram bot. +### Prerequisites -Here is a short demonstration: +- Python 3.x +- [Flask](https://pypi.org/project/Flask/) +- [matplotlib](https://pypi.org/project/matplotlib/) +- [python-telegram-bot](https://pypi.org/project/python-telegram-bot/) +- [loguru](https://pypi.org/project/loguru/) -![app demo](.github/python_project_demo.gif) +### Installation -## Preliminaries +1. Clone this repository: -1. Fork this repo by clicking **Fork** in the top-right corner of the page. -2. Clone your forked repository by: - ```bash - git clone https://github.com// - ``` - Change `` and `` according to your GitHub username and the name you gave to your fork. E.g. `git clone https://github.com/johndoe/PolybotServicePython`. -3. Open the repo as a code project in your favorite IDE (Pycharm, VSCode, etc..). - It is also a good practice to create an isolated Python virtual environment specifically for your project ([see here how to do it in PyCharm](https://www.jetbrains.com/help/pycharm/creating-virtual-environment.html)). + ```sh + git clone + cd + ``` -Later on, you are required to change the `README.md` file content to provide relevant information about your service project, e.g. how to launch the app, main features, etc. +2. Install the dependencies: -Let's get started... - -## Intro to image processing - -Reference: https://ai.stanford.edu/~syyeung/cvweb/tutorial1.html - -### What is a digital image? - - -If we take a closer look on a digital image, we will notice it comprised of individual pixels, -each pixel has its own value. For a grayscale image, each pixel would have an **intensity** value between 0 and 255, with 0 being black and 255 being white. - -![][python_project_pixel] - -A grayscale image, then, can be represented as a matrix of pixel values: - -![][python_project_imagematrix] - -A color image is just a simple extension of this. The colors are constructed from a combination of Red, Green, and Blue (RGB). Instead of one matrix of pixel values, we use 3 different matrix, one for the Red (R) values, one for Green (G), and one Blue (B) values. - - - -As can be seen, each pixel of the image has three channels, represent the red, green, blue values. - -Python-wise, a digital grayscale image is essentially a matrix (list of lists): - -![][python_project_pythonimage] - -Each element in the `image` list is a list represented a **row** of pixels. - -### Image filtering - -Filtered images are ubiquitous in our social media feeds, news articles, books—everywhere! -Image filtering is a technique in image processing that involves modifying or enhancing an image by applying a filter to it. -Filters can be used to remove noise, sharpen edges, blur or smooth the image, or highlight specific features or details, among other effects. - -Python-wise, image filtering is as simple as manipulate the pixel values. - -## The `Img` class - -Under `polybot/img_proc.py`, the `Img` class is designed for image filtering on grayscale images. -Here is a detailed usage instruction for the class: - -1. Creating an instance of `Img`: - - Provide the path to the image file as a parameter when creating an instance of the `Img` class, for example: - - ```python - my_img = Img('path/to/image.jpg') - ``` - -2. Saving the modified image: - After performing operations on the image, you can save the modified image using the `save_img()` method, for example: - - ```python - my_img.save_img() - ``` - - This will save the modified grayscale image to a new path with an appended `_filtered` suffix, and uses the same file extension. - -### Filters for you to implement - -You are instructed to implement at least the following 4 filters: `concat()`, `rotate()`, `salt_n_pepper()`, `segment()`. - -On every error (E.g. image path doesn't exist, input image is not an RGB) you should raise a `RuntimeError` exception. - - -#### Concatenating images - -The `concat()` method is meant to concatenate two images together horizontally (side by side). - - -Implementation instruction for horizontal concatenation: -- Check the dimensions of both images to ensure they are compatible for concatenation. If the dimensions are not compatible (e.g., different heights), raise a `RuntimeError` exception with informative message. -- Combine the pixel values of both images to create a new image. For horizontal concatenation, combine each row of the first image with the corresponding row of the second image. -- Store the resulting concatenated image in the `self.data` attribute of the instance. - -```python -my_img = Img('path/to/image.jpg') -another_img = Img('path/to/image2.jpg') -my_img.concat(another_img) -my_img.save_img() # concatenated image was saved in 'path/to/image_filtered.jpg' -``` - -Note: you can optionally use the `direction` argument to implement `vertical` concatenation as well. - -#### Adding "salt and pepper" noise to the image - -The `salt_n_pepper()` noise method applies a type of image distortion that randomly adds isolated pixels with value of either 255 (maximum white intensity) or 0 (minimum black intensity). -The name "salt and pepper" reflects the appearance of these randomly scattered bright and dark pixels, resembling grains of salt and pepper sprinkled on an image. - -Implementation instruction: - 1. Iterate over the pixels of the image by looping through each row and each pixel value. - 2. For each pixel in the image: - - Randomly generate a number between 0 and 1. - - If the random number is less than 0.2, set the pixel value to the maximum intensity (255) to represent salt. - - If the random number is greater than 0.8, set the pixel value to the minimum intensity (0) to represent pepper. - - If neither condition is met (the random number is in between 0.2 to 0.8), keep the original pixel value without any modification. - - -```python -my_img = Img('path/to/image.jpg') -my_img.salt_n_pepper() -my_img.save_img() # noisy image was saved in 'path/to/image_filtered.jpg' -``` - -#### Rotating the image - -The `rotate()` method rotates an image around its center in a clockwise direction. - -Implementation remarks: -The resulting rotated image will have its rows become the columns, and the columns will become the rows. The pixels in the rotated image will be repositioned based on a clockwise rotation around the center of the original image. For example, the first row in the original image will become the last column in the rotated image, the second row will become the second-to-last column, and so on. - -```python -my_img = Img('path/to/image.jpg') -my_img.rotate() -my_img.rotate() # rotate again for a 180 degrees rotation -my_img.save_img() # rotated image was saved in 'path/to/image_filtered.jpg' -``` - -#### Segmenting the image - -The `segment()` method partitions the image into regions where the pixels have similar attributes, so the image is represented in a more simplified manner, and so we can then identify objects and boundaries more easily. - -Implementation instruction: - 1. Iterate over the pixels of the image by looping through each row and each pixel value. - 2. All pixels with an intensity greater than 100 are replaced with a white pixel (intensity 255) and all others are replaced with a black pixel (intensity 0). - -```python -my_img = Img('path/to/image.jpg') -my_img.segment() -my_img.save_img() -``` - -### Filters for inspiration - -The below two filters was already implemented, you can review these functions to get some inspiration of how might a filter implementation look like. - -#### Blurring the image - -The `blur()` method is already implemented. You can control the blurring level `blur_level` argument (default is 16). - It blurs the image by replacing the value of each pixel by the average of the 16 pixels around him (or any other value, controlled by the `blur_level` argument. The bigger the value, the stronger the blurring level). - -```python -my_img = Img('path/to/image.jpg') -my_img.blur() # or my_img.blur(blur_level=32) for stronger blurring effect -my_img.save_img() -``` - -#### Creating a contour of the image - -The `contour()` method is already implemented. It applies a contour effect to the image by calculating the **differences between neighbor pixels** along each row of the image matrix. - -```python -my_img = Img('path/to/image.jpg') -my_img.contour() -my_img.save_img() -``` - -## Test your filters locally - -Under `polybot/test` you'll find unittests for each filter. - -For example, to execute the test suite for the `concat()` filter, run the below command from the root dir of your repo: - -```bash -python -m polybot.test.test_concat -``` - -An alternative way is to run tests from the Pycharm UI. - -## Create a Telegram Bot + ```sh + pip install -r requirements.txt + ``` +### Create a Telegram Bot 1. Download and install Telegram Desktop (you can use your phone app as well). 2. Once installed, create your own Telegram Bot by following this section to create a bot. Once you have your telegram token you can move to the next step. @@ -202,7 +35,7 @@ An alternative way is to run tests from the Pycharm UI. For now, we will provide the token as an environment variable to your chat app. Later on in the course we will learn better approaches to store sensitive data. -## Running the Telegram bot locally +### Running the Telegram bot locally The Telegram app is a flask-based service that responsible for providing a chat-based interface for users to interact with your image processing functionality. It utilizes the Telegram Bot API to receive user images and respond with processed images. @@ -221,13 +54,8 @@ The webhook method consists of simple two steps: Setting your chat app URL in Telegram Servers: -![][python_project_webhook1] - Once the webhook URL is set, Telegram servers start sending HTTPS POST requests to the specified webhook URL whenever there are updates, such as new messages or events, for the bot. -![][python_project_webhook2] - - You've probably noticed that setting `localhost` URL as the webhook for a Telegram bot can be problematic because Telegram servers need to access the webhook URL over the internet to send updates. As `localhost` is not accessible externally, Telegram servers won't be able to reach the webhook, and the bot won't receive any updates. @@ -253,98 +81,52 @@ Don't forget to set the `TELEGRAM_APP_URL` env var to your URL. In the next step you'll finally run your bot app. -## Running a simple "echo" Bot - the `Bot` class - -Under `polybot/bot.py` you are given a class called `Bot`. This class implements a simple telegram bot, as follows. +### Usage -The constructor `__init__` receives the `token` and `telegram_chat_url` arguments. -The constructor creates an instance of the `TeleBot` object, which is a pythonic interface to Telegram API. You can use this instance to conveniently communicate with the Telegram servers. -Later, the constructor sets the webhook URL to be the `telegram_chat_url`. +1. Set up environment variables: + - `TELEGRAM_TOKEN`: Your Telegram bot token. + - `TELEGRAM_APP_URL`: Your application URL. -The `polybot/app.py` is the main app entrypoint. It's nothing but a simple flask webserver that uses a `Bot` instance to handle incoming messages, caught in the `webhook` endpoint function. +2. Run the Flask app: -The default behavior of the `Bot` class is to "echo" the incoming messages. Try it out! + ```sh + python polybot/app.py + ``` -## Extending the echo bot - the `QuoteBot` class + 3. Start chatting with your Telegram bot! Send an image along with a caption specifying the filter or transformation you want to apply. -In `bot.py` you are given a class called `QuoteBot` which **inherits** from `Bot`. -Upon incoming messages, this bot echoing the message while quoting the original message, unless the user is asking politely not to quote. + **Examples:** + - Applying /start command: -In `app.py`, change the instantiated instance to the `QuoteBot`: + ![Screenshot from 2024-05-18 10-42-58](https://github.com/abd129-0/PolybotServicePythonFursa/assets/75143506/f962be9b-a4e0-4bef-9d10-e6b26e21b613) -```diff -- Bot(TELEGRAM_TOKEN, TELEGRAM_APP_URL) -+ QuoteBot(TELEGRAM_TOKEN, TELEGRAM_APP_URL) -``` - -Run this bot and check its behavior. - -## Build your image processing bot - the `ImageProcessingBot` class - -In `bot.py` you are given a class called `ImageProcessingBot` which **inherits** from `Bot`, again. -Upon incoming **photo messages**, this bot downloads the photos and processes them according to the **`caption`** field provided with the message. -The bot will then send the processed image to the user. - -A few notes: - -- Inside the `ImageProcessingBot` class, override `handle_message` method and implement the needed functionality. -- Remember that by inheriting the `Bot` class, you can use all of its methods (such as `send_text`, `download_user_photo`, `send_photo`...). -- Possible `caption` values are: `['Blur', 'Contour', 'Rotate', 'Segment', 'Salt and pepper', 'Concat']`. -- Handle potential errors using `try... except... `. Send an appropriate message to the user (E.g. "something went wrong... please try again"). -- Set a timeout when sending a message to Telegram. -- Use `logger` to log important information in your app. -- Your bot should support the `Blur` and `Contour` filters (those filters have already implemented for you). - -Test your bot on real photos and make sure it's functioning properly. - -> [!TIP] -> When working with Telegram's API, you might encounter situations where your code encounters errors while processing incoming messages. In such cases, Telegram's server will automatically retry sending messages that were not responded to with a status code of 200. This retry mechanism is designed to ensure the reliable delivery of messages. -> If you find that your bot is receiving repeated messages due to this retry mechanism, just review your code and identify any errors or issues that might be causing the message processing failures. - -## Test your bot locally - -You can test your bot logic locally by: - -```bash -python -m polybot.test.test_telegram_bot -``` - -Or via the Pycharm UI. - - -## Extend your bot functionality - -Add any functionality you wish to your bot... - -- Greet the user. -- Add some informative message when user sends photos without captions or with invalid caption value. -- Add your own filters. -- Extend the functionality of the filters, e.g. allow users to specify "Rotate 2" to rotate the image twice). - -**Go wild!!!** - - -## Submission + + - Applying the blur filter: + + ![Screenshot from 2024-05-18 10-44-55](https://github.com/abd129-0/PolybotServicePythonFursa/assets/75143506/9a371d3b-bda0-4b81-88d0-4b34fd7e1a8c) -Time to submit your solution for testing. + + - Applying the segment filter: + + ![segment](https://github.com/abd129-0/PolybotServicePythonFursa/assets/75143506/3d2d5926-fd97-4692-8e3c-1477cde065e7) -1. Commit and push your changes. -1. In [GitHub Actions][github_actions], watch the automated test execution workflow (enable Actions if needed). - If there are any failures, click on the failed job and **read the test logs carefully**. Fix your solution, commit and push again. + + -## Good Luck + -[DevOpsTheHardWay]: https://github.com/alonitac/DevOpsTheHardWay -[autotest_badge]: ../../actions/workflows/project_auto_testing.yaml/badge.svg?event=push -[autotest_workflow]: ../../actions/workflows/project_auto_testing.yaml/ -[github_actions]: ../../actions +## File Structure -[python_project_demo]: https://alonitac.github.io/DevOpsTheHardWay/img/python_project_demo.gif -[python_project_pixel]: https://alonitac.github.io/DevOpsTheHardWay/img/python_project_pixel.gif -[python_project_imagematrix]: https://alonitac.github.io/DevOpsTheHardWay/img/python_project_imagematrix.png -[python_project_pythonimage]: https://alonitac.github.io/DevOpsTheHardWay/img/python_project_pythonimage.png -[python_project_webhook1]: https://alonitac.github.io/DevOpsTheHardWay/img/python_project_webhook1.png -[python_project_webhook2]: https://alonitac.github.io/DevOpsTheHardWay/img/python_project_webhook2.png +- `app.py`: Flask application handling Telegram webhook and routing. +- `img_proc.py`: Image processing utilities including filters and transformations. +- `bot.py`: Base class and subclasses for different types of Telegram bots. +## Supported Filters/Transformations +- Blur +- Contour +- Rotate +- Salt and Pepper +- Segment +- Concatenation (horizontal/vertical) diff --git a/polybot/app.py b/polybot/app.py index 469190e..9a0f9a9 100644 --- a/polybot/app.py +++ b/polybot/app.py @@ -22,6 +22,7 @@ def webhook(): if __name__ == "__main__": - bot = Bot(TELEGRAM_TOKEN, TELEGRAM_APP_URL) - app.run(host='0.0.0.0', port=8443) + bot = ImageProcessingBot(TELEGRAM_TOKEN, TELEGRAM_APP_URL) # Instantiate QuoteBot1 + + app.run(host='0.0.0.0', port=8443, ssl_context=('YOURPUBLIC.pem', 'YOURPRIVATE.key')) diff --git a/polybot/bot.py b/polybot/bot.py index 7fea847..19eed84 100644 --- a/polybot/bot.py +++ b/polybot/bot.py @@ -3,7 +3,7 @@ import os import time from telebot.types import InputFile -from polybot.img_proc import Img +from img_proc import Img class Bot: @@ -18,7 +18,7 @@ def __init__(self, token, telegram_chat_url): time.sleep(0.5) # set the webhook URL - self.telegram_bot_client.set_webhook(url=f'{telegram_chat_url}/{token}/', timeout=60) + self.telegram_bot_client.set_webhook(url=f'{telegram_chat_url}/{token}/', timeout=60, certificate=open('YOURPUBLIC.pem', 'r')) logger.info(f'Telegram Bot information\n\n{self.telegram_bot_client.get_me()}') @@ -75,4 +75,88 @@ def handle_message(self, msg): class ImageProcessingBot(Bot): - pass + def __init__(self, token, telegram_chat_url): + super().__init__(token, telegram_chat_url) + self.pending_images = {} + + def handle_message(self, msg): + try: + logger.info(f'Incoming message: {msg}') + + choices_msg = ('- Blur\n' + '- Contour\n' + '- Rotate\n' + '- Salt and pepper\n' + '- Segment\n' + '- Concat [horizontal/vertical]') + usage_msg = ('Welcome to the Image Processing Bot!\n' + 'Please send a photo along with a caption specifying the filter you want to apply.\n' + 'Supported filters:\n' + f'{choices_msg}') + + if "text" in msg and msg["text"].strip().lower() == 'bye': + self.send_text(msg['chat']['id'], 'see you soon') + if "text" in msg and msg["text"].strip().lower() == '/start': + self.send_text(msg['chat']['id'], 'Hello! I am your Image Processing Bot. How can I assist you today?') + if "text" in msg and msg["text"].strip().lower() == 'hi': + self.send_text(msg['chat']['id'], 'Hello BOTTT!!!') + + self.send_text(msg['chat']['id'], usage_msg) + return + + is_photo = self.is_current_msg_photo(msg) + + if is_photo: + self.send_text(msg['chat']['id'], 'Processing the image...') + photo_path = self.download_user_photo(msg) + caption = msg.get('caption', '').lower() + + if caption.startswith('concat'): + if msg['chat']['id'] not in self.pending_images: + self.pending_images[msg['chat']['id']] = {'first_image': photo_path} + self.send_text(msg['chat']['id'], 'Please send the second image for concatenation.') + else: + self.pending_images[msg['chat']['id']]['second_image'] = photo_path + concat_direction = 'horizontal' if 'horizontal' in caption else 'vertical' + processed_path = self.process_image(self.pending_images[msg['chat']['id']]['first_image'], + caption, concat_direction, + self.pending_images[msg['chat']['id']]['second_image']) + self.send_photo(msg['chat']['id'], processed_path) + del self.pending_images[msg['chat']['id']] + else: + processed_path = self.process_image(photo_path, caption) + self.send_photo(msg['chat']['id'], processed_path) + else: + self.send_text(msg['chat']['id'], "Please send a photo.") + except Exception as e: + logger.error(f"Error: {e}") + self.send_text(msg['chat']['id'], "Error: Please try again later.") + + def process_image(self, photo_path, caption, concat_direction=None, second_photo_path=None): + img = Img(photo_path) + + if caption == 'blur': + img.blur() + elif caption == 'contour': + img.contour() + elif caption == 'rotate': + img.rotate() + elif caption == 'segment': + img.segment() + elif caption == 'salt and pepper': + img.salt_n_pepper() + elif caption.startswith('concat'): + if second_photo_path: + second_img = Img(second_photo_path) + img.concat(second_img, direction=concat_direction) + else: + raise ValueError("Second image path is required for concatenation.") + else: + + raise ValueError( + f"Invalid caption: {caption}. Supported captions are: ['blur', 'contour', 'rotate', 'segment', " + f"'salt and pepper', 'concat horizontal', 'concat vertical']" + ) + + processed_path = img.save_img() + return processed_path diff --git a/polybot/img_proc.py b/polybot/img_proc.py index 137ca70..281bb41 100644 --- a/polybot/img_proc.py +++ b/polybot/img_proc.py @@ -1,3 +1,4 @@ +import random from pathlib import Path from matplotlib.image import imread, imsave @@ -51,17 +52,45 @@ def contour(self): self.data[i] = res def rotate(self): - # TODO remove the `raise` below, and write your implementation - raise NotImplementedError() + height = len(self.data) + width = len(self.data[0]) + + # Transpose the image (swap rows with columns) + transposed_data = [[self.data[j][i] for j in range(height)] for i in range(width)] + + # Reverse the rows to complete the rotation + for i in range(width): + transposed_data[i] = transposed_data[i][::-1] + + # Update the image data with the rotated data + self.data = transposed_data + def salt_n_pepper(self): - # TODO remove the `raise` below, and write your implementation - raise NotImplementedError() + for i in range(len(self.data)): + for j in range(len(self.data[0])): + rand = random.random() + if rand < 0.2: + self.data[i][j] = 255 # Salt + elif rand > 0.8: + self.data[i][j] = 0 # Pepper def concat(self, other_img, direction='horizontal'): - # TODO remove the `raise` below, and write your implementation - raise NotImplementedError() + if direction == 'horizontal': + if len(self.data) != len(other_img.data): + raise RuntimeError("Images have different heights and cannot be concatenated horizontally.") + self.data = [self_row + other_row for self_row, other_row in zip(self.data, other_img.data)] + elif direction == 'vertical': + if len(self.data[0]) != len(other_img.data[0]): + raise RuntimeError("Images have different widths and cannot be concatenated vertically.") + self.data += other_img.data + else: + raise ValueError("Invalid direction. Direction must be 'horizontal' or 'vertical'.") def segment(self): - # TODO remove the `raise` below, and write your implementation - raise NotImplementedError() + for i in range(len(self.data)): + for j in range(len(self.data[0])): + if self.data[i][j] > 100: + self.data[i][j] = 255 # White + else: + self.data[i][j] = 0 # Black \ No newline at end of file diff --git a/polybot/requirements.txt b/polybot/requirements.txt index 9e38157..29b0f99 100644 --- a/polybot/requirements.txt +++ b/polybot/requirements.txt @@ -2,4 +2,4 @@ pyTelegramBotAPI>=4.12.0 loguru>=0.7.0 requests>=2.31.0 flask>=2.3.2 -matplotlib>=3.7.5 \ No newline at end of file +matplotlib>=3.7.5 diff --git a/polybot/test/test_telegram_bot.py b/polybot/test/test_telegram_bot.py index 8ce7005..426d9b4 100644 --- a/polybot/test/test_telegram_bot.py +++ b/polybot/test/test_telegram_bot.py @@ -101,4 +101,4 @@ def test_contour_with_exception(self, mock_open): if __name__ == '__main__': - unittest.main() + unittest.main() \ No newline at end of file