overall refactor of the bot #1

Merged
rejnronuzz merged 2 commits from dev into main 2026-03-09 06:14:16 +00:00
12 changed files with 513 additions and 651 deletions
Showing only changes of commit 4ace3b6611 - Show all commits

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
bot.log
__pycache__/
*.pyc
2.3.0

View File

@@ -1,107 +1,134 @@
import discord import discord
from discord.ext import commands, tasks from discord.ext import commands
from discord import app_commands from discord import app_commands
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime
from typing import Optional from typing import Optional
import config import config
from utils.database import FunchosaDatabase from utils.database import FunchosaDatabase
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def build_funchosa_embed(message_data: dict) -> discord.Embed:
embed = discord.Embed(
description=message_data['content'] or "*[без текста]*",
color=discord.Color.blue(),
timestamp=datetime.fromisoformat(message_data['timestamp'])
)
embed.set_author(name='random фунчоза of the day')
embed.add_field(
name="info",
value=(
f"автор: <@{message_data['author_id']}>\n"
f"дата: {message_data['timestamp'].replace('T', ' ')[:19]}\n"
f"номер в базе: {message_data['id']}"
),
inline=False
)
if message_data.get('attachments'):
links = [
f"[вложение {i}]({att['url']})"
for i, att in enumerate(message_data['attachments'][:3], 1)
]
embed.add_field(name="вложения", value="\n".join(links), inline=False)
return embed
def build_funchosa_view() -> discord.ui.View:
view = discord.ui.View()
view.add_item(discord.ui.Button(
label="подавай еще, раб",
custom_id="another_random",
style=discord.ButtonStyle.secondary
))
return view
class FunchosaView(discord.ui.View):
def __init__(self, db: FunchosaDatabase, message_url: str):
super().__init__(timeout=None)
self.db = db
self.add_item(discord.ui.Button(
label="перейти к сообщению",
url=message_url,
style=discord.ButtonStyle.link
))
@discord.ui.button(label="подавай еще, раб", custom_id="another_random", style=discord.ButtonStyle.secondary)
async def another_random(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.defer()
message_data = await self.db.get_random_message()
if not message_data:
await interaction.followup.send("помоему чет поломалось. меня пингани", ephemeral=True)
return
embed = build_funchosa_embed(message_data)
view = FunchosaView(self.db, message_data['message_url'])
await interaction.followup.send(embed=embed, view=view)
class FunchosaParser(commands.Cog): class FunchosaParser(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
self.db = FunchosaDatabase() self.db = FunchosaDatabase()
self.target_channel_id = config.FUNCHOSA_CHANNEL_ID self.target_channel_id = config.FUNCHOSA_CHANNEL_ID
self.is_parsing = False self.is_parsing = False
self.parsed_count = 0 self.parsed_count = 0
self.rate_limit_delay = 0.5
async def cog_load(self): async def cog_load(self):
await self.db.init_db() await self.db.init_db()
logger.info("[FunchosaParser] cog initialized") logger.info("FunchosaParser initialized")
await self.bot.wait_until_ready()
@commands.Cog.listener() await self.auto_parse_on_startup()
async def on_ready(self):
await asyncio.sleep(10)
if not self.is_parsing:
await self.auto_parse_on_startup()
async def auto_parse_on_startup(self): async def auto_parse_on_startup(self):
if self.is_parsing:
logger.warning("Parsing already in progress, skipping startup parse")
return
channel = self.bot.get_channel(self.target_channel_id)
if not channel:
logger.warning("Channel with id %s not found", self.target_channel_id)
return
try: try:
if not self.target_channel_id:
logger.warning("[FunchosaParser] no id channel ")
return
channel = self.bot.get_channel(self.target_channel_id)
if not channel:
logger.warning(f"[FunchosaParser] no channel with id {self.target_channel_id} found")
return
status = await self.db.get_parsing_status() status = await self.db.get_parsing_status()
logger.info(f"[FunchosaParser] parsing status; firsttry = {not status['first_parse_done']}") is_first = not status['first_parse_done']
limit = None if is_first else 250
if self.is_parsing: logger.info("Starting %s parse", "full" if is_first else "incremental")
logger.warning("[FunchosaParser] parsing already in progress")
return count = await self._parse_history(channel, limit=limit)
logger.info("[FunchosaParser] starting to parse") last_id = await self.db.get_last_message_in_db()
await self.db.update_parsing_status(first_parse_done=True, last_parsed_message_id=last_id)
if not status['first_parse_done']: logger.info("Parsing finished, %d new messages", count)
logger.info("[FunchosaParser] first try, parse all")
count = await self._parse_history(channel, limit=None)
last_message_id = await self.db.get_last_message_in_db()
await self.db.update_parsing_status(
first_parse_done=True,
last_parsed_message_id=last_message_id
)
logger.info(f"[FunchosaParser] parsing finished, total msg count: {count}")
else:
logger.info("[FunchosaParser] NOTfirst try, parse first 250")
count = await self._parse_history(channel, limit=250)
if count > 0:
new_last_message_id = await self.db.get_last_message_in_db()
await self.db.update_parsing_status(
first_parse_done=True,
last_parsed_message_id=new_last_message_id
)
logger.info(f"[FunchosaParser] parsing finished, total msg count: {count}")
except Exception as e: except Exception as e:
logger.error(f"[FunchosaParser] err when parsing: {e}", exc_info=True) logger.error("Error during startup parse: %s", e, exc_info=True)
@commands.Cog.listener() @commands.Cog.listener()
async def on_message(self, message): async def on_message(self, message):
if message.author.bot: if message.author.bot:
return return
if self.target_channel_id and message.channel.id == self.target_channel_id: if self.target_channel_id and message.channel.id == self.target_channel_id:
await self._save_message(message) await self._save_message(message)
async def _save_message(self, message): async def _save_message(self, message):
try: try:
if await self.db.message_exists(message.id): if await self.db.message_exists(message.id):
return return
attachments_data = [] attachments_data = [
attachment_urls = [] {'url': a.url, 'filename': a.filename}
for a in message.attachments
for attachment in message.attachments: if a.url.lower().endswith(('png', 'jpg', 'jpeg', 'gif', 'webp'))
if attachment.url.lower().endswith(('png', 'jpg', 'jpeg', 'gif', 'webp')): ]
attachments_data.append({
'url': attachment.url,
'filename': attachment.filename
})
attachment_urls.append(attachment.url)
message_data = { message_data = {
'message_id': message.id, 'message_id': message.id,
'channel_id': message.channel.id, 'channel_id': message.channel.id,
@@ -110,83 +137,58 @@ class FunchosaParser(commands.Cog):
'content': message.content, 'content': message.content,
'timestamp': message.created_at.isoformat(), 'timestamp': message.created_at.isoformat(),
'message_url': message.jump_url, 'message_url': message.jump_url,
'has_attachments': len(message.attachments) > 0, 'has_attachments': bool(message.attachments),
'attachment_urls': ','.join(attachment_urls), 'attachments': attachments_data,
'attachments': attachments_data
} }
saved = await self.db.save_message(message_data) saved = await self.db.save_message(message_data)
if saved: if saved:
self.parsed_count += 1 self.parsed_count += 1
if self.parsed_count % 50 == 0: if self.parsed_count % 50 == 0:
logger.info(f"[FunchosaParser] saved messages total: {self.parsed_count}") logger.info("Saved %d messages so far", self.parsed_count)
except Exception as e:
logger.error(f"[FunchosaParser] err when saving msg: {e}")
async def _parse_history(self, channel, limit=None, after_message=None):
try:
self.is_parsing = True
count = 0
skipped = 0
batch_size = 0
logger.info(f"[FunchosaParser] starting to parse {channel.name}")
oldest_first = not limit or limit < 0
parse_kwargs = {
'limit': abs(limit) if limit else None,
'oldest_first': oldest_first,
}
if after_message:
parse_kwargs['after'] = after_message
async for message in channel.history(**parse_kwargs): except Exception as e:
logger.error("Error saving message: %s", e)
async def _parse_history(self, channel, limit=None):
self.is_parsing = True
count = 0
skipped = 0
try:
logger.info("Parsing history of #%s (limit=%s)", channel.name, limit)
async for message in channel.history(limit=limit, oldest_first=True):
if message.author.bot: if message.author.bot:
continue continue
if await self.db.message_exists(message.id): if await self.db.message_exists(message.id):
skipped += 1 skipped += 1
batch_size += 1
if batch_size >= 100:
logger.info(f"[FunchosaParser] batch: +{count} новых, -{skipped} skipped")
batch_size = 0
continue continue
await self._save_message(message) await self._save_message(message)
count += 1 count += 1
batch_size += 1
if (count + skipped) % 100 == 0:
await asyncio.sleep(self.rate_limit_delay) logger.info("Progress: +%d new, -%d skipped", count, skipped)
if batch_size >= 100: logger.info("Parse done: %d new, %d skipped", count, skipped)
logger.info(f"[FunchosaParser] batch: +{count} новых, -{skipped} skipped")
batch_size = 0
logger.info(f"[FunchosaParser] parsing done. total new: {count}, total skipped: {skipped}")
return count return count
except Exception as e: except Exception as e:
logger.error(f"[FunchosaParser] err when parsing history: {e}", exc_info=True) logger.error("Error parsing history: %s", e, exc_info=True)
return 0 return 0
finally: finally:
self.is_parsing = False self.is_parsing = False
@commands.hybrid_command() @commands.hybrid_command()
@app_commands.describe( @app_commands.describe(number="номер сообщения из базы; optional")
number="номер сообщения из базы; optional"
)
async def funchosarand(self, ctx, number: Optional[int] = None): async def funchosarand(self, ctx, number: Optional[int] = None):
await ctx.defer() await ctx.defer()
if number: if number:
message_data = await self.db.get_message_by_number(number) message_data = await self.db.get_message_by_number(number)
if not message_data: if not message_data:
await ctx.send(f"сообщение с номером {number} не найдено в базе ||соси черт||") await ctx.send(f"сообщение с номером {number} не найдено в базе")
return return
else: else:
message_data = await self.db.get_random_message() message_data = await self.db.get_random_message()
@@ -194,129 +196,27 @@ class FunchosaParser(commands.Cog):
await ctx.send("помоему чет поломалось. меня пингани") await ctx.send("помоему чет поломалось. меня пингани")
return return
embed = discord.Embed( embed = build_funchosa_embed(message_data)
description=message_data['content'] or "*[без текста]*", view = FunchosaView(self.db, message_data['message_url'])
color=discord.Color.blue(),
timestamp=datetime.fromisoformat(message_data['timestamp'])
)
embed.set_author(
name='random фунчоза of the day'
)
attachments_text = []
if message_data.get('attachments'):
for i, att in enumerate(message_data['attachments'][:3], 1):
attachments_text.append(f"[вложение {i}]({att['url']})")
embed.add_field(
name="info",
value=(
f"автор: <@{message_data['author_id']}>\n"
f"дата: {message_data['timestamp'].replace('T', ' ')[:19]}\n"
f"номер в базе: {message_data['id']}"
),
inline=False
)
if attachments_text:
embed.add_field(
name="вложения",
value="\n".join(attachments_text),
inline=False
)
view = discord.ui.View()
view.add_item(
discord.ui.Button(
label="перейти к сообщению",
url=message_data['message_url'],
style=discord.ButtonStyle.link
)
)
view.add_item(
discord.ui.Button(
label="подавай еще, раб",
custom_id="another_random",
style=discord.ButtonStyle.secondary
)
)
await ctx.send(embed=embed, view=view) await ctx.send(embed=embed, view=view)
@commands.Cog.listener()
async def on_interaction(self, interaction: discord.Interaction):
if not interaction.data or 'custom_id' not in interaction.data:
return
if interaction.data['custom_id'] == "another_random":
await interaction.response.defer()
message_data = await self.db.get_random_message()
if not message_data:
await interaction.followup.send("помоему чет поломалось. меня пингани", ephemeral=True)
return
embed = discord.Embed(
description=message_data['content'] or "*[без текста]*",
color=discord.Color.blue(),
timestamp=datetime.fromisoformat(message_data['timestamp'])
)
embed.set_author(
name='random фунчоза of the day'
)
embed.add_field(
name="info",
value=(
f"автор: <@{message_data['author_id']}>\n"
f"дата: {message_data['timestamp'].replace('T', ' ')[:19]}\n"
f"номер в базе: {message_data['id']}"
),
inline=False
)
view = discord.ui.View()
view.add_item(
discord.ui.Button(
label="перейти к сообщению",
url=message_data['message_url'],
style=discord.ButtonStyle.link
)
)
view.add_item(
discord.ui.Button(
label="подавай еще, раб",
custom_id="another_random",
style=discord.ButtonStyle.secondary
)
)
await interaction.followup.send(embed=embed, view=view)
@commands.hybrid_command() @commands.hybrid_command()
async def funchosainfo(self, ctx): async def funchosainfo(self, ctx):
total = await self.db.get_total_count() total = await self.db.get_total_count()
status = await self.db.get_parsing_status() status = await self.db.get_parsing_status()
embed = discord.Embed( embed = discord.Embed(title="фунчоза.статы", color=discord.Color.green())
title="фунчоза.статы",
color=discord.Color.green()
)
embed.add_field(name="сообщений в базе", value=f"**{total}**", inline=True) embed.add_field(name="сообщений в базе", value=f"**{total}**", inline=True)
if status['last_parsed_message_id']: if status['last_parsed_message_id']:
embed.add_field( embed.add_field(
name="последнее сообщение", name="последнее сообщение",
value=f"id: `{status['last_parsed_message_id']}`", value=f"id: `{status['last_parsed_message_id']}`",
inline=False inline=False
) )
await ctx.send(embed=embed) await ctx.send(embed=embed)
async def setup(bot): async def setup(bot):
await bot.add_cog(FunchosaParser(bot)) await bot.add_cog(FunchosaParser(bot))

View File

@@ -1,27 +1,19 @@
import discord import logging
from discord.ext import commands from discord.ext import commands
import config
logger = logging.getLogger(__name__)
class HelpCog(commands.Cog): class HelpCog(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@commands.command(name="help") @commands.command(name="help")
async def help(self, ctx): async def help(self, ctx):
help_text = """ await ctx.send(config.HELP_TEXT)
комманды: logger.debug("Help requested by %s", ctx.author.name)
```
uptime : сколько времени прошло с запуска бота
funchosarand <id> : рандомная пикча из фунчозы либо по айдишнику в базе
funchosainfo : фунчоза.статы
kitty : рандомная пикча кошечки из [thecatapi](https://thecatapi.com/)
```
префикс: `!`
в лс отпишите по предложениям че в бота докинуть
"""
await ctx.send(help_text)
async def setup(bot): async def setup(bot):
await bot.add_cog(HelpCog(bot)) await bot.add_cog(HelpCog(bot))

View File

@@ -6,82 +6,83 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Kitty(commands.Cog, name="Котики"): class Kitty(commands.Cog, name="Котики"):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
self.api_key = os.environ.get('CAT_API_KEY') self.api_key = os.environ.get('CAT_API_KEY')
self.search_url = "https://api.thecatapi.com/v1/images/search" self.search_url = "https://api.thecatapi.com/v1/images/search"
self.session: aiohttp.ClientSession | None = None
if not self.api_key: if not self.api_key:
logger.warning("[Kitty] no api key found") logger.warning("No CAT_API_KEY found, using unauthenticated requests")
async def cog_load(self):
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["x-api-key"] = self.api_key
self.session = aiohttp.ClientSession(headers=headers)
async def cog_unload(self):
if self.session:
await self.session.close()
async def _fetch_random_cat(self): async def _fetch_random_cat(self):
headers = {"Content-Type": "application/json"}
if self.api_key and self.api_key != "DEMO-API-KEY":
headers["x-api-key"] = self.api_key
else:
headers["x-api-key"] = "DEMO-API-KEY"
params = { params = {
'size': 'med', 'size': 'med',
'mime_types': 'jpg,png', 'mime_types': 'jpg,png',
'format': 'json', 'format': 'json',
'has_breeds': 'true', 'has_breeds': 'true',
'order': 'RANDOM', 'order': 'RANDOM',
'limit': 1 'limit': 1,
} }
try: try:
async with aiohttp.ClientSession() as session: async with self.session.get(self.search_url, params=params) as response:
async with session.get(self.search_url, headers=headers, params=params) as response: if response.status != 200:
if response.status == 200: error_text = await response.text()
data = await response.json() logger.error("API error %s: %s", response.status, error_text)
if data and isinstance(data, list): return None
logger.info(f"[Kitty] API response received")
return data[0] data = await response.json()
else: if not data or not isinstance(data, list):
logger.error(f"[Kitty] api err: {response.status}") logger.error("Unexpected API response format: %s", data)
return None
return data[0]
try:
error_text = await response.text()
logger.error(f"[Kitty] api error text: {error_text}")
except:
pass
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logger.error(f"[Kitty] client error when contacting api: {e}") logger.error("HTTP client error: %s", e)
except Exception as e: except Exception as e:
logger.error(f"[Kitty] err: {e}") logger.error("Unexpected error fetching cat: %s", e)
return None return None
@commands.hybrid_command(name="kitty", description="kitty") @commands.hybrid_command(name="kitty", description="kitty")
async def kitty(self, ctx): async def kitty(self, ctx):
await ctx.defer() await ctx.defer()
logger.info(f"[Kitty] kitty request from {ctx.author.name} ({ctx.author.id})") logger.info("Kitty request from %s (%s)", ctx.author.name, ctx.author.id)
cat_data = await self._fetch_random_cat() cat_data = await self._fetch_random_cat()
if not cat_data: if not cat_data:
logger.warning("[Kitty] cat_data = null")
await ctx.send("помоему чет поломалось. меня пингани ||not cat_data||") await ctx.send("помоему чет поломалось. меня пингани ||not cat_data||")
return return
image_url = cat_data.get('url') image_url = cat_data.get('url')
if not image_url: if not image_url:
logger.error("[Kitty] no image url")
await ctx.send("помоему чет поломалось. меня пингани ||no image url||") await ctx.send("помоему чет поломалось. меня пингани ||no image url||")
return return
breeds = cat_data.get('breeds')
breeds_info = cat_data.get('breeds') breed_name = breeds[0].get('name') if breeds else None
if breeds_info and len(breeds_info) > 0:
breed = breeds_info[0] if breed_name:
if breed.get('name'): logger.info("Breed found: %s", breed_name)
caption = f"{breed['name']}" await ctx.send(f"random kitty of the day\n[{breed_name}]({image_url})")
logger.info(f"[Kitty] Breed found: {breed['name']}") else:
await ctx.send(f"random kitty of the day\n{image_url}")
logger.info("Successfully sent kitty to %s", ctx.author.name)
await ctx.send(f"random kitty of the day\n[{caption}]({image_url})")
logger.info(f"[Kitty] succesfully send kitty to {ctx.author.name}")
async def setup(bot): async def setup(bot):
await bot.add_cog(Kitty(bot)) await bot.add_cog(Kitty(bot))

View File

@@ -1,147 +1,140 @@
import discord import discord
from discord.ext import commands from discord.ext import commands
from discord import app_commands
import config import config
from utils.data_manager import save_message_id, load_message_id from utils.data_manager import save_message_id, load_message_id
import logging import logging
# Создаем логгер для этого модуля
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RoleManager(commands.Cog): class RoleManager(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
self.role_message_id = None self.role_message_id = None
self.CHANNEL_ID = config.CHANNEL_ID self.CHANNEL_ID = config.CHANNEL_ID
self.REACTION_ROLES = config.REACTION_ROLES self.REACTION_ROLES = config.REACTION_ROLES
self._ready = False
async def cog_load(self): async def cog_load(self):
self.role_message_id = load_message_id() self.role_message_id = load_message_id()
if self.role_message_id: if self.role_message_id:
logger.info(f"[RoleManager] initialized role msg with id: '{self.role_message_id}'") logger.info("Initialized role message with id: %s", self.role_message_id)
else: else:
logger.info('[RoleManager] no role msg found') logger.info("No role message found")
@commands.Cog.listener() await self.bot.wait_until_ready()
async def on_ready(self): if self.role_message_id:
if not self._ready and self.role_message_id:
await self.check_and_sync_roles() await self.check_and_sync_roles()
self._ready = True
@commands.Cog.listener() @commands.Cog.listener()
async def on_raw_reaction_add(self, payload): async def on_raw_reaction_add(self, payload):
await self.handle_reaction(payload, add_role=True) await self.handle_reaction(payload, add_role=True)
@commands.Cog.listener() @commands.Cog.listener()
async def on_raw_reaction_remove(self, payload): async def on_raw_reaction_remove(self, payload):
await self.handle_reaction(payload, add_role=False) await self.handle_reaction(payload, add_role=False)
async def handle_reaction(self, payload, add_role=True): async def handle_reaction(self, payload, add_role=True):
if payload.message_id != self.role_message_id: if payload.message_id != self.role_message_id:
return return
emoji = str(payload.emoji) emoji = str(payload.emoji)
if emoji not in self.REACTION_ROLES: if emoji not in self.REACTION_ROLES:
return return
guild = self.bot.get_guild(payload.guild_id) guild = self.bot.get_guild(payload.guild_id)
if not guild: if not guild:
return return
member = guild.get_member(payload.user_id) member = guild.get_member(payload.user_id)
if not member or member.bot: if not member or member.bot:
return return
role_id = self.REACTION_ROLES[emoji] role_id = self.REACTION_ROLES[emoji]
role = guild.get_role(role_id) role = guild.get_role(role_id)
if not role: if not role:
logger.warning(f"[RoleManager] role with id '{role_id}' not found") logger.warning("Role with id %s not found", role_id)
return return
try: try:
if add_role: if add_role:
await member.add_roles(role) await member.add_roles(role)
logger.info(f"[RoleManager] gave role '{role.name}' to '{member.name}'") logger.info("Gave role '%s' to '%s'", role.name, member.name)
else: else:
await member.remove_roles(role) await member.remove_roles(role)
logger.info(f"[RoleManager] removed role '{role.name}' from user '{member.name}'") logger.info("Removed role '%s' from '%s'", role.name, member.name)
except discord.Forbidden: except discord.Forbidden:
logger.error(f"[RoleManager] not enough rights to give role '{role.name}'") logger.error("Missing permissions to manage role '%s'", role.name)
except Exception as e: except Exception as e:
logger.error(f"[RoleManager] err: '{e}'") logger.error("Unexpected error in handle_reaction: %s", e)
async def check_and_sync_roles(self): async def check_and_sync_roles(self):
if not self.role_message_id: if not self.role_message_id:
return return
try: try:
channel = await self.bot.fetch_channel(self.CHANNEL_ID) channel = await self.bot.fetch_channel(self.CHANNEL_ID)
if not channel:
logger.warning(f"[RoleManager] channel with id '{self.CHANNEL_ID}' not found")
return
message = await channel.fetch_message(self.role_message_id) message = await channel.fetch_message(self.role_message_id)
for reaction in message.reactions: for reaction in message.reactions:
emoji = str(reaction.emoji) emoji = str(reaction.emoji)
if emoji not in self.REACTION_ROLES:
if emoji in self.REACTION_ROLES: continue
role_id = self.REACTION_ROLES[emoji]
role = message.guild.get_role(role_id) role = message.guild.get_role(self.REACTION_ROLES[emoji])
if not role:
if not role: logger.warning("Role with id %s not found during sync", self.REACTION_ROLES[emoji])
logger.warning(f"[RoleManager] role with id '{role_id}' not found") continue
async for user in reaction.users():
if user.bot:
continue continue
member = message.guild.get_member(user.id)
async for user in reaction.users(): if member and role not in member.roles:
if user.bot: await member.add_roles(role)
continue logger.info("Sync gave role '%s' to '%s'", role.name, member.name)
member = message.guild.get_member(user.id)
if member and role not in member.roles:
await member.add_roles(role)
logger.info(f"[RoleManager] gave role '{role.name}' to user '{member.name}'")
except discord.NotFound: except discord.NotFound:
logger.warning("[RoleManager] role msg not found") logger.warning("Role message not found during sync")
except discord.Forbidden: except discord.Forbidden:
logger.error("[RoleManager] no rights to get channel or message") logger.error("Missing permissions to fetch channel or message")
except Exception as e: except Exception as e:
logger.error(f"[RoleManager] sync err: '{e}'") logger.error("Unexpected sync error: %s", e)
@commands.hybrid_command() @commands.hybrid_command()
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def create_role_message(self, ctx): async def create_role_message(self, ctx):
embed = discord.Embed( message = await ctx.send(config.ROLE_MESSAGE_TEXT)
title="ле", for emoji in self.REACTION_ROLES:
description="пикните там роли снизу по реактам,\nшоб если куда то шли играть с фаршем >3 человек то сразу можно было ее пингануть\n\n"
"react_index = {\n"
" '💩': гревдиггер\n"
" '🤙': бара наверн хз\n"
" '🤕': пох ваще за любой движ\n"
" '🇺🇦': мтк\n"
"}\n\n"
"естессно кто будет спамить пингом ролей тот будет опущен и закинут в таймаут\n"
"если бот в оффе роли выдадутся когда я врублю его снова",
color=0x00ff00
)
message = await ctx.send(embed=embed)
for emoji in self.REACTION_ROLES.keys():
await message.add_reaction(emoji) await message.add_reaction(emoji)
self.role_message_id = message.id self.role_message_id = message.id
save_message_id(message.id) save_message_id(message.id)
logger.info(f"[RoleManager] created new role message with id: '{message.id}'") logger.info("Created new role message with id: %s", message.id)
@commands.hybrid_command() @commands.hybrid_command()
@commands.has_permissions(administrator=True) @commands.has_permissions(administrator=True)
async def sync_roles(self, ctx): async def update_role_message(self, ctx):
await self.check_and_sync_roles() if not self.role_message_id:
logger.info("[RoleManager] manual sync triggered by admin") return
try:
channel = await self.bot.fetch_channel(self.CHANNEL_ID)
message = await channel.fetch_message(self.role_message_id)
await message.edit(content=config.ROLE_MESSAGE_TEXT)
existing = [str(r.emoji) for r in message.reactions]
for emoji in self.REACTION_ROLES:
if emoji not in existing:
await message.add_reaction(emoji)
logger.info("Role message updated by %s", ctx.author.name)
except discord.NotFound:
logger.warning("Role message not found during update")
except discord.Forbidden:
logger.error("Missing permissions to edit role message")
async def setup(bot): async def setup(bot):
await bot.add_cog(RoleManager(bot)) await bot.add_cog(RoleManager(bot))

View File

@@ -7,55 +7,64 @@ from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class StatusRotator(commands.Cog): class StatusRotator(commands.Cog):
def __init__(self, bot): def __init__(self, bot, *, status_file: str = 'data/statuses.json', interval: float = 1.0):
self.bot = bot self.bot = bot
self.statuses: list[str] = [] self.statuses: list[str] = []
self.current_index = 0 self.current_index = 0
self.status_file = 'data/statuses.json' self.status_file = status_file
self.rotate_status.change_interval(minutes=interval)
async def cog_load(self): async def cog_load(self):
await self.load_statuses() await self.load_statuses()
self.rotate_status.start() self.rotate_status.start()
logger.info("[StatusRotator] status rotator initialized") logger.info("Status rotator initialized with %d statuses", len(self.statuses))
async def cog_unload(self):
self.rotate_status.cancel()
async def load_statuses(self): async def load_statuses(self):
try: try:
with open(self.status_file, 'r', encoding='utf-8') as f: with open(self.status_file, 'r', encoding='utf-8') as f:
data = json.load(f) data = json.load(f)
self.statuses = data.get('statuses', []) self.statuses = data.get('statuses', [])
logger.info("Loaded %d statuses", len(self.statuses))
logger.info(f"[StatusRotator] loaded {len(self.statuses)} statuses")
except FileNotFoundError: except FileNotFoundError:
logger.error(f"[StatusRotator] file {self.status_file} notfound") logger.error("Status file not found: %s", self.status_file)
except json.JSONDecodeError: except json.JSONDecodeError:
logger.error(f"[StatusRotaror] err while parsing JSON") logger.error("Failed to parse JSON in: %s", self.status_file)
def get_random_status(self) -> str: def get_next_status(self) -> Optional[str]:
return random.choice(self.statuses) if not self.statuses:
return None
def get_next_status(self) -> str:
status = self.statuses[self.current_index] status = self.statuses[self.current_index]
self.current_index = (self.current_index + 1) % len(self.statuses) self.current_index = (self.current_index + 1) % len(self.statuses)
return status return status
def get_random_status(self) -> Optional[str]:
if not self.statuses:
return None
return random.choice(self.statuses)
async def update_status(self, status_text: Optional[str] = None): async def update_status(self, status_text: Optional[str] = None):
if not self.statuses:
logger.warning("No statuses loaded, skipping update")
return
if status_text is None: if status_text is None:
status_text = self.get_random_status() status_text = self.get_next_status()
activity = discord.Game(name=status_text)
try: try:
await self.bot.change_presence(activity=activity) await self.bot.change_presence(activity=discord.Game(name=status_text))
logger.debug(f"[StatusRotator] status updated: {status_text}") logger.debug("Status updated: %s", status_text)
except Exception as e: except Exception as e:
logger.error(f"[StatusRotator] err while updating status: {e}") logger.error("Failed to update status: %s", e)
@tasks.loop(minutes=1.0) @tasks.loop(minutes=1.0)
async def rotate_status(self): async def rotate_status(self):
await self.update_status() await self.update_status()
@rotate_status.before_loop @rotate_status.before_loop
async def before_rotate_status(self): async def before_rotate_status(self):
await self.bot.wait_until_ready() await self.bot.wait_until_ready()

View File

@@ -1,46 +1,47 @@
import discord import discord
from discord.ext import commands from discord.ext import commands
import datetime
def pluralize(n: int, one: str, few: str, many: str) -> str:
if 11 <= n % 100 <= 14:
return f"{n} {many}"
r = n % 10
if r == 1:
return f"{n} {one}"
if 2 <= r <= 4:
return f"{n} {few}"
return f"{n} {many}"
class UptimeSimple(commands.Cog): class UptimeSimple(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
self.start_time = None self.start_time = discord.utils.utcnow()
@commands.Cog.listener()
async def on_ready(self):
if self.start_time is None:
self.start_time = datetime.datetime.now(datetime.timezone.utc)
@commands.command(name="uptime") @commands.command(name="uptime")
async def uptime(self, ctx): async def uptime(self, ctx):
if self.start_time is None: delta = discord.utils.utcnow() - self.start_time
await ctx.send("ебать у тебя тайминги кнш") seconds = int(delta.total_seconds())
return minutes, secs = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
current_time = datetime.datetime.now(datetime.timezone.utc) days, hours = divmod(hours, 24)
uptime = current_time - self.start_time
seconds = int(uptime.total_seconds())
days = seconds // 86400
hours = (seconds % 86400) // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
result = "бот работает уже: "
parts = [] parts = []
if days:
if days > 0: parts.append(pluralize(days, "день", "дня", "дней"))
parts.append(f"{days} дня") if hours:
if hours > 0: parts.append(pluralize(hours, "час", "часа", "часов"))
parts.append(f"{hours} часа") if minutes:
if minutes > 0: parts.append(pluralize(minutes, "минуту", "минуты", "минут"))
parts.append(f"{minutes} минут") if secs or not parts:
if secs > 0 or not parts: parts.append(pluralize(secs, "секунду", "секунды", "секунд"))
parts.append(f"{secs} секунд")
embed = discord.Embed(
result += " ".join(parts) description="бот работает уже: " + " ".join(parts),
await ctx.send(result) color=discord.Color.green()
)
await ctx.send(embed=embed)
async def setup(bot): async def setup(bot):
await bot.add_cog(UptimeSimple(bot)) await bot.add_cog(UptimeSimple(bot))

View File

@@ -5,6 +5,7 @@ load_dotenv()
TOKEN = os.getenv('DISCORD_TOKEN') TOKEN = os.getenv('DISCORD_TOKEN')
CHANNEL_ID = 1454107749028855971 # roles channel CHANNEL_ID = 1454107749028855971 # roles channel
roles_message_id = 1454128857102680187
FUNCHOSA_CHANNEL_ID = 1379127661095551048 FUNCHOSA_CHANNEL_ID = 1379127661095551048
REACTION_ROLES = { REACTION_ROLES = {

View File

@@ -1 +0,0 @@
{"message_id": 1454128857102680187}

45
main.py
View File

@@ -13,13 +13,25 @@ logging.basicConfig(
] ]
) )
logger = logging.getLogger(__name__)
intents = discord.Intents.default() intents = discord.Intents.default()
intents.message_content = True intents.message_content = True
intents.reactions = True intents.reactions = True
intents.members = True intents.members = True
intents.guilds = True intents.guilds = True
intents.messages = True intents.messages = True
intents.voice_states = True
COGS = [
#! cogs to load
'cogs.role_manager',
'cogs.status_rotator',
'cogs.funchosa_parser',
'cogs.uptime',
'cogs.help',
'cogs.kitty',
]
class Bot(commands.Bot): class Bot(commands.Bot):
def __init__(self): def __init__(self):
@@ -28,30 +40,27 @@ class Bot(commands.Bot):
intents=intents, intents=intents,
help_command=None, help_command=None,
) )
async def setup_hook(self): async def setup_hook(self):
# ! load cogs for cog in COGS:
await self.load_extension('cogs.role_manager') try:
await self.load_extension('cogs.status_rotator') await self.load_extension(cog)
await self.load_extension('cogs.funchosa_parser') logger.info("Loaded cog: %s", cog)
await self.load_extension('cogs.uptime') except Exception as e:
await self.load_extension('cogs.help') logger.error("Failed to load cog %s: %s", cog, e, exc_info=True)
await self.load_extension('cogs.kitty')
#await self.load_extension('cogs.muter') # ass
# adding new modules:
# await self.load_extension('cogs.whyrureadingts')
await self.tree.sync() await self.tree.sync()
async def on_ready(self): async def on_ready(self):
print(f"bot initialized succesfully with user '{self.user}'") if not hasattr(self, '_ready'):
print(f"user.id: '{self.user.id}'") self._ready = True
print('initialization (probably) complete; further is logs.') logger.info("Bot ready: %s (id: %s)", self.user, self.user.id)
print('\n*------*\n')
async def main(): async def main():
bot = Bot() bot = Bot()
await bot.start(config.TOKEN) await bot.start(config.TOKEN)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

View File

@@ -1,16 +1,15 @@
import json import json
import os import os
from config import roles_message_id
MESSAGE_ID_FILE = 'data/message_id.json' #ts for roles
def save_message_id(message_id): def save_message_id(message_id):
os.makedirs('data', exist_ok=True) os.makedirs('data', exist_ok=True)
with open(MESSAGE_ID_FILE, 'w') as f: with open(roles_message_id, 'w') as f:
json.dump({'message_id': message_id}, f) json.dump({'message_id': message_id}, f)
def load_message_id(): def load_message_id():
try: try:
with open(MESSAGE_ID_FILE, 'r') as f: with open(roles_message_id, 'r') as f:
data = json.load(f) data = json.load(f)
return data.get('message_id') return data.get('message_id')
except FileNotFoundError: except FileNotFoundError:

View File

@@ -1,211 +1,164 @@
import aiosqlite import aiosqlite
import asyncio
from datetime import datetime
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class FunchosaDatabase: class FunchosaDatabase:
def __init__(self, db_path='data/funchosa.db'): def __init__(self, db_path='data/funchosa.db'):
self.db_path = db_path self.db_path = db_path
self._conn: aiosqlite.Connection | None = None
async def init_db(self): async def init_db(self):
async with aiosqlite.connect(self.db_path) as db: self._conn = await aiosqlite.connect(self.db_path)
await db.execute(''' self._conn.row_factory = aiosqlite.Row
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, await self._conn.executescript('''
message_id BIGINT UNIQUE NOT NULL, CREATE TABLE IF NOT EXISTS messages (
channel_id BIGINT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
author_id BIGINT NOT NULL, message_id BIGINT UNIQUE NOT NULL,
author_name TEXT NOT NULL, channel_id BIGINT NOT NULL,
content TEXT, author_id BIGINT NOT NULL,
timestamp TIMESTAMP NOT NULL, author_name TEXT NOT NULL,
message_url TEXT NOT NULL, content TEXT,
has_attachments BOOLEAN DEFAULT 0, timestamp TIMESTAMP NOT NULL,
attachment_urls TEXT, message_url TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP has_attachments BOOLEAN DEFAULT 0,
) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
''') );
await db.execute(''' CREATE TABLE IF NOT EXISTS attachments (
CREATE TABLE IF NOT EXISTS attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER,
message_id INTEGER, url TEXT UNIQUE NOT NULL,
url TEXT UNIQUE NOT NULL, filename TEXT,
filename TEXT, FOREIGN KEY (message_id) REFERENCES messages (id)
FOREIGN KEY (message_id) REFERENCES messages (id) );
)
''') CREATE TABLE IF NOT EXISTS parsing_status (
id INTEGER PRIMARY KEY CHECK (id = 1),
await db.execute(''' first_parse_done BOOLEAN DEFAULT 0,
CREATE TABLE IF NOT EXISTS parsing_status ( last_parsed_message_id BIGINT,
id INTEGER PRIMARY KEY CHECK (id = 1), last_parse_time TIMESTAMP
first_parse_done BOOLEAN DEFAULT 0, );
last_parsed_message_id BIGINT,
last_parse_time TIMESTAMP CREATE INDEX IF NOT EXISTS idx_message_id ON messages(message_id);
) CREATE INDEX IF NOT EXISTS idx_author_id ON messages(author_id);
''') CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
''')
await db.execute('CREATE INDEX IF NOT EXISTS idx_message_id ON messages(message_id)') await self._conn.commit()
await db.execute('CREATE INDEX IF NOT EXISTS idx_author_id ON messages(author_id)') logger.info("Database initialized")
await db.execute('CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp)')
async def close(self):
await db.commit() if self._conn:
logger.info("[FunchosaDatabase] funchosa db initialized") await self._conn.close()
async def get_parsing_status(self): def _parse_message_row(self, row) -> dict:
async with aiosqlite.connect(self.db_path) as db: message = dict(row)
cursor = await db.execute( if message.get('attachment_urls_list'):
'SELECT first_parse_done, last_parsed_message_id FROM parsing_status WHERE id = 1' urls = message['attachment_urls_list'].split(',')
) filenames = (message['attachment_filenames'] or '').split(',')
result = await cursor.fetchone() message['attachments'] = [
{'url': url, 'filename': filename}
if result: for url, filename in zip(urls, filenames)
return { ]
'first_parse_done': bool(result[0]), else:
'last_parsed_message_id': result[1] message['attachments'] = []
} return message
else:
await db.execute( async def get_parsing_status(self) -> dict:
'INSERT INTO parsing_status (id, first_parse_done, last_parsed_message_id) VALUES (1, 0, NULL)' cursor = await self._conn.execute(
) 'SELECT first_parse_done, last_parsed_message_id FROM parsing_status WHERE id = 1'
await db.commit() )
return { result = await cursor.fetchone()
'first_parse_done': False, if result:
'last_parsed_message_id': None return {'first_parse_done': bool(result[0]), 'last_parsed_message_id': result[1]}
}
await self._conn.execute(
'INSERT INTO parsing_status (id, first_parse_done, last_parsed_message_id) VALUES (1, 0, NULL)'
)
await self._conn.commit()
return {'first_parse_done': False, 'last_parsed_message_id': None}
async def update_parsing_status(self, first_parse_done=False, last_parsed_message_id=None): async def update_parsing_status(self, first_parse_done=False, last_parsed_message_id=None):
async with aiosqlite.connect(self.db_path) as db: await self._conn.execute('''
await db.execute(''' UPDATE parsing_status
UPDATE parsing_status SET first_parse_done = ?,
SET first_parse_done = ?, last_parsed_message_id = ?,
last_parsed_message_id = ?, last_parse_time = CURRENT_TIMESTAMP
last_parse_time = CURRENT_TIMESTAMP WHERE id = 1
WHERE id = 1 ''', (first_parse_done, last_parsed_message_id))
''', (first_parse_done, last_parsed_message_id)) await self._conn.commit()
await db.commit()
async def get_last_message_in_db(self): async def get_last_message_in_db(self):
async with aiosqlite.connect(self.db_path) as db: cursor = await self._conn.execute(
cursor = await db.execute( 'SELECT message_id FROM messages ORDER BY message_id DESC LIMIT 1'
'SELECT message_id FROM messages ORDER BY message_id DESC LIMIT 1' )
) result = await cursor.fetchone()
result = await cursor.fetchone() return result[0] if result else None
return result[0] if result else None
async def save_message(self, message_data: dict) -> bool:
async def save_message(self, message_data): cursor = await self._conn.execute('''
async with aiosqlite.connect(self.db_path) as db: INSERT OR IGNORE INTO messages
cursor = await db.execute(''' (message_id, channel_id, author_id, author_name, content,
INSERT OR IGNORE INTO messages timestamp, message_url, has_attachments)
(message_id, channel_id, author_id, author_name, content, VALUES (?, ?, ?, ?, ?, ?, ?, ?)
timestamp, message_url, has_attachments, attachment_urls) ''', (
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) message_data['message_id'],
''', ( message_data['channel_id'],
message_data['message_id'], message_data['author_id'],
message_data['channel_id'], message_data['author_name'],
message_data['author_id'], message_data['content'],
message_data['author_name'], message_data['timestamp'],
message_data['content'], message_data['message_url'],
message_data['timestamp'], message_data['has_attachments'],
message_data['message_url'], ))
message_data['has_attachments'],
message_data['attachment_urls'] if cursor.rowcount > 0:
)) message_db_id = cursor.lastrowid
for attachment in message_data.get('attachments', []):
if cursor.rowcount > 0: await self._conn.execute('''
message_db_id = cursor.lastrowid INSERT OR IGNORE INTO attachments (message_id, url, filename)
VALUES (?, ?, ?)
if message_data['attachments']: ''', (message_db_id, attachment['url'], attachment['filename']))
for attachment in message_data['attachments']: await self._conn.commit()
await db.execute(''' return True
INSERT OR IGNORE INTO attachments return False
(message_id, url, filename)
VALUES (?, ?, ?) async def message_exists(self, message_id: int) -> bool:
''', ( cursor = await self._conn.execute(
message_db_id, 'SELECT 1 FROM messages WHERE message_id = ? LIMIT 1', (message_id,)
attachment['url'], )
attachment['filename'] return await cursor.fetchone() is not None
))
async def get_random_message(self) -> dict | None:
await db.commit() cursor = await self._conn.execute('''
return True SELECT m.*,
return False GROUP_CONCAT(a.url) as attachment_urls_list,
GROUP_CONCAT(a.filename) as attachment_filenames
async def message_exists(self, message_id): FROM messages m
async with aiosqlite.connect(self.db_path) as db: LEFT JOIN attachments a ON m.id = a.message_id
cursor = await db.execute( GROUP BY m.id
'SELECT 1 FROM messages WHERE message_id = ? LIMIT 1', ORDER BY RANDOM()
(message_id,) LIMIT 1
) ''')
result = await cursor.fetchone() row = await cursor.fetchone()
return result is not None return self._parse_message_row(row) if row else None
async def get_random_message(self): async def get_message_by_number(self, number: int) -> dict | None:
async with aiosqlite.connect(self.db_path) as db: cursor = await self._conn.execute('''
cursor = await db.execute(''' SELECT m.*,
SELECT m.*, GROUP_CONCAT(a.url) as attachment_urls_list,
GROUP_CONCAT(a.url) as attachment_urls_list, GROUP_CONCAT(a.filename) as attachment_filenames
GROUP_CONCAT(a.filename) as attachment_filenames FROM messages m
FROM messages m LEFT JOIN attachments a ON m.id = a.message_id
LEFT JOIN attachments a ON m.id = a.message_id WHERE m.id = ?
GROUP BY m.id GROUP BY m.id
ORDER BY RANDOM() ''', (number,))
LIMIT 1 row = await cursor.fetchone()
''') return self._parse_message_row(row) if row else None
row = await cursor.fetchone() async def get_total_count(self) -> int:
if not row: cursor = await self._conn.execute('SELECT COUNT(*) FROM messages')
return None result = await cursor.fetchone()
return result[0] if result else 0
columns = [description[0] for description in cursor.description]
message = dict(zip(columns, row))
if message['attachment_urls_list']:
urls = message['attachment_urls_list'].split(',')
filenames = message['attachment_filenames'].split(',') if message['attachment_filenames'] else []
message['attachments'] = [
{'url': url, 'filename': filename}
for url, filename in zip(urls, filenames)
]
else:
message['attachments'] = []
return message
async def get_total_count(self):
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute('SELECT COUNT(*) FROM messages')
result = await cursor.fetchone()
return result[0] if result else 0
async def get_message_by_number(self, number):
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute('''
SELECT m.*,
GROUP_CONCAT(a.url) as attachment_urls_list,
GROUP_CONCAT(a.filename) as attachment_filenames
FROM messages m
LEFT JOIN attachments a ON m.id = a.message_id
WHERE m.id = ?
GROUP BY m.id
''', (number,))
row = await cursor.fetchone()
if not row:
return None
columns = [description[0] for description in cursor.description]
message = dict(zip(columns, row))
if message.get('attachment_urls_list'):
urls = message['attachment_urls_list'].split(',')
filenames = message['attachment_filenames'].split(',') if message['attachment_filenames'] else []
message['attachments'] = [
{'url': url, 'filename': filename}
for url, filename in zip(urls, filenames)
]
else:
message['attachments'] = []
return message