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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user