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