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

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"
"⚠️ Для публикации сообщений мне нужно право «Публиковать сообщения» (сделайте бота администратором с этим правом)."
)