easterobot 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. easterobot/__init__.py +19 -0
  2. easterobot/__main__.py +6 -0
  3. easterobot/bot.py +584 -0
  4. easterobot/cli.py +127 -0
  5. easterobot/commands/__init__.py +28 -0
  6. easterobot/commands/base.py +171 -0
  7. easterobot/commands/basket.py +99 -0
  8. easterobot/commands/disable.py +29 -0
  9. easterobot/commands/edit.py +68 -0
  10. easterobot/commands/enable.py +35 -0
  11. easterobot/commands/help.py +33 -0
  12. easterobot/commands/reset.py +121 -0
  13. easterobot/commands/search.py +127 -0
  14. easterobot/commands/top.py +105 -0
  15. easterobot/config.py +401 -0
  16. easterobot/info.py +18 -0
  17. easterobot/logger.py +16 -0
  18. easterobot/models.py +58 -0
  19. easterobot/py.typed +1 -0
  20. easterobot/resources/config.example.yml +226 -0
  21. easterobot/resources/credits.txt +1 -0
  22. easterobot/resources/eggs/egg_01.png +0 -0
  23. easterobot/resources/eggs/egg_02.png +0 -0
  24. easterobot/resources/eggs/egg_03.png +0 -0
  25. easterobot/resources/eggs/egg_04.png +0 -0
  26. easterobot/resources/eggs/egg_05.png +0 -0
  27. easterobot/resources/eggs/egg_06.png +0 -0
  28. easterobot/resources/eggs/egg_07.png +0 -0
  29. easterobot/resources/eggs/egg_08.png +0 -0
  30. easterobot/resources/eggs/egg_09.png +0 -0
  31. easterobot/resources/eggs/egg_10.png +0 -0
  32. easterobot/resources/eggs/egg_11.png +0 -0
  33. easterobot/resources/eggs/egg_12.png +0 -0
  34. easterobot/resources/eggs/egg_13.png +0 -0
  35. easterobot/resources/eggs/egg_14.png +0 -0
  36. easterobot/resources/eggs/egg_15.png +0 -0
  37. easterobot/resources/eggs/egg_16.png +0 -0
  38. easterobot/resources/eggs/egg_17.png +0 -0
  39. easterobot/resources/eggs/egg_18.png +0 -0
  40. easterobot/resources/eggs/egg_19.png +0 -0
  41. easterobot/resources/eggs/egg_20.png +0 -0
  42. easterobot/resources/logging.conf +47 -0
  43. easterobot/resources/logo.png +0 -0
  44. easterobot-1.0.0.dist-info/METADATA +242 -0
  45. easterobot-1.0.0.dist-info/RECORD +48 -0
  46. easterobot-1.0.0.dist-info/WHEEL +4 -0
  47. easterobot-1.0.0.dist-info/entry_points.txt +2 -0
  48. easterobot-1.0.0.dist-info/licenses/LICENSE +21 -0
