dismessage 0.4.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.
dismessage/__init__.py ADDED
@@ -0,0 +1,877 @@
1
+ """DisMessage — Pixel-perfect Discord message & friend request renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import gzip
7
+ import html as _html
8
+ import json
9
+ import os
10
+ import re
11
+ import urllib.parse
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Iterable, Optional
15
+
16
+ __version__ = "0.4.0"
17
+ __all__ = [
18
+ "Author", "Message", "FriendRequest", "MessageRenderer",
19
+ "render_html", "render_png",
20
+ "render_friend_request_html", "render_friend_request_png", "render_friend_request_png_lite",
21
+ "fetch_messages", "render_markdown", "shutdown_browser",
22
+ "__version__",
23
+ ]
24
+
25
+ _PAYLOAD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "discord_payload.b64")
26
+
27
+
28
+ def _load_payload() -> dict[str, str]:
29
+ with open(_PAYLOAD_PATH, "r", encoding="utf-8") as f:
30
+ b64 = f.read()
31
+ return json.loads(gzip.decompress(base64.b64decode(b64)).decode("utf-8"))
32
+
33
+
34
+ _PAYLOAD: dict[str, str] = _load_payload()
35
+
36
+ _FR_PAYLOAD: Optional[dict[str, str]] = None
37
+
38
+
39
+ def _load_fr_payload() -> dict[str, str]:
40
+ global _FR_PAYLOAD
41
+ if _FR_PAYLOAD is not None:
42
+ return _FR_PAYLOAD
43
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "friend_request_payload.b64")
44
+ with open(path, "r", encoding="utf-8") as f:
45
+ b64 = f.read()
46
+ _FR_PAYLOAD = json.loads(gzip.decompress(base64.b64decode(b64)).decode("utf-8"))
47
+ return _FR_PAYLOAD
48
+
49
+
50
+ @dataclass
51
+ class Author:
52
+ id: str
53
+ name: str = "Unknown"
54
+ display_name: Optional[str] = None
55
+ color: Optional[str] = None
56
+ avatar_url: Optional[str] = None
57
+ avatar_decoration_url: Optional[str] = None
58
+ clan_tag: Optional[str] = None
59
+ clan_badge_url: Optional[str] = None
60
+ bot: bool = False
61
+ verified_app: bool = False
62
+
63
+ @property
64
+ def effective_name(self) -> str:
65
+ return self.display_name or self.name
66
+
67
+
68
+ @dataclass
69
+ class Message:
70
+ author: Author
71
+ content: str = ""
72
+ timestamp: Optional[datetime] = None
73
+ grouped_with_previous: Optional[bool] = None
74
+ reply_to: Optional["Message"] = None
75
+ accessories_html: str = ""
76
+
77
+
78
+ @dataclass
79
+ class FriendRequest:
80
+ author: Author
81
+ count: int = 1
82
+ subtitle: str = ""
83
+
84
+
85
+ def _esc(text: str) -> str:
86
+ return _html.escape(text, quote=False)
87
+
88
+
89
+ def render_markdown(text: str) -> str:
90
+ if not text:
91
+ return ""
92
+
93
+ code_blocks: list[str] = []
94
+
95
+ def _stash_codeblock(m: re.Match[str]) -> str:
96
+ lang, code = m.group(1), m.group(2)
97
+ lang_attr = f' data-lang="{_esc(lang)}"' if lang else ""
98
+ idx = len(code_blocks)
99
+ code_blocks.append(
100
+ f'<pre class="codeContainer__75297"><code class="text-xs/normal_cf4812 inline"{lang_attr}>'
101
+ f"{_esc(code)}</code></pre>"
102
+ )
103
+ return f"\x00CODEBLOCK{idx}\x00"
104
+
105
+ text = re.sub(r"```([a-zA-Z0-9]*)\n?(.*?)```", _stash_codeblock, text, flags=re.DOTALL)
106
+
107
+ inline_codes: list[str] = []
108
+
109
+ def _stash_inline_code(m: re.Match[str]) -> str:
110
+ idx = len(inline_codes)
111
+ inline_codes.append(f'<code class="inline">{_esc(m.group(1))}</code>')
112
+ return f"\x00INLINECODE{idx}\x00"
113
+
114
+ text = re.sub(r"`([^`\n]+?)`", _stash_inline_code, text)
115
+
116
+ spoilers: list[str] = []
117
+
118
+ def _stash_spoiler(m: re.Match[str]) -> str:
119
+ idx = len(spoilers)
120
+ spoilers.append(f'<span class="spoilerContent__75297">{_esc(m.group(1))}</span>')
121
+ return f"\x00SPOILER{idx}\x00"
122
+
123
+ text = re.sub(r"\|\|(.+?)\|\|", _stash_spoiler, text, flags=re.DOTALL)
124
+ text = _esc(text)
125
+
126
+ text = re.sub(r"^### (.+)$", r"<h3>\1</h3>", text, flags=re.MULTILINE)
127
+ text = re.sub(r"^## (.+)$", r"<h2>\1</h2>", text, flags=re.MULTILINE)
128
+ text = re.sub(r"^# (.+)$", r"<h1>\1</h1>", text, flags=re.MULTILINE)
129
+ text = re.sub(r"^-# (.+)$", r'<small class="subtext__75297">\1</small>', text, flags=re.MULTILINE)
130
+ text = re.sub(r"^&gt; (.+)$", r'<blockquote><span>\1</span></blockquote>', text, flags=re.MULTILINE)
131
+
132
+ text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text, flags=re.DOTALL)
133
+ text = re.sub(r"__(.+?)__", r"<u>\1</u>", text, flags=re.DOTALL)
134
+ text = re.sub(r"~~(.+?)~~", r"<s>\1</s>", text, flags=re.DOTALL)
135
+ text = re.sub(r"(?<!\w)[*_](?!\s)(.+?)(?<!\s)[*_](?!\w)", r"<em>\1</em>", text, flags=re.DOTALL)
136
+
137
+ def _emoji(m: re.Match[str]) -> str:
138
+ animated = bool(m.group(1))
139
+ name = m.group(2)
140
+ eid = m.group(3)
141
+ ext = "gif" if animated else "png"
142
+ url = f"https://cdn.discordapp.com/emojis/{eid}.{ext}?size=32&quality=lossless"
143
+ return (
144
+ f'<span class="emojiContainer__75abc emojiContainerClickable__75abc">'
145
+ f'<img class="emoji" data-type="emoji" data-name=":{name}:" alt=":{name}:" '
146
+ f'draggable="false" src="{url}"></span>'
147
+ )
148
+ text = re.sub(r"&lt;(a)?:(\w+):(\d+)&gt;", _emoji, text)
149
+
150
+ text = re.sub(
151
+ r"&lt;@!?(\d+)&gt;",
152
+ r'<span class="mention wrapper_f61d60 interactive" role="button" tabindex="0">@user</span>',
153
+ text,
154
+ )
155
+ text = re.sub(
156
+ r"&lt;#(\d+)&gt;",
157
+ r'<span class="mention wrapper_f61d60 interactive" role="button" tabindex="0">#channel</span>',
158
+ text,
159
+ )
160
+ text = re.sub(
161
+ r"&lt;@&amp;(\d+)&gt;",
162
+ r'<span class="mention wrapper_f61d60 interactive" role="button" tabindex="0">@role</span>',
163
+ text,
164
+ )
165
+
166
+ text = re.sub(
167
+ r"(https?://[^\s<]+)",
168
+ r'<a href="\1" target="_blank" rel="noreferrer">\1</a>',
169
+ text,
170
+ flags=re.IGNORECASE,
171
+ )
172
+ text = text.replace("\n", "<br>")
173
+
174
+ text = re.sub(r"\x00CODEBLOCK(\d+)\x00", lambda m: code_blocks[int(m.group(1))], text)
175
+ text = re.sub(r"\x00INLINECODE(\d+)\x00", lambda m: inline_codes[int(m.group(1))], text)
176
+ text = re.sub(r"\x00SPOILER(\d+)\x00", lambda m: spoilers[int(m.group(1))], text)
177
+
178
+ return text
179
+
180
+
181
+ _DISCORD_API = "https://discord.com/api/v10"
182
+ _DISCORD_CDN = "https://cdn.discordapp.com"
183
+
184
+
185
+ def _api_get(client: Any, path: str, *, params: Optional[dict[str, Any]] = None, token: str) -> dict[str, Any]:
186
+ headers = {"Authorization": token, "Accept": "application/json"}
187
+ if not token.startswith(("Bot ", "Bearer ")):
188
+ if re.match(r"^\d+\.", token):
189
+ headers["Authorization"] = f"Bot {token}"
190
+ r = client.get(f"{_DISCORD_API}{path}", headers=headers, params=params, timeout=15.0)
191
+ if r.status_code >= 400:
192
+ raise RuntimeError(f"Discord API {path} returned {r.status_code}: {r.text[:200]}")
193
+ return r.json()
194
+
195
+
196
+ def _resolve_avatar_decoration(client: Any, user_id: str, *, token: str) -> Optional[str]:
197
+ try:
198
+ data = _api_get(client, f"/users/{user_id}", token=token)
199
+ except Exception:
200
+ return None
201
+ add = data.get("avatar_decoration_data") or {}
202
+ asset = add.get("asset")
203
+ if asset:
204
+ return f"{_DISCORD_CDN}/avatar-decoration-presets/{asset}.png?size=80&pas=true"
205
+ return None
206
+
207
+
208
+ def _resolve_clan_tag(client: Any, user_id: str, *, token: str) -> tuple[Optional[str], Optional[str]]:
209
+ try:
210
+ data = _api_get(client, f"/users/{user_id}", token=token)
211
+ except Exception:
212
+ return None, None
213
+ pg = data.get("primary_guild")
214
+ if not pg:
215
+ return None, None
216
+ tag = pg.get("tag")
217
+ if not tag:
218
+ return None, None
219
+ badge_hash = pg.get("badge")
220
+ guild_id = pg.get("identity_guild_id") or pg.get("guild_id")
221
+ badge_url = None
222
+ if badge_hash and guild_id:
223
+ badge_url = f"{_DISCORD_CDN}/clan-badges/{guild_id}/{badge_hash}.png?size=16"
224
+ return tag, badge_url
225
+
226
+
227
+ def fetch_messages(
228
+ token: str,
229
+ channel_id: str | int,
230
+ message_id: str | int,
231
+ *,
232
+ context_before: int = 0,
233
+ context_after: int = 0,
234
+ guild_id: Optional[str | int] = None,
235
+ resolve_clan_tag: bool = True,
236
+ resolve_avatar_decoration: bool = True,
237
+ client: Any = None,
238
+ ) -> list[Message]:
239
+ try:
240
+ import httpx
241
+ except ImportError as e:
242
+ raise RuntimeError("httpx is required for fetch_messages. Install with: pip install httpx") from e
243
+
244
+ owns_client = client is None
245
+ if owns_client:
246
+ client = httpx.Client()
247
+
248
+ try:
249
+ target = _api_get(client, f"/channels/{channel_id}/messages/{message_id}", token=token)
250
+ context: list[dict[str, Any]] = []
251
+ if context_before > 0:
252
+ before = _api_get(client, f"/channels/{channel_id}/messages", params={"limit": context_before, "before": message_id}, token=token)
253
+ context = list(reversed(before)) + context
254
+ if context_after > 0:
255
+ after = _api_get(client, f"/channels/{channel_id}/messages", params={"limit": context_after, "after": message_id}, token=token)
256
+ context = context + list(after)
257
+ all_raw = context + [target]
258
+
259
+ if guild_id is None:
260
+ try:
261
+ chan = _api_get(client, f"/channels/{channel_id}", token=token)
262
+ if chan.get("guild_id"):
263
+ guild_id = chan["guild_id"]
264
+ except Exception:
265
+ pass
266
+
267
+ author_cache: dict[str, Author] = {}
268
+
269
+ def _build_author(raw_user: dict[str, Any], raw_member: Optional[dict[str, Any]] = None) -> Author:
270
+ uid = str(raw_user["id"])
271
+ if uid in author_cache:
272
+ return author_cache[uid]
273
+ name = raw_user.get("global_name") or raw_user.get("username") or "Unknown"
274
+ display = raw_member.get("nick") if raw_member else None
275
+ avatar_hash = raw_user.get("avatar")
276
+ if avatar_hash:
277
+ ext = "gif" if avatar_hash.startswith("a_") else "webp"
278
+ avatar_url = f"{_DISCORD_CDN}/avatars/{uid}/{avatar_hash}.{ext}?size=80"
279
+ else:
280
+ idx = (int(uid) >> 22) % 6 if uid.isdigit() else 0
281
+ avatar_url = f"{_DISCORD_CDN}/embed/avatars/{idx}.png"
282
+ decoration_url = None
283
+ if resolve_avatar_decoration:
284
+ decoration_url = _resolve_avatar_decoration(client, uid, token=token)
285
+ clan_tag = clan_badge = None
286
+ if resolve_clan_tag:
287
+ clan_tag, clan_badge = _resolve_clan_tag(client, uid, token=token)
288
+ is_bot = bool(raw_user.get("bot"))
289
+ verified = False
290
+ public_flags = raw_user.get("public_flags") or 0
291
+ if is_bot and (public_flags & (1 << 16)):
292
+ verified = True
293
+ author = Author(
294
+ id=uid, name=name, display_name=display,
295
+ avatar_url=avatar_url, avatar_decoration_url=decoration_url,
296
+ clan_tag=clan_tag, clan_badge_url=clan_badge,
297
+ bot=is_bot, verified_app=verified,
298
+ )
299
+ author_cache[uid] = author
300
+ return author
301
+
302
+ messages: list[Message] = []
303
+ for raw in all_raw:
304
+ author = _build_author(raw["author"], raw.get("member"))
305
+ ts = None
306
+ if raw.get("timestamp"):
307
+ try:
308
+ ts = datetime.fromisoformat(raw["timestamp"].replace("Z", "+00:00"))
309
+ except Exception:
310
+ ts = None
311
+ messages.append(Message(author=author, content=raw.get("content", "") or "", timestamp=ts))
312
+ return messages
313
+ finally:
314
+ if owns_client:
315
+ client.close()
316
+
317
+
318
+ _CLAN_TAG_CHIP_RE = re.compile(
319
+ r'<span><span class="copyOnlyText__10651"[^>]*></span>'
320
+ r'<span class="chipletContainerInner__10651[^"]*clanTagChiplet_c19a55[^"]*"[^>]*>'
321
+ r'<span[^>]*><img[^>]*messageBadge__10651[^>]*>'
322
+ r'<span[^>]*>Server Tag:[^<]*</span>'
323
+ r'<span class="tagText__10651"[^>]*>[^<]*</span>'
324
+ r'</span></span></span>',
325
+ re.DOTALL,
326
+ )
327
+
328
+
329
+ def _format_timestamp(ts: Optional[datetime]) -> tuple[str, str]:
330
+ if ts is None:
331
+ return ("now", "just now")
332
+ try:
333
+ short = ts.strftime("%I:%M %p").lstrip("0")
334
+ except Exception:
335
+ short = "now"
336
+ full = ts.strftime("%A, %B %d, %Y at %I:%M %p").lstrip("0")
337
+ return (short, full)
338
+
339
+
340
+ def _render_message_li(msg: Message, *, grouped: bool) -> str:
341
+ author = msg.author
342
+ ts_short, ts_full = _format_timestamp(msg.timestamp)
343
+ iso_ts = msg.timestamp.isoformat() if msg.timestamp else ""
344
+ rendered_content = render_markdown(msg.content)
345
+ username = _esc(author.effective_name)
346
+
347
+ if grouped:
348
+ template = _PAYLOAD["li_grouped"]
349
+ elif author.bot or author.verified_app:
350
+ template = _PAYLOAD["li_bot"]
351
+ elif author.avatar_decoration_url:
352
+ template = _PAYLOAD["li_avatar_full"]
353
+ else:
354
+ template = _PAYLOAD["li_avatar_only"]
355
+
356
+ out = (
357
+ template
358
+ .replace("__AVATAR_URL__", _esc(author.avatar_url or ""))
359
+ .replace("__DECORATION_URL__", _esc(author.avatar_decoration_url or ""))
360
+ .replace("__USERNAME__", username)
361
+ .replace("__DATETIME__", _esc(iso_ts))
362
+ .replace("__TIME_SHORT__", _esc(ts_short))
363
+ .replace("__TIME_FULL__", _esc(ts_full))
364
+ .replace("__CONTENT__", rendered_content)
365
+ )
366
+
367
+ if author.color:
368
+ color_re = re.compile(
369
+ r'(class="username_c19a55 clickable_c19a55" aria-expanded="false" data-text="[^"]*" role="button" tabindex="0")'
370
+ )
371
+ out = color_re.sub(rf'\1 style="color: {_esc(author.color)};"', out, count=1)
372
+
373
+ if author.clan_tag:
374
+ out = (
375
+ out
376
+ .replace("__CLAN_TAG__", _esc(author.clan_tag))
377
+ .replace("__CLAN_BADGE_URL__", _esc(author.clan_badge_url or ""))
378
+ )
379
+ else:
380
+ out = _CLAN_TAG_CHIP_RE.sub("", out)
381
+
382
+ if author.bot and not author.verified_app and not grouped:
383
+ out = re.sub(r'<svg class="botTagVerified__82f07".*?</svg>', '', out, count=1, flags=re.DOTALL)
384
+ out = re.sub(r'<span id="_r_ki_" class="hiddenVisually_b18fe2">Verified App</span>', '', out, count=1)
385
+ out = out.replace('aria-label="Verified App"', 'aria-label="Bot"', 1)
386
+
387
+ return out
388
+
389
+
390
+ class MessageRenderer:
391
+ def __init__(self, group_window_seconds: int = 300) -> None:
392
+ self.group_window_seconds = group_window_seconds
393
+
394
+ def should_group(self, prev: Message, cur: Message) -> bool:
395
+ if prev.author.id != cur.author.id:
396
+ return False
397
+ if prev.content.startswith((">", "#", "-#")) or cur.content.startswith((">", "#", "-#")):
398
+ return False
399
+ if prev.timestamp is None or cur.timestamp is None:
400
+ return True
401
+ delta = abs((cur.timestamp - prev.timestamp).total_seconds())
402
+ return delta <= self.group_window_seconds
403
+
404
+ def render_messages(self, messages: Iterable[Message]) -> str:
405
+ msgs = list(messages)
406
+ parts: list[str] = []
407
+ prev: Optional[Message] = None
408
+ for msg in msgs:
409
+ grouped = False
410
+ if prev is not None:
411
+ grouped = msg.grouped_with_previous
412
+ if grouped is None:
413
+ grouped = self.should_group(prev, msg)
414
+ parts.append(_render_message_li(msg, grouped=grouped))
415
+ prev = msg
416
+ messages_html = "\n".join(parts)
417
+ return _PAYLOAD["head"] + messages_html + _PAYLOAD["tail"]
418
+
419
+
420
+ def render_html(
421
+ messages: Iterable[Message],
422
+ output_path: Optional[str] = None,
423
+ *,
424
+ group_window_seconds: int = 300,
425
+ ) -> str:
426
+ renderer = MessageRenderer(group_window_seconds=group_window_seconds)
427
+ html = renderer.render_messages(messages)
428
+ if output_path:
429
+ with open(output_path, "w", encoding="utf-8") as f:
430
+ f.write(html)
431
+ return output_path
432
+ return html
433
+
434
+
435
+ class _PersistentBrowser:
436
+ _thread: Optional[Any] = None
437
+ _queue: Optional[Any] = None
438
+ _browser: Optional[Any] = None
439
+ _playwright: Optional[Any] = None
440
+ _lite: bool = False
441
+ _started: bool = False
442
+
443
+ @classmethod
444
+ def _worker(cls) -> None:
445
+ import queue as _queue
446
+ while True:
447
+ task = cls._queue.get()
448
+ if task is None:
449
+ break
450
+ fn, args, kwargs, result_queue = task
451
+ try:
452
+ result = fn(*args, **kwargs)
453
+ result_queue.put(("ok", result))
454
+ except Exception as e:
455
+ result_queue.put(("error", e))
456
+ finally:
457
+ if task is not None:
458
+ cls._queue.task_done()
459
+
460
+ @classmethod
461
+ def _ensure_started(cls, lite: bool = False) -> None:
462
+ import queue as _queue
463
+ import threading
464
+ if cls._started and cls._lite == lite and cls._browser is not None:
465
+ return
466
+ if cls._started:
467
+ cls._stop()
468
+ cls._queue = _queue.Queue()
469
+ cls._thread = threading.Thread(target=cls._worker, daemon=True)
470
+ cls._thread.start()
471
+ result_q: _queue.Queue = _queue.Queue()
472
+ cls._queue.put((cls._start_browser, [], {"lite": lite}, result_q))
473
+ status, result = result_q.get()
474
+ if status == "error":
475
+ raise result
476
+ cls._browser = result
477
+ cls._lite = lite
478
+ cls._started = True
479
+ print(f"[DisMessage] Persistent Chromium started (lite={lite})")
480
+
481
+ @classmethod
482
+ def _start_browser(cls, lite: bool = False) -> Any:
483
+ from playwright.sync_api import sync_playwright
484
+ pw = sync_playwright().start()
485
+ launch_args = [
486
+ "--disable-blink-features=AutomationControlled",
487
+ "--disable-gpu", "--disable-dev-shm-usage",
488
+ "--disable-extensions", "--disable-background-networking",
489
+ "--no-first-run",
490
+ ]
491
+ if lite:
492
+ launch_args.extend([
493
+ "--no-sandbox", "--disable-sync", "--disable-translate",
494
+ "--disable-default-apps", "--disable-component-update",
495
+ ])
496
+ browser = pw.chromium.launch(headless=True, args=launch_args)
497
+ cls._playwright_obj = pw
498
+ return browser
499
+
500
+ @classmethod
501
+ def submit(cls, fn: Any, *args: Any, **kwargs: Any) -> Any:
502
+ import queue as _queue
503
+ cls._ensure_started()
504
+ result_q: _queue.Queue = _queue.Queue()
505
+ cls._queue.put((fn, args, kwargs, result_q))
506
+ status, result = result_q.get()
507
+ if status == "error":
508
+ raise result
509
+ return result
510
+
511
+ @classmethod
512
+ def _stop(cls) -> None:
513
+ if cls._queue:
514
+ cls._queue.put(None)
515
+ if cls._thread:
516
+ cls._thread.join(timeout=5)
517
+ if hasattr(cls, "_playwright_obj") and cls._playwright_obj:
518
+ try:
519
+ cls._playwright_obj.stop()
520
+ except Exception:
521
+ pass
522
+ cls._browser = None
523
+ cls._playwright = None
524
+ cls._playwright_obj = None
525
+ cls._thread = None
526
+ cls._queue = None
527
+ cls._started = False
528
+
529
+ @classmethod
530
+ def shutdown(cls) -> None:
531
+ cls._stop()
532
+
533
+
534
+ def shutdown_browser() -> None:
535
+ _PersistentBrowser.shutdown()
536
+
537
+
538
+ def _render_png_on_browser(html: str, output_path: str, width: int, lite: bool, device_scale_factor: float) -> str:
539
+ import tempfile, os
540
+ tmp_html = tempfile.mktemp(suffix=".html")
541
+ with open(tmp_html, "w", encoding="utf-8") as f:
542
+ f.write(html)
543
+ try:
544
+ browser = _PersistentBrowser._browser
545
+ ctx = browser.new_context(
546
+ viewport={"width": width, "height": 1200},
547
+ device_scale_factor=1.0 if lite else device_scale_factor,
548
+ )
549
+ if lite:
550
+ def block_resources(route):
551
+ if route.request.resource_type in ("font", "media"):
552
+ route.abort()
553
+ else:
554
+ route.continue_()
555
+ ctx.route("**/*", block_resources)
556
+ page = ctx.new_page()
557
+ page.goto(f"file://{tmp_html}", wait_until="domcontentloaded")
558
+ if not lite:
559
+ try:
560
+ page.wait_for_load_state("networkidle", timeout=15_000)
561
+ except Exception:
562
+ pass
563
+ else:
564
+ try:
565
+ page.wait_for_load_state("domcontentloaded", timeout=10_000)
566
+ except Exception:
567
+ pass
568
+ page.evaluate("""() => {
569
+ document.querySelectorAll('form, .channelBottomBarArea_f75fb0, .channelTextArea_f75fb0').forEach(el => {
570
+ el.style.display = 'none';
571
+ });
572
+ const header = document.querySelector('.title_f75fb0, .subtitleContainer_f75fb0, .container__9293f');
573
+ if (header) header.style.display = 'none';
574
+ }""")
575
+ el = (
576
+ page.query_selector(".scrollerInner__36d07")
577
+ or page.query_selector(".chatContent_f75fb0")
578
+ or page.query_selector("body")
579
+ )
580
+ box = el.bounding_box()
581
+ if box:
582
+ page.screenshot(path=output_path, clip={
583
+ "x": max(0, box["x"] - 16), "y": max(0, box["y"] - 16),
584
+ "width": box["width"] + 32, "height": box["height"] + 32,
585
+ })
586
+ else:
587
+ el.screenshot(path=output_path, omit_background=False)
588
+ page.close()
589
+ ctx.close()
590
+ finally:
591
+ try:
592
+ os.unlink(tmp_html)
593
+ except OSError:
594
+ pass
595
+ return output_path
596
+
597
+
598
+ def render_png(
599
+ messages: Iterable[Message],
600
+ output_path: str,
601
+ *,
602
+ group_window_seconds: int = 300,
603
+ device_scale_factor: float = 2.0,
604
+ width: int = 800,
605
+ lite: bool = False,
606
+ playwright: Any = None,
607
+ persistent: bool = True,
608
+ ) -> str:
609
+ html = render_html(messages, output_path=None, group_window_seconds=group_window_seconds)
610
+
611
+ if persistent and playwright is None:
612
+ _PersistentBrowser.submit(_render_png_on_browser, html, output_path, width, lite, device_scale_factor)
613
+ return output_path
614
+
615
+ owns_pw = playwright is None
616
+ if owns_pw:
617
+ try:
618
+ from playwright.sync_api import sync_playwright
619
+ except ImportError as e:
620
+ raise RuntimeError("Playwright is required. Install with: pip install playwright && python -m playwright install chromium") from e
621
+ pw_ctx = sync_playwright().start()
622
+ playwright = pw_ctx
623
+
624
+ try:
625
+ launch_args = ["--disable-blink-features=AutomationControlled"]
626
+ if lite:
627
+ launch_args.extend(["--disable-gpu", "--disable-dev-shm-usage", "--no-sandbox", "--disable-extensions", "--disable-background-networking", "--no-first-run"])
628
+ browser = playwright.chromium.launch(headless=True, args=launch_args)
629
+ import tempfile, os
630
+ tmp_html = tempfile.mktemp(suffix=".html")
631
+ with open(tmp_html, "w", encoding="utf-8") as f:
632
+ f.write(html)
633
+ ctx = browser.new_context(viewport={"width": width, "height": 1200}, device_scale_factor=1.0 if lite else device_scale_factor)
634
+ page = ctx.new_page()
635
+ page.goto(f"file://{tmp_html}", wait_until="domcontentloaded")
636
+ if not lite:
637
+ try:
638
+ page.wait_for_load_state("networkidle", timeout=15_000)
639
+ except Exception:
640
+ pass
641
+ page.evaluate("""() => {
642
+ document.querySelectorAll('form, .channelBottomBarArea_f75fb0, .channelTextArea_f75fb0').forEach(el => { el.style.display = 'none'; });
643
+ const header = document.querySelector('.title_f75fb0, .subtitleContainer_f75fb0, .container__9293f');
644
+ if (header) header.style.display = 'none';
645
+ }""")
646
+ el = page.query_selector(".scrollerInner__36d07") or page.query_selector(".chatContent_f75fb0") or page.query_selector("body")
647
+ box = el.bounding_box()
648
+ if box:
649
+ page.screenshot(path=output_path, clip={"x": max(0, box["x"] - 16), "y": max(0, box["y"] - 16), "width": box["width"] + 32, "height": box["height"] + 32})
650
+ else:
651
+ el.screenshot(path=output_path, omit_background=False)
652
+ page.close()
653
+ ctx.close()
654
+ browser.close()
655
+ try:
656
+ os.unlink(tmp_html)
657
+ except OSError:
658
+ pass
659
+ finally:
660
+ if owns_pw:
661
+ try:
662
+ pw_ctx.stop()
663
+ except Exception:
664
+ pass
665
+ return output_path
666
+
667
+
668
+ def render_friend_request_html(request: FriendRequest, output_path: Optional[str] = None) -> str:
669
+ payload = _load_fr_payload()
670
+ author = request.author
671
+ username = _esc(author.effective_name)
672
+ avatar_url = _esc(author.avatar_url or "")
673
+ if not avatar_url:
674
+ idx = (int(author.id) >> 22) % 6 if author.id.isdigit() else 0
675
+ avatar_url = f"https://cdn.discordapp.com/embed/avatars/{idx}.png"
676
+ subtext = _esc(request.subtitle) if request.subtitle else _esc(author.name)
677
+ count = str(request.count)
678
+
679
+ card = (
680
+ payload["card"]
681
+ .replace("__AVATAR_URL__", avatar_url)
682
+ .replace("__USERNAME__", username)
683
+ .replace("__SUBTEXT__", subtext)
684
+ .replace("__COUNT__", count)
685
+ )
686
+ html = payload["head"] + card + payload["tail"]
687
+
688
+ if output_path:
689
+ with open(output_path, "w", encoding="utf-8") as f:
690
+ f.write(html)
691
+ return output_path
692
+ return html
693
+
694
+
695
+ def _render_fr_on_browser(html: str, output_path: str, lite: bool, device_scale_factor: float) -> str:
696
+ import tempfile, os
697
+ tmp_html = tempfile.mktemp(suffix=".html")
698
+ with open(tmp_html, "w", encoding="utf-8") as f:
699
+ f.write(html)
700
+ try:
701
+ browser = _PersistentBrowser._browser
702
+ ctx = browser.new_context(viewport={"width": 740, "height": 600}, device_scale_factor=1.0 if lite else device_scale_factor)
703
+ page = ctx.new_page()
704
+ page.goto(f"file://{tmp_html}", wait_until="domcontentloaded")
705
+ try:
706
+ page.wait_for_load_state("domcontentloaded", timeout=10_000)
707
+ except Exception:
708
+ pass
709
+ page.evaluate("""() => {
710
+ document.querySelectorAll('.panels__58c65, .panel_f75fb0, .container__040f0, [class*="panels"], .sidebar__1c6a5, .sidebar__74017, [class*="sidebar"], .tabBar__06f80, .children__133bf, .header__133bf').forEach(el => { el.style.display = 'none'; });
711
+ document.querySelectorAll('.peopleList__5ec2f').forEach(el => { el.style.overflow = 'visible'; el.style.height = 'auto'; el.style.maxHeight = 'none'; });
712
+ }""")
713
+ el = page.query_selector('.peopleList__5ec2f') or page.query_selector('.peopleListItem_cc6179') or page.query_selector('body')
714
+ el.screenshot(path=output_path, omit_background=False)
715
+ page.close()
716
+ ctx.close()
717
+ finally:
718
+ try:
719
+ os.unlink(tmp_html)
720
+ except OSError:
721
+ pass
722
+ return output_path
723
+
724
+
725
+ def render_friend_request_png(
726
+ request: FriendRequest,
727
+ output_path: str,
728
+ *,
729
+ device_scale_factor: float = 2.0,
730
+ lite: bool = False,
731
+ playwright: Any = None,
732
+ persistent: bool = True,
733
+ ) -> str:
734
+ html = render_friend_request_html(request, output_path=None)
735
+
736
+ if persistent and playwright is None:
737
+ _PersistentBrowser.submit(_render_fr_on_browser, html, output_path, lite, device_scale_factor)
738
+ return output_path
739
+
740
+ owns_pw = playwright is None
741
+ if owns_pw:
742
+ try:
743
+ from playwright.sync_api import sync_playwright
744
+ except ImportError as e:
745
+ raise RuntimeError("Playwright is required. Install with: pip install playwright && python -m playwright install chromium") from e
746
+ pw_ctx = sync_playwright().start()
747
+ playwright = pw_ctx
748
+
749
+ try:
750
+ launch_args = ["--disable-blink-features=AutomationControlled"]
751
+ if lite:
752
+ launch_args.extend(["--disable-gpu", "--disable-dev-shm-usage", "--no-sandbox", "--disable-extensions", "--disable-background-networking", "--no-first-run"])
753
+ browser = playwright.chromium.launch(headless=True, args=launch_args)
754
+ import tempfile, os
755
+ tmp_html = tempfile.mktemp(suffix=".html")
756
+ with open(tmp_html, "w", encoding="utf-8") as f:
757
+ f.write(html)
758
+ ctx = browser.new_context(viewport={"width": 740, "height": 600}, device_scale_factor=1.0 if lite else device_scale_factor)
759
+ page = ctx.new_page()
760
+ page.goto(f"file://{tmp_html}", wait_until="domcontentloaded")
761
+ try:
762
+ page.wait_for_load_state("domcontentloaded", timeout=10_000)
763
+ except Exception:
764
+ pass
765
+ page.evaluate("""() => {
766
+ document.querySelectorAll('.panels__58c65, .panel_f75fb0, .container__040f0, [class*="panels"], .sidebar__1c6a5, .sidebar__74017, [class*="sidebar"], .tabBar__06f80, .children__133bf, .header__133bf').forEach(el => { el.style.display = 'none'; });
767
+ document.querySelectorAll('.peopleList__5ec2f').forEach(el => { el.style.overflow = 'visible'; el.style.height = 'auto'; el.style.maxHeight = 'none'; });
768
+ }""")
769
+ el = page.query_selector('.peopleList__5ec2f') or page.query_selector('.peopleListItem_cc6179') or page.query_selector('body')
770
+ el.screenshot(path=output_path, omit_background=False)
771
+ page.close()
772
+ ctx.close()
773
+ browser.close()
774
+ try:
775
+ os.unlink(tmp_html)
776
+ except OSError:
777
+ pass
778
+ finally:
779
+ if owns_pw:
780
+ try:
781
+ pw_ctx.stop()
782
+ except Exception:
783
+ pass
784
+ return output_path
785
+
786
+
787
+ def render_friend_request_png_lite(request: FriendRequest, output_path: str) -> str:
788
+ import io
789
+ try:
790
+ from PIL import Image, ImageDraw, ImageFont
791
+ except ImportError as e:
792
+ raise RuntimeError("Pillow is required for lite mode. Install with: pip install Pillow") from e
793
+ try:
794
+ import httpx
795
+ except ImportError as e:
796
+ raise RuntimeError("httpx is required for lite mode. Install with: pip install httpx") from e
797
+
798
+ author = request.author
799
+ display_name = author.effective_name
800
+ username = author.name
801
+ avatar_url = author.avatar_url or ""
802
+
803
+ BG_PRIMARY = (43, 45, 49)
804
+ BG_ITEM = (35, 36, 40)
805
+ TEXT_NORMAL = (219, 222, 225)
806
+ TEXT_MUTED = (148, 155, 164)
807
+ BRAND = (88, 101, 242)
808
+ ACCENT = (78, 80, 88)
809
+ WHITE = (255, 255, 255)
810
+
811
+ scale = 2
812
+ W, H = 740 * scale, 62 * scale
813
+ img = Image.new("RGB", (W, H), BG_PRIMARY)
814
+ draw = ImageDraw.Draw(img)
815
+
816
+ def load_font(size, bold=False):
817
+ size = int(size * scale)
818
+ candidates = [
819
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
820
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
821
+ "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf" if bold else "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
822
+ ]
823
+ for path in candidates:
824
+ try:
825
+ return ImageFont.truetype(path, size)
826
+ except Exception:
827
+ continue
828
+ return ImageFont.load_default()
829
+
830
+ font_username = load_font(16, bold=True)
831
+ font_subtitle = load_font(14)
832
+ font_button = load_font(14, bold=True)
833
+
834
+ pad = 16 * scale
835
+ avatar_size = 32 * scale
836
+
837
+ avatar_img = None
838
+ if avatar_url:
839
+ try:
840
+ resp = httpx.get(avatar_url, timeout=10.0, follow_redirects=True)
841
+ if resp.status_code == 200:
842
+ avatar_img = Image.open(io.BytesIO(resp.content)).convert("RGBA")
843
+ avatar_img = avatar_img.resize((avatar_size, avatar_size), Image.LANCZOS)
844
+ mask = Image.new("L", (avatar_size, avatar_size), 0)
845
+ mask_draw = ImageDraw.Draw(mask)
846
+ mask_draw.ellipse([0, 0, avatar_size - 1, avatar_size - 1], fill=255)
847
+ img.paste(avatar_img, (pad, pad), mask)
848
+ except Exception:
849
+ pass
850
+
851
+ if avatar_img is None:
852
+ draw.ellipse([pad, pad, pad + avatar_size - 1, pad + avatar_size - 1], fill=(79, 82, 95))
853
+
854
+ username_x = pad + avatar_size + 12 * scale
855
+ draw.text((username_x, pad - 2 * scale), display_name, fill=TEXT_NORMAL, font=font_username)
856
+ subtitle = request.subtitle if request.subtitle else username
857
+ draw.text((username_x, pad + 20 * scale), subtitle, fill=TEXT_MUTED, font=font_subtitle)
858
+
859
+ btn_h = 32 * scale
860
+ btn_w = 72 * scale
861
+ btn_y = (H - btn_h) // 2
862
+ accept_x = W - pad - btn_w * 2 - 8 * scale
863
+ ignore_x = W - pad - btn_w
864
+
865
+ draw.rounded_rectangle([accept_x, btn_y, accept_x + btn_w, btn_y + btn_h], radius=4 * scale, fill=BRAND)
866
+ for text, x in [("Accept", accept_x), ("Ignore", ignore_x)]:
867
+ bbox = draw.textbbox((0, 0), text, font=font_button)
868
+ tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
869
+ draw.text((x + (btn_w - tw) // 2, btn_y + (btn_h - th) // 2 - 2 * scale), text, fill=WHITE, font=font_button)
870
+
871
+ draw.rounded_rectangle([ignore_x, btn_y, ignore_x + btn_w, btn_y + btn_h], radius=4 * scale, fill=ACCENT)
872
+ bbox = draw.textbbox((0, 0), "Ignore", font=font_button)
873
+ tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
874
+ draw.text((ignore_x + (btn_w - tw) // 2, btn_y + (btn_h - th) // 2 - 2 * scale), "Ignore", fill=WHITE, font=font_button)
875
+
876
+ img.save(output_path, "PNG")
877
+ return output_path