init commit
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@@ -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
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,6 +4,12 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
#git
|
||||||
|
.history/
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
|||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -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"]
|
||||||
73
README.md
73
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 и т.п.) и секреты.
|
||||||
|
|||||||
35
alembic.ini
Normal file
35
alembic.ini
Normal file
@@ -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
|
||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
app/bot/__init__.py
Normal file
1
app/bot/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
app/bot/handlers/__init__.py
Normal file
1
app/bot/handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
123
app/bot/handlers/add_group.py
Normal file
123
app/bot/handlers/add_group.py
Normal file
@@ -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))
|
||||||
118
app/bot/handlers/callbacks.py
Normal file
118
app/bot/handlers/callbacks.py
Normal file
@@ -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))
|
||||||
79
app/bot/handlers/drafts.py
Normal file
79
app/bot/handlers/drafts.py
Normal file
@@ -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))
|
||||||
51
app/bot/handlers/join_info.py
Normal file
51
app/bot/handlers/join_info.py
Normal file
@@ -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
|
||||||
51
app/bot/handlers/media.py
Normal file
51
app/bot/handlers/media.py
Normal file
@@ -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))
|
||||||
36
app/bot/handlers/start.py
Normal file
36
app/bot/handlers/start.py
Normal file
@@ -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)
|
||||||
43
app/bot/handlers/utils.py
Normal file
43
app/bot/handlers/utils.py
Normal file
@@ -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
|
||||||
1
app/bot/keyboards/__init__.py
Normal file
1
app/bot/keyboards/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
21
app/bot/keyboards/common.py
Normal file
21
app/bot/keyboards/common.py
Normal file
@@ -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
|
||||||
72
app/bot/messages.py
Normal file
72
app/bot/messages.py
Normal file
@@ -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"
|
||||||
|
"⚠️ Для публикации сообщений мне нужно право «Публиковать сообщения» (сделайте бота администратором с этим правом)."
|
||||||
|
)
|
||||||
23
app/config.py
Normal file
23
app/config.py
Normal file
@@ -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)
|
||||||
1
app/db/__init__.py
Normal file
1
app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
3
app/db/base.py
Normal file
3
app/db/base.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
56
app/db/models.py
Normal file
56
app/db/models.py
Normal file
@@ -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)
|
||||||
10
app/db/session.py
Normal file
10
app/db/session.py
Normal file
@@ -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()
|
||||||
37
app/main.py
Normal file
37
app/main.py
Normal file
@@ -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()
|
||||||
1
app/migrations/README
Normal file
1
app/migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Alembic migrations
|
||||||
72
app/migrations/env.py
Normal file
72
app/migrations/env.py
Normal file
@@ -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()
|
||||||
20
app/migrations/script.py.mako
Normal file
20
app/migrations/script.py.mako
Normal file
@@ -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"}
|
||||||
60
app/migrations/versions/0001_init.py
Normal file
60
app/migrations/versions/0001_init.py
Normal file
@@ -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')
|
||||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -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:
|
||||||
15
entrypoint.sh
Executable file
15
entrypoint.sh
Executable file
@@ -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
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user