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 +877 -0
- dismessage/discord_payload.b64 +1 -0
- dismessage/friend_request_payload.b64 +1 -0
- dismessage-0.4.0.dist-info/METADATA +56 -0
- dismessage-0.4.0.dist-info/RECORD +8 -0
- dismessage-0.4.0.dist-info/WHEEL +5 -0
- dismessage-0.4.0.dist-info/licenses/LICENSE +21 -0
- dismessage-0.4.0.dist-info/top_level.txt +1 -0
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"^> (.+)$", 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"<(a)?:(\w+):(\d+)>", _emoji, text)
|
|
149
|
+
|
|
150
|
+
text = re.sub(
|
|
151
|
+
r"<@!?(\d+)>",
|
|
152
|
+
r'<span class="mention wrapper_f61d60 interactive" role="button" tabindex="0">@user</span>',
|
|
153
|
+
text,
|
|
154
|
+
)
|
|
155
|
+
text = re.sub(
|
|
156
|
+
r"<#(\d+)>",
|
|
157
|
+
r'<span class="mention wrapper_f61d60 interactive" role="button" tabindex="0">#channel</span>',
|
|
158
|
+
text,
|
|
159
|
+
)
|
|
160
|
+
text = re.sub(
|
|
161
|
+
r"<@&(\d+)>",
|
|
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
|