initial commit
Some checks failed
CI / Run tests (push) Has been cancelled
CI / Docker build test (push) Has been cancelled
CI / Lint (ruff + mypy) (push) Has been cancelled

This commit is contained in:
2026-03-30 16:46:26 +07:00
commit 2a7dfa95c8
67 changed files with 5864 additions and 0 deletions

View File

@@ -0,0 +1 @@
__version__ = "0.1.0"

View File

@@ -0,0 +1,3 @@
from glitchup_bot.main import run
run()

View File

View File

@@ -0,0 +1,17 @@
from fastapi import FastAPI
from glitchup_bot.api.webhook import router as webhook_router
def create_app() -> FastAPI:
application = FastAPI(title="GlitchUp Bot", version="0.1.0")
application.include_router(webhook_router)
@application.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
return application
app = create_app()

View File

@@ -0,0 +1,25 @@
from pydantic import BaseModel, Field
class WebhookField(BaseModel):
title: str
value: str
short: bool = False
class WebhookAttachment(BaseModel):
title: str
title_link: str | None = None
text: str | None = None
image_url: str | None = None
color: str | None = None
fields: list[WebhookField] | None = None
mrkdown_in: list[str] | None = None
class GlitchTipWebhookPayload(BaseModel):
text: str
attachments: list[WebhookAttachment] = Field(default_factory=list)
def is_uptime_alert(self) -> bool:
return "uptime" in self.text.lower()

View File

@@ -0,0 +1,25 @@
import logging
from fastapi import APIRouter, Header, HTTPException, Request
from glitchup_bot.config import settings
from glitchup_bot.services.alert_processor import process_webhook_payload
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
logger = logging.getLogger(__name__)
@router.post("/glitchtip")
async def glitchtip_webhook(
request: Request,
x_webhook_secret: str | None = Header(None),
):
if settings.webhook_secret and x_webhook_secret != settings.webhook_secret:
raise HTTPException(status_code=403, detail="Invalid webhook secret")
payload = await request.json()
logger.info("Received GlitchTip webhook: %s", payload.get("text", "unknown"))
await process_webhook_payload(payload)
return {"status": "accepted"}

View File

View File

@@ -0,0 +1,34 @@
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from glitchup_bot.bot.handlers.commands import router as commands_router
from glitchup_bot.config import settings
dp = Dispatcher()
dp.include_router(commands_router)
bot: Bot | None = None
def get_dispatcher() -> Dispatcher:
return dp
def get_bot() -> Bot:
global bot
if bot is None:
bot = Bot(
token=settings.telegram_bot_token,
default=DefaultBotProperties(parse_mode="HTML"),
)
return bot
async def close_bot() -> None:
global bot
if bot is not None:
await bot.session.close()
bot = None

View File

