Skip to content

Commit 61d5cf4

Browse files
authored
Implement a new cog for error logging (#55)
1 parent 6dde43c commit 61d5cf4

File tree

11 files changed

+756
-4
lines changed

11 files changed

+756
-4
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from discord.ext.commands import Bot
2+
3+
from commanderbot.core.utils import add_configured_cog
4+
from commanderbot.ext.stacktracer.stacktracer_cog import StacktracerCog
5+
6+
7+
def setup(bot: Bot):
8+
add_configured_cog(bot, __name__, StacktracerCog)
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
from typing import Optional, cast
2+
3+
from discord import Color, Message, TextChannel, Thread, User
4+
from discord.ext import commands
5+
from discord.ext.commands import Bot, Cog, Context
6+
7+
from commanderbot.core.commander_bot_base import CommanderBotBase
8+
from commanderbot.ext.stacktracer.stacktracer_data import StacktracerData
9+
from commanderbot.ext.stacktracer.stacktracer_guild_state import StacktracerGuildState
10+
from commanderbot.ext.stacktracer.stacktracer_json_store import StacktracerJsonStore
11+
from commanderbot.ext.stacktracer.stacktracer_options import StacktracerOptions
12+
from commanderbot.ext.stacktracer.stacktracer_state import StacktracerState
13+
from commanderbot.ext.stacktracer.stacktracer_store import StacktracerStore
14+
from commanderbot.lib import (
15+
CogGuildStateManager,
16+
EventData,
17+
GuildContext,
18+
InMemoryDatabaseOptions,
19+
JsonFileDatabaseAdapter,
20+
JsonFileDatabaseOptions,
21+
UnsupportedDatabaseOptions,
22+
checks,
23+
)
24+
25+
26+
def _make_store(bot: Bot, cog: Cog, options: StacktracerOptions) -> StacktracerStore:
27+
db_options = options.database
28+
if isinstance(db_options, InMemoryDatabaseOptions):
29+
return StacktracerData()
30+
if isinstance(db_options, JsonFileDatabaseOptions):
31+
return StacktracerJsonStore(
32+
bot=bot,
33+
cog=cog,
34+
db=JsonFileDatabaseAdapter(
35+
options=db_options,
36+
serializer=lambda cache: cache.to_json(),
37+
deserializer=StacktracerData.from_data,
38+
),
39+
)
40+
raise UnsupportedDatabaseOptions(db_options)
41+
42+
43+
class StacktracerCog(Cog, name="commanderbot.ext.stacktracer"):
44+
"""
45+
Prints errors and stacktraces to a channel for staff to see.
46+
47+
Attributes
48+
----------
49+
bot
50+
The bot/client instance this cog is attached to.
51+
options
52+
Immutable, pre-defined settings that define core cog behaviour.
53+
store
54+
Abstracts the data storage and persistence of this cog.
55+
state
56+
Encapsulates the state and logic of this cog, for each guild.
57+
"""
58+
59+
def __init__(self, bot: Bot, **options):
60+
self.bot: Bot = bot
61+
self.bot = bot
62+
self.options = StacktracerOptions.from_data(options)
63+
self.store: StacktracerStore = _make_store(bot, self, self.options)
64+
self.state = StacktracerState(
65+
bot=self.bot,
66+
cog=self,
67+
guilds=CogGuildStateManager(
68+
bot=self.bot,
69+
cog=self,
70+
factory=lambda guild: StacktracerGuildState(
71+
bot=bot, cog=self, guild=guild, store=self.store
72+
),
73+
),
74+
store=self.store,
75+
)
76+
77+
# Register error handlers with the bot core.
78+
if isinstance(bot, CommanderBotBase):
79+
bot.add_event_error_handler(self.handle_event_error)
80+
bot.add_command_error_handler(self.handle_command_error)
81+
82+
async def handle_event_error(
83+
self, error: Exception, event_data: EventData, handled: bool
84+
) -> Optional[bool]:
85+
return await self.state.handle_event_error(error, event_data, handled)
86+
87+
async def handle_command_error(
88+
self, error: Exception, ctx: Context, handled: bool
89+
) -> Optional[bool]:
90+
return await self.state.handle_command_error(error, ctx, handled)
91+
92+
@Cog.listener()
93+
async def on_message_delete(self, message: Message):
94+
expected = f"{self.bot.command_prefix}stacktracer test"
95+
author = cast(User, message.author)
96+
if (message.content == expected) and await self.bot.is_owner(author):
97+
raise Exception("Testing the error logging configuration for events.")
98+
99+
# @@ COMMANDS
100+
101+
# @@ stacktracer
102+
103+
@commands.group(
104+
name="stacktracer",
105+
brief="Manage error logging globally and across guilds.",
106+
)
107+
@checks.is_guild_admin_or_bot_owner()
108+
async def cmd_stacktracer(self, ctx: GuildContext):
109+
if not ctx.invoked_subcommand:
110+
await ctx.send_help(self.cmd_stacktracer)
111+
112+
@cmd_stacktracer.command(
113+
name="test",
114+
brief="Test the error logging configuration for commands.",
115+
)
116+
async def cmd_stacktracer_test(self, ctx: GuildContext):
117+
raise Exception("Testing the error logging configuration for commands.")
118+
119+
# @@ stacktracer global
120+
121+
@cmd_stacktracer.group(
122+
name="global",
123+
brief="Manage global error logging.",
124+
)
125+
@checks.is_owner()
126+
async def cmd_stacktracer_global(self, ctx: Context):
127+
if not ctx.invoked_subcommand:
128+
if ctx.subcommand_passed:
129+
await ctx.send_help(self.cmd_stacktracer_global)
130+
else:
131+
await self.state.show_global_log_options(ctx)
132+
133+
@cmd_stacktracer_global.command(
134+
name="show",
135+
brief="Show the global error logging configuration.",
136+
)
137+
async def cmd_stacktracer_global_show(self, ctx: Context):
138+
await self.state.show_global_log_options(ctx)
139+
140+
@cmd_stacktracer_global.command(
141+
name="set",
142+
brief="Set the global error logging configuration.",
143+
)
144+
async def cmd_stacktracer_global_set(
145+
self,
146+
ctx: Context,
147+
channel: TextChannel | Thread,
148+
stacktrace: Optional[bool],
149+
emoji: Optional[str],
150+
color: Optional[Color],
151+
):
152+
await self.state.set_global_log_options(
153+
ctx,
154+
channel=channel,
155+
stacktrace=stacktrace,
156+
emoji=emoji,
157+
color=color,
158+
)
159+
160+
@cmd_stacktracer_global.command(
161+
name="remove",
162+
brief="Remove the global error logging configuration.",
163+
)
164+
async def cmd_stacktracer_global_remove(self, ctx: Context):
165+
await self.state.remove_global_log_options(ctx)
166+
167+
# @@ stacktracer guild
168+
169+
@cmd_stacktracer.group(
170+
name="guild",
171+
brief="Manage error logging for this guild.",
172+
)
173+
@checks.guild_only()
174+
async def cmd_stacktracer_guild(self, ctx: GuildContext):
175+
if not ctx.invoked_subcommand:
176+
if ctx.subcommand_passed:
177+
await ctx.send_help(self.cmd_stacktracer_guild)
178+
else:
179+
await self.state[ctx.guild].show_guild_log_options(ctx)
180+
181+
@cmd_stacktracer_guild.command(
182+
name="show",
183+
brief="Show the error logging configuration for this guild.",
184+
)
185+
async def cmd_stacktracer_guild_show(self, ctx: GuildContext):
186+
await self.state[ctx.guild].show_guild_log_options(ctx)
187+
188+
@cmd_stacktracer_guild.command(
189+
name="set",
190+
brief="Set the error logging configuration for this guild.",
191+
)
192+
async def cmd_stacktracer_guild_set(
193+
self,
194+
ctx: GuildContext,
195+
channel: TextChannel | Thread,
196+
stacktrace: Optional[bool],
197+
emoji: Optional[str],
198+
color: Optional[Color],
199+
):
200+
await self.state[ctx.guild].set_guild_log_options(
201+
ctx,
202+
channel=channel,
203+
stacktrace=stacktrace,
204+
emoji=emoji,
205+
color=color,
206+
)
207+
208+
@cmd_stacktracer_guild.command(
209+
name="remove",
210+
brief="Remove the error logging configuration for this guild.",
211+
)
212+
async def cmd_stacktracer_guild_remove(self, ctx: GuildContext):
213+
await self.state[ctx.guild].remove_guild_log_options(ctx)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from collections import defaultdict
2+
from dataclasses import dataclass, field
3+
from typing import Any, DefaultDict, Optional, Type, TypeVar
4+
5+
from discord import Guild
6+
7+
from commanderbot.lib import FromDataMixin, GuildID, JsonSerializable, LogOptions
8+
from commanderbot.lib.utils import dict_without_ellipsis, dict_without_falsies
9+
10+
ST = TypeVar("ST")
11+
12+
13+
@dataclass
14+
class StacktracerGuildData(JsonSerializable, FromDataMixin):
15+
log_options: Optional[LogOptions] = None
16+
17+
# @overrides FromDataMixin
18+
@classmethod
19+
def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]:
20+
if isinstance(data, dict):
21+
log_options = LogOptions.from_field_optional(data, "log")
22+
return cls(log_options=log_options)
23+
24+
# @implements JsonSerializable
25+
def to_json(self) -> Any:
26+
# Omit empty log options.
27+
data = dict_without_ellipsis(log=self.log_options or ...)
28+
29+
return data
30+
31+
def set_log_options(
32+
self, log_options: Optional[LogOptions]
33+
) -> Optional[LogOptions]:
34+
old_value = self.log_options
35+
self.log_options = log_options
36+
return old_value
37+
38+
39+
def _guilds_defaultdict_factory() -> DefaultDict[GuildID, StacktracerGuildData]:
40+
return defaultdict(lambda: StacktracerGuildData())
41+
42+
43+
# @implements StacktracerStore
44+
@dataclass
45+
class StacktracerData(JsonSerializable, FromDataMixin):
46+
"""
47+
Implementation of `StacktracerStore` using an in-memory object hierarchy.
48+
"""
49+
50+
# Global log options configured by bot owners.
51+
log_options: Optional[LogOptions] = None
52+
53+
# Per-guild log options configured by admins (or owners).
54+
guilds: DefaultDict[GuildID, StacktracerGuildData] = field(
55+
default_factory=_guilds_defaultdict_factory
56+
)
57+
58+
# @overrides FromDataMixin
59+
@classmethod
60+
def try_from_data(cls: Type[ST], data: Any) -> Optional[ST]:
61+
if isinstance(data, dict):
62+
# Construct global log options.
63+
log_options = LogOptions.from_field_optional(data, "log_options")
64+
65+
# Construct guild data.
66+
guilds = _guilds_defaultdict_factory()
67+
for raw_guild_id, raw_guild_data in data.get("guilds", {}).items():
68+
guild_id = int(raw_guild_id)
69+
guilds[guild_id] = StacktracerGuildData.from_data(raw_guild_data)
70+
71+
return cls(
72+
log_options=log_options,
73+
guilds=guilds,
74+
)
75+
76+
# @implements JsonSerializable
77+
def to_json(self) -> Any:
78+
guilds = {
79+
str(guild_id): guild_data.to_json()
80+
for guild_id, guild_data in self.guilds.items()
81+
}
82+
83+
# Omit empty guilds.
84+
trimmed_guilds = dict_without_falsies(guilds)
85+
86+
# Omit empty fields.
87+
data = dict_without_ellipsis(
88+
log_options=self.log_options or ...,
89+
guilds=trimmed_guilds or ...,
90+
)
91+
92+
return data
93+
94+
# @implements StacktracerStore
95+
async def get_global_log_options(self) -> Optional[LogOptions]:
96+
return self.log_options
97+
98+
# @implements StacktracerStore
99+
async def set_global_log_options(
100+
self, log_options: Optional[LogOptions]
101+
) -> Optional[LogOptions]:
102+
old_value = self.log_options
103+
self.log_options = log_options
104+
return old_value
105+
106+
# @implements StacktracerStore
107+
async def get_guild_log_options(self, guild: Guild) -> Optional[LogOptions]:
108+
return self.guilds[guild.id].log_options
109+
110+
# @implements StacktracerStore
111+
async def set_guild_log_options(
112+
self, guild: Guild, log_options: Optional[LogOptions]
113+
) -> Optional[LogOptions]:
114+
return self.guilds[guild.id].set_log_options(log_options)

0 commit comments

Comments
 (0)