commit a20e4719afd4ebb7df8144b3faa948caf15af7db Author: Danil Kolesnikov Date: Thu Mar 26 19:06:08 2026 +0400 initial verstion diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d759d1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Use a specific version for stability +FROM python:3.11-slim + +# Install system dependencies for image processing +RUN apt-get update && apt-get install -y \ + libheif-dev \ + libde265-0 \ + libjpeg62-turbo \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install python dependencies +# Using --no-cache-dir to keep the image small +RUN pip install --no-cache-dir \ + aiogram \ + pillow-heif \ + Pillow \ + prometheus_client \ + aiohttp + +# Copy your bot code +COPY bot.py . + +# Standardize ports +EXPOSE 8000 8080 + +CMD ["python", "bot.py"] diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..8241be2 --- /dev/null +++ b/bot.py @@ -0,0 +1,115 @@ +import asyncio +import io +import logging +import os +from typing import Any, Dict, List, Union + +import pillow_heif +from aiogram import BaseMiddleware, Bot, Dispatcher, F, types +from aiogram.types import BufferedInputFile, Message +from aiogram.utils.media_group import MediaGroupBuilder +from aiohttp import web +from PIL import Image +from prometheus_client import Counter, start_http_server + +# --- CONFIG & METRICS --- +API_TOKEN = os.getenv("BOT_TOKEN") +METRICS_PORT = 8000 +HEALTH_PORT = 8080 + +CONVERSION_COUNT = Counter("heic_conversions_total", "Total HEIC to JPEG conversions") +logging.basicConfig(level=logging.INFO) +pillow_heif.register_heif_opener() + +if not API_TOKEN: + raise ValueError("No BOT_TOKEN found in environment variables") + + +# --- MIDDLEWARE FOR ALBUMS --- +class AlbumMiddleware(BaseMiddleware): + """Groups multiple messages in a media group into a single list.""" + + def __init__(self, latency: float = 0.6): + self.latency = latency + self.album_data: Dict[str, List[Message]] = {} + + async def __call__(self, handler, event: Message, data: Dict[str, Any]): + if not event.media_group_id: + return await handler(event, data) + + if event.media_group_id not in self.album_data: + self.album_data[event.media_group_id] = [event] + await asyncio.sleep(self.latency) + data["album"] = self.album_data.pop(event.media_group_id) + return await handler(event, data) + + self.album_data[event.media_group_id].append(event) + return + + +# --- HANDLERS --- +async def convert_image(bot: Bot, msg: Message) -> BufferedInputFile: + """Helper to download and convert a single HEIC to JPEG.""" + file_id = msg.document.file_id if msg.document else msg.photo[-1].file_id + file_name = msg.document.file_name if msg.document else f"{file_id}.heic" + + file = await bot.get_file(file_id) + heic_buffer = await bot.download_file(file.file_path) + + with Image.open(heic_buffer) as img: + jpeg_buffer = io.BytesIO() + img.convert("RGB").save(jpeg_buffer, format="JPEG", quality=95) + jpeg_buffer.seek(0) + + new_name = file_name.rsplit(".", 1)[0] + ".jpg" + CONVERSION_COUNT.inc() + return BufferedInputFile(jpeg_buffer.read(), filename=new_name) + + +@dp.message(F.media_group_id) +async def handle_album(message: Message, album: List[Message], bot: Bot): + """Handles multiple HEIC images sent as an album.""" + media_group = MediaGroupBuilder(caption="Converted Images") + for msg in album: + if ( + msg.document and msg.document.file_name.lower().endswith(".heic") + ) or msg.photo: + conv_file = await convert_image(bot, msg) + media_group.add_document(media=conv_file) + + await message.answer_media_group(media=media_group.build()) + + +@dp.message(F.document.file_name.lower().endswith(".heic")) +async def handle_single_doc(message: Message, bot: Bot): + """Handles a single HEIC file.""" + jpeg_file = await convert_image(bot, message) + await message.reply_document(document=jpeg_file) + + +# --- K8S HEALTH CHECK --- +async def health_check(request): + return web.Response(text="OK", status=200) + + +# --- MAIN --- +async def main(): + bot = Bot(token=API_TOKEN) + dp = Dispatcher() + dp.message.middleware(AlbumMiddleware()) + + # Start Prometheus + start_http_server(METRICS_PORT) + + # Start Health Check Server + server = web.Application() + server.router.add_get("/healthz", health_check) + runner = web.AppRunner(server) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", HEALTH_PORT) + + await asyncio.gather(site.start(), dp.start_polling(bot)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..beab8c0 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,14 @@ +services: + heic-bot: + image: + build: . + container_name: heic-converter + environment: + - BOT_TOKEN=${BOT_TOKEN} + ports: + - "8000:8000" # Prometheus Metrics + - "8080:8080" # Health Checks + volumes: + # Optional: mount a local folder if you want to log to file + - ./logs:/app/logs + restart: unless-stopped diff --git a/k8s/base/bot.yaml b/k8s/base/bot.yaml new file mode 100644 index 0000000..a32ce11 --- /dev/null +++ b/k8s/base/bot.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: heic-converter-bot +spec: + replicas: 1 + selector: + matchLabels: + app: heic-bot + template: + metadata: + labels: + app: heic-bot + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8000" + spec: + containers: + - name: bot + image: git.danilkolesnikov.ru/danilko09/heic-bot:latest + env: + - name: BOT_TOKEN + valueFrom: + configMapKeyRef: + name: heic-bot-config + key: BOT_TOKEN + ports: + - containerPort: 8000 + name: metrics + - containerPort: 8080 + name: health + # Critical for image processing pods + resources: + requests: + memory: "256Mi" + cpu: "200m" + limits: + memory: "1Gi" + cpu: "1000m" + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 0000000..9519a26 --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,2 @@ +resources: + - deployment.yaml diff --git a/k8s/overlays/prod/kustomization.yaml b/k8s/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..d5f6aea --- /dev/null +++ b/k8s/overlays/prod/kustomization.yaml @@ -0,0 +1,3 @@ +namespace: iphone_to_jpeg_bot_prod +resources: + - ../../base diff --git a/k8s/templates/bot_config.yaml b/k8s/templates/bot_config.yaml new file mode 100644 index 0000000..9aa9b0c --- /dev/null +++ b/k8s/templates/bot_config.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: heic-bot-config +data: + BOT_TOKEN: "YOUR_TELEGRAM_BOT_TOKEN_HERE" diff --git a/skaffold.yaml b/skaffold.yaml new file mode 100644 index 0000000..ee56458 --- /dev/null +++ b/skaffold.yaml @@ -0,0 +1,19 @@ +apiVersion: skaffold/v4beta13 +kind: Config +metadata: + name: iphone-to-jpeg-bot +build: + artifacts: + - image: git.danilkolesnikov.ru/danilko09/heic-bot + docker: + dockerfile: Dockerfile +manifests: + kustomize: + paths: + - k8s/base +profiles: + - name: prod + manifests: + kustomize: + paths: + - k8s/overlays/prod