From 883e147da45c3b1343458b0adecb250cc4b4b191 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 16 Aug 2026 01:25:37 +0700 Subject: [PATCH] docs: add share bot API integration guide --- docs/integrations/share-bot.md | 214 +++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/integrations/share-bot.md diff --git a/docs/integrations/share-bot.md b/docs/integrations/share-bot.md new file mode 100644 index 0000000..1ae1049 --- /dev/null +++ b/docs/integrations/share-bot.md @@ -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 +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.