From db2e9cbbd3bb2c7cc14a560aa0a8a05777bedc5d Mon Sep 17 00:00:00 2001 From: Mikhail Savchenko Date: Mon, 3 Aug 2026 12:23:37 -0300 Subject: [PATCH 1/2] feat(post-publisher): publish to seven social platforms via official APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collection stopped at ./generated/ and three files said so on purpose. That boundary was defensible when drawn and had stopped being: the OAuth flows differ, but they differ in ways one adapter layer absorbs. common/runners/publishers/ is a sibling of providers/, not a subclass. A provider turns a prompt into bytes, costs money, and retries for free; a publisher takes bytes that exist and does something irreversible. So there is no estimate_cost() here, and in its place preflight() — concrete rather than abstract, so a subclass cannot skip the generic checks by forgetting super(). Dry-run is the default and --yes is what leaves it, inverting the convention elsewhere in this repo where --yes skips the cost prompt. A wasted generation costs cents; a wasted publish costs an audience. Each platform confirms separately. Platforms: telegram, threads, instagram, tiktok, x, youtube, linkedin. - TikTok defaults to the inbox. Direct posting needs an audited app; without one every post is forced to SELF_ONLY while the API reports success — the only failure mode in the set that looks like a success. - Instagram uses Instagram Login, not Facebook Login for Business, so no linked Page is required. Business/Creator still is; there is no API path to a personal account and the docs say so instead of implying one. - The browser fallback is instructions for Claude in Chrome, not code. A Playwright robot holding social session cookies would be worse than the problem it solves, and selectors rot on every redesign. - Meta drafts are genuinely two-step via --publish-container. tokens.py is a second store rather than an extension of keysfile.py: app credentials are long-lived and pasted, user tokens expire in hours. Merging them would have put expired social tokens in os.environ at every runner startup. Tokens are never loaded into the environment. posted.json receipts block a repeat publish, keyed on (platform, content hash) and on state — a draft does not block publishing it, which is the whole point of staging one. 144 new unit tests, none touching the network. Five pin bugs a review pass found: YouTube discarding an explicit --title on an empty caption (precedence), a TikTok chunk plan declaring a chunk larger than the file, Threads permanently unrefreshable via a hardcoded platform name in tokens.py, a one-shot OAuth listener a favicon request could consume, and a partial-alt warning the docs described but the code lacked. Also: scripts/validate.sh only resolved same-skill references/ links, so any cross-skill reference read as broken. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DUcstDF74VfBLh2e2g8g3i --- .env.example | 51 ++ .gitignore | 6 + CHANGELOG.md | 29 + README.md | 5 +- carousel-builder/SKILL.md | 4 +- common/runners/atomicfile.py | 46 ++ common/runners/cli/auth.py | 166 ++++ common/runners/cli/keys.py | 31 + common/runners/cli/publish.py | 422 ++++++++++ common/runners/config.py | 49 ++ common/runners/errors.py | 69 +- common/runners/keysfile.py | 21 +- common/runners/oauth.py | 226 ++++++ common/runners/publishers/__init__.py | 6 + common/runners/publishers/_meta.py | 321 ++++++++ common/runners/publishers/base.py | 388 +++++++++ common/runners/publishers/instagram.py | 164 ++++ common/runners/publishers/linkedin.py | 277 +++++++ common/runners/publishers/telegram.py | 210 +++++ common/runners/publishers/threads.py | 95 +++ common/runners/publishers/tiktok.py | 427 ++++++++++ common/runners/publishers/x.py | 298 +++++++ common/runners/publishers/youtube.py | 323 ++++++++ common/runners/receipts.py | 107 +++ common/runners/storage/s3.py | 32 + common/runners/tokens.py | 193 +++++ docs/COMPOSING.md | 17 +- docs/QUICKSTART.md | 2 +- docs/README.md | 2 +- docs/SKILL-INDEX.md | 6 +- docs/USER-GUIDE.md | 64 +- docs/walkthroughs/README.md | 1 + docs/walkthroughs/publish-to-social.md | 247 ++++++ .../walkthroughs/research-to-carousel-reel.md | 30 +- post-publisher/SKILL.md | 152 ++++ post-publisher/examples/before-after.md | 161 ++++ post-publisher/references/browser-fallback.md | 93 +++ post-publisher/references/oauth-setup.md | 198 +++++ post-publisher/references/platform-limits.md | 117 +++ post-publisher/references/troubleshoot.md | 107 +++ post-publisher/scripts/run.py | 36 + reel-builder/SKILL.md | 2 +- scripts/validate.sh | 6 +- skills.json | 21 + tests/fixtures/skill_post-publisher_input.md | 23 + .../snapshots/skill_post-publisher_input.json | 98 +++ tests/unit/test_publish.py | 748 ++++++++++++++++++ tests/unit/test_tokens.py | 237 ++++++ 48 files changed, 6289 insertions(+), 45 deletions(-) create mode 100644 common/runners/atomicfile.py create mode 100644 common/runners/cli/auth.py create mode 100644 common/runners/cli/publish.py create mode 100644 common/runners/oauth.py create mode 100644 common/runners/publishers/__init__.py create mode 100644 common/runners/publishers/_meta.py create mode 100644 common/runners/publishers/base.py create mode 100644 common/runners/publishers/instagram.py create mode 100644 common/runners/publishers/linkedin.py create mode 100644 common/runners/publishers/telegram.py create mode 100644 common/runners/publishers/threads.py create mode 100644 common/runners/publishers/tiktok.py create mode 100644 common/runners/publishers/x.py create mode 100644 common/runners/publishers/youtube.py create mode 100644 common/runners/receipts.py create mode 100644 common/runners/tokens.py create mode 100644 docs/walkthroughs/publish-to-social.md create mode 100644 post-publisher/SKILL.md create mode 100644 post-publisher/examples/before-after.md create mode 100644 post-publisher/references/browser-fallback.md create mode 100644 post-publisher/references/oauth-setup.md create mode 100644 post-publisher/references/platform-limits.md create mode 100644 post-publisher/references/troubleshoot.md create mode 100755 post-publisher/scripts/run.py create mode 100644 tests/fixtures/skill_post-publisher_input.md create mode 100644 tests/snapshots/skill_post-publisher_input.json create mode 100644 tests/unit/test_publish.py create mode 100644 tests/unit/test_tokens.py diff --git a/.env.example b/.env.example index 8403dd1..697c57b 100644 --- a/.env.example +++ b/.env.example @@ -42,9 +42,60 @@ SUNO_API_ENABLED= # set to 1 to enable Suno calls (gate against acc ELEVENLABS_API_KEY= # Eleven Music + Eleven TTS IDEOGRAM_API_KEY= # Ideogram 3 Turbo / Default / Quality +# ─────────────────────────────────────────────────────────────────────────── +# Publishing — post-publisher (v2.21.0+) +# ─────────────────────────────────────────────────────────────────────────── +# +# These are APP credentials and live here. The short-lived USER tokens they +# produce live in ~/.skills-tokens.json and are written only by the auth flow: +# +# python3 -m common.runners.cli.auth --platform threads +# python3 -m common.runners.cli.auth --status +# +# Never hand-edit the token file. Setup per platform (which app to register, +# which scopes, what account type is required) is in +# post-publisher/references/oauth-setup.md. +# +# Start with Telegram: no OAuth, no app review, works in five minutes. + +TELEGRAM_BOT_TOKEN= # from @BotFather; the bot must be a channel ADMIN +TELEGRAM_CHAT_ID= # @your_channel, or the numeric -100... id + +THREADS_APP_ID= # developers.facebook.com → Threads API use case +THREADS_APP_SECRET= + +INSTAGRAM_APP_ID= # Instagram API with Instagram Login (no FB Page needed) +INSTAGRAM_APP_SECRET= # requires a Business/Creator account + S3 below + +TIKTOK_CLIENT_KEY= # developers.tiktok.com → Content Posting API +TIKTOK_CLIENT_SECRET= # direct publishing needs app audit; --draft does not + +X_CLIENT_ID= # developer.x.com → OAuth 2.0, incl. offline.access +X_CLIENT_SECRET= + +YOUTUBE_CLIENT_ID= # Google Cloud console → YouTube Data API v3 +YOUTUBE_CLIENT_SECRET= # 1600 quota units per upload of 10000/day + +LINKEDIN_CLIENT_ID= # linkedin.com/developers → Share on LinkedIn +LINKEDIN_CLIENT_SECRET= # w_member_social posts as you, not as a company page + +# Optional overrides — vendors deprecate on their own schedule, so the moving +# parts are env vars rather than constants. See references/platform-limits.md. +INSTAGRAM_API_VERSION= # default v21.0 +THREADS_API_VERSION= # default v1.0 +LINKEDIN_API_VERSION= # default 202401; rejected once ~a year old +TIKTOK_PRIVACY_LEVEL= # default PUBLIC_TO_EVERYONE +YOUTUBE_PRIVACY= # default public; --draft forces private regardless +SKILLS_OAUTH_PORT= # default 8723 — the loopback redirect listener + # ─────────────────────────────────────────────────────────────────────────── # Storage (optional — local FS used if all unset) # ─────────────────────────────────────────────────────────────────────────── +# +# REQUIRED for Instagram, for Threads with media, and for TikTok photo posts: +# those platforms fetch media from a URL and never accept uploaded bytes. +# Files are staged as private objects and handed over as presigned links that +# expire in an hour — the bucket is never made public. # Set these to ALSO upload every generated asset to your bucket. Local copy # always written first. URL printed alongside the local path. S3_BUCKET= # bucket name diff --git a/.gitignore b/.gitignore index 7f7af75..a61c374 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ __pycache__/ generated/ .runners-venv/ +# post-publisher: OAuth tokens for the user's social accounts. Belongs in the +# home directory, but a stray copy inside the repo would be a live credential +# for someone's Instagram — ignore it wherever it lands. +.skills-tokens.json +posted.json + # proposal-maker: client brand profiles stay local. A saved profile carries a # named client's tokens, logo and the authored structure of their real offer — # publishing that is their call, not ours. The repo ships only the author's own diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f1682..bc38868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,35 @@ Releases are cut manually. Commit messages use [Conventional Commits](https://ww ## [Unreleased] +### Added — new skills + +- **`post-publisher`** (orchestrator). Publishes finished assets to Instagram, Threads, TikTok, X, YouTube, Telegram and LinkedIn through the official APIs. Composes with `carousel-builder` / `reel-builder` — it takes their output directory, reads `captions.md`, and sends it. + +### Added — the collection can finally publish what it makes + +Every skill here stopped at `./generated/`. Three separate places said so on purpose: `carousel-builder/SKILL.md` ("output is files you upload via the platform's UI / API"), `reel-builder/SKILL.md`, and `docs/walkthroughs/research-to-carousel-reel.md` ("that's a deliberate boundary — each platform's API has different OAuth flows"). The boundary was defensible when it was drawn and had simply stopped being: the OAuth flows differ, but they differ in ways one adapter layer absorbs. + +`common/runners/publishers/` is a sibling of `providers/`, not a subclass of it. A provider turns a prompt into bytes, costs money, and can be retried for free; a publisher takes bytes that exist and does something irreversible with them. Shoehorning the second into `generate(prompt, **kwargs) -> bytes` plus `estimate_cost()` would have inherited the wrong invariants. So there is no cost estimation in the publishing layer, and in its place there is `preflight()` — which every caller runs before `publish()`, and which is a concrete method rather than an abstract one precisely so a subclass cannot forget to call `super()` and skip the file-exists check. + +**Dry-run is the default and `--yes` is what leaves it.** That inverts the convention everywhere else in this repo, where `--yes` means "skip the cost prompt". The asymmetry is the point: a wasted generation costs cents, a wasted publish costs an audience. Even with `--yes`, each platform confirms separately — approving an Instagram post is not approval to also post to X. + +`posted.json` receipts sit next to the assets, keyed on (platform, content hash). Re-running the identical command is refused; editing the caption makes it new content and it goes through. The hash fingerprints media by name and size rather than bytes, because hashing a 1 GB video on every dry run would be absurd and the case worth catching is "ran the same command twice". + +State matters in that check, and a first pass that ignored it broke the workflow drafts exist for: staging a container, reviewing it, then publishing is the documented path for Meta and TikTok, and treating the draft as "already done" turned the second half into a `--force`. A publication is blocked only by a previous publication; a draft is blocked by either, since re-staging something already staged or already live has no purpose. + +Four platform-specific decisions worth recording: + +- **TikTok defaults to the inbox, not to publishing.** Direct posting requires passing TikTok's app audit; an unaudited app has every post silently forced to SELF_ONLY — the API returns success and nobody can see the post. That is the one failure mode in the set that reports itself as a success, so `--draft` routes to `/inbox/video/init/`, which needs no audit, and preflight warns loudly on the direct path. +- **Instagram uses Instagram Login, not Facebook Login for Business.** The older route additionally requires a linked Facebook Page, which most people publishing their own content neither want nor need. Business/Creator is still mandatory — there is no API path to a personal account, and `references/browser-fallback.md` says so rather than implying one exists. +- **The browser fallback is instructions, not code.** A Playwright robot holding social-network session cookies would be a worse thing to own than the problem it solves, and selectors rot on every redesign. It drives Claude in Chrome against the user's already-logged-in browser, stores nothing, and requires explicit consent per post. +- **Meta drafts became genuinely two-step.** A staged container is only useful if you can publish it later, so `--publish-container ` exists. Without it `--draft` on Instagram would have been decorative. + +`common/runners/tokens.py` is a second credential store rather than an extension of `keysfile.py`. App credentials are long-lived and pasted by hand; user tokens expire in hours and arrive from an OAuth flow. Merging them would have meant sixteen generation providers carrying expired social tokens in `os.environ` at every runner startup. Tokens are never loaded into the environment — they are read on demand, refreshed transparently inside a 300-second skew window, and masked everywhere they are printed. `common/runners/atomicfile.py` now holds the secure-write helper both stores use. + +Also: `scripts/validate.sh` only resolved `references/*.md` links inside the same skill, so any cross-skill reference was reported broken. It now matches `../other-skill/references/*.md` too. + +144 new unit tests, none of which touch the network. Five of them exist because a review pass found the bugs they now pin: an explicit `--title` silently discarded on YouTube whenever the caption was empty (operator precedence — `(title or first_line) if text else "Untitled"`); a TikTok chunk plan that declared a 20 MB chunk for a 6 MB file, which TikTok rejects; Threads permanently unrefreshable because `tokens.py` hardcoded `"instagram"` as the one platform allowed to renew without a refresh token; a one-shot OAuth listener that a browser's `/favicon.ico` request could consume instead of the callback; and a partial-alt-text warning that the documentation described and the code did not implement. + ### Fixed — nothing was checking the launch copy Caught by the user asking whether the rewritten posts had been run through the skills. They had not, and two layers of blindness met — both self-inflicted. diff --git a/README.md b/README.md index f46d9c5..a12b115 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ AI content toolkit for [Claude Code](https://docs.claude.com/en/docs/claude-code/skills) — prose editing without LLM-shaped output, AI media generation (image / video / music) with optional in-line execution, and end-to-end content orchestrators (research → carousel / reel). Russian-first, English-capable. -**Forty-one skills** across five layers: one base + twenty-one wrappers + three linters + thirteen orchestrators + three meta. Plain markdown, MIT-licensed, no required external deps (ffmpeg is optional for reel stitching + subtitle burning + GIF conversion + audio mixing). +**Forty-two skills** across five layers: one base + twenty-one wrappers + three linters + fourteen orchestrators + three meta. Plain markdown, MIT-licensed, no required external deps (ffmpeg is optional for reel stitching + subtitle burning + GIF conversion + audio mixing). --- @@ -147,6 +147,7 @@ The shared LLM prompt chains behind the visual skills: [image chain](common/visu | [`transcribe-maker`](transcribe-maker/) | wrapper | en/ru | Transcribe audio / video to SRT / WebVTT / JSON / plain text via OpenAI Whisper. Auto-detects language or accepts ISO-639-1 hint. ~$0.006/min. Closes the loop with subtitle-burner — produce captions from video, then burn them in. 25 MB file limit. | | [`style-suggest`](style-suggest/) | orchestrator | en/ru | Visual-style generator — turn a text description and/or reference image into a fully-structured style entry that drops into common/visual-prompt-library/styles/. Adapted from figma's StyleSuggestAgent: takes free-form input, checks the existing catalog for duplicates (similarity ≥ 0.72), otherwise produces a new entry with all v2.15.0 schema fields (background / accents / elements / mood / accent_text_color / typography / composition_signature / when_to_use). The new style is immediately usable by every visual-output skill via the shared chain. | | [`proposal-maker`](proposal-maker/) | orchestrator | en/ru | Turn a raw, telegram-style commercial offer into a self-contained, brand-faithful HTML proposal whose style is copied from a website. Default flow is LLM-authored: a Python step builds a brand kit (site screenshot + logo + accent/font tokens + per-item catalogue photos + BRIEF.md), then the orchestrator authors bespoke HTML mirroring the brand. Items missing a photo get an on-brand generated image; prices and links stay exact; prints to a Ghostscript-compressed PDF. A --quick mode renders a deterministic theme (editorial/invoice/dark). Output: ./generated/proposal//. | +| [`post-publisher`](post-publisher/) | orchestrator | en/ru | Publish finished assets to Instagram / Threads / TikTok / X / YouTube / Telegram / LinkedIn through the official APIs. Takes a carousel-builder or reel-builder output directory, reads captions.md, runs a per-platform preflight (char limits, media count, file size, posting caps), previews, confirms per platform, then publishes and writes a posted.json receipt so the same content cannot go out twice. Dry-run by default; --yes leaves it; --draft where the platform has drafts. OAuth tokens live in ~/.skills-tokens.json with automatic refresh. | @@ -263,7 +264,7 @@ skills/ ├── CODE_OF_CONDUCT.md # short; scales up if the project does ├── SECURITY.md # how to report a vulnerability │ -├── / # 41 skills, one folder each +├── / # 42 skills, one folder each │ ├── common/ │ ├── references/ # shared anti-pattern catalogues (hype words, preambles, …) diff --git a/carousel-builder/SKILL.md b/carousel-builder/SKILL.md index 0d3cadb..4019fa2 100644 --- a/carousel-builder/SKILL.md +++ b/carousel-builder/SKILL.md @@ -27,7 +27,7 @@ This skill does NOT: - Compose the slides into a single tall image — Instagram / LinkedIn handle multi-image posts natively. - Add text overlays via a design tool — text either gets generated INSIDE the image (gpt-image-2 / Ideogram / Imagen) via `--text-mode embedded`, or is left to the user's editor (`--text-mode overlay`). - Generate animated carousels (those are reels — use `reel-builder`). -- Post to platforms — output is files you upload via the platform's UI / API. +- Post to platforms — that is `post-publisher`, which takes this skill's output directory as its input. ## ROLE @@ -249,4 +249,4 @@ If the user gives a topic but no platform: default to `instagram`, ask once if L Defaults: `--slides 8 --platform instagram --aspect portrait --text-mode embedded --model auto`. Without `--execute`, returns prompts + caption text for manual paste. With `--execute`, generates slides. -This skill is downstream of `research-brief` (consumes the brief) and upstream of any manual post — final step is uploading the slides to the platform's UI. +This skill is downstream of `research-brief` (consumes the brief) and upstream of `post-publisher`, which reads this skill's output directory — slides plus `captions.md` — and sends it to Instagram / Threads / TikTok / LinkedIn. When the user says "и выложи" after a deck is generated, hand the output directory to `post-publisher`; it dry-runs by default, so nothing goes out without a confirmation. diff --git a/common/runners/atomicfile.py b/common/runners/atomicfile.py new file mode 100644 index 0000000..efa6a5b --- /dev/null +++ b/common/runners/atomicfile.py @@ -0,0 +1,46 @@ +"""Atomic writes for files that hold secrets. + +Both credential stores need identical semantics — never leave a truncated file +behind, never widen permissions, never leave a readable temp file lying around +if the write fails: + + ~/.skills.env keysfile.py — long-lived app keys, KEY=VALUE + ~/.skills-tokens.json tokens.py — short-lived OAuth tokens, JSON + +mkstemp() creates with 0600 already; the explicit chmod guards against an +inherited umask on exotic filesystems, and os.replace() preserves the mode. +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +SECRET_MODE = 0o600 + + +def write_secret_text(path: Path, content: str) -> None: + """Write `content` to `path` atomically with 0600 permissions.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + os.chmod(tmp_path, SECRET_MODE) + os.replace(tmp_path, path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def ensure_secure_perms(path: Path) -> None: + """Best-effort chmod 600. Silent on failure — never break a runner over it.""" + if path.is_file(): + try: + path.chmod(SECRET_MODE) + except OSError: + pass diff --git a/common/runners/cli/auth.py b/common/runners/cli/auth.py new file mode 100644 index 0000000..ee5e6c8 --- /dev/null +++ b/common/runners/cli/auth.py @@ -0,0 +1,166 @@ +"""`python3 -m common.runners.cli.auth` — connect social accounts. + +Three subcommands, because there are genuinely three situations: + + auth --platform threads run the OAuth flow in a browser + auth --platform instagram --paste-token paste a token from the platform's + own token tool (the supported route + wherever loopback redirects are not) + auth --status what is connected, and for how long + +Nothing here ever prints a token. `--status` shows a mask and an expiry. +""" + +from __future__ import annotations + +import argparse +import getpass +import sys +import time + +from .. import config, oauth, tokens +from ..errors import RunnerError, TokenError + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="common.runners.cli.auth", + description="Connect, inspect and disconnect social accounts for publishing.", + ) + p.add_argument("--platform", help="platform slug (see cli.publish --list-platforms)") + p.add_argument("--paste-token", action="store_true", help="paste a token instead of running the flow") + p.add_argument("--expires-in", type=float, help="token lifetime in seconds (with --paste-token)") + p.add_argument("--status", action="store_true", help="list connected accounts") + p.add_argument("--revoke", metavar="PLATFORM", help="forget the stored token for a platform") + p.add_argument("--verify", action="store_true", help="re-check the stored token against the platform") + return p + + +def _publisher(name: str): + config.load_all_publishers() + try: + return config.get_publisher(name) + except KeyError as exc: + # str(KeyError) re-quotes the message; args[0] is the sentence we wrote. + raise RunnerError(exc.args[0]) from exc + + +def cmd_status() -> int: + print(f"# {tokens.TOKENS_FILE}") + for line in tokens.status_lines(): + print(line) + return 0 + + +def cmd_revoke(platform: str) -> int: + if tokens.remove(platform): + print(f"Forgot the local token for {platform}.") + print( + "Note: this only clears it here. To cut the app's access for good, " + "remove it in the platform's own app/security settings." + ) + return 0 + print(f"No stored token for {platform}.", file=sys.stderr) + return 1 + + +def cmd_verify(platform: str) -> int: + pub = _publisher(platform) + try: + token = tokens.get_valid(platform) + except TokenError as exc: + print(str(exc), file=sys.stderr) + return 2 + try: + account_id, label = pub.verify_token(token) + except NotImplementedError: + print(f"{platform}: no verification endpoint implemented; token is present but unchecked.") + return 0 + except RunnerError as exc: + print(f"{platform}: token rejected — {exc}", file=sys.stderr) + return 2 + print(f"{platform}: OK — {label or account_id}") + return 0 + + +def cmd_paste(platform: str, expires_in: float | None) -> int: + pub = _publisher(platform) + if sys.stdin.isatty(): + token = getpass.getpass(prompt=f" access token for {platform} (not echoed): ").strip() + else: + token = sys.stdin.readline().strip() + if not token: + print("empty token — nothing stored", file=sys.stderr) + return 2 + + account_id = label = "" + try: + account_id, label = pub.verify_token(token) + except NotImplementedError: + print(f"warning: {platform} has no verification endpoint — storing unverified.", file=sys.stderr) + except RunnerError as exc: + # Refuse to store a token we already know is bad; the whole point of + # verifying here is to fail now rather than mid-publish. + print(f"{platform}: token rejected — {exc}", file=sys.stderr) + return 2 + + tokens.save( + tokens.TokenEntry( + platform=platform, + access_token=token, + expires_at=(time.time() + expires_in) if expires_in else None, + account_id=account_id, + account_label=label, + ) + ) + where = label or account_id or "(account unknown)" + print(f"Stored token for {platform} → {where}") + if not expires_in: + print(" No expiry recorded. Pass --expires-in if this token is short-lived.") + return 0 + + +def cmd_connect(platform: str) -> int: + pub = _publisher(platform) + app = pub.oauth_app() + if app is None: + print( + f"{platform} does not use OAuth — set its keys with skills-keys instead " + f"({', '.join(pub.requires_env) or 'no keys required'}).", + file=sys.stderr, + ) + return 2 + + raw = oauth.run_flow(app) + entry = pub.finalize_auth(raw) + tokens.save(entry) + where = entry.account_label or entry.account_id or "(account unknown)" + print(f"\nConnected {platform} → {where}") + print(f" token expires in {entry.expires_in_human()}") + return 0 + + +def main() -> int: + args = build_parser().parse_args() + config.load_all_publishers() + + try: + if args.status: + return cmd_status() + if args.revoke: + return cmd_revoke(args.revoke) + if not args.platform: + print("missing --platform (or use --status / --revoke).", file=sys.stderr) + return 2 + if args.verify: + return cmd_verify(args.platform) + if args.paste_token: + return cmd_paste(args.platform, args.expires_in) + return cmd_connect(args.platform) + except RunnerError as exc: + print(str(exc), file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/common/runners/cli/keys.py b/common/runners/cli/keys.py index 1c1fe2b..585a15e 100644 --- a/common/runners/cli/keys.py +++ b/common/runners/cli/keys.py @@ -196,6 +196,32 @@ def _cmd_export(args: argparse.Namespace) -> int: return 0 +def _cmd_accounts(args: argparse.Namespace) -> int: + """Social accounts live in a second store (~/.skills-tokens.json) because + they expire and these do not. Surfaced here anyway — this is the skill + people open when they are looking for "where do my credentials live".""" + from .. import tokens + from ..errors import RunnerError + from . import auth + + # Call the command functions directly rather than rewriting sys.argv and + # re-entering auth.main() — that left argv clobbered for anything running + # after this in the same process. + try: + if args.connect: + return auth.cmd_connect(args.connect) + if args.revoke: + return auth.cmd_revoke(args.revoke) + except RunnerError as exc: + print(str(exc), file=sys.stderr) + return 2 + + print(f"# {tokens.TOKENS_FILE}") + for line in tokens.status_lines(): + print(line) + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="common.runners.cli.keys", @@ -239,6 +265,11 @@ def build_parser() -> argparse.ArgumentParser: p_export.add_argument("--mask", action="store_true", help="mask values (for inspection)") p_export.set_defaults(func=_cmd_export) + p_accounts = sub.add_parser("accounts", help="connected social accounts (publishing)") + p_accounts.add_argument("--connect", metavar="PLATFORM", help="run the OAuth flow for a platform") + p_accounts.add_argument("--revoke", metavar="PLATFORM", help="forget a platform's stored token") + p_accounts.set_defaults(func=_cmd_accounts) + return parser diff --git a/common/runners/cli/publish.py b/common/runners/cli/publish.py new file mode 100644 index 0000000..bc76724 --- /dev/null +++ b/common/runners/cli/publish.py @@ -0,0 +1,422 @@ +"""`python3 -m common.runners.cli.publish` — send finished assets to platforms. + +Unlike every other CLI in this package, the default action is to do NOTHING. +Generation is retryable; publication is not. So: + + publish ./generated/carousel/foo --platform threads → dry-run preview + publish ./generated/carousel/foo --platform threads --yes → asks, then posts + +`--yes` is not "skip the prompt" here (that is what it means for cost +confirmation elsewhere) — it is "leave dry-run mode". The per-platform +confirmation still happens, because approving an Instagram post is not +approval to also post to X. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from .. import config, receipts +from ..errors import PublishError, RunnerError +from ..publishers.base import IMAGE_EXTS, VIDEO_EXTS, Post, PostKind, Publisher + +CAPTION_FILE = "captions.md" + +# Headings under which carousel-builder / reel-builder put the main post body. +# Tolerant on purpose: captions.md is written by an agent, not by a runner, so +# the shape is a convention rather than a guarantee. Whatever gets extracted is +# shown in the dry-run preview, which is where a bad parse gets caught. +_MAIN_HEADING = re.compile( + r"^#{1,6}\s*(main post|post caption|caption|post copy|основной пост|подпись)\b.*$", + re.IGNORECASE, +) +_ANY_HEADING = re.compile(r"^#{1,6}\s+") + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="common.runners.cli.publish", + description="Publish finished assets to social platforms. Dry-run unless --yes.", + ) + p.add_argument("source", nargs="?", help="output dir (./generated/...) or a single media file") + p.add_argument("--platform", "--platforms", dest="platform", help="comma-separated: telegram,threads,...") + p.add_argument("--kind", choices=["text", "image", "carousel", "video"], help="override auto-detection") + p.add_argument("--text", help="caption/body (overrides captions.md)") + p.add_argument("--text-file", type=Path, help="read caption from this file verbatim") + p.add_argument("--title", default="", help="video/article title (YouTube, LinkedIn)") + p.add_argument("--hashtags", default="", help="comma-separated, with or without '#'") + p.add_argument("--alt", action="append", default=[], help="alt text, repeat per media file in order") + p.add_argument("--link", help="URL to attach where the platform supports it") + p.add_argument("--draft", action="store_true", help="create a draft instead of publishing") + p.add_argument("--yes", action="store_true", help="leave dry-run mode (still asks per platform)") + p.add_argument("--dry-run", action="store_true", help="explicit no-op preview (the default)") + p.add_argument("--force", action="store_true", help="publish even if posted.json says it already went out") + p.add_argument("--list-platforms", action="store_true", help="show platforms and their readiness") + p.add_argument("--check", action="store_true", help="readiness check for --platform, no posting") + p.add_argument( + "--publish-container", + metavar="ID", + help="publish a container left by an earlier --draft run (Meta platforms)", + ) + return p + + +# ── building the Post ─────────────────────────────────────────────────────── + + +def extract_caption(text: str) -> str: + """Pull the main post body out of a captions.md, or fall back to the lot.""" + lines = text.splitlines() + start = None + for i, line in enumerate(lines): + if _MAIN_HEADING.match(line.strip()): + start = i + 1 + break + + if start is None: + # No recognised heading — use everything, minus heading lines. + body = "\n".join(ln for ln in lines if not _ANY_HEADING.match(ln)) + return body.strip() + + collected = [] + for line in lines[start:]: + if _ANY_HEADING.match(line.strip()): + break + collected.append(line) + return "\n".join(collected).strip() + + +def _sorted_media(paths: list[Path]) -> list[Path]: + """Numeric-aware sort so slide-2 precedes slide-10.""" + + def key(p: Path): + nums = [int(n) for n in re.findall(r"\d+", p.stem)] + return (nums, p.name) + + return sorted(paths, key=key) + + +def discover_media(source: Path) -> tuple[list[Path], PostKind]: + """Work out what a generated output dir is holding.""" + if source.is_file(): + ext = source.suffix.lower() + if ext in VIDEO_EXTS: + return [source], "video" + if ext in IMAGE_EXTS: + return [source], "image" + raise RunnerError(f"unsupported file type: {source.name}") + + final = source / "final.mp4" + if final.is_file(): + return [final], "video" + + videos = _sorted_media([p for p in source.glob("*") if p.suffix.lower() in VIDEO_EXTS]) + if videos: + return ([videos[0]], "video") if len(videos) == 1 else (videos, "carousel") + + images = _sorted_media([p for p in source.glob("*") if p.suffix.lower() in IMAGE_EXTS]) + if len(images) > 1: + return images, "carousel" + if len(images) == 1: + return images, "image" + + return [], "text" + + +def build_post(args: argparse.Namespace) -> tuple[Post, Path | None]: + """Returns the Post plus the directory that should hold posted.json.""" + media: list[Path] = [] + kind: PostKind = "text" + receipt_dir: Path | None = None + + if args.source: + source = Path(args.source).expanduser() + if not source.exists(): + raise RunnerError(f"source not found: {source}") + media, kind = discover_media(source) + receipt_dir = source if source.is_dir() else source.parent + + if args.text is not None: + caption = args.text + elif args.text_file: + caption = args.text_file.read_text(encoding="utf-8").strip() + elif receipt_dir and (receipt_dir / CAPTION_FILE).is_file(): + caption = extract_caption((receipt_dir / CAPTION_FILE).read_text(encoding="utf-8")) + else: + caption = "" + + if args.kind: + kind = args.kind + + hashtags = tuple(h.strip() for h in args.hashtags.split(",") if h.strip()) + + post = Post( + kind=kind, + text=caption, + media=tuple(media), + alt_texts=tuple(args.alt), + title=args.title, + link=args.link, + hashtags=hashtags, + ) + return post, receipt_dir + + +# ── presentation ──────────────────────────────────────────────────────────── + + +def preview(post: Post, pub: Publisher, *, draft: bool) -> str: + rendered = post.rendered_text() + action = "DRAFT" if draft else "PUBLISH" + out = [ + f" → {action} to {pub.name}", + f" kind: {post.kind}", + ] + if post.title: + out.append(f" title: {post.title}") + if rendered: + body = rendered if len(rendered) <= 600 else rendered[:600] + f"… (+{len(rendered) - 600} chars)" + indented = "\n".join(f" {ln}" for ln in body.splitlines()) + out.append(f" text ({len(rendered)} chars):\n{indented}") + for i, m in enumerate(post.media): + size = m.stat().st_size / (1024 * 1024) if m.is_file() else 0 + alt = post.alt_for(i) + alt_note = f' alt="{alt[:40]}"' if alt else " (no alt)" + out.append(f" media: {m.name} {size:.1f} MB{alt_note}") + return "\n".join(out) + + +def ask(question: str) -> bool: + if not sys.stdin.isatty(): + sys.stderr.write(f"\n{question} — stdin is not a TTY, refusing to assume yes.\n") + return False + sys.stderr.write(f"\n{question} [y/N] ") + sys.stderr.flush() + return sys.stdin.readline().strip().lower() in {"y", "yes"} + + +def list_platforms() -> int: + pubs = config.all_publishers() + if not pubs: + print("No publishers registered.", file=sys.stderr) + return 1 + print("Platforms:") + for p in pubs: + if not p.available(): + state = f"missing env: {', '.join(p.missing_env())}" + elif not p.token_ready(): + state = "configured, not authorised — run cli.auth" + else: + state = "ready" + draft = " · draft" if p.supports_draft else "" + print(f" {p.name:12s} {state:48s} [{', '.join(sorted(p.supports))}{draft}]") + return 0 + + +def publish_container(args: argparse.Namespace, names: list[str], *, live: bool) -> int: + """Second half of a --draft run: post the container that was staged then.""" + container_id = args.publish_container + if len(names) != 1: + print("--publish-container takes exactly one --platform", file=sys.stderr) + return 2 + try: + pub = config.get_publisher(names[0]) + except KeyError as exc: + print(exc.args[0], file=sys.stderr) + return 2 + if not hasattr(pub, "publish_container"): + print(f"{pub.name} has no staged-container concept", file=sys.stderr) + return 2 + if not live: + print(f"DRY RUN — would publish container {container_id} on {pub.name}. Add --yes.") + return 0 + if not ask(f"PUBLISH staged container {container_id} on {pub.name}?"): + print("— declined") + return 0 + try: + result = pub.publish_container(container_id) + except RunnerError as exc: + print(f"✗ {pub.name}: {exc}", file=sys.stderr) + return 1 + print(f"✓ {result.display()}") + + # Record it when the source directory is known. Without this the container + # goes live and nothing says so, and the next plain --yes run over the same + # directory would happily publish it a second time. + if args.source: + try: + post, receipt_dir = build_post(args) + except RunnerError: + receipt_dir = None + if receipt_dir: + receipts.record(receipt_dir, result, post.content_hash()) + print(f"Receipt: {receipts.path_for(receipt_dir)}") + else: + print( + " No source directory given, so no receipt was written — pass the " + "output dir alongside --publish-container to keep posted.json honest." + ) + return 0 + + +def check(names: list[str]) -> int: + rc = 0 + for name in names: + try: + pub = config.get_publisher(name) + except KeyError as exc: + print(str(exc), file=sys.stderr) + rc = 2 + continue + if not pub.available(): + print(f"{pub.name}: missing env: {', '.join(pub.missing_env())}", file=sys.stderr) + rc = 2 + elif not pub.token_ready(): + print( + f"{pub.name}: no usable token. " + f"Run: python3 -m common.runners.cli.auth --platform {pub.name}", + file=sys.stderr, + ) + rc = 2 + else: + print(f"{pub.name}: ready") + return rc + + +# ── main ──────────────────────────────────────────────────────────────────── + + +def main() -> int: + args = build_parser().parse_args() + config.load_all_publishers() + + if args.list_platforms: + return list_platforms() + + if not args.platform: + print("missing --platform. Use --list-platforms to see options.", file=sys.stderr) + return 2 + + names = [n.strip() for n in args.platform.split(",") if n.strip()] + + if args.check: + return check(names) + + if args.publish_container: + return publish_container(args, names, live=args.yes and not args.dry_run) + + try: + post, receipt_dir = build_post(args) + except RunnerError as exc: + print(str(exc), file=sys.stderr) + return 2 + + live = args.yes and not args.dry_run + content_hash = post.content_hash() + if not live: + print("DRY RUN — nothing will be sent. Add --yes to publish for real.\n") + + published: list[str] = [] + skipped: list[str] = [] + failed: list[str] = [] + + for name in names: + try: + pub = config.get_publisher(name) + except KeyError as exc: + print(f"✗ {exc.args[0]}", file=sys.stderr) + failed.append(name) + continue + + # The user asked for a draft. If this platform has no draft concept, + # quietly publishing live would betray that intent — skip instead. + if args.draft and not pub.supports_draft: + print(f"— {pub.name}: skipped, no draft support (drop --draft to publish live)") + skipped.append(name) + continue + draft = args.draft + + violations = pub.preflight(post, draft=draft) + blocking = [v for v in violations if v.severity == "block"] + for v in violations: + print(f" {v}") + if blocking: + print(f"✗ {pub.name}: {len(blocking)} blocking issue(s) — not sent", file=sys.stderr) + failed.append(name) + continue + + print(preview(post, pub, draft=draft)) + + # Both of these decide the outcome, so a dry run has to evaluate them + # too — a preview that says "would proceed" about a post that is + # already out, or that would die on a missing token, is worse than no + # preview at all. + prior = ( + receipts.find_blocking(receipt_dir, pub.name, content_hash, drafting=draft) + if receipt_dir and not args.force + else None + ) + if prior: + what = "staged as a draft" if prior.state == "draft" else prior.state + print( + f"— {pub.name}: skipped, identical content already {what} " + f"at {prior.published_at} ({prior.permalink or 'no permalink'}). Use --force." + ) + skipped.append(name) + continue + + if not pub.available(): + print(f"✗ {pub.name}: missing env: {', '.join(pub.missing_env())}", file=sys.stderr) + failed.append(name) + continue + + if not pub.token_ready(): + print( + f"✗ {pub.name}: not authorised. " + f"Run: python3 -m common.runners.cli.auth --platform {pub.name}", + file=sys.stderr, + ) + failed.append(name) + continue + + if not live: + continue + + verb = "Create draft on" if draft else "PUBLISH to" + if not ask(f"{verb} {pub.name}?"): + print(f"— {pub.name}: declined") + skipped.append(name) + continue + + try: + result = pub.publish(post, draft=draft) + except (PublishError, RunnerError) as exc: + print(f"✗ {pub.name}: {exc}", file=sys.stderr) + failed.append(name) + continue + + print(f"✓ {result.display()}") + if receipt_dir: + receipts.record(receipt_dir, result, content_hash) + published.append(name) + + print() + if not live: + ready = len(names) - len(failed) - len(skipped) + print( + f"Dry run complete — {ready} of {len(names)} platform(s) would proceed " + f"({len(skipped)} skipped, {len(failed)} blocked). Add --yes to publish." + ) + return 1 if failed else 0 + + print(f"Published: {len(published)} · skipped: {len(skipped)} · failed: {len(failed)}") + if receipt_dir and published: + print(f"Receipt: {receipts.path_for(receipt_dir)}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/common/runners/config.py b/common/runners/config.py index 2cb2bb7..b1ff80e 100644 --- a/common/runners/config.py +++ b/common/runners/config.py @@ -14,8 +14,10 @@ if TYPE_CHECKING: from .providers.base import Modality, Provider + from .publishers.base import Publisher _REGISTRY: dict[str, "Provider"] = {} +_PUBLISHERS: dict[str, "Publisher"] = {} def register(provider: "Provider") -> None: @@ -82,3 +84,50 @@ def load_all_providers() -> None: runway, suno, ) + + +# ─────────────────────────────────────────────────────────────────────────── +# Publisher registry — same self-registration pattern, separate namespace. +# A publisher named "instagram" must not collide with a provider slug. +# ─────────────────────────────────────────────────────────────────────────── + + +def register_publisher(publisher: "Publisher") -> None: + _PUBLISHERS[publisher.name] = publisher + + +def get_publisher(name: str) -> "Publisher": + try: + return _PUBLISHERS[name] + except KeyError as exc: + known = ", ".join(sorted(_PUBLISHERS)) or "(none registered yet)" + raise KeyError(f"unknown platform '{name}'. Known: {known}") from exc + + +def all_publishers() -> list["Publisher"]: + return sorted(_PUBLISHERS.values(), key=lambda p: p.name) + + +def load_all_publishers() -> None: + """Force-import every publisher module so they self-register. + + Mirrors load_all_providers(), including the ~/.skills.env pull — app-level + creds (META_APP_ID, TELEGRAM_BOT_TOKEN, ...) live there alongside the + generation keys. Short-lived user tokens live in ~/.skills-tokens.json and + are read on demand by the tokens module, not loaded into os.environ. + """ + try: + from . import keysfile + + keysfile.load_into_env(override=False) + except Exception: # noqa: BLE001 — never break runners over keysfile errors + pass + from .publishers import ( # noqa: F401 + instagram, + linkedin, + telegram, + threads, + tiktok, + x, + youtube, + ) diff --git a/common/runners/errors.py b/common/runners/errors.py index 599289c..d7a49c2 100644 --- a/common/runners/errors.py +++ b/common/runners/errors.py @@ -10,12 +10,12 @@ class RunnerError(Exception): class KeyMissingError(RunnerError): """Required env var is missing or empty.""" - def __init__(self, provider: str, env_vars: list[str]): + def __init__(self, provider: str, env_vars: list[str], kind: str = "provider"): self.provider = provider self.env_vars = env_vars joined = " / ".join(env_vars) super().__init__( - f"provider '{provider}' requires env var(s): {joined}. " + f"{kind} '{provider}' requires env var(s): {joined}. " f"Set them in your shell (.zshrc/.bashrc) or source a .env file." ) @@ -54,3 +54,68 @@ class CostConfirmationDeclined(RunnerError): def __init__(self, estimated_usd: float): super().__init__(f"user declined cost confirmation (estimated ${estimated_usd:.4f})") + + +# ─────────────────────────────────────────────────────────────────────────── +# Publishing layer (common/runners/publishers/) +# ─────────────────────────────────────────────────────────────────────────── + + +class PublishError(RunnerError): + """Platform API refused the post.""" + + def __init__(self, platform: str, status: int | None, message: str): + self.platform = platform + self.status = status + self.message = message + prefix = f"[{platform}]" if status is None else f"[{platform} {status}]" + super().__init__(f"{prefix} {message}") + + +class RateLimitError(PublishError): + """Platform returned 429 or a documented posting-cap error.""" + + +class TokenError(RunnerError): + """OAuth token is missing, expired, or could not be refreshed.""" + + def __init__(self, platform: str, reason: str): + self.platform = platform + super().__init__( + f"no usable token for '{platform}': {reason}. " + f"Run: python3 -m common.runners.cli.auth --platform {platform}" + ) + + +class PreflightFailed(RunnerError): + """One or more blocking violations — nothing was sent.""" + + def __init__(self, platform: str, violations: list[str]): + self.platform = platform + self.violations = violations + joined = "\n - ".join(violations) + super().__init__(f"preflight failed for '{platform}':\n - {joined}") + + +class UnsupportedPost(RunnerError): + """Platform cannot carry this post kind (e.g. carousel on YouTube).""" + + def __init__(self, platform: str, kind: str, supported: list[str]): + self.platform = platform + self.kind = kind + super().__init__( + f"'{platform}' does not support {kind} posts. Supported: {', '.join(sorted(supported))}" + ) + + +class AlreadyPublished(RunnerError): + """A receipt records this exact content already going to this platform.""" + + def __init__(self, platform: str, permalink: str | None, when: str): + self.platform = platform + self.permalink = permalink + where = permalink or "(no permalink recorded)" + super().__init__( + f"identical content already published to '{platform}' at {when}: {where}. " + f"Pass --force to post it again." + ) diff --git a/common/runners/keysfile.py b/common/runners/keysfile.py index da3d348..50ebbba 100644 --- a/common/runners/keysfile.py +++ b/common/runners/keysfile.py @@ -15,10 +15,10 @@ import os import re -import tempfile from dataclasses import dataclass from pathlib import Path +from . import atomicfile KEYS_FILE = Path(os.environ.get("SKILLS_KEYS_FILE", str(Path.home() / ".skills.env"))) _KV_RE = re.compile(r"^([A-Z][A-Z0-9_]*)\s*=\s*(.*)$") @@ -50,11 +50,7 @@ def _strip_quotes(s: str) -> str: def ensure_secure_perms() -> None: - if KEYS_FILE.is_file(): - try: - KEYS_FILE.chmod(0o600) - except OSError: - pass + atomicfile.ensure_secure_perms(KEYS_FILE) def read_all() -> list[KeyEntry]: @@ -148,18 +144,7 @@ def load_into_env(*, override: bool = False) -> int: def _atomic_write(content: str) -> None: """Write KEYS_FILE atomically (write to temp + rename).""" - KEYS_FILE.parent.mkdir(parents=True, exist_ok=True) - fd, tmp_path = tempfile.mkstemp(prefix=".skills.env.", dir=str(KEYS_FILE.parent)) - try: - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(content) - os.replace(tmp_path, KEYS_FILE) - except Exception: - try: - os.unlink(tmp_path) - except OSError: - pass - raise + atomicfile.write_secret_text(KEYS_FILE, content) def export_lines(*, mask_values: bool = False) -> list[str]: diff --git a/common/runners/oauth.py b/common/runners/oauth.py new file mode 100644 index 0000000..45bb50a --- /dev/null +++ b/common/runners/oauth.py @@ -0,0 +1,226 @@ +"""OAuth 2.0 authorization-code flow with a one-shot loopback listener. + +Six of the seven platforms want the same dance with different nouns, so the +dance lives here and each publisher supplies an OAuthApp describing its +endpoints, scopes and quirks. + +Two things this module refuses to hide: + + · The redirect URI must match what you registered with the platform, exactly. + Some platforms (Meta, TikTok) reject plain http://localhost, which is why + `cli.auth --paste-token` exists as a first-class path rather than a + workaround — for those, pasting a token from the platform's own token tool + is the supported route, not a lesser one. + + · State is verified, and PKCE is used wherever the platform supports it. + A loopback listener is a local open port; treating the callback as trusted + input would be careless. +""" + +from __future__ import annotations + +import base64 +import hashlib +import os +import secrets +import time +import urllib.parse +import webbrowser +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +import requests + +from .errors import RunnerError + +DEFAULT_PORT = int(os.environ.get("SKILLS_OAUTH_PORT", "8723")) +DEFAULT_REDIRECT = os.environ.get("SKILLS_OAUTH_REDIRECT", f"http://localhost:{DEFAULT_PORT}/callback") +FLOW_TIMEOUT = 300.0 + +_APPS: dict[str, "OAuthApp"] = {} + + +@dataclass +class OAuthApp: + platform: str + authorize_url: str + token_url: str + scopes: tuple[str, ...] + client_id_env: str + client_secret_env: str + use_pkce: bool = False + scope_sep: str = " " + extra_authorize_params: dict[str, str] = field(default_factory=dict) + extra_token_params: dict[str, str] = field(default_factory=dict) + # TikTok names its client credentials client_key/client_secret rather than + # client_id/client_secret, and rejects the standard names outright. + client_id_param: str = "client_id" + client_secret_param: str = "client_secret" + setup_url: str = "" + + def client_id(self) -> str | None: + return os.environ.get(self.client_id_env) + + def client_secret(self) -> str | None: + return os.environ.get(self.client_secret_env) + + def configured(self) -> bool: + return bool(self.client_id() and self.client_secret()) + + +def register_app(app: OAuthApp) -> None: + _APPS[app.platform] = app + + +def get_app(platform: str) -> OAuthApp | None: + return _APPS.get(platform) + + +# ── loopback listener ─────────────────────────────────────────────────────── + + +class _CallbackHandler(BaseHTTPRequestHandler): + result: dict[str, str] = {} + + def do_GET(self): # noqa: N802 — BaseHTTPRequestHandler's naming + parsed = urllib.parse.urlparse(self.path) + if not parsed.path.startswith("/callback"): + self.send_response(404) + self.end_headers() + return + + params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()} + _CallbackHandler.result = params + + ok = "code" in params + body = ( + "

