diff --git a/CHANGELOG.md b/CHANGELOG.md index 563c936..ff7c013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +- **Webhooks** — `webhooks:event-types` / `list` / `get` / `create` / `update` / + `enable` / `disable` / `delete` / `rotate-secret` / `deliveries`. Webhooks are + owned by the API key's user, so every workspace lists the same ones; the + workspace only decides whose API credits the call uses. The signing secret is + printed once, by `create` and `rotate-secret` only (the old secret keeps + signing for 24h after a rotate). `webhooks:deliveries` is the paginated + delivery log for diagnosing why events stopped arriving. +- **Brand Knowledge** — `brand:get` / `section` / `create` / `update` / `delete` / + `source-add` / `source-delete` / `sync` / `post-settings` / `post-settings-update`, + backed by `/workspaces/{workspace_id}/brand`. `create`, `source-add` and `sync` run + the app's AI brand analysis synchronously (up to ~2 minutes), so they wait 180s + (`--timeout`) and are not auto-retried. + ## 1.6.0 — content categories, approval workflows, planner share links, AI video (2026-09-17) The public API gained five surfaces; the CLI mirrors all of them. diff --git a/README.md b/README.md index 6ccd626..be846e8 100644 --- a/README.md +++ b/README.md @@ -1407,6 +1407,24 @@ The CLI wraps these endpoints from the ContentStudio v1 public API (plus the wor | DELETE | `/workspaces/{w}/share-links/{id}` | `planner-share-links:delete` | | POST | `/workspaces/{w}/share-links/{id}/send-invitations` | `planner-share-links:send-invitations` | | GET | `/workspaces/{w}/share-links/{id}/activity` | `planner-share-links:activity` | +| GET | `/workspaces/{w}/webhooks/event-types` | `webhooks:event-types` | +| GET | `/workspaces/{w}/webhooks` | `webhooks:list` | +| POST | `/workspaces/{w}/webhooks` | `webhooks:create` | +| GET | `/workspaces/{w}/webhooks/{id}` | `webhooks:get` | +| PUT | `/workspaces/{w}/webhooks/{id}` | `webhooks:update` / `webhooks:enable` / `webhooks:disable` | +| DELETE | `/workspaces/{w}/webhooks/{id}` | `webhooks:delete` | +| POST | `/workspaces/{w}/webhooks/{id}/rotate-secret` | `webhooks:rotate-secret` | +| GET | `/workspaces/{w}/webhooks/{id}/deliveries` | `webhooks:deliveries` | +| GET | `/workspaces/{w}/brand` | `brand:get` | +| POST | `/workspaces/{w}/brand` | `brand:create` | +| PATCH | `/workspaces/{w}/brand` | `brand:update` | +| DELETE | `/workspaces/{w}/brand` | `brand:delete` | +| GET | `/workspaces/{w}/brand/{section}` | `brand:section` | +| POST | `/workspaces/{w}/brand/sources` | `brand:source-add` | +| DELETE | `/workspaces/{w}/brand/sources/{id}` | `brand:source-delete` | +| POST | `/workspaces/{w}/brand/sync` | `brand:sync` | +| GET | `/workspaces/{w}/brand/post-generation-settings` | `brand:post-settings` | +| PATCH | `/workspaces/{w}/brand/post-generation-settings` | `brand:post-settings-update` | | GET | `/workspaces/{w}/labels` | `labels:list` | | GET | `/workspaces/{w}/team-members` | `team:list` | | GET | `/workspaces/{w}/media` | `media:list` | @@ -1530,6 +1548,19 @@ contentstudio --json planner-share-links:create --name "Client Review" --plan

--email a@b.c --approval-option anyone contentstudio --json planner-share-links:activity [--type comment] # Client comments/actions +# Webhooks (owned by your user — same list in every workspace; the workspace only pays the API credit) +contentstudio --json webhooks:event-types +contentstudio --json webhooks:create --url https://example.com/hook --event-type post.published --event-type post.failed # prints the secret ONCE +contentstudio --json webhooks:deliveries --status failed # why did events stop? +contentstudio --json webhooks:rotate-secret # old secret signs for 24h more + +# Brand Knowledge (one brand per workspace; create/source-add/sync run a ~2 min AI analysis) +contentstudio --json brand:get +contentstudio --json brand:create --website-url https://acme.coffee --social-account +contentstudio --json brand:update --brand-voice '{"tone":["Warm","Confident"]}' # a list sent replaces that list +contentstudio --json brand:source-add --file https://acme.coffee/brand-guide.pdf +contentstudio --json brand:post-settings-update --social-platform linkedin --number-of-posts 5 + # Posts contentstudio --json posts:list [--status draft] [--date-from] [--date-to] # List posts contentstudio --json posts:get # Read one post diff --git a/SKILL.md b/SKILL.md index 0d04473..8cf47fd 100644 --- a/SKILL.md +++ b/SKILL.md @@ -91,7 +91,7 @@ contentstudio workspaces:use The CLI silently defaults to the active workspace (whatever was set by `workspaces:use`). That default is fine for **read-only** calls (`workspaces:list`, `accounts:list`, `posts:list`, `media:list`, etc.) — just use the active workspace. -But for any **mutating** action — `accounts:connect`, `accounts:add-bluesky`, `accounts:add-facebook-group`, `accounts:remove`, `posts:create`, `posts:update`, `posts:delete`, `posts:approve`, `posts:reject`, `comments:add`, `media:upload`, `workspaces:update`, `workspaces:delete`, `labels:create`, `labels:update`, `labels:delete`, `campaigns:create`, `campaigns:update`, `campaigns:delete`, `team:add`, `team:update`, `team:remove`, `categories:create`, `categories:update`, `categories:delete`, `categories:shuffle`, `category-slots:create`, `category-slots:update`, `category-slots:delete`, `approval-workflows:create`, `approval-workflows:update`, `approval-workflows:delete`, `approval-workflows:duplicate`, `approval-workflows:set-default`, `approval-workflows:remove-default`, `planner-share-links:create`, `planner-share-links:update`, `planner-share-links:delete`, `planner-share-links:send-invitations`, every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …), and `ai-video:generate` / `ai-video:run-tool` / `ai-video:cancel-job` — you MUST confirm the workspace with the user first, even if a workspace is already active. Don't assume the active workspace is the one they want to mutate. +But for any **mutating** action — `accounts:connect`, `accounts:add-bluesky`, `accounts:add-facebook-group`, `accounts:remove`, `posts:create`, `posts:update`, `posts:delete`, `posts:approve`, `posts:reject`, `comments:add`, `media:upload`, `workspaces:update`, `workspaces:delete`, `labels:create`, `labels:update`, `labels:delete`, `campaigns:create`, `campaigns:update`, `campaigns:delete`, `team:add`, `team:update`, `team:remove`, `categories:create`, `categories:update`, `categories:delete`, `categories:shuffle`, `category-slots:create`, `category-slots:update`, `category-slots:delete`, `approval-workflows:create`, `approval-workflows:update`, `approval-workflows:delete`, `approval-workflows:duplicate`, `approval-workflows:set-default`, `approval-workflows:remove-default`, `planner-share-links:create`, `planner-share-links:update`, `planner-share-links:delete`, `planner-share-links:send-invitations`, `webhooks:create`, `webhooks:update`, `webhooks:enable`, `webhooks:disable`, `webhooks:delete`, `webhooks:rotate-secret`, `brand:create`, `brand:update`, `brand:delete`, `brand:source-add`, `brand:source-delete`, `brand:sync`, `brand:post-settings-update`, every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …), and `ai-video:generate` / `ai-video:run-tool` / `ai-video:cancel-job` — you MUST confirm the workspace with the user first, even if a workspace is already active. Don't assume the active workspace is the one they want to mutate. > **AI video generation costs credits.** `ai-video:generate` and `ai-video:run-tool` submit a real (billed) job the moment they're called without `--dry-run` — run `ai-video:estimate` first when the flags support it, show the estimate/cost to the user, and `--dry-run` the actual call before running it for real. `ai-video:cancel-job` may also charge for partial work already consumed — don't cancel a job on the user's behalf without confirming. @@ -180,9 +180,9 @@ Did the user say "all" / "every" / "complete list" / "every single"? ### Endpoints that paginate All `*:list` commands paginate: -`workspaces:list`, `accounts:list`, `posts:list`, `comments:list`, `media:list`, `campaigns:list`, `categories:list`, `labels:list`, `team:list`, `approval-workflows:list`, `planner-share-links:list`, `ai-video:jobs`. +`workspaces:list`, `accounts:list`, `posts:list`, `comments:list`, `media:list`, `campaigns:list`, `categories:list`, `labels:list`, `team:list`, `approval-workflows:list`, `planner-share-links:list`, `webhooks:deliveries`, `ai-video:jobs`. -`category-slots:list` and `planner-share-links:activity` return everything in one shot and carry no `pagination` block — a category has a handful of slots, and activity answers its own `total`. +`category-slots:list` and `planner-share-links:activity` return everything in one shot and carry no `pagination` block — a category has a handful of slots, and activity answers its own `total`. `webhooks:list` is likewise unpaginated (a user has at most 5 webhooks) and answers `{used, limit, data[]}`. Non-list commands (`auth:whoami`, `posts:create`, `posts:delete`, `media:upload`, `ai-video:tools`, `ai-video:models`, `ai-video:job`, etc.) never include `pagination` in their envelope. @@ -453,7 +453,7 @@ Errors: 422 for unknown accounts or a workspace with no connected accounts; 502 - **`model_used` is not one of the `images:models` values** — it comes back provider-prefixed (`fal-ai/nano-banana-pro`, `pixelcut/background-removal`) and names the model that actually ran after any fallback. Report it; never compare it for equality with `--model`. - **`images:tools` `controls` describe the underlying tool, not the public payload.** Take `--resolution` / `--aspect-ratio` values from there, but a control with no matching flag cannot be sent at all — `upscale` lists `model` and `upscale_factor`, and neither is in the API's tool payload. Likewise `accepts_instructions: true` on `headshot` and `face-swap` is not reachable: only `images:product-image` has `--instructions`. Sending an unsupported field is dropped in silence, so it will look like it worked. - **`--dimensions`** is `square`, `square_hd`, `portrait_4_5` or `landscape_16_9`, text→image only. Exact pixels are the model's choice — read `width`/`height` back. Anything else is rejected by the CLI before the call. -- **Brand knowledge is a boolean, read-only.** `--use-brand` on `images:generate` only; it is resolved server-side and no brand ID or brand content is ever accepted or returned. `--use-brand` with no brand profile is `brand_applied: false`, not an error — `images:brand` tells you in advance. **The tool commands and `images:generate --image-url` always report `brand_applied: false`** — edits and tools do not apply brand knowledge. +- **Brand knowledge is a boolean here.** `--use-brand` on `images:generate` only; it is resolved server-side and no brand ID or brand content is accepted or returned by the image commands (read or edit the brand itself with the `brand:*` commands — see **Brand Knowledge**). `--use-brand` with no brand profile is `brand_applied: false`, not an error — `images:brand` tells you in advance. **The tool commands and `images:generate --image-url` always report `brand_applied: false`** — edits and tools do not apply brand knowledge. - **`--dry-run` on every generating command** prints the endpoint and body and calls nothing. Use it to show the user the prompt before spending a credit. The three discovery commands are reads and need no `--dry-run`. - **Rate limit: 30 requests/minute**, shared with the ContentStudio app's own AI usage on the same account. A `RateLimitError` here needs the full minute. - Video tools (`image-to-video`, `motion-control`, `lip-sync`, `talking-avatar`) are **not** on this API; asking for one is `NotFoundError` (`TOOL_NOT_FOUND`), same as an unknown key. @@ -621,6 +621,123 @@ the scheduled **posts**, optionally letting them comment or approve. - Prefer `planner-share-links:update --disabled` over `:delete` when a client engagement is only pausing — disabling is reversible, deleting is not. - Errors: `SHARE_LINK_NOT_FOUND` (404), `SHARE_LINK_TOKEN_REVOKE_FAILED` (422). +### Webhooks + +Outbound webhooks push events (`post.published`, `post.failed`, `inbox.message.received`, +`automation.bulk.completed`, …) to an https endpoint you own. + +**A webhook belongs to the API key's USER, not to a workspace.** Every workspace the user +is a member of lists the same webhooks; a webhook created under workspace A is readable +and editable under workspace B. The workspace (`--workspace` / active) only decides +**whose API credits the call uses** — one credit per call. A workspace the user is not a +member of answers 403. Each user can have **5 webhooks** (active and paused both count). + +| Command | Purpose | +|---------|---------| +| `webhooks:event-types` | Events you can subscribe to: `{value, label, group, description, payload_schema}`. Pass `value` to `--event-type` | +| `webhooks:list` | Your webhooks, newest first, plus `used` / `limit` (5) | +| `webhooks:get ` | Read one webhook | +| `webhooks:create --url --event-type … [--name ] [--secret whsec_…] [--custom-headers '']` | Create — prints the signing secret ONCE | +| `webhooks:update [--url] [--event-type …] [--name] [--custom-headers ''] [--status enabled\|disabled]` | Partial update | +| `webhooks:disable ` / `webhooks:enable ` | Shorthand for `update --status disabled` / `--status enabled` | +| `webhooks:delete ` | Delete a webhook | +| `webhooks:rotate-secret ` | New signing secret, printed ONCE; the old one keeps signing for 24h | +| `webhooks:deliveries [--status all\|successful\|failed] [--event-type] [--from] [--to] [--search] [--page] [--per-page]` | Delivery log — the "why did events stop arriving" diagnostic | + +- **The secret is shown only by `create` and `rotate-secret`.** `list` / `get` / `update` return `has_secret` and nothing more. When you run either command, show the user `data.secret` immediately and tell them to store it now — it cannot be fetched again. Never paste it into anything else unprompted. +- `--secret` is optional (`whsec_`); omit it and the backend generates one. `--dry-run` redacts a supplied secret. +- **Create pings the URL first.** The URL must be https and publicly reachable: internal addresses are refused (`WEBHOOK_URL_NOT_ALLOWED`), and the endpoint must answer the ping with a 2xx within 5s (`WEBHOOK_PREFLIGHT_FAILED`). Make sure the receiver is live before creating. A new `--url` on update goes through the same check. +- `--event-type` is repeatable → `event_types[]` (≥1 on create). On update it **replaces** the whole list — pass every event you want to keep. +- `--custom-headers` is a JSON object of string values sent with every delivery, e.g. `'{"X-Tenant":"acme"}'`. It cannot override the signing or system headers. +- `status` is `enabled`, `disabled` (paused by you) or `disabled_by_system` (ContentStudio paused it after 15 consecutive failed deliveries and emailed the owner). Resume either paused state with `webhooks:enable` — that also resets the failure count. You cannot set `disabled_by_system` yourself. +- Prefer `webhooks:disable` over `webhooks:delete` when the pause is temporary — disabling is reversible, deleting is not. +- **Rotation has a 24h grace window**: `previous_secret_expires_at` (ISO, or null if the webhook had no secret) is when the old secret stops signing. Tell the user to deploy the new secret to their receiver before then. +- **"My webhook stopped receiving events"** → `webhooks:get` (check `status` and `last_delivery_status`), then `webhooks:deliveries --status failed`. Each row is one event's latest attempt: `{event_id, event_type, outcome, response_status, duration_ms, is_test, created_at}`. `outcome` is `delivered` / `failed` / `retrying` / `skipped_out_of_credits` (not sent — the account's API credit pool ran out; successful deliveries cost one credit each, tests/pings are free). `response_status: null` means the endpoint was unreachable (DNS, TLS, refused, timeout). `--search` takes an exact event ID or part of an event type; `--from` / `--to` are ISO 8601 (no offset = UTC). Deliveries are kept 30 days. +- Not available through the API: sending a test event, opening a single delivery's request/response bodies, and CSV export — those are in Settings → Webhooks in the web app. +- Errors: `WEBHOOK_NOT_FOUND` (404 — includes webhooks owned by someone else), `VALIDATION_ERROR` (422), `WEBHOOK_LIMIT_REACHED` (422 — already 5), `WEBHOOK_URL_NOT_ALLOWED` (422), `WEBHOOK_PREFLIGHT_FAILED` (422), `WEBHOOK_REQUEST_FAILED` (500), `API_ACCESS_NOT_ALLOWED` / `API_CREDIT_LIMIT_EXCEEDED` (403), `RATE_LIMIT_EXCEEDED` (429). + +### Brand Knowledge + +The workspace's **brand** — the app's Brand Knowledge editor (Brand Style, Brand Profile, +Brand Voice, Source Materials, Media Assets). ContentStudio's AI (composer, inbox, RSS, +Evergreen, AI images with `--use-brand`) resolves it server-side; nothing here passes brand +content into a generation request. **One brand per workspace.** Any workspace member can +read and edit it, and edits show in the app immediately. + +| Command | Purpose | +|---------|---------| +| `brand:get` | The brand object. No brand → `is_set_up: false`, empty sections, null timestamps (a 200, not an error) | +| `brand:section ` | One part: `{schema_version, is_set_up, brand_

