9 changed files with 308 additions and 11 deletions
@ -0,0 +1,48 @@ |
|||||||
|
"""Deterministic PNG avatar: green forest / leaf motif (Imwald — „im Wald“).""" |
||||||
|
|
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import io |
||||||
|
import random |
||||||
|
|
||||||
|
|
||||||
|
def build_forest_avatar_png(*, size: int = 192) -> bytes: |
||||||
|
"""Return small PNG bytes: low resolution, palette, max zlib — keeps nostr.build uploads tiny.""" |
||||||
|
from PIL import Image, ImageDraw # type: ignore[import-not-found, import-untyped] |
||||||
|
|
||||||
|
rng = random.Random(42) |
||||||
|
w = h = max(64, min(size, 256)) |
||||||
|
img = Image.new("RGB", (w, h), (22, 68, 42)) |
||||||
|
dr = ImageDraw.Draw(img) |
||||||
|
# Soft vertical light (forest floor → canopy) |
||||||
|
for y in range(h): |
||||||
|
t = y / (h - 1) if h > 1 else 0.0 |
||||||
|
r = int(22 + 35 * (1 - t)) |
||||||
|
g = int(68 + 55 * (1 - t)) |
||||||
|
b = int(42 + 25 * (1 - t)) |
||||||
|
dr.line([(0, y), (w, y)], fill=(min(r, 120), min(g, 200), min(b, 110))) |
||||||
|
# Fewer, smaller blobs → smaller compressed PNG than a busy 512² canvas |
||||||
|
scale = w / 192.0 |
||||||
|
for _ in range(95): |
||||||
|
x = rng.randint(0, w - 1) |
||||||
|
y = rng.randint(0, h - 1) |
||||||
|
rx = max(2, int(rng.randint(3, 14) * scale)) |
||||||
|
ry = max(2, int(rng.randint(2, 9) * scale)) |
||||||
|
fill = ( |
||||||
|
rng.randint(45, 110), |
||||||
|
rng.randint(120, 200), |
||||||
|
rng.randint(35, 95), |
||||||
|
) |
||||||
|
dr.ellipse((x - rx, y - ry, x + rx, y + ry), fill=fill) |
||||||
|
for _ in range(6): |
||||||
|
x = rng.randint(max(8, w // 8), w - max(8, w // 8)) |
||||||
|
y0 = rng.randint(h // 3, h - max(8, h // 12)) |
||||||
|
dr.rectangle( |
||||||
|
(x, y0, x + max(2, int(rng.randint(3, 7) * scale)), h - max(4, h // 16)), |
||||||
|
fill=(40, 62, 38), |
||||||
|
) |
||||||
|
# Palette image compresses much better than full RGB for this art style |
||||||
|
paletted = img.quantize(colors=48, method=Image.Quantize.MEDIANCUT) |
||||||
|
buf = io.BytesIO() |
||||||
|
paletted.save(buf, format="PNG", compress_level=9, optimize=True) |
||||||
|
return buf.getvalue() |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
"""Random nature-themed display phrases (adjective + noun), e.g. \"romping chipmunk\".""" |
||||||
|
|
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import random |
||||||
|
|
||||||
|
_ADJECTIVES = ( |
||||||
|
"romping", |
||||||
|
"galloping", |
||||||
|
"pretty", |
||||||
|
"gentle", |
||||||
|
"wild", |
||||||
|
"sleepy", |
||||||
|
"busy", |
||||||
|
"tiny", |
||||||
|
"bright", |
||||||
|
"mossy", |
||||||
|
"drifting", |
||||||
|
"rustling", |
||||||
|
"singing", |
||||||
|
"shy", |
||||||
|
"nimble", |
||||||
|
"curious", |
||||||
|
"golden", |
||||||
|
"misty", |
||||||
|
"crisp", |
||||||
|
"dappled", |
||||||
|
) |
||||||
|
|
||||||
|
_NOUNS = ( |
||||||
|
"chipmunks", |
||||||
|
"elephants", |
||||||
|
"acorns", |
||||||
|
"ferns", |
||||||
|
"sparrows", |
||||||
|
"deer", |
||||||
|
"streams", |
||||||
|
"leaves", |
||||||
|
"pinecones", |
||||||
|
"foxes", |
||||||
|
"otters", |
||||||
|
"badgers", |
||||||
|
"thrushes", |
||||||
|
"mushrooms", |
||||||
|
"brambles", |
||||||
|
"dragonflies", |
||||||
|
"woodpeckers", |
||||||
|
"creekbeds", |
||||||
|
"sunbeams", |
||||||
|
"seedpods", |
||||||
|
) |
||||||
|
|
||||||
|
|
||||||
|
def random_nature_username(*, rng: random.Random | None = None) -> str: |
||||||
|
"""Return a lowercase phrase like ``romping chipmunks`` (two words, nature / forest vibe).""" |
||||||
|
r = rng or random.Random() |
||||||
|
return f"{r.choice(_ADJECTIVES)} {r.choice(_NOUNS)}" |
||||||
@ -0,0 +1,136 @@ |
|||||||
|
"""NIP-96 upload to nostr.build with NIP-98 (kind 27235) authorization.""" |
||||||
|
|
||||||
|
from __future__ import annotations |
||||||
|
|
||||||
|
import base64 |
||||||
|
import json |
||||||
|
import logging |
||||||
|
import secrets |
||||||
|
import time |
||||||
|
import urllib.error |
||||||
|
import urllib.request |
||||||
|
from hashlib import sha256 |
||||||
|
from typing import Any |
||||||
|
|
||||||
|
from imwald.core.nostr_crypto import build_signed_event |
||||||
|
|
||||||
|
log = logging.getLogger(__name__) |
||||||
|
|
||||||
|
NOSTR_BUILD_NIP96_UPLOAD = "https://nostr.build/api/v2/nip96/upload" |
||||||
|
|
||||||
|
|
||||||
|
def _multipart_body( |
||||||
|
*, |
||||||
|
boundary: str, |
||||||
|
file_bytes: bytes, |
||||||
|
filename: str, |
||||||
|
mime: str, |
||||||
|
fields: dict[str, str], |
||||||
|
) -> bytes: |
||||||
|
crlf = b"\r\n" |
||||||
|
parts: list[bytes] = [] |
||||||
|
for name, value in fields.items(): |
||||||
|
parts.append(f"--{boundary}".encode() + crlf) |
||||||
|
parts.append(f'Content-Disposition: form-data; name="{name}"'.encode() + crlf + crlf) |
||||||
|
parts.append(value.encode("utf-8") + crlf) |
||||||
|
parts.append(f"--{boundary}".encode() + crlf) |
||||||
|
disp = f'Content-Disposition: form-data; name="file"; filename="{filename}"' |
||||||
|
parts.append(disp.encode() + crlf) |
||||||
|
parts.append(f"Content-Type: {mime}".encode() + crlf + crlf) |
||||||
|
parts.append(file_bytes + crlf) |
||||||
|
parts.append(f"--{boundary}--".encode() + crlf) |
||||||
|
return b"".join(parts) |
||||||
|
|
||||||
|
|
||||||
|
def nip94_tags_to_imeta(tags: list[list[str]]) -> list[str]: |
||||||
|
""" |
||||||
|
NIP-92: one ``imeta`` tag with ``url`` plus space-delimited ``key value`` entries from NIP-94 tags. |
||||||
|
""" |
||||||
|
allowed = frozenset({"url", "m", "dim", "blurhash", "alt", "x", "ox", "size", "thumb", "image", "summary"}) |
||||||
|
parts: list[str] = ["imeta"] |
||||||
|
for t in tags: |
||||||
|
if not t or len(t) < 2: |
||||||
|
continue |
||||||
|
name = str(t[0]) |
||||||
|
if name not in allowed: |
||||||
|
continue |
||||||
|
val = str(t[1]).strip() |
||||||
|
if not val: |
||||||
|
continue |
||||||
|
parts.append(f"{name} {val}") |
||||||
|
if len(parts) < 3: |
||||||
|
raise ValueError("imeta requires url plus at least one other NIP-94 field") |
||||||
|
return parts |
||||||
|
|
||||||
|
|
||||||
|
def upload_image_nip96_nostr_build( |
||||||
|
secret: bytes, |
||||||
|
file_bytes: bytes, |
||||||
|
*, |
||||||
|
filename: str = "imwald-forest-avatar.png", |
||||||
|
mime: str = "image/png", |
||||||
|
caption: str = "imwald default forest avatar", |
||||||
|
alt: str = "Green forest leaf pattern — imwald (in the forest)", |
||||||
|
) -> tuple[str, list[list[str]]]: |
||||||
|
""" |
||||||
|
POST ``file`` to nostr.build NIP-96 endpoint with NIP-98 auth (payload = SHA-256 of multipart body). |
||||||
|
|
||||||
|
Returns ``(download_url, nip94_tags)`` where ``nip94_tags`` is the server's ``nip94_event.tags`` list. |
||||||
|
""" |
||||||
|
api_url = NOSTR_BUILD_NIP96_UPLOAD |
||||||
|
boundary = "ImwaldBoundary" + secrets.token_hex(16) |
||||||
|
fields = { |
||||||
|
"caption": caption, |
||||||
|
"alt": alt, |
||||||
|
"media_type": "avatar", |
||||||
|
"content_type": mime, |
||||||
|
"size": str(len(file_bytes)), |
||||||
|
} |
||||||
|
body = _multipart_body( |
||||||
|
boundary=boundary, |
||||||
|
file_bytes=file_bytes, |
||||||
|
filename=filename, |
||||||
|
mime=mime, |
||||||
|
fields=fields, |
||||||
|
) |
||||||
|
payload_hex = sha256(body).hexdigest() |
||||||
|
created_at = int(time.time()) |
||||||
|
nip98 = build_signed_event( |
||||||
|
secret, |
||||||
|
created_at=created_at, |
||||||
|
kind=27235, |
||||||
|
tags=[ |
||||||
|
["u", api_url], |
||||||
|
["method", "POST"], |
||||||
|
["payload", payload_hex], |
||||||
|
], |
||||||
|
content="", |
||||||
|
) |
||||||
|
auth_b64 = base64.b64encode( |
||||||
|
json.dumps(nip98, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), |
||||||
|
).decode("ascii") |
||||||
|
req = urllib.request.Request(api_url, data=body, method="POST") |
||||||
|
req.add_header("Authorization", f"Nostr {auth_b64}") |
||||||
|
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") |
||||||
|
try: |
||||||
|
with urllib.request.urlopen(req, timeout=90) as resp: |
||||||
|
raw = resp.read().decode("utf-8", errors="replace") |
||||||
|
except urllib.error.HTTPError as e: |
||||||
|
err_body = e.read().decode("utf-8", errors="replace") if e.fp else "" |
||||||
|
log.warning("nip96 upload HTTP %s: %s", e.code, err_body[:500]) |
||||||
|
raise RuntimeError(f"nostr.build upload failed: HTTP {e.code}") from e |
||||||
|
except urllib.error.URLError as e: |
||||||
|
log.warning("nip96 upload URL error: %s", e) |
||||||
|
raise RuntimeError("nostr.build upload failed: network error") from e |
||||||
|
|
||||||
|
data: dict[str, Any] = json.loads(raw) |
||||||
|
if data.get("status") != "success": |
||||||
|
raise RuntimeError(data.get("message") or "nostr.build upload unsuccessful") |
||||||
|
nip94 = data.get("nip94_event") or {} |
||||||
|
tags = nip94.get("tags") or [] |
||||||
|
if not isinstance(tags, list): |
||||||
|
raise RuntimeError("invalid nip94_event.tags in upload response") |
||||||
|
url = next((str(t[1]) for t in tags if isinstance(t, list) and len(t) >= 2 and str(t[0]) == "url"), None) |
||||||
|
if not url: |
||||||
|
raise RuntimeError("no url tag in nip94_event response") |
||||||
|
return url, tags |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
import random |
||||||
|
|
||||||
|
from imwald.core.nature_username import random_nature_username |
||||||
|
|
||||||
|
|
||||||
|
def test_random_nature_username_format() -> None: |
||||||
|
s = random_nature_username(rng=random.Random(0)) |
||||||
|
parts = s.split() |
||||||
|
assert len(parts) == 2 |
||||||
|
assert s == s.lower() |
||||||
|
|
||||||
|
|
||||||
|
def test_random_nature_username_deterministic() -> None: |
||||||
|
a = random_nature_username(rng=random.Random(12345)) |
||||||
|
b = random_nature_username(rng=random.Random(12345)) |
||||||
|
assert a == b |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
from imwald.core.nostr_nip96_upload import nip94_tags_to_imeta |
||||||
|
|
||||||
|
|
||||||
|
def test_nip94_tags_to_imeta_shape() -> None: |
||||||
|
tags = [ |
||||||
|
["url", "https://media.nostr.build/example.png"], |
||||||
|
["m", "image/png"], |
||||||
|
["dim", "512x512"], |
||||||
|
["alt", "Forest pattern"], |
||||||
|
["ox", "a" * 64], |
||||||
|
] |
||||||
|
im = nip94_tags_to_imeta(tags) |
||||||
|
assert im[0] == "imeta" |
||||||
|
assert "url https://media.nostr.build/example.png" in im |
||||||
|
assert any(x.startswith("m image/png") for x in im) |
||||||
|
assert any(x.startswith("dim 512x512") for x in im) |
||||||
Loading…
Reference in new issue