understanding commited on
Commit
165c9f9
·
verified ·
1 Parent(s): a9c0573

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +510 -0
app.py ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+
3
+ import os
4
+ import random
5
+ import textwrap
6
+ import logging
7
+ import asyncio
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from typing import List, Optional, Tuple
10
+
11
+ from dotenv import load_dotenv
12
+ from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageFilter, ImageEnhance, UnidentifiedImageError
13
+ import numpy as np
14
+ from telegram import Update, InputFile
15
+ from telegram.ext import Application, MessageHandler, filters, ContextTypes
16
+ from telegram.constants import ParseMode
17
+ from telegram.error import TelegramError
18
+
19
+ # --- Configuration ---
20
+ class Config:
21
+ # Load environment variables - essential for Hugging Face secrets
22
+ load_dotenv()
23
+ TELEGRAM_TOKEN = os.getenv('TELEGRAM_TOKEN')
24
+ if not TELEGRAM_TOKEN:
25
+ # Use logger here if already configured, otherwise print/raise
26
+ # logger.critical("TELEGRAM_TOKEN environment variable not set!")
27
+ raise ValueError("TELEGRAM_TOKEN environment variable not set! Please set it in Hugging Face Space secrets.")
28
+
29
+ # Directories (relative paths for container compatibility)
30
+ PREDEFINED_TEMPLATES_DIR = "templates"
31
+ OUTPUT_DIR = "generated_images"
32
+ # Assumes arial.ttf is copied into the /app directory by the Dockerfile
33
+ FONT_PATH = "arial.ttf"
34
+
35
+ # Predefined Template Settings
36
+ # Assuming templates are e.g., 1200x900 and have a space for the user image
37
+ TEMPLATE_SIZE = (1200, 900) # Expected size of predefined template canvas
38
+ PLACEHOLDER_SIZE = (700, 500) # Size to fit user image into within the template
39
+ PLACEHOLDER_POSITION = (50, 50) # Top-left corner to paste user image in template
40
+
41
+ # Auto Template Settings
42
+ AUTO_TEMPLATES_COUNT = 5
43
+ AUTO_TEMPLATE_SIZE = (1200, 900) # Canvas size for auto-generated images
44
+ AUTO_USER_IMAGE_SIZE = (600, 450) # Size for user image within auto-template
45
+ MIN_FONT_SIZE = 30
46
+ MAX_FONT_SIZE = 60
47
+ TEXT_STROKE_WIDTH = 2
48
+ NOISE_INTENSITY = 0.03 # Intensity for numpy noise effect (0 to 1)
49
+
50
+ # Other
51
+ MAX_CAPTION_WIDTH = 35 # Characters per line for text wrapping
52
+ JPEG_QUALITY = 85 # Quality for saving JPG images
53
+
54
+ # --- Logging Setup ---
55
+ logging.basicConfig(
56
+ level=logging.INFO,
57
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
58
+ )
59
+ # Reduce verbosity of httpx logger used by python-telegram-bot
60
+ logging.getLogger("httpx").setLevel(logging.WARNING)
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # --- Setup ---
64
+ # Ensure necessary directories exist within the container's filesystem
65
+ try:
66
+ os.makedirs(Config.PREDEFINED_TEMPLATES_DIR, exist_ok=True)
67
+ os.makedirs(Config.OUTPUT_DIR, exist_ok=True)
68
+ logger.info(f"Ensured directories exist: {Config.PREDEFINED_TEMPLATES_DIR}, {Config.OUTPUT_DIR}")
69
+ except OSError as e:
70
+ logger.error(f"Error creating directories: {e}", exc_info=True)
71
+ # Depending on the error, you might want to exit or handle differently
72
+
73
+ # Check for font file availability
74
+ if not os.path.exists(Config.FONT_PATH):
75
+ logger.warning(f"Font file not found at '{Config.FONT_PATH}'. Text rendering might use default system font or fail if Pillow cannot find a fallback.")
76
+ # Pillow might find system fonts installed by Dockerfile's `apt-get install fontconfig ttf-mscorefonts-installer`
77
+
78
+ # --- Helper Functions ---
79
+ def add_noise_to_image(img: Image.Image, intensity: float = 0.02) -> Image.Image:
80
+ """Adds subtle noise to a PIL image using numpy."""
81
+ try:
82
+ # Ensure image is in RGB mode for numpy array processing
83
+ if img.mode != 'RGB':
84
+ img = img.convert('RGB')
85
+ img_array = np.array(img, dtype=np.float32) / 255.0
86
+ # Generate Gaussian noise matching image dimensions
87
+ noise = np.random.randn(*img_array.shape) * intensity
88
+ # Add noise and clip values to valid range [0, 1]
89
+ noisy_img_array = np.clip(img_array + noise, 0.0, 1.0)
90
+ # Convert back to PIL Image
91
+ noisy_img = Image.fromarray((noisy_img_array * 255).astype(np.uint8), 'RGB')
92
+ return noisy_img
93
+ except Exception as e:
94
+ logger.error(f"Error adding noise: {e}", exc_info=True)
95
+ return img # Return original image on error
96
+
97
+ # --- Image Processing Functions ---
98
+
99
+ def apply_template(user_image_path: str, caption: str, template_path: str) -> Optional[str]:
100
+ """
101
+ Applies user image and caption to a predefined template using pasting.
102
+
103
+ Args:
104
+ user_image_path: Path to the downloaded user image.
105
+ caption: Text caption provided by the user.
106
+ template_path: Path to the predefined template image.
107
+
108
+ Returns:
109
+ Path to the generated image (in OUTPUT_DIR), or None if an error occurred.
110
+ """
111
+ # Generate a unique-ish output filename
112
+ base_name = os.path.basename(template_path).split('.')[0]
113
+ output_filename = f"result_{base_name}_{random.randint(1000, 9999)}.jpg"
114
+ output_path = os.path.join(Config.OUTPUT_DIR, output_filename)
115
+
116
+ try:
117
+ # Use 'with' statement for automatic resource cleanup
118
+ with Image.open(template_path).convert("RGBA") as template, \
119
+ Image.open(user_image_path).convert("RGBA") as user_image_orig:
120
+
121
+ # Optional: Check if template size matches config (for consistency)
122
+ if template.size != Config.TEMPLATE_SIZE:
123
+ logger.warning(f"Template {os.path.basename(template_path)} size {template.size} differs from expected {Config.TEMPLATE_SIZE}. Results may vary.")
124
+
125
+ # Resize user image to fit the placeholder area using Lanczos resampling for quality
126
+ user_image_resized = ImageOps.fit(user_image_orig, Config.PLACEHOLDER_SIZE, Image.Resampling.LANCZOS)
127
+
128
+ # Create a working copy of the template to paste onto
129
+ combined = template.copy()
130
+
131
+ # Paste the resized user image into the placeholder position
132
+ # The third argument (mask) uses the alpha channel of the user image for smooth edges if it has transparency
133
+ combined.paste(user_image_resized, Config.PLACEHOLDER_POSITION, user_image_resized if user_image_resized.mode == 'RGBA' else None)
134
+
135
+ # --- Add Caption ---
136
+ draw = ImageDraw.Draw(combined)
137
+ try:
138
+ # Use a medium font size relative to config
139
+ font_size = Config.MAX_FONT_SIZE // 2
140
+ font = ImageFont.truetype(Config.FONT_PATH, font_size)
141
+ except IOError:
142
+ logger.warning(f"Failed to load font: {Config.FONT_PATH}. Using Pillow's default.")
143
+ font = ImageFont.load_default()
144
+
145
+ # Wrap text according to configured width
146
+ wrapped_text = textwrap.fill(caption, width=Config.MAX_CAPTION_WIDTH)
147
+
148
+ # Calculate text position (e.g., centered below the placeholder area)
149
+ text_bbox = draw.textbbox((0, 0), wrapped_text, font=font, align="center")
150
+ text_width = text_bbox[2] - text_bbox[0]
151
+ text_height = text_bbox[3] - text_bbox[1]
152
+ # Center horizontally
153
+ text_x = (combined.width - text_width) // 2
154
+ # Position below placeholder, add some padding
155
+ text_y = Config.PLACEHOLDER_POSITION[1] + Config.PLACEHOLDER_SIZE[1] + 20
156
+
157
+ # Draw text with a simple shadow/stroke for better visibility
158
+ # Draw shadow slightly offset
159
+ shadow_offset = 1
160
+ draw.text((text_x + shadow_offset, text_y + shadow_offset), wrapped_text, font=font, fill="black", align="center")
161
+ # Draw main text
162
+ draw.text((text_x, text_y), wrapped_text, font=font, fill="white", align="center")
163
+
164
+ # Convert to RGB before saving as JPG (removes alpha channel)
165
+ combined_rgb = combined.convert("RGB")
166
+ combined_rgb.save(output_path, "JPEG", quality=Config.JPEG_QUALITY)
167
+ logger.info(f"Generated image using template '{os.path.basename(template_path)}': {output_path}")
168
+ return output_path
169
+
170
+ except FileNotFoundError:
171
+ logger.error(f"Template or user image not found. Template: {template_path}, User Image: {user_image_path}")
172
+ except UnidentifiedImageError:
173
+ logger.error(f"Could not identify image file (corrupted or unsupported format?). Template: {template_path}, User Image: {user_image_path}")
174
+ except Exception as e:
175
+ logger.error(f"Error applying template '{os.path.basename(template_path)}': {e}", exc_info=True)
176
+
177
+ # Explicitly return None on any error during the process
178
+ return None
179
+
180
+
181
+ def create_auto_template(user_image_path: str, caption: str, variant: int) -> Optional[str]:
182
+ """
183
+ Generates a dynamic template with various effects.
184
+
185
+ Args:
186
+ user_image_path: Path to the downloaded user image.
187
+ caption: Text caption provided by the user.
188
+ variant: An integer index to introduce variation.
189
+
190
+ Returns:
191
+ Path to the generated image (in OUTPUT_DIR), or None if an error occurred.
192
+ """
193
+ output_filename = f"auto_template_{variant}_{random.randint(1000, 9999)}.jpg"
194
+ output_path = os.path.join(Config.OUTPUT_DIR, output_filename)
195
+
196
+ try:
197
+ # --- Create Background ---
198
+ # Generate a random somewhat dark color for the background
199
+ bg_color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
200
+ bg = Image.new('RGB', Config.AUTO_TEMPLATE_SIZE, color=bg_color)
201
+
202
+ # --- Process User Image ---
203
+ with Image.open(user_image_path) as user_img_orig:
204
+ # Work on a copy
205
+ user_img = user_img_orig.copy()
206
+ # Resize to fit the designated area within the auto-template
207
+ user_img = ImageOps.fit(user_img, Config.AUTO_USER_IMAGE_SIZE, Image.Resampling.LANCZOS)
208
+
209
+ # Apply random effects based on the variant index
210
+ effect_choice = random.choice(['blur', 'noise', 'color', 'contrast', 'none']) # Add more variety
211
+ logger.debug(f"Auto template variant {variant}: Applying effect '{effect_choice}'")
212
+
213
+ if effect_choice == 'blur':
214
+ user_img = user_img.filter(ImageFilter.GaussianBlur(random.uniform(0.5, 1.5)))
215
+ elif effect_choice == 'noise':
216
+ user_img = add_noise_to_image(user_img, Config.NOISE_INTENSITY)
217
+ elif effect_choice == 'color': # Enhance or reduce color saturation
218
+ enhancer = ImageEnhance.Color(user_img)
219
+ user_img = enhancer.enhance(random.uniform(0.3, 1.7))
220
+ elif effect_choice == 'contrast': # Enhance or reduce contrast
221
+ enhancer = ImageEnhance.Contrast(user_img)
222
+ user_img = enhancer.enhance(random.uniform(0.7, 1.3))
223
+ # 'none' applies no extra filter
224
+
225
+ # Add a decorative border with a random light color
226
+ border_color = (random.randint(180, 255), random.randint(180, 255), random.randint(180, 255))
227
+ border_width = random.randint(8, 20)
228
+ user_img = ImageOps.expand(user_img, border=border_width, fill=border_color)
229
+
230
+ # --- Paste User Image onto Background ---
231
+ # Calculate position to center the (bordered) user image horizontally, and place it in the upper part vertically
232
+ paste_x = (bg.width - user_img.width) // 2
233
+ paste_y = (bg.height - user_img.height) // 3 # Position slightly above vertical center
234
+ bg.paste(user_img, (paste_x, paste_y))
235
+
236
+ # --- Add Styled Text ---
237
+ draw = ImageDraw.Draw(bg)
238
+ try:
239
+ # Random font size within configured range
240
+ font_size = random.randint(Config.MIN_FONT_SIZE, Config.MAX_FONT_SIZE)
241
+ font = ImageFont.truetype(Config.FONT_PATH, font_size)
242
+ except IOError:
243
+ logger.warning(f"Failed to load font: {Config.FONT_PATH}. Using Pillow's default.")
244
+ font = ImageFont.load_default() # Fallback font
245
+
246
+ # Wrap text
247
+ wrapped_text = textwrap.fill(caption, width=Config.MAX_CAPTION_WIDTH)
248
+
249
+ # Calculate text position (centered horizontally, below the pasted image)
250
+ text_bbox = draw.textbbox((0, 0), wrapped_text, font=font, align="center")
251
+ text_width = text_bbox[2] - text_bbox[0]
252
+ text_height = text_bbox[3] - text_bbox[1]
253
+ text_x = (bg.width - text_width) // 2
254
+ # Position below the image + border, add padding
255
+ text_y = paste_y + user_img.height + 30
256
+
257
+ # Random bright text color and dark stroke color
258
+ text_color = (random.randint(200, 255), random.randint(200, 255), random.randint(200, 255))
259
+ stroke_color = (random.randint(0, 50), random.randint(0, 50), random.randint(0, 50))
260
+
261
+ # Draw text with stroke
262
+ draw.text((text_x, text_y), wrapped_text, font=font, fill=text_color,
263
+ stroke_width=Config.TEXT_STROKE_WIDTH, stroke_fill=stroke_color, align="center")
264
+
265
+ # Save the final image
266
+ bg.save(output_path, "JPEG", quality=Config.JPEG_QUALITY)
267
+ logger.info(f"Generated auto-template image (variant {variant}): {output_path}")
268
+ return output_path
269
+
270
+ except FileNotFoundError:
271
+ logger.error(f"User image not found during auto-template creation: {user_image_path}")
272
+ except UnidentifiedImageError:
273
+ logger.error(f"Could not identify user image file during auto-template creation: {user_image_path}")
274
+ except Exception as e:
275
+ logger.error(f"Error creating auto-template variant {variant}: {e}", exc_info=True)
276
+
277
+ return None
278
+
279
+
280
+ def load_predefined_templates() -> List[str]:
281
+ """Loads paths of all valid template images from the predefined directory."""
282
+ templates = []
283
+ logger.debug(f"Searching for templates in: {os.path.abspath(Config.PREDEFINED_TEMPLATES_DIR)}")
284
+ try:
285
+ if not os.path.isdir(Config.PREDEFINED_TEMPLATES_DIR):
286
+ logger.warning(f"Predefined templates directory not found: {Config.PREDEFINED_TEMPLATES_DIR}")
287
+ return []
288
+ for file in os.listdir(Config.PREDEFINED_TEMPLATES_DIR):
289
+ # Check for common image extensions
290
+ if file.lower().endswith(('.png', '.jpg', '.jpeg')):
291
+ full_path = os.path.join(Config.PREDEFINED_TEMPLATES_DIR, file)
292
+ templates.append(full_path)
293
+ logger.info(f"Loaded {len(templates)} predefined templates.")
294
+ except Exception as e:
295
+ logger.error(f"Error loading predefined templates from {Config.PREDEFINED_TEMPLATES_DIR}: {e}", exc_info=True)
296
+ return templates
297
+
298
+ # This function orchestrates the image processing. It contains blocking Pillow calls,
299
+ # so it's designed to be run in a thread pool executor by the async handler.
300
+ def process_images(user_image_path: str, caption: str) -> List[str]:
301
+ """
302
+ Processes the user image against predefined and auto-generated templates.
303
+ This is a SYNCHRONOUS function.
304
+
305
+ Args:
306
+ user_image_path: Path to the temporary user image file.
307
+ caption: The user's caption.
308
+
309
+ Returns:
310
+ A list of paths to the generated images.
311
+ """
312
+ generated_image_paths: List[str] = []
313
+ predefined_templates = load_predefined_templates()
314
+
315
+ # 1. Process predefined templates
316
+ if predefined_templates:
317
+ logger.info(f"Processing {len(predefined_templates)} predefined templates...")
318
+ for template_path in predefined_templates:
319
+ result_path = apply_template(user_image_path, caption, template_path)
320
+ if result_path:
321
+ generated_image_paths.append(result_path)
322
+ else:
323
+ logger.info("No predefined templates found or loaded.")
324
+
325
+ # 2. Generate auto templates
326
+ logger.info(f"Generating {Config.AUTO_TEMPLATES_COUNT} auto-templates...")
327
+ for i in range(Config.AUTO_TEMPLATES_COUNT):
328
+ result_path = create_auto_template(user_image_path, caption, i)
329
+ if result_path:
330
+ generated_image_paths.append(result_path)
331
+
332
+ logger.info(f"Finished processing. Generated {len(generated_image_paths)} images in total.")
333
+ return generated_image_paths
334
+
335
+
336
+ # --- Telegram Bot Handler ---
337
+
338
+ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
339
+ """Handles incoming messages with photos and captions."""
340
+ # Basic check for essential message components
341
+ if not update.message or not update.message.photo or not update.message.caption:
342
+ # This case should ideally be filtered out by MessageHandler filters, but check just in case
343
+ logger.warning("Handler invoked for message without photo or caption.")
344
+ # Optionally reply, but filter should prevent this
345
+ # await update.message.reply_text("⚠️ Please send an image with a caption text!")
346
+ return
347
+
348
+ user = update.message.from_user
349
+ user_id = user.id if user else "UnknownUser"
350
+ caption = update.message.caption
351
+ message_id = update.message.message_id
352
+ chat_id = update.message.chat_id
353
+
354
+ logger.info(f"Received photo with caption from user {user_id} in chat {chat_id}.")
355
+
356
+ # --- Download User Image ---
357
+ # Create a unique temporary path for the downloaded image
358
+ temp_user_image_path = os.path.join(Config.OUTPUT_DIR, f"user_{user_id}_{message_id}.jpg")
359
+ file_downloaded = False
360
+ try:
361
+ photo = update.message.photo[-1] # Get the highest resolution photo available
362
+ logger.info(f"Downloading photo (file_id: {photo.file_id}, size: {photo.width}x{photo.height})...")
363
+ photo_file = await photo.get_file()
364
+ await photo_file.download_to_drive(temp_user_image_path)
365
+ logger.info(f"Photo downloaded successfully to {temp_user_image_path}")
366
+ file_downloaded = True
367
+ except TelegramError as e:
368
+ logger.error(f"Telegram error downloading photo: {e}", exc_info=True)
369
+ await update.message.reply_text("❌ Sorry, there was a Telegram error downloading the image. Please try again.")
370
+ return
371
+ except Exception as e:
372
+ logger.error(f"Unexpected error downloading photo: {e}", exc_info=True)
373
+ await update.message.reply_text("❌ Sorry, I couldn't download the image due to an unexpected error.")
374
+ return
375
+
376
+ if not file_downloaded: # Should not happen if exceptions are caught, but as safety
377
+ return
378
+
379
+ # --- Process Images in Executor ---
380
+ # Notify user that processing has started
381
+ try:
382
+ processing_message = await update.message.reply_text("⏳ Processing your image with different styles...", quote=True)
383
+ message_to_delete = processing_message.message_id
384
+ except TelegramError as e:
385
+ logger.warning(f"Could not send 'Processing...' message: {e}")
386
+ message_to_delete = None # Cannot delete if sending failed
387
+
388
+ loop = asyncio.get_running_loop()
389
+ generated_images = []
390
+ start_time = loop.time()
391
+ try:
392
+ # Run the blocking image processing function in the default thread pool executor
393
+ generated_images = await loop.run_in_executor(
394
+ None, # Use default ThreadPoolExecutor
395
+ process_images, # The synchronous function to run
396
+ temp_user_image_path, # Argument 1 for process_images
397
+ caption # Argument 2 for process_images
398
+ )
399
+ processing_time = loop.time() - start_time
400
+ logger.info(f"Image processing completed in {processing_time:.2f} seconds.")
401
+
402
+ except Exception as e:
403
+ logger.error(f"Error during image processing executor call: {e}", exc_info=True)
404
+ # Try to edit the "Processing..." message to show error
405
+ if message_to_delete:
406
+ try:
407
+ await context.bot.edit_message_text(
408
+ chat_id=chat_id,
409
+ message_id=message_to_delete,
410
+ text="❌ An unexpected error occurred during processing."
411
+ )
412
+ message_to_delete = None # Don't try to delete it again later
413
+ except TelegramError as edit_err:
414
+ logger.warning(f"Could not edit processing message to show error: {edit_err}")
415
+ # Fallback reply if editing failed
416
+ await update.message.reply_text("❌ An unexpected error occurred during processing.")
417
+ else:
418
+ await update.message.reply_text("❌ An unexpected error occurred during processing.")
419
+
420
+ finally:
421
+ # Delete the "Processing..." message if it was sent and not edited to show error
422
+ if message_to_delete:
423
+ try:
424
+ await context.bot.delete_message(
425
+ chat_id=chat_id,
426
+ message_id=message_to_delete
427
+ )
428
+ except TelegramError as del_err:
429
+ logger.warning(f"Could not delete 'Processing...' message ({message_to_delete}): {del_err}")
430
+
431
+
432
+ # --- Send Results ---
433
+ if not generated_images:
434
+ logger.warning("No images were generated successfully.")
435
+ # Avoid sending redundant message if error was already reported
436
+ if 'e' not in locals(): # Check if processing failed with exception 'e'
437
+ await update.message.reply_text("😕 Sorry, I couldn't generate any styled images this time. Please check the templates or try again.")
438
+ else:
439
+ logger.info(f"Sending {len(generated_images)} generated images back to user {user_id}.")
440
+ sent_count = 0
441
+ for i, img_path in enumerate(generated_images):
442
+ caption_text = f"Style variant {i+1}" if len(generated_images) > 1 else "Here's your styled image!"
443
+ try:
444
+ # Send the photo from the generated path
445
+ with open(img_path, 'rb') as photo_data:
446
+ await update.message.reply_photo(
447
+ photo=InputFile(photo_data, filename=os.path.basename(img_path)),
448
+ caption=caption_text
449
+ )
450
+ sent_count += 1
451
+ logger.debug(f"Sent photo {os.path.basename(img_path)}")
452
+
453
+ except FileNotFoundError:
454
+ logger.error(f"Generated image file not found for sending: {img_path}")
455
+ # Try to inform user about partial failure if multiple images were expected
456
+ if len(generated_images) > 1:
457
+ await update.message.reply_text(f"⚠️ Couldn't send style variant {i+1} (file missing).")
458
+ except TelegramError as e:
459
+ logger.error(f"Telegram error sending photo {os.path.basename(img_path)}: {e}", exc_info=True)
460
+ if len(generated_images) > 1:
461
+ await update.message.reply_text(f"⚠️ Couldn't send style variant {i+1} due to a Telegram error.")
462
+ except Exception as e:
463
+ logger.error(f"Unexpected error sending photo {os.path.basename(img_path)}: {e}", exc_info=True)
464
+ if len(generated_images) > 1:
465
+ await update.message.reply_text(f"⚠️ Couldn't send style variant {i+1} due to an unexpected error.")
466
+ finally:
467
+ # Clean up the generated image file regardless of sending success
468
+ try:
469
+ if os.path.exists(img_path):
470
+ os.remove(img_path)
471
+ logger.debug(f"Cleaned up generated image: {os.path.basename(img_path)}")
472
+ except OSError as e:
473
+ logger.error(f"Error deleting generated image file {img_path}: {e}")
474
+
475
+ logger.info(f"Finished sending {sent_count}/{len(generated_images)} images.")
476
+
477
+ # --- Final Cleanup ---
478
+ # Clean up the originally downloaded user image
479
+ try:
480
+ if os.path.exists(temp_user_image_path):
481
+ os.remove(temp_user_image_path)
482
+ logger.info(f"Cleaned up temporary user image: {os.path.basename(temp_user_image_path)}")
483
+ except OSError as e:
484
+ logger.error(f"Error cleaning up user image {temp_user_image_path}: {e}", exc_info=True)
485
+
486
+
487
+ # --- Main Execution ---
488
+ if __name__ == "__main__":
489
+ logger.info("Starting Telegram Bot...")
490
+
491
+ if not Config.TELEGRAM_TOKEN:
492
+ logger.critical("TELEGRAM_TOKEN is not set in environment variables or .env file. Bot cannot start.")
493
+ exit(1) # Exit if token is missing
494
+
495
+ try:
496
+ # Build the application instance
497
+ app = Application.builder().token(Config.TELEGRAM_TOKEN).build()
498
+
499
+ # Add the handler for messages containing both a photo and a caption
500
+ # Filters.PHOTO checks for `message.photo` being non-empty
501
+ # Filters.CAPTION checks for `message.caption` being non-empty
502
+ app.add_handler(MessageHandler(filters.PHOTO & filters.CAPTION, handle_message))
503
+
504
+ logger.info("Bot application built. Starting polling...")
505
+ # Start the bot polling for updates
506
+ app.run_polling(allowed_updates=Update.ALL_TYPES) # Specify allowed updates if needed
507
+
508
+ except Exception as e:
509
+ logger.critical(f"Fatal error initializing or running the bot application: {e}", exc_info=True)
510
+ exit(1)