Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
TOKEN=your_discord_bot_token_here
# уровень логов: DEBUG / INFO / WARNING / ERROR
LOG_LEVEL=INFO
43 changes: 39 additions & 4 deletions bot.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import logging
import math
import re
Expand All @@ -9,25 +10,27 @@
TOKEN,
GUILD_ID,
DATA_DIR,
LOG_LEVEL,
LOG_CHANNEL_ID,
DISCORD_LOG_LEVEL,
LOG_PING_USER_ID,
SOURCE_CHANNEL_1,
SOURCE_CHANNEL_2,
TARGET_VOICE_CHANNELS,
ALLOWED_USERS,
)
from services import embeds
from services.cooldown import CooldownManager
from services.discord_log import DiscordLogHandler
from services.health import write_heartbeat
from services.logger import resolve_level, setup_logging
from services.storage import read_json, write_json, write_json_sync
from services.webhook import WebhookService

GUILD = discord.Object(id=GUILD_ID)
TARGETS_FILE = DATA_DIR / "targets.json"
CHANNELS_FILE = DATA_DIR / "channels.json"

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger(__name__)

_FORWARD_MAP = {
Expand Down Expand Up @@ -58,6 +61,7 @@ def __init__(self):
self.bot_created_channels: set[int] = set()
self.webhook_service = WebhookService()
self.command_cooldown = CooldownManager()
self._discord_log_handler: DiscordLogHandler | None = None

self._load_targets()
self._load_channels()
Expand Down Expand Up @@ -106,7 +110,36 @@ async def save_channels(self) -> None:
}
await write_json(CHANNELS_FILE, data)

async def _setup_discord_logging(self) -> None:
# заводим (или переиспользуем) вебхук в лог-канале и вешаем хендлер на
# корневой логгер. вебхук бот достаёт сам, чтобы url не жил в окружении.
# сбой настройки не должен мешать запуску бота — логи всё равно в консоли
if not LOG_CHANNEL_ID:
return
try:
channel = self.get_channel(LOG_CHANNEL_ID) or await self.fetch_channel(
LOG_CHANNEL_ID
)
webhook = await self.webhook_service.get_or_create_webhook(
channel, "kool-bot logs"
)
except Exception:
log.exception("Discord log setup failed")
return

handler = DiscordLogHandler(
sender=lambda **kw: webhook.send(wait=False, **kw),
loop=asyncio.get_running_loop(),
ping_user_id=LOG_PING_USER_ID,
level=resolve_level(DISCORD_LOG_LEVEL, default=logging.WARNING),
)
handler.start()
logging.getLogger().addHandler(handler)
self._discord_log_handler = handler
log.info("Discord-логи включены в канале %d", LOG_CHANNEL_ID)

async def setup_hook(self):
await self._setup_discord_logging()
await self.load_extension("cogs.moderation")
await self.load_extension("cogs.utility")
await self.load_extension("cogs.anonymous")
Expand All @@ -116,6 +149,7 @@ async def setup_hook(self):
await self.load_extension("cogs.social")
await self.load_extension("cogs.reminders")
await self.load_extension("cogs.stats")
await self.load_extension("cogs.dev")

async def global_slash_cooldown(interaction: discord.Interaction) -> bool:
if interaction.user.id in ALLOWED_USERS:
Expand Down Expand Up @@ -197,6 +231,7 @@ async def on_disconnect(self):


def main() -> None:
setup_logging(LOG_LEVEL)
if not TOKEN:
raise SystemExit("TOKEN не задан: создай .env по образцу .env.example")
bot = CoolBot()
Expand Down
69 changes: 69 additions & 0 deletions cogs/dev.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import logging

import discord
from discord import app_commands
from discord.ext import commands

from config import ALLOWED_USERS
from services import embeds

log = logging.getLogger(__name__)

# уровни для /logtest. None значит «прогнать все по очереди»
_LEVELS = ("debug", "info", "warning", "error")


