understanding commited on
Commit
06b39f0
·
verified ·
1 Parent(s): 96c728b

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +406 -0
app.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import asyncio
3
+ import json
4
+ from telethon import TelegramClient, events
5
+ from telethon.tl import types # For DocumentAttributeFilename
6
+ from PIL import Image, ImageDraw, ImageFont
7
+
8
+ # --- Configuration ---
9
+ try:
10
+ from dotenv import load_dotenv
11
+ load_dotenv()
12
+ except ImportError:
13
+ pass
14
+
15
+ API_ID = os.environ.get('API_ID')
16
+ API_HASH = os.environ.get('API_HASH')
17
+ BOT_TOKEN = os.environ.get('BOT_TOKEN')
18
+
19
+ if not all([API_ID, API_HASH, BOT_TOKEN]):
20
+ print("CRITICAL: API_ID, API_HASH, and BOT_TOKEN environment variables must be set.")
21
+ exit(1)
22
+ try:
23
+ API_ID = int(API_ID)
24
+ except ValueError:
25
+ print("CRITICAL: API_ID must be an integer.")
26
+ exit(1)
27
+
28
+ # Adjusted session path
29
+ SESSION_NAME = 'session/image_bot_session' # Store session file in 'session' subdirectory
30
+ bot = TelegramClient(SESSION_NAME, API_ID, API_HASH)
31
+
32
+
33
+ # --- Paths and Constants ---
34
+ DATA_DIR = "data"
35
+ DOWNLOADS_DIR = "downloads" # Changed from TEMP_DIR
36
+ CONFIG_FILENAME = "config.json"
37
+ TEMPLATE_FILENAME = "user_template.png"
38
+ USER_FONT_FILENAME = "user_font.ttf"
39
+
40
+ CONFIG_PATH = os.path.join(DATA_DIR, CONFIG_FILENAME)
41
+ TEMPLATE_PATH = os.path.join(DATA_DIR, TEMPLATE_FILENAME)
42
+ USER_FONT_PATH = os.path.join(DATA_DIR, USER_FONT_FILENAME)
43
+
44
+ # Ensure base directories exist (Dockerfile should create them, but this is a good fallback)
45
+ # The Dockerfile now creates these, but os.makedirs with exist_ok=True is safe.
46
+ os.makedirs(DATA_DIR, exist_ok=True)
47
+ os.makedirs(DOWNLOADS_DIR, exist_ok=True)
48
+ os.makedirs("session", exist_ok=True) # Ensure the 'session' directory for Telethon exists
49
+
50
+ # --- Default Configuration (if config.json is missing/invalid) ---
51
+ DEFAULT_CONFIG = {
52
+ "raw_image_box": {"x": 40, "y": 40, "width": 1000, "height": 780},
53
+ "caption_area": {"x": 60, "y": 870},
54
+ "caption_font_size": 45,
55
+ "caption_color": "white",
56
+ "line_spacing": 10
57
+ }
58
+ bot_config = {}
59
+
60
+ # --- Config Management ---
61
+ def load_bot_config():
62
+ global bot_config
63
+ try:
64
+ if os.path.exists(CONFIG_PATH):
65
+ with open(CONFIG_PATH, 'r') as f:
66
+ loaded_values = json.load(f)
67
+ bot_config = DEFAULT_CONFIG.copy()
68
+ bot_config.update(loaded_values)
69
+ else:
70
+ bot_config = DEFAULT_CONFIG.copy()
71
+ save_bot_config() # Ensure file exists with all keys
72
+ print(f"Configuration loaded/initialized: {bot_config}")
73
+ except Exception as e:
74
+ print(f"Error loading config, using defaults: {e}")
75
+ bot_config = DEFAULT_CONFIG.copy()
76
+ save_bot_config()
77
+
78
+ def save_bot_config():
79
+ global bot_config
80
+ try:
81
+ with open(CONFIG_PATH, 'w') as f:
82
+ json.dump(bot_config, f, indent=4)
83
+ except Exception as e:
84
+ print(f"Error saving config: {e}")
85
+
86
+ # --- User State Management ---
87
+ user_sessions = {}
88
+
89
+ # --- Helper Functions (Image Processing) ---
90
+ async def generate_image_with_frame(raw_image_path, frame_template_path, caption_text, chat_id):
91
+ output_filename = f"final_image_{chat_id}.png"
92
+ # Save final output to DOWNLOADS_DIR before sending, then clean up
93
+ output_path = os.path.join(DOWNLOADS_DIR, output_filename)
94
+
95
+
96
+ cfg_raw_box = bot_config.get("raw_image_box", DEFAULT_CONFIG["raw_image_box"])
97
+ cfg_caption_area = bot_config.get("caption_area", DEFAULT_CONFIG["caption_area"])
98
+ cfg_font_size = bot_config.get("caption_font_size", DEFAULT_CONFIG["caption_font_size"])
99
+ cfg_caption_color = bot_config.get("caption_color", DEFAULT_CONFIG["caption_color"])
100
+ cfg_line_spacing = bot_config.get("line_spacing", DEFAULT_CONFIG["line_spacing"])
101
+
102
+ try:
103
+ raw_img = Image.open(raw_image_path).convert("RGBA")
104
+ frame_template_img = Image.open(frame_template_path).convert("RGBA")
105
+
106
+ raw_img_copy = raw_img.copy()
107
+ raw_img_copy.thumbnail((cfg_raw_box['width'], cfg_raw_box['height']), Image.LANCZOS)
108
+
109
+ final_img = frame_template_img.copy()
110
+ paste_x = cfg_raw_box['x'] + (cfg_raw_box['width'] - raw_img_copy.width) // 2
111
+ paste_y = cfg_raw_box['y'] + (cfg_raw_box['height'] - raw_img_copy.height) // 2
112
+ final_img.paste(raw_img_copy, (paste_x, paste_y), raw_img_copy if raw_img_copy.mode == 'RGBA' else None)
113
+
114
+ draw = ImageDraw.Draw(final_img)
115
+ font_to_use = None
116
+ if os.path.exists(USER_FONT_PATH):
117
+ try:
118
+ font_to_use = ImageFont.truetype(USER_FONT_PATH, cfg_font_size)
119
+ except IOError:
120
+ print(f"User font at '{USER_FONT_PATH}' found but couldn't be loaded. Using fallback.")
121
+ if not font_to_use:
122
+ try:
123
+ font_to_use = ImageFont.truetype("arial.ttf", cfg_font_size)
124
+ except IOError:
125
+ print("Arial.ttf not found, using Pillow's load_default(). This may look very basic.")
126
+ font_to_use = ImageFont.load_default().font_variant(size=cfg_font_size)
127
+
128
+ lines = caption_text.split('\n')
129
+ current_y = cfg_caption_area['y']
130
+ for line in lines:
131
+ try:
132
+ line_bbox = draw.textbbox((0, 0), line, font=font_to_use)
133
+ line_height = line_bbox[3] - line_bbox[1]
134
+ except AttributeError:
135
+ (text_width, text_height) = draw.textsize(line, font=font_to_use)
136
+ line_height = text_height
137
+
138
+ draw.text((cfg_caption_area['x'], current_y), line, font=font_to_use, fill=cfg_caption_color)
139
+ current_y += line_height + cfg_line_spacing
140
+
141
+ final_img.convert("RGB").save(output_path, "PNG")
142
+ return output_path
143
+
144
+ except FileNotFoundError as fnf_err:
145
+ print(f"Error: Image file not found during processing. Template: {frame_template_path}, Raw: {raw_image_path}. Error: {fnf_err}")
146
+ return None
147
+ except Exception as e:
148
+ print(f"Error during image processing: {e}")
149
+ import traceback
150
+ traceback.print_exc()
151
+ return None
152
+
153
+ # --- Bot Event Handlers --- (Mostly same as before, checking paths for DOWNLOADS_DIR)
154
+ @bot.on(events.NewMessage(pattern='/start'))
155
+ async def start_handler(event):
156
+ chat_id = event.chat_id
157
+ user_sessions[chat_id] = {'state': 'idle', 'data': {}}
158
+ start_message = (
159
+ "Welcome! This bot helps you create images with a template.\n\n"
160
+ "**Initial Setup (if first time or after a reset):**\n"
161
+ "1. `/settemplate` - Upload your frame template image (PNG/JPG).\n"
162
+ "2. `/setfont` - Upload your .ttf font file for captions.\n"
163
+ "3. `/help_config` - Learn how to set layout parameters.\n"
164
+ "4. `/setconfig <key> <value>` - Adjust layout as needed.\n\n"
165
+ "**Regular Use:**\n"
166
+ "`/create` - Start creating a new image.\n"
167
+ "`/viewconfig` - See current layout settings.\n"
168
+ "`/cancel` - Cancel current operation.\n\n"
169
+ "**Note on Hosting:** The `data/` and `session/` directories should ideally use persistent storage on your hosting platform. "
170
+ "If using ephemeral storage (common on free tiers), your uploaded template, font, config, and session might be lost on restarts, requiring setup again."
171
+ )
172
+ await event.reply(start_message)
173
+
174
+ @bot.on(events.NewMessage(pattern='/settemplate'))
175
+ async def set_template_handler(event):
176
+ chat_id = event.chat_id
177
+ user_sessions[chat_id] = {'state': 'awaiting_template_image', 'data': {}}
178
+ await event.reply("Please send your frame template image (e.g., a PNG or JPG).")
179
+
180
+ @bot.on(events.NewMessage(pattern='/setfont'))
181
+ async def set_font_handler(event):
182
+ chat_id = event.chat_id
183
+ user_sessions[chat_id] = {'state': 'awaiting_font_file', 'data': {}}
184
+ await event.reply("Please send your `.ttf` font file as a document/file.")
185
+
186
+ @bot.on(events.NewMessage(pattern='/viewconfig'))
187
+ async def view_config_handler(event):
188
+ load_bot_config()
189
+ config_str = "Current Bot Configuration:\n"
190
+ config_str += f"- Font: {'User font set (' + USER_FONT_FILENAME + ')' if os.path.exists(USER_FONT_PATH) else 'Using fallback font (Arial or Pillow default)'}\n"
191
+ for key, value in bot_config.items():
192
+ config_str += f"- {key}: {json.dumps(value)}\n"
193
+ await event.reply(config_str)
194
+
195
+ @bot.on(events.NewMessage(pattern='/help_config'))
196
+ async def help_config_handler(event):
197
+ await event.reply(
198
+ "**Configuration Commands (`/setconfig <key> <value>`):**\n"
199
+ "`raw_image_box_x <num>` (e.g., 40)\n"
200
+ "`raw_image_box_y <num>`\n"
201
+ "`raw_image_box_width <num>`\n"
202
+ "`raw_image_box_height <num>`\n"
203
+ "`caption_area_x <num>`\n"
204
+ "`caption_area_y <num>`\n"
205
+ "`caption_font_size <num>` (e.g., 45)\n"
206
+ "`caption_color <name_or_hex>` (e.g., white or #FFFFFF)\n"
207
+ "`line_spacing <num>` (e.g., 10)\n\n"
208
+ "Example: `/setconfig caption_font_size 50`\n"
209
+ "Use `/viewconfig` to see current values. "
210
+ "These settings define how the raw image and caption are placed on your template."
211
+ )
212
+
213
+ @bot.on(events.NewMessage(pattern=r'/setconfig (\w+) (.+)'))
214
+ async def set_config_handler(event):
215
+ key_to_set = event.pattern_match.group(1).strip()
216
+ value_str = event.pattern_match.group(2).strip()
217
+
218
+ load_bot_config()
219
+ original_config_copy = json.dumps(bot_config)
220
+ updated = False
221
+ sub_key = None # Initialize sub_key
222
+
223
+ try:
224
+ if key_to_set.startswith("raw_image_box_"):
225
+ sub_key = key_to_set.replace("raw_image_box_", "")
226
+ if "raw_image_box" in bot_config and sub_key in bot_config["raw_image_box"]:
227
+ bot_config["raw_image_box"][sub_key] = int(value_str)
228
+ updated = True
229
+ elif key_to_set.startswith("caption_area_"):
230
+ sub_key = key_to_set.replace("caption_area_", "")
231
+ if "caption_area" in bot_config and sub_key in bot_config["caption_area"]:
232
+ bot_config["caption_area"][sub_key] = int(value_str)
233
+ updated = True
234
+ elif key_to_set in ["caption_font_size", "line_spacing"]:
235
+ bot_config[key_to_set] = int(value_str)
236
+ updated = True
237
+ elif key_to_set == "caption_color":
238
+ bot_config[key_to_set] = value_str
239
+ updated = True
240
+
241
+ if updated:
242
+ if json.dumps(bot_config) != original_config_copy:
243
+ save_bot_config()
244
+ # Construct reply value more carefully
245
+ reply_value = value_str
246
+ if sub_key: # if it was a nested key
247
+ if key_to_set.startswith("raw_image_box_"):
248
+ reply_value = bot_config.get("raw_image_box", {}).get(sub_key, value_str)
249
+ elif key_to_set.startswith("caption_area_"):
250
+ reply_value = bot_config.get("caption_area", {}).get(sub_key, value_str)
251
+ else:
252
+ reply_value = bot_config.get(key_to_set, value_str)
253
+ await event.reply(f"Configuration updated: {key_to_set} = {reply_value}")
254
+ else:
255
+ await event.reply(f"Value for {key_to_set} is already {value_str}. No change made.")
256
+ else:
257
+ await event.reply(f"Unknown or non-configurable key: '{key_to_set}'. See `/help_config`.")
258
+ except ValueError:
259
+ await event.reply(f"Invalid value for '{key_to_set}'. Please provide a number where expected (e.g., for sizes, coordinates).")
260
+ except Exception as e:
261
+ await event.reply(f"Error setting config: {e}")
262
+
263
+ @bot.on(events.NewMessage(pattern='/create'))
264
+ async def create_post_handler(event):
265
+ chat_id = event.chat_id
266
+ if not os.path.exists(TEMPLATE_PATH):
267
+ await event.reply("No template found. Please set one using `/settemplate` first.")
268
+ return
269
+ if not os.path.exists(USER_FONT_PATH):
270
+ await event.reply("Warning: No user font found (use `/setfont`). A fallback font will be attempted, but results may vary.")
271
+
272
+ user_sessions[chat_id] = {'state': 'awaiting_raw_image_for_create', 'data': {}}
273
+ await event.reply("Please send the raw image you want to use.")
274
+
275
+ @bot.on(events.NewMessage(pattern='/cancel'))
276
+ async def cancel_handler(event):
277
+ chat_id = event.chat_id
278
+ if chat_id in user_sessions and user_sessions[chat_id]['state'] != 'idle':
279
+ temp_raw_path = user_sessions[chat_id].get('data', {}).get('raw_image_path')
280
+ if temp_raw_path and os.path.exists(temp_raw_path):
281
+ try: os.remove(temp_raw_path)
282
+ except OSError as e: print(f"Error deleting temp file {temp_raw_path}: {e}")
283
+ user_sessions[chat_id] = {'state': 'idle', 'data': {}}
284
+ await event.reply("Operation cancelled.")
285
+ else:
286
+ await event.reply("Nothing to cancel.")
287
+
288
+ @bot.on(events.NewMessage)
289
+ async def message_handler(event): # pylint: disable=too-many-branches
290
+ chat_id = event.chat_id
291
+ if chat_id not in user_sessions:
292
+ user_sessions[chat_id] = {'state': 'idle', 'data': {}}
293
+
294
+ if event.text and event.text.startswith(('/', '#', '.')):
295
+ return # Let specific command handlers take precedence
296
+
297
+ current_state = user_sessions.get(chat_id, {}).get('state', 'idle')
298
+ session_data = user_sessions.get(chat_id, {}).get('data', {})
299
+
300
+ if event.document and current_state == 'awaiting_font_file':
301
+ is_ttf = False
302
+ if hasattr(event.document, 'mime_type') and \
303
+ ('font' in event.document.mime_type or 'ttf' in event.document.mime_type or 'opentype' in event.document.mime_type):
304
+ is_ttf = True
305
+ if not is_ttf and hasattr(event.document, 'attributes'):
306
+ for attr in event.document.attributes:
307
+ if isinstance(attr, types.DocumentAttributeFilename) and attr.file_name.lower().endswith('.ttf'):
308
+ is_ttf = True
309
+ break
310
+ if is_ttf:
311
+ await event.reply("Downloading font file...")
312
+ try:
313
+ await bot.download_media(event.message.document, USER_FONT_PATH)
314
+ user_sessions[chat_id]['state'] = 'idle'
315
+ await event.reply(f"Font saved as '{USER_FONT_FILENAME}' in `data/` directory!")
316
+ except Exception as e:
317
+ await event.reply(f"Sorry, I couldn't save the font. Error: {e}")
318
+ user_sessions[chat_id]['state'] = 'idle'
319
+ else:
320
+ await event.reply("That doesn't look like a .ttf font file. Please send a valid .ttf font file, or use /cancel.")
321
+ return
322
+
323
+ if event.photo:
324
+ if current_state == 'awaiting_template_image':
325
+ await event.reply("Downloading template image...")
326
+ try:
327
+ await bot.download_media(event.message.photo, TEMPLATE_PATH)
328
+ user_sessions[chat_id]['state'] = 'idle'
329
+ await event.reply(f"Template saved as '{TEMPLATE_FILENAME}' in `data/` directory!")
330
+ except Exception as e:
331
+ await event.reply(f"Couldn't save template image. Error: {e}")
332
+ user_sessions[chat_id]['state'] = 'idle'
333
+ return
334
+ elif current_state == 'awaiting_raw_image_for_create':
335
+ raw_img_filename = f"raw_{chat_id}_{event.message.id}.jpg"
336
+ # Save raw images to DOWNLOADS_DIR
337
+ raw_img_temp_path = os.path.join(DOWNLOADS_DIR, raw_img_filename)
338
+ await event.reply("Downloading raw image...")
339
+ try:
340
+ await bot.download_media(event.message.photo, raw_img_temp_path)
341
+ session_data['raw_image_path'] = raw_img_temp_path
342
+ user_sessions[chat_id]['state'] = 'awaiting_caption_for_create'
343
+ await event.reply("Raw image received. Now, please send the caption text.")
344
+ except Exception as e:
345
+ await event.reply(f"Couldn't save raw image. Error: {e}")
346
+ user_sessions[chat_id]['state'] = 'idle'
347
+ return
348
+
349
+ if event.text and not event.text.startswith('/') and current_state == 'awaiting_caption_for_create':
350
+ caption_text = event.text
351
+ raw_image_path = session_data.get('raw_image_path')
352
+
353
+ if not raw_image_path or not os.path.exists(raw_image_path):
354
+ await event.reply("Error: Raw image data not found. Try `/create` again.")
355
+ user_sessions[chat_id]['state'] = 'idle'
356
+ return
357
+
358
+ processing_msg = await event.reply("⏳ Processing your image, please wait...")
359
+ final_image_path = await generate_image_with_frame(raw_image_path, TEMPLATE_PATH, caption_text, chat_id)
360
+
361
+ try: await bot.delete_messages(chat_id, processing_msg)
362
+ except Exception: pass # noqa
363
+
364
+ if final_image_path and os.path.exists(final_image_path):
365
+ try:
366
+ await bot.send_file(chat_id, final_image_path, caption="🖼️ Here's your generated image!")
367
+ except Exception as e:
368
+ await event.reply(f"Sorry, couldn't send the final image. Error: {e}")
369
+ finally:
370
+ if os.path.exists(final_image_path): os.remove(final_image_path)
371
+ else:
372
+ await event.reply("❌ Sorry, something went wrong while creating the image.")
373
+
374
+ if os.path.exists(raw_image_path): os.remove(raw_image_path)
375
+ user_sessions[chat_id]['state'] = 'idle'
376
+ user_sessions[chat_id]['data'] = {}
377
+ return
378
+
379
+ if event.text and not event.text.startswith('/') and current_state != 'idle':
380
+ await event.reply(f"I'm currently waiting for: `{current_state}`. If you're stuck, try `/cancel`.")
381
+
382
+ # --- Main Bot Execution ---
383
+ async def main():
384
+ print("Bot starting...")
385
+ load_bot_config()
386
+
387
+ try:
388
+ print(f"Connecting to Telegram with API_ID: {str(API_ID)[:4]}... (masked)")
389
+ await bot.start(bot_token=BOT_TOKEN)
390
+ print("Bot is connected and running!")
391
+ me = await bot.get_me()
392
+ print(f"Logged in as: {me.username} (ID: {me.id})")
393
+ await bot.run_until_disconnected()
394
+ except Exception as e:
395
+ print(f"CRITICAL: An error occurred while running the bot: {e}")
396
+ import traceback
397
+ traceback.print_exc()
398
+ finally:
399
+ print("Bot is stopping...")
400
+ if bot.is_connected():
401
+ await bot.disconnect()
402
+ print("Bot has disconnected.")
403
+
404
+ if __name__ == '__main__':
405
+ asyncio.run(main())
406
+