mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-11 11:06:24 +00:00
Discord channel: split oversized digests across embeds (#220)
A mass-backup webhook that exceeded ~2 KB used to be silently truncated by `desc = message[:MAX_EMBED_DESC]` with MAX_EMBED_DESC set to 2048 — half of Discord's real description limit and far below what a multi-VM backup digest produces. The trailing jobs just vanished from the channel. Bring the channel up to Discord's actual webhook contract: * description limit raised to the real 4096-char cap * if the body still doesn't fit, split it on line boundaries into one embed per chunk so every backup entry is preserved * keep title + fields on the first embed only; attach the footer and timestamp to the last embed so the rendered card has the normal head/tail framing even when split across many embeds * enforce Discord's 6000-char-per-embed cap (title + description + every field name+value) — only kicks in when many large fields combine with a chunk already near the description ceiling * batch up to 10 embeds per webhook POST (Discord's per-message limit) and POST additional messages sequentially with a 0.4 s gap so a >10-embed digest doesn't trip the 5/2 s webhook rate limit Verified with synthetic mass-backup payloads: * 14 KB / 200 jobs → 4 embeds, 1 POST * 60 KB / 60 lines → 15 embeds, 2 POSTs (10 + 5) New AppImage SHA-256: 16ad59ea63a64e5be460cd73f87315e8b39b756bf1c61f3cb2019e9fa3e76361 Closes #220. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
|||||||
3b44eb1172b4b1b7e6a36d1c9f1cd5a237ec04d52543bb791358525b0653a402
|
16ad59ea63a64e5be460cd73f87315e8b39b756bf1c61f3cb2019e9fa3e76361
|
||||||
|
|||||||
@@ -397,7 +397,13 @@ class GotifyChannel(NotificationChannel):
|
|||||||
class DiscordChannel(NotificationChannel):
|
class DiscordChannel(NotificationChannel):
|
||||||
"""Discord webhook channel with color-coded embeds."""
|
"""Discord webhook channel with color-coded embeds."""
|
||||||
|
|
||||||
MAX_EMBED_DESC = 2048
|
# Discord webhook hard limits (https://discord.com/developers/docs/resources/channel#embed-object-embed-limits)
|
||||||
|
MAX_EMBED_DESC = 4096 # per embed description
|
||||||
|
MAX_EMBED_TITLE = 256 # per embed title
|
||||||
|
MAX_FIELD_VALUE = 1024 # per field value
|
||||||
|
MAX_FIELDS = 25 # per embed
|
||||||
|
MAX_EMBED_TOTAL = 6000 # title + desc + every field name+value, per embed
|
||||||
|
MAX_EMBEDS_PER_MSG = 10 # per webhook POST
|
||||||
|
|
||||||
SEVERITY_COLORS = {
|
SEVERITY_COLORS = {
|
||||||
'CRITICAL': 0xED4245, # red
|
'CRITICAL': 0xED4245, # red
|
||||||
@@ -436,43 +442,125 @@ class DiscordChannel(NotificationChannel):
|
|||||||
return False, 'Invalid Discord webhook URL (path must be /api/webhooks/...)'
|
return False, 'Invalid Discord webhook URL (path must be /api/webhooks/...)'
|
||||||
return True, ''
|
return True, ''
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _split_description(cls, text: str) -> List[str]:
|
||||||
|
"""Split `text` into chunks ≤ MAX_EMBED_DESC, preferring line breaks.
|
||||||
|
|
||||||
|
Mass-backup digests issued by /api/notifications used to be capped
|
||||||
|
with `message[:2048]`, which silently dropped everything past the
|
||||||
|
cut and lost backup results for the trailing VMs/CTs (#220). The
|
||||||
|
new flow builds one embed per chunk so Discord renders the whole
|
||||||
|
digest. Splitting at "\n" keeps each entry intact; if a single
|
||||||
|
line still exceeds the limit (rare — only if a log line is
|
||||||
|
pathologically long) we fall back to a hard slice.
|
||||||
|
"""
|
||||||
|
if len(text) <= cls.MAX_EMBED_DESC:
|
||||||
|
return [text]
|
||||||
|
chunks: List[str] = []
|
||||||
|
current = ''
|
||||||
|
for line in text.splitlines(keepends=True):
|
||||||
|
if len(line) > cls.MAX_EMBED_DESC:
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
current = ''
|
||||||
|
# hard-slice the oversized line
|
||||||
|
for i in range(0, len(line), cls.MAX_EMBED_DESC):
|
||||||
|
chunks.append(line[i:i + cls.MAX_EMBED_DESC])
|
||||||
|
continue
|
||||||
|
if len(current) + len(line) > cls.MAX_EMBED_DESC:
|
||||||
|
chunks.append(current)
|
||||||
|
current = line
|
||||||
|
else:
|
||||||
|
current += line
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
return chunks
|
||||||
|
|
||||||
def send(self, title: str, message: str, severity: str = 'INFO',
|
def send(self, title: str, message: str, severity: str = 'INFO',
|
||||||
data: Optional[Dict] = None) -> Dict[str, Any]:
|
data: Optional[Dict] = None) -> Dict[str, Any]:
|
||||||
color = self.SEVERITY_COLORS.get(severity, 0x5865F2)
|
color = self.SEVERITY_COLORS.get(severity, 0x5865F2)
|
||||||
|
|
||||||
desc = message[:self.MAX_EMBED_DESC] if len(message) > self.MAX_EMBED_DESC else message
|
title = (title or '')[:self.MAX_EMBED_TITLE]
|
||||||
|
chunks = self._split_description(message or '')
|
||||||
|
timestamp = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||||
|
|
||||||
embed = {
|
# Build fields once; they only attach to the FIRST embed because
|
||||||
'title': title,
|
# Discord's 6000-char-per-embed budget makes repeating them on
|
||||||
'description': desc,
|
# every chunk wasteful, and visually the metadata only needs to
|
||||||
'color': color,
|
# appear once at the head of the message.
|
||||||
'footer': {'text': 'ProxMenux Monitor'},
|
fields: List[Dict[str, Any]] = []
|
||||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use structured fields from render_template if available
|
|
||||||
rendered_fields = (data or {}).get('_rendered_fields', [])
|
rendered_fields = (data or {}).get('_rendered_fields', [])
|
||||||
if rendered_fields:
|
if rendered_fields:
|
||||||
embed['fields'] = [
|
fields = [
|
||||||
{'name': name, 'value': val[:1024], 'inline': True}
|
{'name': name, 'value': val[:self.MAX_FIELD_VALUE], 'inline': True}
|
||||||
for name, val in rendered_fields[:25] # Discord limit: 25 fields
|
for name, val in rendered_fields[:self.MAX_FIELDS]
|
||||||
]
|
]
|
||||||
elif data:
|
elif data:
|
||||||
fields = []
|
|
||||||
if data.get('category'):
|
if data.get('category'):
|
||||||
fields.append({'name': 'Category', 'value': data['category'], 'inline': True})
|
fields.append({'name': 'Category', 'value': data['category'], 'inline': True})
|
||||||
if data.get('hostname'):
|
if data.get('hostname'):
|
||||||
fields.append({'name': 'Host', 'value': data['hostname'], 'inline': True})
|
fields.append({'name': 'Host', 'value': data['hostname'], 'inline': True})
|
||||||
if data.get('severity'):
|
if data.get('severity'):
|
||||||
fields.append({'name': 'Severity', 'value': data['severity'], 'inline': True})
|
fields.append({'name': 'Severity', 'value': data['severity'], 'inline': True})
|
||||||
|
|
||||||
|
embeds: List[Dict[str, Any]] = []
|
||||||
|
for idx, chunk in enumerate(chunks):
|
||||||
|
embed: Dict[str, Any] = {
|
||||||
|
'description': chunk,
|
||||||
|
'color': color,
|
||||||
|
}
|
||||||
|
if idx == 0:
|
||||||
|
# Lead embed carries identity (title + fields).
|
||||||
|
embed['title'] = title
|
||||||
if fields:
|
if fields:
|
||||||
embed['fields'] = fields
|
embed['fields'] = fields
|
||||||
|
if idx == len(chunks) - 1:
|
||||||
|
# Footer/timestamp on the trailing embed so the reader
|
||||||
|
# sees them at the bottom of the whole digest.
|
||||||
|
embed['footer'] = {'text': 'ProxMenux Monitor'}
|
||||||
|
embed['timestamp'] = timestamp
|
||||||
|
embeds.append(embed)
|
||||||
|
|
||||||
result = self._send_with_retry(
|
# Drop any embed whose lead-section (title + fields) plus
|
||||||
lambda: self._post_webhook(embed)
|
# description would exceed Discord's 6000-char-per-embed cap.
|
||||||
|
# This only kicks in when many large fields combine with a
|
||||||
|
# chunk that is already near the 4096 description limit.
|
||||||
|
embeds = [self._trim_embed_to_budget(e) for e in embeds]
|
||||||
|
|
||||||
|
# POST one or more webhook messages, batching up to
|
||||||
|
# MAX_EMBEDS_PER_MSG embeds per request.
|
||||||
|
last_result: Dict[str, Any] = {'success': True, 'status': 0, 'response': ''}
|
||||||
|
for batch_start in range(0, len(embeds), self.MAX_EMBEDS_PER_MSG):
|
||||||
|
batch = embeds[batch_start:batch_start + self.MAX_EMBEDS_PER_MSG]
|
||||||
|
last_result = self._send_with_retry(
|
||||||
|
lambda b=batch: self._post_webhook_batch(b)
|
||||||
)
|
)
|
||||||
result['channel'] = 'discord'
|
if not last_result.get('success'):
|
||||||
return result
|
last_result['channel'] = 'discord'
|
||||||
|
return last_result
|
||||||
|
# Polite gap between sequential messages so a burst of
|
||||||
|
# batches doesn't trip Discord's webhook rate limit (5/2s).
|
||||||
|
if batch_start + self.MAX_EMBEDS_PER_MSG < len(embeds):
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
last_result['channel'] = 'discord'
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _trim_embed_to_budget(cls, embed: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Ensure title + description + fields fit MAX_EMBED_TOTAL."""
|
||||||
|
used = len(embed.get('title', '')) + len(embed.get('description', ''))
|
||||||
|
for f in embed.get('fields', []):
|
||||||
|
used += len(f.get('name', '')) + len(f.get('value', ''))
|
||||||
|
if used <= cls.MAX_EMBED_TOTAL:
|
||||||
|
return embed
|
||||||
|
# Easiest correct shrink: clip the description. Fields are
|
||||||
|
# individually already capped at 1024 and there are at most 25;
|
||||||
|
# the description is where the bulk lives.
|
||||||
|
overflow = used - cls.MAX_EMBED_TOTAL
|
||||||
|
desc = embed.get('description', '')
|
||||||
|
embed['description'] = desc[:max(0, len(desc) - overflow - 1)] + '…'
|
||||||
|
return embed
|
||||||
|
|
||||||
def test(self) -> Tuple[bool, str]:
|
def test(self) -> Tuple[bool, str]:
|
||||||
valid, err = self.validate_config()
|
valid, err = self.validate_config()
|
||||||
@@ -487,9 +575,12 @@ class DiscordChannel(NotificationChannel):
|
|||||||
return result['success'], result.get('error', '')
|
return result['success'], result.get('error', '')
|
||||||
|
|
||||||
def _post_webhook(self, embed: Dict) -> Tuple[int, str]:
|
def _post_webhook(self, embed: Dict) -> Tuple[int, str]:
|
||||||
|
return self._post_webhook_batch([embed])
|
||||||
|
|
||||||
|
def _post_webhook_batch(self, embeds: List[Dict]) -> Tuple[int, str]:
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
'username': 'ProxMenux',
|
'username': 'ProxMenux',
|
||||||
'embeds': [embed]
|
'embeds': embeds,
|
||||||
}).encode('utf-8')
|
}).encode('utf-8')
|
||||||
|
|
||||||
return self._http_request(
|
return self._http_request(
|
||||||
|
|||||||
Reference in New Issue
Block a user