class DevCog(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot

@app_commands.command(
name="logtest", description="Тест логирования: шлёт записи разных уровней"
)
@app_commands.describe(level="Уровень (по умолчанию прогоняет все)")
@app_commands.choices(
level=[app_commands.Choice(name=lvl, value=lvl) for lvl in _LEVELS]
)
@app_commands.default_permissions(administrator=True)
@app_commands.guild_only()
async def logtest(
self,
interaction: discord.Interaction,
level: app_commands.Choice[str] | None = None,
):
if interaction.user.id not in ALLOWED_USERS:
await interaction.response.send_message(
embed=embeds.err("только для админов", user=interaction.user),
ephemeral=True,
)
return

levels = [level.value] if level is not None else list(_LEVELS)
who = interaction.user
for lvl in levels:
if lvl == "error":
# настоящий эксепшен, чтобы проверить и трейсбек, и пинг на ERROR
try:
raise RuntimeError("тестовая ошибка из /logtest")
except RuntimeError:
log.exception("logtest error от %s", who)
else:
log.log(
logging.getLevelName(lvl.upper()),
"logtest %s от %s",
lvl,
who,
)

await interaction.response.send_message(
embed=embeds.ok(
title="logtest",
description=f"отправил уровни: {', '.join(levels)}",
user=who,
),
ephemeral=True,
)


async def setup(bot: commands.Bot):
await bot.add_cog(DevCog(bot))
1 change: 1 addition & 0 deletions cogs/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def __init__(self, bot: commands.Bot):
"SocialCog": "социальное",
"RemindersCog": "напоминания",
"StatsCog": "статистика",
"DevCog": "разработка",
}

@app_commands.command(name="help", description="Выводит полный список команд")
Expand Down
12 changes: 12 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ def _load_token() -> str:
# (./data:/app/data), поэтому пишем именно туда, иначе состояние стирается
# при каждом пересоздании контейнера
DATA_DIR = Path(os.getenv("DATA_DIR", "data"))

# уровень логов для разработчиков: DEBUG / INFO / WARNING / ERROR (WARN тоже ок).
# на проде ставим повыше, локально опускаем до DEBUG. парсится в services.logger
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")

# дублирование логов эмбедами в дискан. вебхук бот заводит сам, поэтому в
# окружении только id канала и уровень (по умолчанию шлём от WARNING и выше).
# LOG_PING_USER_ID пингуется на ERROR и выше
LOG_CHANNEL_ID = int(os.getenv("LOG_CHANNEL_ID", "1496392803092271204"))
DISCORD_LOG_LEVEL = os.getenv("DISCORD_LOG_LEVEL", "WARNING")
LOG_PING_USER_ID = 587208453018091538

GUILD_ID = 1496231771602419772
ALLOWED_USERS = [1043834316620304394, 587208453018091538] # дискорд юзер айди

Expand Down
140 changes: 140 additions & 0 deletions services/discord_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Доставка логов в дискорд эмбедами через вебхук.

Хендлер логгинга синхронный, а отправка в дискорд асинхронная, поэтому
``emit`` не шлёт сам, а лишь кладёт запись в asyncio-очередь (не блокируясь),
а фоновый воркер её разбирает и постит через вебхук. Так логирование никогда
не тормозит вызывающий код и не зависит от состояния шлюза: вебхук это обычный
http-post по своему токену, он работает даже когда бот переподключается.

