Files
QR-master/scripts/social-worker/worker.py
Timo Knuth eb932ebdaa 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>
2026-08-16 13:46:13 +02:00

280 lines
11 KiB
Python

"""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
import tempfile
import time
from pathlib import Path
import requests
from PIL import Image
from requests_oauthlib import OAuth1Session
CHANNELS = ("x", "instagram")
def required(name):
value = os.getenv(name, "").strip()
if not value:
raise RuntimeError(f"Missing {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 graph(path):
return f"https://graph.facebook.com/{os.getenv('GRAPH_API_VERSION', 'v22.0')}/{path}"
def graph_error(response):
"""Meta answers with a JSON error body that says far more than the status."""
try:
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
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])
path.write_bytes(response.content)
return path
# --- 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_x_post(oauth, milestone):
"""Reconcile an uncertain prior attempt before creating another X post."""
token = str(milestone.get("shareToken") or "").strip()
if not token:
raise RuntimeError("Milestone has no share token for duplicate-safe publishing")
identity = oauth.get("https://api.x.com/2/users/me", timeout=30)
identity.raise_for_status()
user_id = identity.json().get("data", {}).get("id")
if not user_id:
raise RuntimeError("X did not return the authenticated user id")
timeline = oauth.get(
f"https://api.x.com/2/users/{user_id}/tweets",
params={"max_results": 100, "tweet.fields": "created_at,entities", "exclude": "retweets,replies"},
timeout=30,
)
timeline.raise_for_status()
for post in timeline.json().get("data") or []:
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 f"https://x.com/i/web/status/{post.get('id')}"
return 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:
with path.open("rb") as image:
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": 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()
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)
# --- 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", f"{base}?channel={channel}").get("milestone")
if not milestone:
return
try:
post_url, reconciled = PUBLISHERS[channel](milestone)
api("PATCH", base, {"id": milestone["id"], "result": "posted", "postUrl": post_url})
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 ({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")
channels = enabled_channels()
print(json.dumps({"worker": "social-milestones", "status": "started", "intervalSeconds": interval, "channels": channels}), flush=True)
while 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)