Publish milestones per channel and add Instagram

Consent is bound to the channel it was given for: approving a post on X says
nothing about Instagram. Publishing state moves from the SocialMilestone row
into SocialMilestonePost, one row per channel, where a missing row means no
consent. The dialog asks per channel, shows the text each one will publish and
keeps a separate handle for each; Instagram captions end in hashtags because a
link there is not clickable.

Also fixes three problems in the existing X path:

- A QR code already past several thresholds produced one prompt per threshold,
  and since the post quotes the current scan count, every one of them would
  have published the same number. Only the highest threshold is announced now.
- Detection ran after every unique scan and re-read the QR code's full scan
  history just to hit skipDuplicates. Known milestones are filtered first.
- A failed post stayed failed forever because the consent dialog only opens
  once. The queue now retries three times on its own, spaces first attempts by
  SOCIAL_MILESTONE_MIN_GAP_HOURS, and Settings lists every milestone per
  channel with restart and revoke.

The worker no longer renders the card itself; it downloads the image the app
renders at /s/m/<token>/og, which also serves the new square and portrait
formats. Instagram publishing stays off until SOCIAL_MILESTONE_CHANNELS and
SOCIAL_WORKER_CHANNELS both name it.

Schema changes are manual SQL, see sql/2026-08-16_*.sql. Run both before
deploying this version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 13:46:13 +02:00
parent 55c04761ce
commit eb932ebdaa
22 changed files with 1159 additions and 298 deletions

View File