, updated_at}` | +| `brand:create [--website-url] [--text] [--file …] [--files ''] [--social-account …] [--timeout ]` | Build the brand by AI analysis ("Build My Brand Knowledge"). 201 | +| `brand:update [--brand-style ''] [--brand-profile ''] [--brand-voice ''] [--brand-enabled \| --no-brand-enabled]` | Partial update (PATCH). Creates the brand by hand, without analysis, if none | +| `brand:delete` | Permanently delete the brand, its brand-asset media, logo and source files | +| `brand:source-add` (same source flags as `create`) | Add source materials and AI-blend each into the existing brand. 201 `{added[], brand}` | +| `brand:source-delete ` | Delete one source (and the brand assets it produced) | +| `brand:sync [--timeout ]` | Re-analyse ALL sources and overwrite style/profile/voice (the app's Sync — no source id) | +| `brand:post-settings` | AI Content Library post-generation defaults | +| `brand:post-settings-update [--social-platform] [--language] [--post-type] [--number-of-posts 1-10] [--caption-length 20-200] [--emoji-usage] [--hashtag-usage] [--aspect-ratio] [--image-style]` | Partial update of those defaults | + +**Sources** (`brand:create`, `brand:source-add`; at least one): `--website-url` → `website_url`; +`--text` → `text` (≤10000 chars); `--file ` (repeatable) and/or `--files +'[{"url":"https://…/guide.pdf","name":"Brand guide"}]'` → `files` (≤50 public **https** PDF / DOCX / +TXT / Markdown, `name` ≤255); `--social-account ` (repeatable) → `social_accounts` — the `id` +from `accounts:list`, which must be a connected account of this workspace on facebook, twitter, +instagram, linkedin, pinterest, telegram, youtube, tiktok, tumblr, gmb or bluesky (otherwise a 422 +on `social_accounts` naming the id). + +- **Slow, synchronous analysis.** `brand:create`, `brand:source-add` and `brand:sync` wait for the + AI analysis — up to ~2 minutes (per source for `source-add`). The CLI waits 180s by default + (`--timeout` to change) and does **not** auto-retry them. Tell the user it will take a while. +- **`brand:create` only works once.** A workspace that already has a brand answers `409 + BRAND_ALREADY_EXISTS` and runs nothing — `brand:get` first; to change an existing brand use + `brand:update`, to feed it more material use `brand:source-add`. Brand assets the analysis + discovers arrive in the background shortly after the 201. No credits are consumed. +- **`brand:update` (PATCH) semantics:** only the fields sent change; a **list sent replaces that + list** (to add a tone: `brand:get`, append, send the whole list); `null` / `""` clears a text + field; **unknown keys are rejected** (422). Fields: + - `--brand-style` → `brand_style` `{logo, colors, title_font, body_font, visual_identity_description}`. + `logo` is an http(s) URL, or `""` / `null` to clear; a ContentStudio storage URL is accepted + only if it is the current logo or a file in this workspace's media library. `colors` is ≤6 + `{hex: "#RRGGBB", role: brand|background|text|accent}` with at most one each of + `brand` / `background` / `text`. + - `--brand-profile` → `brand_profile` `{business_name, core_identity, market_positioning}` + (strings ≤10000) + `{competitors, competitive_advantages, primary_customer_segments, + primary_value_drivers}` (string lists, entries ≤200). + - `--brand-voice` → `brand_voice` `{purpose, audience, voice_description}` (strings ≤10000) + + `{tone, emotion, character, language}` (free-text lists, entries ≤200; common tones: + Professional, Confident, Approachable, Warm, Friendly…). + - `--brand-enabled` / `--no-brand-enabled` → `brand_enabled` — whether AI generation uses the brand. +- **Source statuses:** each source is `pending`, `synced` (analysed into the brand) or + `unreachable` (the analysis could not use it — it is kept anyway). A brand holds at most **50 + sources** (422 on `source_materials`). If `brand:source-add` fails part-way + (`BRAND_SOURCE_ADD_FAILED`), the sources already added stay — `brand:get` to see them. +- **`brand:source-delete`** returns `auto_reply_rules_affected` — inbox auto-reply rules that used + the document as guidance and were detached. Tell the user about them. +- **`brand:sync` overwrites** the style, profile and voice from the sources — hand edits made + with `brand:update` are lost. Confirm before running it. +- **`brand:delete` is permanent** (brand, brand-asset media, logo, source files; posts already + generated are kept). Always `--dry-run` first and get explicit confirmation. Prefer + `brand:update --no-brand-enabled` when the user only wants AI to stop using the brand. +- **Post settings** (`brand:post-settings-update`): `--social-platform` instagram | facebook | + twitter | telegram | tiktok | gmb | threads | bluesky | linkedin | tumblr — **must be stored or + sent before anything saves** (422 on `social_platform`; `brand:post-settings` shows + `social_platform: null` when unset). `--language` English, Spanish, French, Portuguese, German, + Italian, Dutch, Turkish, Indonesian, Tagalog, Swedish, Danish, Norwegian, Romanian, Polish, + Finnish, Hungarian, Greek, Czech, Malay, Vietnamese, "Chinese (Simplified)", "Chinese + (Traditional)". `--post-type` image | text | text_image. `--emoji-usage` / `--hashtag-usage` + none | low | medium | high. `--aspect-ratio` 8:1, 4:1, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, + 3:4, 2:3, 9:16, 1:4, 1:8. `--image-style` none, abstract, oil-painting, neon-punk, app-icon, + black-white, bokeh, cartoon, cinematic, cyberpunk, digital-watercolor, film-noir, film-poster, + flat-design, futuristic, grunge, highly-detailed, isometric, minimalistic, photorealistic, + pixel-art, polaroid, pop-art, retro-80s, steampunk, sticker, super-realistic, surrealism, + tattoo, unreal-engine, vaporwave. `--number-of-posts` → `no_of_posts`. Only the fields sent + change; creates the brand if the workspace has none. +- Errors: `BRAND_NOT_FOUND` (404 — delete / source-add / source-delete / sync with no brand), + `BRAND_SOURCE_NOT_FOUND` (404), `BRAND_ALREADY_EXISTS` (409), `VALIDATION_ERROR` (422, with + `errors: {field: [msg]}`), `BRAND_INSUFFICIENT_CONTENT` (422 — the sources held too little + text, e.g. social accounts with no posts), `BRAND_SOURCE_ADD_FAILED` (422), + `BRAND_ANALYSIS_FAILED` (502 — nothing saved; retry). + ### Labels (write) | Command | Purpose | @@ -1561,7 +1678,11 @@ contentstudio --json inbox:tag-attach \ `CONTENT_CATEGORY_SLOT_NOT_FOUND`, `CONTENT_CATEGORY_SLOT_DUPLICATE`, `APPROVAL_WORKFLOW_NOT_FOUND`, `CANNOT_SET_DRAFT_AS_DEFAULT`, `REQUIRES_FORCE_DELETE`, `CASCADE_JOB_NOT_FOUND`, `SHARE_LINK_NOT_FOUND`, - `SHARE_LINK_TOKEN_REVOKE_FAILED`, `RATE_LIMIT_EXCEEDED`. + `SHARE_LINK_TOKEN_REVOKE_FAILED`, `WEBHOOK_NOT_FOUND`, `WEBHOOK_LIMIT_REACHED`, + `WEBHOOK_URL_NOT_ALLOWED`, `WEBHOOK_PREFLIGHT_FAILED`, `WEBHOOK_REQUEST_FAILED`, + `BRAND_NOT_FOUND`, `BRAND_SOURCE_NOT_FOUND`, `BRAND_ALREADY_EXISTS`, + `BRAND_INSUFFICIENT_CONTENT`, `BRAND_SOURCE_ADD_FAILED`, `BRAND_ANALYSIS_FAILED`, + `API_ACCESS_NOT_ALLOWED`, `API_CREDIT_LIMIT_EXCEEDED`, `RATE_LIMIT_EXCEEDED`. Two success shapes are also worth not mistaking for failures: a 200 carrying `next_slot: null` (`category-slots:next`) and a 200 carrying diff --git a/skills/contentstudio/SKILL.md b/skills/contentstudio/SKILL.md index 0d04473..8cf47fd 100644 --- a/skills/contentstudio/SKILL.md +++ b/skills/contentstudio/SKILL.md @@ -91,7 +91,7 @@ contentstudio workspaces:use The CLI silently defaults to the active workspace (whatever was set by `workspaces:use`). That default is fine for **read-only** calls (`workspaces:list`, `accounts:list`, `posts:list`, `media:list`, etc.) — just use the active workspace. -But for any **mutating** action — `accounts:connect`, `accounts:add-bluesky`, `accounts:add-facebook-group`, `accounts:remove`, `posts:create`, `posts:update`, `posts:delete`, `posts:approve`, `posts:reject`, `comments:add`, `media:upload`, `workspaces:update`, `workspaces:delete`, `labels:create`, `labels:update`, `labels:delete`, `campaigns:create`, `campaigns:update`, `campaigns:delete`, `team:add`, `team:update`, `team:remove`, `categories:create`, `categories:update`, `categories:delete`, `categories:shuffle`, `category-slots:create`, `category-slots:update`, `category-slots:delete`, `approval-workflows:create`, `approval-workflows:update`, `approval-workflows:delete`, `approval-workflows:duplicate`, `approval-workflows:set-default`, `approval-workflows:remove-default`, `planner-share-links:create`, `planner-share-links:update`, `planner-share-links:delete`, `planner-share-links:send-invitations`, every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …), and `ai-video:generate` / `ai-video:run-tool` / `ai-video:cancel-job` — you MUST confirm the workspace with the user first, even if a workspace is already active. Don't assume the active workspace is the one they want to mutate. +But for any **mutating** action — `accounts:connect`, `accounts:add-bluesky`, `accounts:add-facebook-group`, `accounts:remove`, `posts:create`, `posts:update`, `posts:delete`, `posts:approve`, `posts:reject`, `comments:add`, `media:upload`, `workspaces:update`, `workspaces:delete`, `labels:create`, `labels:update`, `labels:delete`, `campaigns:create`, `campaigns:update`, `campaigns:delete`, `team:add`, `team:update`, `team:remove`, `categories:create`, `categories:update`, `categories:delete`, `categories:shuffle`, `category-slots:create`, `category-slots:update`, `category-slots:delete`, `approval-workflows:create`, `approval-workflows:update`, `approval-workflows:delete`, `approval-workflows:duplicate`, `approval-workflows:set-default`, `approval-workflows:remove-default`, `planner-share-links:create`, `planner-share-links:update`, `planner-share-links:delete`, `planner-share-links:send-invitations`, `webhooks:create`, `webhooks:update`, `webhooks:enable`, `webhooks:disable`, `webhooks:delete`, `webhooks:rotate-secret`, `brand:create`, `brand:update`, `brand:delete`, `brand:source-add`, `brand:source-delete`, `brand:sync`, `brand:post-settings-update`, every `inbox:*` write (`inbox:send`, `inbox:comment-add`, `inbox:comment-delete`, `inbox:review-reply`, `inbox:update`, `inbox:tag-*`, …), and `ai-video:generate` / `ai-video:run-tool` / `ai-video:cancel-job` — you MUST confirm the workspace with the user first, even if a workspace is already active. Don't assume the active workspace is the one they want to mutate. > **AI video generation costs credits.** `ai-video:generate` and `ai-video:run-tool` submit a real (billed) job the moment they're called without `--dry-run` — run `ai-video:estimate` first when the flags support it, show the estimate/cost to the user, and `--dry-run` the actual call before running it for real. `ai-video:cancel-job` may also charge for partial work already consumed — don't cancel a job on the user's behalf without confirming. @@ -180,9 +180,9 @@ Did the user say "all" / "every" / "complete list" / "every single"? ### Endpoints that paginate All `*:list` commands paginate: -`workspaces:list`, `accounts:list`, `posts:list`, `comments:list`, `media:list`, `campaigns:list`, `categories:list`, `labels:list`, `team:list`, `approval-workflows:list`, `planner-share-links:list`, `ai-video:jobs`. +`workspaces:list`, `accounts:list`, `posts:list`, `comments:list`, `media:list`, `campaigns:list`, `categories:list`, `labels:list`, `team:list`, `approval-workflows:list`, `planner-share-links:list`, `webhooks:deliveries`, `ai-video:jobs`. -`category-slots:list` and `planner-share-links:activity` return everything in one shot and carry no `pagination` block — a category has a handful of slots, and activity answers its own `total`. +`category-slots:list` and `planner-share-links:activity` return everything in one shot and carry no `pagination` block — a category has a handful of slots, and activity answers its own `total`. `webhooks:list` is likewise unpaginated (a user has at most 5 webhooks) and answers `{used, limit, data[]}`. Non-list commands (`auth:whoami`, `posts:create`, `posts:delete`, `media:upload`, `ai-video:tools`, `ai-video:models`, `ai-video:job`, etc.) never include `pagination` in their envelope. @@ -453,7 +453,7 @@ Errors: 422 for unknown accounts or a workspace with no connected accounts; 502 - **`model_used` is not one of the `images:models` values** — it comes back provider-prefixed (`fal-ai/nano-banana-pro`, `pixelcut/background-removal`) and names the model that actually ran after any fallback. Report it; never compare it for equality with `--model`. - **`images:tools` `controls` describe the underlying tool, not the public payload.** Take `--resolution` / `--aspect-ratio` values from there, but a control with no matching flag cannot be sent at all — `upscale` lists `model` and `upscale_factor`, and neither is in the API's tool payload. Likewise `accepts_instructions: true` on `headshot` and `face-swap` is not reachable: only `images:product-image` has `--instructions`. Sending an unsupported field is dropped in silence, so it will look like it worked. - **`--dimensions`** is `square`, `square_hd`, `portrait_4_5` or `landscape_16_9`, text→image only. Exact pixels are the model's choice — read `width`/`height` back. Anything else is rejected by the CLI before the call. -- **Brand knowledge is a boolean, read-only.** `--use-brand` on `images:generate` only; it is resolved server-side and no brand ID or brand content is ever accepted or returned. `--use-brand` with no brand profile is `brand_applied: false`, not an error — `images:brand` tells you in advance. **The tool commands and `images:generate --image-url` always report `brand_applied: false`** — edits and tools do not apply brand knowledge. +- **Brand knowledge is a boolean here.** `--use-brand` on `images:generate` only; it is resolved server-side and no brand ID or brand content is accepted or returned by the image commands (read or edit the brand itself with the `brand:*` commands — see **Brand Knowledge**). `--use-brand` with no brand profile is `brand_applied: false`, not an error — `images:brand` tells you in advance. **The tool commands and `images:generate --image-url` always report `brand_applied: false`** — edits and tools do not apply brand knowledge. - **`--dry-run` on every generating command** prints the endpoint and body and calls nothing. Use it to show the user the prompt before spending a credit. The three discovery commands are reads and need no `--dry-run`. - **Rate limit: 30 requests/minute**, shared with the ContentStudio app's own AI usage on the same account. A `RateLimitError` here needs the full minute. - Video tools (`image-to-video`, `motion-control`, `lip-sync`, `talking-avatar`) are **not** on this API; asking for one is `NotFoundError` (`TOOL_NOT_FOUND`), same as an unknown key. @@ -621,6 +621,123 @@ the scheduled **posts**, optionally letting them comment or approve. - Prefer `planner-share-links:update --disabled` over `:delete` when a client engagement is only pausing — disabling is reversible, deleting is not. - Errors: `SHARE_LINK_NOT_FOUND` (404), `SHARE_LINK_TOKEN_REVOKE_FAILED` (422). +### Webhooks + +Outbound webhooks push events (`post.published`, `post.failed`, `inbox.message.received`, +`automation.bulk.completed`, …) to an https endpoint you own. + +**A webhook belongs to the API key's USER, not to a workspace.** Every workspace the user +is a member of lists the same webhooks; a webhook created under workspace A is readable +and editable under workspace B. The workspace (`--workspace` / active) only decides +**whose API credits the call uses** — one credit per call. A workspace the user is not a +member of answers 403. Each user can have **5 webhooks** (active and paused both count). + +| Command | Purpose | +|---------|---------| +| `webhooks:event-types` | Events you can subscribe to: `{value, label, group, description, payload_schema}`. Pass `value` to `--event-type` | +| `webhooks:list` | Your webhooks, newest first, plus `used` / `limit` (5) | +| `webhooks:get ` | Read one webhook | +| `webhooks:create --url --event-type … [--name ] [--secret whsec_…] [--custom-headers '']` | Create — prints the signing secret ONCE | +| `webhooks:update [--url] [--event-type …] [--name] [--custom-headers ''] [--status enabled\|disabled]` | Partial update | +| `webhooks:disable ` / `webhooks:enable ` | Shorthand for `update --status disabled` / `--status enabled` | +| `webhooks:delete ` | Delete a webhook | +| `webhooks:rotate-secret ` | New signing secret, printed ONCE; the old one keeps signing for 24h | +| `webhooks:deliveries [--status all\|successful\|failed] [--event-type] [--from] [--to] [--search] [--page] [--per-page]` | Delivery log — the "why did events stop arriving" diagnostic | + +- **The secret is shown only by `create` and `rotate-secret`.** `list` / `get` / `update` return `has_secret` and nothing more. When you run either command, show the user `data.secret` immediately and tell them to store it now — it cannot be fetched again. Never paste it into anything else unprompted. +- `--secret` is optional (`whsec_`); omit it and the backend generates one. `--dry-run` redacts a supplied secret. +- **Create pings the URL first.** The URL must be https and publicly reachable: internal addresses are refused (`WEBHOOK_URL_NOT_ALLOWED`), and the endpoint must answer the ping with a 2xx within 5s (`WEBHOOK_PREFLIGHT_FAILED`). Make sure the receiver is live before creating. A new `--url` on update goes through the same check. +- `--event-type` is repeatable → `event_types[]` (≥1 on create). On update it **replaces** the whole list — pass every event you want to keep. +- `--custom-headers` is a JSON object of string values sent with every delivery, e.g. `'{"X-Tenant":"acme"}'`. It cannot override the signing or system headers. +- `status` is `enabled`, `disabled` (paused by you) or `disabled_by_system` (ContentStudio paused it after 15 consecutive failed deliveries and emailed the owner). Resume either paused state with `webhooks:enable` — that also resets the failure count. You cannot set `disabled_by_system` yourself. +- Prefer `webhooks:disable` over `webhooks:delete` when the pause is temporary — disabling is reversible, deleting is not. +- **Rotation has a 24h grace window**: `previous_secret_expires_at` (ISO, or null if the webhook had no secret) is when the old secret stops signing. Tell the user to deploy the new secret to their receiver before then. +- **"My webhook stopped receiving events"** → `webhooks:get` (check `status` and `last_delivery_status`), then `webhooks:deliveries --status failed`. Each row is one event's latest attempt: `{event_id, event_type, outcome, response_status, duration_ms, is_test, created_at}`. `outcome` is `delivered` / `failed` / `retrying` / `skipped_out_of_credits` (not sent — the account's API credit pool ran out; successful deliveries cost one credit each, tests/pings are free). `response_status: null` means the endpoint was unreachable (DNS, TLS, refused, timeout). `--search` takes an exact event ID or part of an event type; `--from` / `--to` are ISO 8601 (no offset = UTC). Deliveries are kept 30 days. +- Not available through the API: sending a test event, opening a single delivery's request/response bodies, and CSV export — those are in Settings → Webhooks in the web app. +- Errors: `WEBHOOK_NOT_FOUND` (404 — includes webhooks owned by someone else), `VALIDATION_ERROR` (422), `WEBHOOK_LIMIT_REACHED` (422 — already 5), `WEBHOOK_URL_NOT_ALLOWED` (422), `WEBHOOK_PREFLIGHT_FAILED` (422), `WEBHOOK_REQUEST_FAILED` (500), `API_ACCESS_NOT_ALLOWED` / `API_CREDIT_LIMIT_EXCEEDED` (403), `RATE_LIMIT_EXCEEDED` (429). + +### Brand Knowledge + +The workspace's **brand** — the app's Brand Knowledge editor (Brand Style, Brand Profile, +Brand Voice, Source Materials, Media Assets). ContentStudio's AI (composer, inbox, RSS, +Evergreen, AI images with `--use-brand`) resolves it server-side; nothing here passes brand +content into a generation request. **One brand per workspace.** Any workspace member can +read and edit it, and edits show in the app immediately. + +| Command | Purpose | +|---------|---------| +| `brand:get` | The brand object. No brand → `is_set_up: false`, empty sections, null timestamps (a 200, not an error) | +| `brand:section ` | One part: `{schema_version, is_set_up, brand_
, updated_at}` | +| `brand:create [--website-url] [--text] [--file …] [--files ''] [--social-account …] [--timeout ]` | Build the brand by AI analysis ("Build My Brand Knowledge"). 201 | +| `brand:update [--brand-style ''] [--brand-profile ''] [--brand-voice ''] [--brand-enabled \| --no-brand-enabled]` | Partial update (PATCH). Creates the brand by hand, without analysis, if none | +| `brand:delete` | Permanently delete the brand, its brand-asset media, logo and source files | +| `brand:source-add` (same source flags as `create`) | Add source materials and AI-blend each into the existing brand. 201 `{added[], brand}` | +| `brand:source-delete ` | Delete one source (and the brand assets it produced) | +| `brand:sync [--timeout ]` | Re-analyse ALL sources and overwrite style/profile/voice (the app's Sync — no source id) | +| `brand:post-settings` | AI Content Library post-generation defaults | +| `brand:post-settings-update [--social-platform] [--language] [--post-type] [--number-of-posts 1-10] [--caption-length 20-200] [--emoji-usage] [--hashtag-usage] [--aspect-ratio] [--image-style]` | Partial update of those defaults | + +**Sources** (`brand:create`, `brand:source-add`; at least one): `--website-url` → `website_url`; +`--text` → `text` (≤10000 chars); `--file ` (repeatable) and/or `--files +'[{"url":"https://…/guide.pdf","name":"Brand guide"}]'` → `files` (≤50 public **https** PDF / DOCX / +TXT / Markdown, `name` ≤255); `--social-account ` (repeatable) → `social_accounts` — the `id` +from `accounts:list`, which must be a connected account of this workspace on facebook, twitter, +instagram, linkedin, pinterest, telegram, youtube, tiktok, tumblr, gmb or bluesky (otherwise a 422 +on `social_accounts` naming the id). + +- **Slow, synchronous analysis.** `brand:create`, `brand:source-add` and `brand:sync` wait for the + AI analysis — up to ~2 minutes (per source for `source-add`). The CLI waits 180s by default + (`--timeout` to change) and does **not** auto-retry them. Tell the user it will take a while. +- **`brand:create` only works once.** A workspace that already has a brand answers `409 + BRAND_ALREADY_EXISTS` and runs nothing — `brand:get` first; to change an existing brand use + `brand:update`, to feed it more material use `brand:source-add`. Brand assets the analysis + discovers arrive in the background shortly after the 201. No credits are consumed. +- **`brand:update` (PATCH) semantics:** only the fields sent change; a **list sent replaces that + list** (to add a tone: `brand:get`, append, send the whole list); `null` / `""` clears a text + field; **unknown keys are rejected** (422). Fields: + - `--brand-style` → `brand_style` `{logo, colors, title_font, body_font, visual_identity_description}`. + `logo` is an http(s) URL, or `""` / `null` to clear; a ContentStudio storage URL is accepted + only if it is the current logo or a file in this workspace's media library. `colors` is ≤6 + `{hex: "#RRGGBB", role: brand|background|text|accent}` with at most one each of + `brand` / `background` / `text`. + - `--brand-profile` → `brand_profile` `{business_name, core_identity, market_positioning}` + (strings ≤10000) + `{competitors, competitive_advantages, primary_customer_segments, + primary_value_drivers}` (string lists, entries ≤200). + - `--brand-voice` → `brand_voice` `{purpose, audience, voice_description}` (strings ≤10000) + + `{tone, emotion, character, language}` (free-text lists, entries ≤200; common tones: + Professional, Confident, Approachable, Warm, Friendly…). + - `--brand-enabled` / `--no-brand-enabled` → `brand_enabled` — whether AI generation uses the brand. +- **Source statuses:** each source is `pending`, `synced` (analysed into the brand) or + `unreachable` (the analysis could not use it — it is kept anyway). A brand holds at most **50 + sources** (422 on `source_materials`). If `brand:source-add` fails part-way + (`BRAND_SOURCE_ADD_FAILED`), the sources already added stay — `brand:get` to see them. +- **`brand:source-delete`** returns `auto_reply_rules_affected` — inbox auto-reply rules that used + the document as guidance and were detached. Tell the user about them. +- **`brand:sync` overwrites** the style, profile and voice from the sources — hand edits made + with `brand:update` are lost. Confirm before running it. +- **`brand:delete` is permanent** (brand, brand-asset media, logo, source files; posts already + generated are kept). Always `--dry-run` first and get explicit confirmation. Prefer + `brand:update --no-brand-enabled` when the user only wants AI to stop using the brand. +- **Post settings** (`brand:post-settings-update`): `--social-platform` instagram | facebook | + twitter | telegram | tiktok | gmb | threads | bluesky | linkedin | tumblr — **must be stored or + sent before anything saves** (422 on `social_platform`; `brand:post-settings` shows + `social_platform: null` when unset). `--language` English, Spanish, French, Portuguese, German, + Italian, Dutch, Turkish, Indonesian, Tagalog, Swedish, Danish, Norwegian, Romanian, Polish, + Finnish, Hungarian, Greek, Czech, Malay, Vietnamese, "Chinese (Simplified)", "Chinese + (Traditional)". `--post-type` image | text | text_image. `--emoji-usage` / `--hashtag-usage` + none | low | medium | high. `--aspect-ratio` 8:1, 4:1, 21:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, + 3:4, 2:3, 9:16, 1:4, 1:8. `--image-style` none, abstract, oil-painting, neon-punk, app-icon, + black-white, bokeh, cartoon, cinematic, cyberpunk, digital-watercolor, film-noir, film-poster, + flat-design, futuristic, grunge, highly-detailed, isometric, minimalistic, photorealistic, + pixel-art, polaroid, pop-art, retro-80s, steampunk, sticker, super-realistic, surrealism, + tattoo, unreal-engine, vaporwave. `--number-of-posts` → `no_of_posts`. Only the fields sent + change; creates the brand if the workspace has none. +- Errors: `BRAND_NOT_FOUND` (404 — delete / source-add / source-delete / sync with no brand), + `BRAND_SOURCE_NOT_FOUND` (404), `BRAND_ALREADY_EXISTS` (409), `VALIDATION_ERROR` (422, with + `errors: {field: [msg]}`), `BRAND_INSUFFICIENT_CONTENT` (422 — the sources held too little + text, e.g. social accounts with no posts), `BRAND_SOURCE_ADD_FAILED` (422), + `BRAND_ANALYSIS_FAILED` (502 — nothing saved; retry). + ### Labels (write) | Command | Purpose | @@ -1561,7 +1678,11 @@ contentstudio --json inbox:tag-attach \ `CONTENT_CATEGORY_SLOT_NOT_FOUND`, `CONTENT_CATEGORY_SLOT_DUPLICATE`, `APPROVAL_WORKFLOW_NOT_FOUND`, `CANNOT_SET_DRAFT_AS_DEFAULT`, `REQUIRES_FORCE_DELETE`, `CASCADE_JOB_NOT_FOUND`, `SHARE_LINK_NOT_FOUND`, - `SHARE_LINK_TOKEN_REVOKE_FAILED`, `RATE_LIMIT_EXCEEDED`. + `SHARE_LINK_TOKEN_REVOKE_FAILED`, `WEBHOOK_NOT_FOUND`, `WEBHOOK_LIMIT_REACHED`, + `WEBHOOK_URL_NOT_ALLOWED`, `WEBHOOK_PREFLIGHT_FAILED`, `WEBHOOK_REQUEST_FAILED`, + `BRAND_NOT_FOUND`, `BRAND_SOURCE_NOT_FOUND`, `BRAND_ALREADY_EXISTS`, + `BRAND_INSUFFICIENT_CONTENT`, `BRAND_SOURCE_ADD_FAILED`, `BRAND_ANALYSIS_FAILED`, + `API_ACCESS_NOT_ALLOWED`, `API_CREDIT_LIMIT_EXCEEDED`, `RATE_LIMIT_EXCEEDED`. Two success shapes are also worth not mistaking for failures: a 200 carrying `next_slot: null` (`category-slots:next`) and a 200 carrying diff --git a/src/api.ts b/src/api.ts index bcce9d4..68f1454 100644 --- a/src/api.ts +++ b/src/api.ts @@ -4168,5 +4168,290 @@ export function getWorkspaceLimits(c: Client, workspaceId: string) { return c.get(`/workspaces/${workspaceId}/limits`); } +// ───────────────────────────────────────────────────────────────── +// Webhooks — outbound event subscriptions. +// +// A webhook belongs to the API key's USER, not to a workspace: every +// workspace the user is a member of answers with the same webhooks. The +// `{workspace_id}` in the path only picks whose API credit pool the call +// uses (1 credit per call). The webhook object never contains the secret — +// only create and rotate-secret return it, once. +// ───────────────────────────────────────────────────────────────── + +/** + * GET /workspaces/{w}/webhooks/event-types + * + * Returns `[{ value, label, group, description, payload_schema }]`. `value` + * is what `event_types[]` takes on create/update. + */ +export function listWebhookEventTypes(c: Client, workspaceId: string) { + return c.get(`/workspaces/${workspaceId}/webhooks/event-types`); +} + +/** + * GET /workspaces/{w}/webhooks + * + * Not paginated. The response is flat (`{status, message, used, limit, + * data}`), so this reads the raw envelope — unwrapping would drop `used` and + * `limit` (the per-user cap, 5). + */ +export async function listWebhooks( + c: Client, + workspaceId: string, +): Promise<{ used: number; limit: number; data: any[] }> { + const body = await c.request("GET", `/workspaces/${workspaceId}/webhooks`, { + unwrap: false, + }); + return { + used: Number(body?.used ?? 0), + limit: Number(body?.limit ?? 0), + data: Array.isArray(body?.data) ? body.data : [], + }; +} + +/** GET /workspaces/{w}/webhooks/{webhook_id} */ +export function getWebhook(c: Client, workspaceId: string, webhookId: string) { + return c.get(`/workspaces/${workspaceId}/webhooks/${webhookId}`); +} + +/** + * POST /workspaces/{w}/webhooks + * + * Body: `url` (required, https, publicly reachable), `event_types` (required, + * ≥1), `name` (≤120), `secret` (optional `whsec_…`; omitted → generated), + * `custom_headers` (object of strings). The backend refuses internal URLs + * (`WEBHOOK_URL_NOT_ALLOWED`) and pings the URL, which must answer 2xx within + * 5s (`WEBHOOK_PREFLIGHT_FAILED`). Answers 201 with the webhook plus + * `secret` — the only time it is shown, apart from rotate-secret. + */ +export function createWebhook( + c: Client, + workspaceId: string, + body: { + url: string; + event_types: string[]; + name?: string; + secret?: string; + custom_headers?: Record; + }, +) { + return c.post(`/workspaces/${workspaceId}/webhooks`, { json: body }); +} + +/** + * PUT /workspaces/{w}/webhooks/{webhook_id} — partial update. + * + * Accepts `url`, `event_types`, `name`, `custom_headers`, and `status` + * (`enabled` | `disabled`). `enabled` also resumes a `disabled_by_system` + * webhook and resets its failure count. A new `url` is re-checked and pinged. + */ +export function updateWebhook( + c: Client, + workspaceId: string, + webhookId: string, + body: Record, +) { + return c.put(`/workspaces/${workspaceId}/webhooks/${webhookId}`, { + json: body, + }); +} + +/** DELETE /workspaces/{w}/webhooks/{webhook_id} — answers `data: null`. */ +export function deleteWebhook( + c: Client, + workspaceId: string, + webhookId: string, +) { + return c.delete(`/workspaces/${workspaceId}/webhooks/${webhookId}`); +} + +/** + * POST /workspaces/{w}/webhooks/{webhook_id}/rotate-secret + * + * Returns the webhook plus the new `secret` and `previous_secret_expires_at` + * (ISO, or null when it had no secret): the old secret keeps signing + * alongside the new one for 24h. + */ +export function rotateWebhookSecret( + c: Client, + workspaceId: string, + webhookId: string, +) { + return c.post( + `/workspaces/${workspaceId}/webhooks/${webhookId}/rotate-secret`, + ); +} + +/** + * GET /workspaces/{w}/webhooks/{webhook_id}/deliveries — paginated (flat + * Laravel metadata). One row per event, newest first, showing its latest + * attempt: `{ event_id, event_type, outcome, response_status, duration_ms, + * is_test, created_at }`. `outcome` is delivered | failed | retrying | + * skipped_out_of_credits. Kept for 30 days. + */ +export function listWebhookDeliveries( + c: Client, + workspaceId: string, + webhookId: string, + params: { + status?: "all" | "successful" | "failed"; + event_type?: string; + from?: string; + to?: string; + search?: string; + page?: number; + per_page?: number; + } = {}, +) { + return c.getPaginated( + `/workspaces/${workspaceId}/webhooks/${webhookId}/deliveries`, + params, + ); +} + +// ───────────────────────────────────────────────────────────────── +// Brand Knowledge — the workspace's brand (style, profile, voice, source +// materials) that ContentStudio's AI resolves server-side for generation. +// One brand per workspace. Base path /workspaces/{w}/brand. +// +// POST /brand, POST /brand/sources and POST /brand/sync run the app's brand +// analysis synchronously (up to ~2 minutes), so the commands call them with a +// BRAND_ANALYSIS_TIMEOUT_MS client and retries OFF — the built-in 5xx retry +// would re-run an analysis after a 502 BRAND_ANALYSIS_FAILED. +// ───────────────────────────────────────────────────────────────── + +/** Client timeout for the slow analysis calls (create / add sources / sync). */ +export const BRAND_ANALYSIS_TIMEOUT_MS = 180_000; + +/** Sections `GET /brand/{section}` accepts. */ +export const BRAND_SECTIONS = ["style", "profile", "voice"] as const; + +/** Body of `POST /brand` and `POST /brand/sources` (at least one field). */ +export interface BrandSourcesBody { + /** Website to scrape and analyse. */ + website_url?: string; + /** Brand info text, ≤10000 chars. */ + text?: string; + /** ≤50 public https documents (PDF/DOCX/TXT/Markdown); `name` ≤255. */ + files?: { url: string; name?: string }[]; + /** Account `id`s from `GET /accounts` — connected accounts of this workspace. */ + social_accounts?: string[]; +} + +/** + * GET /workspaces/{w}/brand — the brand object. A workspace without a brand + * answers 200 with `is_set_up: false`, empty sections and null timestamps. + */ +export function getBrand(c: Client, workspaceId: string) { + return c.get(`/workspaces/${workspaceId}/brand`); +} + +/** + * GET /workspaces/{w}/brand/{section} — `style` | `profile` | `voice`. + * Returns `{ schema_version, is_set_up, brand_
, updated_at }`. + */ +export function getBrandSection( + c: Client, + workspaceId: string, + section: (typeof BRAND_SECTIONS)[number], +) { + return c.get(`/workspaces/${workspaceId}/brand/${section}`); +} + +/** + * POST /workspaces/{w}/brand — build the brand by AI analysis ("Build My + * Brand Knowledge"). 201 with the brand. 409 BRAND_ALREADY_EXISTS when the + * workspace already has one; 422 BRAND_INSUFFICIENT_CONTENT; 502 + * BRAND_ANALYSIS_FAILED (nothing saved — retry). + */ +export function createBrand( + c: Client, + workspaceId: string, + body: BrandSourcesBody, +) { + return c.post(`/workspaces/${workspaceId}/brand`, { json: body }); +} + +/** + * PATCH /workspaces/{w}/brand — partial update of `brand_style`, + * `brand_profile`, `brand_voice` and/or `brand_enabled`. Only fields sent + * change; a list sent replaces that list; unknown keys are rejected. Creates + * the brand (without analysis) if the workspace has none. + */ +export function updateBrand( + c: Client, + workspaceId: string, + body: Record, +) { + return c.patch(`/workspaces/${workspaceId}/brand`, { json: body }); +} + +/** + * DELETE /workspaces/{w}/brand — permanent. Returns + * `{ deleted: true, brand_assets, source_materials, logo }`. 404 BRAND_NOT_FOUND. + */ +export function deleteBrand(c: Client, workspaceId: string) { + return c.delete(`/workspaces/${workspaceId}/brand`); +} + +/** + * POST /workspaces/{w}/brand/sources — add sources and AI-blend each into the + * existing brand. 201 `{ added: [source…], brand }`; each added source is + * `synced` or `unreachable`. 404 BRAND_NOT_FOUND; 422 VALIDATION_ERROR (incl. + * >50 sources total on `source_materials`); 422 BRAND_SOURCE_ADD_FAILED. + */ +export function addBrandSources( + c: Client, + workspaceId: string, + body: BrandSourcesBody, +) { + return c.post(`/workspaces/${workspaceId}/brand/sources`, { json: body }); +} + +/** + * DELETE /workspaces/{w}/brand/sources/{source_id} — returns + * `{ deleted: true, id, auto_reply_rules_affected: [rule names] }`. + * 404 BRAND_NOT_FOUND / BRAND_SOURCE_NOT_FOUND. + */ +export function deleteBrandSource( + c: Client, + workspaceId: string, + sourceId: string, +) { + return c.delete( + `/workspaces/${workspaceId}/brand/sources/${encodeURIComponent(sourceId)}`, + ); +} + +/** + * POST /workspaces/{w}/brand/sync — re-analyse ALL sources and overwrite the + * style/profile/voice. No body. 404 BRAND_NOT_FOUND; 422 + * BRAND_INSUFFICIENT_CONTENT; 502 BRAND_ANALYSIS_FAILED. + */ +export function syncBrand(c: Client, workspaceId: string) { + return c.post(`/workspaces/${workspaceId}/brand/sync`); +} + +/** GET /workspaces/{w}/brand/post-generation-settings */ +export function getBrandPostSettings(c: Client, workspaceId: string) { + return c.get(`/workspaces/${workspaceId}/brand/post-generation-settings`); +} + +/** + * PATCH /workspaces/{w}/brand/post-generation-settings — only fields sent + * change. Nothing saves until a `social_platform` is stored or sent (422 on + * `social_platform`). Creates the brand if the workspace has none. + */ +export function updateBrandPostSettings( + c: Client, + workspaceId: string, + body: Record, +) { + return c.patch( + `/workspaces/${workspaceId}/brand/post-generation-settings`, + { json: body }, + ); +} + // Re-export ContentStudioError for convenience in commands. export { ContentStudioError }; diff --git a/src/commands/brand.ts b/src/commands/brand.ts new file mode 100644 index 0000000..8ad8bf4 --- /dev/null +++ b/src/commands/brand.ts @@ -0,0 +1,619 @@ +/** + * Brand Knowledge — the workspace's brand (CONT-3684 branch). + * + * One brand per workspace: style, profile, voice, source materials and brand + * assets. ContentStudio's AI resolves it server-side for generation; these + * commands read and edit the same record as the app's Brand Knowledge editor. + * + * brand:create, brand:source-add and brand:sync run the app's brand analysis + * synchronously (up to ~2 minutes). They use a 180s client timeout + * (`--timeout` overrides) and no automatic retry, so a 502 + * BRAND_ANALYSIS_FAILED is reported instead of silently re-running an analysis. + */ + +import type { Argv } from "yargs"; + +import { + BRAND_ANALYSIS_TIMEOUT_MS, + BRAND_SECTIONS, + BrandSourcesBody, + addBrandSources, + createBrand, + deleteBrand, + deleteBrandSource, + getBrand, + getBrandPostSettings, + getBrandSection, + syncBrand, + updateBrand, + updateBrandPostSettings, +} from "../api"; +import { ConfigError } from "../errors"; +import * as out from "../output"; +import { + buildClient, + emitDryRun, + isDryRun, + parseJsonOption, + resolveWorkspace, + run, +} from "../cliCtx"; + +export const BRAND_SOCIAL_PLATFORMS = [ + "instagram", + "facebook", + "twitter", + "telegram", + "tiktok", + "gmb", + "threads", + "bluesky", + "linkedin", + "tumblr", +] as const; + +export const BRAND_LANGUAGES = [ + "English", + "Spanish", + "French", + "Portuguese", + "German", + "Italian", + "Dutch", + "Turkish", + "Indonesian", + "Tagalog", + "Swedish", + "Danish", + "Norwegian", + "Romanian", + "Polish", + "Finnish", + "Hungarian", + "Greek", + "Czech", + "Malay", + "Vietnamese", + "Chinese (Simplified)", + "Chinese (Traditional)", +] as const; + +export const BRAND_POST_TYPES = ["image", "text", "text_image"] as const; +export const BRAND_USAGE_LEVELS = ["none", "low", "medium", "high"] as const; +export const BRAND_ASPECT_RATIOS = [ + "8:1", + "4:1", + "21:9", + "16:9", + "3:2", + "4:3", + "5:4", + "1:1", + "4:5", + "3:4", + "2:3", + "9:16", + "1:4", + "1:8", +] as const; +export const BRAND_IMAGE_STYLES = [ + "none", + "abstract", + "oil-painting", + "neon-punk", + "app-icon", + "black-white", + "bokeh", + "cartoon", + "cinematic", + "cyberpunk", + "digital-watercolor", + "film-noir", + "film-poster", + "flat-design", + "futuristic", + "grunge", + "highly-detailed", + "isometric", + "minimalistic", + "photorealistic", + "pixel-art", + "polaroid", + "pop-art", + "retro-80s", + "steampunk", + "sticker", + "super-realistic", + "surrealism", + "tattoo", + "unreal-engine", + "vaporwave", +] as const; + +export function registerBrand(yargs: Argv): Argv { + return yargs + .command( + "brand:get", + "Read the workspace's brand. A workspace without one answers is_set_up: false (not an error).", + (y) => y, + run(async (_argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const data = await getBrand(client, wid); + out.emitSuccess(data, g, renderBrand); + }), + ) + .command( + "brand:section
", + "Read one part of the brand: style, profile or voice.", + (y) => + y.positional("section", { + type: "string", + choices: [...BRAND_SECTIONS], + demandOption: true, + }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const section = String(argv.section) as (typeof BRAND_SECTIONS)[number]; + const data = await getBrandSection(client, wid, section); + out.emitSuccess(data, g, (d: any) => { + out.status("Set up", d?.is_set_up ? "yes" : "no"); + out.status("Updated", String(d?.updated_at ?? "-")); + console.log(JSON.stringify(d?.[`brand_${section}`] ?? {}, null, 2)); + }); + }), + ) + .command( + "brand:create", + "Build the brand by AI analysis of the sources given (the app's \"Build My Brand Knowledge\"). Synchronous — can take ~2 minutes. Fails with 409 BRAND_ALREADY_EXISTS if the workspace has a brand.", + (y) => + applySourceOptions(y) + .option("timeout", timeoutOption()) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const body = sourcesBodyOf(argv); + const { cfg, client } = analysisClient(g, argv); + const wid = resolveWorkspace(cfg, g); + if (isDryRun(argv)) { + return emitDryRun( + g, + `POST /workspaces/${wid}/brand`, + body as Record, + "build the brand by AI analysis", + ); + } + const data = await createBrand(client, wid, body); + out.emitSuccess(data, g, (d: any) => { + out.success("Brand created."); + renderBrand(d); + out.info("Brand assets discovered by the analysis are added in the background shortly after."); + }); + }), + ) + .command( + "brand:update", + "Partially update the brand (creates it, without analysis, if none). Only fields sent change; a list sent replaces that list; unknown keys are rejected.", + (y) => + y + .option("brand-style", { + type: "string", + describe: + "JSON object → brand_style: {logo, colors:[{hex:\"#RRGGBB\", role:brand|background|text|accent}] (≤6; ≤1 each of brand/background/text), title_font, body_font, visual_identity_description}. logo \"\" or null clears it.", + }) + .option("brand-profile", { + type: "string", + describe: + "JSON object → brand_profile: {business_name, core_identity, market_positioning (≤10000 chars), competitors[], competitive_advantages[], primary_customer_segments[], primary_value_drivers[] (entries ≤200)}.", + }) + .option("brand-voice", { + type: "string", + describe: + "JSON object → brand_voice: {purpose, audience, voice_description (≤10000 chars), tone[], emotion[], character[], language[] (free text, entries ≤200)}.", + }) + .option("brand-enabled", { + type: "boolean", + describe: + "Whether AI generation uses the brand. --brand-enabled / --no-brand-enabled.", + }) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const body: Record = {}; + const style = argv["brand-style"] ?? argv.brandStyle; + if (style !== undefined) body.brand_style = parseJsonOption(style, "--brand-style"); + const profile = argv["brand-profile"] ?? argv.brandProfile; + if (profile !== undefined) body.brand_profile = parseJsonOption(profile, "--brand-profile"); + const voice = argv["brand-voice"] ?? argv.brandVoice; + if (voice !== undefined) body.brand_voice = parseJsonOption(voice, "--brand-voice"); + const enabled = argv["brand-enabled"] ?? argv.brandEnabled; + if (enabled !== undefined) body.brand_enabled = !!enabled; + + if (!Object.keys(body).length) { + throw new ConfigError( + "Pass at least one of --brand-style / --brand-profile / --brand-voice / --brand-enabled.", + ); + } + if (isDryRun(argv)) { + return emitDryRun(g, `PATCH /workspaces/${wid}/brand`, body, "update the brand"); + } + const data = await updateBrand(client, wid, body); + out.emitSuccess(data, g, (d: any) => { + out.success("Brand updated."); + renderBrand(d); + }); + }), + ) + .command( + "brand:delete", + "Permanently delete the brand with its brand-asset media, logo and source-material files. Not reversible.", + (y) => y.option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + if (isDryRun(argv)) { + return emitDryRun(g, `DELETE /workspaces/${wid}/brand`, {}, "delete the brand"); + } + const data = await deleteBrand(client, wid); + out.emitSuccess(data, g, (d: any) => { + out.success("Brand deleted."); + out.status("Brand assets removed", String(d?.brand_assets ?? 0)); + out.status("Source materials removed", String(d?.source_materials ?? 0)); + out.status("Logo removed", d?.logo ? "yes" : "no"); + }); + }), + ) + .command( + "brand:source-add", + "Add source materials and AI-blend each into the existing brand. Synchronous — allow ~2 minutes per source. Each added source comes back synced or unreachable.", + (y) => + applySourceOptions(y) + .option("timeout", timeoutOption()) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const body = sourcesBodyOf(argv); + const { cfg, client } = analysisClient(g, argv); + const wid = resolveWorkspace(cfg, g); + if (isDryRun(argv)) { + return emitDryRun( + g, + `POST /workspaces/${wid}/brand/sources`, + body as Record, + "add brand source materials", + ); + } + const data = await addBrandSources(client, wid, body); + out.emitSuccess(data, g, (d: any) => { + out.success("Source materials added."); + renderSources(d?.added ?? []); + }); + }), + ) + .command( + "brand:source-delete ", + "Delete one source material and the brand assets it produced. Names any inbox auto-reply rules it was detached from.", + (y) => + y + .positional("source_id", { type: "string", demandOption: true }) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const id = String(argv.source_id); + if (isDryRun(argv)) { + return emitDryRun( + g, + `DELETE /workspaces/${wid}/brand/sources/${id}`, + {}, + `delete brand source ${id}`, + ); + } + const data = await deleteBrandSource(client, wid, id); + out.emitSuccess(data, g, (d: any) => { + out.success(`Deleted brand source ${id}.`); + const rules: string[] = d?.auto_reply_rules_affected ?? []; + if (rules.length) { + out.warning(`Detached from auto-reply rules: ${rules.join(", ")}`); + } + }); + }), + ) + .command( + "brand:sync", + "Re-analyse ALL source materials and overwrite the brand style, profile and voice (the app's Sync). Synchronous — can take ~2 minutes.", + (y) => + y + .option("timeout", timeoutOption()) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const { cfg, client } = analysisClient(g, argv); + const wid = resolveWorkspace(cfg, g); + if (isDryRun(argv)) { + return emitDryRun( + g, + `POST /workspaces/${wid}/brand/sync`, + {}, + "re-sync every brand source", + ); + } + const data = await syncBrand(client, wid); + out.emitSuccess(data, g, (d: any) => { + out.success("Brand re-synced from its source materials."); + renderBrand(d); + }); + }), + ) + .command( + "brand:post-settings", + "Read the AI Content Library post generation defaults for this brand.", + (y) => y, + run(async (_argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const data = await getBrandPostSettings(client, wid); + out.emitSuccess(data, g, renderPostSettings); + }), + ) + .command( + "brand:post-settings-update", + "Update the post generation defaults (partial). Nothing saves until a social platform is stored or sent. Creates the brand if none.", + (y) => + y + .option("social-platform", { + type: "string", + choices: [...BRAND_SOCIAL_PLATFORMS], + describe: "→ social_platform. Required on the first save.", + }) + .option("language", { + type: "string", + choices: [...BRAND_LANGUAGES], + describe: "→ language (quote multi-word values, e.g. \"Chinese (Simplified)\").", + }) + .option("post-type", { + type: "string", + choices: [...BRAND_POST_TYPES], + describe: "→ post_type.", + }) + .option("number-of-posts", { + type: "number", + describe: "→ no_of_posts, 1–10.", + }) + .option("caption-length", { + type: "number", + describe: "→ caption_length, 20–200.", + }) + .option("emoji-usage", { + type: "string", + choices: [...BRAND_USAGE_LEVELS], + describe: "→ emoji_usage.", + }) + .option("hashtag-usage", { + type: "string", + choices: [...BRAND_USAGE_LEVELS], + describe: "→ hashtag_usage.", + }) + .option("aspect-ratio", { + type: "string", + choices: [...BRAND_ASPECT_RATIOS], + describe: "→ aspect_ratio.", + }) + .option("image-style", { + type: "string", + choices: [...BRAND_IMAGE_STYLES], + describe: "→ image_style.", + }) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const body: Record = {}; + const str = (flag: string, camel: string, key: string) => { + const v = argv[flag] ?? argv[camel]; + if (v !== undefined) body[key] = String(v); + }; + const int = (flag: string, camel: string, key: string, min: number, max: number) => { + const v = argv[flag] ?? argv[camel]; + if (v === undefined) return; + if (!Number.isInteger(v) || v < min || v > max) { + throw new ConfigError(`--${flag} must be an integer from ${min} to ${max} (got ${v}).`); + } + body[key] = v; + }; + str("social-platform", "socialPlatform", "social_platform"); + str("language", "language", "language"); + str("post-type", "postType", "post_type"); + int("number-of-posts", "numberOfPosts", "no_of_posts", 1, 10); + int("caption-length", "captionLength", "caption_length", 20, 200); + str("emoji-usage", "emojiUsage", "emoji_usage"); + str("hashtag-usage", "hashtagUsage", "hashtag_usage"); + str("aspect-ratio", "aspectRatio", "aspect_ratio"); + str("image-style", "imageStyle", "image_style"); + + if (!Object.keys(body).length) { + throw new ConfigError( + "Pass at least one setting (--social-platform, --language, --post-type, --number-of-posts, --caption-length, --emoji-usage, --hashtag-usage, --aspect-ratio, --image-style).", + ); + } + if (isDryRun(argv)) { + return emitDryRun( + g, + `PATCH /workspaces/${wid}/brand/post-generation-settings`, + body, + "update the brand's post generation settings", + ); + } + const data = await updateBrandPostSettings(client, wid, body); + out.emitSuccess(data, g, (d: any) => { + out.success("Post generation settings updated."); + renderPostSettings(d); + }); + }), + ); +} + +/** Source flags shared by brand:create and brand:source-add. */ +function applySourceOptions(y: Argv): Argv { + return y + .option("website-url", { + type: "string", + describe: "→ website_url. A website to scrape and analyse.", + }) + .option("text", { + type: "string", + describe: "→ text. Brand info, ≤10000 characters.", + }) + .option("file", { + type: "string", + array: true, + describe: + "→ files[].url. Public https URL of a PDF/DOCX/TXT/Markdown document. Repeatable (≤50 files in total).", + }) + .option("files", { + type: "string", + describe: + "JSON array → files, for documents with a display name: '[{\"url\":\"https://…/guide.pdf\",\"name\":\"Brand guide\"}]' (name ≤255). Combined with --file.", + }) + .option("social-account", { + type: "string", + array: true, + describe: + "→ social_accounts[]. An account `id` from accounts:list (connected facebook, twitter, instagram, linkedin, pinterest, telegram, youtube, tiktok, tumblr, gmb or bluesky account). Repeatable.", + }); +} + +function sourcesBodyOf(argv: any): BrandSourcesBody { + const body: BrandSourcesBody = {}; + const website = argv["website-url"] ?? argv.websiteUrl; + if (website !== undefined) body.website_url = String(website); + if (argv.text !== undefined) body.text = String(argv.text); + + const files: { url: string; name?: string }[] = []; + for (const url of (argv.file as string[] | undefined) ?? []) { + if (String(url)) files.push({ url: String(url) }); + } + if (argv.files !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(String(argv.files)); + } catch (e) { + throw new ConfigError(`--files: invalid JSON — ${(e as Error).message}`); + } + if (!Array.isArray(parsed)) { + throw new ConfigError("--files: JSON must be an array of {url, name?} objects."); + } + for (const f of parsed) { + if (!f || typeof f !== "object" || typeof (f as any).url !== "string") { + throw new ConfigError("--files: every entry needs a string `url`."); + } + const entry: { url: string; name?: string } = { url: (f as any).url }; + if ((f as any).name !== undefined) entry.name = String((f as any).name); + files.push(entry); + } + } + if (files.length) body.files = files; + + const accounts = ((argv["social-account"] ?? argv.socialAccount) as string[] | undefined) + ?.map(String) + .filter(Boolean); + if (accounts?.length) body.social_accounts = accounts; + + if (!Object.keys(body).length) { + throw new ConfigError( + "Pass at least one source: --website-url, --text, --file / --files, or --social-account.", + ); + } + return body; +} + +function timeoutOption() { + return { + type: "number" as const, + describe: `Client timeout in seconds (default ${BRAND_ANALYSIS_TIMEOUT_MS / 1000}). The analysis is synchronous and can take ~2 minutes.`, + }; +} + +/** + * A Client for the analysis calls: long timeout and no automatic retry, so a + * 502 BRAND_ANALYSIS_FAILED is reported once instead of re-running the analysis. + */ +function analysisClient(g: any, argv: any) { + const secs = argv.timeout; + let timeoutMs = BRAND_ANALYSIS_TIMEOUT_MS; + if (secs !== undefined) { + if (!Number.isFinite(secs) || secs <= 0) { + throw new ConfigError(`--timeout must be a positive number of seconds (got ${secs}).`); + } + timeoutMs = Math.round(secs * 1000); + } + return buildClient(g, { timeoutMs, retries: 0 }); +} + +function list(v: unknown): string { + return Array.isArray(v) && v.length ? v.join(", ") : "-"; +} + +function renderBrand(d: any): void { + if (!d?.is_set_up) { + out.info("This workspace has no brand set up yet. Create one with brand:create (AI analysis) or brand:update (by hand)."); + } + out.status("Set up", d?.is_set_up ? "yes" : "no"); + out.status("Brand enabled", d?.brand_enabled ? "yes" : "no"); + + const st = d?.brand_style ?? {}; + out.section("Style"); + out.status("Logo", String(st.logo ?? "-")); + out.status( + "Colors", + Array.isArray(st.colors) && st.colors.length + ? st.colors.map((c: any) => `${c.hex} (${c.role})`).join(", ") + : "-", + ); + out.status("Title font", String(st.title_font ?? "-")); + out.status("Body font", String(st.body_font ?? "-")); + + const pr = d?.brand_profile ?? {}; + out.section("Profile"); + out.status("Business name", String(pr.business_name ?? "-")); + out.status("Competitors", list(pr.competitors)); + + const vo = d?.brand_voice ?? {}; + out.section("Voice"); + out.status("Tone", list(vo.tone)); + out.status("Emotion", list(vo.emotion)); + out.status("Character", list(vo.character)); + out.status("Language", list(vo.language)); + + const sources = d?.source_materials ?? []; + if (Array.isArray(sources) && sources.length) { + out.section("Source materials"); + renderSources(sources); + } + out.status("Brand assets", String((d?.brand_assets ?? []).length)); + out.status("Updated", String(d?.updated_at ?? "-")); +} + +function renderSources(items: any[]): void { + out.table( + ["ID", "Type", "Name", "Status", "Last synced"], + items.map((s: any) => [ + String(s.id ?? "-"), + s.type ?? "-", + s.name ?? "-", + s.status ?? "-", + s.last_synced_at ?? "-", + ]), + ); +} + +function renderPostSettings(d: any): void { + out.status("Social platform", String(d?.social_platform ?? "- (not set — required before saving)")); + out.status("Language", String(d?.language ?? "-")); + out.status("Post type", String(d?.post_type ?? "-")); + out.status("Posts", String(d?.no_of_posts ?? "-")); + out.status("Caption length", String(d?.caption_length ?? "-")); + out.status("Emoji usage", String(d?.emoji_usage ?? "-")); + out.status("Hashtag usage", String(d?.hashtag_usage ?? "-")); + out.status("Aspect ratio", String(d?.aspect_ratio ?? "-")); + out.status("Image style", String(d?.image_style ?? "-")); +} diff --git a/src/commands/images.ts b/src/commands/images.ts index 61683f0..4100a95 100644 --- a/src/commands/images.ts +++ b/src/commands/images.ts @@ -335,7 +335,8 @@ export function registerImages(yargs: Argv): Argv { if (!d.configured || !d.enabled) { out.info( "`images:generate --use-brand` will come back with `brand_applied: false` — " + - "that is not an error. Brand knowledge is set up in the ContentStudio web app.", + "that is not an error. Set the brand up with `brand:create` / `brand:update` " + + "(or in the ContentStudio web app); `brand:update --brand-enabled` turns it on.", ); } }); diff --git a/src/commands/webhooks.ts b/src/commands/webhooks.ts new file mode 100644 index 0000000..14af42f --- /dev/null +++ b/src/commands/webhooks.ts @@ -0,0 +1,425 @@ +/** + * Webhooks — outbound event subscriptions (CONT-3684). + * + * A webhook belongs to the API key's USER, not to a workspace: every + * workspace the user is a member of lists the same webhooks. The workspace + * (--workspace / active) only decides whose API credits the call uses — one + * credit per call. Each user can have 5 webhooks. + * + * The signing secret is returned ONLY by `webhooks:create` and + * `webhooks:rotate-secret`, once. Human output prints it with a "store it + * now" warning; JSON output carries it as `data.secret`. + */ + +import type { Argv } from "yargs"; + +import { + createWebhook, + deleteWebhook, + getWebhook, + listWebhookDeliveries, + listWebhookEventTypes, + listWebhooks, + rotateWebhookSecret, + updateWebhook, +} from "../api"; +import { ConfigError } from "../errors"; +import * as out from "../output"; +import { + buildClient, + emitDryRun, + isDryRun, + parseJsonOption, + resolveWorkspace, + run, +} from "../cliCtx"; + +const STATUSES = ["enabled", "disabled"] as const; +const DELIVERY_STATUSES = ["all", "successful", "failed"] as const; + +export function registerWebhooks(yargs: Argv): Argv { + return yargs + .command( + "webhooks:event-types", + "List the event types a webhook can subscribe to (pass `value` to --event-type).", + (y) => y, + run(async (_argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const data = await listWebhookEventTypes(client, wid); + const items = Array.isArray(data) ? data : []; + out.emitSuccess(data, g, () => + out.table( + ["Value", "Group", "Label", "Payload schema"], + items.map((e: any) => [ + e.value ?? "-", + e.group ?? "-", + e.label ?? "-", + e.payload_schema ?? "-", + ]), + ), + ); + }), + ) + .command( + "webhooks:list", + "List your webhooks (user-owned — the same list in every workspace), with used/limit.", + (y) => y, + run(async (_argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const resp = await listWebhooks(client, wid); + out.emitSuccess(resp, g, (d) => { + out.info(`${d.used} of ${d.limit} webhooks used`); + out.table( + ["ID", "Name", "URL", "Status", "Events", "Last delivery"], + d.data.map((w: any) => [ + String(w.id ?? w._id ?? "-"), + w.name ?? "-", + w.url ?? "-", + w.status ?? "-", + String((w.event_types ?? []).length), + w.last_delivery_status + ? `${w.last_delivery_status} @ ${w.last_delivery_at ?? "-"}` + : "-", + ]), + ); + }); + }), + ) + .command( + "webhooks:get ", + "Read one webhook. Never includes the secret — only `has_secret`.", + (y) => y.positional("webhook_id", { type: "string", demandOption: true }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const data = await getWebhook(client, wid, String(argv.webhook_id)); + out.emitSuccess(data, g, renderWebhook); + }), + ) + .command( + "webhooks:create", + "Create a webhook. The backend refuses internal URLs and pings the URL (must answer 2xx within 5s) before saving. Prints the signing secret ONCE.", + (y) => + applyWritableOptions(y) + .option("secret", { + type: "string", + describe: + "Your own signing secret, `whsec_`. Omit it and one is generated.", + }) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const eventTypes = eventTypesOf(argv); + if (!argv.url || !eventTypes?.length) { + throw new ConfigError( + "--url (https) and at least one --event-type are required. See `webhooks:event-types`.", + ); + } + const body: { + url: string; + event_types: string[]; + name?: string; + secret?: string; + custom_headers?: Record; + } = { url: String(argv.url), event_types: eventTypes }; + if (argv.name !== undefined) body.name = String(argv.name); + if (argv.secret !== undefined) body.secret = String(argv.secret); + const headers = customHeadersOf(argv); + if (headers !== undefined) body.custom_headers = headers; + + if (isDryRun(argv)) { + // Never echo a caller-supplied secret into transcripts/logs. + const shown = body.secret ? { ...body, secret: "whsec_…(redacted)" } : body; + return emitDryRun( + g, + `POST /workspaces/${wid}/webhooks`, + shown, + "create webhook", + ); + } + const data = await createWebhook(client, wid, body); + out.emitSuccess(data, g, (d: any) => { + out.success("Webhook created."); + out.status("ID", String(d?.id ?? d?._id ?? "-")); + out.status("URL", String(d?.url ?? "-")); + out.status("Status", String(d?.status ?? "-")); + out.status("Events", (d?.event_types ?? []).join(", ") || "-"); + renderSecret(d); + }); + }), + ) + .command( + "webhooks:update ", + "Update a webhook (partial). A new --url is re-checked and pinged like create.", + (y) => + applyWritableOptions( + y.positional("webhook_id", { type: "string", demandOption: true }), + ) + .option("status", { + type: "string", + choices: [...STATUSES], + describe: + "disabled pauses deliveries; enabled resumes them (also resumes a `disabled_by_system` webhook and resets its failure count).", + }) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const id = String(argv.webhook_id); + const body: Record = {}; + if (argv.url !== undefined) body.url = String(argv.url); + const eventTypes = eventTypesOf(argv); + if (eventTypes !== undefined) body.event_types = eventTypes; + if (argv.name !== undefined) body.name = String(argv.name); + const headers = customHeadersOf(argv); + if (headers !== undefined) body.custom_headers = headers; + if (argv.status !== undefined) body.status = String(argv.status); + + if (!Object.keys(body).length) { + throw new ConfigError( + "Pass at least one of --url / --event-type / --name / --custom-headers / --status.", + ); + } + if (isDryRun(argv)) { + return emitDryRun( + g, + `PUT /workspaces/${wid}/webhooks/${id}`, + body, + `update webhook ${id}`, + ); + } + const data = await updateWebhook(client, wid, id, body); + out.emitSuccess(data, g, (d: any) => { + out.success(`Updated webhook ${id}.`); + out.status("Status", String(d?.status ?? "-")); + }); + }), + ) + .command( + "webhooks:disable ", + "Pause deliveries (PUT status=disabled). Reversible with webhooks:enable.", + (y) => + y + .positional("webhook_id", { type: "string", demandOption: true }) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => setStatus(argv, g, "disabled")), + ) + .command( + "webhooks:enable ", + "Resume deliveries (PUT status=enabled). Also resumes a `disabled_by_system` webhook.", + (y) => + y + .positional("webhook_id", { type: "string", demandOption: true }) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => setStatus(argv, g, "enabled")), + ) + .command( + "webhooks:delete ", + "Delete a webhook. Prefer webhooks:disable when the pause is temporary — deleting is not reversible.", + (y) => + y + .positional("webhook_id", { type: "string", demandOption: true }) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const id = String(argv.webhook_id); + if (isDryRun(argv)) { + return emitDryRun( + g, + `DELETE /workspaces/${wid}/webhooks/${id}`, + {}, + `delete webhook ${id}`, + ); + } + const data = await deleteWebhook(client, wid, id); + out.emitSuccess(data ?? null, g, () => + out.success(`Deleted webhook ${id}.`), + ); + }), + ) + .command( + "webhooks:rotate-secret ", + "Issue a new signing secret (printed ONCE). The previous secret keeps signing for 24h.", + (y) => + y + .positional("webhook_id", { type: "string", demandOption: true }) + .option("dry-run", { type: "boolean", default: false }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const id = String(argv.webhook_id); + if (isDryRun(argv)) { + return emitDryRun( + g, + `POST /workspaces/${wid}/webhooks/${id}/rotate-secret`, + {}, + `rotate the signing secret of webhook ${id}`, + ); + } + const data = await rotateWebhookSecret(client, wid, id); + out.emitSuccess(data, g, (d: any) => { + out.success(`Rotated the signing secret of webhook ${id}.`); + renderSecret(d); + if (d?.previous_secret_expires_at) { + out.info( + `The previous secret keeps signing until ${d.previous_secret_expires_at} — deploy the new one to your receiver before then.`, + ); + } + }); + }), + ) + .command( + "webhooks:deliveries ", + "Delivery log, newest first — the \"why did events stop arriving\" diagnostic. Kept 30 days.", + (y) => + y + .positional("webhook_id", { type: "string", demandOption: true }) + .option("status", { + type: "string", + choices: [...DELIVERY_STATUSES], + describe: + "all (default; includes retrying + skipped_out_of_credits), successful (delivered only), failed (failed only).", + }) + .option("event-type", { + type: "string", + describe: "Only this event type, e.g. post.published.", + }) + .option("from", { + type: "string", + describe: "ISO 8601 lower bound. No offset = UTC.", + }) + .option("to", { + type: "string", + describe: "ISO 8601 upper bound. No offset = UTC.", + }) + .option("search", { + type: "string", + describe: "An exact event ID, or part of an event type.", + }) + .option("page", { type: "number", describe: "Page number (default 1)." }) + .option("per-page", { type: "number", describe: "1–100 (default 25)." }), + run(async (argv: any, g) => { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const resp = await listWebhookDeliveries( + client, + wid, + String(argv.webhook_id), + { + status: argv.status, + event_type: argv["event-type"] ?? argv.eventType, + from: argv.from, + to: argv.to, + search: argv.search, + page: argv.page, + per_page: argv["per-page"] ?? argv.perPage, + }, + ); + const items = (resp.data as any[]) ?? []; + out.emitSuccess( + resp.data, + g, + () => + out.table( + ["When (UTC)", "Event", "Outcome", "HTTP", "ms", "Test", "Event ID"], + items.map((d) => [ + d.created_at ?? "-", + d.event_type ?? "-", + d.outcome ?? "-", + d.response_status == null ? "-" : String(d.response_status), + d.duration_ms == null ? "-" : String(d.duration_ms), + d.is_test ? "yes" : "no", + d.event_id ?? "-", + ]), + ), + { pagination: resp.pagination }, + ); + }), + ); +} + +/** Options shared by create and update. */ +function applyWritableOptions(y: Argv): Argv { + return y + .option("url", { + type: "string", + describe: + "https endpoint, publicly reachable. Internal addresses are refused; the URL is pinged and must answer 2xx within 5s.", + }) + .option("event-type", { + type: "string", + array: true, + describe: + "Event to subscribe to (a `value` from webhooks:event-types). Repeatable → event_types[]. On update it REPLACES the list.", + }) + .option("name", { type: "string", describe: "Display name, ≤120 chars." }) + .option("custom-headers", { + type: "string", + describe: + "JSON object of string headers sent with every delivery → custom_headers, e.g. '{\"X-Tenant\":\"acme\"}'. Cannot replace the signing/system headers.", + }); +} + +function eventTypesOf(argv: any): string[] | undefined { + const raw = (argv["event-type"] ?? argv.eventType) as string[] | undefined; + if (raw === undefined) return undefined; + return raw.map(String).filter(Boolean); +} + +function customHeadersOf(argv: any): Record | undefined { + const raw = argv["custom-headers"] ?? argv.customHeaders; + if (raw === undefined) return undefined; + return parseJsonOption(raw, "--custom-headers"); +} + +async function setStatus( + argv: any, + g: any, + status: (typeof STATUSES)[number], +): Promise { + const { cfg, client } = buildClient(g); + const wid = resolveWorkspace(cfg, g); + const id = String(argv.webhook_id); + const body = { status }; + if (isDryRun(argv)) { + return emitDryRun( + g, + `PUT /workspaces/${wid}/webhooks/${id}`, + body, + `${status === "enabled" ? "enable" : "disable"} webhook ${id}`, + ); + } + const data = await updateWebhook(client, wid, id, body); + out.emitSuccess(data, g, () => + out.success(`Webhook ${id} ${status === "enabled" ? "enabled" : "disabled"}.`), + ); +} + +function renderSecret(d: any): void { + if (!d?.secret) return; + out.warning( + "Store this signing secret NOW — it will not be shown again (list/get only report has_secret).", + ); + out.status("Secret", String(d.secret)); +} + +function renderWebhook(d: any): void { + out.success(`Webhook ${d?.name ?? d?.id ?? "-"}`); + out.status("ID", String(d?.id ?? d?._id ?? "-")); + out.status("URL", String(d?.url ?? "-")); + out.status("Status", String(d?.status ?? "-")); + out.status("Events", (d?.event_types ?? []).join(", ") || "-"); + out.status("Has secret", d?.has_secret ? "yes" : "no"); + const headers = d?.custom_headers; + if (headers && typeof headers === "object" && Object.keys(headers).length) { + out.status("Custom headers", JSON.stringify(headers)); + } + out.status("Last delivery", String(d?.last_delivery_status ?? "-")); + out.status("Last delivery at", String(d?.last_delivery_at ?? "-")); + out.status("Created", String(d?.created_at ?? "-")); + out.status("Updated", String(d?.updated_at ?? "-")); +} diff --git a/src/index.ts b/src/index.ts index c45ddc8..8a2e638 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { registerAnalytics } from "./commands/analytics"; import { registerAnalyticsReports } from "./commands/analyticsReports"; import { registerApprovalWorkflows } from "./commands/approvalWorkflows"; import { registerAuth } from "./commands/auth"; +import { registerBrand } from "./commands/brand"; import { registerComments } from "./commands/comments"; import { registerContentCategories } from "./commands/contentCategories"; import { registerCrud } from "./commands/crud"; @@ -30,6 +31,7 @@ import { registerMedia } from "./commands/media"; import { registerPlannerShareLinks } from "./commands/plannerShareLinks"; import { registerPosts } from "./commands/posts"; import { registerScheduling } from "./commands/scheduling"; +import { registerWebhooks } from "./commands/webhooks"; import { registerWorkspaces } from "./commands/workspaces"; import { maybeNotifyUpdate } from "./updateCheck"; @@ -69,6 +71,8 @@ cli = registerCrud(cli); cli = registerContentCategories(cli); cli = registerApprovalWorkflows(cli); cli = registerPlannerShareLinks(cli); +cli = registerWebhooks(cli); +cli = registerBrand(cli); cli = registerConnect(cli); cli = registerFacebook(cli); cli = registerMedia(cli); diff --git a/tests/api.test.ts b/tests/api.test.ts index e810239..075c6d0 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,6 +1,30 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import nock from "nock"; +import { + addBrandSources, + createBrand, + deleteBrand, + deleteBrandSource, + getBrand, + getBrandPostSettings, + getBrandSection, + syncBrand, + updateBrand, + updateBrandPostSettings, +} from "../src/api"; + +import { + createWebhook, + deleteWebhook, + getWebhook, + listWebhookDeliveries, + listWebhookEventTypes, + listWebhooks, + rotateWebhookSecret, + updateWebhook, +} from "../src/api"; + import { Client, addBlueskyAccount, @@ -1914,6 +1938,276 @@ describe("Planner share links", () => { }); }); +describe("Webhooks", () => { + it("listWebhookEventTypes unwraps the flat data array", async () => { + nock(BASE) + .get(`${PATH}/workspaces/ws-1/webhooks/event-types`) + .reply( + 200, + envelope([ + { + value: "post.published", + label: "Post published", + group: "posts", + description: "x", + payload_schema: "WebhookPostEventData", + }, + ]), + ); + const data: any = await listWebhookEventTypes(mkClient(), "ws-1"); + expect(data[0].value).toBe("post.published"); + }); + + it("listWebhooks keeps used/limit off the flat envelope", async () => { + nock(BASE) + .get(`${PATH}/workspaces/ws-1/webhooks`) + .reply(200, { + status: true, + message: "ok", + used: 1, + limit: 5, + data: [{ id: "wh-1", status: "enabled", has_secret: true }], + }); + const resp = await listWebhooks(mkClient(), "ws-1"); + expect(resp.used).toBe(1); + expect(resp.limit).toBe(5); + expect(resp.data[0].id).toBe("wh-1"); + }); + + it("createWebhook POSTs the contract body and returns the one-time secret", async () => { + let sent: any; + nock(BASE) + .post(`${PATH}/workspaces/ws-1/webhooks`, (b) => { + sent = b; + return true; + }) + .reply(201, envelope({ id: "wh-1", has_secret: true, secret: "whsec_abc" })); + const data: any = await createWebhook(mkClient(), "ws-1", { + url: "https://example.com/hook", + event_types: ["post.published", "post.failed"], + name: "Prod", + custom_headers: { "X-Tenant": "acme" }, + }); + expect(sent).toEqual({ + url: "https://example.com/hook", + event_types: ["post.published", "post.failed"], + name: "Prod", + custom_headers: { "X-Tenant": "acme" }, + }); + expect(data.secret).toBe("whsec_abc"); + }); + + it("createWebhook surfaces WEBHOOK_PREFLIGHT_FAILED as a ValidationError", async () => { + nock(BASE) + .post(`${PATH}/workspaces/ws-1/webhooks`) + .reply(422, { + status: false, + message: "The URL did not answer the ping.", + error_code: "WEBHOOK_PREFLIGHT_FAILED", + }); + const err: any = await createWebhook(mkClient(), "ws-1", { + url: "https://example.com/hook", + event_types: ["post.published"], + }).catch((e) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err.payload.error_code).toBe("WEBHOOK_PREFLIGHT_FAILED"); + }); + + it("get / update / delete address the webhook id", async () => { + nock(BASE) + .get(`${PATH}/workspaces/ws-1/webhooks/wh-1`) + .reply(200, envelope({ id: "wh-1", status: "enabled" })); + const got: any = await getWebhook(mkClient(), "ws-1", "wh-1"); + expect(got.id).toBe("wh-1"); + + let sent: any; + nock(BASE) + .put(`${PATH}/workspaces/ws-1/webhooks/wh-1`, (b) => { + sent = b; + return true; + }) + .reply(200, envelope({ id: "wh-1", status: "disabled" })); + const upd: any = await updateWebhook(mkClient(), "ws-1", "wh-1", { + status: "disabled", + }); + expect(sent).toEqual({ status: "disabled" }); + expect(upd.status).toBe("disabled"); + + nock(BASE) + .delete(`${PATH}/workspaces/ws-1/webhooks/wh-1`) + .reply(200, envelope(null)); + await deleteWebhook(mkClient(), "ws-1", "wh-1"); + expect(nock.isDone()).toBe(true); + }); + + it("rotateWebhookSecret POSTs and returns secret + grace expiry", async () => { + nock(BASE) + .post(`${PATH}/workspaces/ws-1/webhooks/wh-1/rotate-secret`) + .reply( + 200, + envelope({ + id: "wh-1", + secret: "whsec_new", + previous_secret_expires_at: "2026-10-07T09:12:44+00:00", + }), + ); + const data: any = await rotateWebhookSecret(mkClient(), "ws-1", "wh-1"); + expect(data.secret).toBe("whsec_new"); + expect(data.previous_secret_expires_at).toBe("2026-10-07T09:12:44+00:00"); + }); + + it("listWebhookDeliveries forwards filters and preserves pagination", async () => { + let query: any; + nock(BASE) + .get(`${PATH}/workspaces/ws-1/webhooks/wh-1/deliveries`) + .query((q) => { + query = q; + return true; + }) + .reply(200, { + status: true, + message: "ok", + current_page: 1, + per_page: 25, + total: 42, + last_page: 2, + from: 1, + to: 25, + data: [{ event_id: "e1", event_type: "post.published", outcome: "failed", response_status: 503 }], + }); + const resp = await listWebhookDeliveries(mkClient(), "ws-1", "wh-1", { + status: "failed", + event_type: "post.published", + per_page: 25, + }); + expect(query).toEqual({ status: "failed", event_type: "post.published", per_page: "25" }); + expect((resp.data as any[])[0].outcome).toBe("failed"); + expect(resp.pagination?.has_more).toBe(true); + }); +}); + +describe("Brand Knowledge", () => { + it("getBrand / getBrandSection read the brand paths", async () => { + nock(BASE) + .get(`${PATH}/workspaces/ws-1/brand`) + .reply(200, envelope({ schema_version: 1, is_set_up: false, created_at: null })); + const brand: any = await getBrand(mkClient(), "ws-1"); + expect(brand.is_set_up).toBe(false); + + nock(BASE) + .get(`${PATH}/workspaces/ws-1/brand/voice`) + .reply(200, envelope({ schema_version: 1, is_set_up: true, brand_voice: { tone: ["Warm"] }, updated_at: "x" })); + const sec: any = await getBrandSection(mkClient(), "ws-1", "voice"); + expect(sec.brand_voice.tone).toEqual(["Warm"]); + }); + + it("createBrand POSTs the source body", async () => { + let sent: any; + nock(BASE) + .post(`${PATH}/workspaces/ws-1/brand`, (b) => { + sent = b; + return true; + }) + .reply(201, envelope({ is_set_up: true })); + const body = { + website_url: "https://acme.coffee", + text: "Small-batch roaster", + files: [{ url: "https://acme.coffee/guide.pdf", name: "Guide" }], + social_accounts: ["1329910333529595"], + }; + const data: any = await createBrand(mkClient(), "ws-1", body); + expect(sent).toEqual(body); + expect(data.is_set_up).toBe(true); + }); + + it("createBrand maps 409 BRAND_ALREADY_EXISTS and 502 BRAND_ANALYSIS_FAILED", async () => { + nock(BASE) + .post(`${PATH}/workspaces/ws-1/brand`) + .reply(409, { status: false, message: "exists", error_code: "BRAND_ALREADY_EXISTS" }); + const conflict: any = await createBrand(mkClient(), "ws-1", { text: "x" }).catch((e) => e); + expect(conflict).toBeInstanceOf(ConflictError); + expect(conflict.payload.error_code).toBe("BRAND_ALREADY_EXISTS"); + + nock(BASE) + .post(`${PATH}/workspaces/ws-1/brand`) + .reply(502, { status: false, message: "failed", error_code: "BRAND_ANALYSIS_FAILED" }); + const failed: any = await createBrand(mkClient(), "ws-1", { text: "x" }).catch((e) => e); + expect(failed).toBeInstanceOf(BackendError); + }); + + it("updateBrand PATCHes only the sections sent", async () => { + let sent: any; + nock(BASE) + .patch(`${PATH}/workspaces/ws-1/brand`, (b) => { + sent = b; + return true; + }) + .reply(200, envelope({ brand_enabled: false })); + await updateBrand(mkClient(), "ws-1", { + brand_voice: { tone: ["Bold"] }, + brand_enabled: false, + }); + expect(sent).toEqual({ brand_voice: { tone: ["Bold"] }, brand_enabled: false }); + }); + + it("deleteBrand / deleteBrandSource / syncBrand address the right paths", async () => { + nock(BASE) + .delete(`${PATH}/workspaces/ws-1/brand`) + .reply(200, envelope({ deleted: true, brand_assets: 2, source_materials: 1, logo: true })); + const del: any = await deleteBrand(mkClient(), "ws-1"); + expect(del.brand_assets).toBe(2); + + nock(BASE) + .delete(`${PATH}/workspaces/ws-1/brand/sources/src-1`) + .reply(200, envelope({ deleted: true, id: "src-1", auto_reply_rules_affected: ["Shipping"] })); + const src: any = await deleteBrandSource(mkClient(), "ws-1", "src-1"); + expect(src.auto_reply_rules_affected).toEqual(["Shipping"]); + + nock(BASE) + .delete(`${PATH}/workspaces/ws-1/brand/sources/missing`) + .reply(404, { status: false, message: "nope", error_code: "BRAND_SOURCE_NOT_FOUND" }); + await expect(deleteBrandSource(mkClient(), "ws-1", "missing")).rejects.toBeInstanceOf(NotFoundError); + + nock(BASE) + .post(`${PATH}/workspaces/ws-1/brand/sync`) + .reply(200, envelope({ is_set_up: true })); + await syncBrand(mkClient(), "ws-1"); + expect(nock.isDone()).toBe(true); + }); + + it("addBrandSources POSTs to /brand/sources and returns added + brand", async () => { + let sent: any; + nock(BASE) + .post(`${PATH}/workspaces/ws-1/brand/sources`, (b) => { + sent = b; + return true; + }) + .reply(201, envelope({ added: [{ id: "s1", type: "website", status: "synced" }], brand: { is_set_up: true } })); + const data: any = await addBrandSources(mkClient(), "ws-1", { website_url: "https://acme.coffee" }); + expect(sent).toEqual({ website_url: "https://acme.coffee" }); + expect(data.added[0].status).toBe("synced"); + expect(data.brand.is_set_up).toBe(true); + }); + + it("post generation settings GET + PATCH", async () => { + nock(BASE) + .get(`${PATH}/workspaces/ws-1/brand/post-generation-settings`) + .reply(200, envelope({ social_platform: null, language: "English", no_of_posts: 10 })); + const got: any = await getBrandPostSettings(mkClient(), "ws-1"); + expect(got.social_platform).toBeNull(); + + let sent: any; + nock(BASE) + .patch(`${PATH}/workspaces/ws-1/brand/post-generation-settings`, (b) => { + sent = b; + return true; + }) + .reply(200, envelope({ social_platform: "linkedin", no_of_posts: 5 })); + await updateBrandPostSettings(mkClient(), "ws-1", { social_platform: "linkedin", no_of_posts: 5 }); + expect(sent).toEqual({ social_platform: "linkedin", no_of_posts: 5 }); + }); +}); + describe("Single post read + workspace limits", () => { it("getPost GETs one post and exposes the read-side repeat block", async () => { nock(BASE)