easterobot/config.py ADDED
@@ -0,0 +1,401 @@
1
+ """Main program."""
2
+
3
+ import os
4
+ import pathlib
5
+ import random
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Iterable
8
+ from typing import (
9
+ Any,
10
+ Generic,
11
+ Literal,
12
+ Optional,
13
+ TypeVar,
14
+ Union,
15
+ cast,
16
+ )
17
+
18
+ import discord
19
+ import msgspec
20
+ from typing_extensions import TypeGuard, get_args, get_origin, override
21
+
22
+ RAND = random.SystemRandom()
23
+
24
+ T = TypeVar("T")
25
+ V = TypeVar("V")
26
+ Members = Union[discord.Member, list[discord.Member]]
27
+ RESOURCES = pathlib.Path(__file__).parent.resolve() / "resources"
28
+
29
+
30
+ class Serializable(ABC, Generic[V]):
31
+ _decodable_flag = True
32
+
33
+ @abstractmethod
34
+ def encode(self) -> V:
35
+ """Encode current object of msgspec."""
36
+
37
+ @classmethod
38
+ @abstractmethod
39
+ def decode(cls: type[T], args: tuple[Any, ...], obj: V) -> T:
40
+ """Encode current object of msgspec."""
41
+
42
+ @staticmethod
43
+ def decodable(typ: type[Any]) -> TypeGuard["type[Serializable[T]]"]:
44
+ """Check if a class is decodable."""
45
+ return hasattr(typ, "_decodable_flag")
46
+
47
+
48
+ class ConjugableText(Serializable[str]):
49
+ __slots__ = ("_conjugation", "_text")
50
+
51
+ def __init__(self, text: str):
52
+ """Create a conjugable text."""
53
+ self._text = text
54
+ self._conjugation: Conjugation = {}
55
+
56
+ def __str__(self) -> str:
57
+ """Get the string representation."""
58
+ return f"<{self.__class__.__name__} {self._text!r}>"
59
+
60
+ __repr__ = __str__
61
+
62
+ @override
63
+ def encode(self) -> str:
64
+ return self._text
65
+
66
+ @override
67
+ @classmethod
68
+ def decode(cls, typ: tuple[Any, ...], obj: str) -> "ConjugableText":
69
+ return cls(obj)
70
+
71
+ @staticmethod
72
+ def gender(member: discord.Member) -> Literal["man", "woman"]:
73
+ """Get the gender of a people."""
74
+ if any(
75
+ marker in role.name.casefold()
76
+ for role in member.roles
77
+ for marker in (
78
+ "woman",
79
+ "girl",
80
+ "femme",
81
+ "fille",
82
+ "elle",
83
+ "her",
84
+ "she",
85
+ )
86
+ ):
87
+ return "woman"
88
+ return "man"
89
+
90
+ def attach(self, conjugation: "Conjugation") -> None:
91
+ """Attach conjugation to the text."""
92
+ self._conjugation = conjugation
93
+
94
+ def __call__(self, members: Members) -> str:
95
+ """Conjugate the text."""
96
+ if isinstance(members, discord.Member):
97
+ members = [members]
98
+ if not members:
99
+ gender = "man"
100
+ else:
101
+ for member in members:
102
+ if self.gender(member) == "man":
103
+ gender = "man"
104
+ break
105
+ else:
106
+ gender = "woman"
107
+ text = self._text
108
+ for term, versions in self._conjugation.items():
109
+ word = versions[gender]
110
+ text = text.replace("{" + term.lower() + "}", word.lower())
111
+ text = text.replace("{" + term.upper() + "}", word.upper())
112
+ text = text.replace("{" + term.title() + "}", word.title())
113
+ return text.replace("{user}", f"<@{member.id}>")
114
+
115
+
116
+ class RandomItem(
117
+ Serializable[list[T]], # Stored form
118
+ ):
119
+ __slots__ = ("choices",)
120
+
121
+ def __str__(self) -> str:
122
+ """Get the string representation."""
123
+ return f"<{self.__class__.__name__} {self.choices!r}>"
124
+
125
+ __repr__ = __str__
126
+
127
+ def __init__(self, choices: Optional[Iterable[T]] = None):
128
+ """Create RandomItem."""
129
+ self.choices = list(choices) if choices is not None else []
130
+
131
+ @override
132
+ def encode(self) -> list[T]:
133
+ return self.choices
134
+
135
+ @override
136
+ @classmethod
137
+ def decode(cls, args: tuple[Any, ...], obj: list[T]) -> "RandomItem[T]":
138
+ return cls(convert(obj, typ=list[args[0]])) # type: ignore[valid-type]
139
+
140
+ def rand(self) -> T:
141
+ """Get a random choice."""
142
+ return RAND.choice(self.choices)
143
+
144
+
145
+ class RandomConjugableText(RandomItem[ConjugableText]):
146
+ def __call__(self, members: Members) -> str:
147
+ """Conjugate a random item."""
148
+ return self.rand()(members)
149
+
150
+ @override
151
+ @classmethod
152
+ def decode(
153
+ cls, args: tuple[Any, ...], obj: list[ConjugableText]
154
+ ) -> "RandomConjugableText":
155
+ return cls(convert(obj, typ=list[ConjugableText]))
156
+
157
+
158
+ class MCooldown(msgspec.Struct):
159
+ min: float
160
+ max: float
161
+
162
+ def rand(self) -> float:
163
+ """Randomize a min to max."""
164
+ return self.min + RAND.random() * (self.max - self.min)
165
+
166
+
167
+ class MWeights(msgspec.Struct):
168
+ egg: float
169
+ speed: float
170
+
171
+
172
+ class MHunt(msgspec.Struct):
173
+ timeout: float
174
+ cooldown: MCooldown
175
+ weights: MWeights
176
+
177
+
178
+ class MCommand(msgspec.Struct):
179
+ cooldown: float
180
+
181
+
182
+ class MDiscovered(msgspec.Struct):
183
+ shield: int
184
+ min: float
185
+ max: float
186
+
187
+
188
+ class MSpotted(msgspec.Struct):
189
+ shield: int
190
+ min: float
191
+ max: float
192
+
193
+
194
+ class SearchCommand(MCommand):
195
+ discovered: MDiscovered
196
+ spotted: MSpotted
197
+
198
+
199
+ class MGender(msgspec.Struct):
200
+ woman: str = ""
201
+ man: str = ""
202
+
203
+ def __getitem__(self, key: str) -> str:
204
+ """Get text."""
205
+ return getattr(self, key, "")
206
+
207
+
208
+ class MEmbed(msgspec.Struct):
209
+ text: ConjugableText
210
+ gif: str
211
+
212
+
213
+ class MText(msgspec.Struct):
214
+ text: str
215
+ success: MEmbed
216
+ fail: MEmbed
217
+
218
+
219
+ Conjugation = dict[str, MGender]
220
+
221
+
222
+ class MCommands(msgspec.Struct):
223
+ search: SearchCommand
224
+ top: MCommand
225
+ basket: MCommand
226
+ reset: MCommand
227
+ enable: MCommand
228
+ disable: MCommand
229
+ help: MCommand
230
+ edit: MCommand
231
+
232
+ def __getitem__(self, key: str, /) -> MCommand:
233
+ """Get a command."""
234
+ if key not in self.__struct_fields__:
235
+ raise KeyError(key)
236
+ try:
237
+ result = getattr(self, key)
238
+ if not isinstance(result, MCommand):
239
+ raise KeyError(key)
240
+ except AttributeError:
241
+ raise KeyError(key) from None
242
+ return result
243
+
244
+
245
+ class MConfig(msgspec.Struct, dict=True):
246
+ owner_is_admin: bool
247
+ use_logging_file: bool
248
+ admins: list[int]
249
+ database: str
250
+ group: str
251
+ hunt: MHunt
252
+ conjugation: Conjugation
253
+ failed: RandomConjugableText
254
+ hidden: RandomConjugableText
255
+ spotted: RandomConjugableText
256
+ appear: RandomItem[str]
257
+ action: RandomItem[MText]
258
+ commands: MCommands
259
+ _resources: Optional[Union[pathlib.Path, msgspec.UnsetType]] = (
260
+ msgspec.field(name="resources", default=msgspec.UNSET)
261
+ )
262
+ _working_directory: Optional[Union[pathlib.Path, msgspec.UnsetType]] = (
263
+ msgspec.field(name="working_directory", default=msgspec.UNSET)
264
+ )
265
+ token: Optional[Union[str, msgspec.UnsetType]] = msgspec.UNSET
266
+
267
+ def verified_token(self) -> str:
268
+ """Get the safe token."""
269
+ if self.token is None or self.token is msgspec.UNSET:
270
+ error_message = "Token was not provided"
271
+ raise TypeError(error_message)
272
+ if "." not in self.token:
273
+ error_message = "Wrong token format"
274
+ raise ValueError(error_message)
275
+ return self.token
276
+
277
+ def attach_default_working_directory(
278
+ self,
279
+ path: Union[pathlib.Path, str],
280
+ ) -> None:
281
+ """Attach working directory."""
282
+ self._cwd = pathlib.Path(path)
283
+
284
+ @property
285
+ def working_directory(self) -> pathlib.Path:
286
+ """Get the safe token."""
287
+ if (
288
+ self._working_directory is None
289
+ or self._working_directory is msgspec.UNSET
290
+ ):
291
+ if hasattr(self, "_cwd"):
292
+ return self._cwd.resolve()
293
+ return pathlib.Path.cwd().resolve()
294
+ return self._working_directory.resolve()
295
+
296
+ @property
297
+ def resources(self) -> pathlib.Path:
298
+ """Get path to resources or the embed resources if not configured."""
299
+ if self._resources is None or self._resources is msgspec.UNSET:
300
+ return RESOURCES
301
+ if self._resources.is_absolute():
302
+ return self._resources
303
+ return self.working_directory / self._resources
304
+
305
+ def __post_init__(self) -> None:
306
+ """Add conjugation to item and check some value."""
307
+ for conjugable in self.failed.choices:
308
+ conjugable.attach(self.conjugation)
309
+ for conjugable in self.hidden.choices:
310
+ conjugable.attach(self.conjugation)
311
+ for conjugable in self.spotted.choices:
312
+ conjugable.attach(self.conjugation)
313
+ for choice in self.action.choices:
314
+ choice.success.text.attach(self.conjugation)
315
+ choice.fail.text.attach(self.conjugation)
316
+
317
+ def conjugate(self, text: str, member: discord.Member) -> str:
318
+ """Conjugate the text."""
319
+ conj = ConjugableText(text)
320
+ conj.attach(self.conjugation)
321
+ return conj(member)
322
+
323
+
324
+ def _dec_hook(typ: type[T], obj: Any) -> T:
325
+ # Get the base type
326
+ origin: Optional[type[T]] = get_origin(typ)
327
+ if origin is None:
328
+ origin = typ
329
+ args = get_args(typ)
330
+ if issubclass(origin, discord.PartialEmoji):
331
+ return discord.PartialEmoji( # type: ignore[return-value]
332
+ name="_", animated=False, id=obj
333
+ )
334
+ if issubclass(origin, pathlib.Path):
335
+ return cast(T, pathlib.Path(obj))
336
+ if Serializable.decodable(origin):
337
+ return cast(T, origin.decode(args, obj))
338
+ error_message = f"Invalid type {typ!r} for {obj!r}"
339
+ raise TypeError(error_message)
340
+
341
+
342
+ def _enc_hook(obj: Any) -> Any:
343
+ if isinstance(obj, discord.PartialEmoji):
344
+ return obj.id
345
+ if isinstance(obj, pathlib.Path):
346
+ return str(obj)
347
+ if isinstance(obj, Serializable):
348
+ return obj.encode()
349
+ error_message = f"Invalid object {obj!r}"
350
+ raise TypeError(error_message)
351
+
352
+
353
+ def load_yaml(data: Union[bytes, str], typ: type[T]) -> T:
354
+ """Load YAML."""
355
+ return msgspec.yaml.decode( # type: ignore[no-any-return,unused-ignore]
356
+ data, type=typ, dec_hook=_dec_hook
357
+ )
358
+
359
+
360
+ def dump_yaml(obj: Any) -> bytes:
361
+ """Load YAML."""
362
+ return msgspec.yaml.encode( # type: ignore[no-any-return,unused-ignore]
363
+ obj, enc_hook=_enc_hook
364
+ )
365
+
366
+
367
+ def convert(obj: Any, typ: type[T]) -> T:
368
+ """Convert object."""
369
+ return msgspec.convert( # type: ignore[no-any-return,unused-ignore]
370
+ obj, type=typ, dec_hook=_dec_hook
371
+ )
372
+
373
+
374
+ def load_config(
375
+ data: Union[bytes, str],
376
+ token: Optional[str] = None,
377
+ *,
378
+ env: bool = False,
379
+ ) -> MConfig:
380
+ """Load config."""
381
+ config = load_yaml(data, MConfig)
382
+ if env:
383
+ potential_token = os.environ.get("DISCORD_TOKEN")
384
+ if potential_token is not None:
385
+ config.token = potential_token
386
+ if token is not None:
387
+ config.token = token
388
+ return config
389
+
390
+
391
+ def agree(
392
+ singular: str,
393
+ plural: str,
394
+ /,
395
+ amount: Optional[int],
396
+ *args: Any,
397
+ ) -> str:
398
+ """Agree the text to the text."""
399
+ if amount is None or amount in (-1, 0, 1):
400
+ return singular.format(amount, *args)
401
+ return plural.format(amount, *args)
easterobot/info.py ADDED
@@ -0,0 +1,18 @@
1
+ """Module holding metadata."""
2
+
3
+ from importlib.metadata import Distribution
4
+
5
+ _DISTRIBUTION = Distribution.from_name(
6
+ "easterobot",
7
+ )
8
+ _METADATA = _DISTRIBUTION.metadata
9
+
10
+ if "Author" in _METADATA:
11
+ __author__ = str(_METADATA["Author"])
12
+ __email__ = str(_METADATA["Author-email"])
13
+ else:
14
+ __author__, __email__ = _METADATA["Author-email"][:-1].split(" <", 1)
15
+ __version__ = _METADATA["Version"]
16
+ __summary__ = _METADATA["Summary"]
17
+ __copyright__ = f"{__author__} <{__email__}>"
18
+ __issues__ = "https://github.com/Dashstrom/easterobot/issues"
easterobot/logger.py ADDED
@@ -0,0 +1,16 @@
1
+ """Module for logging stuff."""
2
+
3
+ import pathlib
4
+ from logging.handlers import RotatingFileHandler
5
+ from pathlib import Path
6
+ from typing import Any, Union
7
+
8
+
9
+ class AutoDirRotatingFileHandler(RotatingFileHandler):
10
+ def __init__(
11
+ self, filename: Union[str, pathlib.Path], *args: Any, **kwargs: Any
12
+ ) -> None:
13
+ """Show logger."""
14
+ path = Path(filename)
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ super().__init__(filename, *args, **kwargs)
easterobot/models.py ADDED
@@ -0,0 +1,58 @@
1
+ """Module for models."""
2
+
3
+ from sqlalchemy import BigInteger, Integer
4
+ from sqlalchemy.orm import (
5
+ DeclarativeBase,
6
+ Mapped,
7
+ mapped_column,
8
+ )
9
+
10
+ DISCORD_URL = "https://discord.com/channels"
11
+
12
+
13
+ class Base(DeclarativeBase):
14
+ pass
15
+
16
+
17
+ class Egg(Base):
18
+ __tablename__ = "egg"
19
+ id: Mapped[int] = mapped_column(
20
+ BigInteger().with_variant(Integer, "sqlite"),
21
+ primary_key=True,
22
+ autoincrement=True,
23
+ )
24
+ guild_id: Mapped[int] = mapped_column(
25
+ BigInteger, nullable=False, index=True
26
+ )
27
+ channel_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
28
+ user_id: Mapped[int] = mapped_column(
29
+ BigInteger, nullable=False, index=True
30
+ )
31
+ emoji_id: Mapped[int] = mapped_column(BigInteger, nullable=True)
32
+
33
+ @property
34
+ def jump_url(self) -> str:
35
+ """Url to jump to the egg."""
36
+ guild_id = self.guild_id or "@me"
37
+ return f"{DISCORD_URL}/{guild_id}/{self.channel_id}/{self.id}"
38
+
39
+
40
+ class Hunt(Base):
41
+ __tablename__ = "hunt"
42
+ channel_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
43
+ guild_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
44
+ next_egg: Mapped[float] = mapped_column(default=0.0)
45
+
46
+ @property
47
+ def jump_url(self) -> str:
48
+ """Url to jump to an hunt."""
49
+ guild_id = self.guild_id or "@me"
50
+ return f"{DISCORD_URL}/{guild_id}/{self.channel_id}"
51
+
52
+
53
+ class Cooldown(Base):
54
+ __tablename__ = "cooldown"
55
+ user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
56
+ guild_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
57
+ command: Mapped[str] = mapped_column(primary_key=True)
58
+ timestamp: Mapped[float] = mapped_column(default=0.0)
easterobot/py.typed ADDED
@@ -0,0 +1 @@
1
+ # https://peps.python.org/pep-0561/#packaging-type-information