Authorised.

You can close this tab and return to the terminal.

" + if ok + else f"

Authorisation failed.

{params.get('error_description', params)}
" + ) + payload = f"{body}" + self.send_response(200 if ok else 400) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(payload.encode("utf-8")) + + def log_message(self, *args): # silence the default stderr access log + pass + + +def _listen_once(port: int) -> dict[str, str]: + """Serve until the callback arrives or FLOW_TIMEOUT elapses.""" + _CallbackHandler.result = {} + try: + server = HTTPServer(("localhost", port), _CallbackHandler) + except OSError as exc: + raise RunnerError( + f"cannot listen on localhost:{port} ({exc}). " + f"Something else is using it — set SKILLS_OAUTH_PORT to a free port " + f"and register the matching redirect URI with the platform." + ) from exc + + # Loop rather than a single handle_request(): browsers routinely fetch + # /favicon.ico against the loopback origin, and a one-shot listener would + # spend its only request on that and then miss the callback entirely. + # Non-/callback paths get a 404 and the loop keeps waiting. + server.timeout = 1.0 + deadline = time.monotonic() + FLOW_TIMEOUT + try: + while not _CallbackHandler.result and time.monotonic() < deadline: + server.handle_request() + finally: + server.server_close() + + if not _CallbackHandler.result: + raise RunnerError(f"no callback received within {FLOW_TIMEOUT:.0f}s — authorisation abandoned") + return dict(_CallbackHandler.result) + + +# ── the flow ──────────────────────────────────────────────────────────────── + + +def _pkce_pair() -> tuple[str, str]: + verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).decode().rstrip("=") + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") + ) + return verifier, challenge + + +def run_flow(app: OAuthApp, *, redirect_uri: str = DEFAULT_REDIRECT, port: int = DEFAULT_PORT) -> dict[str, Any]: + """Open the browser, catch the callback, exchange the code. Returns the raw + token response so each publisher can read its own non-standard fields.""" + if not app.configured(): + missing = [ + e for e in (app.client_id_env, app.client_secret_env) if not os.environ.get(e) + ] + hint = f" See {app.setup_url}" if app.setup_url else "" + raise RunnerError( + f"{app.platform}: missing app credentials {', '.join(missing)} in ~/.skills.env.{hint}" + ) + + state = secrets.token_urlsafe(24) + verifier = challenge = None + + auth_params = { + app.client_id_param: app.client_id(), + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": app.scope_sep.join(app.scopes), + "state": state, + **app.extra_authorize_params, + } + if app.use_pkce: + verifier, challenge = _pkce_pair() + auth_params["code_challenge"] = challenge + auth_params["code_challenge_method"] = "S256" + + url = f"{app.authorize_url}?{urllib.parse.urlencode(auth_params)}" + print(f"Opening the browser to authorise {app.platform}.") + print(f"If nothing opens, visit:\n {url}\n") + webbrowser.open(url) + + params = _listen_once(port) + + if params.get("state") != state: + # Either a stale tab from an earlier attempt or something forged. Both + # deserve a hard stop rather than a token exchange. + raise RunnerError(f"{app.platform}: state mismatch on callback — authorisation rejected") + + if "code" not in params: + detail = params.get("error_description") or params.get("error") or str(params) + raise RunnerError(f"{app.platform}: authorisation denied — {detail}") + + token_params = { + app.client_id_param: app.client_id(), + app.client_secret_param: app.client_secret(), + "code": params["code"], + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + **app.extra_token_params, + } + if verifier: + token_params["code_verifier"] = verifier + + try: + resp = requests.post( + app.token_url, + data=token_params, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=60, + ) + except requests.RequestException as exc: + raise RunnerError(f"{app.platform}: token exchange failed: {exc}") from exc + + try: + payload = resp.json() + except ValueError: + raise RunnerError(f"{app.platform}: token endpoint returned non-JSON: {resp.text[:300]}") from None + + if resp.status_code >= 400 or "error" in payload: + detail = payload.get("error_description") or payload.get("error") or resp.text[:300] + raise RunnerError(f"{app.platform}: token exchange rejected: {detail}") + + return payload diff --git a/common/runners/publishers/__init__.py b/common/runners/publishers/__init__.py new file mode 100644 index 0000000..7bb02cc --- /dev/null +++ b/common/runners/publishers/__init__.py @@ -0,0 +1,6 @@ +"""Social-platform publishing layer. + +Each module registers one Publisher via config.register_publisher(). Import +them through config.load_all_publishers() rather than directly, so a broken +vendor module cannot crash `--list-platforms`. +""" diff --git a/common/runners/publishers/_meta.py b/common/runners/publishers/_meta.py new file mode 100644 index 0000000..f6cfdf1 --- /dev/null +++ b/common/runners/publishers/_meta.py @@ -0,0 +1,321 @@ +"""Shared machinery for Meta's two own-login publishing APIs. + +Threads (graph.threads.net) and Instagram (graph.instagram.com) are separate +products with separate app credentials, but since Meta shipped Instagram Login +they publish through an identical three-step shape: + + 1. create a media container POST /{user-id}/{container_path} + 2. wait for it to finish processing GET /{container-id}?fields= + 3. publish it POST /{user-id}/{publish_path} + +They also share the awkward part: neither accepts bytes. You hand them a URL +and they fetch it themselves, which is why S3 staging is mandatory for +anything with media, and why preflight blocks early rather than letting the +upload get halfway. + +Step 2 is not optional even for images. Publishing a container that is still +IN_PROGRESS fails with an error that does not say so. + +API versions are env-overridable (THREADS_API_VERSION / INSTAGRAM_API_VERSION) +so a version bump does not need a code change — Meta deprecates versions on a +schedule this repo cannot track. +""" + +from __future__ import annotations + +import os +import time +import uuid +from pathlib import Path +from typing import Any + +import requests + +from .. import oauth, tokens +from ..errors import PublishError, RateLimitError, RunnerError, TokenError +from .base import IMAGE_EXTS, Post, Publisher, PublishResult, Violation, mime_for + +# How long to wait for Meta to ingest a video before giving up. Reels routinely +# take 30-60s; images are near-instant. +CONTAINER_TIMEOUT = 300.0 +CONTAINER_POLL_INTERVAL = 3.0 + +# Presigned links must outlive Meta's fetch, which happens within seconds but +# retries on their side. An hour is generous and still self-expiring. +MEDIA_URL_TTL = 3600 + + +class MetaPublisher(Publisher): + """Base for the container → publish flow. Subclasses set the nouns.""" + + api_root: str + api_version_env: str + default_api_version: str + container_path: str # "threads" | "media" + publish_path: str # "threads_publish" | "media_publish" + status_field: str # "status" | "status_code" + text_param: str # "text" | "caption" + user_id_env: str # optional override for the account id + requires_oauth = True + supports_draft = True + needs_public_media_url = True + + # ── plumbing ──────────────────────────────────────────────────────────── + + def api_version(self) -> str: + return os.environ.get(self.api_version_env, self.default_api_version) + + def base_url(self) -> str: + return f"{self.api_root}/{self.api_version()}" + + def user_id(self) -> str: + """Account id, from the stored token unless explicitly overridden.""" + override = os.environ.get(self.user_id_env) + if override: + return override + entry = tokens.read(self.name) + if entry is None or not entry.account_id: + raise TokenError(self.name, "no account id stored") + return entry.account_id + + def _call( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + token: str | None = None, + timeout: float = 60.0, + ) -> dict[str, Any]: + url = path if path.startswith("http") else f"{self.base_url()}/{path.lstrip('/')}" + payload = dict(params or {}) + payload["access_token"] = token or tokens.get_valid(self.name) + + try: + if method == "GET": + resp = requests.get(url, params=payload, timeout=timeout) + else: + resp = requests.post(url, data=payload, timeout=timeout) + except requests.RequestException as exc: + raise PublishError(self.name, None, f"network error: {exc}") from exc + + try: + data = resp.json() + except ValueError: + raise PublishError(self.name, resp.status_code, resp.text[:400]) from None + + if "error" in data: + err = data["error"] + message = err.get("error_user_msg") or err.get("message") or str(err) + code = err.get("code") + # 4 / 17 / 32 / 613 are Meta's throttling family; 9 is the + # publishing-limit-reached code, which is a cap rather than a bug. + if code in {4, 9, 17, 32, 613} or resp.status_code == 429: + raise RateLimitError(self.name, resp.status_code, message) + raise PublishError(self.name, resp.status_code, message) + + if resp.status_code >= 400: + raise PublishError(self.name, resp.status_code, resp.text[:400]) + return data + + # ── media staging ─────────────────────────────────────────────────────── + + def _stage(self, path: Path) -> str: + from ..storage import s3 + + if not s3.s3_configured(): + raise PublishError( + self.name, + None, + "media staging needs S3 (S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY) — " + f"{self.name} fetches media by URL and cannot take raw bytes", + ) + key = f"publish/{self.name}/{uuid.uuid4().hex[:12]}/{path.name}" + return s3.stage_for_fetch(path, key, mime_for(path), ttl=MEDIA_URL_TTL) + + # ── container lifecycle ───────────────────────────────────────────────── + + def _media_params(self, path: Path, url: str) -> dict[str, Any]: + """Subclass hook: how this platform names an image vs a video.""" + raise NotImplementedError + + def _create_container(self, params: dict[str, Any]) -> str: + data = self._call("POST", f"{self.user_id()}/{self.container_path}", params=params) + container_id = data.get("id") + if not container_id: + raise PublishError(self.name, None, f"container creation returned no id: {data}") + return str(container_id) + + def _wait_for(self, container_id: str) -> None: + """Poll until the container is ready. Skipping this is the single most + common way a Reels publish fails with an unhelpful error.""" + deadline = time.time() + CONTAINER_TIMEOUT + last = "UNKNOWN" + # Threads reports progress in `status`, Instagram in `status_code`, and + # asking for both keeps one code path. dict.fromkeys dedupes so Threads + # does not request "status,status". + fields = ",".join(dict.fromkeys([self.status_field, "status"])) + while time.time() < deadline: + data = self._call("GET", container_id, params={"fields": fields}) + last = str(data.get(self.status_field) or data.get("status") or "UNKNOWN").upper() + if last in {"FINISHED", "PUBLISHED"}: + return + if last in {"ERROR", "EXPIRED"}: + detail = data.get("status") or data.get("error_message") or "" + raise PublishError(self.name, None, f"container {container_id} is {last}. {detail}") + time.sleep(CONTAINER_POLL_INTERVAL) + raise PublishError( + self.name, + None, + f"container {container_id} still {last} after {CONTAINER_TIMEOUT:.0f}s — " + f"Meta may still finish it; check the account before retrying", + ) + + def publish_container(self, container_id: str) -> PublishResult: + """Publish a container created earlier by a --draft run.""" + data = self._call( + "POST", f"{self.user_id()}/{self.publish_path}", params={"creation_id": container_id} + ) + media_id = str(data.get("id", "")) + return PublishResult( + platform=self.name, + post_id=media_id, + state="published", + permalink=self._permalink(media_id), + ) + + def _permalink(self, media_id: str) -> str | None: + if not media_id: + return None + try: + return self._call("GET", media_id, params={"fields": "permalink"}).get("permalink") + except RunnerError: + # A missing permalink is cosmetic — the post is already live, and + # failing here would wrongly report the publish as failed. + return None + + # ── publish ───────────────────────────────────────────────────────────── + + def publish(self, post: Post, *, draft: bool = False) -> PublishResult: + self.ensure_available() + text = post.rendered_text() + + if post.kind == "carousel": + children = [] + for path in post.media: + url = self._stage(path) + params = self._media_params(path, url) + params["is_carousel_item"] = "true" + child = self._create_container(params) + self._wait_for(child) + children.append(child) + container_id = self._create_container( + {"media_type": "CAROUSEL", "children": ",".join(children), self.text_param: text} + ) + elif post.media: + params = self._media_params(post.media[0], self._stage(post.media[0])) + params[self.text_param] = text + container_id = self._create_container(params) + else: + container_id = self._create_container({"media_type": "TEXT", self.text_param: text}) + + self._wait_for(container_id) + + if draft: + return PublishResult( + platform=self.name, + post_id=container_id, + state="draft", + note=( + f"container staged and processed, NOT posted. It expires in 24h. " + f"Publish it with: --platform {self.name} --publish-container {container_id}" + ), + extra={"container_id": container_id}, + ) + + return self.publish_container(container_id) + + # ── authorisation ─────────────────────────────────────────────────────── + + def oauth_app(self): + return oauth.get_app(self.name) + + def verify_token(self, access_token: str) -> tuple[str, str]: + data = self._call("GET", "me", params={"fields": "id,username"}, token=access_token) + username = data.get("username", "") + return str(data.get("id", "")), (f"@{username}" if username else "") + + def finalize_auth(self, raw: dict[str, Any]): + short_lived = raw.get("access_token") + if not short_lived: + raise RunnerError(f"{self.name}: token response had no access_token") + long_lived, expires_in = self._exchange_long_lived(short_lived) + account_id, label = self.verify_token(long_lived) + return tokens.TokenEntry( + platform=self.name, + access_token=long_lived, + expires_at=time.time() + expires_in if expires_in else None, + scopes=list(self.oauth_scopes), + account_id=account_id or str(raw.get("user_id", "")), + account_label=label, + ) + + # Subclasses supply these — the exchange/refresh endpoints differ per product. + oauth_scopes: tuple[str, ...] = () + exchange_url: str = "" + exchange_grant: str = "" + refresh_url: str = "" + refresh_grant: str = "" + + def _exchange_long_lived(self, short_lived: str) -> tuple[str, float]: + """Trade the ~1h code-exchange token for the ~60-day one.""" + resp = requests.get( + self.exchange_url, + params={ + "grant_type": self.exchange_grant, + "client_secret": os.environ.get(self.secret_env, ""), + "access_token": short_lived, + }, + timeout=60, + ) + data = resp.json() if resp.content else {} + if resp.status_code >= 400 or "access_token" not in data: + raise RunnerError(f"{self.name}: long-lived token exchange failed: {resp.text[:300]}") + return data["access_token"], float(data.get("expires_in", 0)) + + def refresh(self, entry: tokens.TokenEntry) -> tokens.TokenEntry: + """Meta long-lived tokens renew using themselves — no refresh token.""" + resp = requests.get( + self.refresh_url, + params={"grant_type": self.refresh_grant, "access_token": entry.access_token}, + timeout=60, + ) + data = resp.json() if resp.content else {} + if resp.status_code >= 400 or "access_token" not in data: + raise RunnerError( + f"{self.name}: refresh failed ({resp.text[:200]}). " + f"A token unused for 60 days cannot be refreshed — re-run cli.auth." + ) + entry.access_token = data["access_token"] + entry.expires_at = time.time() + float(data.get("expires_in", 0)) + return entry + + secret_env: str = "" + + # ── preflight ─────────────────────────────────────────────────────────── + + def _extra_preflight(self, post: Post, *, draft: bool = False) -> list[Violation]: + v: list[Violation] = [] + if post.kind == "carousel" and len(post.media) < 2: + v.append(Violation("block", "media", "a carousel needs at least 2 items")) + for path in post.media: + if path.suffix.lower() not in IMAGE_EXTS and post.kind == "carousel": + v.append( + Violation( + "warn", + "media", + f"{path.name} is video in a carousel — supported, but it ingests " + f"slowly and fails more often than an all-image deck", + ) + ) + return v diff --git a/common/runners/publishers/base.py b/common/runners/publishers/base.py new file mode 100644 index 0000000..a15331a --- /dev/null +++ b/common/runners/publishers/base.py @@ -0,0 +1,388 @@ +"""Publisher ABC + shared dataclasses. + +Sibling of `providers/base.py`, deliberately NOT a subclass of it. A provider +turns a prompt into bytes and can be retried for free. A publisher takes bytes +that already exist and does something irreversible and outward-facing with +them. Different contract, different failure modes: + + provider generate(prompt, **kwargs) -> bytes · costs money · retryable + publisher publish(post, draft=...) -> permalink · costs nothing · NOT retryable + +So there is no `estimate_cost()` here. Instead there is `preflight()`, which +every caller must run before `publish()`, and a `supports_draft` flag so the +CLI can offer the safe path where a platform has one. +""" + +from __future__ import annotations + +import hashlib +import os +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from ..errors import KeyMissingError + +PostKind = Literal["text", "image", "carousel", "video"] +PostState = Literal["published", "draft", "pending_review"] +Severity = Literal["block", "warn"] + +# Extensions we recognise. Anything else gets flagged by preflight rather than +# being silently POSTed with a wrong Content-Type. +IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".webp"}) +VIDEO_EXTS = frozenset({".mp4", ".mov", ".m4v", ".webm"}) + +MIME_BY_EXT = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".m4v": "video/x-m4v", + ".webm": "video/webm", +} + +MB = 1024 * 1024 + + +def mime_for(path: Path) -> str: + return MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream") + + +@dataclass +class Violation: + """One preflight finding. + + `block` means publish() must not be called. `warn` means the post will go + through but something is off (truncation, unusual aspect, missing alt text). + """ + + severity: Severity + field: str + message: str + + def __str__(self) -> str: + mark = "BLOCK" if self.severity == "block" else "warn " + return f"{mark} {self.field}: {self.message}" + + +@dataclass +class Post: + """Platform-neutral envelope. One Post can be sent to several publishers. + + `text` is the caption/body as authored. `hashtags` stay separate so that + preflight can count them and each platform can place them where it belongs + (Instagram: in the caption; YouTube: in the description; TikTok: in title). + """ + + kind: PostKind + text: str = "" + media: tuple[Path, ...] = () + alt_texts: tuple[str, ...] = () + title: str = "" # YouTube video title, LinkedIn article title + link: str | None = None + thread: tuple[str, ...] = () # X threads / Threads reply chains + hashtags: tuple[str, ...] = () + extra: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + # Accept lists/strings/Paths from callers and normalise once, so every + # publisher downstream can assume tuples of Path. + if isinstance(self.media, (str, Path)): + self.media = (Path(self.media),) + else: + self.media = tuple(Path(m) for m in self.media) + self.alt_texts = tuple(self.alt_texts) + self.thread = tuple(self.thread) + self.hashtags = tuple(h.lstrip("#") for h in self.hashtags if h.strip()) + + def rendered_text(self, *, hashtag_sep: str = "\n\n") -> str: + """Caption with hashtags appended. What most platforms actually receive.""" + body = self.text.strip() + if not self.hashtags: + return body + tags = " ".join(f"#{h}" for h in self.hashtags) + return f"{body}{hashtag_sep}{tags}" if body else tags + + def alt_for(self, index: int) -> str: + return self.alt_texts[index] if index < len(self.alt_texts) else "" + + def content_hash(self) -> str: + """Stable fingerprint used for the duplicate-post receipt check. + + Media is fingerprinted by (name, size) rather than by bytes — hashing a + 1 GB video on every dry-run would be absurd, and a same-name same-size + file in the same output dir is the case we actually need to catch + (re-running the same publish command twice). + """ + h = hashlib.sha256() + h.update(self.kind.encode()) + h.update(b"\x00") + h.update(self.text.strip().encode()) + h.update(b"\x00") + h.update(self.title.strip().encode()) + h.update(b"\x00") + h.update(" ".join(self.hashtags).encode()) + for m in self.media: + h.update(b"\x00") + h.update(m.name.encode()) + try: + h.update(str(m.stat().st_size).encode()) + except OSError: + h.update(b"missing") + return h.hexdigest()[:16] + + +@dataclass +class PublishResult: + platform: str + post_id: str + state: PostState + permalink: str | None = None + note: str = "" # e.g. "landed in TikTok inbox — finish in the app" + extra: dict[str, Any] = field(default_factory=dict) + + def display(self) -> str: + head = f"{self.platform}: {self.state}" + if self.permalink: + head += f" → {self.permalink}" + elif self.post_id: + head += f" (id {self.post_id})" + return f"{head}\n {self.note}" if self.note else head + + +class Publisher(ABC): + """Platform adapter — subclass and implement publish().""" + + name: str # canonical slug: "telegram", "instagram", ... + requires_env: tuple[str, ...] = () # app-level creds from ~/.skills.env + requires_oauth: bool = False # user token from ~/.skills-tokens.json + supports: frozenset[PostKind] = frozenset() + supports_draft: bool = False + needs_public_media_url: bool = False # platform fetches media itself (Meta) + doc_url: str = "" + + # Generic limits — subclasses override with real numbers. None = no limit. + max_text_chars: int | None = None + max_title_chars: int | None = None + max_hashtags: int | None = None + min_media: int = 0 + max_media: int = 1 + max_image_mb: float | None = None + max_video_mb: float | None = None + + # ── availability ──────────────────────────────────────────────────────── + + def available(self) -> bool: + """True when app creds are present. OAuth token presence is checked + separately by `token_ready()` — a platform can be configured but not + yet authorised, and the two need distinct messages.""" + return all(os.environ.get(k) for k in self.requires_env) + + def missing_env(self) -> list[str]: + return [k for k in self.requires_env if not os.environ.get(k)] + + def ensure_available(self) -> None: + if not self.available(): + raise KeyMissingError(self.name, self.missing_env(), kind="platform") + + def token_ready(self) -> bool: + """Whether a usable OAuth token exists. Non-OAuth platforms are always ready.""" + if not self.requires_oauth: + return True + from .. import tokens + + return tokens.has_usable(self.name) + + # ── authorisation hooks (overridden by OAuth platforms) ───────────────── + + def oauth_app(self) -> Any | None: + """Return this platform's OAuthApp, or None if it needs no OAuth flow.""" + return None + + def finalize_auth(self, raw: dict[str, Any]) -> Any: + """Turn a raw token response into a TokenEntry. + + Kept per-platform because the responses barely agree on anything: some + return `expires_in`, some an absolute `expires_at`, some neither; the + account identifier may need a second call to fetch. + """ + raise NotImplementedError(f"{self.name} does not implement finalize_auth()") + + def verify_token(self, access_token: str) -> tuple[str, str]: + """Confirm a token works and return (account_id, account_label). + + Used both after the OAuth flow and by `--paste-token`, so that a bad + paste fails here rather than at 2 a.m. mid-publish. + """ + raise NotImplementedError(f"{self.name} does not implement verify_token()") + + # ── preflight ─────────────────────────────────────────────────────────── + + def preflight(self, post: Post, *, draft: bool = False) -> list[Violation]: + """Generic checks every platform needs, plus the subclass hook. + + Concrete rather than abstract on purpose: a subclass that forgets to + call super() would skip the file-exists check and fail mid-upload with + a confusing vendor error. Subclasses extend via `_extra_preflight()`. + + `draft` is passed through because it can change what is valid, not just + what happens afterwards — TikTok's audit caveat applies to a direct post + and is noise on a draft. + """ + v: list[Violation] = [] + + if post.kind not in self.supports: + v.append( + Violation( + "block", + "kind", + f"{self.name} does not support {post.kind} posts " + f"(supported: {', '.join(sorted(self.supports))})", + ) + ) + + text = post.rendered_text() + if self.max_text_chars is not None and len(text) > self.max_text_chars: + v.append( + Violation( + "block", + "text", + f"{len(text)} chars exceeds the {self.max_text_chars}-char limit " + f"(over by {len(text) - self.max_text_chars})", + ) + ) + + if self.max_title_chars is not None and len(post.title) > self.max_title_chars: + v.append( + Violation( + "block", + "title", + f"{len(post.title)} chars exceeds the {self.max_title_chars}-char limit", + ) + ) + + if self.max_hashtags is not None and len(post.hashtags) > self.max_hashtags: + v.append( + Violation( + "warn", + "hashtags", + f"{len(post.hashtags)} hashtags; {self.name} tolerates about " + f"{self.max_hashtags} before it reads as spam", + ) + ) + + n = len(post.media) + if n < self.min_media: + v.append(Violation("block", "media", f"needs at least {self.min_media} file(s), got {n}")) + if n > self.max_media: + v.append(Violation("block", "media", f"accepts at most {self.max_media} file(s), got {n}")) + + for m in post.media: + v.extend(self._check_file(m)) + + if post.media and not post.alt_texts: + v.append(Violation("warn", "alt_texts", "no alt text — hurts reach and accessibility")) + elif post.media and len(post.alt_texts) < len(post.media): + # Partial alt text is the likelier mistake than none at all: --alt is + # repeated per file, so it is easy to describe the first two slides + # and forget the rest. Naming the gap beats a generic nudge. + first_missing = len(post.alt_texts) + 1 + v.append( + Violation( + "warn", + "alt_texts", + f"{len(post.alt_texts)} alt texts for {len(post.media)} files — " + f"{'file' if first_missing == len(post.media) else 'files'} " + f"{first_missing}" + f"{'' if first_missing == len(post.media) else f'-{len(post.media)}'} " + f"will go without", + ) + ) + + if self.needs_public_media_url and post.media: + from ..storage import s3 + + if not s3.s3_configured(): + v.append( + Violation( + "block", + "media", + f"{self.name} fetches media by URL and cannot accept raw bytes. " + f"Set S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY in ~/.skills.env. " + f"(--draft does not avoid this: staging the container is what " + f"needs the URL. Without a bucket, post it by hand — see " + f"references/browser-fallback.md.)", + ) + ) + + if draft and not self.supports_draft: + v.append(Violation("block", "draft", f"{self.name} has no draft concept")) + + v.extend(self._extra_preflight(post, draft=draft)) + return v + + def _check_file(self, path: Path) -> list[Violation]: + v: list[Violation] = [] + if not path.is_file(): + v.append(Violation("block", "media", f"file not found: {path}")) + return v + + ext = path.suffix.lower() + size_bytes = path.stat().st_size + if size_bytes == 0: + v.append(Violation("block", "media", f"file is empty: {path.name}")) + return v + size_mb = size_bytes / MB + + if ext in IMAGE_EXTS: + if self.max_image_mb and size_mb > self.max_image_mb: + v.append( + Violation( + "block", + "media", + f"{path.name} is {size_mb:.1f} MB; {self.name} caps images at {self.max_image_mb} MB", + ) + ) + elif ext in VIDEO_EXTS: + if self.max_video_mb and size_mb > self.max_video_mb: + v.append( + Violation( + "block", + "media", + f"{path.name} is {size_mb:.1f} MB; {self.name} caps video at {self.max_video_mb} MB", + ) + ) + else: + v.append( + Violation( + "block", + "media", + f"unrecognised media type '{ext}' ({path.name}); " + f"expected one of {', '.join(sorted(IMAGE_EXTS | VIDEO_EXTS))}", + ) + ) + return v + + def _extra_preflight(self, post: Post, *, draft: bool = False) -> list[Violation]: # noqa: ARG002 + """Platform-specific rules. Override; default is no extra rules.""" + return [] + + # ── publish ───────────────────────────────────────────────────────────── + + @abstractmethod + def publish(self, post: Post, *, draft: bool = False) -> PublishResult: + """Send it. Callers must have run preflight() and confirmed with the user.""" + + def info(self) -> dict[str, Any]: + return { + "name": self.name, + "requires_env": list(self.requires_env), + "requires_oauth": self.requires_oauth, + "supports": sorted(self.supports), + "supports_draft": self.supports_draft, + "available": self.available(), + "token_ready": self.token_ready(), + } diff --git a/common/runners/publishers/instagram.py b/common/runners/publishers/instagram.py new file mode 100644 index 0000000..6543e05 --- /dev/null +++ b/common/runners/publishers/instagram.py @@ -0,0 +1,164 @@ +"""Instagram publishing via the Instagram API with Instagram Login. + +Two routes exist to publish on Instagram. This module takes the newer one — +`graph.instagram.com` with Instagram Login — because the older route +(`graph.facebook.com` with Facebook Login for Business) additionally requires +the account to be linked to a Facebook Page, which is a setup step most people +publishing their own content do not want and do not need. + +Still required, and there is no way around it: a Business or Creator account. +Instagram does not publish to personal accounts through any API. If that is a +blocker, the browser path in the skill's references/browser-fallback.md is the +honest alternative — not a different endpoint. + +Three constraints shape the code: + + · Media is fetched by Instagram from a URL. Local bytes are never accepted, + so S3 staging is mandatory and preflight blocks without it. + · Video is Reels. There is no "video feed post" any more; media_type=REELS + is the only video container that publishes. + · 25 published posts per rolling 24h. The account can be asked how much of + that it has spent, so it is asked before publishing rather than after. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .. import oauth, tokens +from ..errors import RunnerError +from .base import IMAGE_EXTS, Post, PublishResult, Violation +from ._meta import MetaPublisher + +API_ROOT = "https://graph.instagram.com" + +CAPTION_LIMIT = 2200 +HASHTAG_LIMIT = 30 +CAROUSEL_MIN = 2 +CAROUSEL_MAX = 10 +DAILY_POST_CAP = 25 + + +class InstagramPublisher(MetaPublisher): + name = "instagram" + requires_env = ("INSTAGRAM_APP_ID", "INSTAGRAM_APP_SECRET") + supports = frozenset({"image", "carousel", "video"}) # no text-only posts + doc_url = "https://developers.facebook.com/docs/instagram-platform/content-publishing" + + api_root = API_ROOT + api_version_env = "INSTAGRAM_API_VERSION" + default_api_version = "v21.0" + container_path = "media" + publish_path = "media_publish" + status_field = "status_code" + text_param = "caption" + user_id_env = "INSTAGRAM_USER_ID" + secret_env = "INSTAGRAM_APP_SECRET" + + max_text_chars = CAPTION_LIMIT + max_hashtags = HASHTAG_LIMIT + min_media = 1 + max_media = CAROUSEL_MAX + max_image_mb = 8.0 + max_video_mb = 1024.0 + + oauth_scopes = ("instagram_business_basic", "instagram_business_content_publish") + exchange_url = f"{API_ROOT}/access_token" + exchange_grant = "ig_exchange_token" + refresh_url = f"{API_ROOT}/refresh_access_token" + refresh_grant = "ig_refresh_token" + + def _media_params(self, path: Path, url: str) -> dict[str, Any]: + if path.suffix.lower() in IMAGE_EXTS: + return {"image_url": url} + # Video only publishes as a Reel; a plain video container is rejected. + return {"media_type": "REELS", "video_url": url} + + # ── posting cap ───────────────────────────────────────────────────────── + + def quota_used(self) -> int | None: + """Posts spent from the rolling 24h allowance, or None if unavailable.""" + try: + data = self._call( + "GET", + f"{self.user_id()}/content_publishing_limit", + params={"fields": "config,quota_usage"}, + ) + except RunnerError: + return None + rows = data.get("data") or [] + if not rows: + return None + return int(rows[0].get("quota_usage", 0)) + + def publish(self, post: Post, *, draft: bool = False) -> PublishResult: + # Asked before the work, not after: staging a Reel to S3 and waiting a + # minute for Meta to ingest it, only to be refused at the last step, + # wastes the upload and leaves an orphan container. + used = self.quota_used() + if used is not None and used >= DAILY_POST_CAP: + from ..errors import RateLimitError + + raise RateLimitError( + self.name, + None, + f"{used}/{DAILY_POST_CAP} posts used in the last 24h — Instagram will refuse this one", + ) + result = super().publish(post, draft=draft) + if used is not None and result.state == "published": + result.note = f"{used + 1}/{DAILY_POST_CAP} of the 24h posting allowance used" + return result + + # ── preflight ─────────────────────────────────────────────────────────── + + def _extra_preflight(self, post: Post, *, draft: bool = False) -> list[Violation]: + v = super()._extra_preflight(post, draft=draft) + + if post.kind == "carousel" and len(post.media) > CAROUSEL_MAX: + v.append(Violation("block", "media", f"an Instagram carousel holds at most {CAROUSEL_MAX} items")) + + if post.kind == "video": + v.append( + Violation( + "warn", + "media", + "video publishes as a Reel — there is no plain video feed post via the API", + ) + ) + + used = None + if self.available() and self.token_ready(): + used = self.quota_used() + if used is not None and used >= DAILY_POST_CAP: + v.append( + Violation("block", "quota", f"{used}/{DAILY_POST_CAP} posts used in the last 24h") + ) + elif used is not None and used >= DAILY_POST_CAP - 3: + v.append( + Violation("warn", "quota", f"{used}/{DAILY_POST_CAP} posts used in the last 24h") + ) + + return v + + +_PUBLISHER = InstagramPublisher() + +oauth.register_app( + oauth.OAuthApp( + platform="instagram", + authorize_url="https://www.instagram.com/oauth/authorize", + token_url="https://api.instagram.com/oauth/access_token", + scopes=_PUBLISHER.oauth_scopes, + client_id_env="INSTAGRAM_APP_ID", + client_secret_env="INSTAGRAM_APP_SECRET", + scope_sep=",", + setup_url="https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login", + ) +) + +tokens.register_refresher("instagram", _PUBLISHER.refresh) + +from ..config import register_publisher # noqa: E402 + +register_publisher(_PUBLISHER) diff --git a/common/runners/publishers/linkedin.py b/common/runners/publishers/linkedin.py new file mode 100644 index 0000000..aeb8ea8 --- /dev/null +++ b/common/runners/publishers/linkedin.py @@ -0,0 +1,277 @@ +"""LinkedIn publishing via the Posts API. + +Scope note, because it decides whether this works at all: `w_member_social` +posts as the authenticated person and is available through ordinary OAuth. +Posting as a company page needs the Community Management API, which is +partner-gated — LinkedIn reviews the application and most are declined. This +module therefore targets member posts, and preflight says so rather than +letting someone discover it from a 403. + +LinkedIn versions its API by a monthly `LinkedIn-Version` header rather than a +URL path, and rejects versions older than about a year. It is env-overridable +(LINKEDIN_API_VERSION) because that value ages out on a schedule no pinned +default can survive. +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any + +import requests + +from .. import oauth, tokens +from ..errors import PublishError, RateLimitError, RunnerError +from .base import IMAGE_EXTS, Post, Publisher, PublishResult, Violation, mime_for + +API_ROOT = "https://api.linkedin.com" +DEFAULT_VERSION = "202401" + +TEXT_LIMIT = 3000 + + +class LinkedInPublisher(Publisher): + name = "linkedin" + requires_env = ("LINKEDIN_CLIENT_ID", "LINKEDIN_CLIENT_SECRET") + requires_oauth = True + supports = frozenset({"text", "image", "carousel", "video"}) + supports_draft = False + needs_public_media_url = False + doc_url = "https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api" + + max_text_chars = TEXT_LIMIT + max_hashtags = 5 # LinkedIn's own guidance; more actively suppresses reach + min_media = 0 + max_media = 20 + max_image_mb = 10.0 + max_video_mb = 500.0 + + oauth_scopes = ("w_member_social", "openid", "profile") + + def api_version(self) -> str: + return os.environ.get("LINKEDIN_API_VERSION", DEFAULT_VERSION) + + # ── plumbing ──────────────────────────────────────────────────────────── + + def _headers(self, token: str | None = None) -> dict[str, str]: + return { + "Authorization": f"Bearer {token or tokens.get_valid(self.name)}", + "LinkedIn-Version": self.api_version(), + "X-Restli-Protocol-Version": "2.0.0", + } + + def _request(self, method: str, path: str, *, token: str | None = None, **kwargs) -> dict[str, Any]: + url = path if path.startswith("http") else f"{API_ROOT}{path}" + headers = kwargs.pop("headers", {}) + headers.update(self._headers(token)) + kwargs.setdefault("timeout", 120) + try: + resp = requests.request(method, url, headers=headers, **kwargs) + except requests.RequestException as exc: + raise PublishError(self.name, None, f"network error: {exc}") from exc + + if resp.status_code == 429: + raise RateLimitError(self.name, 429, "rate limited") + if resp.status_code >= 400: + detail = resp.text[:300] + if resp.status_code == 403: + detail += ( + " — if this is a company page, it needs the partner-gated " + "Community Management API; member posts need only w_member_social" + ) + raise PublishError(self.name, resp.status_code, detail) + + # A created post returns its URN in a header, with an empty body. + created = resp.headers.get("x-restli-id") or resp.headers.get("x-linkedin-id") + try: + data = resp.json() if resp.content else {} + except ValueError: + data = {} + if created: + data.setdefault("id", created) + return data + + def author_urn(self) -> str: + override = os.environ.get("LINKEDIN_AUTHOR_URN") + if override: + return override + entry = tokens.read(self.name) + if entry is None or not entry.account_id: + raise RunnerError("linkedin: no member id stored — re-run cli.auth") + return f"urn:li:person:{entry.account_id}" + + # ── media ─────────────────────────────────────────────────────────────── + + def _upload_media(self, path: Path) -> str: + is_image = path.suffix.lower() in IMAGE_EXTS + resource = "images" if is_image else "videos" + init = self._request( + "POST", + f"/rest/{resource}?action=initializeUpload", + headers={"Content-Type": "application/json"}, + data=json.dumps({"initializeUploadRequest": {"owner": self.author_urn()}}), + ) + value = init.get("value", {}) + upload_url = value.get("uploadUrl") or (value.get("uploadInstructions") or [{}])[0].get("uploadUrl") + urn = value.get("image") or value.get("video") + if not upload_url or not urn: + raise PublishError(self.name, None, f"upload init returned no url/urn: {init}") + + try: + resp = requests.put( + upload_url, + data=path.read_bytes(), + headers={"Content-Type": mime_for(path)}, + timeout=600, + ) + except requests.RequestException as exc: + raise PublishError(self.name, None, f"media upload failed: {exc}") from exc + if resp.status_code >= 400: + raise PublishError(self.name, resp.status_code, f"media upload rejected: {resp.text[:200]}") + return urn + + # ── publish ───────────────────────────────────────────────────────────── + + def publish(self, post: Post, *, draft: bool = False) -> PublishResult: + if draft: + raise PublishError(self.name, None, "LinkedIn has no draft API for member posts") + self.ensure_available() + + body: dict[str, Any] = { + "author": self.author_urn(), + "commentary": post.rendered_text(), + "visibility": os.environ.get("LINKEDIN_VISIBILITY", "PUBLIC"), + "distribution": { + "feedDistribution": "MAIN_FEED", + "targetEntities": [], + "thirdPartyDistributionChannels": [], + }, + "lifecycleState": "PUBLISHED", + "isReshareDisabledByAuthor": False, + } + + if len(post.media) == 1: + urn = self._upload_media(post.media[0]) + body["content"] = {"media": {"id": urn, "altText": post.alt_for(0)}} + elif len(post.media) > 1: + elements = [] + for i, path in enumerate(post.media): + elements.append({"id": self._upload_media(path), "altText": post.alt_for(i)}) + body["content"] = {"multiImage": {"images": elements}} + + data = self._request( + "POST", "/rest/posts", headers={"Content-Type": "application/json"}, data=json.dumps(body) + ) + post_id = str(data.get("id", "")) + permalink = ( + f"https://www.linkedin.com/feed/update/{post_id}" if post_id.startswith("urn:") else None + ) + return PublishResult( + platform=self.name, post_id=post_id, state="published", permalink=permalink + ) + + # ── preflight ─────────────────────────────────────────────────────────── + + def _extra_preflight(self, post: Post, *, draft: bool = False) -> list[Violation]: + v: list[Violation] = [] + if post.kind == "text" and not post.text.strip(): + v.append(Violation("block", "text", "a text post needs text")) + if os.environ.get("LINKEDIN_AUTHOR_URN", "").startswith("urn:li:organization"): + v.append( + Violation( + "warn", + "author", + "posting as an organisation needs the partner-gated Community Management " + "API; expect 403 unless the app was approved for it", + ) + ) + videos = [p for p in post.media if p.suffix.lower() not in IMAGE_EXTS] + if videos and len(post.media) > 1: + v.append(Violation("block", "media", "LinkedIn takes one video, or several images — not both")) + return v + + # ── authorisation ─────────────────────────────────────────────────────── + + def oauth_app(self): + return oauth.get_app(self.name) + + def verify_token(self, access_token: str) -> tuple[str, str]: + try: + resp = requests.get( + f"{API_ROOT}/v2/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=60, + ) + data = resp.json() if resp.content else {} + except (requests.RequestException, ValueError) as exc: + raise RunnerError(f"linkedin: could not verify token: {exc}") from exc + if resp.status_code >= 400: + raise RunnerError(f"linkedin: token rejected: {resp.text[:200]}") + return str(data.get("sub", "")), str(data.get("name", "")) + + def finalize_auth(self, raw: dict[str, Any]): + access = raw.get("access_token") + if not access: + raise RunnerError(f"linkedin: token response had no access_token: {raw}") + account_id = label = "" + try: + account_id, label = self.verify_token(access) + except RunnerError: + pass + return tokens.TokenEntry( + platform=self.name, + access_token=access, + refresh_token=raw.get("refresh_token"), + expires_at=time.time() + float(raw.get("expires_in", 5_184_000)), # 60 days + scopes=list(self.oauth_scopes), + account_id=account_id, + account_label=label, + ) + + def refresh(self, entry: tokens.TokenEntry) -> tokens.TokenEntry: + if not entry.refresh_token: + raise RunnerError( + "linkedin: no refresh token — refresh tokens are only issued to apps " + "approved for them. Re-run cli.auth to get a fresh 60-day token." + ) + resp = requests.post( + "https://www.linkedin.com/oauth/v2/accessToken", + data={ + "grant_type": "refresh_token", + "refresh_token": entry.refresh_token, + "client_id": os.environ.get("LINKEDIN_CLIENT_ID", ""), + "client_secret": os.environ.get("LINKEDIN_CLIENT_SECRET", ""), + }, + timeout=60, + ) + data = resp.json() if resp.content else {} + if resp.status_code >= 400 or "access_token" not in data: + raise RunnerError(f"linkedin: refresh failed: {resp.text[:200]}") + entry.access_token = data["access_token"] + entry.refresh_token = data.get("refresh_token", entry.refresh_token) + entry.expires_at = time.time() + float(data.get("expires_in", 5_184_000)) + return entry + + +_PUBLISHER = LinkedInPublisher() + +oauth.register_app( + oauth.OAuthApp( + platform="linkedin", + authorize_url="https://www.linkedin.com/oauth/v2/authorization", + token_url="https://www.linkedin.com/oauth/v2/accessToken", + scopes=_PUBLISHER.oauth_scopes, + client_id_env="LINKEDIN_CLIENT_ID", + client_secret_env="LINKEDIN_CLIENT_SECRET", + setup_url="https://www.linkedin.com/developers/apps", + ) +) + +tokens.register_refresher("linkedin", _PUBLISHER.refresh) + +from ..config import register_publisher # noqa: E402 + +register_publisher(_PUBLISHER) diff --git a/common/runners/publishers/telegram.py b/common/runners/publishers/telegram.py new file mode 100644 index 0000000..b85d0e0 --- /dev/null +++ b/common/runners/publishers/telegram.py @@ -0,0 +1,210 @@ +"""Telegram channel publishing via the Bot API. + +Reference implementation for this layer, and the only platform of the seven +that can be verified end-to-end without an OAuth dance, a business account, or +an app review: create a bot with @BotFather, add it to a channel as admin, +done. Smoke tests target this module. + +Bot API quirks worth knowing before reading the code: + - A caption attached to media is capped at 1024 chars, but a plain text + message gets 4096. Same "text" field to the user, two different limits. + - Albums go through sendMediaGroup, where the files ride as multipart parts + and the `media` array references them by `attach://`. + - Only the FIRST item of an album may carry a caption; a caption on later + items is silently dropped. + - Uploads are capped at 50 MB by the public Bot API (self-hosted Bot API + servers lift this, which is why the error message says so). +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import requests + +from ..errors import PublishError, RateLimitError +from .base import IMAGE_EXTS, VIDEO_EXTS, Post, Publisher, PublishResult, Violation, mime_for + +API_ROOT = "https://api.telegram.org" + +CAPTION_LIMIT = 1024 +MESSAGE_LIMIT = 4096 +UPLOAD_LIMIT_MB = 50.0 + + +class TelegramPublisher(Publisher): + name = "telegram" + requires_env = ("TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID") + requires_oauth = False + supports = frozenset({"text", "image", "carousel", "video"}) + supports_draft = False + needs_public_media_url = False + doc_url = "https://core.telegram.org/bots/api" + + max_text_chars = MESSAGE_LIMIT + max_hashtags = 10 + min_media = 0 + max_media = 10 + max_image_mb = UPLOAD_LIMIT_MB + max_video_mb = UPLOAD_LIMIT_MB + + # ── preflight ─────────────────────────────────────────────────────────── + + def _extra_preflight(self, post: Post, *, draft: bool = False) -> list[Violation]: + v: list[Violation] = [] + text = post.rendered_text() + + if post.media and len(text) > CAPTION_LIMIT: + v.append( + Violation( + "block", + "text", + f"{len(text)} chars: a caption on media is capped at {CAPTION_LIMIT} " + f"(a text-only post would allow {MESSAGE_LIMIT}). " + f"Shorten it, or post the text separately.", + ) + ) + + if post.kind == "carousel": + if len(post.media) < 2: + v.append(Violation("block", "media", "an album needs at least 2 files")) + kinds = {"image" if m.suffix.lower() in IMAGE_EXTS else "video" for m in post.media} + if len(kinds) > 1: + v.append( + Violation( + "warn", + "media", + "album mixes images and video — Telegram allows it but the " + "layout is unpredictable across clients", + ) + ) + + if post.kind == "text" and not post.text.strip(): + v.append(Violation("block", "text", "a text post needs text")) + + return v + + # ── publish ───────────────────────────────────────────────────────────── + + def publish(self, post: Post, *, draft: bool = False) -> PublishResult: + if draft: + # Checked before credentials on purpose: this request is impossible + # regardless of how well the bot is configured, so "set your token" + # would be a misleading thing to say. The CLI screens on + # supports_draft first; reaching here means a caller bypassed it. + raise PublishError( + self.name, None, "Telegram has no draft concept — post it or don't" + ) + + self.ensure_available() + chat_id = os.environ["TELEGRAM_CHAT_ID"] + text = post.rendered_text() + + if post.kind == "text": + result = self._call("sendMessage", data={"chat_id": chat_id, "text": text}) + elif post.kind == "carousel": + result = self._send_album(chat_id, post, text) + elif post.kind == "video": + result = self._send_single("sendVideo", "video", chat_id, post.media[0], text) + else: + result = self._send_single("sendPhoto", "photo", chat_id, post.media[0], text) + + return self._to_result(result) + + # ── HTTP ──────────────────────────────────────────────────────────────── + + def _url(self, method: str) -> str: + return f"{API_ROOT}/bot{os.environ['TELEGRAM_BOT_TOKEN']}/{method}" + + def _call( + self, + method: str, + *, + data: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, + timeout: float = 180.0, + ) -> dict[str, Any]: + try: + resp = requests.post(self._url(method), data=data, files=files, timeout=timeout) + except requests.RequestException as exc: + raise PublishError(self.name, None, f"network error: {exc}") from exc + + try: + payload = resp.json() + except ValueError: + raise PublishError(self.name, resp.status_code, resp.text[:500]) from None + + if not payload.get("ok"): + desc = payload.get("description", resp.text[:500]) + code = payload.get("error_code", resp.status_code) + if code == 429: + retry = payload.get("parameters", {}).get("retry_after") + suffix = f" — retry after {retry}s" if retry else "" + raise RateLimitError(self.name, code, f"{desc}{suffix}") + raise PublishError(self.name, code, desc) + + return payload["result"] + + def _send_single( + self, method: str, field: str, chat_id: str, path: Path, caption: str + ) -> dict[str, Any]: + with path.open("rb") as fh: + return self._call( + method, + data={"chat_id": chat_id, "caption": caption}, + files={field: (path.name, fh, mime_for(path))}, + ) + + def _send_album(self, chat_id: str, post: Post, caption: str) -> dict[str, Any]: + media_spec: list[dict[str, Any]] = [] + files: dict[str, Any] = {} + handles = [] + + try: + for i, path in enumerate(post.media): + key = f"file{i}" + fh = path.open("rb") + handles.append(fh) + files[key] = (path.name, fh, mime_for(path)) + item: dict[str, Any] = { + "type": "video" if path.suffix.lower() in VIDEO_EXTS else "photo", + "media": f"attach://{key}", + } + if i == 0 and caption: + item["caption"] = caption + media_spec.append(item) + + result = self._call( + "sendMediaGroup", + data={"chat_id": chat_id, "media": json.dumps(media_spec)}, + files=files, + ) + finally: + for fh in handles: + fh.close() + + # sendMediaGroup returns a list of messages; the first anchors the album. + return result[0] if isinstance(result, list) and result else {} + + def _to_result(self, result: dict[str, Any]) -> PublishResult: + message_id = str(result.get("message_id", "")) + chat = result.get("chat", {}) + username = chat.get("username") + permalink = f"https://t.me/{username}/{message_id}" if username and message_id else None + note = "" if permalink else "private chat/channel — no public permalink" + return PublishResult( + platform=self.name, + post_id=message_id, + state="published", + permalink=permalink, + note=note, + extra={"chat_id": chat.get("id")}, + ) + + +from ..config import register_publisher # noqa: E402 + +register_publisher(TelegramPublisher()) diff --git a/common/runners/publishers/threads.py b/common/runners/publishers/threads.py new file mode 100644 index 0000000..4804bbb --- /dev/null +++ b/common/runners/publishers/threads.py @@ -0,0 +1,95 @@ +"""Threads publishing via the official Threads API. + +The gentler half of the Meta pair, and the right one to connect first: + + · a text-only post needs no media, so no S3 bucket and no staging; + · a personal Threads account is enough — no business conversion, no linked + Facebook Page; + · the posting cap (250/day) is high enough that you will not meet it. + +The 500-character limit is the thing that actually bites. viral-text already +knows it (viral-text/references/platforms.md), so a post drafted for Threads +usually fits; a caption written for Instagram never does, which is why +preflight measures the rendered text including hashtags. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .. import oauth, tokens +from .base import IMAGE_EXTS, Post, Violation +from ._meta import MetaPublisher + +API_ROOT = "https://graph.threads.net" + +TEXT_LIMIT = 500 +CAROUSEL_MIN = 2 +CAROUSEL_MAX = 20 +DAILY_POST_CAP = 250 + + +class ThreadsPublisher(MetaPublisher): + name = "threads" + requires_env = ("THREADS_APP_ID", "THREADS_APP_SECRET") + supports = frozenset({"text", "image", "carousel", "video"}) + doc_url = "https://developers.facebook.com/docs/threads" + + api_root = API_ROOT + api_version_env = "THREADS_API_VERSION" + default_api_version = "v1.0" + container_path = "threads" + publish_path = "threads_publish" + status_field = "status" + text_param = "text" + user_id_env = "THREADS_USER_ID" + secret_env = "THREADS_APP_SECRET" + + max_text_chars = TEXT_LIMIT + max_hashtags = 5 # Threads surfaces one topic tag; a wall of them reads as spam + min_media = 0 + max_media = CAROUSEL_MAX + max_image_mb = 8.0 + max_video_mb = 1024.0 + + oauth_scopes = ("threads_basic", "threads_content_publish") + exchange_url = f"{API_ROOT}/access_token" + exchange_grant = "th_exchange_token" + refresh_url = f"{API_ROOT}/refresh_access_token" + refresh_grant = "th_refresh_token" + + def _media_params(self, path: Path, url: str) -> dict[str, Any]: + if path.suffix.lower() in IMAGE_EXTS: + return {"media_type": "IMAGE", "image_url": url} + return {"media_type": "VIDEO", "video_url": url} + + def _extra_preflight(self, post: Post, *, draft: bool = False) -> list[Violation]: + v = super()._extra_preflight(post, draft=draft) + if post.kind == "carousel" and len(post.media) > CAROUSEL_MAX: + v.append(Violation("block", "media", f"a Threads carousel holds at most {CAROUSEL_MAX} items")) + if post.kind == "text" and not post.text.strip(): + v.append(Violation("block", "text", "a text post needs text")) + return v + + +_PUBLISHER = ThreadsPublisher() + +oauth.register_app( + oauth.OAuthApp( + platform="threads", + authorize_url="https://threads.net/oauth/authorize", + token_url=f"{API_ROOT}/oauth/access_token", + scopes=_PUBLISHER.oauth_scopes, + client_id_env="THREADS_APP_ID", + client_secret_env="THREADS_APP_SECRET", + scope_sep=",", + setup_url="https://developers.facebook.com/docs/threads/get-started", + ) +) + +tokens.register_refresher("threads", _PUBLISHER.refresh) + +from ..config import register_publisher # noqa: E402 + +register_publisher(_PUBLISHER) diff --git a/common/runners/publishers/tiktok.py b/common/runners/publishers/tiktok.py new file mode 100644 index 0000000..24426b6 --- /dev/null +++ b/common/runners/publishers/tiktok.py @@ -0,0 +1,427 @@ +"""TikTok publishing via the Content Posting API. + +The one platform where the default is deliberately NOT to publish. + +TikTok gates direct publishing behind an app audit. An unaudited app can call +the direct-post endpoint, but everything it posts is forced to SELF_ONLY — the +call succeeds, the post exists, and nobody can see it. That failure mode is +invisible from the API response, which makes it exactly the kind of thing this +layer should refuse to walk into quietly. + +So the mapping is inverted compared to the other platforms: + + --draft (default advice) → /inbox/video/init/ lands in the app's inbox, + creator finishes and publishes by hand. + No audit needed. Always works. + no --draft → /video/init/ real direct post. Requires + an audited app; preflight says so loudly. + +Video uses FILE_UPLOAD, so bytes go straight from disk — no S3 needed. Photo +carousels are the exception: TikTok only accepts photos via PULL_FROM_URL, and +that URL's domain must be verified in the developer portal. Preflight flags it +rather than letting the upload fail three steps later. +""" + +from __future__ import annotations + +import json +import os +import time +import uuid +from pathlib import Path +from typing import Any + +import requests + +from .. import oauth, tokens +from ..errors import PublishError, RateLimitError, RunnerError +from .base import IMAGE_EXTS, MB, Post, Publisher, PublishResult, Violation, mime_for + +API_ROOT = "https://open.tiktokapis.com/v2" + +TITLE_LIMIT = 2200 +CAROUSEL_MAX = 35 + +# TikTok requires chunks between 5 MB and 64 MB, with the final chunk allowed +# to run up to 128 MB. Anything under the minimum must go as a single chunk. +MIN_CHUNK = 5 * MB +MAX_CHUNK = 64 * MB +CHUNK_SIZE = 20 * MB + +STATUS_TIMEOUT = 600.0 +STATUS_POLL_INTERVAL = 5.0 +MEDIA_URL_TTL = 3600 + + +class TikTokPublisher(Publisher): + name = "tiktok" + requires_env = ("TIKTOK_CLIENT_KEY", "TIKTOK_CLIENT_SECRET") + requires_oauth = True + supports = frozenset({"video", "carousel", "image"}) + supports_draft = True + needs_public_media_url = False # video uploads bytes; photos are checked separately + doc_url = "https://developers.tiktok.com/doc/content-posting-api-get-started" + + max_text_chars = TITLE_LIMIT + max_hashtags = 10 + min_media = 1 + max_media = CAROUSEL_MAX + max_video_mb = 4096.0 + max_image_mb = 20.0 + + oauth_scopes = ("video.upload", "video.publish") + + # ── plumbing ──────────────────────────────────────────────────────────── + + def _post(self, path: str, body: dict[str, Any], *, timeout: float = 120.0) -> dict[str, Any]: + token = tokens.get_valid(self.name) + try: + resp = requests.post( + f"{API_ROOT}/{path.lstrip('/')}", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=UTF-8", + }, + data=json.dumps(body), + timeout=timeout, + ) + except requests.RequestException as exc: + raise PublishError(self.name, None, f"network error: {exc}") from exc + + try: + payload = resp.json() + except ValueError: + raise PublishError(self.name, resp.status_code, resp.text[:400]) from None + + error = payload.get("error") or {} + code = error.get("code", "ok") + if code not in {"ok", "", None}: + message = error.get("message") or code + if "rate_limit" in str(code) or resp.status_code == 429: + raise RateLimitError(self.name, resp.status_code, message) + raise PublishError(self.name, resp.status_code, f"{code}: {message}") + if resp.status_code >= 400: + raise PublishError(self.name, resp.status_code, resp.text[:400]) + return payload.get("data", payload) + + def creator_info(self) -> dict[str, Any]: + """Mandatory before posting. TikTok requires the app to have fetched the + creator's current options (privacy levels, whether comments are off, + max duration) so the UI it shows the user is not stale. Also the + cheapest way to confirm the token is good before uploading anything.""" + return self._post("post/publish/creator_info/query/", {}) + + # ── upload ────────────────────────────────────────────────────────────── + + def _chunk_plan(self, size: int) -> tuple[int, int]: + """Pick (chunk_size, total_chunk_count) for the init call. + + TikTok validates the declared plan against the real file, and rejects a + chunk_size larger than video_size. So anything that fits in a single + chunk must declare chunk_size == video_size — declaring the nominal + 20 MB for a 6 MB file is a rejection, not a rounding detail. + + A lone chunk is also the final chunk, and the final chunk may run to + 128 MB, so everything up to MAX_CHUNK goes in one piece. + """ + if size <= MAX_CHUNK: + return size, 1 + chunk = min(max(CHUNK_SIZE, MIN_CHUNK), MAX_CHUNK) + count = int(size // chunk) + if count <= 1: + return size, 1 + return chunk, count + + def _upload(self, upload_url: str, path: Path, chunk: int, count: int) -> None: + size = path.stat().st_size + mime = mime_for(path) + with path.open("rb") as fh: + for index in range(count): + start = index * chunk + # The final chunk absorbs the remainder rather than adding a + # short chunk TikTok would reject. + end = size - 1 if index == count - 1 else start + chunk - 1 + fh.seek(start) + data = fh.read(end - start + 1) + try: + resp = requests.put( + upload_url, + headers={ + "Content-Range": f"bytes {start}-{end}/{size}", + "Content-Type": mime, + "Content-Length": str(len(data)), + }, + data=data, + timeout=600, + ) + except requests.RequestException as exc: + raise PublishError(self.name, None, f"chunk {index + 1}/{count} failed: {exc}") from exc + if resp.status_code >= 400: + raise PublishError( + self.name, resp.status_code, f"chunk {index + 1}/{count}: {resp.text[:200]}" + ) + + def _wait_for(self, publish_id: str) -> dict[str, Any]: + deadline = time.time() + STATUS_TIMEOUT + last = "UNKNOWN" + while time.time() < deadline: + data = self._post("post/publish/status/fetch/", {"publish_id": publish_id}) + last = str(data.get("status", "UNKNOWN")).upper() + if last in {"PUBLISH_COMPLETE", "SEND_TO_USER_INBOX"}: + return data + if last == "FAILED": + reason = data.get("fail_reason", "no reason given") + raise PublishError(self.name, None, f"publish failed: {reason}") + time.sleep(STATUS_POLL_INTERVAL) + raise PublishError( + self.name, None, f"still {last} after {STATUS_TIMEOUT:.0f}s — check the TikTok app" + ) + + # ── publish ───────────────────────────────────────────────────────────── + + def publish(self, post: Post, *, draft: bool = False) -> PublishResult: + self.ensure_available() + info = self.creator_info() + username = info.get("creator_username", "") + + if post.kind in {"image", "carousel"}: + return self._publish_photos(post, draft=draft, info=info, username=username) + return self._publish_video(post, draft=draft, info=info, username=username) + + def _publish_video( + self, post: Post, *, draft: bool, info: dict[str, Any], username: str + ) -> PublishResult: + path = post.media[0] + size = path.stat().st_size + chunk, count = self._chunk_plan(size) + source = { + "source": "FILE_UPLOAD", + "video_size": size, + "chunk_size": chunk, + "total_chunk_count": count, + } + + if draft: + data = self._post("post/publish/inbox/video/init/", {"source_info": source}) + else: + data = self._post( + "post/publish/video/init/", + { + "post_info": self._post_info(post, info), + "source_info": source, + }, + ) + + publish_id = data.get("publish_id", "") + upload_url = data.get("upload_url") + if not upload_url: + raise PublishError(self.name, None, f"init returned no upload_url: {data}") + + self._upload(upload_url, path, chunk, count) + self._wait_for(publish_id) + return self._result(publish_id, draft=draft, username=username) + + def _publish_photos( + self, post: Post, *, draft: bool, info: dict[str, Any], username: str + ) -> PublishResult: + from ..storage import s3 + + if not s3.s3_configured(): + raise PublishError( + self.name, + None, + "photo posts are accepted only via PULL_FROM_URL — set S3_* in ~/.skills.env, " + "and verify that domain in the TikTok developer portal", + ) + urls = [] + for path in post.media: + key = f"publish/tiktok/{uuid.uuid4().hex[:12]}/{path.name}" + urls.append(s3.stage_for_fetch(path, key, mime_for(path), ttl=MEDIA_URL_TTL)) + + data = self._post( + "post/publish/content/init/", + { + "post_info": self._post_info(post, info), + "source_info": { + "source": "PULL_FROM_URL", + "photo_cover_index": 0, + "photo_images": urls, + }, + "post_mode": "MEDIA_UPLOAD" if draft else "DIRECT_POST", + "media_type": "PHOTO", + }, + ) + publish_id = data.get("publish_id", "") + self._wait_for(publish_id) + return self._result(publish_id, draft=draft, username=username) + + def _post_info(self, post: Post, info: dict[str, Any]) -> dict[str, Any]: + options = info.get("privacy_level_options") or [] + # Honour what the creator's account actually allows. A private account + # has no PUBLIC_TO_EVERYONE option and passing it is an error. + wanted = os.environ.get("TIKTOK_PRIVACY_LEVEL", "PUBLIC_TO_EVERYONE") + privacy = wanted if wanted in options else (options[0] if options else "SELF_ONLY") + return { + "title": post.rendered_text()[:TITLE_LIMIT], + "privacy_level": privacy, + "disable_duet": bool(info.get("duet_disabled", False)), + "disable_stitch": bool(info.get("stitch_disabled", False)), + "disable_comment": bool(info.get("comment_disabled", False)), + } + + def _result(self, publish_id: str, *, draft: bool, username: str) -> PublishResult: + if draft: + return PublishResult( + platform=self.name, + post_id=publish_id, + state="draft", + note="landed in the TikTok app inbox — open the app to caption and publish it", + ) + permalink = f"https://www.tiktok.com/@{username}" if username else None + return PublishResult( + platform=self.name, + post_id=publish_id, + state="published", + permalink=permalink, + note="if the post is visible only to you, the app is not audited for direct publishing", + ) + + # ── preflight ─────────────────────────────────────────────────────────── + + def _extra_preflight(self, post: Post, *, draft: bool = False) -> list[Violation]: + v: list[Violation] = [] + + if not draft: + # Surfaced on every direct TikTok post, because the failure it + # describes is silent: the API reports success and the post is + # invisible. Irrelevant for a draft, which always works. + v.append( + Violation( + "warn", + "audit", + "direct publishing requires an audited TikTok app. Unaudited apps have " + "every post forced to SELF_ONLY — it looks published and nobody sees it. " + "Use --draft to land it in the app inbox instead, which always works.", + ) + ) + + if post.kind in {"image", "carousel"}: + from ..storage import s3 + + if not s3.s3_configured(): + v.append( + Violation( + "block", + "media", + "photo posts upload only via PULL_FROM_URL — needs S3_* configured", + ) + ) + else: + v.append( + Violation( + "warn", + "media", + "photo posts pull from a URL whose domain must be verified in the " + "TikTok developer portal, or the fetch is rejected", + ) + ) + + if post.kind == "video" and post.media: + size_mb = post.media[0].stat().st_size / MB if post.media[0].is_file() else 0 + if size_mb > 1024: + v.append( + Violation("warn", "media", f"{size_mb:.0f} MB will take a while to chunk-upload") + ) + + for path in post.media: + if post.kind == "video" and path.suffix.lower() in IMAGE_EXTS: + v.append(Violation("block", "media", f"{path.name} is an image in a video post")) + + return v + + # ── authorisation ─────────────────────────────────────────────────────── + + def oauth_app(self): + return oauth.get_app(self.name) + + def verify_token(self, access_token: str) -> tuple[str, str]: + try: + resp = requests.post( + f"{API_ROOT}/post/publish/creator_info/query/", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=60, + ) + data = resp.json().get("data", {}) + except (requests.RequestException, ValueError) as exc: + raise RunnerError(f"tiktok: could not verify token: {exc}") from exc + username = data.get("creator_username", "") + return username, (f"@{username}" if username else "") + + def finalize_auth(self, raw: dict[str, Any]): + access = raw.get("access_token") + if not access: + raise RunnerError(f"tiktok: token response had no access_token: {raw}") + account_id, label = "", "" + try: + account_id, label = self.verify_token(access) + except RunnerError: + pass # token is stored either way; --verify re-checks it + return tokens.TokenEntry( + platform=self.name, + access_token=access, + refresh_token=raw.get("refresh_token"), + expires_at=time.time() + float(raw.get("expires_in", 86400)), + scopes=str(raw.get("scope", "")).split(","), + account_id=account_id, + account_label=label, + ) + + def refresh(self, entry: tokens.TokenEntry) -> tokens.TokenEntry: + if not entry.refresh_token: + raise RunnerError( + "tiktok: no refresh token stored — re-run cli.auth. TikTok access tokens " + "last 24h, so this connection cannot be renewed without one." + ) + resp = requests.post( + f"{API_ROOT}/oauth/token/", + data={ + "client_key": os.environ.get("TIKTOK_CLIENT_KEY", ""), + "client_secret": os.environ.get("TIKTOK_CLIENT_SECRET", ""), + "grant_type": "refresh_token", + "refresh_token": entry.refresh_token or "", + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=60, + ) + data = resp.json() if resp.content else {} + if resp.status_code >= 400 or "access_token" not in data: + raise RunnerError(f"tiktok: refresh failed: {resp.text[:200]}") + entry.access_token = data["access_token"] + entry.refresh_token = data.get("refresh_token", entry.refresh_token) + entry.expires_at = time.time() + float(data.get("expires_in", 86400)) + return entry + + +_PUBLISHER = TikTokPublisher() + +oauth.register_app( + oauth.OAuthApp( + platform="tiktok", + authorize_url="https://www.tiktok.com/v2/auth/authorize/", + token_url=f"{API_ROOT}/oauth/token/", + scopes=_PUBLISHER.oauth_scopes, + client_id_env="TIKTOK_CLIENT_KEY", + client_secret_env="TIKTOK_CLIENT_SECRET", + use_pkce=True, + scope_sep=",", + # TikTok rejects the standard client_id/client_secret parameter names. + client_id_param="client_key", + setup_url="https://developers.tiktok.com/doc/content-posting-api-get-started", + ) +) + +tokens.register_refresher("tiktok", _PUBLISHER.refresh) + +from ..config import register_publisher # noqa: E402 + +register_publisher(_PUBLISHER) diff --git a/common/runners/publishers/x.py b/common/runners/publishers/x.py new file mode 100644 index 0000000..41c3419 --- /dev/null +++ b/common/runners/publishers/x.py @@ -0,0 +1,298 @@ +"""X (Twitter) publishing via API v2. + +The distinguishing feature here is the thread. X is the only platform in this +set where the natural unit is a chain rather than a post, so `Post.thread` +gets used: the first item is the root, each subsequent one replies to the id +returned by the previous call. That also means a thread cannot be rolled back +halfway — if post 4 of 6 fails, posts 1-3 are already public. The result +records how far it got instead of pretending the whole thing failed. + +Cost of entry is a real constraint rather than a footnote: the free tier +allows on the order of 17 posts per 24h and 500 per month, shared across +everything the app does. A daily thread will exhaust it. + +Media upload endpoints have moved between API versions more than once. The +upload host is env-overridable (X_UPLOAD_URL) so a move does not require a +code change, and the reference doc says to verify it before first use. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Any + +import requests + +from .. import oauth, tokens +from ..errors import PublishError, RateLimitError, RunnerError +from .base import MB, IMAGE_EXTS, Post, Publisher, PublishResult, Violation, mime_for + +API_ROOT = "https://api.x.com/2" +DEFAULT_UPLOAD_URL = f"{API_ROOT}/media/upload" + +TEXT_LIMIT = 280 +CHUNK_SIZE = 4 * MB +PROCESSING_TIMEOUT = 180.0 + + +class XPublisher(Publisher): + name = "x" + requires_env = ("X_CLIENT_ID", "X_CLIENT_SECRET") + requires_oauth = True + supports = frozenset({"text", "image", "carousel", "video"}) + supports_draft = False + needs_public_media_url = False + doc_url = "https://docs.x.com/x-api/posts/creation-of-a-post" + + max_text_chars = TEXT_LIMIT + max_hashtags = 3 # more than this on X reads as spam and suppresses reach + min_media = 0 + max_media = 4 # X allows at most 4 images, or 1 video, per post + max_image_mb = 5.0 + max_video_mb = 512.0 + + oauth_scopes = ("tweet.read", "tweet.write", "users.read", "offline.access") + + def upload_url(self) -> str: + return os.environ.get("X_UPLOAD_URL", DEFAULT_UPLOAD_URL) + + # ── plumbing ──────────────────────────────────────────────────────────── + + def _headers(self, token: str | None = None) -> dict[str, str]: + return {"Authorization": f"Bearer {token or tokens.get_valid(self.name)}"} + + def _request(self, method: str, url: str, **kwargs) -> dict[str, Any]: + kwargs.setdefault("timeout", 120) + headers = kwargs.pop("headers", {}) + headers.update(self._headers(kwargs.pop("token", None))) + try: + resp = requests.request(method, url, headers=headers, **kwargs) + except requests.RequestException as exc: + raise PublishError(self.name, None, f"network error: {exc}") from exc + + if resp.status_code == 429: + reset = resp.headers.get("x-rate-limit-reset", "") + hint = f" (limit resets at epoch {reset})" if reset else "" + raise RateLimitError(self.name, 429, f"rate limited{hint}") + + try: + data = resp.json() if resp.content else {} + except ValueError: + raise PublishError(self.name, resp.status_code, resp.text[:400]) from None + + if resp.status_code >= 400: + detail = data.get("detail") or data.get("title") or resp.text[:300] + raise PublishError(self.name, resp.status_code, detail) + return data + + # ── media ─────────────────────────────────────────────────────────────── + + def _upload_media(self, path: Path) -> str: + size = path.stat().st_size + is_video = path.suffix.lower() not in IMAGE_EXTS + category = "tweet_video" if is_video else "tweet_image" + + init = self._request( + "POST", + self.upload_url(), + data={ + "command": "INIT", + "total_bytes": size, + "media_type": mime_for(path), + "media_category": category, + }, + ) + media_id = str((init.get("data") or init).get("id") or (init.get("data") or init).get("media_id")) + if not media_id or media_id == "None": + raise PublishError(self.name, None, f"media INIT returned no id: {init}") + + with path.open("rb") as fh: + index = 0 + while True: + chunk = fh.read(CHUNK_SIZE) + if not chunk: + break + self._request( + "POST", + self.upload_url(), + data={"command": "APPEND", "media_id": media_id, "segment_index": index}, + files={"media": (path.name, chunk, mime_for(path))}, + ) + index += 1 + + finalize = self._request( + "POST", self.upload_url(), data={"command": "FINALIZE", "media_id": media_id} + ) + self._await_processing(media_id, finalize) + return media_id + + def _await_processing(self, media_id: str, finalize: dict[str, Any]) -> None: + """Video needs server-side transcoding. Posting before it finishes + fails with an error that does not mention processing.""" + info = (finalize.get("data") or finalize).get("processing_info") + deadline = time.time() + PROCESSING_TIMEOUT + while info and time.time() < deadline: + state = info.get("state") + if state == "succeeded": + return + if state == "failed": + raise PublishError(self.name, None, f"media processing failed: {info.get('error')}") + time.sleep(max(1, int(info.get("check_after_secs", 5)))) + status = self._request( + "GET", self.upload_url(), params={"command": "STATUS", "media_id": media_id} + ) + info = (status.get("data") or status).get("processing_info") + + # ── publish ───────────────────────────────────────────────────────────── + + def publish(self, post: Post, *, draft: bool = False) -> PublishResult: + if draft: + raise PublishError(self.name, None, "X has no draft API — post it or don't") + self.ensure_available() + + media_ids = [self._upload_media(p) for p in post.media] + + body: dict[str, Any] = {"text": post.rendered_text()} + if media_ids: + body["media"] = {"media_ids": media_ids} + + root = self._request("POST", f"{API_ROOT}/tweets", json=body) + root_id = str((root.get("data") or {}).get("id", "")) + + posted = 1 + parent = root_id + try: + for follow_up in post.thread: + data = self._request( + "POST", + f"{API_ROOT}/tweets", + json={"text": follow_up, "reply": {"in_reply_to_tweet_id": parent}}, + ) + parent = str((data.get("data") or {}).get("id", "")) + posted += 1 + except (PublishError, RunnerError) as exc: + # The root is already public. Reporting a clean failure would be a + # lie and would make --force the only way forward. + total = len(post.thread) + 1 + return PublishResult( + platform=self.name, + post_id=root_id, + state="published", + permalink=self._permalink(root_id), + note=f"PARTIAL: {posted}/{total} posts published, then failed — {exc}", + ) + + note = f"thread of {posted} posts" if posted > 1 else "" + return PublishResult( + platform=self.name, + post_id=root_id, + state="published", + permalink=self._permalink(root_id), + note=note, + ) + + def _permalink(self, post_id: str) -> str | None: + if not post_id: + return None + entry = tokens.read(self.name) + handle = (entry.account_label if entry else "").lstrip("@") or "i" + return f"https://x.com/{handle}/status/{post_id}" + + # ── preflight ─────────────────────────────────────────────────────────── + + def _extra_preflight(self, post: Post, *, draft: bool = False) -> list[Violation]: + v: list[Violation] = [] + + videos = [p for p in post.media if p.suffix.lower() not in IMAGE_EXTS] + if videos and len(post.media) > 1: + v.append(Violation("block", "media", "X takes either one video or up to four images")) + + for i, part in enumerate(post.thread, start=2): + if len(part) > TEXT_LIMIT: + v.append( + Violation("block", "thread", f"post {i} of the thread is {len(part)} chars (max {TEXT_LIMIT})") + ) + if not part.strip(): + v.append(Violation("block", "thread", f"post {i} of the thread is empty")) + + if post.kind == "text" and not post.text.strip(): + v.append(Violation("block", "text", "a text post needs text")) + + return v + + # ── authorisation ─────────────────────────────────────────────────────── + + def oauth_app(self): + return oauth.get_app(self.name) + + def verify_token(self, access_token: str) -> tuple[str, str]: + data = self._request("GET", f"{API_ROOT}/users/me", token=access_token).get("data", {}) + username = data.get("username", "") + return str(data.get("id", "")), (f"@{username}" if username else "") + + def finalize_auth(self, raw: dict[str, Any]): + access = raw.get("access_token") + if not access: + raise RunnerError(f"x: token response had no access_token: {raw}") + account_id = label = "" + try: + account_id, label = self.verify_token(access) + except RunnerError: + pass + return tokens.TokenEntry( + platform=self.name, + access_token=access, + refresh_token=raw.get("refresh_token"), + expires_at=time.time() + float(raw.get("expires_in", 7200)), + scopes=str(raw.get("scope", "")).split(), + account_id=account_id, + account_label=label, + ) + + def refresh(self, entry: tokens.TokenEntry) -> tokens.TokenEntry: + if not entry.refresh_token: + raise RunnerError( + "x: no refresh token stored — the app was authorised without the " + "offline.access scope. Re-run cli.auth after adding it." + ) + resp = requests.post( + "https://api.x.com/2/oauth2/token", + data={ + "grant_type": "refresh_token", + "refresh_token": entry.refresh_token or "", + "client_id": os.environ.get("X_CLIENT_ID", ""), + }, + auth=(os.environ.get("X_CLIENT_ID", ""), os.environ.get("X_CLIENT_SECRET", "")), + timeout=60, + ) + data = resp.json() if resp.content else {} + if resp.status_code >= 400 or "access_token" not in data: + raise RunnerError(f"x: refresh failed: {resp.text[:200]}") + entry.access_token = data["access_token"] + entry.refresh_token = data.get("refresh_token", entry.refresh_token) + entry.expires_at = time.time() + float(data.get("expires_in", 7200)) + return entry + + +_PUBLISHER = XPublisher() + +oauth.register_app( + oauth.OAuthApp( + platform="x", + authorize_url="https://x.com/i/oauth2/authorize", + token_url="https://api.x.com/2/oauth2/token", + scopes=_PUBLISHER.oauth_scopes, + client_id_env="X_CLIENT_ID", + client_secret_env="X_CLIENT_SECRET", + use_pkce=True, # X requires PKCE for OAuth 2.0 user-context tokens + setup_url="https://developer.x.com/en/portal/dashboard", + ) +) + +tokens.register_refresher("x", _PUBLISHER.refresh) + +from ..config import register_publisher # noqa: E402 + +register_publisher(_PUBLISHER) diff --git a/common/runners/publishers/youtube.py b/common/runners/publishers/youtube.py new file mode 100644 index 0000000..6938e62 --- /dev/null +++ b/common/runners/publishers/youtube.py @@ -0,0 +1,323 @@ +"""YouTube publishing via the Data API v3 resumable upload. + +The cleanest draft story of the seven: `privacyStatus: private` produces a real +video on the real channel that only the owner can see, editable and publishable +from YouTube Studio. So --draft here means something a user can actually act on, +unlike the 24h staging containers Meta calls drafts. + +The constraint that surprises people is quota, not file size. A single +videos.insert costs 1600 units against a default daily allowance of 10,000 — +six uploads a day, and that is shared with every other call the project makes. +Preflight cannot read the remaining quota (there is no endpoint for it), so it +says so rather than implying a check happened. + +Shorts are not a separate API: a vertical video under about three minutes is +classified as a Short by YouTube itself. +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any + +import requests + +from .. import oauth, tokens +from ..errors import PublishError, RateLimitError, RunnerError +from .base import MB, IMAGE_EXTS, Post, Publisher, PublishResult, Violation, mime_for + +API_ROOT = "https://www.googleapis.com/youtube/v3" +UPLOAD_ROOT = "https://www.googleapis.com/upload/youtube/v3" + +TITLE_LIMIT = 100 +DESCRIPTION_LIMIT = 5000 +TAGS_TOTAL_LIMIT = 500 # characters across all tags +QUOTA_PER_UPLOAD = 1600 +DEFAULT_DAILY_QUOTA = 10_000 +UPLOAD_CHUNK = 8 * MB + + +class YouTubePublisher(Publisher): + name = "youtube" + requires_env = ("YOUTUBE_CLIENT_ID", "YOUTUBE_CLIENT_SECRET") + requires_oauth = True + supports = frozenset({"video"}) + supports_draft = True + needs_public_media_url = False + doc_url = "https://developers.google.com/youtube/v3/docs/videos/insert" + + max_text_chars = DESCRIPTION_LIMIT + max_title_chars = TITLE_LIMIT + max_hashtags = 15 # YouTube only renders the first three above the title + min_media = 1 + max_media = 1 + max_video_mb = 256_000.0 + + oauth_scopes = ("https://www.googleapis.com/auth/youtube.upload",) + + # ── plumbing ──────────────────────────────────────────────────────────── + + def _headers(self, token: str | None = None) -> dict[str, str]: + return {"Authorization": f"Bearer {token or tokens.get_valid(self.name)}"} + + def _raise_for(self, resp: requests.Response) -> None: + try: + data = resp.json() if resp.content else {} + except ValueError: + raise PublishError(self.name, resp.status_code, resp.text[:400]) from None + error = data.get("error", {}) + message = error.get("message", resp.text[:300]) + reasons = {e.get("reason", "") for e in error.get("errors", [])} + if "quotaExceeded" in reasons or resp.status_code == 429: + raise RateLimitError( + self.name, + resp.status_code, + f"{message} — videos.insert costs {QUOTA_PER_UPLOAD} units of the " + f"{DEFAULT_DAILY_QUOTA}/day default allowance", + ) + raise PublishError(self.name, resp.status_code, message) + + # ── publish ───────────────────────────────────────────────────────────── + + def publish(self, post: Post, *, draft: bool = False) -> PublishResult: + self.ensure_available() + path = post.media[0] + size = path.stat().st_size + + metadata = { + "snippet": { + "title": self._derive_title(post), + "description": post.rendered_text()[:DESCRIPTION_LIMIT], + "tags": list(post.hashtags), + "categoryId": os.environ.get("YOUTUBE_CATEGORY_ID", "22"), # People & Blogs + }, + "status": { + "privacyStatus": "private" if draft else os.environ.get("YOUTUBE_PRIVACY", "public"), + "selfDeclaredMadeForKids": False, + }, + } + + session_url = self._start_session(metadata, size, mime_for(path)) + video_id = self._upload(session_url, path, size) + + return PublishResult( + platform=self.name, + post_id=video_id, + state="draft" if draft else "published", + permalink=f"https://youtu.be/{video_id}" if video_id else None, + note=( + "uploaded as private — publish it from YouTube Studio when ready" + if draft + else f"{QUOTA_PER_UPLOAD} quota units spent" + ), + ) + + def _derive_title(self, post: Post) -> str: + """An explicit --title always wins; otherwise fall back to the first + non-blank line of the caption. Written out rather than chained with + `or`, because the inline version bound as + `(title or first_line) if text else "Untitled"` and threw away an + explicit title whenever the caption happened to be empty.""" + if post.title.strip(): + return post.title.strip()[:TITLE_LIMIT] + for line in post.text.splitlines(): + if line.strip(): + return line.strip()[:TITLE_LIMIT] + return "Untitled" + + def _start_session(self, metadata: dict[str, Any], size: int, mime: str) -> str: + headers = self._headers() + headers.update( + { + "Content-Type": "application/json; charset=UTF-8", + "X-Upload-Content-Length": str(size), + "X-Upload-Content-Type": mime, + } + ) + try: + resp = requests.post( + f"{UPLOAD_ROOT}/videos", + params={"uploadType": "resumable", "part": "snippet,status"}, + headers=headers, + data=json.dumps(metadata), + timeout=120, + ) + except requests.RequestException as exc: + raise PublishError(self.name, None, f"network error: {exc}") from exc + + if resp.status_code >= 400: + self._raise_for(resp) + session_url = resp.headers.get("Location") + if not session_url: + raise PublishError(self.name, None, "resumable session returned no Location header") + return session_url + + def _upload(self, session_url: str, path: Path, size: int) -> str: + """Push the bytes in chunks. 308 means 'keep going' and is not an error.""" + sent = 0 + with path.open("rb") as fh: + while sent < size: + chunk = fh.read(UPLOAD_CHUNK) + if not chunk: + break + end = sent + len(chunk) - 1 + try: + resp = requests.put( + session_url, + headers={ + "Content-Length": str(len(chunk)), + "Content-Range": f"bytes {sent}-{end}/{size}", + }, + data=chunk, + timeout=600, + ) + except requests.RequestException as exc: + raise PublishError( + self.name, None, f"upload failed at byte {sent}/{size}: {exc}" + ) from exc + + if resp.status_code in {200, 201}: + try: + return str(resp.json().get("id", "")) + except ValueError: + return "" + if resp.status_code == 308: + sent = end + 1 + continue + self._raise_for(resp) + raise PublishError(self.name, None, "upload finished without YouTube returning a video id") + + # ── preflight ─────────────────────────────────────────────────────────── + + def _extra_preflight(self, post: Post, *, draft: bool = False) -> list[Violation]: + v: list[Violation] = [] + + if post.media and post.media[0].suffix.lower() in IMAGE_EXTS: + v.append(Violation("block", "media", "YouTube takes video, not stills")) + + if not post.title: + v.append( + Violation( + "warn", + "title", + "no --title given; the first line of the caption will be used, which is " + "rarely a good YouTube title", + ) + ) + + tag_chars = sum(len(h) for h in post.hashtags) + if tag_chars > TAGS_TOTAL_LIMIT: + v.append(Violation("block", "hashtags", f"tags total {tag_chars} chars (max {TAGS_TOTAL_LIMIT})")) + + # Warned on drafts too: a private upload costs exactly the same 1600 + # units as a public one. Suppressing it there would teach people that + # --draft is free, which is the opposite of true. + v.append( + Violation( + "warn", + "quota", + f"this upload costs {QUOTA_PER_UPLOAD} of the {DEFAULT_DAILY_QUOTA}/day default " + f"quota (~6 uploads/day, private ones included). There is no API to check " + f"what is left.", + ) + ) + + return v + + # ── authorisation ─────────────────────────────────────────────────────── + + def oauth_app(self): + return oauth.get_app(self.name) + + def verify_token(self, access_token: str) -> tuple[str, str]: + try: + resp = requests.get( + f"{API_ROOT}/channels", + params={"part": "snippet", "mine": "true"}, + headers=self._headers(access_token), + timeout=60, + ) + items = resp.json().get("items", []) if resp.content else [] + except (requests.RequestException, ValueError) as exc: + raise RunnerError(f"youtube: could not verify token: {exc}") from exc + if resp.status_code >= 400: + raise RunnerError(f"youtube: token rejected: {resp.text[:200]}") + if not items: + # The upload scope alone cannot read channels; not fatal. + return "", "" + return items[0].get("id", ""), items[0].get("snippet", {}).get("title", "") + + def finalize_auth(self, raw: dict[str, Any]): + access = raw.get("access_token") + if not access: + raise RunnerError(f"youtube: token response had no access_token: {raw}") + if not raw.get("refresh_token"): + raise RunnerError( + "youtube: Google returned no refresh_token. Revoke the app's access at " + "myaccount.google.com/permissions and authorise again — Google only issues " + "one on first consent." + ) + account_id = label = "" + try: + account_id, label = self.verify_token(access) + except RunnerError: + pass + return tokens.TokenEntry( + platform=self.name, + access_token=access, + refresh_token=raw.get("refresh_token"), + expires_at=time.time() + float(raw.get("expires_in", 3600)), + scopes=list(self.oauth_scopes), + account_id=account_id, + account_label=label, + ) + + def refresh(self, entry: tokens.TokenEntry) -> tokens.TokenEntry: + if not entry.refresh_token: + raise RunnerError( + "youtube: no refresh token stored. Google issues one only on first " + "consent — revoke at myaccount.google.com/permissions and re-run cli.auth." + ) + resp = requests.post( + "https://oauth2.googleapis.com/token", + data={ + "grant_type": "refresh_token", + "refresh_token": entry.refresh_token or "", + "client_id": os.environ.get("YOUTUBE_CLIENT_ID", ""), + "client_secret": os.environ.get("YOUTUBE_CLIENT_SECRET", ""), + }, + timeout=60, + ) + data = resp.json() if resp.content else {} + if resp.status_code >= 400 or "access_token" not in data: + raise RunnerError(f"youtube: refresh failed: {resp.text[:200]}") + entry.access_token = data["access_token"] + entry.expires_at = time.time() + float(data.get("expires_in", 3600)) + return entry + + +_PUBLISHER = YouTubePublisher() + +oauth.register_app( + oauth.OAuthApp( + platform="youtube", + authorize_url="https://accounts.google.com/o/oauth2/v2/auth", + token_url="https://oauth2.googleapis.com/token", + scopes=_PUBLISHER.oauth_scopes, + client_id_env="YOUTUBE_CLIENT_ID", + client_secret_env="YOUTUBE_CLIENT_SECRET", + # Google issues a refresh token only with these two, and only on the + # first consent — without them the token dies in an hour, permanently. + extra_authorize_params={"access_type": "offline", "prompt": "consent"}, + setup_url="https://console.cloud.google.com/apis/credentials", + ) +) + +tokens.register_refresher("youtube", _PUBLISHER.refresh) + +from ..config import register_publisher # noqa: E402 + +register_publisher(_PUBLISHER) diff --git a/common/runners/receipts.py b/common/runners/receipts.py new file mode 100644 index 0000000..f917568 --- /dev/null +++ b/common/runners/receipts.py @@ -0,0 +1,107 @@ +"""posted.json — what already went out, so it does not go out twice. + +Every other runner in this repo is safely re-runnable: regenerate a slide and +you have spent a few cents. Re-run a publish and your followers see the post +twice. So publishing keeps a receipt next to the assets it published: + + ./generated/carousel// + slide-1.png ... + captions.md + manifest.json ← generation state (existing) + posted.json ← publication state (this module) + +Keyed on (platform, content_hash) so that editing the caption and re-running is +correctly treated as a new post, while re-running the identical command is not. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +from .publishers.base import PublishResult + +RECEIPT_FILE = "posted.json" +SCHEMA_VERSION = 1 + + +@dataclass +class Receipt: + platform: str + post_id: str + state: str + content_hash: str + published_at: str # ISO 8601 UTC + permalink: str | None = None + note: str = "" + + +def path_for(source_dir: Path) -> Path: + return source_dir / RECEIPT_FILE + + +def load(source_dir: Path) -> list[Receipt]: + path = path_for(source_dir) + if not path.is_file(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + # A damaged receipt must not block publishing — but it also must not + # silently claim "nothing published". The CLI warns on an empty result + # when the file exists but did not parse. + return [] + known = {f for f in Receipt.__dataclass_fields__} + return [Receipt(**{k: v for k, v in r.items() if k in known}) for r in data.get("receipts", [])] + + +def find(source_dir: Path, platform: str, content_hash: str) -> Receipt | None: + """Most recent receipt for this exact content on this platform, any state.""" + for r in reversed(load(source_dir)): + if r.platform == platform and r.content_hash == content_hash: + return r + return None + + +def find_blocking(source_dir: Path, platform: str, content_hash: str, *, drafting: bool) -> Receipt | None: + """The receipt that should stop this run, if any. + + State matters, and ignoring it broke the workflow the drafts exist for. + Staging a draft, reviewing it, then publishing is the documented path for + Meta and TikTok — and a receipt that treats `draft` as "already done" turns + the second half of that into a --force, which is exactly backwards: the + user deliberately staged it in order to publish it. + + So: publishing is blocked only by a previous publication. Drafting is + blocked by either, since re-staging something already staged or already + live has no purpose. + """ + prior = find(source_dir, platform, content_hash) + if prior is None: + return None + if prior.state == "published": + return prior + return prior if drafting else None + + +def record(source_dir: Path, result: PublishResult, content_hash: str) -> Receipt: + """Append a receipt. Written immediately after each platform succeeds, so a + crash mid-fan-out cannot cause a re-post of the platforms already done.""" + receipt = Receipt( + platform=result.platform, + post_id=result.post_id, + state=result.state, + content_hash=content_hash, + published_at=datetime.now(timezone.utc).isoformat(timespec="seconds"), + permalink=result.permalink, + note=result.note, + ) + existing = load(source_dir) + existing.append(receipt) + path = path_for(source_dir) + path.parent.mkdir(parents=True, exist_ok=True) + payload = {"version": SCHEMA_VERSION, "receipts": [asdict(r) for r in existing]} + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return receipt diff --git a/common/runners/storage/s3.py b/common/runners/storage/s3.py index c7cc3b8..c568908 100644 --- a/common/runners/storage/s3.py +++ b/common/runners/storage/s3.py @@ -42,6 +42,38 @@ def _client() -> Any: return boto3.client("s3", **kwargs) +def presigned_url(key: str, *, ttl: int = 3600) -> str: + """Time-limited GET URL for an object already in the bucket. + + Used by the publishing layer: Instagram and Threads will not accept raw + bytes — they fetch the media themselves from a URL you hand them. A + presigned link is the right shape for that and a public-read ACL is not: + the bucket stays private, the link dies on its own, and nothing generated + here is left permanently readable by whoever finds the URL. + """ + if not s3_configured(): + raise RuntimeError("S3 not configured (S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY missing)") + bucket = os.environ["S3_BUCKET"] + prefix = os.environ.get("S3_PATH_PREFIX", "").strip("/") + full_key = f"{prefix}/{_safe_key(key)}" if prefix else _safe_key(key) + return _client().generate_presigned_url( + "get_object", Params={"Bucket": bucket, "Key": full_key}, ExpiresIn=ttl + ) + + +def stage_for_fetch(path, key: str, content_type: str, *, ttl: int = 3600) -> str: + """Upload a local file and return a URL a platform can fetch it from. + + One call because the two halves are never useful apart, and because + forgetting the presign step would silently hand Meta a URL that 403s. + """ + from pathlib import Path + + data = Path(path).read_bytes() + write_s3(data, key, content_type) + return presigned_url(key, ttl=ttl) + + def write_s3(content: bytes, key: str, content_type: str) -> str: """Upload to the configured bucket. Returns a URL. diff --git a/common/runners/tokens.py b/common/runners/tokens.py new file mode 100644 index 0000000..2048175 --- /dev/null +++ b/common/runners/tokens.py @@ -0,0 +1,193 @@ +"""~/.skills-tokens.json — OAuth token store for the publishing layer. + +Deliberately separate from ~/.skills.env. The two hold different things with +different lifetimes and different ways of arriving: + + ~/.skills.env app credentials long-lived pasted by the user + ~/.skills-tokens.json user access tokens hours/days produced by an OAuth flow + +Cramming expiring JSON blobs into a flat KEY=VALUE file that sixteen providers +read at startup would have been the cheap move and the wrong one — every +runner would then carry expired social tokens in os.environ for no reason. +So tokens are never loaded into the environment; they are read on demand. + +Refresh is platform-specific, so each publisher registers its own refresher: + + tokens.register_refresher("instagram", _refresh_long_lived) + +get_valid() then transparently refreshes anything inside the skew window and +persists the result before returning. +""" + +from __future__ import annotations + +import json +import os +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Callable + +from . import atomicfile +from .errors import TokenError + +TOKENS_FILE = Path(os.environ.get("SKILLS_TOKENS_FILE", str(Path.home() / ".skills-tokens.json"))) + +SCHEMA_VERSION = 1 + +# Refresh this many seconds before nominal expiry. Covers clock drift plus the +# round-trip of the request we are about to make. +REFRESH_SKEW = 300.0 + +_REFRESHERS: dict[str, Callable[["TokenEntry"], "TokenEntry"]] = {} + + +@dataclass +class TokenEntry: + platform: str + access_token: str + refresh_token: str | None = None + expires_at: float | None = None # epoch seconds; None = does not expire + scopes: list[str] = field(default_factory=list) + account_id: str = "" + account_label: str = "" # "@handle" / channel name — for the confirmation prompt + obtained_at: float = 0.0 + + def expired(self, *, skew: float = REFRESH_SKEW) -> bool: + if self.expires_at is None: + return False + return time.time() >= (self.expires_at - skew) + + def expires_in_human(self) -> str: + if self.expires_at is None: + return "no expiry" + remaining = self.expires_at - time.time() + if remaining <= 0: + return "expired" + if remaining < 3600: + return f"{remaining / 60:.0f} min" + if remaining < 86400: + return f"{remaining / 3600:.1f} h" + return f"{remaining / 86400:.1f} days" + + def masked(self) -> str: + from .keysfile import mask + + return mask(self.access_token) + + +def register_refresher(platform: str, fn: Callable[[TokenEntry], TokenEntry]) -> None: + """Publishers call this at import time to teach the store how to refresh.""" + _REFRESHERS[platform] = fn + + +# ── file I/O ──────────────────────────────────────────────────────────────── + + +def _read_raw() -> dict: + if not TOKENS_FILE.is_file(): + return {"version": SCHEMA_VERSION, "platforms": {}} + try: + data = json.loads(TOKENS_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + # A corrupt token file must not brick every publisher. Treat as empty; + # re-running the auth flow rewrites it. + return {"version": SCHEMA_VERSION, "platforms": {}} + if not isinstance(data, dict) or "platforms" not in data: + return {"version": SCHEMA_VERSION, "platforms": {}} + return data + + +def _write_raw(data: dict) -> None: + atomicfile.write_secret_text(TOKENS_FILE, json.dumps(data, indent=2, sort_keys=True) + "\n") + + +def read(platform: str) -> TokenEntry | None: + raw = _read_raw()["platforms"].get(platform) + if not raw: + return None + known = {f for f in TokenEntry.__dataclass_fields__} + return TokenEntry(**{k: v for k, v in raw.items() if k in known}) + + +def save(entry: TokenEntry) -> None: + if not entry.obtained_at: + entry.obtained_at = time.time() + data = _read_raw() + data["version"] = SCHEMA_VERSION + data["platforms"][entry.platform] = asdict(entry) + _write_raw(data) + + +def remove(platform: str) -> bool: + data = _read_raw() + if platform not in data["platforms"]: + return False + del data["platforms"][platform] + _write_raw(data) + return True + + +def all_entries() -> list[TokenEntry]: + known = {f for f in TokenEntry.__dataclass_fields__} + out = [] + for raw in _read_raw()["platforms"].values(): + out.append(TokenEntry(**{k: v for k, v in raw.items() if k in known})) + return sorted(out, key=lambda e: e.platform) + + +# ── the part publishers actually call ─────────────────────────────────────── + + +def has_usable(platform: str) -> bool: + """Cheap, offline check for --list-platforms. No refresh attempted.""" + entry = read(platform) + if entry is None or not entry.access_token: + return False + if not entry.expired(): + return True + # Expired but refreshable counts as usable — get_valid() will renew it. + return bool(entry.refresh_token and platform in _REFRESHERS) + + +def get_valid(platform: str) -> str: + """Return a live access token, refreshing first if it is close to expiry.""" + entry = read(platform) + if entry is None or not entry.access_token: + raise TokenError(platform, "no token stored") + + if not entry.expired(): + return entry.access_token + + refresher = _REFRESHERS.get(platform) + if refresher is None: + raise TokenError( + platform, f"token expired {entry.expires_in_human()} ago and this platform has no refresh flow" + ) + + # Deliberately no "is there a refresh token?" check here. Whether one is + # needed is a property of the platform's flow, and this module has no + # business knowing it: Meta's long-lived tokens renew using the access + # token itself and never store a refresh token, so a guard here silently + # made Threads unrefreshable. Each refresher validates its own inputs. + try: + refreshed = refresher(entry) + except TokenError: + raise + except Exception as exc: # noqa: BLE001 — surface as a typed, actionable error + raise TokenError(platform, f"refresh failed: {exc}") from exc + + save(refreshed) + return refreshed.access_token + + +def status_lines() -> list[str]: + """Human-readable summary for `skills-keys --oauth-status`. Never prints a token.""" + entries = all_entries() + if not entries: + return ["No social accounts connected. Run: python3 -m common.runners.cli.auth --platform "] + lines = [] + for e in entries: + label = e.account_label or e.account_id or "(unknown account)" + lines.append(f" {e.platform:12s} {label:24s} {e.masked():14s} expires in {e.expires_in_human()}") + return lines diff --git a/docs/COMPOSING.md b/docs/COMPOSING.md index b0f3728..e13d903 100644 --- a/docs/COMPOSING.md +++ b/docs/COMPOSING.md @@ -1,6 +1,6 @@ # Composing skills — workflow recipes -The 41 skills aren't independent — they stack. `writer` is the foundation; wrappers extend it; linters audit without mutating; orchestrators chain multiple wrappers + the execute layer + style libraries; meta-skills manage the collection. +The 42 skills aren't independent — they stack. `writer` is the foundation; wrappers extend it; linters audit without mutating; orchestrators chain multiple wrappers + the execute layer + style libraries; meta-skills manage the collection. This file is the **recipe book**: named workflows showing concrete skill chains for typical jobs. @@ -23,11 +23,11 @@ This file is the **recipe book**: named workflows showing concrete skill chains ## Core composition rules -1. **`writer` is the base.** 12 of 41 skills run `writer` as their final cleanup pass. You rarely call `writer` directly except for raw clean-up. +1. **`writer` is the base.** 12 of 42 skills run `writer` as their final cleanup pass. You rarely call `writer` directly except for raw clean-up. 2. **Linters are read-only.** `style-check`, `translation-sync`, `canon-check` produce reports, never mutate text. Use as quality gates. 3. **One wrapper per pass.** Don't try to chain `prose-edit` + `essay-write` on the same text in one go. Pick the right one for the genre. 4. **Linters AFTER wrappers.** Apply rewrites first, then lint. The opposite order tells you what you already know. -5. **Orchestrators have one upstream feeder.** `research-brief` feeds `carousel-builder` and `reel-builder`. The orchestrator's outputs (manifest.json + style-used.md) are reproducible — keep them. +5. **Orchestrators have one upstream feeder.** `research-brief` feeds `carousel-builder` and `reel-builder`, which in turn feed `post-publisher` — the only terminal skill, and the only one whose output leaves your machine. The orchestrator's outputs (manifest.json + style-used.md) are reproducible — keep them. 6. **Meta-skills are operational.** `skills-update` and `skills-keys` don't edit text; they manage the collection and its keys. --- @@ -45,6 +45,10 @@ This file is the **recipe book**: named workflows showing concrete skill chains │ ▼ │ │ carousel-builder · reel-builder │ │ (consume --research from above) │ +│ │ │ +│ ▼ │ +│ post-publisher │ +│ (consumes their output dir; sends it) │ └─────────────────────────────────────────────┘ ┌─────────────────── linters (read-only) ─────┐ @@ -365,8 +369,9 @@ Checks for newer release, shows CHANGELOG diff, asks confirmation, runs `install | `rfc-writer` | RFC/ADR md | `writer` | optional cleanup | | `landing-copy` | landing sections | `microcopy` + `writer` | UI strings + cleanup | | `research-brief` | markdown brief | `carousel-builder` / `reel-builder` | via `--research ` | -| `carousel-builder` | PNG slides + captions + manifest | (no skill; user posts) | manifest enables --resume | -| `reel-builder` | final.mp4 + components + manifest | (no skill; user posts) | manifest enables --resume | +| `carousel-builder` | PNG slides + captions + manifest | `post-publisher` | manifest enables --resume | +| `reel-builder` | final.mp4 + components + manifest | `post-publisher` | manifest enables --resume | +| `post-publisher` | live post or platform draft + posted.json | none (terminal) | receipt blocks a repeat publish | | `skills-keys` | (writes ~/.skills.env) | runner CLIs | auto-loaded at runner startup | | `skills-update` | (re-installs) | none | meta | @@ -396,7 +401,7 @@ Checks for newer release, shows CHANGELOG diff, asks confirmation, runs `install ## Cross-references -- [All 41 skills (auto-generated table)](../README.md#whats-in-the-box) +- [All 42 skills (auto-generated table)](../README.md#whats-in-the-box) - [Scenario-based picker](USER-GUIDE.md) - [Walkthroughs (categorized index)](walkthroughs/) - [Quickstart (5-minute first run)](QUICKSTART.md) diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index c5c8c16..84d1d7e 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -12,7 +12,7 @@ curl -fsSL https://raw.githubusercontent.com/Mikefluff/skills/main/install.sh | What this does: -- Copies 41 skills into `~/.claude/skills/` +- Copies 42 skills into `~/.claude/skills/` - Copies shared references + the style library (50 visual / directorial / music presets) - Auto-creates `~/.claude/skills/.runners-venv` with Python deps (for the optional `--execute` layer) - Offers to install ffmpeg via brew / apt (needed only for the `reel-builder` skill) diff --git a/docs/README.md b/docs/README.md index 3b61b5f..cb18654 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,7 +21,7 @@ For the project overview + install + the skills table, go back to the top-level | Doc | What it's for | Length | |---|---|---| | [SKILL-INDEX.md](SKILL-INDEX.md) | Every skill indexed by layer / domain / language. Auto-generated from `skills.json` | ~75 lines | -| [COMPOSING.md](COMPOSING.md) | How the 41 skills compose — dependency graph + named workflows + data flow table + anti-patterns | ~290 lines | +| [COMPOSING.md](COMPOSING.md) | How the 42 skills compose — dependency graph + named workflows + data flow table + anti-patterns | ~290 lines | | [walkthroughs/](walkthroughs/) | 19 step-by-step recipes with categorized [index](walkthroughs/README.md) | ~200 lines each | | [LINTER-COVERAGE.md](LINTER-COVERAGE.md) | Auto-generated regex coverage table for the `writer` linter (25 categories) | ~50 lines | diff --git a/docs/SKILL-INDEX.md b/docs/SKILL-INDEX.md index 3af81cf..29534a5 100644 --- a/docs/SKILL-INDEX.md +++ b/docs/SKILL-INDEX.md @@ -65,12 +65,12 @@ skills), see [COMPOSING.md](COMPOSING.md). - **audio** — [`music-prompt`](../music-prompt/), [`voiceover-maker`](../voiceover-maker/), [`reel-builder`](../reel-builder/), [`audio-mix-maker`](../audio-mix-maker/), [`transcribe-maker`](../transcribe-maker/) - **fiction** — [`prose-edit`](../prose-edit/), [`canon-check`](../canon-check/), [`pelevin-digression`](../pelevin-digression/) -- **marketing** — [`viral-text`](../viral-text/), [`landing-copy`](../landing-copy/), [`carousel-builder`](../carousel-builder/), [`cover-maker`](../cover-maker/), [`thumbnail-maker`](../thumbnail-maker/), [`flyer-maker`](../flyer-maker/), [`reel-builder`](../reel-builder/), [`logo-maker`](../logo-maker/), [`quote-card-maker`](../quote-card-maker/), [`banner-maker`](../banner-maker/), [`meme-card-maker`](../meme-card-maker/), [`proposal-maker`](../proposal-maker/) +- **marketing** — [`viral-text`](../viral-text/), [`landing-copy`](../landing-copy/), [`carousel-builder`](../carousel-builder/), [`cover-maker`](../cover-maker/), [`thumbnail-maker`](../thumbnail-maker/), [`flyer-maker`](../flyer-maker/), [`reel-builder`](../reel-builder/), [`logo-maker`](../logo-maker/), [`quote-card-maker`](../quote-card-maker/), [`banner-maker`](../banner-maker/), [`meme-card-maker`](../meme-card-maker/), [`proposal-maker`](../proposal-maker/), [`post-publisher`](../post-publisher/) - **non-fiction** — [`essay-write`](../essay-write/), [`pelevin-digression`](../pelevin-digression/) - **outreach** — [`cold-email`](../cold-email/) - **product** — [`microcopy`](../microcopy/), [`release-notes`](../release-notes/) - **research** — [`research-brief`](../research-brief/) -- **social** — [`viral-text`](../viral-text/) +- **social** — [`viral-text`](../viral-text/), [`post-publisher`](../post-publisher/) - **tech-docs** — [`release-notes`](../release-notes/), [`rfc-writer`](../rfc-writer/) - **ux-copy** — [`microcopy`](../microcopy/) - **visual** — [`skills-styles`](../skills-styles/), [`image-prompt`](../image-prompt/), [`video-prompt`](../video-prompt/), [`carousel-builder`](../carousel-builder/), [`cover-maker`](../cover-maker/), [`thumbnail-maker`](../thumbnail-maker/), [`bg-remover`](../bg-remover/), [`avatar-maker`](../avatar-maker/), [`subtitle-burner`](../subtitle-burner/), [`flyer-maker`](../flyer-maker/), [`reel-builder`](../reel-builder/), [`logo-maker`](../logo-maker/), [`quote-card-maker`](../quote-card-maker/), [`gif-maker`](../gif-maker/), [`banner-maker`](../banner-maker/), [`meme-card-maker`](../meme-card-maker/), [`upscaler`](../upscaler/), [`style-transfer`](../style-transfer/), [`style-suggest`](../style-suggest/) @@ -78,7 +78,7 @@ skills), see [COMPOSING.md](COMPOSING.md). ## By language - **EN + PT-BR + RU** (1) — [`translation-sync`](../translation-sync/) -- **EN + RU** (37) — [`writer`](../writer/), [`viral-text`](../viral-text/), [`style-check`](../style-check/), [`canon-check`](../canon-check/), [`skills-update`](../skills-update/), [`skills-keys`](../skills-keys/), [`skills-styles`](../skills-styles/), [`tone-shifter`](../tone-shifter/), [`cold-email`](../cold-email/), [`image-prompt`](../image-prompt/), [`video-prompt`](../video-prompt/), [`music-prompt`](../music-prompt/), [`microcopy`](../microcopy/), [`release-notes`](../release-notes/), [`rfc-writer`](../rfc-writer/), [`landing-copy`](../landing-copy/), [`research-brief`](../research-brief/), [`carousel-builder`](../carousel-builder/), [`cover-maker`](../cover-maker/), [`thumbnail-maker`](../thumbnail-maker/), [`bg-remover`](../bg-remover/), [`avatar-maker`](../avatar-maker/), [`voiceover-maker`](../voiceover-maker/), [`subtitle-burner`](../subtitle-burner/), [`flyer-maker`](../flyer-maker/), [`reel-builder`](../reel-builder/), [`logo-maker`](../logo-maker/), [`quote-card-maker`](../quote-card-maker/), [`gif-maker`](../gif-maker/), [`banner-maker`](../banner-maker/), [`meme-card-maker`](../meme-card-maker/), [`upscaler`](../upscaler/), [`audio-mix-maker`](../audio-mix-maker/), [`style-transfer`](../style-transfer/), [`transcribe-maker`](../transcribe-maker/), [`style-suggest`](../style-suggest/), [`proposal-maker`](../proposal-maker/) +- **EN + RU** (38) — [`writer`](../writer/), [`viral-text`](../viral-text/), [`style-check`](../style-check/), [`canon-check`](../canon-check/), [`skills-update`](../skills-update/), [`skills-keys`](../skills-keys/), [`skills-styles`](../skills-styles/), [`tone-shifter`](../tone-shifter/), [`cold-email`](../cold-email/), [`image-prompt`](../image-prompt/), [`video-prompt`](../video-prompt/), [`music-prompt`](../music-prompt/), [`microcopy`](../microcopy/), [`release-notes`](../release-notes/), [`rfc-writer`](../rfc-writer/), [`landing-copy`](../landing-copy/), [`research-brief`](../research-brief/), [`carousel-builder`](../carousel-builder/), [`cover-maker`](../cover-maker/), [`thumbnail-maker`](../thumbnail-maker/), [`bg-remover`](../bg-remover/), [`avatar-maker`](../avatar-maker/), [`voiceover-maker`](../voiceover-maker/), [`subtitle-burner`](../subtitle-burner/), [`flyer-maker`](../flyer-maker/), [`reel-builder`](../reel-builder/), [`logo-maker`](../logo-maker/), [`quote-card-maker`](../quote-card-maker/), [`gif-maker`](../gif-maker/), [`banner-maker`](../banner-maker/), [`meme-card-maker`](../meme-card-maker/), [`upscaler`](../upscaler/), [`audio-mix-maker`](../audio-mix-maker/), [`style-transfer`](../style-transfer/), [`transcribe-maker`](../transcribe-maker/), [`style-suggest`](../style-suggest/), [`proposal-maker`](../proposal-maker/), [`post-publisher`](../post-publisher/) - **RU** (3) — [`prose-edit`](../prose-edit/), [`essay-write`](../essay-write/), [`pelevin-digression`](../pelevin-digression/) diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 7b3a5d0..8b61c6e 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -69,7 +69,9 @@ Jump straight to the section for what you want to do. | Research a topic with citations | [§ research-brief](#i-want-to-research-a-topic) | | Build an N-slide carousel | [§ carousel-builder](#i-want-to-build-a-carousel) | | Build a vertical reel | [§ reel-builder](#i-want-to-build-a-reel) | +| Publish it to a social platform | [§ post-publisher](#i-want-to-publish-a-post) | | Full chain (research → carousel + reel) | [walkthrough](walkthroughs/research-to-carousel-reel.md) | +| Full chain (generate → publish) | [walkthrough](walkthroughs/publish-to-social.md) | ### Manage the collection @@ -84,12 +86,12 @@ Stuck? [FAQ](FAQ.md) · [Troubleshooting](TROUBLESHOOTING.md). ## The collection in one paragraph -Forty-one skills layered on top of one base linter (`writer`): +Forty-two skills layered on top of one base linter (`writer`): - **Base**: `writer` strips 25 catalogued categories of LLM-prose tells from any text, in RU or EN, plus chatbot copy-paste artifacts and rhythm metrics. Runs as a final pass under every other prose skill. - **Wrappers** (21): prose editing (`viral-text`, `prose-edit`, `essay-write`, `pelevin-digression`, `tone-shifter`), marketing + ops (`cold-email`, `microcopy`, `release-notes`, `rfc-writer`, `landing-copy`), AI media prompts (`image-prompt`, `video-prompt`, `music-prompt`), and media utilities (`bg-remover`, `voiceover-maker`, `subtitle-burner`, `gif-maker`, `upscaler`, `audio-mix-maker`, `style-transfer`, `transcribe-maker`). - **Linters** (read-only — produce reports, don't edit): `style-check` for pre-commit prose lint and AI-detection audits, `translation-sync` for RU/EN/PT-BR parity, `canon-check` for story-bible consistency. -- **Orchestrators** (13, end-to-end pipelines): `research-brief` produces cited research; `carousel-builder` turns topics into N-slide carousels; `reel-builder` produces stitched vertical reels with matched music; `proposal-maker` turns a raw offer into a brand-faithful HTML proposal; `style-suggest` turns a description or reference image into a new visual-style entry; single-image orchestrators (`flyer-maker` / `cover-maker` / `thumbnail-maker` / `avatar-maker` / `logo-maker` / `quote-card-maker` / `banner-maker` / `meme-card-maker`) ship structured visual artifacts. +- **Orchestrators** (14, end-to-end pipelines): `research-brief` produces cited research; `carousel-builder` turns topics into N-slide carousels; `reel-builder` produces stitched vertical reels with matched music; `post-publisher` sends any of that to Instagram / Threads / TikTok / X / YouTube / Telegram / LinkedIn through the official APIs; `proposal-maker` turns a raw offer into a brand-faithful HTML proposal; `style-suggest` turns a description or reference image into a new visual-style entry; single-image orchestrators (`flyer-maker` / `cover-maker` / `thumbnail-maker` / `avatar-maker` / `logo-maker` / `quote-card-maker` / `banner-maker` / `meme-card-maker`) ship structured visual artifacts. - **Meta** (3): `skills-update` (apply a newer release of this collection), `skills-keys` (manage `~/.skills.env` API keys for the `--execute` layer), `skills-styles` (manage the local style library). Skills work on any text file (`.md`, `.tex`, `.txt`, …) — there's no assumed file format or project layout. @@ -559,6 +561,62 @@ For details: [reel-builder/SKILL.md](../reel-builder/SKILL.md) and [research-to- --- +### "I want to publish a post" {#i-want-to-publish-a-post} + +`/post-publisher ` closes the last mile: it takes a `carousel-builder` or +`reel-builder` output directory, reads `captions.md`, and sends it to the +platform. Instagram, Threads, TikTok, X, YouTube, Telegram and LinkedIn, all +through their official APIs. + +**Publishing is irreversible, so dry-run is the default.** Without `--yes` +nothing leaves the machine; with `--yes` each platform is still confirmed +separately. + +``` +/post-publisher ./generated/carousel// --platform instagram,threads +/post-publisher ./generated/carousel// --platform instagram --draft --yes +/post-publisher ./generated/reel// --platform tiktok --draft --yes +/post-publisher --kind text --text "..." --platform telegram --yes +/post-publisher --list-platforms # what is configured +``` + +Before the first post, connect the account once: + +``` +python3 -m common.runners.cli.auth --platform threads # browser OAuth flow +python3 -m common.runners.cli.auth --status # what is connected +skills-keys accounts # the same, from the keys skill +``` + +Start with **Telegram** (no OAuth at all — a BotFather token and an admin bot) +or **Threads** (simplest OAuth, and a text post needs no S3 bucket). Instagram +is the worst place to start: it needs a Business account, a registered app and +an S3 bucket before the first post can go out, because Instagram fetches media +from a URL rather than accepting uploaded bytes. + +Two behaviours worth knowing: + +- **`--draft` is a promise.** On a platform without drafts (Telegram, X, + LinkedIn) the platform is *skipped*, never published live. +- **Receipts prevent double-posting.** Each success appends to `posted.json` in + the source directory. Re-running the identical command is refused; editing the + caption counts as new content and goes through. `--force` overrides. + +TikTok deserves its own warning: direct publishing requires passing TikTok's app +audit, and an unaudited app has every post silently forced to SELF_ONLY — the +API reports success and nobody can see the post. Use `--draft`, which lands it +in the app inbox and always works. + +Setup per platform: [post-publisher/references/oauth-setup.md](../post-publisher/references/oauth-setup.md). +When the API path is genuinely closed (unaudited TikTok, personal Instagram, +company LinkedIn pages), [browser-fallback.md](../post-publisher/references/browser-fallback.md) +covers posting by hand without breaking the receipt trail. + +For details: [post-publisher/SKILL.md](../post-publisher/SKILL.md) and +[publish-to-social](walkthroughs/publish-to-social.md). + +--- + ### "I want to design a logo / brand mark" {#i-want-to-make-a-logo} `/logo-maker --brand ""` generates N stochastic logo variants. Defaults to `ideogram-3-quality` for cleanest embedded text. Pick from six style presets: `wordmark` / `minimal` / `illustrated` / `typographic` / `geometric` / `emblem`. @@ -935,7 +993,7 @@ See [`README.md § What's in the box`](../README.md#whats-in-the-box) for the up - [QUICKSTART](QUICKSTART.md) — 5-minute first run - [FAQ](FAQ.md) — answers to the questions people ask first - [TROUBLESHOOTING](TROUBLESHOOTING.md) — known failure modes + fixes -- [COMPOSING](COMPOSING.md) — how the 41 skills compose; recipe library +- [COMPOSING](COMPOSING.md) — how the 42 skills compose; recipe library - [SKILL-INDEX](SKILL-INDEX.md) — every skill indexed by layer / domain / language - [walkthroughs/README.md](walkthroughs/README.md) — 19 walkthroughs, categorized - [CONTRIBUTING](../CONTRIBUTING.md) — adding your own skill to the collection diff --git a/docs/walkthroughs/README.md b/docs/walkthroughs/README.md index 3f4ca3d..2a56193 100644 --- a/docs/walkthroughs/README.md +++ b/docs/walkthroughs/README.md @@ -60,6 +60,7 @@ These chain multiple skills + the execute layer + style libraries into a single | Walkthrough | Skills | What you'll do | |---|---|---| | [Research → carousel → reel (end-to-end)](research-to-carousel-reel.md) | `research-brief` + `carousel-builder` + `reel-builder` | One topic → cited research brief → 8-slide LinkedIn carousel → 15-second vertical reel. ~6-10 min wall time, $2-7 total cost depending on provider mix. The most ambitious recipe in the collection. | +| [Generate → publish (the last mile)](publish-to-social.md) | `post-publisher` + `carousel-builder` + `viral-text` | Connect an account, then send a finished deck or reel to Instagram / Threads / TikTok / X / YouTube / Telegram / LinkedIn. Dry-run first, drafts where the platform has them, receipts so nothing goes out twice. ~20 min the first time, ~2 min after. | --- diff --git a/docs/walkthroughs/publish-to-social.md b/docs/walkthroughs/publish-to-social.md new file mode 100644 index 0000000..087885a --- /dev/null +++ b/docs/walkthroughs/publish-to-social.md @@ -0,0 +1,247 @@ +--- +title: "Generate → publish (the last mile)" +persona: "Solo creator who already has the content and needs it posted" +time: "20 minutes first time, 2 minutes after" +skills: + - post-publisher + - carousel-builder + - viral-text +--- + +# Generate → publish + +Everything else in this collection ends at `./generated/`. This is the part that +sends it. + +Two rules shape the whole workflow, and both exist because publishing cannot be +undone: + +- **Dry-run is the default.** Without `--yes` nothing leaves your machine. +- **Consent does not fan out.** Even with `--yes`, each platform asks separately. + +--- + +## Step 0 — connect one account (once) + +Do this with Telegram first. It is the only platform with no OAuth, no app +review and no account-type requirement, which makes it the one place you can +prove the pipeline works before fighting anyone's developer portal. + +1. Message [@BotFather](https://t.me/BotFather) → `/newbot` → copy the token. +2. Add the bot to your channel **as an administrator** with "Post messages" on. + +```bash +skills-keys add TELEGRAM_BOT_TOKEN 123456:AA... +skills-keys add TELEGRAM_CHAT_ID @your_channel + +post-publisher --list-platforms +``` + +``` +Platforms: + instagram missing env: INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET [carousel, image, video · draft] + linkedin missing env: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET [carousel, image, text, video] + telegram ready [carousel, image, text, video] + threads missing env: THREADS_APP_ID, THREADS_APP_SECRET [carousel, image, text, video · draft] + ... +``` + +A bot that is a member but not an admin is the commonest failure here, and it +shows up as `not enough rights` at publish time rather than in this list. + +--- + +## Step 1 — a text post, end to end + +```bash +post-publisher --kind text --text "Проверка связи." --platform telegram +``` + +``` +DRY RUN — nothing will be sent. Add --yes to publish for real. + + → PUBLISH to telegram + kind: text + text (15 chars): + Проверка связи. + +Dry run complete — 1 of 1 platform(s) would proceed (0 skipped, 0 blocked). Add --yes to publish. +``` + +Nothing happened. Add `--yes`: + +```bash +post-publisher --kind text --text "Проверка связи." --platform telegram --yes +``` + +``` +PUBLISH to telegram? [y/N] y +✓ telegram: published → https://t.me/your_channel/1 +``` + +That is the whole contract. Everything below is the same three steps with more +platforms attached. + +--- + +## Step 2 — a real carousel + +```bash +/carousel-builder --topic "почему автопостинг ломается на последней миле" \ + --slides 8 --platform instagram --execute +``` + +Then hand the output directory over: + +```bash +post-publisher ./generated/carousel/pochemu-avtoposting-lomaetsya/ \ + --platform telegram \ + --hashtags "smm,автопостинг" \ + --alt "Обложка" --alt "График срывов" +``` + +The caption comes from `captions.md` automatically. **Read what it extracted** +in the preview — that file is written by an agent, so its structure is a +convention rather than a guarantee. Override with `--text-file` if the parse is +wrong. + +Preflight runs before anything is sent, and it knows the difference between a +Telegram text post (4096 chars) and a Telegram caption on media (1024): + +``` + warn alt_texts: 2 alt texts for 8 files — files 3-8 will go without + → PUBLISH to telegram + kind: carousel + media: slide-1.png 1.2 MB alt="Обложка" + ... +``` + +--- + +## Step 3 — add Instagram + +Instagram needs three things Telegram did not: a Business or Creator account, a +registered app, and an S3 bucket. The bucket is not optional — Instagram fetches +media from a URL and never accepts uploaded bytes. + +```bash +skills-keys add INSTAGRAM_APP_ID +skills-keys add INSTAGRAM_APP_SECRET +skills-keys add S3_BUCKET my-bucket +skills-keys add S3_ACCESS_KEY ... +skills-keys add S3_SECRET_KEY ... + +python3 -m common.runners.cli.auth --platform instagram +``` + +Full setup, per platform, including which scopes and which account type: +[`post-publisher/references/oauth-setup.md`](../../post-publisher/references/oauth-setup.md). + +Then stage it rather than publishing it: + +```bash +post-publisher ./generated/carousel// --platform instagram --draft --yes +``` + +``` +✓ instagram: draft (id 17920...) + container staged and processed, NOT posted. It expires in 24h. + Publish it with: --platform instagram --publish-container 17920... +``` + +The container is real — the media has been uploaded, fetched and processed, so a +broken file fails here rather than at publish time. Nothing is public yet. + +```bash +post-publisher --platform instagram --publish-container 17920... --yes +``` + +--- + +## Step 4 — fan out, and what happens when it does not fit + +```bash +post-publisher ./generated/posts/ --platform telegram,threads,x --yes +``` + +``` +✓ telegram: published → https://t.me/your_channel/412 + + BLOCK text: 1180 chars exceeds the 500-char limit (over by 680) +✗ threads: 1 blocking issue(s) — not sent + + BLOCK text: 1180 chars exceeds the 280-char limit (over by 900) +✗ x: 1 blocking issue(s) — not sent + +Published: 1 · skipped: 0 · failed: 2 +``` + +This is the normal outcome of writing one caption for seven networks, and the +fix belongs upstream. Ask `viral-text` for a per-platform variant — the budgets +live in [`viral-text/references/platforms.md`](../../viral-text/references/platforms.md) +— and publish each with its own `--text-file`. The skill will not truncate to +make a bad fit look like a good one. + +--- + +## Step 5 — re-running is safe + +```bash +post-publisher ./generated/carousel// --platform telegram --yes +``` + +``` +— telegram: skipped, identical content already published at 2026-08-01T09:14:00+00:00 + (https://t.me/your_channel/412). Use --force. +``` + +`posted.json` in the source directory records every success, keyed on platform +plus a hash of the content. Fix a typo in the caption and it counts as a new +post and goes through; re-run the same command and it does not. + +--- + +## The platform that behaves differently + +TikTok. Direct publishing requires passing TikTok's app audit, and until that +happens every direct post is forced to SELF_ONLY — **the API reports success and +nobody can see the post.** There is no error to catch. + +So the recommended path is the inbox: + +```bash +post-publisher ./generated/reel// --platform tiktok --draft --yes +``` + +``` +✓ tiktok: draft (id v_pub_url~v2...) + landed in the TikTok app inbox — open the app to caption and publish it +``` + +Without `--draft`, preflight says so before you commit to it. + +--- + +## When the API cannot do it + +An unaudited TikTok app, a personal Instagram account, a LinkedIn company page — +these are closed to the API, not merely inconvenient. +[`browser-fallback.md`](../../post-publisher/references/browser-fallback.md) +covers posting those by hand while keeping the receipt trail intact, so a later +API run does not double-post. Run the dry-run first even then: character limits +and file sizes do not stop mattering because the transport changed. + +--- + +## Summary + +| Step | Time | Result | +|---|---|---| +| Connect Telegram | 5 min | Whole pipeline provable, no OAuth | +| Text post | 1 min | Dry-run → `--yes` → live | +| Carousel from `./generated/` | 1 min | Caption auto-read, preflight enforced | +| Connect Instagram + S3 | 15 min | Business account, app, bucket | +| Everything after | ~30 s | One command per post | + +Troubleshooting any platform error: +[`post-publisher/references/troubleshoot.md`](../../post-publisher/references/troubleshoot.md). diff --git a/docs/walkthroughs/research-to-carousel-reel.md b/docs/walkthroughs/research-to-carousel-reel.md index 0d33d1f..2b5e3b6 100644 --- a/docs/walkthroughs/research-to-carousel-reel.md +++ b/docs/walkthroughs/research-to-carousel-reel.md @@ -196,11 +196,33 @@ You now have: - 15-second vertical reel in `./generated/reel/.../final.mp4` - Research brief with cited sources at `./generated/research/...md` (useful for fact-checking comments) -Upload manually: -- LinkedIn: open the LinkedIn UI, attach 8 images, paste the caption from `captions.md`. -- Instagram / TikTok: open the app, upload `final.mp4`, paste post caption. +### Step 4 — publish -The skills don't post for you — that's a deliberate boundary (each platform's API has different OAuth flows, and you may want to schedule via Buffer / Later / Notion). +`post-publisher` takes either output directory and sends it. Nothing goes out +without a confirmation: dry-run is the default, and `--yes` still asks per +platform. + +```bash +# see what would happen, change nothing +post-publisher ./generated/carousel// --platform linkedin,instagram + +# stage the Instagram post without publishing it +post-publisher ./generated/carousel// --platform instagram --draft --yes + +# the reel into TikTok's inbox, to finish in the app +post-publisher ./generated/reel// --platform tiktok --draft --yes +``` + +The caption is read from `captions.md` automatically — check what it extracted +in the dry-run preview before adding `--yes`. + +Accounts have to be connected once first; see +[`post-publisher/references/oauth-setup.md`](../../post-publisher/references/oauth-setup.md). +Telegram needs no OAuth and Threads is the simplest of the OAuth ones, so those +are the right places to start. Where the API path is closed — an unaudited +TikTok app, a personal Instagram account — uploading by hand is still the +answer, and `references/browser-fallback.md` covers doing that with the browser +while keeping the receipt honest. --- diff --git a/post-publisher/SKILL.md b/post-publisher/SKILL.md new file mode 100644 index 0000000..7fbc1d6 --- /dev/null +++ b/post-publisher/SKILL.md @@ -0,0 +1,152 @@ +--- +name: post-publisher +description: "Publish finished assets to Instagram, Threads, TikTok, X, YouTube, Telegram or LinkedIn via official APIs. Dry-run by default; --draft where supported. Use when: 'опубликуй это', 'выложи карусель в треды', 'post this to Instagram'. Downstream of carousel-builder / reel-builder, which only make files." + +license: MIT +allowed-tools: + - Read + - Write + - Edit + - Bash + - Grep + - Glob +--- + + +The last mile. Every other skill in this collection stops at `./generated/...`; this one sends what is there to the platform and records that it did. + +Input: an output directory from `carousel-builder` / `reel-builder` (or a single file, or just text). Output: a live post or a platform-side draft, plus a `posted.json` receipt. + +This skill does NOT: +- Write the content — that is `viral-text` / `essay-write` / `carousel-builder` / `reel-builder`. +- Schedule anything. There is no queue and no cron. It publishes now, or it stages a draft you finish by hand. +- Post to a platform you have not connected. Credentials are yours; nothing is bundled. +- Guarantee reach, or touch analytics. + + +## ROLE + +Resolve a source directory → build one platform-neutral `Post` → preflight it against each target platform's real limits → show the user exactly what would go where → ask → publish → write a receipt. + +**Publication is irreversible. Dry-run is the default and `--yes` is what leaves it.** Even with `--yes`, each platform is confirmed separately: approving an Instagram post is not approval to also post to X. + +## PIPELINE + +1. **Resolve the source.** A directory is inspected in this order: `final.mp4` → video; several images → carousel (sorted numerically, so `slide-2` precedes `slide-10`); one image → image; nothing → text post. Override with `--kind`. + +2. **Resolve the caption.** From `--text`, else `--text-file`, else `captions.md` in the source directory. The parser looks for a `## Main post` / `## Основной пост` heading and takes everything up to the next heading; with no such heading it takes the whole file minus headings. **Always check the extracted text in the dry-run preview** — `captions.md` is written by an agent, so its shape is a convention, not a contract. + +3. **Preflight, per platform.** Character limits including hashtags, media count, file size, extension, missing alt text, and each platform's own rules (Telegram's 1024-char caption ceiling that does not apply to text posts; Instagram's 24h posting cap; TikTok's audit caveat). A `block` finding means nothing is sent; a `warn` means it goes but you should know. + +4. **Preview + confirm.** Full text, character count, every file with its size and alt text, and whether it is going out live or as a draft. + +5. **Publish, then record.** The receipt (`posted.json`) is written immediately after each platform succeeds — a crash mid-fan-out cannot cause a re-post of the platforms already done. A later run with identical content is refused unless `--force`. + +## PLATFORMS + +| Platform | Post kinds | Draft | The thing that will bite you | +|---|---|---|---| +| `telegram` | text, image, carousel, video | — | Caption on media caps at 1024 chars (text-only gets 4096). Video ≤50 MB. **The only one testable end-to-end without OAuth — start here.** | +| `threads` | text, image, carousel, video | container | 500 chars. Text-only posts need no S3 — the easiest real connection. | +| `instagram` | image, carousel, video | container | Business/Creator account required. Media is fetched by URL, so **S3 is mandatory**. Video publishes as a Reel. 25 posts/24h. | +| `tiktok` | video, image, carousel | **inbox** | Direct publishing needs an audited app; without one, posts are silently forced to SELF_ONLY. **Use `--draft`.** | +| `x` | text, image, carousel, video | — | 280 chars. Free tier ~17 posts/24h. Threads publish incrementally — a failure mid-chain leaves earlier posts live. | +| `youtube` | video | `private` | 1600 quota units per upload out of 10,000/day ≈ 6 uploads, drafts included. Cleanest draft of the seven. | +| `linkedin` | text, image, carousel, video | — | 3000 chars. Member posts work with `w_member_social`; company pages need the partner-gated Community Management API. | + +## MODES + +### Source +- `post-publisher ` — a `carousel-builder` / `reel-builder` output directory +- `post-publisher ` — a single image or video +- `post-publisher --kind text --text "..."` — no source at all + +### Targeting +- `--platform a,b,c` — comma-separated; each is confirmed separately +- `--draft` — stage instead of publish. A platform without drafts is **skipped**, not published live +- `--kind text|image|carousel|video` — override auto-detection + +### Content +- `--text` / `--text-file` — caption (overrides `captions.md`) +- `--title` — YouTube title / LinkedIn article title +- `--hashtags "a,b,c"` — kept separate from the body so preflight can count them +- `--alt "..."` — repeat once per media file, in order +- `--link` — attached where the platform supports it + +### Execution +- *(nothing)* — dry-run. Prints what would happen and stops +- `--yes` — leave dry-run. Still asks before each platform +- `--force` — publish despite a matching receipt in `posted.json` +- `--publish-container ` — publish a Meta container left by an earlier `--draft` +- `--check --platform X` — readiness: credentials present, token usable +- `--list-platforms` — everything, with what each is missing + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Everything asked for happened, or was already done | +| `1` | At least one platform failed — blocked by preflight, missing credentials, or a platform error | +| `2` | Usage error: no `--platform`, unreadable source | + +A *skip* is not a failure. A platform skipped because it has no drafts, or +because `posted.json` says this content already went out, leaves the code at +`0` — in a mixed run (`--platform telegram,instagram --draft`) Instagram +drafting successfully should not be reported as an error because Telegram was +skipped. The summary line always states how many proceeded, were skipped and +were blocked. + +### Accounts +- `python3 -m common.runners.cli.auth --platform ` — OAuth in a browser +- `... --paste-token` — paste a token from the platform's own token tool (the supported route wherever loopback redirects are rejected) +- `... --status` / `--verify` / `--revoke ` +- `skills-keys accounts` — the same list, from the credentials skill + +## REFERENCES (load on demand) + +| File | When to load | +|---|---| +| [references/oauth-setup.md](references/oauth-setup.md) | **First** — per-platform app registration, scopes, account type, which env vars go in `~/.skills.env` | +| [references/platform-limits.md](references/platform-limits.md) | Step 3 — the limit matrix `preflight` enforces, with what to verify against live docs | +| [references/browser-fallback.md](references/browser-fallback.md) | When the API path is closed: unaudited TikTok, personal Instagram, org LinkedIn pages | +| [references/troubleshoot.md](references/troubleshoot.md) | A platform returned an error and the message is not self-explanatory | +| [viral-text platform budgets](../viral-text/references/platforms.md) | Writing to fit a platform *before* generating — character budgets and tone per network | + +## EXAMPLES + +See [examples/before-after.md](examples/before-after.md) — three calibration runs: an 8-slide carousel to Instagram as a draft, a reel to TikTok's inbox, and a text post fanned out to Threads + Telegram + X. + +## CONSTRAINTS + +- **Dry-run is the default and must stay that way.** Every other runner here is safely re-runnable because a wasted generation costs cents. A wasted publish costs an audience. `--yes` is the deliberate act. + +- **Confirm per platform, never in bulk.** A single "yes" must not fan out to seven networks. + +- **`--draft` is a promise.** If a platform cannot draft, skip it. Publishing live because the user asked for a draft is the worst possible reading of the flag. + +- **Never print a token.** Masked in `--status`, absent from errors, absent from logs. `~/.skills-tokens.json` is `chmod 600` and gitignored. + +- **Preflight before bytes.** Every check that can happen locally happens before the first network call. Staging a 200 MB reel to S3 and waiting a minute for ingestion, only to be refused on a caption length, is a bug. + +- **Receipts are per (platform, content).** Editing the caption makes it a new post and it goes through; re-running the identical command does not. + +- **Report partial failure as partial.** An X thread that publishes 3 of 6 posts is not a failed publish and must not be reported as one — the receipt records what actually went out. + +- **Verify endpoints before first use.** All seven APIs move. Versions are env-overridable (`INSTAGRAM_API_VERSION`, `LINKEDIN_API_VERSION`, `THREADS_API_VERSION`, `X_UPLOAD_URL`) precisely so a vendor change does not need a code change. `references/platform-limits.md` carries the last-verified date. + +- **The browser path is a fallback, not a peer.** It is best-effort, it is a grey area under some platforms' terms, and it requires explicit consent every single time. Never drive it silently. + +- **No engagement automation.** This skill posts what the user wrote. It does not follow, like, comment, DM, or run multiple accounts in concert. + +## INVOCATION HINTS + +Triggers: +- "опубликуй / выложи / запость это", "залей в инсту", "выложи карусель в треды", "кинь рил в тикток" +- "post this to X", "publish the carousel", "upload the reel to YouTube", "send it to the channel" +- Immediately after `carousel-builder` or `reel-builder` finishes, when the user says "и выложи" / "and post it" + +Defaults: dry-run, no `--draft`, caption from `captions.md`. If the user names no platform, ask once — do not guess a destination. + +If nothing is connected yet, route to `references/oauth-setup.md` and suggest starting with Telegram (no OAuth) or Threads (simplest OAuth), not Instagram. + +Upstream: `carousel-builder`, `reel-builder`, `viral-text`, `quote-card-maker`, `meme-card-maker` — anything that lands assets in `./generated/`. diff --git a/post-publisher/examples/before-after.md b/post-publisher/examples/before-after.md new file mode 100644 index 0000000..a0069a1 --- /dev/null +++ b/post-publisher/examples/before-after.md @@ -0,0 +1,161 @@ +# Calibration runs + +Three runs showing what the skill prints and where it stops. All three begin +in dry-run, because that is the default. + +--- + +## 1. An 8-slide carousel to Instagram, as a draft + +**Input** — a finished `carousel-builder` directory: + +``` +./generated/carousel/pochemu-avtoposting-lomaetsya/ + slide-1.png … slide-8.png + captions.md + manifest.json +``` + +**Command** + +```bash +post-publisher ./generated/carousel/pochemu-avtoposting-lomaetsya/ \ + --platform instagram --draft \ + --hashtags "smm,автопостинг,контент" \ + --alt "Обложка: заголовок на тёмном фоне" --alt "График срывов публикаций" +``` + +**Output** + +``` +DRY RUN — nothing will be sent. Add --yes to publish for real. + + warn alt_texts: 2 alt texts for 8 files — files 3-8 will go without + → DRAFT to instagram + kind: carousel + text (312 chars): + Автопостинг ломается не на генерации, а на последней миле. + Разобрали восемь мест, где это происходит. + + #smm #автопостинг #контент + media: slide-1.png 1.2 MB alt="Обложка: заголовок на тёмном фоне" + media: slide-2.png 1.1 MB alt="График срывов публикаций" + media: slide-3.png 1.3 MB (no alt) + … + media: slide-8.png 1.2 MB (no alt) + +Dry run complete — 1 of 1 platform(s) would proceed (0 skipped, 0 blocked). Add --yes to publish. +``` + +Two things to read here. The caption came from `captions.md` — check it, because +that file is written by an agent and its shape is a convention. And the alt-text +warning is real: eight slides, two alts. + +**Then, for real:** + +```bash +post-publisher ./generated/carousel/pochemu-avtoposting-lomaetsya/ \ + --platform instagram --draft --yes ... +``` + +``` +Create draft on instagram? [y/N] y +✓ instagram: draft (id 17920...) + container staged and processed, NOT posted. It expires in 24h. + Publish it with: --platform instagram --publish-container 17920... + +Published: 0 · skipped: 0 · failed: 0 +Receipt: ./generated/carousel/pochemu-avtoposting-lomaetsya/posted.json +``` + +The container is real: the media has been fetched and processed, so a broken +file would have failed here rather than at publish time. Nothing is public yet. + +--- + +## 2. A reel to TikTok's inbox + +**Command** + +```bash +post-publisher ./generated/reel/kak-my-slomali-ochered/ --platform tiktok --draft --yes +``` + +**Output** + +``` + → DRAFT to tiktok + kind: video + text (88 chars): + Три недели чинили очередь публикаций. Рассказываем, что было не так. + media: final.mp4 24.3 MB (no alt) + +Create draft on tiktok? [y/N] y +✓ tiktok: draft (id v_pub_url~v2...) + landed in the TikTok app inbox — open the app to caption and publish it +``` + +Without `--draft` the same command adds a warning first: + +``` + warn audit: direct publishing requires an audited TikTok app. Unaudited apps have + every post forced to SELF_ONLY — it looks published and nobody sees it. + Use --draft to land it in the app inbox instead, which always works. +``` + +That warning is the reason the inbox is the recommended path. The direct-post +call *succeeds* on an unaudited app — it simply produces a post only you can +see, and the API says nothing about it. + +--- + +## 3. One text post to three platforms at once + +This is the recurring real failure: a post drafted for Telegram's 4096 +characters, then fanned out unchanged. + +**Command** + +```bash +post-publisher --kind text --text-file ./generated/posts/ocheredi.md \ + --platform telegram,threads,x --yes +``` + +**Output** + +``` + → PUBLISH to telegram + kind: text + text (1180 chars): + Три недели мы чинили очередь публикаций, и в итоге выяснилось, что + проблема была не в очереди. + … + +PUBLISH to telegram? [y/N] y +✓ telegram: published → https://t.me/mikefluff_channel/412 + + BLOCK text: 1180 chars exceeds the 500-char limit (over by 680) +✗ threads: 1 blocking issue(s) — not sent + + BLOCK text: 1180 chars exceeds the 280-char limit (over by 900) +✗ x: 1 blocking issue(s) — not sent + +Published: 1 · skipped: 0 · failed: 2 +Receipt: ./generated/posts/posted.json +``` + +Four things this run demonstrates: + +- **Each platform is confirmed separately.** One "yes" does not fan out to the + rest. +- **A block on one platform does not stop the others.** Telegram went out; + Threads and X were refused locally and never received a request. +- **Nothing partial was posted to the blocked platforms** — the check is local, + so no truncated version leaked out. +- **Exit code is 1** because something failed, even though one post succeeded. + +The fix is upstream, not here: ask `viral-text` for a per-platform variant +([`viral-text/references/platforms.md`](../../viral-text/references/platforms.md) +carries the budgets), then publish each with its own `--text-file`. Writing one +caption and hoping it fits all seven networks does not work, and this skill will +not silently truncate to make it look like it did. diff --git a/post-publisher/references/browser-fallback.md b/post-publisher/references/browser-fallback.md new file mode 100644 index 0000000..d6c11d4 --- /dev/null +++ b/post-publisher/references/browser-fallback.md @@ -0,0 +1,93 @@ +# Browser fallback + +For the cases where the official API is closed rather than merely inconvenient. + +This is a **fallback, not a peer** of the API path. It is slower, it breaks when +a platform ships a redesign, on some platforms UI automation sits in a grey area +of the terms of service, and it cannot run unattended. Reach for it when the API +genuinely cannot do the job — not to skip a setup step. + +## When it is actually justified + +| Situation | Why the API cannot do it | +|---|---| +| TikTok, public post, unaudited app | Direct publishing is gated behind app audit. Everything else is forced to SELF_ONLY. | +| Instagram on a personal account | No API publishes to personal accounts. Converting to Creator is free and is the better fix if the user is willing. | +| LinkedIn company page | Needs the partner-gated Community Management API. | +| Instagram or Threads with media, no S3 | Both fetch media by URL. If a bucket is genuinely not an option, hands are the alternative. | +| One-off post, no app registered | Registering an app to post once is disproportionate. | + +If none of these apply, fix the API path instead — see `oauth-setup.md`. + +## What this is NOT for + +- Posting to many accounts, or on a schedule, or at volume. That is exactly the + behaviour platform anti-automation exists to stop, and this file does not help + with it. +- Working around an account restriction, a ban, or a rate limit. +- Anything the user has not asked for in this session. + +## Protocol + +Driven by Claude in Chrome against the user's own logged-in browser. Nothing is +stored: no cookies, no session, no credentials. That is the point — a Playwright +robot holding social-network session cookies would be a worse thing to own than +the problem it solves. + +1. **Prepare first.** Run the normal dry run so preflight still applies: + + ```bash + post-publisher ./generated/carousel// --platform instagram + ``` + + Character limits, file sizes and aspect rules do not stop mattering because + the transport changed. Fix anything it blocks on before opening a browser. + +2. **Show the user what will be posted** — the caption in full, the file list, + the target account — and get an explicit yes. Every time. Consent for one + post is not consent for the next. + +3. **Open a new tab.** Never reuse a tab id from an earlier session; call + `tabs_context_mcp` for current ids. + +4. **Navigate to the composer**, attach the files in order, paste the caption. + +5. **Stop before the final button.** Screenshot the composed post, show it, and + ask once more. This is the last reversible moment. + +6. **Publish**, then confirm the post exists and capture its URL. + +7. **Record it.** Append to `posted.json` in the source directory by hand so a + later API run does not double-post: + + ```json + {"version": 1, "receipts": [{ + "platform": "instagram", "post_id": "manual", + "state": "published", "content_hash": "", + "published_at": "2026-08-01T12:00:00+00:00", + "permalink": "https://instagram.com/p/...", "note": "posted via browser" + }]} + ``` + + The `content_hash` is printed by the dry run. Without this entry the receipt + check cannot protect you. + +## Rules + +- **Never trigger a modal.** `alert()` / `confirm()` blocks the extension and + the session goes unresponsive until the user dismisses it by hand. Avoid + anything that raises one, and warn the user first if it is unavoidable. +- **Stop after two or three failed attempts.** Report what was tried and what + happened; do not keep clicking. A composer that will not accept a file is + usually a real problem, not a timing one. +- **Do not log in on the user's behalf** and never handle their password. If the + session is logged out, hand the browser back and wait. +- **One post per invocation.** No loops over accounts or queues. + +## Cost of the redesign + +Every platform in this list reworks its composer periodically. When a step here +stops matching what is on screen, the fix is to re-read the page and adapt — +not to add selectors to this file. It is deliberately written as intent +("navigate to the composer") rather than as a click script, because a click +script is guaranteed to rot and would give a false impression of reliability. diff --git a/post-publisher/references/oauth-setup.md b/post-publisher/references/oauth-setup.md new file mode 100644 index 0000000..fd50673 --- /dev/null +++ b/post-publisher/references/oauth-setup.md @@ -0,0 +1,198 @@ +# Connecting accounts + +Two stores, two lifetimes. App credentials go in `~/.skills.env` by hand and last +until you rotate them. User tokens go in `~/.skills-tokens.json` and are written +only by the auth flow — never edit that file. + +```bash +python3 -m common.runners.cli.auth --platform threads # browser flow +python3 -m common.runners.cli.auth --platform instagram --paste-token +python3 -m common.runners.cli.auth --status # what is connected +python3 -m common.runners.cli.auth --platform x --verify # is it still good +python3 -m common.runners.cli.auth --revoke tiktok # forget it locally +``` + +The loopback listener defaults to `http://localhost:8723/callback`. Register +exactly that string with the platform. Change it with `SKILLS_OAUTH_PORT` / +`SKILLS_OAUTH_REDIRECT` if 8723 is taken — and update the platform to match. + +**`--paste-token` is not a lesser path.** Several platforms reject plain-http +loopback redirects, and their own token tools are the supported way to get a +token. Pasting is verified against the platform before anything is stored, so a +bad paste fails immediately rather than mid-publish. + +--- + +## Recommended order + +1. **Telegram** — no OAuth at all, works in five minutes, and it is the only + platform you can test the whole pipeline against without an approval queue. +2. **Threads** — simplest real OAuth, and a text post needs no S3 bucket. +3. Everything else, once you know the shape works. + +Starting with Instagram is the common mistake: it needs a Business account, an +app, *and* an S3 bucket before the first post can go out. + +--- + +## Telegram + +No app review, no OAuth, no expiry. + +1. Message [@BotFather](https://t.me/BotFather) → `/newbot` → copy the token. +2. Add the bot to your channel **as an administrator** with "Post messages" on. +3. Channel id: `@your_channel` for a public channel, or the numeric `-100…` id + for a private one. + +```bash +skills-keys add TELEGRAM_BOT_TOKEN 123456:AA... +skills-keys add TELEGRAM_CHAT_ID @your_channel +``` + +A bot that is a member but not an admin fails with "not enough rights" — that is +the single most common setup error here. + +--- + +## Threads + +1. [developers.facebook.com](https://developers.facebook.com) → create an app → + add the **Threads API** use case. +2. Permissions: `threads_basic`, `threads_content_publish`. +3. Add the redirect URI under the Threads settings. + +```bash +skills-keys add THREADS_APP_ID +skills-keys add THREADS_APP_SECRET +python3 -m common.runners.cli.auth --platform threads +``` + +The flow exchanges the short-lived token for a ~60-day one automatically and +refreshes it on use. A token untouched for 60 days cannot be refreshed — +re-run the flow. + +--- + +## Instagram + +Requires **a Business or Creator account**. There is no API path to a personal +account; if that is a blocker, see `browser-fallback.md`. + +This skill uses the *Instagram API with Instagram Login* (`graph.instagram.com`), +which does **not** need a linked Facebook Page. The older Graph API route does. + +1. Create an app → add **Instagram** → *API setup with Instagram login*. +2. Permissions: `instagram_business_basic`, `instagram_business_content_publish`. +3. Add the redirect URI. + +```bash +skills-keys add INSTAGRAM_APP_ID +skills-keys add INSTAGRAM_APP_SECRET +python3 -m common.runners.cli.auth --platform instagram +``` + +**S3 is mandatory for Instagram**, and for Threads with media. Instagram fetches +media from a URL you supply; it never accepts uploaded bytes. Set `S3_BUCKET`, +`S3_ACCESS_KEY`, `S3_SECRET_KEY` (plus `S3_ENDPOINT` / `S3_REGION` for R2, Spaces +or MinIO). The bucket stays private — files are staged and handed over as +presigned links that expire in an hour. + +--- + +## TikTok + +1. [developers.tiktok.com](https://developers.tiktok.com) → create an app → add + the **Content Posting API** product. +2. Scopes: `video.upload` (inbox/draft) and `video.publish` (direct). +3. Redirect URI must be **https**. If yours is http, use `--paste-token`. + +```bash +skills-keys add TIKTOK_CLIENT_KEY +skills-keys add TIKTOK_CLIENT_SECRET +python3 -m common.runners.cli.auth --platform tiktok +``` + +**Direct publishing requires passing TikTok's app audit.** Until then every +direct post is forced to SELF_ONLY — the API says success and nobody can see +the post. Use `--draft`, which routes to the inbox and needs no audit. + +Access tokens last 24h; the refresh token lasts a year and is used automatically. + +--- + +## X + +1. [developer.x.com](https://developer.x.com/en/portal/dashboard) → project → app. +2. User authentication: **OAuth 2.0**, type *Web App / Automated App*, with + `tweet.read`, `tweet.write`, `users.read`, `offline.access`. +3. `offline.access` is what yields a refresh token — without it the connection + dies in two hours. + +```bash +skills-keys add X_CLIENT_ID +skills-keys add X_CLIENT_SECRET +python3 -m common.runners.cli.auth --platform x +``` + +Check which tier you are on before relying on this: the free tier allows roughly +17 posts per 24h and 500 per month across the whole app. + +--- + +## YouTube + +1. [console.cloud.google.com](https://console.cloud.google.com) → project → + enable **YouTube Data API v3**. +2. OAuth consent screen → add scope `.../auth/youtube.upload` → add yourself as + a test user (an unpublished app only works for test users). +3. Credentials → OAuth client ID → **Desktop app** or Web with the loopback URI. + +```bash +skills-keys add YOUTUBE_CLIENT_ID +skills-keys add YOUTUBE_CLIENT_SECRET +python3 -m common.runners.cli.auth --platform youtube +``` + +Google issues a refresh token **only on first consent**. The flow requests +`access_type=offline&prompt=consent` to force it; if you still see "no +refresh_token", revoke the app at +[myaccount.google.com/permissions](https://myaccount.google.com/permissions) +and authorise again. + +Quota: 1600 units per upload against 10,000/day ≈ six uploads, private ones +included. + +--- + +## LinkedIn + +1. [linkedin.com/developers/apps](https://www.linkedin.com/developers/apps) → + create an app, associated with a company page you administer (the page is + only for app ownership, not for posting). +2. Products: **Share on LinkedIn** and **Sign In with LinkedIn using OpenID + Connect**. +3. Scopes: `w_member_social`, `openid`, `profile`. + +```bash +skills-keys add LINKEDIN_CLIENT_ID +skills-keys add LINKEDIN_CLIENT_SECRET +python3 -m common.runners.cli.auth --platform linkedin +``` + +This posts as **you**. Posting as a company page needs the Community Management +API, which is partner-gated and usually declined for individual developers. + +Tokens last ~60 days. Refresh tokens are only granted to approved apps, so +expect to re-run the flow rather than rely on refresh. + +--- + +## Troubleshooting the flow + +| Symptom | Cause | +|---|---| +| `cannot listen on localhost:8723` | Port in use. Set `SKILLS_OAUTH_PORT` and update the platform's redirect URI. | +| `state mismatch on callback` | A stale browser tab from an earlier attempt. Close it, re-run. | +| `redirect_uri_mismatch` | The registered URI differs from `SKILLS_OAUTH_REDIRECT`, character for character. | +| `no callback received within 300s` | The browser never reached the listener — check that the URL printed in the terminal opens. | +| Token stored but publishing 401s | Scopes were granted for a different account, or the app is unpublished and you are not a test user. Run `--verify`. | diff --git a/post-publisher/references/platform-limits.md b/post-publisher/references/platform-limits.md new file mode 100644 index 0000000..12232a5 --- /dev/null +++ b/post-publisher/references/platform-limits.md @@ -0,0 +1,117 @@ +# Platform limits + +What `preflight` enforces, and where each number came from. + +**Last verified against live vendor docs: not yet — see "Verifying" below.** +The numbers here were written from the APIs as documented at build time and are +encoded in `common/runners/publishers/*.py`. All seven platforms change theirs. +Treat a mismatch between this table and a live rejection as this table being +stale, and fix both. + +Character budgets for *writing* live in +[`viral-text/references/platforms.md`](../../viral-text/references/platforms.md) +— that is the file to consult before generating. This one is about what the +posting API accepts. + +--- + +## Text + +| Platform | Limit | Notes | +|---|---|---| +| Telegram | 4096 / **1024** | 4096 for a text message, 1024 once media is attached. Same field, two ceilings — the commonest surprise in this table. | +| Threads | 500 | Hashtags count. | +| Instagram | 2200 | Hashtags count. ~30 tags is the practical ceiling. | +| TikTok | 2200 | Title, hashtags included. | +| X | 280 | 25,000 on Premium. Each post of a thread is measured separately. | +| YouTube | 100 title / 5000 description | Tags additionally capped at ~500 chars total. | +| LinkedIn | 3000 | Beyond ~5 hashtags reach drops. | + +`preflight` measures `post.rendered_text()` — body **plus** hashtags — because +that is what the platform receives. + +## Media count + +| Platform | Images | Video | +|---|---|---| +| Telegram | 2–10 per album | 1, ≤50 MB via the public Bot API | +| Threads | 2–20 carousel | 1 | +| Instagram | 2–10 carousel | 1, publishes as a Reel | +| TikTok | up to 35 photos | 1 | +| X | up to 4 | 1, and never mixed with images | +| YouTube | — | exactly 1 | +| LinkedIn | multi-image | 1, and never mixed with images | + +## File size + +| Platform | Image | Video | +|---|---|---| +| Telegram | 10 MB | 50 MB (a self-hosted Bot API server lifts this) | +| Threads | 8 MB | 1 GB | +| Instagram | 8 MB | 1 GB | +| TikTok | 20 MB | 4 GB, chunked at 5–64 MB | +| X | 5 MB | 512 MB | +| YouTube | — | 256 GB / 12 h | +| LinkedIn | 10 MB | 500 MB | + +## Posting caps + +| Platform | Cap | Checkable? | +|---|---|---| +| Instagram | 25 posts / 24 h | **Yes** — queried before publishing, and again in preflight once authorised | +| Threads | 250 posts / 24 h | No | +| X | ~17 / 24 h, 500 / month on the free tier | Only from the response headers, after the fact | +| YouTube | 1600 quota units per upload of 10,000/day ≈ 6 | No endpoint exists | +| TikTok | per-account, undocumented | No | +| Telegram | ~20 messages/min to one chat | No | +| LinkedIn | per-member throttle, undocumented | No | + +## Media transport + +This is the constraint that decides whether a platform needs an S3 bucket. + +| Platform | How media arrives | +|---|---| +| Telegram | multipart upload — bytes from disk | +| X | chunked upload — bytes from disk | +| YouTube | resumable upload — bytes from disk | +| LinkedIn | initialise, then PUT bytes | +| TikTok video | chunked `FILE_UPLOAD` — bytes from disk | +| **TikTok photos** | `PULL_FROM_URL` only — **needs S3 and a domain verified in the TikTok portal** | +| **Instagram** | platform fetches a URL — **needs S3** | +| **Threads with media** | platform fetches a URL — **needs S3** | + +Staged files go up as private objects and are handed over as presigned links +with a one-hour life. The bucket is never made public. + +--- + +## Overridable versions + +Vendors deprecate on a schedule this repo cannot track, so the moving parts are +environment variables rather than constants: + +| Variable | Default | For | +|---|---|---| +| `INSTAGRAM_API_VERSION` | `v21.0` | Graph version in the URL path | +| `THREADS_API_VERSION` | `v1.0` | same | +| `LINKEDIN_API_VERSION` | `202401` | the `LinkedIn-Version` header; rejected once ~a year old | +| `X_UPLOAD_URL` | `https://api.x.com/2/media/upload` | media upload host, which has moved before | +| `TIKTOK_PRIVACY_LEVEL` | `PUBLIC_TO_EVERYONE` | falls back to whatever the account actually allows | +| `YOUTUBE_PRIVACY` | `public` | `--draft` forces `private` regardless | +| `YOUTUBE_CATEGORY_ID` | `22` | People & Blogs | +| `LINKEDIN_VISIBILITY` | `PUBLIC` | | +| `LINKEDIN_AUTHOR_URN` | from the token | set only to post as something other than yourself | +| `INSTAGRAM_USER_ID` / `THREADS_USER_ID` | from the token | override the stored account id | + +## Verifying + +Before relying on any of this in anger, and after any vendor deprecation notice: + +1. Open each platform's content-publishing reference and compare endpoints, + parameter names, and the limits above. +2. Fix the constants in `common/runners/publishers/.py` and this table + together — they must not drift apart. +3. Update the "last verified" line at the top with the date. +4. Run `make test-unit`; the preflight tests assert against these numbers, so a + changed limit shows up as a failing test rather than a surprise in production. diff --git a/post-publisher/references/troubleshoot.md b/post-publisher/references/troubleshoot.md new file mode 100644 index 0000000..e444d8d --- /dev/null +++ b/post-publisher/references/troubleshoot.md @@ -0,0 +1,107 @@ +# Troubleshooting + +Platform errors, translated. Ordered by how often they actually happen. + +## Nothing is sent, and no error appears + +You are in dry-run, which is the default. Add `--yes`. The summary line says so: + +``` +Dry run complete — 1 of 1 platform(s) would proceed (0 skipped, 0 blocked). Add --yes to publish. +``` + +## `— platform: skipped, no draft support` + +You passed `--draft` to a platform that has no drafts (Telegram, X, LinkedIn). +Skipping is deliberate: publishing live when a draft was asked for would be the +worst possible reading of the flag. Drop `--draft`, or target only platforms +that support it. + +## `— platform: skipped, identical content already published` + +`posted.json` in the source directory records this exact content going to this +exact platform. Change the caption and it counts as new; pass `--force` to post +it again anyway. + +## `not authorised. Run: cli.auth --platform X` + +No usable token. Either it was never obtained, or it expired past the point of +refresh. `--status` shows which. + +## Telegram + +| Message | Meaning | +|---|---| +| `not enough rights` | The bot is in the channel but not an administrator, or lacks the post-messages permission. | +| `chat not found` | Wrong `TELEGRAM_CHAT_ID`. Public channels use `@name`; private ones the numeric `-100…` id. | +| `Request Entity Too Large` | Over 50 MB. The public Bot API cannot take it; a self-hosted Bot API server can. | +| `message caption is too long` | Over 1024 chars with media attached. Preflight normally catches this first. | + +## Instagram / Threads + +| Message | Meaning | +|---|---| +| `The user is not an Instagram Business` | Personal account. Convert to Business/Creator, or use the browser fallback. | +| `Media upload has failed` / container `ERROR` | Meta could not fetch the URL. Presigned link expired, bucket unreachable, or the file is not really the type its extension claims. | +| container stuck `IN_PROGRESS` | Normal for video; Reels take 30–60s. Failing after 300s means Meta is struggling with the file — re-encode it. | +| `Application request limit reached` | Rate limited. For Instagram, `--check` reports the 24h posting allowance. | +| `Invalid OAuth access token` | Expired past refresh (60 days unused), or issued for a different account. Re-run `cli.auth`. | +| `media_type REELS is required` | A plain video container was attempted. This is handled automatically, so encountering it means the API changed — check `platform-limits.md`. | + +## TikTok + +| Symptom | Meaning | +|---|---| +| Post succeeds, nobody can see it | The app is not audited. Every direct post is forced to SELF_ONLY. Use `--draft`, or apply for audit. This is the one failure the API reports as success. | +| `spam_risk_too_many_posts` | Per-account posting cap. Wait. | +| `url_ownership_unverified` | Photo posts pull from a URL whose domain must be verified in the developer portal. | +| `privacy_level_option_mismatch` | The account does not offer the requested privacy level — a private account has no public option. Normally resolved from `creator_info`; override with `TIKTOK_PRIVACY_LEVEL`. | +| `access_token_invalid` | TikTok access tokens live 24h. Refresh is automatic; if it fails, the year-long refresh token also expired. | + +## X + +| Message | Meaning | +|---|---| +| `PARTIAL: 3/6 posts published` | A thread failed part way. The earlier posts are live. Delete them by hand, or continue the thread manually — re-running would duplicate the root. | +| 429 | Free tier is ~17 posts/24h and 500/month across the whole app. | +| `Unsupported Authentication` | The endpoint wants OAuth 1.0a rather than the OAuth 2.0 user token. Check `X_UPLOAD_URL` against current docs. | +| `duplicate content` | X refuses identical text posted twice in quick succession. | + +## YouTube + +| Message | Meaning | +|---|---| +| `quotaExceeded` | 1600 units per upload, 10,000/day default ≈ 6 uploads, private ones included. Resets at midnight Pacific. Request more in the Cloud console. | +| `no refresh_token` | Google issues one only on first consent. Revoke at myaccount.google.com/permissions and authorise again. | +| `youtubeSignupRequired` | The Google account has no YouTube channel. | +| `uploadLimitExceeded` | Per-account daily upload cap, separate from API quota. | +| upload ends with no video id | The resumable session died. Re-run; nothing was published. | + +## LinkedIn + +| Message | Meaning | +|---|---| +| 403 on `/rest/posts` | Missing `w_member_social`, or posting as an organisation without Community Management approval. | +| `Invalid version` | `LINKEDIN_API_VERSION` has aged out — LinkedIn rejects versions older than about a year. Set it to the current `YYYYMM`. | +| `no member id stored` | The token was obtained without `openid`/`profile`, so the member id was never fetched. Re-run `cli.auth`. | +| Refresh fails | Refresh tokens are only granted to approved apps. Re-run the flow for a fresh 60-day token. | + +## S3 staging + +| Message | Meaning | +|---|---| +| `fetches media by URL and cannot accept raw bytes` | Instagram/Threads with media and no bucket configured. Set `S3_BUCKET` / `S3_ACCESS_KEY` / `S3_SECRET_KEY`. | +| `boto3 is not installed` | `pip install -r common/runners/requirements.txt`, or re-run `install.sh`. | +| Meta cannot fetch the staged file | Check `S3_ENDPOINT` is reachable from the public internet. A MinIO instance on `127.0.0.1` is visible to you and not to Meta. | + +## Diagnostics + +```bash +python3 -m common.runners.cli.publish --list-platforms # configured / authorised / neither +python3 -m common.runners.cli.publish --check --platform instagram +python3 -m common.runners.cli.auth --status # tokens and expiry, masked +python3 -m common.runners.cli.auth --platform x --verify # ask the platform, not the file +``` + +When reporting a problem, include the `--list-platforms` output. It never +contains a token. diff --git a/post-publisher/scripts/run.py b/post-publisher/scripts/run.py new file mode 100755 index 0000000..22ed5e5 --- /dev/null +++ b/post-publisher/scripts/run.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""post-publisher execute entry — delegates to common.runners.cli.publish. + +Auto-discovery: +1. Walks up to find the dir containing `common/runners/`. +2. If a sibling `.runners-venv/` exists (created by install.sh), re-execs via + that venv's python so vendor SDK deps are available without a manual pip + install. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +HERE = Path(__file__).resolve() +ROOT: Path | None = None +for parent in HERE.parents: + if (parent / "common" / "runners").is_dir(): + ROOT = parent + break + +if ROOT is None: + sys.stderr.write("ERROR: cannot locate common/runners. Did you run install.sh?\n") + sys.exit(1) + +_VENV_PY = ROOT / ".runners-venv" / "bin" / "python3" +if _VENV_PY.exists() and sys.executable != str(_VENV_PY): + os.execv(str(_VENV_PY), [str(_VENV_PY), str(HERE), *sys.argv[1:]]) + +sys.path.insert(0, str(ROOT)) +from common.runners.cli import publish # noqa: E402 + +if __name__ == "__main__": + sys.exit(publish.main()) diff --git a/reel-builder/SKILL.md b/reel-builder/SKILL.md index cd4834a..b30a4ad 100644 --- a/reel-builder/SKILL.md +++ b/reel-builder/SKILL.md @@ -28,7 +28,7 @@ This skill does NOT: - Add a voiceover / TTS narrator (out of scope for v1 — would need ducking/mixing logic; planned v2.4.0) - Composite multi-track edits beyond hard cuts (no dissolves, no transitions other than concat) - Beat-sync shots to music BPM (would need BPM detection; planned later) -- Post to platforms — output is an MP4 you upload via the platform's UI +- Post to platforms — that is `post-publisher`, which takes this skill's output directory (the `final.mp4`) as its input - Run editing software (Premiere / DaVinci / CapCut) — uses ffmpeg only diff --git a/scripts/validate.sh b/scripts/validate.sh index 8e2dc20..4c26259 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -63,8 +63,10 @@ for skill in $SKILLS; do pass "frontmatter OK (name, description, license, allowed-tools)" fi - # 2) Cross-link check: every references/.md mentioned in SKILL.md must exist - links="$(grep -oE 'references/[a-z0-9_-]+\.md' "$skill/SKILL.md" | sort -u || true)" + # 2) Cross-link check: every references/.md mentioned in SKILL.md must exist. + # Matches intra-skill links (references/x.md) and cross-skill ones + # (../other-skill/references/x.md); both resolve against the skill dir. + links="$(grep -oE '(\.\./[a-z0-9_-]+/)?references/[a-z0-9_-]+\.md' "$skill/SKILL.md" | sort -u || true)" link_err=0 for link in $links; do if [ -f "$skill/$link" ]; then diff --git a/skills.json b/skills.json index 9a1c5c2..45eecec 100644 --- a/skills.json +++ b/skills.json @@ -694,6 +694,27 @@ "orchestration" ], "deps": [] + }, + { + "name": "post-publisher", + "dir": "post-publisher", + "layer": "orchestrator", + "description": "Publish finished assets to Instagram / Threads / TikTok / X / YouTube / Telegram / LinkedIn through the official APIs. Takes a carousel-builder or reel-builder output directory, reads captions.md, runs a per-platform preflight (char limits, media count, file size, posting caps), previews, confirms per platform, then publishes and writes a posted.json receipt so the same content cannot go out twice. Dry-run by default; --yes leaves it; --draft where the platform has drafts. OAuth tokens live in ~/.skills-tokens.json with automatic refresh.", + "languages": [ + "en", + "ru" + ], + "tags": [ + "social", + "marketing", + "orchestration", + "ops" + ], + "deps": [ + "carousel-builder", + "reel-builder", + "viral-text" + ] } ] } diff --git a/tests/fixtures/skill_post-publisher_input.md b/tests/fixtures/skill_post-publisher_input.md new file mode 100644 index 0000000..065ab16 --- /dev/null +++ b/tests/fixtures/skill_post-publisher_input.md @@ -0,0 +1,23 @@ +# Captions + +## Main post + +В современном мире социальных сетей публикация контента — это не просто нажатие кнопки. Это целая система, и сегодня мы хотим поделиться с вами нашим опытом. + +Мы потратили три недели на то, чтобы разобраться, почему наши посты не выходят вовремя. И знаете что? Оказалось, что проблема была вовсе не в контенте. + +Вот ключевые выводы, которые мы сделали: + +1. Каждая платформа имеет свои уникальные требования — это критически важно понимать. +2. Автоматизация без проверки — это путь к катастрофе. +3. Черновики спасают гораздо чаще, чем кажется на первый взгляд. + +Важно отметить, что универсального решения не существует. Однако есть подходы, которые работают лучше других. + +В итоге, публикация — это не только про технологии. Это про внимание к деталям и уважение к своей аудитории. А как вы решаете эту задачу в своей команде? + +## Per-slide alts + +1. Обложка: заголовок на тёмном фоне +2. График срывов публикаций по неделям +3. Схема: генерация → проверка → публикация diff --git a/tests/snapshots/skill_post-publisher_input.json b/tests/snapshots/skill_post-publisher_input.json new file mode 100644 index 0000000..0d49f87 --- /dev/null +++ b/tests/snapshots/skill_post-publisher_input.json @@ -0,0 +1,98 @@ +{ + "verdict": "borderline", + "gate": "fail", + "total": 11, + "hard_bans": 7, + "by_category": { + "RHYTHM_MONOTONE": 1, + "FILLER_INTRO": 1, + "EM_DASH_RU": 4, + "NEG_PARALLEL": 1, + "GPT_FILLER": 2, + "MATH_SIGN_PROSE": 2 + }, + "by_severity": { + "blocker": 7, + "caution": 4, + "nit": 0 + }, + "hits": [ + { + "line": 0, + "col": 0, + "category": "RHYTHM_MONOTONE", + "severity": "caution", + "match": "adjacent sentence lengths differ by 3.8 words on average (live prose: 6+)" + }, + { + "line": 5, + "col": 1, + "category": "FILLER_INTRO", + "severity": "caution", + "match": "В современном мире" + }, + { + "line": 5, + "col": 57, + "category": "EM_DASH_RU", + "severity": "blocker", + "match": "—" + }, + { + "line": 5, + "col": 59, + "category": "NEG_PARALLEL", + "severity": "blocker", + "match": "это не просто" + }, + { + "line": 11, + "col": 54, + "category": "EM_DASH_RU", + "severity": "blocker", + "match": "—" + }, + { + "line": 11, + "col": 71, + "category": "GPT_FILLER", + "severity": "caution", + "match": "важно понимать" + }, + { + "line": 12, + "col": 31, + "category": "EM_DASH_RU", + "severity": "blocker", + "match": "—" + }, + { + "line": 15, + "col": 1, + "category": "GPT_FILLER", + "severity": "caution", + "match": "Важно отметить" + }, + { + "line": 17, + "col": 21, + "category": "EM_DASH_RU", + "severity": "blocker", + "match": "—" + }, + { + "line": 23, + "col": 21, + "category": "MATH_SIGN_PROSE", + "severity": "blocker", + "match": "→" + }, + { + "line": 23, + "col": 32, + "category": "MATH_SIGN_PROSE", + "severity": "blocker", + "match": "→" + } + ] +} diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py new file mode 100644 index 0000000..8f30aa9 --- /dev/null +++ b/tests/unit/test_publish.py @@ -0,0 +1,748 @@ +"""Unit tests for the publishing layer — Post, preflight, receipts, CLI parsing. + +Publishing is the one irreversible thing this repo does, and preflight is the +gate in front of it. Every rule here exists so that a bad post is caught while +it is still a local dataclass rather than after it is visible to an audience. + +Nothing in this file touches the network. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from common.runners import receipts # noqa: E402 +from common.runners.cli import publish as publish_cli # noqa: E402 +from common.runners.publishers.base import Post, Publisher, PublishResult # noqa: E402 +from common.runners.publishers.instagram import InstagramPublisher # noqa: E402 +from common.runners.publishers.linkedin import LinkedInPublisher # noqa: E402 +from common.runners.publishers.telegram import TelegramPublisher # noqa: E402 +from common.runners.publishers.threads import ThreadsPublisher # noqa: E402 +from common.runners.publishers.tiktok import TikTokPublisher # noqa: E402 +from common.runners.publishers.x import XPublisher # noqa: E402 +from common.runners.publishers.youtube import YouTubePublisher # noqa: E402 + + +class DummyPublisher(Publisher): + """Minimal publisher for exercising the generic rules in Publisher.preflight.""" + + name = "dummy" + supports = frozenset({"text", "image", "carousel"}) + supports_draft = True + max_text_chars = 100 + max_hashtags = 3 + min_media = 0 + max_media = 2 + max_image_mb = 1.0 + + def publish(self, post, *, draft=False): + return PublishResult(platform=self.name, post_id="1", state="draft" if draft else "published") + + +class MediaCase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def make(self, name, size=1024): + p = self.dir / name + p.write_bytes(b"x" * size) + return p + + def blocks(self, violations): + return [v for v in violations if v.severity == "block"] + + def fields(self, violations, severity=None): + return [v.field for v in violations if severity is None or v.severity == severity] + + +class TestPostNormalisation(unittest.TestCase): + def test_single_path_is_wrapped(self): + self.assertEqual(Post(kind="image", media=Path("/a/b.png")).media, (Path("/a/b.png"),)) + + def test_string_media_becomes_path(self): + self.assertEqual(Post(kind="image", media="/a/b.png").media, (Path("/a/b.png"),)) + + def test_list_of_strings_becomes_paths(self): + post = Post(kind="carousel", media=["/a/1.png", "/a/2.png"]) + self.assertEqual(post.media, (Path("/a/1.png"), Path("/a/2.png"))) + + def test_hashes_are_stripped_and_blanks_dropped(self): + post = Post(kind="text", hashtags=["#smm", " ", "автопостинг"]) + self.assertEqual(post.hashtags, ("smm", "автопостинг")) + + def test_rendered_text_appends_hashtags(self): + post = Post(kind="text", text="Тело поста.", hashtags=["a", "b"]) + self.assertEqual(post.rendered_text(), "Тело поста.\n\n#a #b") + + def test_rendered_text_without_hashtags_is_untouched(self): + self.assertEqual(Post(kind="text", text="Просто текст.").rendered_text(), "Просто текст.") + + def test_rendered_text_hashtags_only(self): + self.assertEqual(Post(kind="text", hashtags=["a"]).rendered_text(), "#a") + + def test_alt_for_is_safe_past_the_end(self): + post = Post(kind="carousel", alt_texts=["one"]) + self.assertEqual(post.alt_for(0), "one") + self.assertEqual(post.alt_for(5), "") + + +class TestContentHash(MediaCase): + def test_identical_posts_hash_alike(self): + a = Post(kind="text", text="hello", hashtags=["x"]) + b = Post(kind="text", text="hello", hashtags=["x"]) + self.assertEqual(a.content_hash(), b.content_hash()) + + def test_edited_caption_is_a_different_post(self): + # The whole point of the receipt: fixing a typo and re-running must be + # allowed through, while re-running the identical command must not. + a = Post(kind="text", text="hello") + b = Post(kind="text", text="hello!") + self.assertNotEqual(a.content_hash(), b.content_hash()) + + def test_whitespace_only_edits_do_not_count(self): + a = Post(kind="text", text="hello") + b = Post(kind="text", text=" hello ") + self.assertEqual(a.content_hash(), b.content_hash()) + + def test_different_hashtags_change_the_hash(self): + a = Post(kind="text", text="x", hashtags=["a"]) + b = Post(kind="text", text="x", hashtags=["b"]) + self.assertNotEqual(a.content_hash(), b.content_hash()) + + def test_different_media_size_changes_the_hash(self): + small = self.make("s.png", 10) + post_a = Post(kind="image", media=[small]) + hash_a = post_a.content_hash() + small.write_bytes(b"y" * 999) + self.assertNotEqual(Post(kind="image", media=[small]).content_hash(), hash_a) + + def test_missing_media_still_hashes(self): + # Must not raise — preflight reports the missing file, not content_hash. + Post(kind="image", media=[self.dir / "nope.png"]).content_hash() + + +class TestGenericPreflight(MediaCase): + def setUp(self): + super().setUp() + self.pub = DummyPublisher() + + def test_unsupported_kind_blocks(self): + v = self.pub.preflight(Post(kind="video", media=[])) + self.assertIn("kind", self.fields(self.blocks(v))) + + def test_text_over_limit_blocks(self): + v = self.pub.preflight(Post(kind="text", text="я" * 101)) + self.assertIn("text", self.fields(self.blocks(v))) + + def test_hashtags_count_against_the_limit(self): + # 90 chars of body plus "#a #b" tips it over 100. + v = self.pub.preflight(Post(kind="text", text="я" * 96, hashtags=["a", "b"])) + self.assertIn("text", self.fields(self.blocks(v))) + + def test_too_many_hashtags_warns_but_does_not_block(self): + v = self.pub.preflight(Post(kind="text", text="ok", hashtags=["a", "b", "c", "d"])) + self.assertEqual(self.blocks(v), []) + self.assertIn("hashtags", self.fields(v, "warn")) + + def test_too_many_media_blocks(self): + files = [self.make(f"{i}.png") for i in range(3)] + v = self.pub.preflight(Post(kind="carousel", media=files)) + self.assertIn("media", self.fields(self.blocks(v))) + + def test_missing_file_blocks(self): + v = self.pub.preflight(Post(kind="image", media=[self.dir / "ghost.png"])) + self.assertTrue(any("not found" in x.message for x in self.blocks(v))) + + def test_empty_file_blocks(self): + v = self.pub.preflight(Post(kind="image", media=[self.make("empty.png", 0)])) + self.assertTrue(any("empty" in x.message for x in self.blocks(v))) + + def test_oversized_image_blocks(self): + big = self.make("big.png", 2 * 1024 * 1024) + v = self.pub.preflight(Post(kind="image", media=[big])) + self.assertTrue(any("caps images" in x.message for x in self.blocks(v))) + + def test_unknown_extension_blocks(self): + v = self.pub.preflight(Post(kind="image", media=[self.make("thing.psd")])) + self.assertTrue(any("unrecognised" in x.message for x in self.blocks(v))) + + def test_missing_alt_text_warns_only(self): + v = self.pub.preflight(Post(kind="image", media=[self.make("a.png")])) + self.assertEqual(self.blocks(v), []) + self.assertIn("alt_texts", self.fields(v, "warn")) + + def test_partial_alt_text_names_the_gap(self): + # --alt is repeated per file, so describing the first slides and + # forgetting the rest is likelier than omitting alt text entirely. + files = [self.make(f"{i}.png") for i in range(2)] + v = self.pub.preflight(Post(kind="carousel", media=files, text="ok", alt_texts=["only the first"])) + warns = [x for x in v if x.field == "alt_texts"] + self.assertEqual(len(warns), 1) + self.assertIn("1 alt texts for 2 files", warns[0].message) + self.assertIn("file 2", warns[0].message) + + def test_full_alt_text_does_not_warn(self): + files = [self.make(f"{i}.png") for i in range(2)] + v = self.pub.preflight(Post(kind="carousel", media=files, text="ok", alt_texts=["a", "b"])) + self.assertEqual([x for x in v if x.field == "alt_texts"], []) + + def test_clean_post_has_no_findings(self): + post = Post(kind="image", media=[self.make("a.png")], text="ok", alt_texts=["a chart"]) + self.assertEqual(self.pub.preflight(post), []) + + +class TestTelegramPreflight(MediaCase): + def setUp(self): + super().setUp() + self.pub = TelegramPublisher() + + def test_long_text_is_fine_without_media(self): + v = self.pub.preflight(Post(kind="text", text="я" * 2000)) + self.assertEqual(self.blocks(v), []) + + def test_same_text_blocks_once_media_is_attached(self): + # 2000 chars is a legal message but an illegal caption. The rule that + # catches this cannot live in max_text_chars — it depends on the kind. + post = Post(kind="image", media=[self.make("a.png")], text="я" * 2000, alt_texts=["x"]) + blocks = self.blocks(self.pub.preflight(post)) + self.assertTrue(any("1024" in x.message for x in blocks)) + + def test_text_over_the_message_limit_blocks(self): + v = self.pub.preflight(Post(kind="text", text="я" * 5000)) + self.assertIn("text", self.fields(self.blocks(v))) + + def test_empty_text_post_blocks(self): + v = self.pub.preflight(Post(kind="text", text=" ")) + self.assertIn("text", self.fields(self.blocks(v))) + + def test_album_of_one_blocks(self): + post = Post(kind="carousel", media=[self.make("a.png")], alt_texts=["x"]) + self.assertTrue(any("at least 2" in x.message for x in self.blocks(self.pub.preflight(post)))) + + def test_mixed_album_warns(self): + post = Post( + kind="carousel", + media=[self.make("a.png"), self.make("b.mp4")], + alt_texts=["x", "y"], + ) + v = self.pub.preflight(post) + self.assertEqual(self.blocks(v), []) + self.assertIn("media", self.fields(v, "warn")) + + def test_eleven_slides_exceed_the_album_cap(self): + files = [self.make(f"s{i}.png") for i in range(11)] + post = Post(kind="carousel", media=files, alt_texts=["x"] * 11) + self.assertTrue(any("at most 10" in x.message for x in self.blocks(self.pub.preflight(post)))) + + def test_draft_is_refused_rather_than_silently_published(self): + with self.assertRaises(Exception) as ctx: + self.pub.publish(Post(kind="text", text="x"), draft=True) + self.assertIn("draft", str(ctx.exception).lower()) + + +class TestMetaPreflight(MediaCase): + """Threads and Instagram. Nothing here reaches the network: the quota probe + is gated on available() and token_ready(), both false without credentials.""" + + def setUp(self): + super().setUp() + for var in ( + "S3_BUCKET", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "THREADS_APP_ID", + "THREADS_APP_SECRET", + "INSTAGRAM_APP_ID", + "INSTAGRAM_APP_SECRET", + ): + self._unset(var) + self.threads = ThreadsPublisher() + self.instagram = InstagramPublisher() + + def _unset(self, var): + import os + + saved = os.environ.pop(var, None) + if saved is not None: + self.addCleanup(os.environ.__setitem__, var, saved) + + def _with_s3(self): + import os + + for k, v in (("S3_BUCKET", "b"), ("S3_ACCESS_KEY", "k"), ("S3_SECRET_KEY", "s")): + os.environ[k] = v + self.addCleanup(os.environ.pop, k, None) + + # ── the media-URL requirement ─────────────────────────────────────────── + + def test_media_without_s3_blocks(self): + post = Post(kind="image", media=[self.make("a.png")], alt_texts=["x"]) + blocks = self.blocks(self.threads.preflight(post)) + self.assertTrue(any("fetches media by URL" in x.message for x in blocks)) + + def test_media_with_s3_is_allowed(self): + self._with_s3() + post = Post(kind="image", media=[self.make("a.png")], text="ok", alt_texts=["x"]) + self.assertEqual(self.blocks(self.threads.preflight(post)), []) + + def test_text_only_threads_post_needs_no_s3(self): + # The reason Threads is the first platform worth connecting. + self.assertEqual(self.blocks(self.threads.preflight(Post(kind="text", text="Привет."))), []) + + # ── Threads ───────────────────────────────────────────────────────────── + + def test_threads_500_char_limit(self): + v = self.threads.preflight(Post(kind="text", text="я" * 501)) + self.assertIn("text", self.fields(self.blocks(v))) + + def test_threads_counts_hashtags_towards_the_limit(self): + v = self.threads.preflight(Post(kind="text", text="я" * 495, hashtags=["smm"])) + self.assertIn("text", self.fields(self.blocks(v))) + + def test_threads_carousel_cap(self): + self._with_s3() + files = [self.make(f"s{i}.png") for i in range(21)] + post = Post(kind="carousel", media=files, alt_texts=["x"] * 21) + self.assertTrue(any("at most" in x.message for x in self.blocks(self.threads.preflight(post)))) + + def test_threads_empty_text_post_blocks(self): + self.assertIn("text", self.fields(self.blocks(self.threads.preflight(Post(kind="text"))))) + + # ── Instagram ─────────────────────────────────────────────────────────── + + def test_instagram_refuses_text_only_posts(self): + v = self.instagram.preflight(Post(kind="text", text="Просто текст.")) + self.assertIn("kind", self.fields(self.blocks(v))) + + def test_instagram_caption_limit(self): + self._with_s3() + post = Post(kind="image", media=[self.make("a.png")], text="я" * 2201, alt_texts=["x"]) + self.assertIn("text", self.fields(self.blocks(self.instagram.preflight(post)))) + + def test_instagram_carousel_cap_is_ten(self): + self._with_s3() + files = [self.make(f"s{i}.png") for i in range(11)] + post = Post(kind="carousel", media=files, alt_texts=["x"] * 11) + self.assertTrue(any("at most" in x.message for x in self.blocks(self.instagram.preflight(post)))) + + def test_instagram_ten_slides_are_fine(self): + self._with_s3() + files = [self.make(f"s{i}.png") for i in range(10)] + post = Post(kind="carousel", media=files, text="ok", alt_texts=["x"] * 10) + self.assertEqual(self.blocks(self.instagram.preflight(post)), []) + + def test_instagram_video_warns_that_it_becomes_a_reel(self): + self._with_s3() + post = Post(kind="video", media=[self.make("final.mp4")], text="ok", alt_texts=["x"]) + v = self.instagram.preflight(post) + self.assertEqual(self.blocks(v), []) + self.assertTrue(any("Reel" in x.message for x in v)) + + def test_instagram_needs_at_least_one_file(self): + v = self.instagram.preflight(Post(kind="image", media=[])) + self.assertIn("media", self.fields(self.blocks(v))) + + def test_thirty_one_hashtags_warn(self): + self._with_s3() + post = Post( + kind="image", + media=[self.make("a.png")], + text="ok", + alt_texts=["x"], + hashtags=[f"t{i}" for i in range(31)], + ) + v = self.instagram.preflight(post) + self.assertEqual(self.blocks(v), []) + self.assertIn("hashtags", self.fields(v, "warn")) + + # ── media params ──────────────────────────────────────────────────────── + + def test_instagram_video_container_is_reels(self): + params = self.instagram._media_params(Path("a.mp4"), "https://x/a.mp4") + self.assertEqual(params["media_type"], "REELS") + + def test_instagram_image_container_has_no_media_type(self): + params = self.instagram._media_params(Path("a.png"), "https://x/a.png") + self.assertEqual(params, {"image_url": "https://x/a.png"}) + + def test_threads_distinguishes_image_from_video(self): + self.assertEqual(self.threads._media_params(Path("a.png"), "u")["media_type"], "IMAGE") + self.assertEqual(self.threads._media_params(Path("a.mp4"), "u")["media_type"], "VIDEO") + + +class TestTikTokPreflight(MediaCase): + def setUp(self): + super().setUp() + import os + + for var in ("S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY"): + saved = os.environ.pop(var, None) + if saved is not None: + self.addCleanup(os.environ.__setitem__, var, saved) + self.pub = TikTokPublisher() + + def video(self, mb=1): + return Post( + kind="video", media=[self.make("final.mp4", mb * 1024 * 1024)], text="ok", alt_texts=["x"] + ) + + def test_direct_post_warns_about_the_audit(self): + v = self.pub.preflight(self.video(), draft=False) + self.assertTrue(any(x.field == "audit" for x in v)) + + def test_draft_does_not_warn_about_the_audit(self): + # The warning is about direct publishing; repeating it on the safe path + # would train people to ignore it. + v = self.pub.preflight(self.video(), draft=True) + self.assertFalse(any(x.field == "audit" for x in v)) + + def test_video_needs_no_s3(self): + self.assertEqual(self.blocks(self.pub.preflight(self.video(), draft=True)), []) + + def test_photo_post_without_s3_blocks(self): + post = Post(kind="carousel", media=[self.make("a.png"), self.make("b.png")], alt_texts=["x", "y"]) + self.assertTrue(any("PULL_FROM_URL" in x.message for x in self.blocks(self.pub.preflight(post)))) + + def test_image_in_a_video_post_blocks(self): + post = Post(kind="video", media=[self.make("a.png")], alt_texts=["x"]) + self.assertTrue(any("image in a video post" in x.message for x in self.blocks(self.pub.preflight(post)))) + + def test_draft_is_supported(self): + self.assertTrue(self.pub.supports_draft) + + def test_title_limit(self): + post = Post(kind="video", media=[self.make("final.mp4")], text="я" * 2201, alt_texts=["x"]) + self.assertIn("text", self.fields(self.blocks(self.pub.preflight(post)))) + + # ── chunking ──────────────────────────────────────────────────────────── + + def test_a_single_chunk_always_declares_the_real_file_size(self): + # TikTok rejects a chunk_size larger than video_size. Declaring the + # nominal 20 MB for a 6 MB file is a rejection, not a rounding detail. + from common.runners.publishers.tiktok import MAX_CHUNK, MB + + for size in (1024, 5 * MB, 6 * MB, 25 * MB, MAX_CHUNK): + chunk, count = self.pub._chunk_plan(size) + self.assertEqual(count, 1, f"{size} should be one chunk") + self.assertEqual(chunk, size, f"{size}: chunk must equal the file size when count is 1") + + def test_large_file_splits_within_the_allowed_range(self): + from common.runners.publishers.tiktok import MAX_CHUNK, MIN_CHUNK + + chunk, count = self.pub._chunk_plan(200 * 1024 * 1024) + self.assertGreaterEqual(chunk, MIN_CHUNK) + self.assertLessEqual(chunk, MAX_CHUNK) + self.assertGreater(count, 1) + + def test_chunk_plan_never_over_declares_for_any_size(self): + from common.runners.publishers.tiktok import MAX_CHUNK, MIN_CHUNK, MB + + for mb in (1, 5, 6, 20, 25, 64, 65, 100, 200, 201, 4096): + size = mb * MB + chunk, count = self.pub._chunk_plan(size) + # Declared plan must fit inside the file — the last chunk absorbs + # the remainder rather than becoming an undersized one. + self.assertLessEqual(chunk * count, size, f"{mb}MB over-declares") + self.assertLessEqual(chunk, size, f"{mb}MB: chunk exceeds the file") + if count > 1: + self.assertGreaterEqual(chunk, MIN_CHUNK, f"{mb}MB: chunk under the 5MB floor") + self.assertLessEqual(chunk, MAX_CHUNK, f"{mb}MB: chunk over the 64MB ceiling") + + +class TestXPreflight(MediaCase): + def setUp(self): + super().setUp() + self.pub = XPublisher() + + def test_280_char_limit(self): + self.assertIn("text", self.fields(self.blocks(self.pub.preflight(Post(kind="text", text="я" * 281))))) + + def test_thread_parts_are_measured_individually(self): + # A thread whose root fits but whose third post does not must be caught + # here — X publishes the root before it ever sees post three. + post = Post(kind="text", text="короткий корень", thread=["ок", "я" * 300]) + blocks = self.blocks(self.pub.preflight(post)) + self.assertTrue(any(x.field == "thread" and "post 3" in x.message for x in blocks)) + + def test_empty_thread_part_blocks(self): + post = Post(kind="text", text="корень", thread=[" "]) + self.assertIn("thread", self.fields(self.blocks(self.pub.preflight(post)))) + + def test_valid_thread_passes(self): + post = Post(kind="text", text="корень", thread=["второй", "третий"]) + self.assertEqual(self.blocks(self.pub.preflight(post)), []) + + def test_four_images_are_allowed(self): + files = [self.make(f"{i}.png") for i in range(4)] + post = Post(kind="carousel", media=files, text="ok", alt_texts=["x"] * 4) + self.assertEqual(self.blocks(self.pub.preflight(post)), []) + + def test_five_images_block(self): + files = [self.make(f"{i}.png") for i in range(5)] + post = Post(kind="carousel", media=files, text="ok", alt_texts=["x"] * 5) + self.assertIn("media", self.fields(self.blocks(self.pub.preflight(post)))) + + def test_video_mixed_with_images_blocks(self): + post = Post( + kind="carousel", media=[self.make("a.mp4"), self.make("b.png")], text="ok", alt_texts=["x", "y"] + ) + self.assertTrue(any("one video or up to four" in x.message for x in self.blocks(self.pub.preflight(post)))) + + def test_no_draft_support(self): + self.assertFalse(self.pub.supports_draft) + self.assertIn("draft", self.fields(self.blocks(self.pub.preflight(Post(kind="text", text="x"), draft=True)))) + + +class TestYouTubePreflight(MediaCase): + def setUp(self): + super().setUp() + self.pub = YouTubePublisher() + + def test_only_video_is_supported(self): + post = Post(kind="image", media=[self.make("a.png")], alt_texts=["x"]) + self.assertIn("kind", self.fields(self.blocks(self.pub.preflight(post)))) + + def test_still_image_in_a_video_post_blocks(self): + post = Post(kind="video", media=[self.make("a.png")], title="T", alt_texts=["x"]) + self.assertTrue(any("takes video" in x.message for x in self.blocks(self.pub.preflight(post)))) + + def test_title_over_100_chars_blocks(self): + post = Post(kind="video", media=[self.make("a.mp4")], title="я" * 101, alt_texts=["x"]) + self.assertIn("title", self.fields(self.blocks(self.pub.preflight(post)))) + + def test_missing_title_warns(self): + post = Post(kind="video", media=[self.make("a.mp4")], text="описание", alt_texts=["x"]) + v = self.pub.preflight(post) + self.assertEqual(self.blocks(v), []) + self.assertIn("title", self.fields(v, "warn")) + + def test_explicit_title_survives_an_empty_caption(self): + # The inline version of this bound as (title or first_line) if text + # else "Untitled", so an explicit --title was discarded whenever the + # caption happened to be empty. + self.assertEqual( + self.pub._derive_title(Post(kind="video", title="Мой заголовок", text="")), "Мой заголовок" + ) + + def test_title_falls_back_to_the_first_non_blank_caption_line(self): + post = Post(kind="video", text="\n\n Первая строка\nвторая\n") + self.assertEqual(self.pub._derive_title(post), "Первая строка") + + def test_title_falls_back_to_untitled(self): + self.assertEqual(self.pub._derive_title(Post(kind="video", text=" \n \n")), "Untitled") + + def test_derived_title_is_truncated(self): + post = Post(kind="video", title="я" * 200) + self.assertEqual(len(self.pub._derive_title(post)), 100) + + def test_quota_is_warned_about_on_publish_and_on_draft_alike(self): + # A private upload costs the same 1600 units as a public one, so the + # warning must not imply --draft is the cheap option. + post = Post(kind="video", media=[self.make("a.mp4")], title="T", alt_texts=["x"]) + self.assertIn("quota", self.fields(self.pub.preflight(post, draft=False), "warn")) + self.assertIn("quota", self.fields(self.pub.preflight(post, draft=True), "warn")) + + def test_draft_is_supported(self): + self.assertTrue(self.pub.supports_draft) + + def test_oversized_tag_block(self): + post = Post( + kind="video", + media=[self.make("a.mp4")], + title="T", + alt_texts=["x"], + hashtags=["я" * 60] * 10, + ) + self.assertIn("hashtags", self.fields(self.blocks(self.pub.preflight(post)))) + + +class TestLinkedInPreflight(MediaCase): + def setUp(self): + super().setUp() + self.pub = LinkedInPublisher() + + def test_3000_char_limit(self): + self.assertIn("text", self.fields(self.blocks(self.pub.preflight(Post(kind="text", text="я" * 3001))))) + + def test_text_post_is_supported(self): + self.assertEqual(self.blocks(self.pub.preflight(Post(kind="text", text="Пост."))), []) + + def test_empty_text_blocks(self): + self.assertIn("text", self.fields(self.blocks(self.pub.preflight(Post(kind="text"))))) + + def test_video_with_images_blocks(self): + post = Post( + kind="carousel", media=[self.make("a.mp4"), self.make("b.png")], text="ok", alt_texts=["x", "y"] + ) + self.assertIn("media", self.fields(self.blocks(self.pub.preflight(post)))) + + def test_organisation_author_warns_about_partner_gating(self): + import os + + os.environ["LINKEDIN_AUTHOR_URN"] = "urn:li:organization:123" + self.addCleanup(os.environ.pop, "LINKEDIN_AUTHOR_URN", None) + v = self.pub.preflight(Post(kind="text", text="Пост.")) + self.assertIn("author", self.fields(v, "warn")) + + def test_no_draft_support(self): + self.assertFalse(self.pub.supports_draft) + + +class TestDraftGating(MediaCase): + def test_draft_on_a_platform_without_drafts_blocks(self): + pub = TelegramPublisher() + v = pub.preflight(Post(kind="text", text="x"), draft=True) + self.assertIn("draft", self.fields(self.blocks(v))) + + def test_no_draft_flag_is_fine(self): + pub = TelegramPublisher() + self.assertEqual(self.blocks(pub.preflight(Post(kind="text", text="x"))), []) + + +class TestCaptionExtraction(unittest.TestCase): + def test_main_post_heading_wins(self): + md = "# Captions\n\n## Main post\n\nТело поста.\n\n## Per-slide alts\n\n1. Обложка\n" + self.assertEqual(publish_cli.extract_caption(md), "Тело поста.") + + def test_russian_heading_is_recognised(self): + md = "## Основной пост\n\nТело.\n\n## Другое\n\nне это\n" + self.assertEqual(publish_cli.extract_caption(md), "Тело.") + + def test_heading_match_is_case_insensitive(self): + self.assertEqual(publish_cli.extract_caption("## MAIN POST\n\nBody.\n"), "Body.") + + def test_without_a_known_heading_everything_is_used(self): + # Better to over-include than to silently post an empty caption; the + # dry-run preview is where the user catches it. + md = "# Something\n\nline one\n\nline two\n" + self.assertEqual(publish_cli.extract_caption(md), "line one\n\nline two") + + def test_stops_at_the_next_heading_of_any_level(self): + md = "## Main post\n\nkeep\n\n### Sub\n\ndrop\n" + self.assertEqual(publish_cli.extract_caption(md), "keep") + + def test_empty_file_yields_empty_caption(self): + self.assertEqual(publish_cli.extract_caption(""), "") + + +class TestMediaDiscovery(MediaCase): + def test_slides_sort_numerically_not_lexically(self): + for i in (1, 2, 10, 11): + self.make(f"slide-{i}.png") + media, kind = publish_cli.discover_media(self.dir) + self.assertEqual(kind, "carousel") + self.assertEqual([m.name for m in media], ["slide-1.png", "slide-2.png", "slide-10.png", "slide-11.png"]) + + def test_final_mp4_wins_over_slides(self): + self.make("slide-1.png") + self.make("final.mp4") + media, kind = publish_cli.discover_media(self.dir) + self.assertEqual(kind, "video") + self.assertEqual(media[0].name, "final.mp4") + + def test_single_image_is_an_image_post(self): + self.make("cover.png") + self.assertEqual(publish_cli.discover_media(self.dir)[1], "image") + + def test_empty_dir_is_a_text_post(self): + media, kind = publish_cli.discover_media(self.dir) + self.assertEqual((media, kind), ([], "text")) + + def test_single_file_source(self): + p = self.make("one.mp4") + self.assertEqual(publish_cli.discover_media(p), ([p], "video")) + + def test_unsupported_single_file_raises(self): + with self.assertRaises(Exception): + publish_cli.discover_media(self.make("notes.txt")) + + +class TestReceipts(MediaCase): + def result(self, platform="telegram"): + return PublishResult( + platform=platform, post_id="42", state="published", permalink="https://t.me/c/42" + ) + + def test_absent_receipt_file_is_empty(self): + self.assertEqual(receipts.load(self.dir), []) + self.assertIsNone(receipts.find(self.dir, "telegram", "abc")) + + def test_record_then_find(self): + receipts.record(self.dir, self.result(), "hash-1") + found = receipts.find(self.dir, "telegram", "hash-1") + self.assertIsNotNone(found) + self.assertEqual(found.permalink, "https://t.me/c/42") + + def test_find_is_scoped_to_the_platform(self): + # Posting the same deck to Telegram must not mark Threads as done. + receipts.record(self.dir, self.result("telegram"), "hash-1") + self.assertIsNone(receipts.find(self.dir, "threads", "hash-1")) + + def test_find_is_scoped_to_the_content(self): + receipts.record(self.dir, self.result(), "hash-1") + self.assertIsNone(receipts.find(self.dir, "telegram", "hash-2")) + + def test_records_accumulate(self): + receipts.record(self.dir, self.result("telegram"), "hash-1") + receipts.record(self.dir, self.result("threads"), "hash-1") + self.assertEqual(len(receipts.load(self.dir)), 2) + + def test_corrupt_receipt_does_not_raise(self): + receipts.path_for(self.dir).write_text("{oops", encoding="utf-8") + self.assertEqual(receipts.load(self.dir), []) + + # ── what actually blocks a run ────────────────────────────────────────── + + def draft(self, platform="instagram"): + return PublishResult(platform=platform, post_id="c1", state="draft") + + def test_a_draft_does_not_block_publishing_it(self): + # Staging, reviewing, then publishing is the documented Meta and TikTok + # path. Treating the draft as "already done" turned the second half of + # that into a --force, which is backwards — the user staged it in order + # to publish it. + receipts.record(self.dir, self.draft(), "hash-1") + self.assertIsNone(receipts.find_blocking(self.dir, "instagram", "hash-1", drafting=False)) + + def test_a_draft_blocks_drafting_again(self): + receipts.record(self.dir, self.draft(), "hash-1") + self.assertIsNotNone(receipts.find_blocking(self.dir, "instagram", "hash-1", drafting=True)) + + def test_a_publication_blocks_publishing_again(self): + receipts.record(self.dir, self.result("instagram"), "hash-1") + self.assertIsNotNone(receipts.find_blocking(self.dir, "instagram", "hash-1", drafting=False)) + + def test_a_publication_blocks_drafting_too(self): + receipts.record(self.dir, self.result("instagram"), "hash-1") + self.assertIsNotNone(receipts.find_blocking(self.dir, "instagram", "hash-1", drafting=True)) + + def test_draft_then_publish_then_publish_is_blocked(self): + # The full lifecycle: stage, publish, and only then refuse a repeat. + receipts.record(self.dir, self.draft(), "hash-1") + self.assertIsNone(receipts.find_blocking(self.dir, "instagram", "hash-1", drafting=False)) + receipts.record(self.dir, self.result("instagram"), "hash-1") + self.assertIsNotNone(receipts.find_blocking(self.dir, "instagram", "hash-1", drafting=False)) + + def test_find_returns_the_most_recent_receipt(self): + receipts.record(self.dir, self.draft(), "hash-1") + receipts.record(self.dir, self.result("instagram"), "hash-1") + self.assertEqual(receipts.find(self.dir, "instagram", "hash-1").state, "published") + + def test_blocking_is_scoped_to_the_platform(self): + receipts.record(self.dir, self.result("telegram"), "hash-1") + self.assertIsNone(receipts.find_blocking(self.dir, "threads", "hash-1", drafting=False)) + + def test_published_at_is_recorded(self): + r = receipts.record(self.dir, self.result(), "hash-1") + self.assertTrue(r.published_at.startswith("20")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_tokens.py b/tests/unit/test_tokens.py new file mode 100644 index 0000000..f4aa1e6 --- /dev/null +++ b/tests/unit/test_tokens.py @@ -0,0 +1,237 @@ +"""Unit tests for common/runners/tokens.py — the OAuth token store. + +This module decides whether a stored credential is still good enough to post +with. Getting that wrong is expensive in both directions: too eager and every +publish dies on a 401 the user cannot explain; too lax and we hand an expired +token to a platform mid-upload. It also writes secrets to disk, so the same +permission and atomicity concerns as keysfile apply. +""" + +import stat +import sys +import tempfile +import time +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from common.runners import tokens # noqa: E402 +from common.runners.errors import TokenError # noqa: E402 + + +class TokensCase(unittest.TestCase): + """Redirects TOKENS_FILE at a temp path so no test can touch real tokens.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self._saved = tokens.TOKENS_FILE + tokens.TOKENS_FILE = Path(self._tmp.name) / ".skills-tokens.json" + self._saved_refreshers = dict(tokens._REFRESHERS) + tokens._REFRESHERS.clear() + + def tearDown(self): + tokens.TOKENS_FILE = self._saved + tokens._REFRESHERS.clear() + tokens._REFRESHERS.update(self._saved_refreshers) + self._tmp.cleanup() + + def entry(self, platform="threads", **kw): + base = dict( + platform=platform, + access_token="tok-ABCDEFGHIJKLMNOP", + refresh_token="ref-1", + expires_at=time.time() + 86400, + ) + base.update(kw) + return tokens.TokenEntry(**base) + + +class TestExpiry(TokensCase): + def test_none_expiry_never_expires(self): + self.assertFalse(self.entry(expires_at=None).expired()) + + def test_past_expiry_is_expired(self): + self.assertTrue(self.entry(expires_at=time.time() - 1).expired()) + + def test_skew_window_expires_early(self): + # Still nominally valid, but inside the skew window — we must refresh + # BEFORE the request rather than discover the 401 halfway through an + # upload that already pushed 200 MB. + soon = time.time() + (tokens.REFRESH_SKEW / 2) + self.assertTrue(self.entry(expires_at=soon).expired()) + + def test_outside_skew_window_is_fine(self): + later = time.time() + (tokens.REFRESH_SKEW * 3) + self.assertFalse(self.entry(expires_at=later).expired()) + + def test_human_readable_expiry(self): + self.assertEqual(self.entry(expires_at=None).expires_in_human(), "no expiry") + self.assertEqual(self.entry(expires_at=time.time() - 10).expires_in_human(), "expired") + self.assertIn("min", self.entry(expires_at=time.time() + 600).expires_in_human()) + self.assertIn("h", self.entry(expires_at=time.time() + 7200).expires_in_human()) + self.assertIn("days", self.entry(expires_at=time.time() + 300000).expires_in_human()) + + +class TestRoundTrip(TokensCase): + def test_save_then_read(self): + tokens.save(self.entry(account_label="@mikefluff")) + got = tokens.read("threads") + self.assertIsNotNone(got) + self.assertEqual(got.access_token, "tok-ABCDEFGHIJKLMNOP") + self.assertEqual(got.account_label, "@mikefluff") + + def test_read_missing_platform_is_none(self): + self.assertIsNone(tokens.read("tiktok")) + + def test_save_stamps_obtained_at(self): + tokens.save(self.entry()) + self.assertGreater(tokens.read("threads").obtained_at, 0) + + def test_save_is_per_platform_not_wholesale(self): + tokens.save(self.entry("threads")) + tokens.save(self.entry("telegram", access_token="tg-XXXXXXXXXXXX")) + self.assertEqual(len(tokens.all_entries()), 2) + self.assertEqual(tokens.read("threads").access_token, "tok-ABCDEFGHIJKLMNOP") + + def test_remove(self): + tokens.save(self.entry()) + self.assertTrue(tokens.remove("threads")) + self.assertIsNone(tokens.read("threads")) + self.assertFalse(tokens.remove("threads")) + + def test_unknown_fields_in_file_are_ignored(self): + # A token file written by a future version must not crash this one. + tokens.TOKENS_FILE.write_text( + '{"version": 99, "platforms": {"threads": ' + '{"platform": "threads", "access_token": "t", "quantum_field": 1}}}', + encoding="utf-8", + ) + self.assertEqual(tokens.read("threads").access_token, "t") + + +class TestFileSafety(TokensCase): + def test_file_is_owner_only(self): + tokens.save(self.entry()) + mode = stat.S_IMODE(tokens.TOKENS_FILE.stat().st_mode) + self.assertEqual(mode, 0o600, f"token file is {oct(mode)}, expected 0o600") + + def test_corrupt_file_does_not_raise(self): + tokens.TOKENS_FILE.write_text("{not json at all", encoding="utf-8") + self.assertEqual(tokens.all_entries(), []) + self.assertIsNone(tokens.read("threads")) + + def test_corrupt_file_can_be_overwritten(self): + tokens.TOKENS_FILE.write_text("garbage", encoding="utf-8") + tokens.save(self.entry()) + self.assertEqual(tokens.read("threads").access_token, "tok-ABCDEFGHIJKLMNOP") + + def test_token_is_never_shown_in_full(self): + self.assertEqual(self.entry().masked(), "tok-…MNOP") + + def test_status_lines_never_leak_a_token(self): + tokens.save(self.entry(account_label="@mikefluff")) + joined = "\n".join(tokens.status_lines()) + self.assertNotIn("tok-ABCDEFGHIJKLMNOP", joined) + self.assertIn("@mikefluff", joined) + + def test_status_lines_when_empty_tell_you_what_to_run(self): + self.assertIn("cli.auth", tokens.status_lines()[0]) + + +class TestGetValid(TokensCase): + def test_missing_token_raises_actionable_error(self): + with self.assertRaises(TokenError) as ctx: + tokens.get_valid("threads") + self.assertIn("cli.auth --platform threads", str(ctx.exception)) + + def test_live_token_returned_untouched(self): + tokens.save(self.entry()) + self.assertEqual(tokens.get_valid("threads"), "tok-ABCDEFGHIJKLMNOP") + + def test_expired_without_refresher_raises(self): + tokens.save(self.entry(expires_at=time.time() - 10)) + with self.assertRaises(TokenError): + tokens.get_valid("threads") + + def test_expired_with_refresher_is_renewed_and_persisted(self): + tokens.save(self.entry(expires_at=time.time() - 10)) + calls = [] + + def refresher(old): + calls.append(old.refresh_token) + return tokens.TokenEntry( + platform="threads", + access_token="tok-NEWNEWNEWNEWNEW", + refresh_token="ref-2", + expires_at=time.time() + 86400, + ) + + tokens.register_refresher("threads", refresher) + self.assertEqual(tokens.get_valid("threads"), "tok-NEWNEWNEWNEWNEW") + self.assertEqual(calls, ["ref-1"]) + # Persisted, so the next process does not refresh again. + self.assertEqual(tokens.read("threads").access_token, "tok-NEWNEWNEWNEWNEW") + + def test_refresher_failure_becomes_typed_error(self): + tokens.save(self.entry(expires_at=time.time() - 10)) + + def boom(_old): + raise RuntimeError("upstream 500") + + tokens.register_refresher("threads", boom) + with self.assertRaises(TokenError) as ctx: + tokens.get_valid("threads") + self.assertIn("upstream 500", str(ctx.exception)) + + def test_missing_refresh_token_is_the_refreshers_call_not_this_modules(self): + # Whether a refresh token is needed is a property of the platform's + # flow. A guard here previously hardcoded "instagram" as the exception + # and so made Threads — which refreshes identically — unrefreshable. + tokens.save(self.entry(expires_at=time.time() - 10, refresh_token=None)) + tokens.register_refresher( + "threads", + lambda old: tokens.TokenEntry( + platform="threads", access_token="th-RENEWEDRENEWED", expires_at=time.time() + 5_000_000 + ), + ) + self.assertEqual(tokens.get_valid("threads"), "th-RENEWEDRENEWED") + + def test_a_refresher_that_demands_a_refresh_token_still_reports_it(self): + tokens.save(self.entry(expires_at=time.time() - 10, refresh_token=None)) + + def needs_one(old): + if not old.refresh_token: + raise RuntimeError("no refresh token stored") + return old + + tokens.register_refresher("threads", needs_one) + with self.assertRaises(TokenError) as ctx: + tokens.get_valid("threads") + self.assertIn("no refresh token stored", str(ctx.exception)) + + +class TestHasUsable(TokensCase): + def test_absent(self): + self.assertFalse(tokens.has_usable("threads")) + + def test_live(self): + tokens.save(self.entry()) + self.assertTrue(tokens.has_usable("threads")) + + def test_expired_but_refreshable_counts_as_usable(self): + tokens.save(self.entry(expires_at=time.time() - 10)) + tokens.register_refresher("threads", lambda old: old) + self.assertTrue(tokens.has_usable("threads")) + + def test_expired_without_refresher_is_not_usable(self): + tokens.save(self.entry(expires_at=time.time() - 10)) + self.assertFalse(tokens.has_usable("threads")) + + def test_empty_access_token_is_not_usable(self): + tokens.save(self.entry(access_token="")) + self.assertFalse(tokens.has_usable("threads")) + + +if __name__ == "__main__": + unittest.main() From 3d5d3272fbd2b7bfff9444e6e84e329d121f5a71 Mon Sep 17 00:00:00 2001 From: Mikhail Savchenko Date: Mon, 3 Aug 2026 12:29:50 -0300 Subject: [PATCH 2/2] fix(dist): npm package and Docker image shipped a 17-skill subset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught while deciding whether the release was safe to cut. Tagging v*.*.* triggers the Docker build, and the image would have gone out without post-publisher — along with 24 other skills. Dockerfile and package.json both listed the same seventeen directories: exactly the v1.x prose set, frozen since around v2.3 while twenty-five skills were added around them. skills.json advertised all forty-two, so install.sh running inside the container warned about twenty-five missing skills — inside the artifact meant to contain them. The Dockerfile's own header claimed it ships "all skill markdown", which had stopped being true. The cost was never size. These are markdown directories; the rebuilt image is 117 MB and the skills contribute almost nothing to that. The subset was drift, not a decision. Both lists regenerated from skills.json, and check-docs-consistency.sh gained gate 7 to compare them against it. That gate is the actual fix — the lists drifted for eighteen releases precisely because nothing compared them to anything. Verified it fails by removing a skill from each and watching it name both. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DUcstDF74VfBLh2e2g8g3i --- CHANGELOG.md | 10 ++++++ Dockerfile | 60 ++++++++++++++++++++++--------- package.json | 47 ++++++++++++++++++------ scripts/check-docs-consistency.sh | 56 +++++++++++++++++++++++++---- 4 files changed, 140 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc38868..42fdc60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,16 @@ Also: `scripts/validate.sh` only resolved `references/*.md` links inside the sam 144 new unit tests, none of which touch the network. Five of them exist because a review pass found the bugs they now pin: an explicit `--title` silently discarded on YouTube whenever the caption was empty (operator precedence — `(title or first_line) if text else "Untitled"`); a TikTok chunk plan that declared a 20 MB chunk for a 6 MB file, which TikTok rejects; Threads permanently unrefreshable because `tokens.py` hardcoded `"instagram"` as the one platform allowed to renew without a refresh token; a one-shot OAuth listener that a browser's `/favicon.ico` request could consume instead of the callback; and a partial-alt-text warning that the documentation described and the code did not implement. +### Fixed — the npm package and the Docker image shipped a 17-skill subset + +Caught while deciding whether the release was safe to cut. Tagging `v*.*.*` triggers the Docker build, and the image would have gone out without `post-publisher` — along with 24 other skills. + +`Dockerfile` and `package.json` both listed the same seventeen directories: exactly the v1.x prose set, frozen since around v2.3 while twenty-five skills were added around them. `skills.json` advertised all forty-two, so `install.sh` running inside the container warned about twenty-five missing skills — inside the artifact meant to contain them. The Dockerfile's own header claimed it ships "all skill markdown", which had quietly stopped being true. + +The cost was never size. These are markdown directories; the rebuilt image is 117 MB and the skills contribute almost nothing to that. The subset was drift, not a decision. + +Both lists regenerated from `skills.json`, and `check-docs-consistency.sh` gained gate 7 to compare them against it. That gate is the actual fix — the lists drifted for eighteen releases precisely because nothing compared them to anything. Verified it fails by removing a skill from each and watching it name both. + ### Fixed — nothing was checking the launch copy Caught by the user asking whether the rewritten posts had been run through the skills. They had not, and two layers of blindness met — both self-inflicted. diff --git a/Dockerfile b/Dockerfile index 7aee5c4..acc8444 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,23 +54,51 @@ COPY writer/examples/ /skills/writer/examples/ # Copy remaining skills (read-only reference material — useful inside the image # but not needed for lint.py to run). -COPY viral-text/ /skills/viral-text/ -COPY prose-edit/ /skills/prose-edit/ -COPY essay-write/ /skills/essay-write/ -COPY style-check/ /skills/style-check/ -COPY translation-sync/ /skills/translation-sync/ -COPY canon-check/ /skills/canon-check/ +COPY audio-mix-maker/ /skills/audio-mix-maker/ +COPY avatar-maker/ /skills/avatar-maker/ +COPY banner-maker/ /skills/banner-maker/ +COPY bg-remover/ /skills/bg-remover/ +COPY canon-check/ /skills/canon-check/ +COPY carousel-builder/ /skills/carousel-builder/ +COPY cold-email/ /skills/cold-email/ +COPY cover-maker/ /skills/cover-maker/ +COPY essay-write/ /skills/essay-write/ +COPY flyer-maker/ /skills/flyer-maker/ +COPY gif-maker/ /skills/gif-maker/ +COPY image-prompt/ /skills/image-prompt/ +COPY landing-copy/ /skills/landing-copy/ +COPY logo-maker/ /skills/logo-maker/ +COPY meme-card-maker/ /skills/meme-card-maker/ +COPY microcopy/ /skills/microcopy/ +COPY music-prompt/ /skills/music-prompt/ COPY pelevin-digression/ /skills/pelevin-digression/ -COPY skills-update/ /skills/skills-update/ -COPY tone-shifter/ /skills/tone-shifter/ -COPY cold-email/ /skills/cold-email/ -COPY image-prompt/ /skills/image-prompt/ -COPY video-prompt/ /skills/video-prompt/ -COPY microcopy/ /skills/microcopy/ -COPY release-notes/ /skills/release-notes/ -COPY rfc-writer/ /skills/rfc-writer/ -COPY landing-copy/ /skills/landing-copy/ -COPY common/ /skills/common/ +COPY post-publisher/ /skills/post-publisher/ +COPY proposal-maker/ /skills/proposal-maker/ +COPY prose-edit/ /skills/prose-edit/ +COPY quote-card-maker/ /skills/quote-card-maker/ +COPY reel-builder/ /skills/reel-builder/ +COPY release-notes/ /skills/release-notes/ +COPY research-brief/ /skills/research-brief/ +COPY rfc-writer/ /skills/rfc-writer/ +COPY skills-keys/ /skills/skills-keys/ +COPY skills-styles/ /skills/skills-styles/ +COPY skills-update/ /skills/skills-update/ +COPY style-check/ /skills/style-check/ +COPY style-suggest/ /skills/style-suggest/ +COPY style-transfer/ /skills/style-transfer/ +COPY subtitle-burner/ /skills/subtitle-burner/ +COPY thumbnail-maker/ /skills/thumbnail-maker/ +COPY tone-shifter/ /skills/tone-shifter/ +COPY transcribe-maker/ /skills/transcribe-maker/ +COPY translation-sync/ /skills/translation-sync/ +COPY upscaler/ /skills/upscaler/ +COPY video-prompt/ /skills/video-prompt/ +COPY viral-text/ /skills/viral-text/ +COPY voiceover-maker/ /skills/voiceover-maker/ + +# Shared runner layer + style libraries. Not a skill, so not in skills.json, +# and therefore not in the generated list above — it needs its own line. +COPY common/ /skills/common/ # Helper scripts the entrypoint dispatches to COPY scripts/coverage.py /skills/scripts/coverage.py diff --git a/package.json b/package.json index 9de2fea..901a554 100644 --- a/package.json +++ b/package.json @@ -36,23 +36,48 @@ "VERSION", "LICENSE", "README.md", - "writer/", - "viral-text/", - "prose-edit/", - "essay-write/", - "style-check/", - "translation-sync/", + "audio-mix-maker/", + "avatar-maker/", + "banner-maker/", + "bg-remover/", "canon-check/", - "pelevin-digression/", - "skills-update/", - "tone-shifter/", + "carousel-builder/", "cold-email/", + "cover-maker/", + "essay-write/", + "flyer-maker/", + "gif-maker/", "image-prompt/", - "video-prompt/", + "landing-copy/", + "logo-maker/", + "meme-card-maker/", "microcopy/", + "music-prompt/", + "pelevin-digression/", + "post-publisher/", + "proposal-maker/", + "prose-edit/", + "quote-card-maker/", + "reel-builder/", "release-notes/", + "research-brief/", "rfc-writer/", - "landing-copy/", + "skills-keys/", + "skills-styles/", + "skills-update/", + "style-check/", + "style-suggest/", + "style-transfer/", + "subtitle-burner/", + "thumbnail-maker/", + "tone-shifter/", + "transcribe-maker/", + "translation-sync/", + "upscaler/", + "video-prompt/", + "viral-text/", + "voiceover-maker/", + "writer/", "common/" ], "engines": { diff --git a/scripts/check-docs-consistency.sh b/scripts/check-docs-consistency.sh index 9a2bc93..7f04559 100755 --- a/scripts/check-docs-consistency.sh +++ b/scripts/check-docs-consistency.sh @@ -12,6 +12,7 @@ # 5. New skill folders (since last release tag) must be mentioned in # CHANGELOG.md [Unreleased] # 6. docs/SKILL-INDEX.md is up to date with skills.json (auto-generated) +# 7. Dockerfile + package.json ship every registered skill (distribution drift) # # Usage: # bash scripts/check-docs-consistency.sh @@ -46,7 +47,7 @@ skill_dirs_on_disk() { # ── Check 1 — README table is in sync with skills.json ────────────────────── -echo "[1/6] README skills table ↔ skills.json" +echo "[1/7] README skills table ↔ skills.json" if python3 scripts/gen-skills-table.py --check >/dev/null 2>&1; then pass "README table is up to date with skills.json" else @@ -57,7 +58,7 @@ echo # ── Check 2 — every disk skill folder is in skills.json ───────────────────── -echo "[2/6] skill folders on disk ↔ skills.json" +echo "[2/7] skill folders on disk ↔ skills.json" manifest_set=" $(skills_in_manifest | tr '\n' ' ')" unregistered="" for dir in $(skill_dirs_on_disk); do @@ -77,7 +78,7 @@ echo # ── Check 3 — walkthroughs cite only real skills ──────────────────────────── -echo "[3/6] docs/walkthroughs/ frontmatter ↔ skills.json" +echo "[3/7] docs/walkthroughs/ frontmatter ↔ skills.json" if [ -d docs/walkthroughs ]; then walked=0 for w in docs/walkthroughs/*.md; do @@ -123,7 +124,7 @@ echo # ── Check 4 — every skill is named somewhere in USER-GUIDE.md ─────────────── -echo "[4/6] every skill is mentioned in docs/USER-GUIDE.md" +echo "[4/7] every skill is mentioned in docs/USER-GUIDE.md" if [ -f docs/USER-GUIDE.md ]; then guide="$(cat docs/USER-GUIDE.md)" missing="" @@ -152,7 +153,7 @@ echo # manually, so a skill shipping in an untagged 2.20.0 belongs under [2.20.0] — # scanning [Unreleased] alone would fail every such entry. -echo "[5/6] new skills since last v* tag ↔ CHANGELOG (untagged sections)" +echo "[5/7] new skills since last v* tag ↔ CHANGELOG (untagged sections)" last_tag="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)" if [ -z "$last_tag" ]; then info "no v* tag yet — skipping (this check matters only after first release)" @@ -203,7 +204,7 @@ fi echo # ── Check 6 — SKILL-INDEX.md is up to date with skills.json ───────────────── -echo "[6/6] docs/SKILL-INDEX.md ↔ skills.json" +echo "[6/7] docs/SKILL-INDEX.md ↔ skills.json" if [ -f scripts/gen-skill-index.py ]; then if python3 scripts/gen-skill-index.py --check >/dev/null 2>&1; then pass "SKILL-INDEX.md is up to date with skills.json" @@ -215,6 +216,49 @@ else fi echo +echo "[7/7] distribution manifests ↔ skills.json" +# Nothing checked this, which is how the npm package and the Docker image came +# to ship a 17-skill v1.x subset while skills.json advertised 42 — install.sh +# then warned about 25 missing skills inside the very artifacts meant to +# contain them. A registered skill that reaches neither channel is invisible to +# everyone who did not install from the git tarball. +if ! python3 - <<'PY' +import json, re, sys +from pathlib import Path + +skills = {s["dir"] for s in json.loads(Path("skills.json").read_text(encoding="utf-8"))["skills"]} +errors = [] + +dockerfile = Path("Dockerfile") +if dockerfile.is_file(): + copied = set(re.findall(r"^COPY ([a-z0-9-]+)/", dockerfile.read_text(encoding="utf-8"), re.M)) + missing = sorted(skills - copied) + if missing: + errors.append(f"Dockerfile does not COPY: {', '.join(missing)}") + if "common" not in copied: + errors.append("Dockerfile does not COPY common/ — the runner layer") + +pkg = Path("package.json") +if pkg.is_file(): + files = set(json.loads(pkg.read_text(encoding="utf-8")).get("files", [])) + listed = {f.rstrip("/") for f in files} + missing = sorted(skills - listed) + if missing: + errors.append(f"package.json 'files' omits: {', '.join(missing)}") + if "common" not in listed: + errors.append("package.json 'files' omits common/") + +if errors: + for e in errors: + print(f" ✗ {e}") + sys.exit(1) +print(f" ✓ Dockerfile + package.json ship all {len(skills)} skills") +PY +then + fail "distribution manifests out of sync with skills.json" +fi +echo + if [ "$err" = "0" ]; then green "docs-consistency: OK" else