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..42fdc60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,45 @@ 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 — 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/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/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/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/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 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()