init commit

This commit is contained in:
2025-08-20 21:10:31 +09:00
parent 36a9382cb6
commit 745046c638
31 changed files with 1074 additions and 1 deletions

16
.env.example Normal file
View 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
View File

@@ -4,6 +4,12 @@ __pycache__/
*.py[cod]
*$py.class
# env
.env
.venv/
#git
.history/
# C extensions
*.so

16
Dockerfile Normal file
View 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"]

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@

1
app/bot/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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))

View 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))

View 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))

View 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
View 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
View 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
View 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

View File

@@ -0,0 +1 @@

View 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
View 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
View 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
View File

@@ -0,0 +1 @@

3
app/db/base.py Normal file
View File

@@ -0,0 +1,3 @@
from sqlalchemy.orm import declarative_base
Base = declarative_base()

56
app/db/models.py Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
Alembic migrations

72
app/migrations/env.py Normal file
View 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()

View 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"}

View 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
View 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
View 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
View 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