@@ -0,0 +1,618 @@
import logging
from collections.abc import Awaitable, Callable
from html import escape
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.types import CallbackQuery, Message
from glitchup_bot.bot.keyboards import admin_menu_keyboard, help_menu_keyboard
from glitchup_bot.config import settings
from glitchup_bot.services.digest_builder import (
build_digest,
build_project_summary,
build_release_detail,
build_release_summary,
build_stale_issues,
build_sync_status,
build_today_summary,
build_top_issues,
run_manual_sync,
)
from glitchup_bot.services.mute_rules import add_rule, list_rules, remove_rule
from glitchup_bot.services.routing import (
add_subscriber,
clear_project_group,
clear_topic_override,
list_project_overrides,
list_subscriber_overrides,
list_topic_overrides,
remove_subscriber,
resolve_subscribers,
resolve_topic_id,
set_project_group,
set_topic_override,
)
router = Router()
logger = logging.getLogger(__name__)
def _sender_id(message: Message) -> int | None:
return message.from_user.id if message.from_user else None
def _callback_sender_id(callback: CallbackQuery) -> int | None:
return callback.from_user.id if callback.from_user else None
def _is_admin_user(user_id: int | None) -> bool:
return settings.is_admin(user_id)
async def _require_admin(message: Message) -> bool:
if _is_admin_user(_sender_id(message)):
return True
await message.answer("Команда доступна только администраторам.")
return False
async def _require_admin_callback(callback: CallbackQuery) -> bool:
if _is_admin_user(_callback_sender_id(callback)):
return True
await callback.answer("Только для администраторов", show_alert=True)
return False
def _help_text(is_admin: bool) -> str:
lines = [
"<b>GlitchUp Bot Help</b>",
"",
"Быстрые действия доступны кнопками ниже.",
"",
"<b>Пользовательские команды:</b>",
"• /week — digest за неделю",
"• /today — новые issues за сегодня",
"• /project &lt;slug&gt; — сводка по проекту",
"• /top — самые шумные issues",
"• /stale — старые незакрытые issues",
"• /releases — список релизов с issues",
"• /release &lt;version&gt; — детали по релизу",
"• /sync_status — статус последней синхронизации",
"• /subscribe &lt;backend|frontend&gt; — подписка на DM",
"• /unsubscribe &lt;backend|frontend&gt; — отписка от DM",
]
if is_admin:
lines.extend(
[
"",
"<b>Админ-команды:</b>",
"• /admin — открыть панель управления",
"• /sync — принудительный sync",
"• /ownership — показать overrides",
"• /owner &lt;slug&gt; &lt;backend|frontend&gt;",
"• /owner_reset &lt;slug&gt;",
"• /topic &lt;backend|frontend|digest&gt; &lt;topic_id&gt;",
"• /topic_reset &lt;backend|frontend|digest&gt;",
"• /mute_add &lt;regex&gt;",
"• /mute_list",
"• /mute_del &lt;id&gt;",
]
)
lines.extend(
[
"",
"<b>Как пользоваться:</b>",
"1. Открой /help и выбери нужный раздел кнопками.",
"2. Для ежедневной работы достаточно кнопок digest/today/top/stale/releases.",
"3. Для администрирования используй /admin.",
]
)
return "\n".join(lines)
def _admin_text() -> str:
return "\n".join(
[
"<b>Админ-панель GlitchUp Bot</b>",
"",
"Основные действия доступны кнопками ниже.",
"",
"<b>Быстрые действия:</b>",
"• Запустить sync",
"• Посмотреть sync status",
"• Посмотреть ownership и mute rules",
"• Открыть основные сводки",
"",
"<b>Команды настройки:</b>",
"• /owner &lt;slug&gt; &lt;backend|frontend&gt;",
"• /owner_reset &lt;slug&gt;",
"• /topic &lt;backend|frontend|digest&gt; &lt;topic_id&gt;",
"• /topic_reset &lt;backend|frontend|digest&gt;",
"• /mute_add &lt;regex&gt;",
"• /mute_del &lt;id&gt;",
]
)
async def _answer_text(
target: Message | CallbackQuery,
text: str,
*,
reply_markup=None,
disable_web_page_preview: bool = True,
) -> None:
if isinstance(target, CallbackQuery):
await target.message.answer(
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
else:
await target.answer(
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
async def _handle_subscription_action(
target: Message | CallbackQuery,
group_name: str,
action: str,
user_id: int | None,
) -> None:
if user_id is None:
await _answer_text(target, "Не удалось определить пользователя.")
return
if action == "subscribe":
await add_subscriber(group_name, user_id)
await _answer_text(target, f"Подписка на <b>{escape(group_name)}</b> включена.")
return
removed = await remove_subscriber(group_name, user_id)
if not removed:
await _answer_text(target, "Runtime-подписка не найдена.")
return
await _answer_text(target, f"Подписка на <b>{escape(group_name)}</b> отключена.")
async def _run_summary_action(
target: Message | CallbackQuery,
loader: Callable[[], Awaitable[str]],
) -> None:
await _answer_text(target, await loader())
@router.message(Command("start"))
async def cmd_start(message: Message) -> None:
is_admin = _is_admin_user(_sender_id(message))
text = (
"<b>GlitchUp Bot</b>\n\nБот запущен и готов к работе.\nДля удобной навигации открой /help."
)
await message.answer(
text,
reply_markup=help_menu_keyboard(is_admin),
disable_web_page_preview=True,
)
@router.message(Command("help"))
async def cmd_help(message: Message) -> None:
is_admin = _is_admin_user(_sender_id(message))
await message.answer(
_help_text(is_admin),
reply_markup=help_menu_keyboard(is_admin),
disable_web_page_preview=True,
)
@router.message(Command("admin"))
async def cmd_admin(message: Message) -> None:
if not await _require_admin(message):
return
await message.answer(
_admin_text(),
reply_markup=admin_menu_keyboard(),
disable_web_page_preview=True,
)
@router.callback_query(F.data.startswith("help:"))
async def cb_help_actions(callback: CallbackQuery) -> None:
data = callback.data or ""
action = data.removeprefix("help:")
await callback.answer()
if action == "week":
await _run_summary_action(callback, lambda: build_digest(refresh=True))
return
if action == "today":
await _run_summary_action(callback, lambda: build_today_summary(refresh=True))
return
if action == "top":
await _run_summary_action(callback, lambda: build_top_issues(refresh=True))
return
if action == "stale":
await _run_summary_action(callback, lambda: build_stale_issues(refresh=True))
return
if action == "releases":
await _run_summary_action(callback, lambda: build_release_summary(refresh=True))
return
if action == "sync_status":
await _run_summary_action(callback, build_sync_status)
return
if action.startswith("sub:"):
await _handle_subscription_action(
callback,
action.split(":", 1)[1],
"subscribe",
_callback_sender_id(callback),
)
return
if action.startswith("unsub:"):
await _handle_subscription_action(
callback,
action.split(":", 1)[1],
"unsubscribe",
_callback_sender_id(callback),
)
return
@router.callback_query(F.data == "admin:open")
async def cb_admin_open(callback: CallbackQuery) -> None:
if not await _require_admin_callback(callback):
return
await callback.answer()
await callback.message.answer(
_admin_text(),
reply_markup=admin_menu_keyboard(),
disable_web_page_preview=True,
)
@router.callback_query(F.data.startswith("admin:"))
async def cb_admin_actions(callback: CallbackQuery) -> None:
if not await _require_admin_callback(callback):
return
action = (callback.data or "").removeprefix("admin:")
await callback.answer()
if action == "sync":
summary = await run_manual_sync()
await callback.message.answer(
"<b>Sync завершён</b>\n\n"
f"• проектов: {summary.project_count}\n"
f"• issues: {summary.issue_count}\n"
f"• помечено resolved: {summary.resolved_count}\n"
f"• время: {escape(summary.synced_at.isoformat())}",
disable_web_page_preview=True,
)
return
if action == "sync_status":
await _run_summary_action(callback, build_sync_status)
return
if action == "ownership":
await cmd_ownership(callback.message)
return
if action == "mute_list":
await cmd_mute_list(callback.message)
return
if action == "releases":
await _run_summary_action(callback, lambda: build_release_summary(refresh=True))
return
if action == "today":
await _run_summary_action(callback, lambda: build_today_summary(refresh=True))
return
if action == "week":
await _run_summary_action(callback, lambda: build_digest(refresh=True))
return
if action == "top":
await _run_summary_action(callback, lambda: build_top_issues(refresh=True))
return
if action == "stale":
await _run_summary_action(callback, lambda: build_stale_issues(refresh=True))
return
if action == "guide":
await callback.message.answer(
"\n".join(
[
"<b>Подсказка по админке</b>",
"",
"Через кнопки можно быстро смотреть состояние и запускать sync.",
"Изменение параметров делается командами:",
"• /owner slug backend",
"• /topic backend 123",
"• /mute_add payment.*timeout",
]
),
disable_web_page_preview=True,
)
return
@router.message(Command("week"))
async def cmd_week(message: Message) -> None:
await message.answer(await build_digest(refresh=True), disable_web_page_preview=True)
@router.message(Command("today"))
async def cmd_today(message: Message) -> None:
await message.answer(await build_today_summary(refresh=True), disable_web_page_preview=True)
@router.message(Command("project"))
async def cmd_project(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /project &lt;slug&gt;")
return
await message.answer(
await build_project_summary(args[1].strip(), refresh=True),
disable_web_page_preview=True,
)
@router.message(Command("top"))
async def cmd_top(message: Message) -> None:
await message.answer(await build_top_issues(refresh=True), disable_web_page_preview=True)
@router.message(Command("stale"))
async def cmd_stale(message: Message) -> None:
await message.answer(await build_stale_issues(refresh=True), disable_web_page_preview=True)
@router.message(Command("releases"))
async def cmd_releases(message: Message) -> None:
await message.answer(await build_release_summary(refresh=True), disable_web_page_preview=True)
@router.message(Command("release"))
async def cmd_release(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /release &lt;version&gt;")
return
await message.answer(
await build_release_detail(args[1].strip(), refresh=True),
disable_web_page_preview=True,
)
@router.message(Command("sync_status"))
async def cmd_sync_status(message: Message) -> None:
await message.answer(await build_sync_status())
@router.message(Command("sync"))
async def cmd_sync(message: Message) -> None:
if not await _require_admin(message):
return
summary = await run_manual_sync()
await message.answer(
"<b>Sync завершён</b>\n\n"
f"• проектов: {summary.project_count}\n"
f"• issues: {summary.issue_count}\n"
f"• помечено resolved: {summary.resolved_count}\n"
f"• время: {escape(summary.synced_at.isoformat())}"
)
@router.message(Command("subscribe"))
async def cmd_subscribe(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /subscribe &lt;backend|frontend&gt;")
return
await _handle_subscription_action(
message,
args[1].strip().lower(),
"subscribe",
_sender_id(message),
)
@router.message(Command("unsubscribe"))
async def cmd_unsubscribe(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /unsubscribe &lt;backend|frontend&gt;")
return
await _handle_subscription_action(
message,
args[1].strip().lower(),
"unsubscribe",
_sender_id(message),
)
@router.message(Command("ownership"))
async def cmd_ownership(message: Message) -> None:
if not await _require_admin(message):
return
project_overrides = await list_project_overrides()
topic_overrides = await list_topic_overrides()
subscriber_overrides = await list_subscriber_overrides()
backend_subscribers = await resolve_subscribers("backend")
frontend_subscribers = await resolve_subscribers("frontend")
lines = [
"<b>Ownership runtime state</b>",
"",
"<b>Topics:</b>",
f"• backend: {await resolve_topic_id('backend')}",
f"• frontend: {await resolve_topic_id('frontend')}",
f"• digest: {await resolve_topic_id('digest')}",
"",
"<b>Subscribers:</b>",
f"• backend: {', '.join(map(str, backend_subscribers)) or 'none'}",
f"• frontend: {', '.join(map(str, frontend_subscribers)) or 'none'}",
"",
"<b>Project overrides:</b>",
]
if project_overrides:
lines.extend(
f"{escape(record.project_slug)}{escape(record.group_name)}"
for record in project_overrides
)
else:
lines.append("• none")
lines.extend(["", "<b>Topic overrides:</b>"])
if topic_overrides:
lines.extend(
f"{escape(record.group_name)}{record.topic_id}" for record in topic_overrides
)
else:
lines.append("• none")
lines.extend(["", "<b>Subscriber overrides:</b>"])
if subscriber_overrides:
lines.extend(
f"{escape(record.group_name)}{record.user_id}" for record in subscriber_overrides
)
else:
lines.append("• none")
await message.answer("\n".join(lines), disable_web_page_preview=True)
@router.message(Command("owner"))
async def cmd_owner(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=2) if message.text else []
if len(args) < 3:
await message.answer("Использование: /owner &lt;slug&gt; &lt;backend|frontend&gt;")
return
project_slug = args[1].strip()
group_name = args[2].strip().lower()
await set_project_group(project_slug, group_name)
await message.answer(
f"Проект <b>{escape(project_slug)}</b> привязан к группе {escape(group_name)}."
)
@router.message(Command("owner_reset"))
async def cmd_owner_reset(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /owner_reset &lt;slug&gt;")
return
removed = await clear_project_group(args[1].strip())
if not removed:
await message.answer("Override для проекта не найден.")
return
await message.answer("Override для проекта удалён.")
@router.message(Command("topic"))
async def cmd_topic(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=2) if message.text else []
if len(args) < 3:
await message.answer(
"Использование: /topic &lt;backend|frontend|digest&gt; &lt;topic_id&gt;"
)
return
group_name = args[1].strip().lower()
topic_id = int(args[2].strip())
await set_topic_override(group_name, topic_id)
await message.answer(f"Topic override для <b>{escape(group_name)}</b> сохранён: {topic_id}.")
@router.message(Command("topic_reset"))
async def cmd_topic_reset(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /topic_reset &lt;backend|frontend|digest&gt;")
return
removed = await clear_topic_override(args[1].strip().lower())
if not removed:
await message.answer("Topic override не найден.")
return
await message.answer("Topic override удалён.")
@router.message(Command("mute_add"))
async def cmd_mute_add(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /mute_add &lt;regex&gt;")
return
rule = await add_rule(args[1].strip())
await message.answer(f"Добавлено mute rule #{rule.id}: <code>{escape(rule.pattern)}</code>")
@router.message(Command("mute_list"))
async def cmd_mute_list(message: Message) -> None:
if not await _require_admin(message):
return
rules = await list_rules()
if not rules:
await message.answer("Mute rules не настроены.")
return
lines = ["<b>Mute rules</b>", ""]
for rule in rules:
suffix = f"{escape(rule.description)}" if rule.description else ""
lines.append(f"• #{rule.id} <code>{escape(rule.pattern)}</code>{suffix}")
await message.answer("\n".join(lines), disable_web_page_preview=True)
@router.message(Command("mute_del"))
async def cmd_mute_del(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /mute_del &lt;id&gt;")
return
removed = await remove_rule(int(args[1].strip()))
if not removed:
await message.answer("Mute rule не найдено.")
return
await message.answer("Mute rule удалено.")

View File

@@ -0,0 +1,36 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
def help_menu_keyboard(is_admin: bool) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="Сводка за неделю", callback_data="help:week")
builder.button(text="Сегодня", callback_data="help:today")
builder.button(text="Топ issues", callback_data="help:top")
builder.button(text="Старые issues", callback_data="help:stale")
builder.button(text="Релизы", callback_data="help:releases")
builder.button(text="Статус sync", callback_data="help:sync_status")
builder.button(text="Подписка backend", callback_data="help:sub:backend")
builder.button(text="Подписка frontend", callback_data="help:sub:frontend")
builder.button(text="Отписка backend", callback_data="help:unsub:backend")
builder.button(text="Отписка frontend", callback_data="help:unsub:frontend")
if is_admin:
builder.button(text="Админ-панель", callback_data="admin:open")
builder.adjust(2)
return builder.as_markup()
def admin_menu_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="Запустить sync", callback_data="admin:sync")
builder.button(text="Статус sync", callback_data="admin:sync_status")
builder.button(text="Ownership", callback_data="admin:ownership")
builder.button(text="Mute rules", callback_data="admin:mute_list")
builder.button(text="Релизы", callback_data="admin:releases")
builder.button(text="Today", callback_data="admin:today")
builder.button(text="Week digest", callback_data="admin:week")
builder.button(text="Топ issues", callback_data="admin:top")
builder.button(text="Старые issues", callback_data="admin:stale")
builder.button(text="Инструкция", callback_data="admin:guide")
builder.adjust(2)
return builder.as_markup()

115
src/glitchup_bot/config.py Normal file
View File

@@ -0,0 +1,115 @@
from functools import lru_cache
from typing import Any
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
telegram_bot_token: str
telegram_group_chat_id: int
telegram_backend_topic_id: int
telegram_frontend_topic_id: int
telegram_digest_topic_id: int
backend_projects: list[str] = Field(default_factory=list)
frontend_projects: list[str] = Field(default_factory=list)
backend_subscribers: list[int] = Field(default_factory=list)
frontend_subscribers: list[int] = Field(default_factory=list)
telegram_admin_ids: list[int] = Field(default_factory=list)
glitchtip_url: str
glitchtip_api_token: str
glitchtip_org_slug: str
database_url: str
api_port: int = 8080
webhook_secret: str = ""
digest_cron_day: str = "mon"
digest_cron_hour: int = 10
digest_cron_minute: int = 0
digest_timezone: str = "Asia/Krasnoyarsk"
sync_interval_minutes: int = 30
alert_environments: list[str] = Field(default_factory=lambda: ["production"])
dedup_window_hours: int = 6
alert_rate_limit_count: int = 10
alert_rate_limit_window_minutes: int = 15
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
enable_decoding=False,
)
@field_validator("backend_projects", "frontend_projects", "alert_environments", mode="before")
@classmethod
def split_comma_str(cls, value: str | list[str]) -> list[str]:
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
return value
@field_validator(
"backend_subscribers",
"frontend_subscribers",
"telegram_admin_ids",
mode="before",
)
@classmethod
def split_comma_int(cls, value: str | list[int]) -> list[int]:
if isinstance(value, str):
return [int(item.strip()) for item in value.split(",") if item.strip()]
return value
def get_environment(self, project_slug: str) -> str | None:
parts = project_slug.rsplit("-", 1)
return parts[-1] if len(parts) == 2 else None
def get_group(self, project_slug: str | None) -> str:
if project_slug in self.frontend_projects:
return "frontend"
return "backend"
def get_topic_id(self, group: str) -> int:
if group == "digest":
return self.telegram_digest_topic_id
if group == "frontend":
return self.telegram_frontend_topic_id
return self.telegram_backend_topic_id
def get_subscribers(self, group: str) -> list[int]:
if group == "frontend":
return self.frontend_subscribers
return self.backend_subscribers
def is_alert_environment(self, project_slug: str) -> bool:
environment = self.get_environment(project_slug)
return environment in self.alert_environments if environment else False
def is_admin(self, user_id: int | None) -> bool:
if user_id is None:
return False
if not self.telegram_admin_ids:
return True
return user_id in self.telegram_admin_ids
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
def clear_settings_cache() -> None:
get_settings.cache_clear()
class SettingsProxy:
def __getattr__(self, name: str) -> Any:
return getattr(get_settings(), name)
settings = SettingsProxy()

View File

@@ -0,0 +1,106 @@
import logging
import httpx
from glitchup_bot.config import settings
logger = logging.getLogger(__name__)
class GlitchTipClient:
def __init__(self) -> None:
self.base_url = settings.glitchtip_url.rstrip("/")
self.headers = {"Authorization": f"Bearer {settings.glitchtip_api_token}"}
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=self.headers,
timeout=30,
)
return self._client
async def _get(self, path: str, params: dict | None = None) -> list | dict:
client = await self._get_client()
response = await client.get(path, params=params)
response.raise_for_status()
return response.json()
async def _get_paginated(self, path: str, params: dict | None = None) -> list:
results: list = []
base_params = params or {}
base_params.setdefault("limit", 100)
cursor: str | None = None
client = await self._get_client()
while True:
request_params = dict(base_params)
if cursor:
request_params["cursor"] = cursor
response = await client.get(path, params=request_params)
response.raise_for_status()
data = response.json()
results.extend(data)
next_cursor = self._parse_next_cursor(response.headers.get("link", ""))
if not next_cursor or not data:
break
cursor = next_cursor
return results
@staticmethod
def _parse_next_cursor(link_header: str) -> str | None:
for part in link_header.split(","):
if 'rel="next"' not in part or 'results="true"' not in part or "cursor=" not in part:
continue
start = part.index("cursor=") + len("cursor=")
end = part.find(">", start)
return part[start:end] if end != -1 else part[start:]
return None
async def list_projects(self) -> list[dict]:
return await self._get_paginated(
f"/api/0/organizations/{settings.glitchtip_org_slug}/projects/"
)
async def list_issues(
self, project_slug: str, query: str = "is:unresolved", sort: str = "date"
) -> list[dict]:
return await self._get_paginated(
f"/api/0/projects/{settings.glitchtip_org_slug}/{project_slug}/issues/",
params={"query": query, "sort": sort},
)
async def get_issue(self, issue_id: int) -> dict:
return await self._get(f"/api/0/issues/{issue_id}/")
async def close(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
glitchtip_client: GlitchTipClient | None = None
def get_glitchtip_client() -> GlitchTipClient:
global glitchtip_client
if glitchtip_client is None:
glitchtip_client = GlitchTipClient()
return glitchtip_client
async def close_glitchtip_client() -> None:
global glitchtip_client
if glitchtip_client is not None:
await glitchtip_client.close()
glitchtip_client = None

64
src/glitchup_bot/main.py Normal file
View File

@@ -0,0 +1,64 @@
import asyncio
import logging
import uvicorn
from glitchup_bot.api.app import app
from glitchup_bot.bot.bot import close_bot, get_bot, get_dispatcher
from glitchup_bot.config import settings
from glitchup_bot.glitchtip_client.client import close_glitchtip_client
from glitchup_bot.models.database import dispose_engine
from glitchup_bot.tasks.scheduler import setup_scheduler, shutdown_scheduler
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
async def start_api() -> None:
config = uvicorn.Config(app, host="0.0.0.0", port=settings.api_port, log_level="info")
server = uvicorn.Server(config)
await server.serve()
async def start_bot() -> None:
logger.info("Starting Telegram bot polling")
await get_dispatcher().start_polling(get_bot())
async def shutdown_resources() -> None:
await shutdown_scheduler()
await close_glitchtip_client()
await close_bot()
await dispose_engine()
async def main() -> None:
logger.info("GlitchUp Bot starting")
setup_scheduler()
api_task = asyncio.create_task(start_api(), name="api")
bot_task = asyncio.create_task(start_bot(), name="bot")
try:
done, pending = await asyncio.wait(
{api_task, bot_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
for task in done:
task.result()
finally:
await shutdown_resources()
def run() -> None:
asyncio.run(main())
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,21 @@
from glitchup_bot.models.base import Base
from glitchup_bot.models.issues import IssueCache
from glitchup_bot.models.mute_rules import MuteRule
from glitchup_bot.models.notifications import NotificationSent
from glitchup_bot.models.ownership import (
GroupSubscriberOverride,
GroupTopicOverride,
ProjectOwnershipOverride,
)
from glitchup_bot.models.sync import SyncState
__all__ = [
"Base",
"GroupSubscriberOverride",
"GroupTopicOverride",
"IssueCache",
"MuteRule",
"NotificationSent",
"ProjectOwnershipOverride",
"SyncState",
]

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@@ -0,0 +1,48 @@
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from glitchup_bot.config import settings
engine: AsyncEngine | None = None
session_factory: async_sessionmaker[AsyncSession] | None = None
def get_engine() -> AsyncEngine:
global engine
if engine is None:
engine = create_async_engine(settings.database_url, echo=False)
return engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
global session_factory
if session_factory is None:
session_factory = async_sessionmaker(
get_engine(), class_=AsyncSession, expire_on_commit=False
)
return session_factory
async def get_session() -> AsyncIterator[AsyncSession]:
async with get_session_factory()() as session:
yield session
async def dispose_engine() -> None:
global engine, session_factory
if engine is not None:
await engine.dispose()
engine = None
session_factory = None

View File

@@ -0,0 +1,29 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from glitchup_bot.models.base import Base
class IssueCache(Base):
__tablename__ = "issues_cache"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
glitchtip_issue_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
project_slug: Mapped[str] = mapped_column(String(255), index=True)
title: Mapped[str] = mapped_column(Text)
culprit: Mapped[str | None] = mapped_column(Text, nullable=True)
level: Mapped[str] = mapped_column(String(50), default="error")
status: Mapped[str] = mapped_column(String(50), default="unresolved")
first_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
event_count: Mapped[int] = mapped_column(Integer, default=0)
is_regression: Mapped[bool] = mapped_column(default=False)
link: Mapped[str | None] = mapped_column(Text, nullable=True)
release: Mapped[str | None] = mapped_column(String(255), nullable=True)
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()
)

View File

@@ -0,0 +1,16 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from glitchup_bot.models.base import Base
class MuteRule(Base):
__tablename__ = "mute_rules"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
pattern: Mapped[str] = mapped_column(Text, unique=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from glitchup_bot.models.base import Base
class NotificationSent(Base):
__tablename__ = "notifications_sent"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
issue_id: Mapped[int] = mapped_column(BigInteger, index=True)
notification_type: Mapped[str] = mapped_column(String(50)) # "alert", "digest", "uptime"
fingerprint: Mapped[str] = mapped_column(String(255), index=True)
project_slug: Mapped[str | None] = mapped_column(String(255), nullable=True)
group_name: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
priority: Mapped[str | None] = mapped_column(String(20), nullable=True)
delivery_status: Mapped[str] = mapped_column(String(50), default="sent")
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,40 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from glitchup_bot.models.base import Base
class ProjectOwnershipOverride(Base):
__tablename__ = "project_ownership_overrides"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
project_slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
group_name: Mapped[str] = mapped_column(String(50))
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()
)
class GroupTopicOverride(Base):
__tablename__ = "group_topic_overrides"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
group_name: Mapped[str] = mapped_column(String(50), unique=True, index=True)
topic_id: Mapped[int] = mapped_column(Integer)
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()
)
class GroupSubscriberOverride(Base):
__tablename__ = "group_subscriber_overrides"
__table_args__ = (UniqueConstraint("group_name", "user_id", name="uq_group_subscriber"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
group_name: Mapped[str] = mapped_column(String(50), index=True)
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,19 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from glitchup_bot.models.base import Base
class SyncState(Base):
__tablename__ = "sync_state"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
source: Mapped[str] = mapped_column(String(100), unique=True) # "api_sync", "webhook"
last_successful_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

View File

@@ -0,0 +1,197 @@
import logging
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from glitchup_bot.api.schemas import GlitchTipWebhookPayload, WebhookAttachment
from glitchup_bot.config import settings
from glitchup_bot.models.database import get_session_factory
from glitchup_bot.models.notifications import NotificationSent
from glitchup_bot.services.mute_rules import find_matching_rule
from glitchup_bot.services.routing import resolve_group
from glitchup_bot.services.sync_service import mark_sync_success
from glitchup_bot.services.telegram_sender import send_alert
logger = logging.getLogger(__name__)
def _extract_field(attachment: WebhookAttachment, field_name: str) -> str | None:
if not attachment.fields:
return None
for field in attachment.fields:
if field.title.lower() == field_name.lower():
return field.value
return None
def _extract_project_slug(attachment: WebhookAttachment) -> str | None:
return _extract_field(attachment, "project")
def _extract_release_name(attachment: WebhookAttachment) -> str | None:
return _extract_field(attachment, "release")
def _build_fingerprint(attachment: WebhookAttachment) -> str:
return f"{_extract_project_slug(attachment)}:{attachment.title}"
async def _is_duplicate(fingerprint: str) -> bool:
cutoff = datetime.now(UTC) - timedelta(hours=settings.dedup_window_hours)
async with get_session_factory()() as session:
result = await session.execute(
select(NotificationSent.id).where(
NotificationSent.fingerprint == fingerprint,
NotificationSent.notification_type == "alert",
NotificationSent.delivery_status == "sent",
NotificationSent.sent_at >= cutoff,
)
)
return result.scalar_one_or_none() is not None
async def _is_rate_limited(group_name: str, priority: str) -> bool:
if priority == "P1":
return False
cutoff = datetime.now(UTC) - timedelta(minutes=settings.alert_rate_limit_window_minutes)
async with get_session_factory()() as session:
result = await session.execute(
select(func.count(NotificationSent.id)).where(
NotificationSent.notification_type == "alert",
NotificationSent.delivery_status == "sent",
NotificationSent.group_name == group_name,
NotificationSent.priority == priority,
NotificationSent.sent_at >= cutoff,
)
)
count = result.scalar_one()
return count >= settings.alert_rate_limit_count
async def _record_notification(
issue_id: int,
fingerprint: str,
*,
project_slug: str | None,
group_name: str | None,
priority: str | None,
delivery_status: str,
notification_type: str = "alert",
) -> None:
async with get_session_factory()() as session:
session.add(
NotificationSent(
issue_id=issue_id,
notification_type=notification_type,
fingerprint=fingerprint,
project_slug=project_slug,
group_name=group_name,
priority=priority,
delivery_status=delivery_status,
)
)
await session.commit()
def _determine_priority(attachment: WebhookAttachment, project_slug: str | None) -> str:
environment = settings.get_environment(project_slug) if project_slug else None
if attachment.color == "#e52b50" and environment == "production":
return "P1"
if environment == "production":
return "P2"
return "P3"
async def process_webhook_payload(raw_payload: dict) -> None:
payload = GlitchTipWebhookPayload(**raw_payload)
await mark_sync_success("webhook")
if payload.is_uptime_alert():
for attachment in payload.attachments:
group_name = await send_alert(
attachment,
project_slug=None,
priority="P1",
is_uptime=True,
)
await _record_notification(
issue_id=0,
fingerprint=f"uptime:{attachment.title}",
project_slug=None,
group_name=group_name,
priority="P1",
delivery_status="sent",
notification_type="uptime",
)
return
for attachment in payload.attachments:
project_slug = _extract_project_slug(attachment)
if not project_slug:
logger.warning(
"Skipping webhook attachment without project field: %s", attachment.title
)
continue
if not settings.is_alert_environment(project_slug):
logger.debug("Skipping non-alert environment: %s", project_slug)
continue
fingerprint = _build_fingerprint(attachment)
if await _is_duplicate(fingerprint):
logger.debug("Skipping duplicate alert for %s", fingerprint)
continue
priority = _determine_priority(attachment, project_slug)
if priority == "P3":
logger.debug("Skipping P3 alert for %s", attachment.title)
continue
group_name = await resolve_group(project_slug)
muted_by = await find_matching_rule(attachment, project_slug)
if muted_by is not None:
logger.info("Muted alert %s by rule %s", fingerprint, muted_by.pattern)
await _record_notification(
issue_id=0,
fingerprint=fingerprint,
project_slug=project_slug,
group_name=group_name,
priority=priority,
delivery_status="muted",
)
continue
if await _is_rate_limited(group_name, priority):
logger.info("Rate-limited alert %s for group %s", fingerprint, group_name)
await _record_notification(
issue_id=0,
fingerprint=fingerprint,
project_slug=project_slug,
group_name=group_name,
priority=priority,
delivery_status="rate_limited",
)
continue
group_name = await send_alert(
attachment,
project_slug,
priority,
release_name=_extract_release_name(attachment),
)
await _record_notification(
issue_id=0,
fingerprint=fingerprint,
project_slug=project_slug,
group_name=group_name,
priority=priority,
delivery_status="sent",
)

View File

@@ -0,0 +1,263 @@
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from html import escape
from glitchup_bot.services.sync_service import (
IssueSnapshot,
SyncSummary,
get_last_sync_state,
load_issue_snapshots,
sync_issues,
)
def _issue_label(issue: IssueSnapshot) -> str:
title = escape(issue.title)
slug = escape(issue.project_slug)
if issue.link:
return f'<a href="{escape(issue.link, quote=True)}">{title}</a> ({slug})'
return f"{title} ({slug})"
async def _load_issues(
project_slugs: list[str] | None = None,
*,
refresh: bool = True,
unresolved_only: bool = True,
) -> list[IssueSnapshot]:
return await load_issue_snapshots(
project_slugs,
refresh=refresh,
unresolved_only=unresolved_only,
)
async def build_digest(refresh: bool = True) -> str:
now = datetime.now(UTC)
week_ago = now - timedelta(days=7)
issues = await _load_issues(refresh=refresh)
new_issues: list[IssueSnapshot] = []
regressions: list[IssueSnapshot] = []
stale: list[IssueSnapshot] = []
by_release: dict[str, list[IssueSnapshot]] = defaultdict(list)
project_stats: dict[str, dict[str, int]] = defaultdict(
lambda: {"new": 0, "regression": 0, "events": 0}
)
top_noisy = sorted(issues, key=lambda item: item.event_count, reverse=True)
for issue in issues:
if issue.first_seen and issue.first_seen >= week_ago:
new_issues.append(issue)
project_stats[issue.project_slug]["new"] += 1
if issue.is_regression:
regressions.append(issue)
project_stats[issue.project_slug]["regression"] += 1
if issue.first_seen and issue.first_seen < week_ago:
stale.append(issue)
if issue.release:
by_release[issue.release].append(issue)
project_stats[issue.project_slug]["events"] += issue.event_count
lines = [
"<b>📊 GlitchTip digest за неделю</b>",
"",
"<b>Всего:</b>",
f"• новых issues: {len(new_issues)}",
f"• regressions: {len(regressions)}",
f"• unresolved > 7 дней: {len(stale)}",
"",
]
if project_stats:
lines.append("<b>По проектам:</b>")
for slug, stats in sorted(
project_stats.items(),
key=lambda item: (item[1]["new"], item[1]["regression"], item[0]),
reverse=True,
)[:5]:
parts = [f"{stats['new']} новых"]
if stats["regression"]:
parts.append(f"{stats['regression']} regression")
lines.append(f"• <b>{escape(slug)}</b> — {', '.join(parts)}")
lines.append("")
if by_release:
lines.append("<b>После релизов:</b>")
for release_name, release_issues in sorted(
by_release.items(),
key=lambda item: (len(item[1]), sum(issue.event_count for issue in item[1])),
reverse=True,
)[:5]:
lines.append(
f"• <b>{escape(release_name)}</b> — "
f"{len(release_issues)} issues, "
f"{sum(issue.event_count for issue in release_issues)} событий"
)
lines.append("")
if top_noisy:
lines.append("<b>Топ шумных:</b>")
for issue in top_noisy[:5]:
lines.append(f"{_issue_label(issue)}{issue.event_count} событий")
lines.append("")
if stale:
lines.append("<b>Хвосты:</b>")
for issue in sorted(
stale,
key=lambda item: (now - (item.first_seen or now)).days,
reverse=True,
)[:5]:
age = (now - (issue.first_seen or now)).days
lines.append(f"{_issue_label(issue)}{age} дн. без разбора")
if len(lines) == 7:
lines.append("Все чисто! Новых проблем нет.")
return "\n".join(lines)
async def build_today_summary(refresh: bool = True) -> str:
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
issues = await _load_issues(refresh=refresh)
today_issues = [
issue for issue in issues if issue.first_seen and issue.first_seen >= today_start
]
if not today_issues:
return "За сегодня новых issues не обнаружено."
lines = [f"<b>📋 Сегодня: {len(today_issues)} новых issues</b>", ""]
for issue in sorted(
today_issues,
key=lambda item: item.first_seen or now,
reverse=True,
)[:10]:
lines.append(f"{_issue_label(issue)}{issue.event_count} событий")
return "\n".join(lines)
async def build_project_summary(project_slug: str, refresh: bool = True) -> str:
issues = await _load_issues([project_slug], refresh=refresh)
if not issues:
return f"<b>{escape(project_slug)}</b>: нет unresolved issues."
total_events = sum(issue.event_count for issue in issues)
regressions = [issue for issue in issues if issue.is_regression]
lines = [
f"<b>📦 {escape(project_slug)}</b>",
"",
f"• unresolved issues: {len(issues)}",
f"• всего событий: {total_events}",
f"• regressions: {len(regressions)}",
"",
"<b>Последние:</b>",
]
for issue in sorted(
issues,
key=lambda item: item.last_seen or datetime.min.replace(tzinfo=UTC),
reverse=True,
)[:5]:
lines.append(f"{_issue_label(issue)}{issue.event_count} событий")
return "\n".join(lines)
async def build_top_issues(limit: int = 10, refresh: bool = True) -> str:
issues = await _load_issues(refresh=refresh)
if not issues:
return "Нет unresolved issues."
lines = ["<b>🔊 Топ шумных issues</b>", ""]
for issue in sorted(issues, key=lambda item: item.event_count, reverse=True)[:limit]:
lines.append(f"• <b>{issue.event_count}</b> событий — {_issue_label(issue)}")
return "\n".join(lines)
async def build_stale_issues(
min_days: int = 7,
limit: int = 10,
refresh: bool = True,
) -> str:
now = datetime.now(UTC)
issues = await _load_issues(refresh=refresh)
stale = [
issue for issue in issues if issue.first_seen and (now - issue.first_seen).days >= min_days
]
if not stale:
return "Нет старых незакрытых issues (> 7 дней)."
lines = ["<b>🕸 Старые незакрытые issues</b>", ""]
for issue in sorted(
stale,
key=lambda item: now - (item.first_seen or now),
reverse=True,
)[:limit]:
age = (now - (issue.first_seen or now)).days
lines.append(f"• <b>{age} дн.</b> — {_issue_label(issue)} ({issue.event_count} событий)")
return "\n".join(lines)
async def build_release_summary(limit: int = 10, refresh: bool = True) -> str:
issues = await _load_issues(refresh=refresh)
grouped: dict[str, list[IssueSnapshot]] = defaultdict(list)
for issue in issues:
if issue.release:
grouped[issue.release].append(issue)
if not grouped:
return "Релизы в данных GlitchTip не обнаружены."
lines = ["<b>🚀 Релизы с незакрытыми issue</b>", ""]
for release_name, release_issues in sorted(
grouped.items(),
key=lambda item: (len(item[1]), sum(issue.event_count for issue in item[1])),
reverse=True,
)[:limit]:
lines.append(
f"• <b>{escape(release_name)}</b> — {len(release_issues)} issues, "
f"{sum(issue.event_count for issue in release_issues)} событий"
)
return "\n".join(lines)
async def build_release_detail(release_name: str, refresh: bool = True) -> str:
issues = await _load_issues(refresh=refresh)
matched = [issue for issue in issues if issue.release == release_name]
if not matched:
return f"Для релиза <b>{escape(release_name)}</b> незакрытых issues не найдено."
lines = [f"<b>🚀 Релиз {escape(release_name)}</b>", ""]
for issue in sorted(matched, key=lambda item: item.event_count, reverse=True)[:10]:
suffix = " regression" if issue.is_regression else ""
lines.append(f"{_issue_label(issue)}{issue.event_count} событий{suffix}")
return "\n".join(lines)
async def run_manual_sync() -> SyncSummary:
return await sync_issues()
async def build_sync_status() -> str:
state = await get_last_sync_state("api_sync")
if state is None or state.last_successful_at is None:
return "Синхронизация ещё не выполнялась."
return (
"<b>Последняя синхронизация</b>\n\n"
f"• источник: api_sync\n"
f"• время: {escape(state.last_successful_at.isoformat())}"
)

View File

@@ -0,0 +1,74 @@
import re
from sqlalchemy import select
from glitchup_bot.api.schemas import WebhookAttachment
from glitchup_bot.models.database import get_session_factory
from glitchup_bot.models.mute_rules import MuteRule
def _rule_target_text(attachment: WebhookAttachment, project_slug: str | None) -> str:
fields = [project_slug or "", attachment.title, attachment.text or ""]
return "\n".join(fields)
def validate_pattern(pattern: str) -> str:
normalized = pattern.strip()
if not normalized:
raise ValueError("Pattern must not be empty")
re.compile(normalized)
return normalized
async def find_matching_rule(
attachment: WebhookAttachment,
project_slug: str | None,
) -> MuteRule | None:
target = _rule_target_text(attachment, project_slug)
async with get_session_factory()() as session:
result = await session.execute(
select(MuteRule).where(MuteRule.is_active.is_(True)).order_by(MuteRule.id)
)
rules = list(result.scalars().all())
for rule in rules:
if re.search(rule.pattern, target, flags=re.IGNORECASE):
return rule
return None
async def add_rule(pattern: str, description: str | None = None) -> MuteRule:
normalized = validate_pattern(pattern)
async with get_session_factory()() as session:
result = await session.execute(select(MuteRule).where(MuteRule.pattern == normalized))
rule = result.scalar_one_or_none()
if rule is None:
rule = MuteRule(pattern=normalized, description=description)
session.add(rule)
else:
rule.description = description
rule.is_active = True
await session.commit()
await session.refresh(rule)
return rule
async def remove_rule(rule_id: int) -> bool:
async with get_session_factory()() as session:
result = await session.execute(select(MuteRule).where(MuteRule.id == rule_id))
rule = result.scalar_one_or_none()
if rule is None:
return False
await session.delete(rule)
await session.commit()
return True
async def list_rules() -> list[MuteRule]:
async with get_session_factory()() as session:
result = await session.execute(select(MuteRule).order_by(MuteRule.id))
return list(result.scalars().all())

View File

@@ -0,0 +1,195 @@
from sqlalchemy import select
from glitchup_bot.config import settings
from glitchup_bot.models.database import get_session_factory
from glitchup_bot.models.ownership import (
GroupSubscriberOverride,
GroupTopicOverride,
ProjectOwnershipOverride,
)
PROJECT_GROUPS = {"backend", "frontend"}
TOPIC_GROUPS = {"backend", "frontend", "digest"}
def validate_project_group(group_name: str) -> str:
normalized = group_name.lower()
if normalized not in PROJECT_GROUPS:
raise ValueError("Group must be backend or frontend")
return normalized
def validate_topic_group(group_name: str) -> str:
normalized = group_name.lower()
if normalized not in TOPIC_GROUPS:
raise ValueError("Group must be backend, frontend, or digest")
return normalized
async def resolve_group(project_slug: str | None) -> str:
if not project_slug:
return "backend"
async with get_session_factory()() as session:
result = await session.execute(
select(ProjectOwnershipOverride.group_name).where(
ProjectOwnershipOverride.project_slug == project_slug
)
)
group_name = result.scalar_one_or_none()
return group_name or settings.get_group(project_slug)
async def resolve_topic_id(group_name: str) -> int:
normalized = validate_topic_group(group_name)
async with get_session_factory()() as session:
result = await session.execute(
select(GroupTopicOverride.topic_id).where(GroupTopicOverride.group_name == normalized)
)
topic_id = result.scalar_one_or_none()
return topic_id if topic_id is not None else settings.get_topic_id(normalized)
async def resolve_subscribers(group_name: str) -> list[int]:
normalized = validate_project_group(group_name)
subscribers = set(settings.get_subscribers(normalized))
async with get_session_factory()() as session:
result = await session.execute(
select(GroupSubscriberOverride.user_id).where(
GroupSubscriberOverride.group_name == normalized
)
)
subscribers.update(result.scalars().all())
return sorted(subscribers)
async def set_project_group(project_slug: str, group_name: str) -> None:
normalized = validate_project_group(group_name)
async with get_session_factory()() as session:
result = await session.execute(
select(ProjectOwnershipOverride).where(
ProjectOwnershipOverride.project_slug == project_slug
)
)
record = result.scalar_one_or_none()
if record is None:
record = ProjectOwnershipOverride(project_slug=project_slug, group_name=normalized)
session.add(record)
else:
record.group_name = normalized
await session.commit()
async def clear_project_group(project_slug: str) -> bool:
async with get_session_factory()() as session:
result = await session.execute(
select(ProjectOwnershipOverride).where(
ProjectOwnershipOverride.project_slug == project_slug
)
)
record = result.scalar_one_or_none()
if record is None:
return False
await session.delete(record)
await session.commit()
return True
async def set_topic_override(group_name: str, topic_id: int) -> None:
normalized = validate_topic_group(group_name)
async with get_session_factory()() as session:
result = await session.execute(
select(GroupTopicOverride).where(GroupTopicOverride.group_name == normalized)
)
record = result.scalar_one_or_none()
if record is None:
record = GroupTopicOverride(group_name=normalized, topic_id=topic_id)
session.add(record)
else:
record.topic_id = topic_id
await session.commit()
async def clear_topic_override(group_name: str) -> bool:
normalized = validate_topic_group(group_name)
async with get_session_factory()() as session:
result = await session.execute(
select(GroupTopicOverride).where(GroupTopicOverride.group_name == normalized)
)
record = result.scalar_one_or_none()
if record is None:
return False
await session.delete(record)
await session.commit()
return True
async def add_subscriber(group_name: str, user_id: int) -> None:
normalized = validate_project_group(group_name)
async with get_session_factory()() as session:
result = await session.execute(
select(GroupSubscriberOverride).where(
GroupSubscriberOverride.group_name == normalized,
GroupSubscriberOverride.user_id == user_id,
)
)
record = result.scalar_one_or_none()
if record is None:
session.add(GroupSubscriberOverride(group_name=normalized, user_id=user_id))
await session.commit()
async def remove_subscriber(group_name: str, user_id: int) -> bool:
normalized = validate_project_group(group_name)
async with get_session_factory()() as session:
result = await session.execute(
select(GroupSubscriberOverride).where(
GroupSubscriberOverride.group_name == normalized,
GroupSubscriberOverride.user_id == user_id,
)
)
record = result.scalar_one_or_none()
if record is None:
return False
await session.delete(record)
await session.commit()
return True
async def list_project_overrides() -> list[ProjectOwnershipOverride]:
async with get_session_factory()() as session:
result = await session.execute(
select(ProjectOwnershipOverride).order_by(ProjectOwnershipOverride.project_slug)
)
return list(result.scalars().all())
async def list_topic_overrides() -> list[GroupTopicOverride]:
async with get_session_factory()() as session:
result = await session.execute(
select(GroupTopicOverride).order_by(GroupTopicOverride.group_name)
)
return list(result.scalars().all())
async def list_subscriber_overrides() -> list[GroupSubscriberOverride]:
async with get_session_factory()() as session:
result = await session.execute(
select(GroupSubscriberOverride).order_by(
GroupSubscriberOverride.group_name, GroupSubscriberOverride.user_id
)
)
return list(result.scalars().all())

View File

@@ -0,0 +1,233 @@
import logging
from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from glitchup_bot.config import settings
from glitchup_bot.glitchtip_client.client import get_glitchtip_client
from glitchup_bot.models.database import get_session_factory
from glitchup_bot.models.issues import IssueCache
from glitchup_bot.models.sync import SyncState
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class IssueSnapshot:
issue_id: int
project_slug: str
title: str
culprit: str | None
level: str
status: str
first_seen: datetime | None
last_seen: datetime | None
event_count: int
is_regression: bool
link: str | None
release: str | None
@dataclass(slots=True)
class SyncSummary:
project_count: int
issue_count: int
resolved_count: int
synced_at: datetime
def _configured_project_slugs() -> list[str]:
return settings.backend_projects + settings.frontend_projects
def _parse_dt(value: str | None) -> datetime | None:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _extract_release(issue: dict[str, Any]) -> str | None:
direct = issue.get("lastRelease") or issue.get("release") or issue.get("releaseName")
if isinstance(direct, str) and direct.strip():
return direct.strip()
if isinstance(direct, dict):
for key in ("version", "shortVersion", "name"):
value = direct.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
tags = issue.get("tags")
if isinstance(tags, list):
for tag in tags:
if not isinstance(tag, dict):
continue
if tag.get("key") != "release":
continue
value = tag.get("value")
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _normalize_issue(project_slug: str, issue: dict[str, Any]) -> IssueSnapshot:
issue_id = int(issue["id"])
return IssueSnapshot(
issue_id=issue_id,
project_slug=project_slug,
title=issue.get("title") or "unknown",
culprit=issue.get("culprit"),
level=(issue.get("level") or "error").lower(),
status=(issue.get("status") or "unresolved").lower(),
first_seen=_parse_dt(issue.get("firstSeen")),
last_seen=_parse_dt(issue.get("lastSeen")),
event_count=int(issue.get("count") or 0),
is_regression=bool(issue.get("isRegression")),
link=issue.get("permalink") or issue.get("link"),
release=_extract_release(issue),
)
async def mark_sync_success(source: str) -> None:
now = datetime.now(UTC)
async with get_session_factory()() as session:
result = await session.execute(select(SyncState).where(SyncState.source == source))
record = result.scalar_one_or_none()
if record is None:
record = SyncState(source=source, last_successful_at=now)
session.add(record)
else:
record.last_successful_at = now
await session.commit()
async def sync_issues(project_slugs: list[str] | None = None) -> SyncSummary:
slugs = project_slugs or _configured_project_slugs()
client = get_glitchtip_client()
snapshots: list[IssueSnapshot] = []
for slug in slugs:
issues = await client.list_issues(slug)
snapshots.extend(
_normalize_issue(slug, issue) for issue in issues if issue.get("id") is not None
)
issue_ids_by_slug: dict[str, set[int]] = defaultdict(set)
for snapshot in snapshots:
issue_ids_by_slug[snapshot.project_slug].add(snapshot.issue_id)
now = datetime.now(UTC)
resolved_count = 0
async with get_session_factory()() as session:
existing_rows = (
await session.execute(select(IssueCache).where(IssueCache.project_slug.in_(slugs)))
).scalars()
existing_by_id = {row.glitchtip_issue_id: row for row in existing_rows}
for snapshot in snapshots:
row = existing_by_id.get(snapshot.issue_id)
if row is None:
row = IssueCache(
glitchtip_issue_id=snapshot.issue_id,
project_slug=snapshot.project_slug,
title=snapshot.title,
culprit=snapshot.culprit,
level=snapshot.level,
status=snapshot.status,
first_seen=snapshot.first_seen,
last_seen=snapshot.last_seen,
event_count=snapshot.event_count,
is_regression=snapshot.is_regression,
link=snapshot.link,
release=snapshot.release,
)
session.add(row)
continue
row.project_slug = snapshot.project_slug
row.title = snapshot.title
row.culprit = snapshot.culprit
row.level = snapshot.level
row.status = snapshot.status
row.first_seen = snapshot.first_seen
row.last_seen = snapshot.last_seen
row.event_count = snapshot.event_count
row.is_regression = snapshot.is_regression
row.link = snapshot.link
row.release = snapshot.release
row.updated_at = now
for row in existing_by_id.values():
if row.glitchtip_issue_id in issue_ids_by_slug[row.project_slug]:
continue
if row.status != "resolved":
row.status = "resolved"
row.updated_at = now
resolved_count += 1
result = await session.execute(select(SyncState).where(SyncState.source == "api_sync"))
state = result.scalar_one_or_none()
if state is None:
state = SyncState(source="api_sync", last_successful_at=now)
session.add(state)
else:
state.last_successful_at = now
await session.commit()
return SyncSummary(
project_count=len(slugs),
issue_count=len(snapshots),
resolved_count=resolved_count,
synced_at=now,
)
async def load_issue_snapshots(
project_slugs: list[str] | None = None,
*,
refresh: bool = True,
unresolved_only: bool = True,
) -> list[IssueSnapshot]:
slugs = project_slugs or _configured_project_slugs()
if refresh:
try:
await sync_issues(slugs)
except Exception:
logger.exception("Issue sync failed, falling back to cached data")
async with get_session_factory()() as session:
stmt = select(IssueCache).where(IssueCache.project_slug.in_(slugs))
if unresolved_only:
stmt = stmt.where(IssueCache.status == "unresolved")
rows = (await session.execute(stmt)).scalars().all()
return [
IssueSnapshot(
issue_id=row.glitchtip_issue_id,
project_slug=row.project_slug,
title=row.title,
culprit=row.culprit,
level=row.level,
status=row.status,
first_seen=row.first_seen,
last_seen=row.last_seen,
event_count=row.event_count,
is_regression=row.is_regression,
link=row.link,
release=row.release,
)
for row in rows
]
async def get_last_sync_state(source: str) -> SyncState | None:
async with get_session_factory()() as session:
result = await session.execute(select(SyncState).where(SyncState.source == source))
return result.scalar_one_or_none()

View File

@@ -0,0 +1,113 @@
import html
import logging
from glitchup_bot.api.schemas import WebhookAttachment
from glitchup_bot.bot.bot import get_bot
from glitchup_bot.config import settings
from glitchup_bot.services.routing import resolve_group, resolve_subscribers, resolve_topic_id
logger = logging.getLogger(__name__)
def _escape(value: str | None) -> str:
return html.escape(value or "unknown")
def _format_link(label: str, url: str | None) -> str | None:
if not url:
return None
return f'<a href="{html.escape(url, quote=True)}">{html.escape(label)}</a>'
def _format_alert_message(
attachment: WebhookAttachment,
project_slug: str | None,
priority: str,
is_uptime: bool = False,
release_name: str | None = None,
) -> str:
if is_uptime:
lines = [
"⚠️ <b>GlitchTip Uptime Alert</b>",
"",
f"<b>Монитор:</b> {_escape(attachment.title)}",
f"<b>Статус:</b> {_escape(attachment.text)}",
]
open_link = _format_link("Открыть в GlitchTip", attachment.title_link)
if open_link:
lines.append(open_link)
return "\n".join(lines)
icon = "🔥" if priority == "P1" else "🟡"
lines = [
f"{icon} <b>GlitchTip alert / {html.escape(priority)}</b>",
"",
f"<b>Проект:</b> {_escape(project_slug)}",
f"<b>Проблема:</b> {_escape(attachment.title)}",
]
if attachment.text:
lines.append(f"<b>Где:</b> {_escape(attachment.text)}")
if release_name:
lines.append(f"<b>Релиз:</b> {_escape(release_name)}")
open_link = _format_link("Открыть в GlitchTip", attachment.title_link)
if open_link:
lines.append(open_link)
return "\n".join(lines)
async def send_alert(
attachment: WebhookAttachment,
project_slug: str | None,
priority: str,
is_uptime: bool = False,
release_name: str | None = None,
) -> str:
text = _format_alert_message(
attachment,
project_slug,
priority,
is_uptime=is_uptime,
release_name=release_name,
)
group_name = await resolve_group(project_slug)
topic_id = await resolve_topic_id(group_name)
bot = get_bot()
try:
await bot.send_message(
chat_id=settings.telegram_group_chat_id,
message_thread_id=topic_id,
text=text,
disable_web_page_preview=True,
)
except Exception:
logger.exception("Failed to send alert to topic %s", topic_id)
for user_id in await resolve_subscribers(group_name):
try:
await bot.send_message(
chat_id=user_id,
text=text,
disable_web_page_preview=True,
)
except Exception:
logger.exception("Failed to send DM to user %s", user_id)
return group_name
async def send_digest_message(text: str) -> None:
bot = get_bot()
topic_id = await resolve_topic_id("digest")
try:
await bot.send_message(
chat_id=settings.telegram_group_chat_id,
message_thread_id=topic_id,
text=text,
disable_web_page_preview=True,
)
except Exception:
logger.exception("Failed to send digest")

View File

View File

@@ -0,0 +1,81 @@
import logging
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.sync_service import sync_issues
from glitchup_bot.services.telegram_sender import send_digest_message
logger = logging.getLogger(__name__)
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))
logger.info("Weekly digest sent successfully")
except Exception:
logger.exception("Failed to send weekly digest")
async def sync_job() -> None:
logger.info("Running scheduled issue sync")
try:
summary = await sync_issues()
logger.info(
"Issue sync finished: %s projects, %s issues, %s resolved",
summary.project_count,
summary.issue_count,
summary.resolved_count,
)
except Exception:
logger.exception("Scheduled issue sync failed")
def setup_scheduler() -> AsyncIOScheduler:
global scheduler
if scheduler is not None and scheduler.running:
return scheduler
scheduler = AsyncIOScheduler()
scheduler.add_job(
sync_job,
IntervalTrigger(minutes=settings.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,
),
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,
)
return scheduler
async def shutdown_scheduler() -> None:
global scheduler
if scheduler is not None:
scheduler.shutdown(wait=False)
scheduler = None