- •Проектная работа
- •Глава 1. Технологии разработки Telegram бота. 4
- •Глава 2. Разработка Telegram-бота 21
- •Перечень терминов и сокращений
- •Введение
- •Глава 1. Технологии разработки Telegram бота.
- •Обзор литературы
- •1.2 История мессенджера Telegram
- •1.3 Сравнительный анализ языков программирования для Telegram-ботов
- •1.4 Характеристика языка программирования Python
- •1.5 Обзор фреймворков и библиотек для создания ботов
- •1.6 Обзор библиотек и методов обработки изображений
- •1.7 Описание алгоритмов и форматов данных
- •1.7.1. Цветовые модели и форматы данных
- •1.7.2 Алгоритм палитризации
- •1.7.3 Алгоритм классификации изображений
- •1.7.4 Алгоритм сжатия jpeg
- •1.7.5 Алгоритм сжатия png
- •Глава 2. Разработка Telegram-бота
- •2.1 Планируемые возможности бота и подходы к их реализации
- •2.2 Разработка модуля обработки изображений
- •2.3 Разработка пользовательского интерфейса
- •2.4 Разработка функций для работы с изображениями и передачи данных
- •2.4.1 Импорты, настройки и словари
- •2.4.2 Пользовательские настройки
- •2.4.3 Функции обработки изображений
- •2.4.4 Функции работы с пользовательскими данными
- •2.4.6 Обработчики команд и сообщений
- •2.4.7 Основной цикл
- •2.5 Описание разработки Telegram-бота
- •2.6 Инструкция пользователя
- •Список литературы
- •Приложение
Список литературы
Курилов В. А. Технологии и средства проектирования программных систем. — Новосибирск: НГТУ, 2020. — 276 с.
Вигерс К., Битти Д. Разработка требований к программному обеспечению. 3-е изд., доп. — М.: Русская редакция, 2022. — 736 с.
Матвеева Н. Ю., Золотарюк А. В. Технологии создания и применения чат-ботов // Научные записки молодых исследователей. — 2018. — № 1. — С. 136.
Жеребцова Ю. А., Чижик А. В. Создание чат-бота: обзор архитектур и векторных представлений текста // International Journal of Open Information Technologies. — 2020. — Т. 8, № 7. — С. 56.
Фленов М. В. Программирование на Python. Полный курс. — М.: Эксмо, 2022. — 512 с.
Морозов С. В. Telegram-боты: от новичка до профи. — СПб.: Питер, 2023. — 304 с.
Скубриева Е. А., Агеев С. А. Разработка интерактивных систем на Python. — М.: НИЦ ИНФРА-М, 2021. — 198 с.
Aiogram Community. Aiogram Cookbook [Электронный ресурс]. – Режим доступа: https://github.com/aiogram/aiogram/blob/dev-3.x/docs, свободный. – Дата обращения: 09.06.2025.
PEP 492 – Coroutines with async and await syntax [Электронный ресурс]. – Режим доступа: https://peps.python.org/pep-0492/, свободный. – Дата обращения: 09.06.2025.
GitHub. Async SQLite usage examples [Электронный ресурс]. – Режим доступа: https://github.com/omnilib/aiosqlite, свободный. – Дата обращения: 09.06.2025.
Приложение
import os
import io
import re
import json
import uuid
from pathlib import Path
from typing import Optional, Tuple, Dict, Any, List
from dotenv import load_dotenv
from aiogram import Bot, Dispatcher, F
from aiogram.types import Message, BufferedInputFile, ReplyKeyboardRemove
from aiogram.filters import Command
from PIL import Image, ImageFilter, UnidentifiedImageError
import img2pdf
# ENV & рабочие каталоги
load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN")
WORKDIR = Path(os.getenv("WORKDIR", "./data")).resolve()
WORKDIR.mkdir(parents=True, exist_ok=True)
if not BOT_TOKEN:
raise SystemExit("BOT_TOKEN is not set in .env")
# Настройки по умолчанию
DEFAULT_PREFS = {
"quality": 80, # 1..95 (для JPEG)
"target_kb": None, # целевой размер (КБ) — если задан, качество подбираем бинпоиском
"max_w": None, # ограничение ширины (px)
"max_h": None, # ограничение высоты (px)
# «изюминка»
"auto": True, # авто-режим выбора формата (photo -> JPEG, graphics -> PNG-палитра)
"palette_colors": 128, # глубина палитры для графики (PNG-P)
"strip_exif": False # удалять EXIF/ICC для приватности и меньшего веса
}
# Временные PDF-сессии в RAM: {user_id: [bytes]}
PDF_SESSIONS: Dict[int, List[bytes]] = {}
# Работа с prefs на диске
def user_dir(user_id: int) -> Path:
d = WORKDIR / str(user_id)
d.mkdir(parents=True, exist_ok=True)
return d
def prefs_path(user_id: int) -> Path:
return user_dir(user_id) / "prefs.json"
def load_prefs(user_id: int) -> Dict[str, Any]:
p = prefs_path(user_id)
if p.exists():
try:
data = json.loads(p.read_text("utf-8"))
return {**DEFAULT_PREFS, **data}
except Exception:
return DEFAULT_PREFS.copy()
return DEFAULT_PREFS.copy()
def save_prefs(user_id: int, prefs: Dict[str, Any]) -> None:
pp = prefs_path(user_id)
merged = {**DEFAULT_PREFS, **prefs}
pp.write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
# Хелперы изображений
def _resize_if_needed(img: Image.Image, max_w: Optional[int], max_h: Optional[int]) -> Image.Image:
if not max_w and not max_h:
return img
w, h = img.size
tw = max_w or w
th = max_h or h
scale = min(tw / w, th / h)
if scale >= 1.0: # не апскейлим
return img
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
return img.resize(new_size, Image.LANCZOS)
def _apply_strip_exif(img: Image.Image, strip: bool) -> None:
# В Pillow EXIF хранится в info["exif"] (если есть)
if strip:
img.info.pop("exif", None)
img.info.pop("icc_profile", None)
def _save_jpeg_bytes(img: Image.Image, quality: int) -> bytes:
buf = io.BytesIO()
img.save(
buf,
format="JPEG",
quality=max(1, min(95, quality)),
optimize=True,
progressive=True, # лучше для «проявки» и часто компактнее
subsampling="4:2:0" # стандартный компромисс для фото
)
return buf.getvalue()
def _save_png_palettized_bytes(img: Image.Image, colors: int) -> bytes:
# Палитризация + дизеринг Флойда–Штайнберга — сильно уменьшает «графику»
pimg = img.convert("P", palette=Image.ADAPTIVE, colors=max(2, min(256, int(colors))),
dither=Image.FLOYDSTEINBERG)
buf = io.BytesIO()
# Для PNG стандартный дефлейт; Pillow сам подберёт фильтры строк (в т.ч. Paeth)
pimg.save(buf, format="PNG", optimize=True)
return buf.getvalue()
def classify_image(img: Image.Image) -> str:
"""
Простая эвристика:
- считаем число уникальных цветов на уменьшенной копии;
- считаем «резкость» краёв (FIND_EDGES) на уменьшенной копии;
Скриншоты/схемы обычно имеют мало цветов и низкую среднюю «резкость».
"""
rgb_small = img.convert("RGB").resize((256, 256))
colors = rgb_small.getcolors(maxcolors=1 << 20)
uniq = len(colors) if colors else (1 << 20)
edges = rgb_small.filter(ImageFilter.FIND_EDGES).convert("L")
edge_mean = sum(edges.getdata()) / (256 * 256)
# Пороговая логика
if uniq < 20000 and edge_mean < 40:
return "graphics"
return "photo"
def compress_image(
raw_bytes: bytes,
*,
quality: int = 80,
target_kb: Optional[int] = None,
max_w: Optional[int] = None,
max_h: Optional[int] = None,
auto: bool = True,
palette_colors: int = 128,
strip_exif: bool = False
) -> Tuple[bytes, str]:
"""
Возвращает: (bytes, filename)
- auto=True: если «graphics» -> PNG палитровый, иначе JPEG (с target_kb/quality).
- auto=False: всегда JPEG (с target_kb/quality).
"""
try:
with Image.open(io.BytesIO(raw_bytes)) as im:
# Приводим к удобному режиму
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
im = _resize_if_needed(im, max_w, max_h)
_apply_strip_exif(im, strip_exif)
def _jpeg_with_target(img: Image.Image, q_default: int, tgt_kb: Optional[int]) -> bytes:
if tgt_kb is None:
return _save_jpeg_bytes(img, q_default)
target = max(5, tgt_kb) * 1024
lo, hi = 5, 95
best = _save_jpeg_bytes(img, q_default)
# Бинарный поиск по качеству (ограничим ~10 итерациями)
for _ in range(10):
mid = (lo + hi) // 2
cand = _save_jpeg_bytes(img, mid)
if len(cand) > target:
hi = mid - 1
else:
best = cand
lo = mid + 1
return best
if auto:
kind = classify_image(im)
if kind == "graphics":
# PNG-палитра (почти без потерь, резко уменьшает скриншоты/схемы)
data = _save_png_palettized_bytes(im, palette_colors)
name = f"compressed_{uuid.uuid4().hex[:8]}.png"
return data, name
else:
data = _jpeg_with_target(im, quality, target_kb)
name = f"compressed_{uuid.uuid4().hex[:8]}.jpg"
return data, name
else:
# Ручной JPEG-путь (как в MVP)
data = _jpeg_with_target(im, quality, target_kb)
name = f"compressed_{uuid.uuid4().hex[:8]}.jpg"
return data, name
except UnidentifiedImageError:
raise ValueError("Не удалось прочитать изображение. Формат не поддерживается.")
def bytes_to_pdf(images: List[bytes]) -> bytes:
"""Собрать PDF из списка байтов изображений. Для совместимости приводим страницы к JPEG (RGB) перед конвертацией."""
jpg_pages = []
for raw in images:
with Image.open(io.BytesIO(raw)) as im:
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
b = io.BytesIO()
im.save(b, format="JPEG", quality=90, optimize=True)
jpg_pages.append(b.getvalue())
return img2pdf.convert(jpg_pages)
# Telegram bot wiring
bot = Bot(BOT_TOKEN)
dp = Dispatcher()
HELP = (
"PhotoCompressor Bot\n\n"
"/start — привет и помощь\n"
"/quality 60 — JPEG-качество по умолчанию\n"
"/target 200 — целевой размер в КБ (0 = выкл)\n"
"/maxsize 1920x1080 — ограничение W×H\n"
"/pdf_start — начать сбор PDF (шлите картинки)\n"
"/pdf_done — собрать PDF\n"
"/auto on|off — авто-режим выбора формата (фото→JPEG, графика→PNG)\n"
"/palette 64|128|256 — глубина палитры для PNG (авто-режим)\n"
"/strip_exif on|off — удалять EXIF/ICC для приватности\n\n"
"Пришлите изображение — бот сожмёт и вернёт как документ (без доп. сжатия Telegram)."
)
@dp.message(Command("start"))
async def cmd_start(message: Message):
save_prefs(message.from_user.id, load_prefs(message.from_user.id)) # ensure file exists
await message.answer(HELP, reply_markup=ReplyKeyboardRemove())
@dp.message(Command("quality"))
async def cmd_quality(message: Message):
m = re.search(r"/quality\s+(\d+)", message.text or "")
if not m:
return await message.reply("Формат: /quality 1..95")
q = int(m.group(1))
if not (1 <= q <= 95):
return await message.reply("Значение 1..95")
prefs = load_prefs(message.from_user.id)
prefs["quality"] = q
save_prefs(message.from_user.id, prefs)
await message.reply(f"OK. Качество по умолчанию: {q}")
@dp.message(Command("target"))
async def cmd_target(message: Message):
m = re.search(r"/target\s+(\d+)", message.text or "")
if not m:
return await message.reply("Формат: /target <КБ>. Укажи 0 чтобы отключить.")
kb = int(m.group(1))
prefs = load_prefs(message.from_user.id)
prefs["target_kb"] = None if kb == 0 else max(5, kb)
save_prefs(message.from_user.id, prefs)
val = "выключен" if kb == 0 else f"{prefs['target_kb']} КБ"
await message.reply(f"OK. Целевой размер: {val}")
@dp.message(Command("maxsize"))
async def cmd_maxsize(message: Message):
m = re.search(r"/maxsize\s+(\d+)x(\d+)", message.text or "")
if not m:
return await message.reply("Формат: /maxsize <W>x<H>, например 1920x1080. Укажи 0x0 чтобы снять лимит.")
w, h = int(m.group(1)), int(m.group(2))
prefs = load_prefs(message.from_user.id)
prefs["max_w"] = None if w <= 0 else w
prefs["max_h"] = None if h <= 0 else h
save_prefs(message.from_user.id, prefs)
if prefs["max_w"] or prefs["max_h"]:
await message.reply(f"OK. Максимум: {prefs['max_w'] or '—'}x{prefs['max_h'] or '—'}")
else:
await message.reply("OK. Ограничение размера отключено.")
@dp.message(Command("auto"))
async def cmd_auto(message: Message):
m = re.search(r"/auto\s+(on|off)", (message.text or "").lower())
if not m:
return await message.reply("Формат: /auto on|off")
val = m.group(1) == "on"
prefs = load_prefs(message.from_user.id)
prefs["auto"] = val
save_prefs(message.from_user.id, prefs)
await message.reply(f"Авто-режим: {'ON' if val else 'OFF'}")
@dp.message(Command("palette"))
async def cmd_palette(message: Message):
m = re.search(r"/palette\s+(\d+)", message.text or "")
if not m:
return await message.reply("Формат: /palette 64|128|256")
colors = int(m.group(1))
if colors not in (64, 128, 256):
return await message.reply("Допустимо: 64, 128, 256")
prefs = load_prefs(message.from_user.id)
prefs["palette_colors"] = colors
save_prefs(message.from_user.id, prefs)
await message.reply(f"OK. Палитра для PNG: {colors} цветов")
@dp.message(Command("strip_exif"))
async def cmd_strip_exif(message: Message):
m = re.search(r"/strip_exif\s+(on|off)", (message.text or "").lower())
if not m:
return await message.reply("Формат: /strip_exif on|off")
val = m.group(1) == "on"
prefs = load_prefs(message.from_user.id)
prefs["strip_exif"] = val
save_prefs(message.from_user.id, prefs)
await message.reply(f"EXIF/ICC удаление: {'ON' if val else 'OFF'}")
@dp.message(Command("pdf_start"))
async def cmd_pdf_start(message: Message):
PDF_SESSIONS[message.from_user.id] = []
await message.reply("PDF-режим: пришлите картинки в нужном порядке. Завершите /pdf_done")
@dp.message(Command("pdf_done"))
async def cmd_pdf_done(message: Message):
imgs = PDF_SESSIONS.pop(message.from_user.id, [])
if not imgs:
return await message.reply("Нечего собирать — пришлите картинки после /pdf_start")
try:
pdf_bytes = bytes_to_pdf(imgs)
fname = f"photos_{uuid.uuid4().hex[:8]}.pdf"
await message.answer_document(BufferedInputFile(pdf_bytes, filename=fname))
except Exception as e:
await message.reply(f"Ошибка сборки PDF: {e}")
# Приём изображений
async def _download_any_image_bytes(msg: Message) -> Optional[bytes]:
# Универсальная загрузка байтов файла под aiogram v3
async def _dl(file_id: str) -> bytes:
f = await bot.get_file(file_id)
buf = io.BytesIO()
await bot.download(f, destination=buf)
return buf.getvalue()
# Предпочитаем document (без сжатия Telegram)
if msg.document and (msg.document.mime_type or "").startswith("image/"):
return await _dl(msg.document.file_id)
# Иначе photo (Telegram уже сжал). Берём самую большую версию
if msg.photo:
return await _dl(msg.photo[-1].file_id)
return None
@dp.message(F.document | F.photo)
async def on_image(message: Message):
raw = await _download_any_image_bytes(message)
if raw is None:
return # игнорируем нерелевантные документы
# Если пользователь в PDF-режиме — накапливаем оригиналы
if message.from_user.id in PDF_SESSIONS:
PDF_SESSIONS[message.from_user.id].append(raw)
return await message.reply("Добавлено в PDF-сессию")
# Иначе — сжимаем и выдаём готовый файл
prefs = load_prefs(message.from_user.id)
try:
out_bytes, name = compress_image(
raw,
quality=prefs["quality"],
target_kb=prefs["target_kb"],
max_w=prefs["max_w"],
max_h=prefs["max_h"],
auto=prefs["auto"],
palette_colors=prefs["palette_colors"],
strip_exif=prefs["strip_exif"],
)
await message.answer_document(BufferedInputFile(out_bytes, filename=name))
except ValueError as ve:
await message.reply(str(ve))
except Exception as e:
await message.reply(f"Ошибка обработки: {e}")
if __name__ == "__main__":
import asyncio
async def main():
print("PhotoCompressor Bot started")
await dp.start_polling(bot, allowed_updates=["message", "edited_message"])
asyncio.run(main())
Санкт-Петербург 2025
