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
72 changes: 71 additions & 1 deletion api_docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ Note that the `status` field uses integer status codes:
Structure:

- `data`: Array of cron history records
- `details`: Present on message runs recorded after this field was introduced. Holds the item that was published and where it landed: `url`, `sent`, `failed`, and `manual` (true for runs triggered through [`/api/message/retry`](#apimessageretry)). Absent for older records and for collect runs.
- `pagination`: Pagination metadata object containing:
- `total_count`: Total number of records matching the filters
- `current_page`: Current page number
Expand Down Expand Up @@ -368,7 +369,12 @@ Structure:
"name": "message",
"timestamp": "2024-03-15T10:10:00Z",
"status": 2,
"output": "Message sent to: telegram. Failed: bluesky"
"output": "Message sent to: telegram. Failed: bluesky",
"details": {
"url": "https://github.com/resemble-ai/chatterbox",
"sent": ["telegram"],
"failed": ["bluesky"]
}
}
],
"pagination": {
Expand Down Expand Up @@ -469,6 +475,70 @@ curl -H "Authorization: Bearer <API_TOKEN>" \
- 401: Unauthorized - Invalid or missing Bearer token
- 500: Internal Server Error - Database or server error

### /api/message/retry

**Endpoint:** `/api/message/retry`

**Method:** `POST`

**Description:** Re-send an already published repository to the integrations that did not receive it.

A message run marks the repository as posted as soon as **any** integration succeeds, which drops the item out of the publication queue. The connectors that failed can therefore never recover it on the next run — this endpoint is the way to finish such a partial publication by hand.

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.

**Curl Example:**

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

**Request Parameters:**

| Parameter | Type | Required | Description |
| --------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `apis` | string[] | Yes | Names of the integrations to send to, as configured in`/api/api-configs`. Blanks and duplicates are ignored. |
| `url` | string | No | Repository to publish. When omitted the most recently published repository is used, which is only a guess at what a partial run consumed: a run that failed for *every* integration never marked its item as posted, so the guess resolves to the previous one. Callers that know the item — the dashboard reads it from the run details — should always pass it. |

**Response Structure:**

- `url`: The repository that was published
- `status`: `0` (nothing sent), `1` (all sent), `2` (partially sent) — the same codes as cron history
- `message`: The text recorded in cron history
- `succeeded` / `failed`: Integration names per outcome
- `outcomes`: Per-integration detail, with an `error` string for every failure

**Response Example:**

```json
{
"url": "https://github.com/resemble-ai/chatterbox",
"status": 1,
"message": "Manual retry: https://github.com/resemble-ai/chatterbox sent to: threads",
"succeeded": ["threads"],
"failed": null,
"outcomes": [{ "api_name": "threads", "success": true }]
}
```

Every retry is recorded in cron history under the `message` name with `details.manual = true`, so [`/api/cron-history`](#apicron-history) shows it alongside scheduled runs.

**Status Codes:**

- 200: The retry ran. Individual integration failures are reported in `outcomes`, not in the status code
- 400: Bad Request - Invalid body or an empty `apis` list
- 401: Unauthorized - Invalid or missing Bearer token
- 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 @@ -111,6 +111,7 @@ func main() {
mux.Handle("/api/collect-settings", middleware.LoggingMiddleware(middleware.CorsMiddleware(middleware.AuthMiddleware(http.HandlerFunc(cronAPI.HandleCollectSettings)))))
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/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 @@ -21,3 +21,10 @@ type UpdateScheduleRequest struct {
type UpdateStatusRequest struct {
IsActive bool `json:"is_active"`
}

// RetryMessageRequest asks for a repository to be re-sent to the named APIs.
// URL is optional: when empty the most recently published repository is used.
type RetryMessageRequest struct {
APIs []string `json:"apis"`
URL string `json:"url"`
}
19 changes: 15 additions & 4 deletions internal/models/cron_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,21 @@ package models
import "time"

type CronHistory struct {
Name string `json:"name"`
Timestamp time.Time `json:"timestamp"`
Success int `json:"status"`
Output string `json:"output,omitempty"`
Name string `json:"name"`
Timestamp time.Time `json:"timestamp"`
Success int `json:"status"`
Output string `json:"output,omitempty"`
Details *MessageRunDetails `json:"details,omitempty"`
}

// MessageRunDetails records which item a message run published and where it
// landed, so a partial run can be re-sent to the connectors that missed it
// without re-parsing the human-readable output.
type MessageRunDetails struct {
URL string `json:"url,omitempty"`
Sent []string `json:"sent,omitempty"`
Failed []string `json:"failed,omitempty"`
Manual bool `json:"manual,omitempty"`
}

type PaginationMetadata struct {
Expand Down
76 changes: 76 additions & 0 deletions internal/repository/endpoints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package repository

import (
"bytes"
"context"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)

// client carries no timeout of its own: it is applied per request from the
// environment, so nothing about the content-alchemist connection depends on when
// this package was imported.
var client = &http.Client{}

func getContentAlchemistTimeout() time.Duration {
timeoutStr := os.Getenv("CONTENT_ALCHEMIST_TIMEOUT")
if timeoutStr == "" {
return 30 * time.Second
}

timeoutSeconds, err := strconv.Atoi(timeoutStr)
if err != nil {
return 30 * time.Second
}

return time.Duration(timeoutSeconds) * time.Second
}

// doRequest sends a request to content-alchemist under the configured timeout.
func doRequest(req *http.Request) (*http.Response, error) {
ctx, cancel := context.WithTimeout(req.Context(), getContentAlchemistTimeout())
defer cancel()

resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}

// The deferred cancel must not close the body before the caller reads it, so
// the body is buffered and the connection released here.
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
resp.Body = io.NopCloser(bytes.NewReader(body))

return resp, nil
}

// The content-alchemist location is resolved per call rather than at import
// time, so it does not depend on when the environment was populated and can be
// pointed at a stub in tests.
func contentAlchemistURL(path string) string {
return strings.TrimRight(os.Getenv("CONTENT_ALCHEMIST_URL"), "/") + path
}

func getRepositoryURL() string {
return contentAlchemistURL("/think-root/api/get-repository/")
}

func updatePostedURL() string {
return contentAlchemistURL("/think-root/api/update-posted/")
}

func deleteRepositoryURL() string {
return contentAlchemistURL("/think-root/api/delete-repository/")
}

func authorizationHeader() string {
return "Bearer " + os.Getenv("CONTENT_ALCHEMIST_BEARER")
}
Loading
Loading