9 changed files with 308 additions and 11 deletions
@ -0,0 +1,48 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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