docs: add share bot API integration guide
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
# Share API bot integration
|
||||
|
||||
Use this endpoint when a trusted Discord bot needs to publish a member's text
|
||||
attachment and return a public Erika Share link.
|
||||
|
||||
```http
|
||||
POST /api/share
|
||||
Authorization: Bearer <SHARE_BOT_SECRET>
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
The endpoint does not use a NextAuth session or a public API key. It is private and
|
||||
requires the shared bearer secret configured on the website as `SHARE_BOT_SECRET`.
|
||||
Store the same secret in the bot's private environment; never place it in source
|
||||
code, Discord messages, browser code, or logs.
|
||||
|
||||
## Required website configuration
|
||||
|
||||
The website needs these values before the endpoint can work:
|
||||
|
||||
- `SHARE_BOT_SECRET`: long random secret shared only with the bot.
|
||||
- `BASE_URL`, or `NEXTAUTH_URL` as its fallback: canonical website origin used in
|
||||
returned links.
|
||||
- Discord bot token and guild ID already used by Erika's Discord integration.
|
||||
- Redis configuration for rate limiting.
|
||||
|
||||
The bot can use its own variable names:
|
||||
|
||||
```dotenv
|
||||
ERIKA_BASE_URL=https://example.com
|
||||
ERIKA_SHARE_BOT_SECRET=replace-with-the-same-secret
|
||||
```
|
||||
|
||||
Do not include a trailing slash in `ERIKA_BASE_URL`.
|
||||
|
||||
## Multipart fields
|
||||
|
||||
| Field | Required | Contract |
|
||||
| --- | --- | --- |
|
||||
| `file` | Yes | Non-empty UTF-8 `.txt` file, maximum 5 MiB. Blank text and NUL characters are rejected. |
|
||||
| `senderDiscordId` | Yes | `message.author.id`, as a 15–22 digit Discord snowflake. Do not send the bot's ID. |
|
||||
| `description` | No | Plain text, maximum 5,000 characters. |
|
||||
| `image` | No | PNG, JPEG, WebP, or GIF, maximum 50 MiB. The declared MIME type must match the file contents. |
|
||||
|
||||
Do not send `displayName`, `avatarUrl`, `authorName`, or `authorAvatarUrl`. The API
|
||||
rejects those fields. It verifies that `senderDiscordId` belongs to the configured
|
||||
Discord guild and fetches the member's global display name and global avatar directly
|
||||
from Discord.
|
||||
|
||||
## Discord.js example
|
||||
|
||||
This example uses the built-in `fetch`, `FormData`, and `Blob` APIs available in
|
||||
modern Node.js runtimes. It publishes the first `.txt` attachment on a message and
|
||||
uses the message text as the optional description.
|
||||
|
||||
```ts
|
||||
import type { Attachment, Message } from "discord.js";
|
||||
|
||||
type ShareSuccess = {
|
||||
success: true;
|
||||
share: {
|
||||
id: string;
|
||||
url: string;
|
||||
author: {
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
imageUrl: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ShareFailure = {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
async function attachmentBlob(attachment: Attachment) {
|
||||
const response = await fetch(attachment.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to download ${attachment.name}: ${response.status}`);
|
||||
}
|
||||
|
||||
return new Blob([await response.arrayBuffer()], {
|
||||
type: attachment.contentType ?? "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishTextAttachment(message: Message) {
|
||||
if (message.author.bot) return;
|
||||
|
||||
const textAttachment = message.attachments.find((attachment) =>
|
||||
attachment.name?.toLowerCase().endsWith(".txt"),
|
||||
);
|
||||
if (!textAttachment?.name) return;
|
||||
|
||||
const baseUrl = process.env.ERIKA_BASE_URL;
|
||||
const secret = process.env.ERIKA_SHARE_BOT_SECRET;
|
||||
if (!baseUrl || !secret) {
|
||||
throw new Error("Erika Share API is not configured");
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.set("file", await attachmentBlob(textAttachment), textAttachment.name);
|
||||
form.set("senderDiscordId", message.author.id);
|
||||
|
||||
const description = message.content.trim();
|
||||
if (description) form.set("description", description.slice(0, 5_000));
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/share`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${secret}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
const result = (await response.json()) as ShareSuccess | ShareFailure;
|
||||
if (!response.ok || !result.success) {
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
const retryNote = retryAfter ? `; retry after ${retryAfter}s` : "";
|
||||
throw new Error(`Erika Share failed (${response.status}): ${result.error}${retryNote}`);
|
||||
}
|
||||
|
||||
await message.reply(result.share.url);
|
||||
return result.share;
|
||||
}
|
||||
```
|
||||
|
||||
Do not manually set the `Content-Type` request header. `FormData` supplies the
|
||||
required multipart boundary automatically.
|
||||
|
||||
To include the first supported image attachment, add it before the request:
|
||||
|
||||
```ts
|
||||
const imageAttachment = message.attachments.find((attachment) =>
|
||||
attachment.contentType?.startsWith("image/"),
|
||||
);
|
||||
if (imageAttachment?.name) {
|
||||
form.set("image", await attachmentBlob(imageAttachment), imageAttachment.name);
|
||||
}
|
||||
```
|
||||
|
||||
## cURL example
|
||||
|
||||
```bash
|
||||
curl --fail-with-body \
|
||||
-X POST "https://example.com/api/share" \
|
||||
-H "Authorization: Bearer $ERIKA_SHARE_BOT_SECRET" \
|
||||
-F "file=@./message.txt;type=text/plain" \
|
||||
-F "senderDiscordId=123456789012345678" \
|
||||
-F "description=Optional context for this share"
|
||||
```
|
||||
|
||||
## Successful response
|
||||
|
||||
The API returns HTTP `201`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"share": {
|
||||
"id": "a1b2c3d4",
|
||||
"url": "https://example.com/share/a1b2c3d4",
|
||||
"author": {
|
||||
"displayName": "Example User",
|
||||
"avatarUrl": "https://cdn.discordapp.com/avatars/..."
|
||||
},
|
||||
"imageUrl": null,
|
||||
"createdAt": "2026-08-16T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The bot should post `share.url`. The shared page is public and does not require the
|
||||
recipient to log in.
|
||||
|
||||
## Error response
|
||||
|
||||
Failures use this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Human-readable error"
|
||||
}
|
||||
```
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `400` | Missing/malformed multipart data, invalid sender ID, blank text, invalid UTF-8, NUL content, or a supplied profile field. |
|
||||
| `401` | Missing or incorrect bearer secret. |
|
||||
| `413` | Text, description, or image exceeds its limit. |
|
||||
| `415` | The main file is not `.txt`, or the image type/content is unsupported. |
|
||||
| `422` | The sender is not a member of the configured Discord guild. |
|
||||
| `429` | A rate limit was exceeded. Read the `Retry-After` response header. |
|
||||
| `503` | Bot API configuration, Discord profile lookup, or Redis rate limiting is unavailable. |
|
||||
| `500` | Unexpected internal failure. Log the status and error without logging the bearer secret. |
|
||||
|
||||
## Rate limits
|
||||
|
||||
- 60 authenticated bot API requests per hour globally.
|
||||
- 10 shares per hour per Discord sender, combined across bot and website uploads.
|
||||
|
||||
On HTTP `429`, wait for the number of seconds in `Retry-After` before retrying. Do
|
||||
not retry `400`, `401`, `413`, `415`, or `422` automatically. A short bounded retry
|
||||
with backoff is appropriate for `503` and transient `500` responses.
|
||||
|
||||
## Identity and duplicate safety
|
||||
|
||||
The website creates or updates the member's linked NextAuth user and Discord account
|
||||
inside a transaction. Repeated or concurrent uploads for one Discord ID resolve to
|
||||
the same user/account pair. If that member later signs in through Discord OAuth, the
|
||||
login reuses the bot-created user instead of creating a duplicate.
|
||||
Reference in New Issue
Block a user