diff --git a/video/carousels/production-lessons/post.json b/video/carousels/production-lessons/post.json new file mode 100644 index 0000000..6ffb83b --- /dev/null +++ b/video/carousels/production-lessons/post.json @@ -0,0 +1,17 @@ +{ + "text": "What running AI agents in production taught us 👇\n\n🔍 Week one: 611 of 612 agent sessions were empty polling cycles. About 31M tokens spent on “nothing happened”.\n⚡ The fix: event-driven agents. Idle token cost dropped by 95%.\n👀 Autonomy without visibility is just anxiety: leaderboard, work/idle accounting, daily reports.\n⏱ ~4 minutes for a board decision to become law on every agent.\n🔁 3 model migrations, zero harness loss.\n🛡 Money stays human-gated. Every action is an auditable event. Kill-switch per agent.\n\nEvery number here is a log, not an aspiration.\nMore at cil.by\n\n#AIagents #AI #Startups #Automation #BuildInPublic", + "x": [ + "What running AI agents in production taught us 🧵\n\nWeek one: 611 of 612 agent sessions were empty polling. ~31M tokens on “nothing happened”.\n\nEvent-driven fixed it: idle token cost −95%.", + "~4 min from a board decision to law on every agent.\n3 model swaps, zero harness loss.\nMoney stays human-gated; every action is an auditable event.\n\nEvery number is a log, not an aspiration → cil.by" + ], + "alt": [ + "Cover: What running AI agents in production taught us. Alex Shkor, Shkor Labs & Cooperative 42", + "611/612 agent sessions in week one were empty polling cycles", + "Make agents event-driven, not scheduled: idle token cost dropped by 95%", + "Autonomy without visibility is just anxiety", + "About 4 minutes for a board decision to become law on every agent", + "Swap the model, keep the harness: 3 model migrations, zero harness loss", + "Guardrails: revenue-weighted score, human-gated money, auditable events, per-agent kill-switch", + "Every number here is a log, not an aspiration. cil.by" + ] +} diff --git a/video/carousels/production-lessons/published.json b/video/carousels/production-lessons/published.json new file mode 100644 index 0000000..868d598 --- /dev/null +++ b/video/carousels/production-lessons/published.json @@ -0,0 +1,7 @@ +{ + "telegram": "https://t.me/shkorvision/87", + "x": "https://x.com/alexshkor/status/2111776008772673591", + "linkedin": "https://www.linkedin.com/feed/update/urn:li:ugcPost:7517541789080301569/", + "facebook": "https://facebook.com/1157904360743869_122136193929359366", + "instagram": "https://www.instagram.com/p/DeoinF_EYHU/" +} \ No newline at end of file diff --git a/video/carousels/publish_carousel.py b/video/carousels/publish_carousel.py new file mode 100644 index 0000000..540f944 --- /dev/null +++ b/video/carousels/publish_carousel.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Публикация карусели (slides/*.png) в соцсети Alex. + + python3 video/carousels/publish_carousel.py video/carousels/ --platforms telegram,x,linkedin,facebook,instagram + +Тексты — /post.json: {"text": "...", "x": ["твит 1", "твит 2"], "alt": ["alt слайда 1", ...]} +Креды — те же, что у /root/coop/social/publish.py. Картинки для Buffer должны быть по публичному URL: +скрипт кладёт JPG в /srv/raw.cil.by/simbiot/carousels//. +Результаты пишутся в /published.json; уже опубликованные площадки повторно не постятся. +""" +import base64 +import hashlib +import hmac +import json +import os +import subprocess +import sys +import time +import urllib.parse +import urllib.request +import uuid + +sys.path.insert(0, "/root/coop/social") +import publish # noqa: E402 load_env, LI_VERSION + +PUBLIC_DIR, PUBLIC_URL = "/srv/raw.cil.by/simbiot/carousels", "https://raw.cil.by/simbiot/carousels" + + +def jpgs(d, slug): + out_dir = os.path.join(PUBLIC_DIR, slug) + os.makedirs(out_dir, exist_ok=True) + files = [] + for f in sorted(os.listdir(os.path.join(d, "slides"))): + if f.startswith("slide-") and f.endswith(".png"): + dst = os.path.join(out_dir, f.replace(".png", ".jpg")) + subprocess.run(["ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", os.path.join(d, "slides", f), "-q:v", "2", dst], check=True) + files.append(dst) + return files + + +def multipart(fields, files): + b = uuid.uuid4().hex + parts = [f"--{b}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n".encode() for k, v in fields.items()] + for name, (fname, data, ctype) in files.items(): + parts.append(f"--{b}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\nContent-Type: {ctype}\r\n\r\n".encode() + data + b"\r\n") + parts.append(f"--{b}--\r\n".encode()) + return b"".join(parts), f"multipart/form-data; boundary={b}" + + +# ── X: тред, по 4 картинки на твит ── +def x_thread(env, tweets, images): + q = lambda s: urllib.parse.quote(str(s), safe="") + + def auth(method, url): + p = {"oauth_consumer_key": env["X_CONSUMER_KEY"], "oauth_nonce": uuid.uuid4().hex, "oauth_signature_method": "HMAC-SHA1", + "oauth_timestamp": str(int(time.time())), "oauth_token": env["X_ACCESS_TOKEN"], "oauth_version": "1.0"} + base = "&".join(f"{q(k)}={q(v)}" for k, v in sorted(p.items())) + key = f"{q(env['X_CONSUMER_SECRET'])}&{q(env['X_ACCESS_TOKEN_SECRET'])}" + p["oauth_signature"] = base64.b64encode(hmac.new(key.encode(), f"{method}&{q(url)}&{q(base)}".encode(), hashlib.sha1).digest()).decode() + return "OAuth " + ", ".join(f'{k}="{q(v)}"' for k, v in p.items()) + + UP = "https://upload.twitter.com/1.1/media/upload.json" + ids, prev = [], None + for k, text in enumerate(tweets): + media = [] + for img in images[4 * k:4 * k + 4]: + body, ctype = multipart({}, {"media": (os.path.basename(img), open(img, "rb").read(), "image/jpeg")}) + r = urllib.request.urlopen(urllib.request.Request(UP, data=body, method="POST", + headers={"Authorization": auth("POST", UP), "Content-Type": ctype}), timeout=120) + media.append(json.loads(r.read())["media_id_string"]) + payload = {"text": text[:280]} + if media: + payload["media"] = {"media_ids": media} + if prev: + payload["reply"] = {"in_reply_to_tweet_id": prev} + url = "https://api.twitter.com/2/tweets" + r = urllib.request.urlopen(urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST", + headers={"Authorization": auth("POST", url), "Content-Type": "application/json"}), timeout=60) + prev = json.loads(r.read())["data"]["id"] + ids.append(prev) + return f"https://x.com/alexshkor/status/{ids[0]}" + + +# ── Telegram: альбом (sendMediaGroup, подпись у первого фото) ── +def telegram_album(env, text, images): + api = f"https://api.telegram.org/bot{env['TG_PUBLISH_BOT_TOKEN']}" + media, files = [], {} + for k, img in enumerate(images[:10]): + item = {"type": "photo", "media": f"attach://p{k}"} + if k == 0: + item["caption"] = text[:1024] + media.append(item) + files[f"p{k}"] = (f"p{k}.jpg", open(img, "rb").read(), "image/jpeg") + body, ctype = multipart({"chat_id": env["TG_PUBLISH_CHANNEL"], "media": json.dumps(media)}, files) + resp = json.loads(urllib.request.urlopen(urllib.request.Request(f"{api}/sendMediaGroup", data=body, method="POST", + headers={"Content-Type": ctype}), timeout=180).read()) + if not resp.get("ok"): + raise RuntimeError(resp) + ch = env["TG_PUBLISH_CHANNEL"].lstrip("@") + return f"https://t.me/{ch}/{resp['result'][0]['message_id']}" + + +# ── LinkedIn: multiImage ── +def linkedin_multi(env, text, images, alts): + tok, urn = env["LINKEDIN_ACCESS_TOKEN"], env["LINKEDIN_PERSON_URN"] + H = {"Authorization": f"Bearer {tok}", "X-Restli-Protocol-Version": "2.0.0", "LinkedIn-Version": publish.LI_VERSION, + "Content-Type": "application/json"} + urns = [] + for img in images[:20]: + init = json.loads(urllib.request.urlopen(urllib.request.Request( + "https://api.linkedin.com/rest/images?action=initializeUpload", + data=json.dumps({"initializeUploadRequest": {"owner": urn}}).encode(), method="POST", headers=H), timeout=30).read())["value"] + urllib.request.urlopen(urllib.request.Request(init["uploadUrl"], data=open(img, "rb").read(), method="PUT", + headers={"Authorization": f"Bearer {tok}", "Content-Type": "image/jpeg"}), timeout=120) + urns.append(init["image"]) + time.sleep(5) + payload = {"author": urn, "commentary": text, "visibility": "PUBLIC", + "distribution": {"feedDistribution": "MAIN_FEED", "targetEntities": [], "thirdPartyDistributionChannels": []}, + "content": {"multiImage": {"images": [{"id": u, "altText": (alts[k] if k < len(alts) else "")[:120]} for k, u in enumerate(urns)]}}, + "lifecycleState": "PUBLISHED", "isReshareDisabledByAuthor": False} + r = urllib.request.urlopen(urllib.request.Request("https://api.linkedin.com/rest/posts", data=json.dumps(payload).encode(), + method="POST", headers=H), timeout=60) + pid = r.headers.get("x-restli-id", "") + return f"https://www.linkedin.com/feed/update/{pid}/" if pid else "posted" + + +# ── Facebook / Instagram через Buffer ── +def buffer_images(env, service, text, urls, alts): + key, org = env["BUFFER_ACCESS_TOKEN"], env.get("BUFFER_ORG_ID", "6151d52eea195d0f412ff493") + + def gql(qs, v=None): + req = urllib.request.Request("https://api.buffer.com", data=json.dumps({"query": qs, "variables": v or {}}).encode(), + headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"}) + return json.loads(urllib.request.urlopen(req, timeout=90).read()) + + chans = gql('query($o: OrganizationId!){ channels(input:{organizationId:$o}){ id service isDisconnected isLocked } }', {"o": org})["data"]["channels"] + ch = next(c for c in chans if c["service"] == service and not c["isDisconnected"] and not c["isLocked"]) + meta = {"facebook": {"facebook": {"type": "post"}}, "instagram": {"instagram": {"type": "post", "shouldShareToFeed": True}}}[service] + assets = [{"image": {"url": u, "metadata": {"altText": (alts[k] if k < len(alts) else "slide")[:120]}}} for k, u in enumerate(urls[:10])] + inp = {"channelId": ch["id"], "text": text, "assets": assets, "mode": "shareNow", "schedulingType": "automatic", + "needsApproval": False, "metadata": meta, "source": "simbiot-carousel"} + r = gql("mutation($input: CreatePostInput!){ createPost(input:$input){ __typename ... on PostActionSuccess { post { id } } ... on MutationError { message } } }", + {"input": inp})["data"]["createPost"] + if r["__typename"] != "PostActionSuccess": + raise RuntimeError(r.get("message")) + pid = r["post"]["id"] + for _ in range(60): + p = gql("query($id: PostId!){ post(input:{id:$id}){ status error { message } externalLink } }", {"id": pid})["data"]["post"] + if p["status"] == "sent": + return p.get("externalLink") or f"sent {pid}" + if p.get("error"): + raise RuntimeError(p["error"]) + time.sleep(10) + return f"pending {pid}" + + +def main(): + d = os.path.abspath(sys.argv[1]) + slug = os.path.basename(d) + plats = sys.argv[sys.argv.index("--platforms") + 1].split(",") if "--platforms" in sys.argv else \ + ["telegram", "x", "linkedin", "facebook", "instagram"] + post = json.load(open(os.path.join(d, "post.json"))) + env = publish.load_env() + images = jpgs(d, slug) + urls = [f"{PUBLIC_URL}/{slug}/{os.path.basename(p)}" for p in images] + done_path = os.path.join(d, "published.json") + done = json.load(open(done_path)) if os.path.exists(done_path) else {} + alts = post.get("alt", []) + for p in plats: + if str(done.get(p, "")).startswith("http"): + print(p, "already:", done[p]) + continue + try: + if p == "telegram": + done[p] = telegram_album(env, post["text"], images) + elif p == "x": + done[p] = x_thread(env, post["x"], images) + elif p == "linkedin": + done[p] = linkedin_multi(env, post["text"], images, alts) + elif p in ("facebook", "instagram"): + done[p] = buffer_images(env, p, post["text"], urls, alts) + except urllib.error.HTTPError as e: + done[p] = f"ERROR {e.code}: {e.read()[:300]!r}" + except Exception as e: + done[p] = f"ERROR: {str(e)[:300]}" + print(p, "→", done[p], flush=True) + json.dump(done, open(done_path, "w"), ensure_ascii=False, indent=1) + + +if __name__ == "__main__": + main()