Files

215 lines
6.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 https://erika.sudloh.com/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://erika.sudloh.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://erika.sudloh.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://erika.sudloh.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.