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.class
|
||||
|
||||
# env
|
||||
.env
|
||||
.venv/
|
||||
|
||||
#git
|
||||
.history/
|
||||
# C extensions
|
||||
*.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