Files
erika/docs/integrations/share-bot.md
T

6.8 KiB
Raw Blame History

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.

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:

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.

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:

const imageAttachment = message.attachments.find((attachment) =>
  attachment.contentType?.startsWith("image/"),
);
if (imageAttachment?.name) {
  form.set("image", await attachmentBlob(imageAttachment), imageAttachment.name);
}

cURL example

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:

{
  "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:

{
  "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.