-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhandler.py
More file actions
237 lines (195 loc) · 9.13 KB
/
handler.py
File metadata and controls
237 lines (195 loc) · 9.13 KB
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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""Python logging support for Discord."""
import logging
import sys
from typing import Optional, List
from discord_webhook import DiscordEmbed, DiscordWebhook
#: The default log level colors as hexacimal, converted int
from requests import Response
DEFAULT_COLOURS = {
None: 2040357, # Unknown log level
logging.CRITICAL: 14362664, # Red
logging.ERROR: 14362664, # Red
logging.WARNING: 16497928, # Yellow
logging.INFO: 2196944, # Blue
logging.DEBUG: 8947848, # Gray
}
#: The default log emojis as
DEFAULT_EMOJIS = {
None: "", # Unknown log level
logging.CRITICAL: "🆘 ",
logging.ERROR: "❌ ",
logging.WARNING: "⚠️ ",
logging.INFO: "",
logging.DEBUG: "",
}
class DiscordHandler(logging.Handler):
"""Output logs to Discord chat.
A handler class which writes logging records, appropriately formatted,
to a Discord Server using webhooks.
"""
def __init__(self,
service_name: str,
webhook_url: str,
colours=DEFAULT_COLOURS,
emojis=DEFAULT_EMOJIS,
avatar_url: Optional[str] = None,
rate_limit_retry: bool = True,
embed_line_wrap_threshold: int = 60,
message_break_char: Optional[str] = None,
discord_timeout: float = 5.0):
"""
:param service_name: Shows at the bot username in Discord.
:param webhook_url: Channel webhook URL. See README for details.
:param colours: Log level to Discord embed color mapping
:param emojis:
Log level to emoticon decoration mapping.
If present this is appended as a prefix to the first line of the log.
:param avatar_url: Bot profile picture
:param rate_limit_retry: Try to recover when Discord server tells us to slow down
:param embed_line_wrap_threshold:
How many characters a text line can contain until we go to "long line" output format.
:param message_break_char:
If given, manually split log entry text to several Discord messages by this character.
Useful if your application output long custom messages.
For example, you can use ellipsis `…` in your log message to split in several Discord
messages to overcome the 2000 character limitation.
:param discord_timeout:
How many seconds to wait before giving up on Discord request.
"""
logging.Handler.__init__(self)
self.webhook_url = webhook_url
self.service_name = service_name
self.colours = colours
self.emojis = emojis
self.rate_limit_retry = rate_limit_retry
self.avatar_url = avatar_url
self.reentry_barrier = False
self.embed_line_wrap_threshold = embed_line_wrap_threshold
self.message_break_char = message_break_char
self.discord_timeout = discord_timeout
def should_format_as_code_block(self, record: logging.LogRecord, msg: str) -> bool:
"""Figure out whether we want to use code block formatting in Discord.
Check for new lines and long lines in the log message.
"""
if "\n" not in msg:
if len(msg) > self.embed_line_wrap_threshold:
return True
return "\n" in msg
def clip_content(self, content: str, max_len=1900, clip_to_end=True) -> str:
"""Make sure the text fits to a Discord message.
Discord max message length is 2000 chars.
"""
if len(content) > max_len - 5:
if clip_to_end:
return "..." + content[-max_len:]
else:
return content[0:max_len] + "..."
else:
return content
def split_by_break_character(self, content: str) -> List[str]:
"""Split the inbound log message to several Discord messages.
Uses manual break character method if set. If not set,
then do nothing.
"""
if self.message_break_char:
return content.split(self.message_break_char)
else:
return [content]
def attempt_to_report_failure(self, resp: Response, orignal: DiscordWebhook):
"""Attempt to report a failure to deliver a log message.
Usually this happens if we pass content to Discord the server does not like.
More information
- https://stackoverflow.com/questions/53935198/in-my-discord-webhook-i-am-getting-the-error-embeds-0
"""
# Output to the stderr, as it is not safe to use logging here
#
print(
f"Discord webhook request failed: {resp.status_code}: {resp.content}. Payload content was: {orignal.content}, embeds: {orignal.embeds}", file=sys.stderr)
# Attempt to warn user about log failure
discord = DiscordWebhook(
url=self.webhook_url,
username=self.service_name,
rate_limit_retry=self.rate_limit_retry,
avatar_url=self.avatar_url,
timeout=self.discord_timeout,
)
discord.content = f"Failed to deliver log message: {resp.status_code}: {resp.content}"
discord.execute()
def emit(self, record: logging.LogRecord):
"""Send a log entry to Discord."""
if self.reentry_barrier:
# Don't let Discord and request internals to cause logging
# and thus infinite recursion. This is because the underlying
# requests package itself uses logging.
return
self.reentry_barrier = True
try:
# About the Embed footer trick
# https://stackoverflow.com/a/65543555/315168
try:
# Run internal log message formatting that will expand %s, %d and such
inbound_msg = self.format(record)
# Choose colour and emoji for this log record
colour = self.colours.get(record.levelno) or self.colours[None]
emoji = self.emojis.get(record.levelno, "")
if emoji:
# Add some space before the next char
emoji += " "
split_msgs = self.split_by_break_character(inbound_msg)
for msg in split_msgs:
if not msg:
# Don't attempt to deliver empty messages
continue
# Start preparing the webhook payload for this messgae
discord = DiscordWebhook(
url=self.webhook_url,
username=self.service_name,
rate_limit_retry=self.rate_limit_retry,
avatar_url=self.avatar_url,
timeout=self.discord_timeout,
)
# Should we use Markdown code blocks for ths message?
if self.should_format_as_code_block(record, msg):
try:
first, remainder = msg.split("\n", maxsplit=1)
except ValueError:
first = msg
remainder = ""
# Find our longest line lenght
max_line_length = max([len(l)
for l in msg.split("\n")])
# Truncate the message to its last 2000 chars / bottom lines
clipped = self.clip_content(remainder)
if max_line_length > self.embed_line_wrap_threshold:
# We have long lines, need to use Markdown
clipped_msg = self.clip_content(msg)
discord.content = f"```{emoji}{clipped_msg}```"
else:
# We have a clipped content, but short lines
embed = DiscordEmbed(
title=f"{emoji}{first}", description=clipped, color=colour)
discord.add_embed(embed)
else:
# This message should be straight forward
if emoji:
title = f"{emoji}{msg}"
else:
title = msg
embed = DiscordEmbed(title=title, color=colour)
discord.add_embed(embed)
# Can be one or list of responses,
# bad API design
resp = discord.execute()
assert isinstance(
resp, Response), f"Discord webhook replies: {resp}"
if resp.status_code != 200:
self.attempt_to_report_failure(resp, discord)
except Exception as e:
# We cannot use handleError here, because Discord request may cause
# infinite recursion when Discord connection fails and
# it tries to log.
# We fall back to writing the error to stderr
print(f"Error from Discord logger {e}", file=sys.stderr)
self.handleError(record)
finally:
self.reentry_barrier = False