watch: add pkg_watch subscription with chat support

parent fe65117d
......@@ -8,3 +8,5 @@ from . import profile as profile_keyboards
from . import watch as watch_keyboards
from . import bugs as bugs_keyboards
from . import changelog as changelog_keyboards
from . import chat_settings as chat_settings_keyboards
from . import common as common_keyboards
from telegrinder import InlineKeyboard, InlineButton
from .common import packages_kb, schedule_days_kb
from services.pkg_watch import MAX_WATCH_LIST_SIZE
def chat_settings_kb(pkg_count: int, task):
kb = InlineKeyboard()
kb.add(InlineButton(
f"Список пакетов ({pkg_count})",
callback_data="chat/packages"
))
kb.row()
if task:
kb.add(InlineButton(
"Расписание",
callback_data="chat/schedule/edit"
))
kb.row()
kb.add(InlineButton(
"Отключить рассылку",
callback_data="chat/schedule/remove"
))
else:
kb.add(InlineButton(
"Включить рассылку",
callback_data="chat/schedule/add"
))
return kb.get_markup()
def chat_packages_kb(pkgs: list[str]):
return packages_kb("chat/pkg", pkgs, back_callback="chat/back", max_size=MAX_WATCH_LIST_SIZE)
def chat_schedule_days_kb(days_list, selected_days):
return schedule_days_kb("chat/schedule", days_list, selected_days, back_callback="chat/back")
from telegrinder import InlineKeyboard, InlineButton
def cancel_kb(callback_data: str = "input/cancel"):
return (
InlineKeyboard()
.add(InlineButton("Отмена", callback_data=callback_data))
).get_markup()
def packages_kb(prefix: str, packages: list[str], back_callback: str | None = None, max_size: int = 0):
kb = InlineKeyboard()
for pkg in packages:
kb.add(InlineButton(f"X {pkg}", callback_data=f"{prefix}/rm/{pkg}"))
kb.row()
if max_size <= 0 or len(packages) < max_size:
kb.add(InlineButton("Добавить", callback_data=f"{prefix}/add"))
if back_callback:
kb.row()
kb.add(InlineButton("Назад", callback_data=back_callback))
return kb.get_markup()
def schedule_days_kb(prefix: str, days_list, selected_days, back_callback: str | None = None):
kb = InlineKeyboard()
for i, day_name in enumerate(days_list):
emoji = "🟢" if i in selected_days else "🔴"
new_days = selected_days.copy()
if i in new_days:
new_days.remove(i)
else:
new_days.append(i)
new_days_str = ",".join(map(str, sorted(new_days)))
kb.add(InlineButton(
f"{emoji} {day_name}",
callback_data=f"{prefix}/day/{new_days_str}"
))
if i == 3:
kb.row()
kb.row()
kb.add(InlineButton(
"Продолжить",
callback_data=f"{prefix}/time"
))
if back_callback:
kb.row()
kb.add(InlineButton("Назад", callback_data=back_callback))
return kb.get_markup()
from telegrinder import InlineKeyboard, InlineButton
from config import DEFAUIL_BRANCHES
from .common import packages_kb
from services.pkg_watch import MAX_WATCH_LIST_SIZE
profile_kb = (
InlineKeyboard()
......@@ -14,6 +16,8 @@ profile_settings_kb = (
.add(InlineButton("Сменить сопровождающего", callback_data="profile/settings/maintainer"))
.row()
.add(InlineButton("Сменить репозиторий", callback_data="profile/settings/branch"))
.row()
.add(InlineButton("Закрыть", callback_data="profile/close"))
).get_markup()
......@@ -22,21 +26,23 @@ def profile_settings_branch_kb():
for branch in DEFAUIL_BRANCHES:
kb.add(InlineButton(
f"{branch}", callback_data=f"profile/settings/branch/{branch}"))
kb.row()
kb.add(InlineButton("Назад", callback_data="profile/settings"))
return kb.get_markup()
def settings_tasks_kb(watch_task, bugs_task):
def settings_tasks_kb(watch_task, bugs_task, pkg_watch_task):
kb = InlineKeyboard()
if watch_task:
kb.add(InlineButton(
"Редактировать отслеживание",
callback_data=f"profile/settings/mailing/watch/edit"
callback_data="profile/settings/mailing/watch/edit"
))
else:
kb.add(InlineButton(
"Подписаться на отслеживание",
callback_data=f"profile/settings/mailing/watch/add"
callback_data="profile/settings/mailing/watch/add"
))
kb.row()
......@@ -44,17 +50,43 @@ def settings_tasks_kb(watch_task, bugs_task):
if bugs_task:
kb.add(InlineButton(
"Редактировать баги",
callback_data=f"profile/settings/mailing/bugs/edit"
callback_data="profile/settings/mailing/bugs/edit"
))
else:
kb.add(InlineButton(
"Подписаться на баги",
callback_data=f"profile/settings/mailing/bugs/add"
callback_data="profile/settings/mailing/bugs/add"
))
kb.row()
if pkg_watch_task:
kb.add(InlineButton(
"Редактировать watch по пакетам",
callback_data="profile/settings/mailing/pkg_watch/edit"
))
else:
kb.add(InlineButton(
"Подписаться на watch по пакетам",
callback_data="profile/settings/mailing/pkg_watch/add"
))
kb.row()
kb.add(InlineButton(
"Мои пакеты",
callback_data="profile/settings/packages"
))
kb.row()
kb.add(InlineButton("Назад", callback_data="profile/settings"))
return kb.get_markup()
def user_packages_kb(pkgs: list[str]):
return packages_kb("profile/pkg", pkgs, back_callback="profile/settings/mailing", max_size=MAX_WATCH_LIST_SIZE)
def set_task_days_kb(task_type, days, selected_days):
kb = InlineKeyboard()
......@@ -77,8 +109,10 @@ def set_task_days_kb(task_type, days, selected_days):
kb.row()
kb.row()
kb.add(InlineButton(
f"Продолжить",
"Продолжить",
callback_data=f"profile/settings/mailing/{task_type}/set-time"
))
kb.row()
kb.add(InlineButton("Назад", callback_data="profile/settings/mailing"))
return kb.get_markup()
import json
from .models import Maintainer, User, Package, ScheduledTask
from .models import (
Maintainer, User, Package, ScheduledTask,
WatchList, ChatSubscription,
)
class MaintainerMethod:
......@@ -196,8 +199,74 @@ class SchedulerMethod:
).execute()
class WatchListMethod:
@classmethod
def add(cls, owner_id: int, package_name: str) -> bool:
_, created = WatchList.get_or_create(
owner_id=owner_id, package_name=package_name)
return created
@classmethod
def remove(cls, owner_id: int, package_name: str) -> bool:
count = WatchList.delete().where(
(WatchList.owner_id == owner_id) &
(WatchList.package_name == package_name)
).execute()
return count > 0
@classmethod
def get_list(cls, owner_id: int) -> list[str]:
return [
row.package_name
for row in WatchList.select().where(WatchList.owner_id == owner_id)
]
@classmethod
def clear(cls, owner_id: int):
WatchList.delete().where(WatchList.owner_id == owner_id).execute()
class ChatSubscriptionMethod:
@classmethod
def add(cls, chat_id: int, task_type: str, days: str, time: str):
sub = ChatSubscription(
chat_id=chat_id, task_type=task_type, days=days, send_time=time)
sub.save()
@classmethod
def get(cls, chat_id: int, task_type: str) -> ChatSubscription | None:
return ChatSubscription.get_or_none(
(ChatSubscription.chat_id == chat_id) &
(ChatSubscription.task_type == task_type)
)
@classmethod
def get_by_id(cls, sub_id: int) -> ChatSubscription | None:
return ChatSubscription.get_or_none(ChatSubscription.id == sub_id)
@classmethod
def update(cls, chat_id: int, task_type: str, **kwargs):
ChatSubscription.update(**kwargs).where(
(ChatSubscription.chat_id == chat_id) &
(ChatSubscription.task_type == task_type)
).execute()
@classmethod
def all(cls):
return list(ChatSubscription.select())
@classmethod
def delete(cls, chat_id: int, task_type: str):
ChatSubscription.delete().where(
(ChatSubscription.chat_id == chat_id) &
(ChatSubscription.task_type == task_type)
).execute()
class DB:
maintainer = MaintainerMethod
user = UserMethod
package = PackageMethod
scheduler = SchedulerMethod
watch_list = WatchListMethod
chat_subscription = ChatSubscriptionMethod
......@@ -60,6 +60,34 @@ class Package(BaseModel):
table_name = "packages"
class WatchList(BaseModel):
"""Список пакетов для watch-рассылки (user_id > 0, chat_id < 0)"""
owner_id = IntegerField()
package_name = CharField()
class Meta:
table_name = "watch_lists"
indexes = (
(('owner_id', 'package_name'), True),
)
class ChatSubscription(BaseModel):
"""Подписка чата на рассылку"""
chat_id = IntegerField()
task_type = CharField()
days = CharField()
send_time = TimeField()
class Meta:
table_name = "chat_subscriptions"
indexes = (
(('chat_id', 'task_type'), True),
)
class ScheduledTask(BaseModel):
"""Модель запланированной задачи"""
......
import asyncio
from telegrinder import Dispatch, Message, CallbackQuery, WaiterMachine, MESSAGE_FROM_USER
from telegrinder.rules import Command, CallbackDataMarkup, HasText
from database.func import DB
from data.keyboards import chat_settings_keyboards
from data.keyboards.common import cancel_kb
from modules import scheduler
from rules import IsChatAdmin, check_chat_admin
from services.pkg_watch import MAX_WATCH_LIST_SIZE
from services.utils import _bold, DAYS, format_packages_text, validate_package_name, validate_time
CANCEL_KB = cancel_kb("chat/input/cancel")
dp = Dispatch()
wm = WaiterMachine(dp)
def _chat_id(cb: CallbackQuery) -> int:
return cb.message.unwrap().v.chat.id
def _main_menu(chat_id: int):
packages = DB.watch_list.get_list(chat_id)
task = DB.chat_subscription.get(chat_id, "pkg_watch")
text = _bold("Настройки чата:\n\n")
text += f"Пакетов в списке: {len(packages)}\n"
if task:
days = ", ".join(DAYS[int(i)] for i in task.days.split(","))
time_str = f"{task.send_time.hour}:{task.send_time.minute:02d}"
text += f"Рассылка: активна\n"
text += f"Дни: {days}\n"
text += f"Время: {time_str}\n"
else:
text += "Рассылка: неактивна\n"
markup = chat_settings_keyboards.chat_settings_kb(len(packages), task)
return text, markup
@dp.message(Command("chat_settings"), IsChatAdmin())
async def chat_settings_handler(m: Message) -> None:
text, markup = _main_menu(m.chat_id)
await m.answer(text, reply_markup=markup)
@dp.callback_query(CallbackDataMarkup("chat/back"))
async def chat_back_handler(cb: CallbackQuery) -> None:
chat_id = _chat_id(cb)
text, markup = _main_menu(chat_id)
await cb.edit_text(text, reply_markup=markup)
await cb.answer()
@dp.callback_query(CallbackDataMarkup("chat/packages"))
async def chat_packages_handler(cb: CallbackQuery) -> None:
chat_id = _chat_id(cb)
if not await check_chat_admin(cb, chat_id):
return
packages = DB.watch_list.get_list(chat_id)
await cb.edit_text(
format_packages_text(packages),
reply_markup=chat_settings_keyboards.chat_packages_kb(packages),
)
await cb.answer()
@dp.callback_query(CallbackDataMarkup("chat/pkg/add"))
async def chat_pkg_add_handler(cb: CallbackQuery) -> None:
chat_id = _chat_id(cb)
if not await check_chat_admin(cb, chat_id):
return
packages = DB.watch_list.get_list(chat_id)
if len(packages) >= MAX_WATCH_LIST_SIZE:
await cb.answer(f"Достигнут лимит: {MAX_WATCH_LIST_SIZE} пакетов.")
return
await cb.answer()
prompt = await cb.ctx_api.send_message(
chat_id=chat_id, text="Введите имя пакета:", reply_markup=CANCEL_KB,
)
prompt_msg = prompt.unwrap()
try:
while True:
msg, _ = await wm.wait(MESSAGE_FROM_USER, cb.from_user.id, release=HasText())
package_name = msg.text.unwrap().strip()
await cb.ctx_api.delete_message(chat_id=chat_id, message_id=msg.message_id)
error = await validate_package_name(package_name)
if error:
await cb.ctx_api.edit_message_text(
chat_id=chat_id, message_id=prompt_msg.message_id,
text=f"{error} Введите имя пакета:", reply_markup=CANCEL_KB,
)
continue
break
except (asyncio.CancelledError, LookupError):
return
await cb.ctx_api.delete_message(chat_id=chat_id, message_id=prompt_msg.message_id)
if DB.watch_list.add(chat_id, package_name):
await cb.answer(f"Пакет {package_name} добавлен.")
else:
await cb.answer(f"Пакет {package_name} уже в списке.")
packages = DB.watch_list.get_list(chat_id)
await cb.edit_text(
format_packages_text(packages),
reply_markup=chat_settings_keyboards.chat_packages_kb(packages),
)
@dp.callback_query(CallbackDataMarkup("chat/pkg/rm/<package>"))
async def chat_pkg_remove_handler(cb: CallbackQuery, package: str) -> None:
chat_id = _chat_id(cb)
if not await check_chat_admin(cb, chat_id):
return
DB.watch_list.remove(chat_id, package)
packages = DB.watch_list.get_list(chat_id)
await cb.edit_text(
format_packages_text(packages),
reply_markup=chat_settings_keyboards.chat_packages_kb(packages),
)
await cb.answer(f"{package} удалён")
@dp.callback_query(CallbackDataMarkup("chat/schedule/add"))
@dp.callback_query(CallbackDataMarkup("chat/schedule/edit"))
async def chat_schedule_edit_handler(cb: CallbackQuery) -> None:
chat_id = _chat_id(cb)
if not await check_chat_admin(cb, chat_id):
return
task = DB.chat_subscription.get(chat_id, "pkg_watch")
selected_days = list(map(int, task.days.split(","))) if task and task.days else []
await cb.edit_text(
text='Выберите дни и нажмите "Продолжить".',
reply_markup=chat_settings_keyboards.chat_schedule_days_kb(DAYS, selected_days),
)
await cb.answer()
@dp.callback_query(CallbackDataMarkup("chat/schedule/day/<days>"))
async def chat_schedule_day_handler(cb: CallbackQuery, days: str) -> None:
chat_id = _chat_id(cb)
if not await check_chat_admin(cb, chat_id):
return
task = DB.chat_subscription.get(chat_id, "pkg_watch")
if not days:
if task:
await scheduler.remove_chat_task(chat_id, "pkg_watch")
text, markup = _main_menu(chat_id)
await cb.edit_text(text, reply_markup=markup)
await cb.answer("Рассылка отключена")
return
if task:
await scheduler.update_chat_task(chat_id, "pkg_watch", days=days)
else:
await scheduler.add_chat_task(chat_id, "pkg_watch", days, "12:00")
selected_days = list(map(int, days.split(",")))
await cb.edit_text(
text='Выберите дни и нажмите "Продолжить".',
reply_markup=chat_settings_keyboards.chat_schedule_days_kb(DAYS, selected_days),
)
await cb.answer()
@dp.callback_query(CallbackDataMarkup("chat/schedule/time"))
async def chat_schedule_time_handler(cb: CallbackQuery) -> None:
chat_id = _chat_id(cb)
if not await check_chat_admin(cb, chat_id):
return
await cb.answer()
prompt = await cb.ctx_api.send_message(
chat_id=chat_id,
text="Введите время в формате ЧЧ:ММ (например, 09:30)",
reply_markup=CANCEL_KB,
)
prompt_msg = prompt.unwrap()
try:
while True:
msg, _ = await wm.wait(MESSAGE_FROM_USER, cb.from_user.id, release=HasText())
time_str = msg.text.unwrap().strip()
await cb.ctx_api.delete_message(chat_id=chat_id, message_id=msg.message_id)
error = validate_time(time_str)
if error:
await cb.ctx_api.edit_message_text(
chat_id=chat_id, message_id=prompt_msg.message_id,
text=error, reply_markup=CANCEL_KB,
)
continue
break
except (asyncio.CancelledError, LookupError):
return
await scheduler.update_chat_task(chat_id, "pkg_watch", send_time=time_str)
await cb.ctx_api.delete_message(chat_id=chat_id, message_id=prompt_msg.message_id)
text, markup = _main_menu(chat_id)
await cb.edit_text(text, reply_markup=markup)
@dp.callback_query(CallbackDataMarkup("chat/input/cancel"))
async def chat_input_cancel_handler(cb: CallbackQuery) -> None:
chat_id = _chat_id(cb)
msg = cb.message.unwrap().v
await cb.ctx_api.delete_message(chat_id=chat_id, message_id=msg.message_id)
try:
await wm.drop(MESSAGE_FROM_USER, cb.from_user.id)
except LookupError:
pass
await cb.answer()
@dp.callback_query(CallbackDataMarkup("chat/schedule/remove"))
async def chat_schedule_remove_handler(cb: CallbackQuery) -> None:
chat_id = _chat_id(cb)
if not await check_chat_admin(cb, chat_id):
return
await scheduler.remove_chat_task(chat_id, "pkg_watch")
text, markup = _main_menu(chat_id)
await cb.edit_text(text, reply_markup=markup)
await cb.answer("Рассылка отключена")
from telegrinder import Dispatch, Message
from telegrinder.rules import Command
from database.func import DB
from database.models import User
from services.pkg_watch import fetch_pkg_updates, format_pkg_updates
dp = Dispatch()
@dp.message(Command("pkg_watch"))
async def pkg_watch_handler(m: Message, user: User | None) -> None:
owner_id = m.chat_id if m.chat.type != "private" else m.from_user.id
packages = DB.watch_list.get_list(owner_id)
if not packages:
await m.answer("Список пакетов пуст. Добавьте пакеты через настройки.")
return
latest = await fetch_pkg_updates(packages)
if not latest:
await m.answer("Нет обновлений для отслеживаемых пакетов.")
return
await m.answer(format_pkg_updates(latest))
from telegrinder import Dispatch, Message, CallbackQuery, MESSAGE_FROM_USER, WaiterMachine
from telegrinder.rules import Command, PayloadEqRule, PayloadMarkupRule, Text, IsPrivate, HasText, Argument
import asyncio
from asyncio import sleep
import re
from datetime import datetime
from telegrinder import Dispatch, Message, CallbackQuery, MESSAGE_FROM_USER, WaiterMachine
from telegrinder.rules import Command, PayloadEqRule, PayloadMarkupRule, Text, IsPrivate, HasText
from config import altrepo
from database.func import DB
from database.models import User
from data.keyboards import profile_keyboards
from data.keyboards.common import cancel_kb
from modules import scheduler
from config import altrepo
from services.menu import send_menu
from services.utils import _bold
from database.models import User
DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
from services.pkg_watch import MAX_WATCH_LIST_SIZE
from services.utils import _bold, DAYS, format_packages_text, validate_package_name, validate_time
dp = Dispatch()
wm = WaiterMachine(dp)
def _mailing_text_and_markup(user):
watch_task = DB.scheduler.get(user, 'watch')
bugs_task = DB.scheduler.get(user, 'bugs')
pkg_watch_task = DB.scheduler.get(user, 'pkg_watch')
text = (
f"{_bold('Рассылка:\n\n')}"
f"{format_task(watch_task, 'Отслеживание')}"
f"{format_task(bugs_task, 'Баги')}"
f"{format_task(pkg_watch_task, 'Watch по пакетам')}"
)
markup = profile_keyboards.settings_tasks_kb(watch_task, bugs_task, pkg_watch_task)
return text, markup
@dp.message(Command("profile") | Text(["profile", "профиль"], ignore_case=True))
async def profile_handler(m: Message, user: User | None) -> None:
......@@ -71,44 +81,53 @@ async def profile_handler(m: Message, user: User | None) -> None:
@dp.callback_query(PayloadEqRule("profile/settings"))
async def callback_confirm_handler(cb: CallbackQuery) -> None:
await cb.ctx_api.send_message(
chat_id=cb.from_user.id,
text="Настройки",
async def settings_handler(cb: CallbackQuery) -> None:
await cb.edit_text(
"Настройки",
reply_markup=profile_keyboards.profile_settings_kb
)
await cb.answer()
@dp.callback_query(PayloadEqRule("profile/settings/maintainer"))
async def callback_confirm_handler(cb: CallbackQuery) -> None:
await cb.edit_text("Введите никнейм сопровождающего:")
@dp.callback_query(PayloadEqRule("profile/close"))
async def close_handler(cb: CallbackQuery) -> None:
await cb.delete()
await cb.answer()
while True:
msg, _ = await wm.wait(MESSAGE_FROM_USER, cb.from_user.id, release=HasText())
maintainer = msg.text.unwrap().lower()
maintainer_obj = DB.maintainer.get(maintainer)
if maintainer_obj:
@dp.callback_query(PayloadEqRule("profile/settings/maintainer"))
async def maintainer_handler(cb: CallbackQuery) -> None:
cancel = cancel_kb("profile/settings")
await cb.edit_text("Введите никнейм сопровождающего:", reply_markup=cancel)
try:
while True:
msg, _ = await wm.wait(MESSAGE_FROM_USER, cb.from_user.id, release=HasText())
maintainer = msg.text.unwrap().lower()
await msg.delete()
await cb.edit_text(f"Вы выбрали {maintainer}")
DB.user.change_maintainer(
cb.from_user.id,
maintainer_obj
)
await sleep(3.0)
await cb.delete()
break
else:
await cb.edit_text(
f"Сопровождающий {maintainer} не найден.\n"
"Введите никнейм сопровождающего:"
)
maintainer_obj = DB.maintainer.get(maintainer)
if maintainer_obj:
DB.user.change_maintainer(cb.from_user.id, maintainer_obj)
await cb.edit_text(
f"Сопровождающий: {maintainer}",
reply_markup=profile_keyboards.profile_settings_kb
)
break
else:
await cb.edit_text(
f"Сопровождающий {maintainer} не найден.\n"
"Введите никнейм сопровождающего:",
reply_markup=cancel,
)
except (asyncio.CancelledError, LookupError):
return
@dp.callback_query(PayloadEqRule("profile/settings/branch"))
async def callback_confirm_handler(cb: CallbackQuery) -> None:
await cb.answer()
@dp.callback_query(PayloadEqRule("profile/settings/branch"))
async def branch_handler(cb: CallbackQuery) -> None:
await cb.edit_text(
"Выберите репозиторий",
reply_markup=profile_keyboards.profile_settings_branch_kb()
......@@ -116,40 +135,24 @@ async def callback_confirm_handler(cb: CallbackQuery) -> None:
@dp.callback_query(PayloadMarkupRule("profile/settings/branch/<branch>"))
async def callback_confirm_handler(cb: CallbackQuery, branch: str) -> None:
await cb.edit_text(f"Вы выбрали {branch}")
DB.user.change_default_branch(
cb.from_user.id,
branch
async def branch_select_handler(cb: CallbackQuery, branch: str) -> None:
DB.user.change_default_branch(cb.from_user.id, branch)
await cb.edit_text(
f"Репозиторий: {branch}",
reply_markup=profile_keyboards.profile_settings_kb
)
await sleep(3.0)
await cb.delete()
await cb.answer()
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing"))
async def mailing_handler(cb: CallbackQuery) -> None:
user = DB.user.get(cb.from_user.id)
if user is None:
return
watch_task = DB.scheduler.get(user, 'watch')
bugs_task = DB.scheduler.get(user, 'bugs')
markup = profile_keyboards.settings_tasks_kb(watch_task, bugs_task)
message = (
f"{_bold('Рассылка:\n\n')}"
f"{format_task(watch_task, 'Отслеживание')}"
f"{format_task(bugs_task, 'Баги')}"
)
text, markup = _mailing_text_and_markup(user)
await cb.edit_text(text, reply_markup=markup)
await cb.answer()
await cb.ctx_api.send_message(
chat_id=cb.from_user.id,
text=message,
reply_markup=markup
)
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing/<task_type>/set-day/<days>"))
......@@ -160,45 +163,47 @@ async def mailing_set_days_handler(cb: CallbackQuery, task_type: str, days: str)
if not days:
await scheduler.remove_task(user, task_type)
await cb.edit_text(
"Задача удалена — ни один день не выбран."
)
await sleep(3.0)
await cb.delete()
text, markup = _mailing_text_and_markup(user)
await cb.edit_text(text, reply_markup=markup)
await cb.answer("Задача удалена")
return
task = scheduler.get_task(user, task_type)
if task:
await scheduler.update_task(user, task_type, days=days)
else:
DB.scheduler.add(user, task_type, days, "12:00")
await scheduler.add_task(user, task_type, days, "12:00")
await edit_days_message(cb, task_type)
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing/<task_type>/set-time"))
async def mailing_action_handler(cb: CallbackQuery, task_type: str):
async def mailing_time_handler(cb: CallbackQuery, task_type: str):
user = DB.user.get(cb.from_user.id)
if not user:
return
await cb.edit_text("Введите время в формате ЧЧ:ММ (например, 09:30)")
while True:
msg, _ = await wm.wait(MESSAGE_FROM_USER, cb.from_user.id, release=HasText())
time_str = msg.text.unwrap().lower()
cancel = cancel_kb("profile/settings/mailing")
await cb.edit_text("Введите время в формате ЧЧ:ММ (например, 09:30)", reply_markup=cancel)
if re.match(r"^(?:[01]\d|2[0-3]):[0-5]\d$", time_str):
parsed_time = datetime.strptime(time_str, "%H:%M").time()
await scheduler.update_task(user, task_type, send_time=time_str)
await cb.edit_text(f"Время для задачи '{task_type}' установлено: {parsed_time.strftime('%H:%M')}")
try:
while True:
msg, _ = await wm.wait(MESSAGE_FROM_USER, cb.from_user.id, release=HasText())
time_str = msg.text.unwrap().strip()
await msg.delete()
await sleep(3.0)
await cb.delete()
error = validate_time(time_str)
if error:
await cb.edit_text(error, reply_markup=cancel)
continue
break
else:
await msg.delete()
await cb.edit_text("Неверный формат. Введите время в формате ЧЧ:ММ, например 08:45.")
except (asyncio.CancelledError, LookupError):
return
await scheduler.update_task(user, task_type, send_time=time_str)
text, markup = _mailing_text_and_markup(user)
await cb.edit_text(text, reply_markup=markup)
await cb.answer(f"Время: {time_str}")
@dp.callback_query(PayloadMarkupRule("profile/settings/mailing/<task_type>/<action>"))
......@@ -206,6 +211,64 @@ async def mailing_action_handler(cb: CallbackQuery, task_type: str, action: str)
await edit_days_message(cb, task_type)
@dp.callback_query(PayloadEqRule("profile/settings/packages"))
async def user_packages_handler(cb: CallbackQuery) -> None:
uid = cb.from_user.id
packages = DB.watch_list.get_list(uid)
await cb.edit_text(
format_packages_text(packages, "Мои пакеты"),
reply_markup=profile_keyboards.user_packages_kb(packages),
)
await cb.answer()
@dp.callback_query(PayloadMarkupRule("profile/pkg/add"))
async def user_pkg_add_handler(cb: CallbackQuery) -> None:
packages = DB.watch_list.get_list(cb.from_user.id)
if len(packages) >= MAX_WATCH_LIST_SIZE:
await cb.answer(f"Достигнут лимит: {MAX_WATCH_LIST_SIZE} пакетов.")
return
cancel = cancel_kb("profile/settings/packages")
await cb.edit_text("Введите имя пакета:", reply_markup=cancel)
try:
while True:
msg, _ = await wm.wait(MESSAGE_FROM_USER, cb.from_user.id, release=HasText())
package_name = msg.text.unwrap().strip()
await msg.delete()
error = await validate_package_name(package_name)
if error:
await cb.edit_text(f"{error} Введите имя пакета:", reply_markup=cancel)
continue
break
except (asyncio.CancelledError, LookupError):
return
DB.watch_list.add(cb.from_user.id, package_name)
packages = DB.watch_list.get_list(cb.from_user.id)
await cb.edit_text(
format_packages_text(packages, "Мои пакеты"),
reply_markup=profile_keyboards.user_packages_kb(packages),
)
await cb.answer(f"Пакет {package_name} добавлен")
@dp.callback_query(PayloadMarkupRule("profile/pkg/rm/<package>"))
async def user_pkg_remove_handler(cb: CallbackQuery, package: str) -> None:
uid = cb.from_user.id
DB.watch_list.remove(uid, package)
packages = DB.watch_list.get_list(uid)
await cb.edit_text(
format_packages_text(packages, "Мои пакеты"),
reply_markup=profile_keyboards.user_packages_kb(packages),
)
await cb.answer(f"{package} удалён")
@dp.callback_query(PayloadEqRule("command/menu"))
async def menu_handler(cb: CallbackQuery):
await send_menu(cb=cb)
......
......@@ -8,7 +8,10 @@ from config import tg_api, altrepo
from modules import scheduler
from altrepo.api.errors import TooManyRequests
from database.models import db, Maintainer, User, Package, ScheduledTask
from database.models import (
db, Maintainer, User, Package, ScheduledTask,
WatchList, ChatSubscription,
)
from middlewares import UserMiddleware
from services.test_api_version import test_api_version
......@@ -23,7 +26,10 @@ bot.on.message.register_middleware(UserMiddleware)
@bot.loop_wrapper.lifespan.on_startup
async def startup():
db.create_tables([Maintainer, User, Package, ScheduledTask])
db.create_tables([
Maintainer, User, Package, ScheduledTask,
WatchList, ChatSubscription,
])
logger.info("initializing ALTRepo")
await altrepo.init()
......@@ -44,6 +50,7 @@ async def startup():
commands=[
BotCommand("watch", "Отслеживание по пакетам"),
BotCommand("bugs", "Отслеживание по ошибкам"),
BotCommand("pkg_watch", "Обновления отслеживаемых пакетов"),
BotCommand("ftbfs", "Ошибки пересборки"),
BotCommand("statistics", "Статистика репозитория"),
BotCommand("altrepo_info", "Информация о боте"),
......
......@@ -6,6 +6,7 @@ from database.func import DB
from services.watch import watch
from services.bugs import bugs
from services.pkg_watch import pkg_watch
class CustomScheduler:
......@@ -45,8 +46,29 @@ class CustomScheduler:
user_id, task_type
)
# --- Chat subscription methods ---
async def add_chat_task(self, chat_id: int, task_type: str, days: str, time: str):
h, m = map(int, time.split(":"))
DB.chat_subscription.add(chat_id, task_type, days, dtime(h, m))
await self.reload_tasks()
async def update_chat_task(self, chat_id: int, task_type: str, **kwargs):
DB.chat_subscription.update(chat_id, task_type, **kwargs)
await self.reload_tasks()
async def remove_chat_task(self, chat_id: int, task_type: str):
DB.chat_subscription.delete(chat_id, task_type)
await self.reload_tasks()
def get_chat_task(self, chat_id: int, task_type: str):
return DB.chat_subscription.get(chat_id, task_type)
# --- Reload & run ---
async def reload_tasks(self):
self.scheduler.remove_all_jobs()
for task in DB.scheduler.all():
hour = task.send_time.hour
minute = task.send_time.minute
......@@ -62,11 +84,39 @@ class CustomScheduler:
id=f"{task.user.id}_{task.task_type}_{day}"
)
for sub in DB.chat_subscription.all():
hour = sub.send_time.hour
minute = sub.send_time.minute
for day in sub.days.split(","):
self.scheduler.add_job(
self._chat_job_runner,
CronTrigger(
day_of_week=int(day),
hour=int(hour),
minute=int(minute)
),
args=[sub.id],
id=f"chat_{sub.chat_id}_{sub.task_type}_{day}"
)
async def _job_runner(self, task_id: int):
"""Вызывается планировщиком"""
"""Вызывается планировщиком для пользовательских задач"""
task = DB.scheduler.get_by_id(task_id)
if not task:
return
match task.task_type:
case "watch": await watch(task.user)
case "bugs": await bugs(task.user)
case "pkg_watch":
packages = DB.watch_list.get_list(task.user.user_id)
await pkg_watch(packages, task.user.user_id)
async def _chat_job_runner(self, sub_id: int):
"""Вызывается планировщиком для чат-подписок"""
sub = DB.chat_subscription.get_by_id(sub_id)
if not sub:
return
match sub.task_type:
case "pkg_watch":
packages = DB.watch_list.get_list(sub.chat_id)
await pkg_watch(packages, sub.chat_id)
from .bot_admin import BotAdmin
from .chat_admin import IsChatAdmin, check_chat_admin
from telegrinder import Message, CallbackQuery
from telegrinder.bot.rules.abc import ABCRule
ADMIN_STATUSES = ("administrator", "creator")
class IsChatAdmin(ABCRule):
async def check(self, m: Message) -> bool:
if m.chat.type == "private":
return False
member = await m.ctx_api.get_chat_member(
chat_id=m.chat_id, user_id=m.from_user.id
)
return member.unwrap().v.status in ADMIN_STATUSES
async def check_chat_admin(cb: CallbackQuery, chat_id: int) -> bool:
member = await cb.ctx_api.get_chat_member(
chat_id=chat_id, user_id=cb.from_user.id
)
if member.unwrap().v.status not in ADMIN_STATUSES:
await cb.answer("Только админы чата могут менять настройки.")
return False
return True
from telegrinder.tools.formatting import HTMLFormatter
from config import tg_api, altrepo
from services.utils import _bold
MAX_WATCH_LIST_SIZE = 30
async def fetch_pkg_updates(packages: list[str]) -> dict:
watch_data = await altrepo.parser.packages.watch_total()
if not watch_data:
return {}
package_set = set(packages)
latest = {}
for pkg in watch_data:
if pkg.pkg_name not in package_set:
continue
prev = latest.get(pkg.pkg_name)
if prev is None or pkg.new_version > prev.new_version:
latest[pkg.pkg_name] = pkg
return latest
def format_pkg_updates(latest: dict) -> str:
message = _bold("Обновления отслеживаемых пакетов:\n\n")
for pkg in latest.values():
message += HTMLFormatter(
f"{pkg.pkg_name}: {pkg.old_version} -> "
f"<a href='{pkg.url}'>{pkg.new_version}</a>\n"
)
return message
async def pkg_watch(packages: list[str], chat_id: int) -> None:
if not packages:
return
latest = await fetch_pkg_updates(packages)
if not latest:
return
await tg_api.send_message(chat_id=chat_id, text=format_pkg_updates(latest))
......@@ -92,6 +92,39 @@ def int_validator(s: str) -> int | None:
return None
return int(s)
DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
def format_packages_text(packages: list[str], title: str = "Список пакетов") -> str:
if not packages:
return "Список пакетов пуст."
text = _bold(f"{title}:\n\n")
for pkg in packages:
text += f" {pkg}\n"
return text
async def validate_package_name(name: str) -> str | None:
"""Проверяет имя пакета. Возвращает текст ошибки или None."""
if " " in name:
return "Имя пакета не должно содержать пробелов."
try:
from config import altrepo
result = await altrepo.api.package.package_info(name, branch="sisyphus", source=True)
if not result.packages:
return f"Пакет {name} не найден."
except Exception:
return f"Пакет {name} не найден."
return None
def validate_time(value: str) -> str | None:
"""Проверяет формат времени ЧЧ:ММ. Возвращает текст ошибки или None."""
if not re.match(r"^(?:[01]\d|2[0-3]):[0-5]\d$", value):
return "Неверный формат. Введите время в формате ЧЧ:ММ (например, 09:30)"
return None
def pluralize(n: int, one: str, few: str, many: str) -> str:
n_abs = abs(n) % 100
if 11 <= n_abs <= 19:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment