This repository has been archived by the owner on Jul 19, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathbot.py
190 lines (158 loc) · 8.35 KB
/
bot.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
# MIT License
#
# Copyright (c) 2023 Kacper Wojciuch
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import pickle
from dataclasses import dataclass, field
from datetime import datetime
from io import BytesIO
from logging import getLogger
import aiohttp
import discord
from sortedcontainers import SortedList
from .style import Styl
from .zadanie import Ogloszenie
__all__ = "SpisBot", "PROSTY_FORMAT_DATY"
logger = getLogger(__name__)
# Link do API GitHuba, aby zdobyć informacje o najnowszych zmianach
LINK_GITHUB_API: str = "https://api.github.com/repos/Kacper0510/SpisZadanDomowych/commits?per_page=1"
PROSTY_FORMAT_DATY = "%d.%m.%y %H:%M:%S"
@dataclass
class StanBota:
"""Klasa przechowująca stan bota między uruchomieniami"""
lista_zadan: SortedList[Ogloszenie] = field(default_factory=SortedList)
style: dict[int, Styl] = field(default_factory=dict) # Styl każdego użytkownika
ostatni_zapis: datetime = field(default_factory=datetime.now)
uzycia_spis: int = 0 # Globalna ilość użyć /spis
# Poniższe ustawienie jest dostępne jedynie poprzez edycję pliku pickle badź tego pliku źródłowego
edytor: int | None = None # ID serwera, na którym można edytować spis
def __hash__(self):
"""Zwraca hash stanu"""
dane_do_hashowania: tuple = (
tuple(self.lista_zadan),
frozenset(self.style.items()),
self.ostatni_zapis,
self.uzycia_spis,
self.edytor
)
return hash(dane_do_hashowania)
class SpisBot(discord.Bot):
"""Rozszerzenie podstawowego bota o potrzebne metody"""
def __init__(self, *args, **kwargs):
"""Inicjalizacja zmiennych"""
super().__init__(*args, **kwargs)
self.stan: StanBota | None = None
# Konfiguracja
self.autosave: bool = True # Auto-zapis przy wyłączaniu i auto-wczytywanie przy włączaniu
self.serwer_dev: int | None = None # Serwer do zarejestrowania komend developerskich
# Dane uzupełniane przy inicjalizacji
self.backup_kanal: discord.DMChannel | None = None # Kanał do zapisywania/backupowania/wczytywania stanu spisu
self.hash_stanu: int = 0 # Hash stanu bota przy ostatnim zapisie/wczytaniu
self.czas_startu = datetime.now() # Czas startu bota, do obliczania uptime
self.invite_link: str = "" # Link do zaproszenia bota na serwer
self.ostatni_commit: str = "" # Ostatnia aktualizacja bota
async def zapisz(self) -> bool:
"""Zapisuje stan bota do pliku i wysyła go do twórcy bota.
Uwaga: zapis nie odbędzie się w przypadku wykrycia identycznego hasha stanu."""
if hash(self.stan) == self.hash_stanu: # Nic się nie zmieniło od ostatniego zapisu
logger.info("Zapis stanu nie był konieczny - identyczny hash")
return False
ostatni_zapis_old = self.stan.ostatni_zapis # Do przywrócenia w przypadku niepowodzenia zapisu
self.stan.ostatni_zapis = datetime.now()
try:
backup = pickle.dumps(self.stan, pickle.HIGHEST_PROTOCOL)
plik = discord.File(BytesIO(backup), f"spis_backup_{round(self.stan.ostatni_zapis.timestamp())}.pickle")
await self.backup_kanal.send("", file=plik)
logger.info(f"Pomyślnie zapisano plik {plik.filename} na kanale {self.backup_kanal!r}")
logger.debug(f"Zapisane dane: {self.stan!r}")
return True
except pickle.PickleError as e:
logger.exception("Nie udało się zapisać stanu jako obiekt pickle!", exc_info=e)
self.stan.ostatni_zapis = ostatni_zapis_old
return False
finally: # Zawsze przekalkuluj hash stanu
self.hash_stanu = hash(self.stan)
async def wczytaj(self) -> bool:
"""Wczytuje stan bota z kanału prywatnego twórcy bota"""
try:
ostatnia_wiadomosc = (await self.backup_kanal.history(limit=1).flatten())[0]
if len(ostatnia_wiadomosc.attachments) != 1:
logger.warning(f"Ostatnia wiadomość na kanale {self.backup_kanal!r} miała złą ilość załączników, "
f"porzucono wczytywanie stanu!")
return False
dane = await ostatnia_wiadomosc.attachments[0].read()
self.stan = pickle.loads(dane, fix_imports=False)
# Usuń zadania z przeszłości
for zadanie in list(self.stan.lista_zadan):
if zadanie.termin_usuniecia < datetime.now():
self.stan.lista_zadan.remove(zadanie)
logger.info(f"Pomyślnie wczytano backup z {self.stan.ostatni_zapis.strftime(PROSTY_FORMAT_DATY)} "
f"z kanału {self.backup_kanal!r}")
logger.debug(f"Zapisane dane: {self.stan!r}")
return True
except (pickle.PickleError, IndexError) as e:
logger.exception("Nie udało się wczytać pliku pickle!", exc_info=e)
self.stan = StanBota()
return False
finally: # Zawsze przekalkuluj hash stanu
self.hash_stanu = hash(self.stan)
async def _pobierz_informacje_z_githuba(self) -> None:
"""Pobiera informacje o ostatnich zmianach z GitHuba i zapisuje je do zmiennej OSTATNI_COMMIT"""
try:
async with aiohttp.ClientSession() as session, session.get(LINK_GITHUB_API) as response:
dane = (await response.json())[0]
logger.info(f"Wczytano informacje z GitHuba: {dane['sha']}")
# Ładne sformatowanie wczytanych informacji
self.ostatni_commit = \
f"<t:{int(datetime.strptime(dane['commit']['author']['date'], '%Y-%m-%dT%H:%M:%S%z').timestamp())}:R> "\
f"- `{dane['sha'][:7]}` - [" + dane['commit']['message'].split('\n')[0] + f"]({dane['html_url']})"
except aiohttp.ClientError as e:
logger.exception(f"Nie udało się wczytać informacji z GitHuba!", exc_info=e)
async def on_connect(self):
"""Nadpisane, aby uniknąć zbędnego wywołania sync_commands()"""
pass
async def on_ready(self):
"""Wykonywane przy starcie bota"""
logger.info(f"Zalogowano jako {self.user}!")
# Inicjalizacja kanału przechowywania backupu
wlasciciel = (await self.application_info()).owner
self.owner_id = wlasciciel.id
self.backup_kanal = wlasciciel.dm_channel or await wlasciciel.create_dm()
if self.autosave:
await self.wczytaj() # Próba wczytania
else:
self.stan = StanBota()
self.invite_link = f"https://discord.com/api/oauth2/authorize?client_id={self.application_id}" \
f"&permissions=277025672192&scope=bot%20applications.commands"
await self._pobierz_informacje_z_githuba()
# Ładowanie rozszerzeń zawierających komendy bota
for ext in ("global", "dev", "edytor"):
self.load_extension("spis.komendy." + ext)
await self.sync_commands()
logger.info("Wczytywanie zakończone!")
# noinspection PyMethodMayBeStatic
async def on_guild_join(self, guild):
"""Wywoływane, gdy bota dodano do serwera"""
logger.info(f"Bot został dodany do serwera {guild!r}")
async def close(self):
"""Zamyka bota zapisując jego stan"""
if self.autosave:
await self.zapisz() # Próba zapisu
await super().close()