Цвет эмбеда зависит от уровня. ERROR и выше пингуют ответственного.
"""

from __future__ import annotations

import asyncio
import datetime
import logging
from collections.abc import Awaitable, Callable

import discord

# цвета эмбедов по уровню, чтобы уровень читался с одного взгляда
LEVEL_COLORS = {
logging.DEBUG: 0x95A5A6, # серый
logging.INFO: 0x3498DB, # синий
logging.WARNING: 0xE67E22, # оранжевый
logging.ERROR: 0xE74C3C, # красный
logging.CRITICAL: 0x992D22, # тёмно-красный
}
_DEFAULT_COLOR = 0x3A3340

# дискордовские лимиты на эмбед, с запасом под обрамление код-блока
_MAX_DESC = 3900
_MAX_FOOTER = 2048

# размер буфера: если за раз накопилось больше, лишнее тихо роняем, чтобы
# всплеск логов не съел память и не устроил очередь на минуты
_QUEUE_MAXSIZE = 1000


def build_embed(
record: logging.LogRecord, format_message: Callable[..., str]
) -> discord.Embed:
"""Собирает эмбед из записи лога. ``format_message`` форматирует тело."""
body = format_message(record)
embed = discord.Embed(
title=record.levelname,
description=f"```\n{body[:_MAX_DESC]}\n```",
color=LEVEL_COLORS.get(record.levelno, _DEFAULT_COLOR),
timestamp=datetime.datetime.fromtimestamp(
record.created, tz=datetime.timezone.utc
),
)
embed.set_footer(
text=f"{record.name} · {record.module}:{record.lineno}"[:_MAX_FOOTER]
)
return embed


class DiscordLogHandler(logging.Handler):
"""Шлёт записи лога эмбедами в дисканал через переданный sender.

``sender`` — корутина-отправитель (обычно ``webhook.send``), принимает
``content``, ``embed``, ``allowed_mentions``. Хендлер не знает про вебхук
напрямую, поэтому его легко тестировать с моком.
"""

def __init__(
self,
*,
sender: Callable[..., Awaitable[object]],
loop: asyncio.AbstractEventLoop,
ping_user_id: int | None = None,
level: int = logging.WARNING,
) -> None:
super().__init__(level=level)
self._sender = sender
self._loop = loop
self._ping_user_id = ping_user_id
self.queue: asyncio.Queue[logging.LogRecord] = asyncio.Queue(_QUEUE_MAXSIZE)
self._task: asyncio.Task | None = None
self.setFormatter(logging.Formatter("%(message)s"))
# не зацикливаемся: записи самого хендлера и болтливого http дискорда
# (рейтлимиты и т.п.) в дискорд не шлём, иначе ошибка отправки породит
# новую отправку и так до бесконечности
self.addFilter(lambda r: not r.name.startswith(("discord", __name__)))

def start(self) -> None:
"""Запускает фонового воркера. Вызывать внутри работающего loop."""
if self._task is None:
self._task = self._loop.create_task(self._worker())

async def aclose(self) -> None:
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None

def emit(self, record: logging.LogRecord) -> None:
# синхронный путь: только кладём запись в очередь, не блокируясь.
# из любого потока безопасно через call_soon_threadsafe
if self._loop.is_closed():
return
try:
self._loop.call_soon_threadsafe(self._enqueue, record)
except RuntimeError:
# loop закрылся между проверкой и вызовом — теряем запись, но не падаем
pass

def _enqueue(self, record: logging.LogRecord) -> None:
try:
self.queue.put_nowait(record)
except asyncio.QueueFull:
# очередь забита: роняем запись, логирование важнее доставки в дискорд
pass

async def _worker(self) -> None:
while True:
record = await self.queue.get()
try:
await self._deliver(record)
except Exception:
# ошибку доставки пишем в stderr, а НЕ через logging, иначе
# снова попадём сюда же
self.handleError(record)
finally:
self.queue.task_done()

async def _deliver(self, record: logging.LogRecord) -> None:
embed = build_embed(record, self.format)
content: str | None = None
allowed = discord.AllowedMentions.none()
if record.levelno >= logging.ERROR and self._ping_user_id is not None:
content = f"<@{self._ping_user_id}>"
allowed = discord.AllowedMentions(
users=[discord.Object(id=self._ping_user_id)]
)
await self._sender(content=content, embed=embed, allowed_mentions=allowed)
Loading
Loading