diff --git a/tweetshot.py b/tweetshot.py
new file mode 100644
index 0000000..41f9559
--- /dev/null
+++ b/tweetshot.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+"""Render a message as a screenshot of the X (Twitter) composer.
+
+Fetched at run time by the "Tweet mockups" Indent automation, which replies to every message in
+Slack #tweets with the message rendered as a tweet:
+
+ curl -fsSL https://assets.indent.com/tweetshot.py -o /tmp/tweetshot.py
+ python3 /tmp/tweetshot.py --name sashank --handle sthupukari \
+ --avatar "https://unavatar.io/x/sthupukari?fallback=false" \
+ --text "message text" --out /tmp/tweet.png
+
+Geometry, type and colours are matched to the original mockup: 642 css px wide at 2x device scale,
+40 px avatar, Inter Tight at 16.7 px on a 20 px line. Needs google-chrome, Pillow and numpy.
+"""
+import argparse, base64, html, os, re, subprocess, sys, tempfile, urllib.parse, urllib.request
+
+FONT_CACHE = os.path.join(tempfile.gettempdir(), "tweetshot-fontface.css")
+
+CARD_W = 642 # css px; screenshotted at 2x -> 1284 px wide
+PAD_L, PAD_T, PAD_R, PAD_B = 27, 21, 50, 25
+AVATAR, GAP = 40, 8
+NAME_SIZE = 16.2
+HANDLE_SIZE = 16.7
+TEXT_SIZE = 16.7
+LINE_HEIGHT = 20
+TRACKING = -0.2
+NAME_GAP_BOTTOM = 2
+ICONS_GAP = 5
+
+BLUE = "#4897ee"
+HANDLE_COLOR = "#606c79"
+BADGE_COLOR = "#4d9fec"
+FALLBACK = "'Liberation Sans','Helvetica Neue',Helvetica,Arial,sans-serif"
+
+BADGE_SVG = (
+ '" % BADGE_COLOR
+)
+
+ICONS_URI = "data:image/png;base64," + (
+ "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"
+ "RK5CYII="
+)
+
+UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
+
+
+def fetch(url, timeout=45):
+ return urllib.request.urlopen(
+ urllib.request.Request(url, headers={"User-Agent": UA}), timeout=timeout
+ ).read()
+
+
+def font_css(text=""):
+ """Google-hosted Inter Tight (+ any emoji the text needs), inlined as data URIs."""
+ css_parts = []
+ if os.path.exists(FONT_CACHE):
+ css_parts.append(open(FONT_CACHE).read())
+ else:
+ block = inline_faces("https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;700&display=swap",
+ latin_only=True)
+ open(FONT_CACHE, "w").write(block)
+ css_parts.append(block)
+ glyphs = "".join(sorted({c for c in text if ord(c) > 0x2000 and not c.isspace()}))
+ if glyphs:
+ try:
+ css_parts.append(inline_faces(
+ "https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&text="
+ + urllib.parse.quote(glyphs), latin_only=False))
+ except Exception as exc:
+ print("emoji font unavailable: %s" % exc, file=sys.stderr)
+ return "\n".join(css_parts)
+
+
+def inline_faces(css_url, latin_only):
+ css = fetch(css_url).decode()
+ out = []
+ for block in re.findall(r"@font-face\s*\{[^}]*\}", css):
+ rng = re.search(r"unicode-range:\s*([^;]+);", block)
+ if latin_only and rng and not re.match(r"\s*U\+0{0,3}0-|\s*U\+0100-02BA", rng.group(1)):
+ continue
+ url = re.search(r"url\((https://[^)]+)\)", block)
+ if not url:
+ continue
+ blob = base64.b64encode(fetch(url.group(1))).decode()
+ out.append(block.replace(url.group(1), "data:font/woff2;base64," + blob))
+ return "\n".join(out)
+
+
+def slack_to_text(raw):
+ """Slack markup -> the plain text a reader sees."""
+ t = raw
+ t = re.sub(r"<@[UWB][A-Z0-9]+\|([^>]+)>", r"@\1", t)
+ t = re.sub(r"<@[UWB][A-Z0-9]+>", "", t)
+ t = re.sub(r"<#C[A-Z0-9]+\|([^>]+)>", r"#\1", t)
+ t = re.sub(r"]*>", r"@\1", t)
+ t = re.sub(r"<((?:https?|mailto):[^|>]+)\|([^>]+)>", r"\2", t)
+ t = re.sub(r"<((?:https?|mailto):[^>]+)>", r"\1", t)
+ t = t.replace("&", "&").replace("<", "<").replace(">", ">")
+ t = re.sub(r"[ \t]+", " ", t)
+ return t.strip()
+
+
+def text_html(text):
+ out, token = [], re.compile(r"(https?://\S+|[@#][A-Za-z0-9_]+)")
+ for part in token.split(text):
+ if not part:
+ continue
+ if token.fullmatch(part):
+ out.append('%s' % html.escape(part))
+ else:
+ out.append(html.escape(part).replace("\n", "
"))
+ return "".join(out)
+
+
+def data_uri(src):
+ blob = fetch(src) if src.startswith("http") else open(src, "rb").read()
+ kind = "png" if blob[:4] == b"\x89PNG" else "jpeg"
+ return "data:image/%s;base64,%s" % (kind, base64.b64encode(blob).decode())
+
+
+def avatar_uri(candidates, name):
+ """First candidate URL/path that resolves; otherwise an initial-on-gradient circle."""
+ for src in candidates:
+ if not src:
+ continue
+ try:
+ return data_uri(src)
+ except Exception as exc:
+ print("avatar %s unavailable: %s" % (src, exc), file=sys.stderr)
+ letter = html.escape((name.strip()[:1] or "?").upper())
+ hue = sum(ord(c) for c in name) % 360
+ svg = (
+ "" % (hue, letter)
+ )
+ return "data:image/svg+xml;base64," + base64.b64encode(svg.encode()).decode()
+
+
+def build_html(name, handle, avatar, text, icons, verified=True):
+ return f"""
+