From 745046c638afa8ff3546dbd1418ff4e6b402d83e Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Wed, 20 Aug 2025 21:10:31 +0900 Subject: [PATCH] init commit --- .env.example | 16 ++++ .gitignore | 6 ++ Dockerfile | 16 ++++ README.md | 73 +++++++++++++++- alembic.ini | 35 ++++++++ app/__init__.py | 1 + app/bot/__init__.py | 1 + app/bot/handlers/__init__.py | 1 + app/bot/handlers/add_group.py | 123 +++++++++++++++++++++++++++ app/bot/handlers/callbacks.py | 118 +++++++++++++++++++++++++ app/bot/handlers/drafts.py | 79 +++++++++++++++++ app/bot/handlers/join_info.py | 51 +++++++++++ app/bot/handlers/media.py | 51 +++++++++++ app/bot/handlers/start.py | 36 ++++++++ app/bot/handlers/utils.py | 43 ++++++++++ app/bot/keyboards/__init__.py | 1 + app/bot/keyboards/common.py | 21 +++++ app/bot/messages.py | 72 ++++++++++++++++ app/config.py | 23 +++++ app/db/__init__.py | 1 + app/db/base.py | 3 + app/db/models.py | 56 ++++++++++++ app/db/session.py | 10 +++ app/main.py | 37 ++++++++ app/migrations/README | 1 + app/migrations/env.py | 72 ++++++++++++++++ app/migrations/script.py.mako | 20 +++++ app/migrations/versions/0001_init.py | 60 +++++++++++++ docker-compose.yml | 28 ++++++ entrypoint.sh | 15 ++++ requirements.txt | 5 ++ 31 files changed, 1074 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/bot/__init__.py create mode 100644 app/bot/handlers/__init__.py create mode 100644 app/bot/handlers/add_group.py create mode 100644 app/bot/handlers/callbacks.py create mode 100644 app/bot/handlers/drafts.py create mode 100644 app/bot/handlers/join_info.py create mode 100644 app/bot/handlers/media.py create mode 100644 app/bot/handlers/start.py create mode 100644 app/bot/handlers/utils.py create mode 100644 app/bot/keyboards/__init__.py create mode 100644 app/bot/keyboards/common.py create mode 100644 app/bot/messages.py create mode 100644 app/config.py create mode 100644 app/db/__init__.py create mode 100644 app/db/base.py create mode 100644 app/db/models.py create mode 100644 app/db/session.py create mode 100644 app/main.py create mode 100644 app/migrations/README create mode 100644 app/migrations/env.py create mode 100644 app/migrations/script.py.mako create mode 100644 app/migrations/versions/0001_init.py create mode 100644 docker-compose.yml create mode 100755 entrypoint.sh create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..80870f9 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# === Telegram === +BOT_TOKEN=123456789:ABCDEF_your_token_here + +# === Database (choose) === +# Preferred single URL: +#DATABASE_URL=postgresql+psycopg://postgres:postgres@db:5432/tg_poster + +# Or compose parts: +DB_HOST=db +DB_PORT=5432 +DB_NAME=tg_poster +DB_USER=postgres +DB_PASSWORD=postgres + +# Logging level: DEBUG, INFO, WARNING, ERROR +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index 36b13f1..60132f0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ __pycache__/ *.py[cod] *$py.class +# env +.env +.venv/ + +#git +.history/ # C extensions *.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd13762 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# System deps +RUN apt-get update -y && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN chmod +x ./entrypoint.sh + +ENV PYTHONUNBUFFERED=1 +CMD ["./entrypoint.sh"] diff --git a/README.md b/README.md index 7925e1d..d24d5ed 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,73 @@ -# tg_post_min +# TG Poster Bot +Бот для конструирования сообщений (текст + фото/видео/анимация) и отправки в выбранные группы/каналы. +Привязка чатов — вручную: пользователь добавляет бота в чат, затем в ЛС выполняет `/add_group` и вставляет chat_id или пересылает сообщение из чата. + +## Стек +- Python 3.12 +- python-telegram-bot v21 +- SQLAlchemy 2.x +- Alembic +- Docker & Compose +- Drone CI pipeline + +## Быстрый старт (локально с Docker) +1. Скопируйте `.env.example` в `.env` и заполните `BOT_TOKEN` (получите у @BotFather). +2. Запустите: + ```bash + docker compose up --build + ``` +3. Бот сам применит миграции и запустится. + +## Команды бота +- `/add_group` — привязать группу/канал (вставьте chat_id `-100...` или перешлите сообщение из чата) +- `/groups` — список привязанных чатов и признак права публикации +- `/new` — создать черновик, затем отправить его в выбранный чат +- `/help` — краткая справка + +## Миграции +Alembic уже настроен. Первичная ревизия `0001_init` добавлена. В контейнере миграции запускаются автоматически в `entrypoint.sh`. + +Локально: +```bash +# создать новую ревизию +alembic revision -m "feature: add something" + +# применить миграции +alembic upgrade head + +# откатиться +alembic downgrade -1 +``` + +## Настройка Drone CI +- В секретах репозитория создайте: + - `docker_username` + - `docker_password` + - `docker_repo` (например, `registry.example.com/namespace/tg-poster-bot`) +- (Опционально для деплоя) `ssh_host`, `ssh_user`, `ssh_key`, `ssh_port`, и переменная окружения `DEPLOY_DIR` в настройках репозитория. + +## Структура проекта +``` +app/ + bot/ + handlers/ # команды и коллбэки + keyboards/ # inline-клавиатуры + messages.py # текстовые константы + db/ + base.py # Base = declarative_base() + session.py # движок и SessionLocal + models.py # модели + migrations/ # alembic (env.py, versions/...) + main.py # точка входа +alembic.ini +docker-compose.yml +Dockerfile +entrypoint.sh +requirements.txt +``` + +## Заметки +- Для каналов бот должен быть администратором с правом «Публиковать сообщения». +- `DATABASE_URL` имеет приоритет перед параметрами `DB_*`. +- В проде используйте внешнюю БД (RDS и т.п.) и секреты. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..0c9bba6 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = app/migrations +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/bot/__init__.py b/app/bot/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/bot/__init__.py @@ -0,0 +1 @@ + diff --git a/app/bot/handlers/__init__.py b/app/bot/handlers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/bot/handlers/__init__.py @@ -0,0 +1 @@ + diff --git a/app/bot/handlers/add_group.py b/app/bot/handlers/add_group.py new file mode 100644 index 0000000..47130e1 --- /dev/null +++ b/app/bot/handlers/add_group.py @@ -0,0 +1,123 @@ +from telegram import Update +from telegram.constants import ParseMode, ChatType +from telegram.ext import ContextTypes +from telegram.error import BadRequest + +from app.bot.messages import ( + ASK_ADD_GROUP, + GROUP_BOUND, + NEED_ADD_FIRST, + NO_RIGHTS_CHANNEL, + NO_RIGHTS_GROUP, +) +from app.db.session import get_session +from app.db.models import User, Chat +from .utils import parse_chat_id, verify_and_fetch_chat + +STATE_KEY = "await_chat_id" + + +def _get_forwarded_chat_id(message) -> int | None: + """ + Универсально достаём chat_id источника пересылки. + Поддерживает старые поля (forward_from_chat) и новые (forward_origin.chat). + """ + if not message: + return None + + # Старое поле (иногда ещё присутствует) + fwd_chat = getattr(message, "forward_from_chat", None) + if fwd_chat: + return fwd_chat.id + + # Новая схема Bot API: forward_origin с типами MessageOrigin* + origin = getattr(message, "forward_origin", None) + if origin: + chat = getattr(origin, "chat", None) # у MessageOriginChat/Channel есть .chat + if chat: + return chat.id + + return None + + +async def add_group_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + # ensure user exists + with get_session() as s: + u = s.query(User).filter_by(tg_id=update.effective_user.id).first() + if not u: + u = User(tg_id=update.effective_user.id, name=update.effective_user.full_name) + s.add(u) + s.commit() + ctx.user_data[STATE_KEY] = True + await update.effective_message.reply_text(ASK_ADD_GROUP, parse_mode=ParseMode.MARKDOWN) + + +async def add_group_capture(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if update.effective_chat.type != ChatType.PRIVATE: + return + if not ctx.user_data.get(STATE_KEY): + return + + msg = update.effective_message + raw = None + + # 1) Пересланное сообщение из чата/канала + fwd_cid = _get_forwarded_chat_id(msg) + if fwd_cid: + raw = fwd_cid + else: + # 2) Ввод в виде текста или подписи (caption) + txt = (getattr(msg, "text", None) or getattr(msg, "caption", None) or "").strip() + if not txt: + await msg.reply_text("Вставьте chat_id или перешлите сообщение из группы/канала.") + return + + if txt.startswith("@") or "t.me/" in txt: + raw = txt + else: + cid = parse_chat_id(txt) + if cid is None: + await msg.reply_text("Не удалось распознать chat_id. Пример: -1001234567890 или @username.") + return + raw = cid + + # Проверяем, что бот в чате и что у него есть право постить (если нужно) + try: + chat, member, can_post = await verify_and_fetch_chat(ctx, raw) + except BadRequest: + await msg.reply_text("Такого чата не существует или у меня нет доступа. Проверьте chat_id/username.") + return + + if member is None: + await msg.reply_text(NEED_ADD_FIRST.format(title_or_id=(chat.title or chat.id))) + return + + # Сохраняем привязку + with get_session() as s: + me = s.query(User).filter_by(tg_id=update.effective_user.id).first() + row = s.query(Chat).filter_by(chat_id=chat.id).first() + if not row: + row = Chat( + chat_id=chat.id, + type=chat.type, + title=chat.title, + owner_user_id=me.id, + can_post=can_post, + ) + s.add(row) + else: + row.title = chat.title + row.type = chat.type + if row.owner_user_id is None: + row.owner_user_id = me.id + row.can_post = can_post + s.commit() + + ctx.user_data.pop(STATE_KEY, None) + + rights = ( + "✅ У меня есть право публиковать." + if can_post + else (NO_RIGHTS_CHANNEL if chat.type == ChatType.CHANNEL else NO_RIGHTS_GROUP) + ) + await msg.reply_text(GROUP_BOUND.format(title_or_id=(chat.title or chat.id), rights=rights)) diff --git a/app/bot/handlers/callbacks.py b/app/bot/handlers/callbacks.py new file mode 100644 index 0000000..a8b6eca --- /dev/null +++ b/app/bot/handlers/callbacks.py @@ -0,0 +1,118 @@ +from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation +from telegram.constants import ParseMode, ChatType +from telegram.ext import ContextTypes + +from app.db.session import get_session +from app.db.models import Draft, Chat, Delivery +from app.bot.keyboards.common import kb_choose_chat +from app.bot.messages import READY_SELECT_CHAT, SENT_OK, SEND_ERR, NEED_MEDIA_BEFORE_NEXT, CANCELLED +from .drafts import STATE_DRAFT, KEY_DRAFT_ID, STATE_AWAIT_TEXT, STATE_CONFIRM +from .add_group import STATE_KEY # чтобы не конфликтовать по user_data ключам + + +async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + await q.answer() + data = q.data + + # --- Переход с медиа на текст --- + if data.startswith("draft_next_text:"): + draft_id = int(data.split(":")[1]) + with get_session() as s: + d = s.get(Draft, draft_id) + if not d: + await q.edit_message_text("Черновик не найден.") + return + if len(d.media) == 0: + await q.edit_message_text(NEED_MEDIA_BEFORE_NEXT) + return + ctx.user_data[KEY_DRAFT_ID] = draft_id + ctx.user_data[STATE_DRAFT] = STATE_AWAIT_TEXT + await q.edit_message_text("Шаг 2/3 — текст.\nОтправьте текст поста.") + + # --- Подтверждение (Отправить) -> выбор чата --- + elif data.startswith("draft_confirm_send:"): + draft_id = int(data.split(":")[1]) + with get_session() as s: + d = s.get(Draft, draft_id) + if not d: + await q.edit_message_text("Черновик не найден.") + return + d.status = "ready" + s.commit() + # Разрешённые чаты владельца черновика + chats = s.query(Chat).filter_by(owner_user_id=d.user_id, can_post=True).all() + buttons = [(c.title or str(c.chat_id), c.chat_id) for c in chats] + kb = kb_choose_chat(draft_id, buttons) + if not kb: + await q.edit_message_text("Нет чатов с правом публикации. Добавьте/обновите через /add_group.") + return + ctx.user_data[STATE_DRAFT] = STATE_CONFIRM + await q.edit_message_text(READY_SELECT_CHAT, reply_markup=kb) + + # --- Отмена черновика --- + elif data.startswith("draft_cancel:"): + draft_id = int(data.split(":")[1]) + with get_session() as s: + d = s.get(Draft, draft_id) + if d: + d.status = "cancelled" + s.commit() + ctx.user_data.pop(KEY_DRAFT_ID, None) + ctx.user_data.pop(STATE_DRAFT, None) + await q.edit_message_text(CANCELLED) + + # --- Отправка в выбранный чат --- + elif data.startswith("send:"): + _, draft_id, chat_id = data.split(":") + draft_id = int(draft_id); chat_id = int(chat_id) + + with get_session() as s: + d = s.get(Draft, draft_id) + if not d: + await q.edit_message_text("Черновик не найден.") + return + + media = list(sorted(d.media, key=lambda m: m.order)) + sent_ids = [] + try: + if media: + if len(media) > 1: + im = [] + for i, m in enumerate(media): + cap = d.text if i == 0 else None + if m.kind == "photo": + im.append(InputMediaPhoto(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) + elif m.kind == "video": + im.append(InputMediaVideo(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) + elif m.kind == "animation": + im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML)) + msgs = await ctx.bot.send_media_group(chat_id=chat_id, media=im) + sent_ids = [str(m.message_id) for m in msgs] + else: + m = media[0] + if m.kind == "photo": + msg = await ctx.bot.send_photo(chat_id=chat_id, photo=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) + elif m.kind == "video": + msg = await ctx.bot.send_video(chat_id=chat_id, video=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) + else: + msg = await ctx.bot.send_animation(chat_id=chat_id, animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML) + sent_ids = [str(msg.message_id)] + else: + msg = await ctx.bot.send_message(chat_id=chat_id, text=d.text or "(пусто)", parse_mode=ParseMode.HTML) + sent_ids = [str(msg.message_id)] + + deliv = Delivery(draft_id=d.id, chat_id=chat_id, status="sent", message_ids=",".join(sent_ids)) + s.add(deliv) + d.status = "sent" + s.commit() + + # Сбрасываем пользовательское состояние редактора + ctx.user_data.pop(KEY_DRAFT_ID, None) + ctx.user_data.pop(STATE_DRAFT, None) + + await q.edit_message_text(SENT_OK) + except Exception as e: + deliv = Delivery(draft_id=d.id, chat_id=chat_id, status="failed", error=str(e)) + s.add(deliv); s.commit() + await q.edit_message_text(SEND_ERR.format(e=e)) diff --git a/app/bot/handlers/drafts.py b/app/bot/handlers/drafts.py new file mode 100644 index 0000000..4795ddd --- /dev/null +++ b/app/bot/handlers/drafts.py @@ -0,0 +1,79 @@ +from datetime import datetime +from telegram import Update +from telegram.constants import ChatType, ParseMode +from telegram.ext import ContextTypes + +from app.db.session import get_session +from app.db.models import User, Draft +from app.bot.messages import ( + ASK_MEDIA, ASK_TEXT, TEXT_ADDED, CONFIRM, ALREADY_AT_TEXT, ALREADY_READY, NEED_START_NEW +) +from app.bot.keyboards.common import kb_next_text, kb_confirm +from .add_group import add_group_capture, STATE_KEY # /add_group ожидание + +# Состояния пошагового редактора +STATE_DRAFT = "draft_state" +KEY_DRAFT_ID = "draft_id" +STATE_AWAIT_MEDIA = "await_media" +STATE_AWAIT_TEXT = "await_text" +STATE_CONFIRM = "confirm" + + +def _start_new_draft(tg_id: int) -> Draft: + with get_session() as s: + u = s.query(User).filter_by(tg_id=tg_id).first() + if not u: + u = User(tg_id=tg_id, name="") + s.add(u); s.commit(); s.refresh(u) + # Закрываем предыдущие "editing" как cancelled (по желанию) + s.query(Draft).filter(Draft.user_id == u.id, Draft.status == "editing").update({"status": "cancelled"}) + d = Draft(user_id=u.id, status="editing") + s.add(d); s.commit(); s.refresh(d) + return d + + +def _get_draft(draft_id: int) -> Draft | None: + with get_session() as s: + return s.get(Draft, draft_id) + + +async def new_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + d = _start_new_draft(update.effective_user.id) + ctx.user_data[KEY_DRAFT_ID] = d.id + ctx.user_data[STATE_DRAFT] = STATE_AWAIT_MEDIA + await update.effective_message.reply_text(ASK_MEDIA, reply_markup=kb_next_text(d.id)) + + +async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + # Если ждём chat_id для /add_group, отдаём управление туда + if ctx.user_data.get(STATE_KEY): + return await add_group_capture(update, ctx) + + if update.effective_chat.type != ChatType.PRIVATE: + return + + draft_id = ctx.user_data.get(KEY_DRAFT_ID) + state = ctx.user_data.get(STATE_DRAFT) + + if not draft_id or not state: + await update.effective_message.reply_text(NEED_START_NEW) + return + + if state == STATE_AWAIT_MEDIA: + # Сначала медиа + await update.effective_message.reply_text("Сначала пришлите медиа и нажмите «Дальше — текст».") + return + + if state == STATE_CONFIRM: + await update.effective_message.reply_text(ALREADY_READY) + return + + if state == STATE_AWAIT_TEXT: + with get_session() as s: + d = s.get(Draft, draft_id) + d.text = update.effective_message.text_html_urled + d.updated_at = datetime.utcnow() + s.commit() + ctx.user_data[STATE_DRAFT] = STATE_CONFIRM + await update.effective_message.reply_text(TEXT_ADDED, parse_mode=ParseMode.HTML) + await update.effective_message.reply_text(CONFIRM, reply_markup=kb_confirm(draft_id)) diff --git a/app/bot/handlers/join_info.py b/app/bot/handlers/join_info.py new file mode 100644 index 0000000..4fa3ce1 --- /dev/null +++ b/app/bot/handlers/join_info.py @@ -0,0 +1,51 @@ +from telegram import Update +from telegram.constants import ChatMemberStatus, ChatType, ParseMode +from telegram.ext import ContextTypes +from app.bot.messages import JOIN_INFO_GROUP, JOIN_INFO_CHANNEL + +async def on_my_chat_member(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + """ + Сообщаем инструкцию и chat_id, когда бота добавили в группу/канал + или повысили до администратора. + """ + mcm = update.my_chat_member + if not mcm: + return + + chat = mcm.chat + new_status = mcm.new_chat_member.status + if new_status not in (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR): + return + + title = chat.title or str(chat.id) + chat_id = chat.id + + # Текст подсказки + if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP): + text = JOIN_INFO_GROUP.format(title=title, chat_id=chat_id) + # Пытаемся написать прямо в группу + try: + await ctx.bot.send_message(chat_id=chat_id, text=text, parse_mode=ParseMode.MARKDOWN) + except Exception: + # как запасной вариант — в ЛС пользователю, который добавил + if update.effective_user: + try: + await ctx.bot.send_message(update.effective_user.id, text=text, parse_mode=ParseMode.MARKDOWN) + except Exception: + pass + elif chat.type == ChatType.CHANNEL: + text = JOIN_INFO_CHANNEL.format(title=title, chat_id=chat_id) + # В канале можем не иметь права постинга — пробуем ЛС добавившему + sent = False + try: + # если права даны, сообщим прямо в канал + await ctx.bot.send_message(chat_id=chat_id, text=text, parse_mode=ParseMode.MARKDOWN) + sent = True + except Exception: + pass + + if not sent and update.effective_user: + try: + await ctx.bot.send_message(update.effective_user.id, text=text, parse_mode=ParseMode.MARKDOWN) + except Exception: + pass diff --git a/app/bot/handlers/media.py b/app/bot/handlers/media.py new file mode 100644 index 0000000..dd201eb --- /dev/null +++ b/app/bot/handlers/media.py @@ -0,0 +1,51 @@ +from datetime import datetime +from telegram import Update +from telegram.constants import ChatType +from telegram.ext import ContextTypes + +from app.db.session import get_session +from app.db.models import Draft, DraftMedia +from app.bot.messages import MEDIA_ADDED, ALREADY_AT_TEXT, NEED_START_NEW +from .drafts import KEY_DRAFT_ID, STATE_DRAFT, STATE_AWAIT_MEDIA, STATE_AWAIT_TEXT, STATE_CONFIRM +from .add_group import add_group_capture, STATE_KEY # перехват для /add_group + + +async def on_media(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if update.effective_chat.type != ChatType.PRIVATE: + return + + # Если пользователь сейчас привязывает чат — используем пересланное медиа для извлечения chat_id + if ctx.user_data.get(STATE_KEY): + return await add_group_capture(update, ctx) + + draft_id = ctx.user_data.get(KEY_DRAFT_ID) + state = ctx.user_data.get(STATE_DRAFT) + + if not draft_id or not state: + await update.effective_message.reply_text(NEED_START_NEW) + return + + if state != STATE_AWAIT_MEDIA: + # Уже перешли к тексту/подтверждению — блокируем добавление медиа + await update.effective_message.reply_text(ALREADY_AT_TEXT if state == STATE_AWAIT_TEXT else "Редактор на шаге подтверждения.") + return + + kind = None + file_id = None + if update.message.photo: + kind = "photo"; file_id = update.message.photo[-1].file_id + elif update.message.video: + kind = "video"; file_id = update.message.video.file_id + elif update.message.animation: + kind = "animation"; file_id = update.message.animation.file_id + else: + return + + with get_session() as s: + d = s.get(Draft, draft_id) + order = len(d.media) + m = DraftMedia(draft_id=d.id, kind=kind, file_id=file_id, order=order) + s.add(m); d.updated_at = datetime.utcnow() + s.commit() + + await update.effective_message.reply_text(MEDIA_ADDED.format(kind=kind)) diff --git a/app/bot/handlers/start.py b/app/bot/handlers/start.py new file mode 100644 index 0000000..23905c4 --- /dev/null +++ b/app/bot/handlers/start.py @@ -0,0 +1,36 @@ +from telegram import Update +from telegram.constants import ChatType, ParseMode +from telegram.ext import ContextTypes +from app.bot.messages import START, HELP, NO_CHATS +from app.db.session import get_session +from app.db.models import User, Chat + +async def start(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + # register user + with get_session() as s: + u = s.query(User).filter_by(tg_id=update.effective_user.id).first() + if not u: + u = User(tg_id=update.effective_user.id, name=update.effective_user.full_name) + s.add(u); s.commit() + await update.effective_message.reply_text(START) + +async def help_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + await update.effective_message.reply_text(HELP) + +async def groups_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + with get_session() as s: + u = s.query(User).filter_by(tg_id=update.effective_user.id).first() + if not u: + await update.effective_message.reply_text(NO_CHATS) + return + chats = s.query(Chat).filter_by(owner_user_id=u.id).order_by(Chat.created_at.desc()).all() + + if not chats: + await update.effective_message.reply_text(NO_CHATS) + return + lines = [] + for c in chats: + icon = "📣" if c.type == ChatType.CHANNEL else "👥" + post = "✅ могу публиковать" if c.can_post else "⚠️ нет прав публикации" + lines.append(f"{icon} {c.title or c.chat_id} — {post}") + await update.effective_message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) diff --git a/app/bot/handlers/utils.py b/app/bot/handlers/utils.py new file mode 100644 index 0000000..4d2fa39 --- /dev/null +++ b/app/bot/handlers/utils.py @@ -0,0 +1,43 @@ +import re +from typing import Tuple, Optional, Any +from telegram.constants import ChatType +from telegram.error import Forbidden, BadRequest + +async def verify_and_fetch_chat(ctx, raw: str | int): + """Return (chat, member, can_post) for the bot in that chat.""" + bot = ctx.bot + + # Normalize t.me links to @username + if isinstance(raw, str) and "t.me/" in raw: + raw = raw.strip().split("t.me/")[-1] + raw = raw.strip().lstrip("/") + if raw and not raw.startswith("@"): + raw = "@" + raw + + chat = await bot.get_chat(raw) + try: + member = await bot.get_chat_member(chat.id, bot.id) + except Forbidden: + return chat, None, False + except BadRequest as e: + raise e + + can_post = False + if chat.type == ChatType.CHANNEL: + status = getattr(member, "status", "") + if status in ("administrator", "creator"): + flag = getattr(member, "can_post_messages", None) + can_post = True if (flag is None or flag is True) else False + elif chat.type in (ChatType.SUPERGROUP, ChatType.GROUP): + status = getattr(member, "status", "") + can_post = status in ("member", "administrator", "creator") + return chat, member, can_post + +def parse_chat_id(text: str) -> int | None: + m = re.search(r'(-?\d{5,})', text or "") + if m: + try: + return int(m.group(1)) + except ValueError: + return None + return None diff --git a/app/bot/keyboards/__init__.py b/app/bot/keyboards/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/bot/keyboards/__init__.py @@ -0,0 +1 @@ + diff --git a/app/bot/keyboards/common.py b/app/bot/keyboards/common.py new file mode 100644 index 0000000..a39f481 --- /dev/null +++ b/app/bot/keyboards/common.py @@ -0,0 +1,21 @@ +from telegram import InlineKeyboardButton, InlineKeyboardMarkup + +def kb_next_text(draft_id: int): + return InlineKeyboardMarkup( + [[InlineKeyboardButton("Дальше — текст", callback_data=f"draft_next_text:{draft_id}")]] + ) + +def kb_confirm(draft_id: int): + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton("Отправить", callback_data=f"draft_confirm_send:{draft_id}"), + InlineKeyboardButton("Отменить", callback_data=f"draft_cancel:{draft_id}"), + ] + ] + ) + +def kb_choose_chat(draft_id: int, chats: list[tuple[str, int]]): + # chats: list of (title, chat_id) + rows = [[InlineKeyboardButton(title, callback_data=f"send:{draft_id}:{chat_id}")] for title, chat_id in chats] + return InlineKeyboardMarkup(rows) if rows else None diff --git a/app/bot/messages.py b/app/bot/messages.py new file mode 100644 index 0000000..4c359bb --- /dev/null +++ b/app/bot/messages.py @@ -0,0 +1,72 @@ +START = ( + "Привет! Я помогу отправлять сообщения в ваши группы и каналы.\n\n" + "1) Добавьте меня в группу/канал (в канале — дайте право публиковать).\n" + "2) В ЛС боту нажмите /add_group и вставьте chat_id или перешлите сюда сообщение из этого чата.\n" + "3) Создайте черновик /new и отправьте в выбранный чат.\n\n" + "Команды:\n" + "/add_group — привязать группу/канал вручную\n" + "/groups — список моих чатов\n" + "/new — создать черновик поста\n" + "/help — справка" +) + +HELP = ( + "1) В Telegram добавьте бота в группу/канал (для каналов — админ с правом «Публиковать сообщения»).\n" + "2) В ЛС — /add_group: вставьте chat_id (например, -100123...) или перешлите сюда любое сообщение из чата.\n" + "3) Создайте черновик /new и отправьте его в выбранный чат." +) + +ASK_ADD_GROUP = ( + "Отправьте *chat_id* группы/канала (например, `-1001234567890`) " + "или *перешлите сюда* любое сообщение из нужной группы/канала." +) + +NO_CHATS = "Пока ни одного чата не привязано. Нажмите /add_group для привязки." + +# Пошаговый редактор +ASK_MEDIA = ( + "Шаг 1/3 — медиа.\nПришлите фото/видео/гиф. Можно несколько (альбом).\n" + "Когда закончите — нажмите «Дальше — текст»." +) +ASK_TEXT = ( + "Шаг 2/3 — текст.\nОтправьте текст поста. Он станет подписью к медиа (или отдельным сообщением, если медиа нет)." +) +CONFIRM = ( + "Шаг 3/3 — подтверждение.\nПроверьте пост и нажмите «Отправить» или «Отменить»." +) + +TEXT_ADDED = "Текст добавлен в черновик." +MEDIA_ADDED = "Медиа добавлено ({kind})." +NEED_MEDIA_BEFORE_NEXT = "Нужно добавить минимум одно медиа перед переходом к тексту." +ALREADY_AT_TEXT = "Вы уже перешли к вводу текста. Пришлите текст или нажмите «Отменить»." +ALREADY_READY = "Пост готов к отправке — нажмите «Отправить» или «Отменить»." +NEED_START_NEW = "Сначала начните новый пост: /new" +CANCELLED = "Черновик отменён." + +READY_SELECT_CHAT = "Куда отправить?" +SENT_OK = "✅ Отправлено." +SEND_ERR = "❌ Ошибка отправки: {e}" + +GROUP_BOUND = "Чат «{title_or_id}» привязан.\n{rights}" +NEED_ADD_FIRST = "Я не добавлен в «{title_or_id}». Сначала добавьте бота в этот чат и повторите /add_group." +NO_RIGHTS_CHANNEL = "⚠️ Я в канале не администратор. Дайте боту право «Публиковать сообщения» и повторите /add_group." +NO_RIGHTS_GROUP = "⚠️ Похоже, я не могу публиковать. Проверьте права чата." + +# --- Новое: инструкции при добавлении бота в чат/канал --- +JOIN_INFO_GROUP = ( + "Спасибо, что добавили меня в группу «{title}»!\n" + "ID группы: `{chat_id}`\n\n" + "Чтобы привязать эту группу к своему аккаунту:\n" + "1) Откройте ЛС со мной\n" + "2) Выполните команду /add_group\n" + "3) Вставьте ID выше *или* просто перешлите сюда любое сообщение из этой группы." +) + +JOIN_INFO_CHANNEL = ( + "Спасибо, что добавили меня в канал «{title}»!\n" + "ID канала: `{chat_id}`\n\n" + "Чтобы привязать канал к своему аккаунту:\n" + "1) Откройте ЛС со мной и выполните /add_group\n" + "2) Вставьте ID выше *или* перешлите сюда сообщение из канала\n\n" + "⚠️ Для публикации сообщений мне нужно право «Публиковать сообщения» (сделайте бота администратором с этим правом)." +) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..fb7abc5 --- /dev/null +++ b/app/config.py @@ -0,0 +1,23 @@ +import os +from dataclasses import dataclass + +@dataclass +class Config: + bot_token: str + database_url: str + log_level: str = os.getenv("LOG_LEVEL", "INFO") + +def load_config() -> "Config": + bot_token = os.getenv("BOT_TOKEN", "").strip() + if not bot_token: + raise RuntimeError("BOT_TOKEN is not set") + # DATABASE_URL takes precedence; else compose from parts + db_url = os.getenv("DATABASE_URL", "").strip() + if not db_url: + host = os.getenv("DB_HOST", "db") + port = os.getenv("DB_PORT", "5432") + name = os.getenv("DB_NAME", "tg_poster") + user = os.getenv("DB_USER", "postgres") + pwd = os.getenv("DB_PASSWORD", "postgres") + db_url = f"postgresql+psycopg://{user}:{pwd}@{host}:{port}/{name}" + return Config(bot_token=bot_token, database_url=db_url) diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ + diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..59be703 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.orm import declarative_base + +Base = declarative_base() diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..6cfcbc5 --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,56 @@ +from datetime import datetime +from sqlalchemy import Column, Integer, BigInteger, String, DateTime, ForeignKey, Text, Boolean +from sqlalchemy.orm import relationship +from app.db.base import Base + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + tg_id = Column(BigInteger, unique=True, nullable=False, index=True) + name = Column(String(255)) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + +class Chat(Base): + __tablename__ = "chats" + id = Column(Integer, primary_key=True) + chat_id = Column(BigInteger, unique=True, nullable=False, index=True) + type = Column(String(32)) # "group" | "supergroup" | "channel" + title = Column(String(255)) + owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + can_post = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + owner = relationship("User") + +class Draft(Base): + __tablename__ = "drafts" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), index=True) + text = Column(Text, nullable=True) + status = Column(String(16), default="editing", index=True) # editing | ready | sent + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + user = relationship("User") + media = relationship("DraftMedia", cascade="all, delete-orphan", back_populates="draft") + +class DraftMedia(Base): + __tablename__ = "draft_media" + id = Column(Integer, primary_key=True) + draft_id = Column(Integer, ForeignKey("drafts.id"), index=True) + kind = Column(String(16)) # photo | video | animation + file_id = Column(String(255)) + """Ordering inside album.""" + order = Column(Integer, default=0) + + draft = relationship("Draft", back_populates="media") + +class Delivery(Base): + __tablename__ = "deliveries" + id = Column(Integer, primary_key=True) + draft_id = Column(Integer, ForeignKey("drafts.id"), index=True) + chat_id = Column(BigInteger, index=True) + status = Column(String(16), default="new", index=True) # new | sent | failed + error = Column(Text, nullable=True) + message_ids = Column(Text, nullable=True) # CSV for album parts + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..3e5609d --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,10 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.config import load_config + +_config = load_config() +engine = create_engine(_config.database_url, pool_pre_ping=True, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + +def get_session(): + return SessionLocal() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..c563e3d --- /dev/null +++ b/app/main.py @@ -0,0 +1,37 @@ +import logging +from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, CallbackQueryHandler, ChatMemberHandler, filters +from app.config import load_config +from app.bot.handlers.start import start, help_cmd, groups_cmd +from app.bot.handlers.add_group import add_group_cmd, add_group_capture +from app.bot.handlers.drafts import new_cmd, on_text +from app.bot.handlers.media import on_media +from app.bot.handlers.callbacks import on_callback +from app.bot.handlers.join_info import on_my_chat_member # ← ново + +def main(): + cfg = load_config() + logging.basicConfig(level=getattr(logging, cfg.log_level.upper(), logging.INFO)) + app = ApplicationBuilder().token(cfg.bot_token).build() + + # Commands + app.add_handler(CommandHandler("start", start)) + app.add_handler(CommandHandler("help", help_cmd)) + app.add_handler(CommandHandler("groups", groups_cmd)) + app.add_handler(CommandHandler("add_group", add_group_cmd)) + app.add_handler(CommandHandler("new", new_cmd)) + + # Callback queries + app.add_handler(CallbackQueryHandler(on_callback)) + + # Private chat handlers + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.TEXT, on_text)) + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & filters.FORWARDED, add_group_capture)) + app.add_handler(MessageHandler(filters.ChatType.PRIVATE & (filters.PHOTO | filters.VIDEO | filters.ANIMATION), on_media)) + + # NEW: реагируем, когда бота добавили/изменили права в чате + app.add_handler(ChatMemberHandler(on_my_chat_member, chat_member_types=ChatMemberHandler.MY_CHAT_MEMBER)) + + app.run_polling(allowed_updates=None) + +if __name__ == "__main__": + main() diff --git a/app/migrations/README b/app/migrations/README new file mode 100644 index 0000000..ced79f5 --- /dev/null +++ b/app/migrations/README @@ -0,0 +1 @@ +Alembic migrations diff --git a/app/migrations/env.py b/app/migrations/env.py new file mode 100644 index 0000000..4582fcf --- /dev/null +++ b/app/migrations/env.py @@ -0,0 +1,72 @@ +from __future__ import annotations +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool +from alembic import context + +# Сделаем проект импортируемым при запуске alembic из корня +sys.path.append(os.getcwd()) + +from app.db.base import Base # noqa +from app.db import models # noqa: F401 # важно импортировать, чтобы metadata увидела модели + +# Alembic Config +config = context.config + +# Логирование из alembic.ini +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_url() -> str: + """Берём URL БД из env или собираем из DB_* переменных.""" + url = os.getenv("DATABASE_URL", "").strip() + if url: + return url + host = os.getenv("DB_HOST", "db") + port = os.getenv("DB_PORT", "5432") + name = os.getenv("DB_NAME", "tg_poster") + user = os.getenv("DB_USER", "postgres") + pwd = os.getenv("DB_PASSWORD", "postgres") + return f"postgresql+psycopg://{user}:{pwd}@{host}:{port}/{name}" + + +def run_migrations_offline() -> None: + """Запуск миграций в оффлайн-режиме (генерация SQL).""" + url = get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Запуск миграций в онлайн-режиме с активным соединением.""" + configuration = config.get_section(config.config_ini_section) or {} + # ВАЖНО: используем ключ sqlalchemy.url и prefix="sqlalchemy." + configuration["sqlalchemy.url"] = get_url() + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/app/migrations/script.py.mako b/app/migrations/script.py.mako new file mode 100644 index 0000000..6e5b078 --- /dev/null +++ b/app/migrations/script.py.mako @@ -0,0 +1,20 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/app/migrations/versions/0001_init.py b/app/migrations/versions/0001_init.py new file mode 100644 index 0000000..53ba536 --- /dev/null +++ b/app/migrations/versions/0001_init.py @@ -0,0 +1,60 @@ +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '0001_init' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table('users', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('tg_id', sa.BigInteger(), nullable=False, unique=True, index=True), + sa.Column('name', sa.String(length=255)), + sa.Column('created_at', sa.DateTime(), nullable=False), + ) + + op.create_table('chats', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('chat_id', sa.BigInteger(), nullable=False, unique=True, index=True), + sa.Column('type', sa.String(length=32)), + sa.Column('title', sa.String(length=255)), + sa.Column('owner_user_id', sa.Integer(), sa.ForeignKey('users.id'), index=True), + sa.Column('can_post', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('created_at', sa.DateTime(), nullable=False), + ) + + op.create_table('drafts', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), index=True), + sa.Column('text', sa.Text()), + sa.Column('status', sa.String(length=16), index=True, server_default='editing'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + ) + + op.create_table('draft_media', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('draft_id', sa.Integer(), sa.ForeignKey('drafts.id'), index=True), + sa.Column('kind', sa.String(length=16)), + sa.Column('file_id', sa.String(length=255)), + sa.Column('order', sa.Integer(), server_default='0'), + ) + + op.create_table('deliveries', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('draft_id', sa.Integer(), sa.ForeignKey('drafts.id'), index=True), + sa.Column('chat_id', sa.BigInteger(), index=True), + sa.Column('status', sa.String(length=16), index=True, server_default='new'), + sa.Column('error', sa.Text()), + sa.Column('message_ids', sa.Text()), + sa.Column('created_at', sa.DateTime(), nullable=False), + ) + +def downgrade() -> None: + op.drop_table('deliveries') + op.drop_table('draft_media') + op.drop_table('drafts') + op.drop_table('chats') + op.drop_table('users') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3141a93 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3.9" +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-tg_poster} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 10 + + bot: + build: . + depends_on: + db: + condition: service_healthy + env_file: .env + restart: unless-stopped + +volumes: + pgdata: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..bf1c360 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Export env from .env if present +if [ -f ".env" ]; then + set -a + source ./.env + set +a +fi + +# Run migrations +alembic upgrade head + +# Start bot +python -m app.main diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3ddceaf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +python-telegram-bot==21.6 +SQLAlchemy==2.0.36 +alembic==1.13.2 +psycopg[binary]==3.2.1 +python-dotenv==1.0.1