Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion api_docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,9 @@ A message run marks the repository as posted as soon as **any** integration succ

The repository text is fetched per integration in that integration's configured `text_language`, and one image is generated for the whole retry when any integration has `socialify_image` enabled. No Pushover notification is sent: a manual retry is already being watched by whoever triggered it.

Retries are serialised — a second one waits for the first, so a double-clicked button cannot publish twice. An item that is still unposted is marked as posted only when **every** requested integration succeeded; marking it after a partial retry would drop it out of the queue again, which is the failure this endpoint repairs.
Retries are serialised against each other, against [`/api/message/publish`](#apimessagepublish) and against the `message` cron — a double-clicked button cannot publish twice, and a cron run cannot mark an item it never sent. Unlike publish-now, a retry **waits** for whatever holds that lock instead of refusing: it is a repair action, and refusing it would leave the connectors that failed unrepaired. The wait is unbounded and there is no request timeout, so a retry issued during a cron run can hold its connection for the length of that run (minutes, in the worst case described under [`/api/message/publish`](#apimessagepublish)); repeated clicks queue up behind it rather than failing fast.

An item that is still unposted is marked as posted only when **every** requested integration succeeded; marking it after a partial retry would drop it out of the queue again, which is the failure this endpoint repairs.

**Curl Example:**

Expand Down Expand Up @@ -516,6 +518,8 @@ curl -X POST \
- `message`: The text recorded in cron history
- `succeeded` / `failed`: Integration names per outcome
- `outcomes`: Per-integration detail, with an `error` string for every failure
- `posted`: Whether the item was marked as published and left the publication queue
- `posted_error`: Present only when that marking failed — the item went out but stayed in the queue, so the scheduled run will publish it again

**Response Example:**

Expand All @@ -539,6 +543,75 @@ Every retry is recorded in cron history under the `message` name with `details.m
- 401: Unauthorized - Invalid or missing Bearer token
- 500: Internal Server Error - API configurations not loaded, or the repository could not be resolved

### /api/message/publish

**Endpoint:** `/api/message/publish`

**Method:** `POST`

**Description:** Publish one repository to every enabled integration immediately, instead of waiting for its turn in the publication queue.

It exists because a post that is lost *after* publication — a bad record deleted from the queue once it was already sent — cannot be recovered by promoting anything: the item is gone from the queue. Promoting a repository (content-alchemist's `/promote-repository/`) only moves it to the head of the queue; this endpoint publishes it now.

There is no `apis` parameter: the run targets every integration whose `api_configs` row has `enabled = true`, resolved server-side at request time. A dashboard's cached configuration must not decide what actually gets published.

The repository text is fetched per integration in that integration's configured `text_language`, and one image is generated for the whole run when any integration has `socialify_image` enabled. No Pushover notification is sent — whoever triggered the run is watching it.

Unlike the message cron this does **not** revalidate the repository URL and delete dead ones: the cron picks blindly from the queue, while this publishes the row a human chose.

**Marking as posted:** the item is marked as published as soon as **any** integration accepts it, matching the message cron (and unlike [`/api/message/retry`](#apimessageretry), which requires every requested integration to succeed). The run is recorded in cron history as a manual one, so the integrations that failed are finished off from there with the retry endpoint.

**Concurrency:** serialised against the message cron and the retry endpoint. Rather than queueing behind a cron run that can take minutes, it refuses with `409`. A repository that is already published is refused with `409` as well, before any integration is contacted — a stale dashboard row must not publish the same post twice.

**Duration:** the request is **synchronous** and can take minutes — image generation retries for up to ~63 s, the Threads connector alone is configured with a 90 s timeout, and every integration adds its own repository lookup. Any reverse proxy in front of content-maestro needs a matching read timeout, or the browser gets a `504` while the publication keeps running.

**Curl Example:**

```bash
curl -X POST \
'http://localhost:8080/api/message/publish' \
-H 'Authorization: Bearer <API_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://github.com/resemble-ai/chatterbox"
}'
```

**Request Parameters:**

| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------------------------------------------------------------------ |
| `url` | string | Yes | Repository to publish. There is no fallback: "publish something now" is never a safe guess. |

**Response Structure:** identical to [`/api/message/retry`](#apimessageretry) — `url`, `status`, `message`, `succeeded`, `failed`, `outcomes`, `posted`, `posted_error`.

**Response Example:**

```json
{
"url": "https://github.com/resemble-ai/chatterbox",
"status": 2,
"message": "Manual publish: https://github.com/resemble-ai/chatterbox sent to: telegram. Failed: threads. Errors: threads: API request failed with status 500",
"succeeded": ["telegram"],
"failed": ["threads"],
"outcomes": [
{ "api_name": "telegram", "success": true },
{ "api_name": "threads", "success": false, "error": "API request failed with status 500" }
],
"posted": true
}
```

Every run is recorded in cron history under the `message` name with `details.manual = true`, so [`/api/cron-history`](#apicron-history) shows it alongside scheduled runs — and its `details.failed` list is what the retry endpoint works from.

**Status Codes:**

- 200: The publication ran. Individual integration failures are reported in `outcomes`, not in the status code
- 400: Bad Request - Invalid body or a missing `url`
- 401: Unauthorized - Invalid or missing Bearer token
- 409: Conflict - Another publication is already running, no integration is enabled, or the repository is already published
- 500: Internal Server Error - API configurations not loaded, or the repository could not be resolved

### /api/api-configs

**Endpoint:** `/api/api-configs`
Expand Down
1 change: 1 addition & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ func main() {
mux.Handle("/api/prompt-settings", middleware.LoggingMiddleware(middleware.CorsMiddleware(middleware.AuthMiddleware(http.HandlerFunc(cronAPI.HandlePromptSettings)))))
mux.Handle("/api/cron-history", middleware.LoggingMiddleware(middleware.CorsMiddleware(middleware.AuthMiddleware(http.HandlerFunc(cronAPI.GetCronHistory)))))
mux.Handle("/api/message/retry", middleware.LoggingMiddleware(middleware.CorsMiddleware(middleware.AuthMiddleware(http.HandlerFunc(cronAPI.RetryMessagePost)))))
mux.Handle("/api/message/publish", middleware.LoggingMiddleware(middleware.CorsMiddleware(middleware.AuthMiddleware(http.HandlerFunc(cronAPI.PublishMessageNow)))))
mux.Handle("/api/api-configs", middleware.LoggingMiddleware(middleware.CorsMiddleware(middleware.AuthMiddleware(http.HandlerFunc(cronAPI.HandleAPIConfigs)))))
mux.Handle("/api/api-configs/", middleware.LoggingMiddleware(middleware.CorsMiddleware(middleware.AuthMiddleware(http.HandlerFunc(cronAPI.HandleAPIConfig)))))

Expand Down
7 changes: 7 additions & 0 deletions internal/models/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,10 @@ type RetryMessageRequest struct {
APIs []string `json:"apis"`
URL string `json:"url"`
}

// PublishMessageRequest asks for one repository to be published immediately to
// every enabled integration. URL is required: there is no sensible fallback for
// "publish something now".
type PublishMessageRequest struct {
URL string `json:"url"`
}
15 changes: 14 additions & 1 deletion internal/repository/get_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package repository
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -58,6 +59,12 @@ func GetRepository(limit int, posted bool, sort_order, sort_by string, textLangu
})
}

// ErrRepositoryNotFound reports that content-alchemist has no repository for the
// requested identifier. It is a sentinel so HTTP callers can answer 404 for what
// is a client mistake - a typo'd or already deleted url - instead of 500, which
// would be indistinguishable from content-alchemist itself being down.
var ErrRepositoryNotFound = errors.New("repository not found")

// GetRepositoryByURL fetches one repository by its url regardless of its posted
// state. Used when a specific publication has to be re-sent to a connector, so
// the item must not be looked up through the publication queue.
Expand All @@ -75,7 +82,7 @@ func GetRepositoryByURL(url, textLanguage string) (*Item, error) {
}

if len(response.Data.Items) == 0 {
return nil, fmt.Errorf("repository %s not found", url)
return nil, fmt.Errorf("%w: %s", ErrRepositoryNotFound, url)
}

item := response.Data.Items[0]
Expand Down Expand Up @@ -137,6 +144,12 @@ func makeRepositoryRequest(payload getRepositoryRequest) (*repositoryResponse, e
// A rejected request must surface as an error. Decoding it as a normal
// payload leaves Items empty, which callers used to report as "no items
// available" and hid the real cause.
if resp.StatusCode == http.StatusNotFound {
// content-alchemist answers 404 for an identifier it does not know, which is
// a client mistake rather than a failure of either service.
return nil, fmt.Errorf("%w: %s", ErrRepositoryNotFound, errorDetail(respBody))
}

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("content-alchemist error (status %d): %s", resp.StatusCode, errorDetail(respBody))
}
Expand Down
Loading
Loading