welcome: aurora welcome/goodbye cards

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-07 11:48:46 +03:00
1 parent 5adcf3478e
commit 1b074d4b58
9 files changed
+276

No files matched your search

+2
View File
@@ -151,6 +151,8 @@ CATEGORIES: dict[str, tuple[str, str, str, str]] = {
"playing Strinova, updated every 10 minutes.\n\n"
"**Member count** — a channel name that shows how many members the server has, "
"updated every 10 minutes.\n\n"
"**Welcome & goodbye cards** — I post a card when someone joins the server, "
"and a black & white one when someone leaves.\n\n"
"**Custom roles** — in the roles channel, press *Get my role* and I'll DM you "
"to make your own colored role.\n\n"
"**Website** — the PC's live stats are shown on https://esefos.netlify.app"
+179
View File
@@ -0,0 +1,179 @@
"""
cogs/welcome.py
─────────────────────────────────────────────────────────────────────────────
Welcome / goodbye cards.
• When someone joins, posts an "Aurora" card in WELCOME_CHANNEL_ID: their
avatar blurred into the background, the avatar itself in a ring on the left,
and their name + join date beside it. The new member is pinged.
• When someone leaves, posts the same card in black & white with how long
they were here. No ping.
• Bots are skipped, and only joins/leaves of the channel's own server count.
• Fonts: Inter (SIL OFL), bundled in welcome_asset/fonts.
"""
import asyncio
import io
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
import discord
from discord.ext import commands
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageOps
log = logging.getLogger(__name__)
FONT_DIR = Path(__file__).parent.parent / "welcome_asset" / "fonts"
W, H = 1200, 400
SS = 2 # draw at 2x and downscale for smooth edges
def _font(weight: str, size: int) -> ImageFont.FreeTypeFont:
return ImageFont.truetype(str(FONT_DIR / f"Inter-{weight}.ttf"), size * SS)
def _renderable(text: str) -> bool:
"""True if Inter has a glyph for every character (no tofu boxes)."""
f = _font("Bold", 20)
def glyph(c: str) -> bytes:
img = Image.new("L", (100, 100))
ImageDraw.Draw(img).text((10, 10), c, font=f, fill=255)
return img.tobytes()
tofu = glyph("￿")
return all(c.isspace() or glyph(c) != tofu for c in text)
def _fit_font(draw: ImageDraw.ImageDraw, text: str, weight: str, size: int, max_w: int):
while size > 20:
f = _font(weight, size)
if draw.textlength(text, font=f) <= max_w * SS:
return f
size -= 2
return _font(weight, size)
def _tracked(draw: ImageDraw.ImageDraw, xy, text: str, f, fill, tracking: int) -> None:
"""Draw text with letter-spacing (tracking in px at 1x)."""
x, y = xy
for ch in text:
draw.text((x, y), ch, font=f, fill=fill)
x += draw.textlength(ch, font=f) + tracking * SS
def _circle(img: Image.Image, d: int) -> Image.Image:
img = ImageOps.fit(img, (d, d), Image.LANCZOS)
mask = Image.new("L", (d, d), 0)
ImageDraw.Draw(mask).ellipse((0, 0, d - 1, d - 1), fill=255)
out = Image.new("RGBA", (d, d))
out.paste(img, (0, 0), mask)
return out
def render_card(avatar: bytes, name: str, label: str, line1: str, line2: str, leaving: bool) -> io.BytesIO:
"""Render the Aurora card; goodbye cards are greyscale."""
av = Image.open(io.BytesIO(avatar)).convert("RGBA")
cw, ch = W * SS, H * SS
bg = ImageOps.fit(av.convert("RGB"), (cw, ch), Image.LANCZOS, centering=(0.5, 0.35))
bg = bg.filter(ImageFilter.GaussianBlur(60 * SS))
bg = ImageEnhance.Color(bg).enhance(1.8)
bg = ImageEnhance.Brightness(bg).enhance(0.5)
canvas = bg.convert("RGBA")
# soft left-to-right darkening so the text stays readable
shade = Image.linear_gradient("L").rotate(90).resize((cw, ch))
canvas = Image.composite(Image.new("RGBA", (cw, ch), (0, 0, 0, 255)), canvas, shade.point(lambda v: v * 0.35))
d = ImageDraw.Draw(canvas)
ad = 220 * SS
ax, ay = 90 * SS, (ch - ad) // 2
ring = 6 * SS
d.ellipse((ax - ring, ay - ring, ax + ad + ring, ay + ad + ring), fill=(255, 255, 255, 230))
canvas.alpha_composite(_circle(av, ad), (ax, ay))
tx = ax + ad + 70 * SS
max_w = W - tx // SS - 70
_tracked(d, (tx, 92 * SS), label, _font("SemiBold", 22), (255, 255, 255, 170), 8)
d.text((tx, 130 * SS), name, font=_fit_font(d, name, "Bold", 76, max_w), fill=(255, 255, 255))
d.text((tx, 238 * SS), line1, font=_fit_font(d, line1, "Regular", 28, max_w), fill=(255, 255, 255, 215))
d.text((tx, 282 * SS), line2, font=_font("Light", 24), fill=(255, 255, 255, 150))
if leaving:
canvas = ImageOps.grayscale(canvas.convert("RGB")).convert("RGBA")
card = canvas.resize((W, H), Image.LANCZOS)
mask = Image.new("L", (W, H), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, W, H), 28, fill=255)
card.putalpha(mask)
buf = io.BytesIO()
card.save(buf, "PNG")
buf.seek(0)
return buf
def _stayed(joined: datetime | None) -> str:
"""'Was here for 4 months' style duration."""
if joined is None:
return ""
secs = (datetime.now(timezone.utc) - joined).total_seconds()
for unit, size in (("year", 365 * 86400), ("month", 30 * 86400), ("day", 86400),
("hour", 3600), ("minute", 60)):
n = int(secs // size)
if n >= 1:
return f"Was here for {n} {unit}{'s' if n != 1 else ''}"
return "Was here for less than a minute"
class Welcome(commands.Cog):
def __init__(self, bot):
self.bot = bot
val = os.getenv("WELCOME_CHANNEL_ID", "")
self.channel_id = int(val) if val.isdigit() else None
def _channel(self, guild: discord.Guild) -> discord.TextChannel | None:
if self.channel_id is None:
return None
channel = self.bot.get_channel(self.channel_id)
return channel if channel and channel.guild.id == guild.id else None
def _display(self, member: discord.Member) -> str:
name = member.display_name
return name if _renderable(name) else member.name
async def _post(self, member, channel, label, line1, line2, leaving, content):
try:
avatar = await member.display_avatar.replace(size=512, format="png").read()
img = await asyncio.to_thread(
render_card, avatar, self._display(member), label, line1, line2, leaving)
await channel.send(
content,
file=discord.File(img, filename="goodbye.png" if leaving else "welcome.png"),
allowed_mentions=discord.AllowedMentions(users=True),
)
except Exception:
log.exception("welcome card failed for %s", member)
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member):
channel = self._channel(member.guild)
if member.bot or channel is None:
return
joined = member.joined_at or datetime.now(timezone.utc)
await self._post(member, channel, "WELCOME", f"Welcome to {member.guild.name}",
f"Joined {joined:%b} {joined.day}, {joined.year}", False, member.mention)
@commands.Cog.listener()
async def on_member_remove(self, member: discord.Member):
channel = self._channel(member.guild)
if member.bot or channel is None:
return
await self._post(member, channel, "GOODBYE", f"Left {member.guild.name}",
_stayed(member.joined_at), True, None)
async def setup(bot):
await bot.add_cog(Welcome(bot))