diff --git a/migrations/versions/20260331_0004_runtime_settings.py b/migrations/versions/20260331_0004_runtime_settings.py new file mode 100644 index 0000000..4e86214 --- /dev/null +++ b/migrations/versions/20260331_0004_runtime_settings.py @@ -0,0 +1,44 @@ +"""runtime settings + +Revision ID: 20260331_0004 +Revises: 20260330_0003 +Create Date: 2026-03-31 10:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260331_0004" +down_revision: str | None = "20260330_0003" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "runtime_settings", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("key", sa.String(length=100), nullable=False), + sa.Column("value", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + op.create_index(op.f("ix_runtime_settings_key"), "runtime_settings", ["key"], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f("ix_runtime_settings_key"), table_name="runtime_settings") + op.drop_table("runtime_settings") diff --git a/src/glitchup_bot/bot/bot.py b/src/glitchup_bot/bot/bot.py index d7dc69c..4529185 100644 --- a/src/glitchup_bot/bot/bot.py +++ b/src/glitchup_bot/bot/bot.py @@ -1,5 +1,6 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties +from aiogram.client.session.aiohttp import AiohttpSession from glitchup_bot.bot.handlers.commands import router as commands_router from glitchup_bot.config import settings @@ -18,9 +19,15 @@ def get_bot() -> Bot: global bot if bot is None: + session = ( + AiohttpSession(proxy=settings.telegram_proxy_url) + if settings.telegram_proxy_url + else None + ) bot = Bot( token=settings.telegram_bot_token, default=DefaultBotProperties(parse_mode="HTML"), + session=session, ) return bot diff --git a/src/glitchup_bot/bot/handlers/commands.py b/src/glitchup_bot/bot/handlers/commands.py index db0cd4d..2e90e2b 100644 --- a/src/glitchup_bot/bot/handlers/commands.py +++ b/src/glitchup_bot/bot/handlers/commands.py @@ -12,10 +12,11 @@ from aiogram.types import CallbackQuery, Message from glitchup_bot.bot.keyboards import ( admin_home_keyboard, - admin_overview_keyboard, admin_recipient_group_keyboard, admin_recipients_keyboard, admin_result_keyboard, + admin_routing_keyboard, + admin_settings_keyboard, admin_sync_keyboard, help_home_keyboard, help_result_keyboard, @@ -49,6 +50,7 @@ from glitchup_bot.services.routing import ( set_project_group, set_topic_override, ) +from glitchup_bot.services.runtime_settings import get_runtime_settings, set_runtime_setting from glitchup_bot.services.sync_service import get_last_sync_state router = Router() @@ -57,6 +59,7 @@ MAX_PAGE_CHARS = 3000 MAX_PAGE_LINES = 18 MAX_PAGINATION_SESSIONS = 200 PENDING_RECIPIENT_ACTIONS: dict[int, tuple[str, str]] = {} +PENDING_SETTING_ACTIONS: dict[int, tuple[str, str]] = {} @dataclass(slots=True) @@ -164,6 +167,7 @@ def _help_text(is_admin: bool) -> str: async def _start_text(is_admin: bool) -> str: + runtime = await get_runtime_settings() state = await get_last_sync_state("api_sync") sync_value = ( state.last_successful_at.astimezone().strftime("%Y-%m-%d %H:%M") @@ -171,22 +175,22 @@ async def _start_text(is_admin: bool) -> str: else "ещё не было" ) lines = [ - "GlitchUp Bot", + f"{escape(runtime.bot_title)}", f"Синхронизация: {escape(sync_value)}", "", - "Откройте нужную сводку кнопками ниже.", + escape(runtime.bot_purpose), ] if is_admin: - lines.extend(["", "Администрирование доступно через кнопку ниже."]) + lines.extend(["", escape(runtime.bot_admin_hint)]) return "\n".join(lines) -def _admin_overview_text() -> str: +def _admin_routing_text() -> str: return "\n".join( [ - "Сводки", + "Topic и routing", "", - "Быстрый доступ к основным экранам без лишних разделов.", + "Здесь можно смотреть текущую схему и менять topic override без env.", ] ) @@ -198,10 +202,11 @@ def _admin_text() -> str: "", "Здесь только практичные разделы:", "• синхронизация", - "• основные сводки", "• получатели по backend/frontend", + "• topic и routing", + "• runtime-настройки бота", "• администраторы", - "• routing и mute rules", + "• mute rules", ] ) @@ -228,6 +233,26 @@ def _admin_recipients_text() -> str: ) +async def _runtime_settings_text() -> str: + runtime = await get_runtime_settings() + lines = [ + "Настройки бота", + "", + f"• автосинк: {'включён' if runtime.sync_enabled else 'выключен'}", + f"• интервал sync: {runtime.sync_interval_minutes} мин", + ( + "• отчёт: " + f"{runtime.digest_cron_day} " + f"{runtime.digest_cron_hour:02d}:{runtime.digest_cron_minute:02d} " + f"{runtime.digest_timezone}" + ), + f"• название: {escape(runtime.bot_title)}", + f"• описание: {escape(runtime.bot_purpose)}", + f"• подсказка админу: {escape(runtime.bot_admin_hint)}", + ] + return "\n".join(lines) + + def _recipient_group_text(group_name: str) -> str: return "\n".join( [ @@ -313,6 +338,20 @@ async def _recipients_text(group_name: str) -> str: return "\n".join(lines) +def _setting_prompt_text(setting_key: str, target: str) -> str: + prompts = { + "topic": f"Отправьте новым сообщением числовой topic ID для {escape(target)}.", + "sync_interval_minutes": "Отправьте интервал синхронизации в минутах.", + "digest_cron_day": "Отправьте день отчёта, например mon.", + "digest_time": "Отправьте время отчёта в формате HH:MM.", + "digest_timezone": "Отправьте timezone, например Asia/Krasnoyarsk.", + "bot_title": "Отправьте новое название бота.", + "bot_purpose": "Отправьте короткое описание, для чего нужен бот.", + "bot_admin_hint": "Отправьте текст подсказки для администратора на /start.", + } + return prompts[setting_key] + + async def _require_admin(message: Message) -> bool: if await _is_admin_user(_sender_id(message)): return True @@ -667,13 +706,6 @@ async def cb_admin_actions(callback: CallbackQuery) -> None: reply_markup=admin_sync_keyboard(), ) return - if action == "menu:overview": - await _show_callback_screen( - callback, - _admin_overview_text(), - reply_markup=admin_overview_keyboard(), - ) - return if action == "menu:recipients": await _show_callback_screen( callback, @@ -681,6 +713,20 @@ async def cb_admin_actions(callback: CallbackQuery) -> None: reply_markup=admin_recipients_keyboard(), ) return + if action == "menu:routing": + await _show_callback_screen( + callback, + _admin_routing_text(), + reply_markup=admin_routing_keyboard(), + ) + return + if action == "menu:settings": + await _show_callback_screen( + callback, + await _runtime_settings_text(), + reply_markup=admin_settings_keyboard(), + ) + return if action in {"recipients:backend", "recipients:frontend"}: group_name = action.split(":", 1)[1] await _show_callback_screen( @@ -742,6 +788,99 @@ async def cb_admin_actions(callback: CallbackQuery) -> None: admin_mode=True, ) return + if action == "settings:sync_enabled": + runtime = await get_runtime_settings() + await set_runtime_setting("sync_enabled", "false" if runtime.sync_enabled else "true") + from glitchup_bot.tasks.scheduler import reload_scheduler + + await reload_scheduler() + await _show_callback_screen( + callback, + await _runtime_settings_text(), + reply_markup=admin_settings_keyboard(), + ) + return + if action == "settings:sync_interval": + admin_id = _callback_sender_id(callback) + if admin_id is not None: + PENDING_SETTING_ACTIONS[admin_id] = ("sync_interval_minutes", "") + await _show_callback_screen( + callback, + _setting_prompt_text("sync_interval_minutes", ""), + reply_markup=admin_result_keyboard("admin:menu:settings"), + ) + return + if action == "settings:digest_day": + admin_id = _callback_sender_id(callback) + if admin_id is not None: + PENDING_SETTING_ACTIONS[admin_id] = ("digest_cron_day", "") + await _show_callback_screen( + callback, + _setting_prompt_text("digest_cron_day", ""), + reply_markup=admin_result_keyboard("admin:menu:settings"), + ) + return + if action == "settings:digest_time": + admin_id = _callback_sender_id(callback) + if admin_id is not None: + PENDING_SETTING_ACTIONS[admin_id] = ("digest_time", "") + await _show_callback_screen( + callback, + _setting_prompt_text("digest_time", ""), + reply_markup=admin_result_keyboard("admin:menu:settings"), + ) + return + if action == "settings:digest_timezone": + admin_id = _callback_sender_id(callback) + if admin_id is not None: + PENDING_SETTING_ACTIONS[admin_id] = ("digest_timezone", "") + await _show_callback_screen( + callback, + _setting_prompt_text("digest_timezone", ""), + reply_markup=admin_result_keyboard("admin:menu:settings"), + ) + return + if action == "settings:bot_title": + admin_id = _callback_sender_id(callback) + if admin_id is not None: + PENDING_SETTING_ACTIONS[admin_id] = ("bot_title", "") + await _show_callback_screen( + callback, + _setting_prompt_text("bot_title", ""), + reply_markup=admin_result_keyboard("admin:menu:settings"), + ) + return + if action == "settings:bot_purpose": + admin_id = _callback_sender_id(callback) + if admin_id is not None: + PENDING_SETTING_ACTIONS[admin_id] = ("bot_purpose", "") + await _show_callback_screen( + callback, + _setting_prompt_text("bot_purpose", ""), + reply_markup=admin_result_keyboard("admin:menu:settings"), + ) + return + if action == "settings:bot_admin_hint": + admin_id = _callback_sender_id(callback) + if admin_id is not None: + PENDING_SETTING_ACTIONS[admin_id] = ("bot_admin_hint", "") + await _show_callback_screen( + callback, + _setting_prompt_text("bot_admin_hint", ""), + reply_markup=admin_result_keyboard("admin:menu:settings"), + ) + return + if action.startswith("settings:topic:"): + group_name = action.rsplit(":", 1)[1] + admin_id = _callback_sender_id(callback) + if admin_id is not None: + PENDING_SETTING_ACTIONS[admin_id] = ("topic", group_name) + await _show_callback_screen( + callback, + _setting_prompt_text("topic", group_name), + reply_markup=admin_result_keyboard("admin:menu:routing"), + ) + return if action == "sync": summary = await run_manual_sync() await _deliver_result( @@ -779,42 +918,6 @@ async def cb_admin_actions(callback: CallbackQuery) -> None: admin_mode=True, ) return - if action == "today": - await _run_summary_action( - callback, - lambda: build_today_summary(refresh=False), - back_callback="admin:menu:overview", - is_admin=True, - admin_mode=True, - ) - return - if action == "week": - await _run_summary_action( - callback, - lambda: build_digest(refresh=False), - back_callback="admin:menu:overview", - is_admin=True, - admin_mode=True, - ) - return - if action == "top": - await _run_summary_action( - callback, - lambda: build_top_issues(refresh=False), - back_callback="admin:menu:overview", - is_admin=True, - admin_mode=True, - ) - return - if action == "stale": - await _run_summary_action( - callback, - lambda: build_stale_issues(refresh=False), - back_callback="admin:menu:overview", - is_admin=True, - admin_mode=True, - ) - return if action == "guide": await _deliver_result( callback, @@ -1033,7 +1136,7 @@ async def cmd_admin_del(message: Message) -> None: @router.message(F.text) async def cmd_pending_recipient_input(message: Message) -> None: user_id = _sender_id(message) - if user_id is None or user_id not in PENDING_RECIPIENT_ACTIONS: + if user_id is None: return if not await _require_admin(message): return @@ -1041,6 +1144,73 @@ async def cmd_pending_recipient_input(message: Message) -> None: raw_value = (message.text or "").strip() if raw_value.startswith("/"): return + + if user_id in PENDING_SETTING_ACTIONS: + setting_key, target = PENDING_SETTING_ACTIONS.pop(user_id) + + if setting_key == "topic": + if not raw_value.lstrip("-").isdigit(): + await message.answer("Нужен числовой topic ID.") + return + await set_topic_override(target, int(raw_value)) + text = f"Topic для {escape(target)} обновлён: {escape(raw_value)}." + back_callback = "admin:menu:routing" + elif setting_key == "sync_interval_minutes": + if not raw_value.isdigit(): + await message.answer("Нужно указать интервал в минутах числом.") + return + await set_runtime_setting("sync_interval_minutes", raw_value) + from glitchup_bot.tasks.scheduler import reload_scheduler + + await reload_scheduler() + text = f"Интервал sync обновлён: {escape(raw_value)} мин." + back_callback = "admin:menu:settings" + elif setting_key == "digest_cron_day": + await set_runtime_setting("digest_cron_day", raw_value) + from glitchup_bot.tasks.scheduler import reload_scheduler + + await reload_scheduler() + text = f"День weekly digest обновлён: {escape(raw_value)}." + back_callback = "admin:menu:settings" + elif setting_key == "digest_time": + if ":" not in raw_value: + await message.answer("Нужно указать время в формате HH:MM.") + return + hour, minute = raw_value.split(":", 1) + if not (hour.isdigit() and minute.isdigit()): + await message.answer("Нужно указать время в формате HH:MM.") + return + await set_runtime_setting("digest_cron_hour", hour) + await set_runtime_setting("digest_cron_minute", minute) + from glitchup_bot.tasks.scheduler import reload_scheduler + + await reload_scheduler() + text = f"Время weekly digest обновлено: {escape(raw_value)}." + back_callback = "admin:menu:settings" + elif setting_key == "digest_timezone": + await set_runtime_setting("digest_timezone", raw_value) + from glitchup_bot.tasks.scheduler import reload_scheduler + + await reload_scheduler() + text = f"Timezone обновлён: {escape(raw_value)}." + back_callback = "admin:menu:settings" + else: + await set_runtime_setting(setting_key, raw_value) + text = f"Настройка {escape(setting_key)} обновлена." + back_callback = "admin:menu:settings" + + await _deliver_result( + message, + text, + back_callback=back_callback, + is_admin=True, + admin_mode=True, + ) + return + + if user_id not in PENDING_RECIPIENT_ACTIONS: + return + if not raw_value.lstrip("-").isdigit(): await message.answer("Нужен числовой Telegram ID.") return diff --git a/src/glitchup_bot/bot/keyboards.py b/src/glitchup_bot/bot/keyboards.py index 878f0c2..ca8075f 100644 --- a/src/glitchup_bot/bot/keyboards.py +++ b/src/glitchup_bot/bot/keyboards.py @@ -52,32 +52,29 @@ def help_result_keyboard( total_pages=total_pages, ) builder.button(text="Назад", callback_data=back_callback) - builder.button(text="Главное меню", callback_data="help:open") if is_admin: - builder.button(text="Админ-панель", callback_data="admin:open") if total_pages > 1: - builder.adjust(3, 2, 1) + builder.adjust(3, 1) else: - builder.adjust(2, 1) + builder.adjust(1) else: if total_pages > 1: - builder.adjust(3, 2) + builder.adjust(3, 1) else: - builder.adjust(2) + builder.adjust(1) return builder.as_markup() def admin_home_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - builder.button(text="Сводки", callback_data="admin:menu:overview") builder.button(text="Синхронизация", callback_data="admin:menu:sync") builder.button(text="Получатели", callback_data="admin:menu:recipients") + builder.button(text="Topic и routing", callback_data="admin:menu:routing") + builder.button(text="Настройки бота", callback_data="admin:menu:settings") builder.button(text="Администраторы", callback_data="admin:admins") - builder.button(text="Routing и topics", callback_data="admin:ownership") builder.button(text="Mute rules", callback_data="admin:mute_list") builder.button(text="Инструкция", callback_data="admin:guide") - builder.button(text="Пользовательское меню", callback_data="help:open") - builder.adjust(2, 2, 2, 1, 1) + builder.adjust(2, 2, 2, 1) return builder.as_markup() @@ -91,17 +88,32 @@ def admin_sync_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() -def admin_overview_keyboard() -> InlineKeyboardMarkup: +def admin_routing_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - builder.button(text="Сегодня", callback_data="admin:today") - builder.button(text="Неделя", callback_data="admin:week") - builder.button(text="Самые шумные", callback_data="admin:top") - builder.button(text="Давно висят", callback_data="admin:stale") + builder.button(text="Текущая схема", callback_data="admin:ownership") + builder.button(text="Topic backend", callback_data="admin:settings:topic:backend") + builder.button(text="Topic frontend", callback_data="admin:settings:topic:frontend") + builder.button(text="Topic digest", callback_data="admin:settings:topic:digest") builder.button(text="Назад", callback_data="admin:open") builder.adjust(2, 2, 1) return builder.as_markup() +def admin_settings_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="Автосинк: вкл/выкл", callback_data="admin:settings:sync_enabled") + builder.button(text="Интервал sync", callback_data="admin:settings:sync_interval") + builder.button(text="День отчёта", callback_data="admin:settings:digest_day") + builder.button(text="Время отчёта", callback_data="admin:settings:digest_time") + builder.button(text="Часовой пояс", callback_data="admin:settings:digest_timezone") + builder.button(text="Название бота", callback_data="admin:settings:bot_title") + builder.button(text="Описание бота", callback_data="admin:settings:bot_purpose") + builder.button(text="Подсказка админу", callback_data="admin:settings:bot_admin_hint") + builder.button(text="Назад", callback_data="admin:open") + builder.adjust(2, 2, 2, 2, 1) + return builder.as_markup() + + def admin_recipients_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="Backend", callback_data="admin:recipients:backend") @@ -136,10 +148,8 @@ def admin_result_keyboard( total_pages=total_pages, ) builder.button(text="Назад", callback_data=back_callback) - builder.button(text="Админ-панель", callback_data="admin:open") - builder.button(text="Пользовательское меню", callback_data="help:open") if total_pages > 1: - builder.adjust(3, 2, 1) + builder.adjust(3, 1) else: - builder.adjust(2, 1) + builder.adjust(1) return builder.as_markup() diff --git a/src/glitchup_bot/config.py b/src/glitchup_bot/config.py index 3526d4a..3f63799 100644 --- a/src/glitchup_bot/config.py +++ b/src/glitchup_bot/config.py @@ -23,6 +23,8 @@ class Settings(BaseSettings): glitchtip_url: str glitchtip_api_token: str glitchtip_org_slug: str + glitchtip_proxy_url: str = "" + telegram_proxy_url: str = "" database_url: str diff --git a/src/glitchup_bot/glitchtip_client/client.py b/src/glitchup_bot/glitchtip_client/client.py index fa712b8..b2acaed 100644 --- a/src/glitchup_bot/glitchtip_client/client.py +++ b/src/glitchup_bot/glitchtip_client/client.py @@ -20,6 +20,7 @@ class GlitchTipClient: base_url=self.base_url, headers=self.headers, timeout=30, + proxy=settings.glitchtip_proxy_url or None, ) return self._client diff --git a/src/glitchup_bot/main.py b/src/glitchup_bot/main.py index 5c03f2b..65c214e 100644 --- a/src/glitchup_bot/main.py +++ b/src/glitchup_bot/main.py @@ -39,7 +39,7 @@ async def shutdown_resources() -> None: async def main() -> None: logger.info("GlitchUp Bot starting") await warm_issue_cache_on_startup() - setup_scheduler() + await setup_scheduler() api_task = asyncio.create_task(start_api(), name="api") bot_task = asyncio.create_task(start_bot(), name="bot") diff --git a/src/glitchup_bot/models/__init__.py b/src/glitchup_bot/models/__init__.py index a78d6c2..32863cc 100644 --- a/src/glitchup_bot/models/__init__.py +++ b/src/glitchup_bot/models/__init__.py @@ -8,6 +8,7 @@ from glitchup_bot.models.ownership import ( GroupTopicOverride, ProjectOwnershipOverride, ) +from glitchup_bot.models.runtime_settings import RuntimeSetting from glitchup_bot.models.sync import SyncState __all__ = [ @@ -19,5 +20,6 @@ __all__ = [ "MuteRule", "NotificationSent", "ProjectOwnershipOverride", + "RuntimeSetting", "SyncState", ] diff --git a/src/glitchup_bot/models/runtime_settings.py b/src/glitchup_bot/models/runtime_settings.py new file mode 100644 index 0000000..08ceb39 --- /dev/null +++ b/src/glitchup_bot/models/runtime_settings.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from glitchup_bot.models.base import Base + + +class RuntimeSetting(Base): + __tablename__ = "runtime_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + key: Mapped[str] = mapped_column(String(100), unique=True, index=True) + value: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/src/glitchup_bot/services/runtime_settings.py b/src/glitchup_bot/services/runtime_settings.py new file mode 100644 index 0000000..8303d16 --- /dev/null +++ b/src/glitchup_bot/services/runtime_settings.py @@ -0,0 +1,77 @@ +from dataclasses import dataclass + +from sqlalchemy import select + +from glitchup_bot.config import settings +from glitchup_bot.models.database import get_session_factory +from glitchup_bot.models.runtime_settings import RuntimeSetting + + +@dataclass(slots=True) +class RuntimeSettingsView: + bot_title: str + bot_purpose: str + bot_admin_hint: str + sync_enabled: bool + sync_interval_minutes: int + digest_cron_day: str + digest_cron_hour: int + digest_cron_minute: int + digest_timezone: str + + +DEFAULT_BOT_TITLE = "GlitchUp Bot" +DEFAULT_BOT_PURPOSE = "Бот показывает состояние ошибок и помогает команде быстро их разбирать." +DEFAULT_BOT_ADMIN_HINT = "Администрирование доступно через кнопку ниже." + + +async def _list_runtime_settings_map() -> dict[str, str]: + async with get_session_factory()() as session: + result = await session.execute(select(RuntimeSetting).order_by(RuntimeSetting.key)) + return {record.key: record.value for record in result.scalars().all()} + + +async def get_runtime_settings() -> RuntimeSettingsView: + values = await _list_runtime_settings_map() + return RuntimeSettingsView( + bot_title=values.get("bot_title", DEFAULT_BOT_TITLE), + bot_purpose=values.get("bot_purpose", DEFAULT_BOT_PURPOSE), + bot_admin_hint=values.get("bot_admin_hint", DEFAULT_BOT_ADMIN_HINT), + sync_enabled=values.get("sync_enabled", "true").lower() == "true", + sync_interval_minutes=int( + values.get("sync_interval_minutes", settings.sync_interval_minutes) + ), + digest_cron_day=values.get("digest_cron_day", settings.digest_cron_day), + digest_cron_hour=int(values.get("digest_cron_hour", settings.digest_cron_hour)), + digest_cron_minute=int(values.get("digest_cron_minute", settings.digest_cron_minute)), + digest_timezone=values.get("digest_timezone", settings.digest_timezone), + ) + + +async def list_runtime_settings() -> list[RuntimeSetting]: + async with get_session_factory()() as session: + result = await session.execute(select(RuntimeSetting).order_by(RuntimeSetting.key)) + return list(result.scalars().all()) + + +async def set_runtime_setting(key: str, value: str) -> None: + async with get_session_factory()() as session: + result = await session.execute(select(RuntimeSetting).where(RuntimeSetting.key == key)) + record = result.scalar_one_or_none() + if record is None: + session.add(RuntimeSetting(key=key, value=value)) + else: + record.value = value + await session.commit() + + +async def delete_runtime_setting(key: str) -> bool: + async with get_session_factory()() as session: + result = await session.execute(select(RuntimeSetting).where(RuntimeSetting.key == key)) + record = result.scalar_one_or_none() + if record is None: + return False + + await session.delete(record) + await session.commit() + return True diff --git a/src/glitchup_bot/tasks/scheduler.py b/src/glitchup_bot/tasks/scheduler.py index 148e84d..d586fa9 100644 --- a/src/glitchup_bot/tasks/scheduler.py +++ b/src/glitchup_bot/tasks/scheduler.py @@ -4,8 +4,8 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger -from glitchup_bot.config import settings from glitchup_bot.services.digest_builder import build_digest +from glitchup_bot.services.runtime_settings import get_runtime_settings from glitchup_bot.services.sync_service import sync_issues from glitchup_bot.services.telegram_sender import send_digest_message @@ -17,7 +17,7 @@ scheduler: AsyncIOScheduler | None = None async def weekly_digest_job() -> None: logger.info("Running weekly digest job") try: - await send_digest_message(await build_digest(refresh=True)) + await send_digest_message(await build_digest(refresh=False)) logger.info("Weekly digest sent successfully") except Exception: logger.exception("Failed to send weekly digest") @@ -37,42 +37,53 @@ async def sync_job() -> None: logger.exception("Scheduled issue sync failed") -def setup_scheduler() -> AsyncIOScheduler: +async def setup_scheduler() -> AsyncIOScheduler: global scheduler if scheduler is not None and scheduler.running: return scheduler + runtime = await get_runtime_settings() scheduler = AsyncIOScheduler() - scheduler.add_job( - sync_job, - IntervalTrigger(minutes=settings.sync_interval_minutes), - id="issue_sync", - replace_existing=True, - ) + if runtime.sync_enabled: + scheduler.add_job( + sync_job, + IntervalTrigger(minutes=runtime.sync_interval_minutes), + id="issue_sync", + replace_existing=True, + ) scheduler.add_job( weekly_digest_job, CronTrigger( - day_of_week=settings.digest_cron_day, - hour=settings.digest_cron_hour, - minute=settings.digest_cron_minute, - timezone=settings.digest_timezone, + day_of_week=runtime.digest_cron_day, + hour=runtime.digest_cron_hour, + minute=runtime.digest_cron_minute, + timezone=runtime.digest_timezone, ), id="weekly_digest", replace_existing=True, ) scheduler.start() logger.info( - "Scheduler started: sync every %s min, digest at %s %02d:%02d %s", - settings.sync_interval_minutes, - settings.digest_cron_day, - settings.digest_cron_hour, - settings.digest_cron_minute, - settings.digest_timezone, + "Scheduler started: sync %s, digest at %s %02d:%02d %s", + ( + f"every {runtime.sync_interval_minutes} min" + if runtime.sync_enabled + else "disabled" + ), + runtime.digest_cron_day, + runtime.digest_cron_hour, + runtime.digest_cron_minute, + runtime.digest_timezone, ) return scheduler +async def reload_scheduler() -> AsyncIOScheduler: + await shutdown_scheduler() + return await setup_scheduler() + + async def shutdown_scheduler() -> None: global scheduler