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:
MacRimi
2026-06-02 17:30:59 +02:00
parent 72b613a00e
commit 5a116e77b9
3 changed files with 122 additions and 31 deletions
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3b44eb1172b4b1b7e6a36d1c9f1cd5a237ec04d52543bb791358525b0653a402
16ad59ea63a64e5be460cd73f87315e8b39b756bf1c61f3cb2019e9fa3e76361
+121 -30
View File
@@ -396,9 +396,15 @@ class GotifyChannel(NotificationChannel):
class DiscordChannel(NotificationChannel):
"""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 = {
'CRITICAL': 0xED4245, # red
'WARNING': 0xFEE75C, # yellow
@@ -406,7 +412,7 @@ class DiscordChannel(NotificationChannel):
'OK': 0x57F287, # green
'UNKNOWN': 0x99AAB5, # grey
}
def __init__(self, webhook_url: str):
super().__init__()
self.webhook_url = webhook_url.strip()
@@ -436,43 +442,125 @@ class DiscordChannel(NotificationChannel):
return False, 'Invalid Discord webhook URL (path must be /api/webhooks/...)'
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',
data: Optional[Dict] = None) -> Dict[str, Any]:
color = self.SEVERITY_COLORS.get(severity, 0x5865F2)
desc = message[:self.MAX_EMBED_DESC] if len(message) > self.MAX_EMBED_DESC else message
embed = {
'title': title,
'description': desc,
'color': color,
'footer': {'text': 'ProxMenux Monitor'},
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
}
# Use structured fields from render_template if available
title = (title or '')[:self.MAX_EMBED_TITLE]
chunks = self._split_description(message or '')
timestamp = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
# Build fields once; they only attach to the FIRST embed because
# Discord's 6000-char-per-embed budget makes repeating them on
# every chunk wasteful, and visually the metadata only needs to
# appear once at the head of the message.
fields: List[Dict[str, Any]] = []
rendered_fields = (data or {}).get('_rendered_fields', [])
if rendered_fields:
embed['fields'] = [
{'name': name, 'value': val[:1024], 'inline': True}
for name, val in rendered_fields[:25] # Discord limit: 25 fields
fields = [
{'name': name, 'value': val[:self.MAX_FIELD_VALUE], 'inline': True}
for name, val in rendered_fields[:self.MAX_FIELDS]
]
elif data:
fields = []
if data.get('category'):
fields.append({'name': 'Category', 'value': data['category'], 'inline': True})
if data.get('hostname'):
fields.append({'name': 'Host', 'value': data['hostname'], 'inline': True})
if data.get('severity'):
fields.append({'name': 'Severity', 'value': data['severity'], 'inline': True})
if fields:
embed['fields'] = fields
result = self._send_with_retry(
lambda: self._post_webhook(embed)
)
result['channel'] = 'discord'
return result
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:
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)
# Drop any embed whose lead-section (title + fields) plus
# 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)
)
if not last_result.get('success'):
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]:
valid, err = self.validate_config()
@@ -487,11 +575,14 @@ class DiscordChannel(NotificationChannel):
return result['success'], result.get('error', '')
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({
'username': 'ProxMenux',
'embeds': [embed]
'embeds': embeds,
}).encode('utf-8')
return self._http_request(
self.webhook_url, payload, {'Content-Type': 'application/json'}
)