@@ -1,4 +1,10 @@
"""Always-on QR Master X milestone worker. The web app never receives X keys."""
"""Always-on QR Master milestone publisher.
Polls the app's internal queue per channel and publishes what a customer has
explicitly approved for that channel. The web app never receives X or Meta
credentials; this worker never composes text of its own.
"""
import base64
import io
import json
import os
@@ -7,9 +13,11 @@ import time
from pathlib import Path
import requests
from PIL import Image, ImageDraw, ImageFont
from PIL import Image
from requests_oauthlib import OAuth1Session
CHANNELS = ("x", "instagram")
def required(name):
value = os.getenv(name, "").strip()
@@ -18,108 +26,60 @@ def required(name):
return value
def enabled_channels():
configured = [value.strip().lower() for value in os.getenv("SOCIAL_WORKER_CHANNELS", "x").split(",")]
return [channel for channel in configured if channel in CHANNELS] or ["x"]
def api(method, url, payload=None):
response = requests.request(method, url, json=payload, headers={"Authorization": f"Bearer {required('INTERNAL_API_SECRET')}"}, timeout=30)
response.raise_for_status()
return response.json()
def font(name, size):
for candidate in (f"/usr/share/fonts/truetype/dejavu/{name}", f"C:/Windows/Fonts/{'arialbd.ttf' if 'Bold' in name else 'arial.ttf'}"):
if Path(candidate).exists():
return ImageFont.truetype(candidate, size)
return ImageFont.load_default()
def graph(path):
return f"https://graph.facebook.com/{os.getenv('GRAPH_API_VERSION', 'v22.0')}/{path}"
def logo():
"""Use the actual deployed QR Master favicon, not an invented icon."""
def graph_error(response):
"""Meta answers with a JSON error body that says far more than the status."""
try:
base = required("QRMASTER_API_BASE").rstrip("/")
response = requests.get(f"{base}/favicon.ico", timeout=10)
response.raise_for_status()
mark = Image.open(io.BytesIO(response.content)).convert("RGBA")
mark.thumbnail((56, 56))
return mark
except Exception:
error = response.json().get("error") or {}
detail = error.get("error_user_msg") or error.get("message")
if detail:
return f"{detail} (code {error.get('code')})"
except ValueError:
pass
return f"HTTP {response.status_code}"
def milestone_image(milestone, image_format="landscape"):
"""Download the card the app renders at /s/m/<token>/og.
The popup, the link preview and the published post therefore show the exact
same image: one renderer, one source of truth, nothing to keep in sync here.
The internal base is used on purpose - the public host is not necessarily
reachable from inside the worker network.
"""
token = str(milestone.get("shareToken") or "").strip()
if not token:
return None
def render_card(card):
"""Render an immutable cumulative scan timeline from the stored snapshot."""
image = Image.new("RGB", (1200, 630), "#f8f7f4")
draw = ImageDraw.Draw(image)
navy, blue, slate, mint = "#061b31", "#0256ff", "#64748b", "#059669"
regular, medium, display = font("DejaVuSans.ttf", 27), font("DejaVuSans-Bold.ttf", 27), font("DejaVuSans.ttf", 142)
mark = logo()
if mark:
image.paste(mark, (68, 58), mark)
logo_x = 140
else:
logo_x = 68
draw.text((logo_x, 70), "QR MASTER", font=medium, fill=navy)
badge_text = "Verified scan milestone"
badge_font = font("DejaVuSans.ttf", 19)
badge_box = draw.textbbox((0, 0), badge_text, font=badge_font)
badge_width = badge_box[2] - badge_box[0]
draw.rounded_rectangle((1120 - badge_width - 32, 61, 1132, 106), radius=7, fill="#ecfdf5")
draw.text((1116, 73), badge_text, font=badge_font, fill=mint, anchor="ra")
draw.line((68, 124, 1132, 124), fill="#e5edf5", width=2)
total = int(card.get("totalUniqueScans") or card.get("threshold") or 0)
total_scans = int(card.get("totalScans") or total)
draw.text((66, 212), f"{total:,}", font=display, fill=navy)
draw.text((73, 374), "UNIQUE SCANS", font=medium, fill=navy)
draw.text((74, 410), f"{total_scans:,} total scans", font=font("DejaVuSans.ttf", 20), fill=slate)
trend = card.get("trend") or {}
raw_points = trend.get("points") or []
left, top, width, height = 590, 140, 540, 330
if raw_points:
start_ms = min(_timestamp(point.get("at")) for point in raw_points)
end_ms = max(_timestamp(point.get("at")) for point in raw_points)
span = max(1, end_ms - start_ms)
ceiling = 5.0 if total <= 5 else float(trend.get("ceiling") or total * 1.25)
points = [(left + round((_timestamp(point.get("at")) - start_ms) / span * width), top + height - round(float(point.get("total", 0)) / ceiling * height)) for point in raw_points]
target_y = top + height - round(float(trend.get("target") or total) / ceiling * height)
# Small milestones use whole scans (1..5); larger ones keep the
# reached milestone on the fourth of five levels.
axis_values = list(range(1, 6)) if total <= 5 else [total * index / 4 for index in range(1, 6)]
for value in axis_values:
y = top + height - round(value / ceiling * height)
is_target = value == total
draw.line((left, y, left + width, y), fill="#bfdbfe" if is_target else "#dde5ef", width=2 if is_target else 1)
draw.text((left - 15, y - 12), _format_axis(value), font=font("DejaVuSans.ttf", 18), fill=blue if is_target else slate, anchor="ra")
draw.line(points, fill=blue, width=5, joint="curve")
x, y = points[-1]
draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill="#ffffff", outline=blue, width=5)
draw.text((left, top + height + 27), trend.get("startLabel") or "Created", font=font("DejaVuSans.ttf", 19), fill=slate)
draw.text((left + width, top + height + 27), trend.get("endLabel") or "Reached", font=font("DejaVuSans.ttf", 19), fill=slate, anchor="ra")
draw.line((68, 536, 1132, 536), fill="#e5edf5", width=2)
title = str(card.get("qrTitle") or "QR code")
if len(title) > 52:
title = title[:49].rstrip() + "..."
draw.text((68, 558), title, font=medium, fill=navy)
base = required("QRMASTER_API_BASE").rstrip("/")
response = requests.get(f"{base}/s/m/{token}/og", params={"format": image_format}, timeout=60)
response.raise_for_status()
path = Path(tempfile.mkstemp(suffix=".png")[1])
image.save(path, "PNG", optimize=True)
path.write_bytes(response.content)
return path
def _timestamp(value):
try:
return int(__import__("datetime").datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000)
except Exception:
return 0
def _format_axis(value):
return f"{value:g}" if value < 1000 else f"{value:,.0f}"
# --- X ---------------------------------------------------------------------
def oauth_client():
return OAuth1Session(required("X_API_KEY"), client_secret=required("X_API_SECRET"), resource_owner_key=required("X_ACCESS_TOKEN"), resource_owner_secret=required("X_ACCESS_TOKEN_SECRET"))
def find_existing_post(oauth, milestone):
def find_existing_x_post(oauth, milestone):
"""Reconcile an uncertain prior attempt before creating another X post."""
token = str(milestone.get("shareToken") or "").strip()
if not token:
@@ -139,12 +99,20 @@ def find_existing_post(oauth, milestone):
urls = (post.get("entities") or {}).get("urls") or []
expanded = " ".join(str(url.get("expanded_url") or url.get("unwound_url") or "") for url in urls)
if token in expanded:
return post
return f"https://x.com/i/web/status/{post.get('id')}"
return None
def post_x(text, card, oauth):
path = render_card(card) if card else None
def publish_x(milestone):
oauth = oauth_client()
# Reading the timeline costs far more X quota than writing a post, so
# reconcile only when this post was already attempted before.
if milestone.get("attempts"):
existing = find_existing_x_post(oauth, milestone)
if existing:
return existing, True
path = milestone_image(milestone)
try:
media_id = None
if path:
@@ -152,45 +120,160 @@ def post_x(text, card, oauth):
upload = oauth.post("https://upload.x.com/1.1/media/upload.json", files={"media": image}, timeout=60)
upload.raise_for_status()
media_id = upload.json()["media_id_string"]
payload = {"text": text}
payload = {"text": milestone["text"]}
if media_id:
payload["media"] = {"media_ids": [media_id]}
result = oauth.post("https://api.x.com/2/tweets", json=payload, timeout=30)
result.raise_for_status()
return result.json()
tweet_id = result.json().get("data", {}).get("id")
return (f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None), False
finally:
if path:
path.unlink(missing_ok=True)
def run_once():
# --- Instagram -------------------------------------------------------------
def instagram_jpeg(path):
"""Instagram accepts JPEG only, so the rendered PNG is flattened onto white.
The card is drawn at 1080x1350 (4:5), the tallest ratio Instagram allows.
"""
with Image.open(path) as image:
rgba = image.convert("RGBA")
canvas = Image.new("RGB", rgba.size, "white")
canvas.paste(rgba, mask=rgba.split()[3])
buffer = io.BytesIO()
canvas.save(buffer, "JPEG", quality=92, optimize=True)
target = Path(tempfile.mkstemp(suffix=".jpg")[1])
target.write_bytes(buffer.getvalue())
return target
def host_asset(path):
"""Meta downloads `image_url` itself, so the file must be publicly readable.
/api/social-assets stores it in Postgres and serves it from the verified
qrmaster.net domain - no object storage and no deploy needed per post.
"""
base = required("QRMASTER_API_BASE").rstrip("/")
response = requests.post(
f"{base}/api/social-assets",
headers={"x-admin-key": required("SOCIAL_ASSET_ADMIN_KEY")},
json={"files": [{"filename": path.name, "mimeType": "image/jpeg", "dataBase64": base64.b64encode(path.read_bytes()).decode()}]},
timeout=60,
)
if not response.ok:
raise RuntimeError(f"Asset upload failed: {response.text[:200]}")
url = ((response.json().get("assets") or [{}])[0]).get("url")
if not url:
raise RuntimeError("Asset upload returned no URL")
return url
def find_existing_instagram_post(caption):
"""Reconcile by caption: a repeated attempt must not post twice.
Instagram has nothing like a client-side idempotency key, and a share token
in the caption would only be visible clutter - the caption itself is the
identifying detail.
"""
response = requests.get(
graph(f"{required('INSTAGRAM_USER_ID')}/media"),
params={"fields": "id,caption,permalink", "limit": 25, "access_token": required("INSTAGRAM_ACCESS_TOKEN")},
timeout=30,
)
if not response.ok:
raise RuntimeError(graph_error(response))
for media in response.json().get("data") or []:
if (media.get("caption") or "").strip() == caption.strip():
return media.get("permalink")
return None
def publish_instagram(milestone):
caption = milestone["text"]
if milestone.get("attempts"):
existing = find_existing_instagram_post(caption)
if existing:
return existing, True
user_id = required("INSTAGRAM_USER_ID")
token = required("INSTAGRAM_ACCESS_TOKEN")
png = milestone_image(milestone, "portrait")
if not png:
raise RuntimeError("Milestone has no share token, so no image can be published")
jpeg = None
try:
jpeg = instagram_jpeg(png)
image_url = host_asset(jpeg)
container = requests.post(graph(f"{user_id}/media"), data={"image_url": image_url, "caption": caption, "access_token": token}, timeout=60)
if not container.ok:
raise RuntimeError(graph_error(container))
creation_id = container.json().get("id")
if not creation_id:
raise RuntimeError("Instagram did not return a container id")
# Meta fetches and processes the image asynchronously.
deadline = time.time() + 120
while True:
status = requests.get(graph(creation_id), params={"fields": "status_code,status", "access_token": token}, timeout=30)
if not status.ok:
raise RuntimeError(graph_error(status))
code = status.json().get("status_code")
if code == "FINISHED":
break
if code in {"ERROR", "EXPIRED"}:
raise RuntimeError(f"Instagram rejected the media container: {status.json().get('status') or code}")
if time.time() > deadline:
raise RuntimeError("Instagram did not finish processing the image within 120s")
time.sleep(5)
published = requests.post(graph(f"{user_id}/media_publish"), data={"creation_id": creation_id, "access_token": token}, timeout=60)
if not published.ok:
raise RuntimeError(graph_error(published))
media_id = published.json().get("id")
permalink = None
if media_id:
link = requests.get(graph(media_id), params={"fields": "permalink", "access_token": token}, timeout=30)
permalink = link.json().get("permalink") if link.ok else None
return permalink, False
finally:
png.unlink(missing_ok=True)
if jpeg:
jpeg.unlink(missing_ok=True)
PUBLISHERS = {"x": publish_x, "instagram": publish_instagram}
def run_once(channel):
base = required("QRMASTER_API_BASE").rstrip("/") + "/api/internal/social-milestones"
milestone = api("GET", base).get("milestone")
milestone = api("GET", f"{base}?channel={channel}").get("milestone")
if not milestone:
return
try:
oauth = oauth_client()
existing = find_existing_post(oauth, milestone)
result = {"data": existing, "reconciled": True} if existing else post_x(milestone["text"], milestone.get("card"), oauth)
tweet_id = result.get("data", {}).get("id")
post_url = f"https://x.com/i/web/status/{tweet_id}" if tweet_id else None
post_url, reconciled = PUBLISHERS[channel](milestone)
api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url})
print(json.dumps({"posted": milestone["id"], "reconciled": bool(existing), "x": result}), flush=True)
print(json.dumps({"posted": milestone["id"], "channel": channel, "reconciled": reconciled, "url": post_url}), flush=True)
except Exception as error:
api("PATCH", base, {"id": milestone["id"], "result": "failed", "error": str(error)[:500]})
print(f"Milestone post failed: {error}", flush=True)
print(f"Milestone post failed ({channel}): {error}", flush=True)
if __name__ == "__main__":
interval = max(5, int(os.getenv("SOCIAL_WORKER_INTERVAL_SECONDS", "10")))
if os.getenv("SOCIAL_MILESTONE_POSTING_ENABLED", "").lower() not in {"true", "1", "yes"}:
raise RuntimeError("Set SOCIAL_MILESTONE_POSTING_ENABLED=true to run this worker")
print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval}), flush=True)
channels = enabled_channels()
print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval, "channels": channels}), flush=True)
while True:
try:
run_once()
except Exception as error:
# Stay alive and make configuration/network errors visible in the
# container logs instead of entering a silent restart loop.
print(f"Worker cycle failed: {error}", flush=True)
for channel in channels:
try:
run_once(channel)
except Exception as error:
# Stay alive and make configuration/network errors visible in the
# container logs instead of entering a silent restart loop.
print(f"Worker cycle failed ({channel}): {error}", flush=True)
time.sleep(interval)