Добавление прокси + пересмена интерфейса
This commit is contained in:
44
migrations/versions/20260331_0004_runtime_settings.py
Normal file
44
migrations/versions/20260331_0004_runtime_settings.py
Normal file
@@ -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")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.client.default import DefaultBotProperties
|
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.bot.handlers.commands import router as commands_router
|
||||||
from glitchup_bot.config import settings
|
from glitchup_bot.config import settings
|
||||||
@@ -18,9 +19,15 @@ def get_bot() -> Bot:
|
|||||||
global bot
|
global bot
|
||||||
|
|
||||||
if bot is None:
|
if bot is None:
|
||||||
|
session = (
|
||||||
|
AiohttpSession(proxy=settings.telegram_proxy_url)
|
||||||
|
if settings.telegram_proxy_url
|
||||||
|
else None
|
||||||
|
)
|
||||||
bot = Bot(
|
bot = Bot(
|
||||||
token=settings.telegram_bot_token,
|
token=settings.telegram_bot_token,
|
||||||
default=DefaultBotProperties(parse_mode="HTML"),
|
default=DefaultBotProperties(parse_mode="HTML"),
|
||||||
|
session=session,
|
||||||
)
|
)
|
||||||
|
|
||||||
return bot
|
return bot
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ from aiogram.types import CallbackQuery, Message
|
|||||||
|
|
||||||
from glitchup_bot.bot.keyboards import (
|
from glitchup_bot.bot.keyboards import (
|
||||||
admin_home_keyboard,
|
admin_home_keyboard,
|
||||||
admin_overview_keyboard,
|
|
||||||
admin_recipient_group_keyboard,
|
admin_recipient_group_keyboard,
|
||||||
admin_recipients_keyboard,
|
admin_recipients_keyboard,
|
||||||
admin_result_keyboard,
|
admin_result_keyboard,
|
||||||
|
admin_routing_keyboard,
|
||||||
|
admin_settings_keyboard,
|
||||||
admin_sync_keyboard,
|
admin_sync_keyboard,
|
||||||
help_home_keyboard,
|
help_home_keyboard,
|
||||||
help_result_keyboard,
|
help_result_keyboard,
|
||||||
@@ -49,6 +50,7 @@ from glitchup_bot.services.routing import (
|
|||||||
set_project_group,
|
set_project_group,
|
||||||
set_topic_override,
|
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
|
from glitchup_bot.services.sync_service import get_last_sync_state
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -57,6 +59,7 @@ MAX_PAGE_CHARS = 3000
|
|||||||
MAX_PAGE_LINES = 18
|
MAX_PAGE_LINES = 18
|
||||||
MAX_PAGINATION_SESSIONS = 200
|
MAX_PAGINATION_SESSIONS = 200
|
||||||
PENDING_RECIPIENT_ACTIONS: dict[int, tuple[str, str]] = {}
|
PENDING_RECIPIENT_ACTIONS: dict[int, tuple[str, str]] = {}
|
||||||
|
PENDING_SETTING_ACTIONS: dict[int, tuple[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -164,6 +167,7 @@ def _help_text(is_admin: bool) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _start_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")
|
state = await get_last_sync_state("api_sync")
|
||||||
sync_value = (
|
sync_value = (
|
||||||
state.last_successful_at.astimezone().strftime("%Y-%m-%d %H:%M")
|
state.last_successful_at.astimezone().strftime("%Y-%m-%d %H:%M")
|
||||||
@@ -171,22 +175,22 @@ async def _start_text(is_admin: bool) -> str:
|
|||||||
else "ещё не было"
|
else "ещё не было"
|
||||||
)
|
)
|
||||||
lines = [
|
lines = [
|
||||||
"<b>GlitchUp Bot</b>",
|
f"<b>{escape(runtime.bot_title)}</b>",
|
||||||
f"Синхронизация: {escape(sync_value)}",
|
f"Синхронизация: {escape(sync_value)}",
|
||||||
"",
|
"",
|
||||||
"Откройте нужную сводку кнопками ниже.",
|
escape(runtime.bot_purpose),
|
||||||
]
|
]
|
||||||
if is_admin:
|
if is_admin:
|
||||||
lines.extend(["", "Администрирование доступно через кнопку ниже."])
|
lines.extend(["", escape(runtime.bot_admin_hint)])
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _admin_overview_text() -> str:
|
def _admin_routing_text() -> str:
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
[
|
[
|
||||||
"<b>Сводки</b>",
|
"<b>Topic и routing</b>",
|
||||||
"",
|
"",
|
||||||
"Быстрый доступ к основным экранам без лишних разделов.",
|
"Здесь можно смотреть текущую схему и менять topic override без env.",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -198,10 +202,11 @@ def _admin_text() -> str:
|
|||||||
"",
|
"",
|
||||||
"Здесь только практичные разделы:",
|
"Здесь только практичные разделы:",
|
||||||
"• синхронизация",
|
"• синхронизация",
|
||||||
"• основные сводки",
|
|
||||||
"• получатели по backend/frontend",
|
"• получатели по 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 = [
|
||||||
|
"<b>Настройки бота</b>",
|
||||||
|
"",
|
||||||
|
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:
|
def _recipient_group_text(group_name: str) -> str:
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
[
|
[
|
||||||
@@ -313,6 +338,20 @@ async def _recipients_text(group_name: str) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _setting_prompt_text(setting_key: str, target: str) -> str:
|
||||||
|
prompts = {
|
||||||
|
"topic": f"Отправьте новым сообщением числовой topic ID для <b>{escape(target)}</b>.",
|
||||||
|
"sync_interval_minutes": "Отправьте интервал синхронизации в минутах.",
|
||||||
|
"digest_cron_day": "Отправьте день отчёта, например <code>mon</code>.",
|
||||||
|
"digest_time": "Отправьте время отчёта в формате <code>HH:MM</code>.",
|
||||||
|
"digest_timezone": "Отправьте timezone, например <code>Asia/Krasnoyarsk</code>.",
|
||||||
|
"bot_title": "Отправьте новое название бота.",
|
||||||
|
"bot_purpose": "Отправьте короткое описание, для чего нужен бот.",
|
||||||
|
"bot_admin_hint": "Отправьте текст подсказки для администратора на /start.",
|
||||||
|
}
|
||||||
|
return prompts[setting_key]
|
||||||
|
|
||||||
|
|
||||||
async def _require_admin(message: Message) -> bool:
|
async def _require_admin(message: Message) -> bool:
|
||||||
if await _is_admin_user(_sender_id(message)):
|
if await _is_admin_user(_sender_id(message)):
|
||||||
return True
|
return True
|
||||||
@@ -667,13 +706,6 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
|
|||||||
reply_markup=admin_sync_keyboard(),
|
reply_markup=admin_sync_keyboard(),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if action == "menu:overview":
|
|
||||||
await _show_callback_screen(
|
|
||||||
callback,
|
|
||||||
_admin_overview_text(),
|
|
||||||
reply_markup=admin_overview_keyboard(),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if action == "menu:recipients":
|
if action == "menu:recipients":
|
||||||
await _show_callback_screen(
|
await _show_callback_screen(
|
||||||
callback,
|
callback,
|
||||||
@@ -681,6 +713,20 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
|
|||||||
reply_markup=admin_recipients_keyboard(),
|
reply_markup=admin_recipients_keyboard(),
|
||||||
)
|
)
|
||||||
return
|
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"}:
|
if action in {"recipients:backend", "recipients:frontend"}:
|
||||||
group_name = action.split(":", 1)[1]
|
group_name = action.split(":", 1)[1]
|
||||||
await _show_callback_screen(
|
await _show_callback_screen(
|
||||||
@@ -742,6 +788,99 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
|
|||||||
admin_mode=True,
|
admin_mode=True,
|
||||||
)
|
)
|
||||||
return
|
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":
|
if action == "sync":
|
||||||
summary = await run_manual_sync()
|
summary = await run_manual_sync()
|
||||||
await _deliver_result(
|
await _deliver_result(
|
||||||
@@ -779,42 +918,6 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
|
|||||||
admin_mode=True,
|
admin_mode=True,
|
||||||
)
|
)
|
||||||
return
|
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":
|
if action == "guide":
|
||||||
await _deliver_result(
|
await _deliver_result(
|
||||||
callback,
|
callback,
|
||||||
@@ -1033,7 +1136,7 @@ async def cmd_admin_del(message: Message) -> None:
|
|||||||
@router.message(F.text)
|
@router.message(F.text)
|
||||||
async def cmd_pending_recipient_input(message: Message) -> None:
|
async def cmd_pending_recipient_input(message: Message) -> None:
|
||||||
user_id = _sender_id(message)
|
user_id = _sender_id(message)
|
||||||
if user_id is None or user_id not in PENDING_RECIPIENT_ACTIONS:
|
if user_id is None:
|
||||||
return
|
return
|
||||||
if not await _require_admin(message):
|
if not await _require_admin(message):
|
||||||
return
|
return
|
||||||
@@ -1041,6 +1144,73 @@ async def cmd_pending_recipient_input(message: Message) -> None:
|
|||||||
raw_value = (message.text or "").strip()
|
raw_value = (message.text or "").strip()
|
||||||
if raw_value.startswith("/"):
|
if raw_value.startswith("/"):
|
||||||
return
|
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 для <b>{escape(target)}</b> обновлён: <code>{escape(raw_value)}</code>."
|
||||||
|
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 обновлён: <code>{escape(raw_value)}</code> мин."
|
||||||
|
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 обновлён: <code>{escape(raw_value)}</code>."
|
||||||
|
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 обновлено: <code>{escape(raw_value)}</code>."
|
||||||
|
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 обновлён: <code>{escape(raw_value)}</code>."
|
||||||
|
back_callback = "admin:menu:settings"
|
||||||
|
else:
|
||||||
|
await set_runtime_setting(setting_key, raw_value)
|
||||||
|
text = f"Настройка <code>{escape(setting_key)}</code> обновлена."
|
||||||
|
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():
|
if not raw_value.lstrip("-").isdigit():
|
||||||
await message.answer("Нужен числовой Telegram ID.")
|
await message.answer("Нужен числовой Telegram ID.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -52,32 +52,29 @@ def help_result_keyboard(
|
|||||||
total_pages=total_pages,
|
total_pages=total_pages,
|
||||||
)
|
)
|
||||||
builder.button(text="Назад", callback_data=back_callback)
|
builder.button(text="Назад", callback_data=back_callback)
|
||||||
builder.button(text="Главное меню", callback_data="help:open")
|
|
||||||
if is_admin:
|
if is_admin:
|
||||||
builder.button(text="Админ-панель", callback_data="admin:open")
|
|
||||||
if total_pages > 1:
|
if total_pages > 1:
|
||||||
builder.adjust(3, 2, 1)
|
builder.adjust(3, 1)
|
||||||
else:
|
else:
|
||||||
builder.adjust(2, 1)
|
builder.adjust(1)
|
||||||
else:
|
else:
|
||||||
if total_pages > 1:
|
if total_pages > 1:
|
||||||
builder.adjust(3, 2)
|
builder.adjust(3, 1)
|
||||||
else:
|
else:
|
||||||
builder.adjust(2)
|
builder.adjust(1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def admin_home_keyboard() -> InlineKeyboardMarkup:
|
def admin_home_keyboard() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
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:sync")
|
||||||
builder.button(text="Получатели", callback_data="admin:menu:recipients")
|
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="Администраторы", 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="Mute rules", callback_data="admin:mute_list")
|
||||||
builder.button(text="Инструкция", callback_data="admin:guide")
|
builder.button(text="Инструкция", callback_data="admin:guide")
|
||||||
builder.button(text="Пользовательское меню", callback_data="help:open")
|
builder.adjust(2, 2, 2, 1)
|
||||||
builder.adjust(2, 2, 2, 1, 1)
|
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -91,17 +88,32 @@ def admin_sync_keyboard() -> InlineKeyboardMarkup:
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def admin_overview_keyboard() -> InlineKeyboardMarkup:
|
def admin_routing_keyboard() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="Сегодня", callback_data="admin:today")
|
builder.button(text="Текущая схема", callback_data="admin:ownership")
|
||||||
builder.button(text="Неделя", callback_data="admin:week")
|
builder.button(text="Topic backend", callback_data="admin:settings:topic:backend")
|
||||||
builder.button(text="Самые шумные", callback_data="admin:top")
|
builder.button(text="Topic frontend", callback_data="admin:settings:topic:frontend")
|
||||||
builder.button(text="Давно висят", callback_data="admin:stale")
|
builder.button(text="Topic digest", callback_data="admin:settings:topic:digest")
|
||||||
builder.button(text="Назад", callback_data="admin:open")
|
builder.button(text="Назад", callback_data="admin:open")
|
||||||
builder.adjust(2, 2, 1)
|
builder.adjust(2, 2, 1)
|
||||||
return builder.as_markup()
|
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:
|
def admin_recipients_keyboard() -> InlineKeyboardMarkup:
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
builder.button(text="Backend", callback_data="admin:recipients:backend")
|
builder.button(text="Backend", callback_data="admin:recipients:backend")
|
||||||
@@ -136,10 +148,8 @@ def admin_result_keyboard(
|
|||||||
total_pages=total_pages,
|
total_pages=total_pages,
|
||||||
)
|
)
|
||||||
builder.button(text="Назад", callback_data=back_callback)
|
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:
|
if total_pages > 1:
|
||||||
builder.adjust(3, 2, 1)
|
builder.adjust(3, 1)
|
||||||
else:
|
else:
|
||||||
builder.adjust(2, 1)
|
builder.adjust(1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class Settings(BaseSettings):
|
|||||||
glitchtip_url: str
|
glitchtip_url: str
|
||||||
glitchtip_api_token: str
|
glitchtip_api_token: str
|
||||||
glitchtip_org_slug: str
|
glitchtip_org_slug: str
|
||||||
|
glitchtip_proxy_url: str = ""
|
||||||
|
telegram_proxy_url: str = ""
|
||||||
|
|
||||||
database_url: str
|
database_url: str
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class GlitchTipClient:
|
|||||||
base_url=self.base_url,
|
base_url=self.base_url,
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
proxy=settings.glitchtip_proxy_url or None,
|
||||||
)
|
)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async def shutdown_resources() -> None:
|
|||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
logger.info("GlitchUp Bot starting")
|
logger.info("GlitchUp Bot starting")
|
||||||
await warm_issue_cache_on_startup()
|
await warm_issue_cache_on_startup()
|
||||||
setup_scheduler()
|
await setup_scheduler()
|
||||||
|
|
||||||
api_task = asyncio.create_task(start_api(), name="api")
|
api_task = asyncio.create_task(start_api(), name="api")
|
||||||
bot_task = asyncio.create_task(start_bot(), name="bot")
|
bot_task = asyncio.create_task(start_bot(), name="bot")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from glitchup_bot.models.ownership import (
|
|||||||
GroupTopicOverride,
|
GroupTopicOverride,
|
||||||
ProjectOwnershipOverride,
|
ProjectOwnershipOverride,
|
||||||
)
|
)
|
||||||
|
from glitchup_bot.models.runtime_settings import RuntimeSetting
|
||||||
from glitchup_bot.models.sync import SyncState
|
from glitchup_bot.models.sync import SyncState
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -19,5 +20,6 @@ __all__ = [
|
|||||||
"MuteRule",
|
"MuteRule",
|
||||||
"NotificationSent",
|
"NotificationSent",
|
||||||
"ProjectOwnershipOverride",
|
"ProjectOwnershipOverride",
|
||||||
|
"RuntimeSetting",
|
||||||
"SyncState",
|
"SyncState",
|
||||||
]
|
]
|
||||||
|
|||||||
20
src/glitchup_bot/models/runtime_settings.py
Normal file
20
src/glitchup_bot/models/runtime_settings.py
Normal file
@@ -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(),
|
||||||
|
)
|
||||||
77
src/glitchup_bot/services/runtime_settings.py
Normal file
77
src/glitchup_bot/services/runtime_settings.py
Normal file
@@ -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
|
||||||
@@ -4,8 +4,8 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
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.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.sync_service import sync_issues
|
||||||
from glitchup_bot.services.telegram_sender import send_digest_message
|
from glitchup_bot.services.telegram_sender import send_digest_message
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ scheduler: AsyncIOScheduler | None = None
|
|||||||
async def weekly_digest_job() -> None:
|
async def weekly_digest_job() -> None:
|
||||||
logger.info("Running weekly digest job")
|
logger.info("Running weekly digest job")
|
||||||
try:
|
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")
|
logger.info("Weekly digest sent successfully")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to send weekly digest")
|
logger.exception("Failed to send weekly digest")
|
||||||
@@ -37,42 +37,53 @@ async def sync_job() -> None:
|
|||||||
logger.exception("Scheduled issue sync failed")
|
logger.exception("Scheduled issue sync failed")
|
||||||
|
|
||||||
|
|
||||||
def setup_scheduler() -> AsyncIOScheduler:
|
async def setup_scheduler() -> AsyncIOScheduler:
|
||||||
global scheduler
|
global scheduler
|
||||||
|
|
||||||
if scheduler is not None and scheduler.running:
|
if scheduler is not None and scheduler.running:
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|
||||||
|
runtime = await get_runtime_settings()
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
|
if runtime.sync_enabled:
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
sync_job,
|
sync_job,
|
||||||
IntervalTrigger(minutes=settings.sync_interval_minutes),
|
IntervalTrigger(minutes=runtime.sync_interval_minutes),
|
||||||
id="issue_sync",
|
id="issue_sync",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
weekly_digest_job,
|
weekly_digest_job,
|
||||||
CronTrigger(
|
CronTrigger(
|
||||||
day_of_week=settings.digest_cron_day,
|
day_of_week=runtime.digest_cron_day,
|
||||||
hour=settings.digest_cron_hour,
|
hour=runtime.digest_cron_hour,
|
||||||
minute=settings.digest_cron_minute,
|
minute=runtime.digest_cron_minute,
|
||||||
timezone=settings.digest_timezone,
|
timezone=runtime.digest_timezone,
|
||||||
),
|
),
|
||||||
id="weekly_digest",
|
id="weekly_digest",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scheduler started: sync every %s min, digest at %s %02d:%02d %s",
|
"Scheduler started: sync %s, digest at %s %02d:%02d %s",
|
||||||
settings.sync_interval_minutes,
|
(
|
||||||
settings.digest_cron_day,
|
f"every {runtime.sync_interval_minutes} min"
|
||||||
settings.digest_cron_hour,
|
if runtime.sync_enabled
|
||||||
settings.digest_cron_minute,
|
else "disabled"
|
||||||
settings.digest_timezone,
|
),
|
||||||
|
runtime.digest_cron_day,
|
||||||
|
runtime.digest_cron_hour,
|
||||||
|
runtime.digest_cron_minute,
|
||||||
|
runtime.digest_timezone,
|
||||||
)
|
)
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|
||||||
|
|
||||||
|
async def reload_scheduler() -> AsyncIOScheduler:
|
||||||
|
await shutdown_scheduler()
|
||||||
|
return await setup_scheduler()
|
||||||
|
|
||||||
|
|
||||||
async def shutdown_scheduler() -> None:
|
async def shutdown_scheduler() -> None:
|
||||||
global scheduler
|
global scheduler
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user