Взаимодействие с Midjourney с использованием Discord API • Часть II

4d4a9eb2669affa814c38cf7689e031f.png

Введение

Эта статья продолжение первой части. Мы подробно рассмотрим систему модерации Midjourney, коснемся лимитов скорости (rate limits) Discord, двух самых сложных тем. В конце статьи приведен рабочий код.

Прежде чем мы начнем, убедитесь, что у вас есть учетная запись Discord с активной подпиской Midjourney. Базового плана за $10 будет достаточно.

Следуйте этим простым шагам, чтобы получить:

  • Идентификатор сервера Discord, который будет упомянут в этой статье как server_id

  • Идентификатор канала Discord, который будет упомянут в этой статье как channel_id

  • Токен Discord, который будет упомянут в этой статье как discord_token

Лимит скорости (rate limits) Discord

Оригинальная документация Discord предлагает использовать HTTP заголовки для определения случаев, когда лимит скорости превышен, и соответствующе корректировать действия. Этот подход необходим для сложных или коммерческих приложений, в этой статье мы будем использовать более простой, но не менее эффективный метод.

Мы добавим паузу в 350 миллисекунд перед каждым вызовом API Discord. Поскольку наш код работает с одним потоком, это будет достаточно для того, чтобы оставаться в пределах лимита. Общая производительность нашей небольшой программы не пострадает, поскольку время ответа Midjourney гарантированно превосходит предложенные 350 миллисекунд.

Система модерации Midjourney

Midjourney выполняет как пре-модерацию так и пост-модерацию команд /imagine, вам потребуется надежный способ обнаружения обоих случаев.

При выполнении POST-запроса с командой /imagine в канале Discord (как описано в первой части), вы столкнетесь с семью возможными случаями:

  1. Успешное выполнение
    Через несколько секунд размещенное сообщение появится в канале с исходным идентификатором сообщения id, статусом (Waiting to start) в поле content и с полем type установленным в 0.

    GET https://discord.com/api/v10/channels/channel_id/messages

 [
 {
  "id": "",
  "type": 0,
  "content": "**Wonders of the World…** - <@Discord user id> (Waiting to start)",
  "channel_id": "",

Как только Midjourney завершит генерацию, исходное сообщение будет удалено, Midjourney разместит окончательное сообщение с новым идентификатором id содержащим результаты генерации.

8c8eb9ddf6669cf26fa79ac549427d0d.jpg

GET https://discord.com/api/v10/channels/channel_id/messages

[
 {
  "id": "",
  "type": 0,
  "content": "**Wonders of the World…** - <@Discord user id> (fast)",
  "channel_id": "",  
  1. Пре-модерация
    Это самый простой случай: размещенное сообщение никогда не появится в канале. Вы увидите что-то подобное этому.

Сообщения, помеченные как «Only you can see this» (Только вы можете видеть это), могут быть получены с помощью Discord Gateway API.

  1. Пост-модерация
    Пост будет появляться в канале так же, как в случае «Успешного выполнения».

ad9bf03a68cea9e579c0925c40a9f060.png

GET https://discord.com/api/v10/channels/channel_id/messages

[
 {
  "id": "",
  "type": 0,
  "content": "**Beautiful lady swimming in the pool…** - <@Discord user id> (Waiting to start)",
  "channel_id": "",

Генерация может даже начаться, и вы увидите прогресс, но внезапно остановится с сообщением, похожим на следующее.

4ea4e71aead8486cd3d363dfebdb1683.png

GET https://discord.com/api/v10/channels/channel_id/messages

[
 {
  "id": "",
  "type": 0,
  "content": "**Beautiful lady swimming in the pool…** - <@Discord user id> (Stopped)",
  "channel_id": "",
  1. Эфемерная модерация
    Некоторые слова в вашем запросе могут вызвать «мягкую модерацию». Генерация начинается и успешно завершается, но после завершения сообщение будет отображаться как «Original message was deleted» (Исходное сообщение было удалено). Сообщение больше не возвращается запрос GET https://discord.com/api/v10/channels/channel_id/messages, даже если изначально оно было. Пример ниже.

f5a1c49fc607af99c137f249991a4802.png

GET https://discord.com/api/v10/channels/channel_id/messages

[
 {
  "id": "",
  "type": 0,
  "content": "**Irresistibly beautiful woman, pinup…** - <@Discord user id> (Waiting to start)",
  "channel_id": "",

Сгенерированное сообщение было удалено (Original message was deleted).

ca2edc63f5a34dd4b8f7e9e284c07c6a.jpg

Сообщения, помеченные как «Only you can see this» (Только вы можете видеть это), могут быть получены с помощью Discord Gateway API.

  1. Неверный запрос
    Это происходит когда вы указываете неправильный параметр. Как и в случае пре-модерации размещенное сообщение не появится в канале. Рекомендуется добавить код для проверки синтаксиса запроса перед его размещением. Мы планируем рассмотреть тему проверки синтаксиса запроса в одной из наших последующих статей.

5ede0f7d1bd2cca64fe4e6aa0310da47.png

Сообщения, помеченные как «Only you can see this» (Только вы можете видеть это), могут быть получены с помощью Discord Gateway API.

  1. Сообщение в очереди
    В зависимости от вашего плана подписки на Midjourney у вас может быть от 3 (Basic & Standard) до 15 (Pro & Mega) одновременных выполнений заданий. Как только этот лимит достигнут, запросы на выполнение заданий будут поставлены в очередь и сообщения, связанные с этими заданиями, не будут немедленно появляться в GET https://discord.com/api/v10/channels/channel_id/messages до тех пока Midjourney не завершит выполнение одного из активных заданий. Фактически это означает, что задание было помещено во внутреннюю очередь Midjourney.

9595c7f3668dc698d9496e1266823a06.png

Сообщения, помеченные как «Only you can see this» (Только вы можете видеть это), могут быть получены с помощью Discord Gateway API.

Желательно минимизировать или полностью исключить случаи «Сообщение в очереди», поскольку существует значительная вероятность того, что Midjourney может ограничивать ваши запросы на генерацию в таких случаях и использовать это как индикатор нарушения условий использования Midjourney (…may not use automated tools to access, interact with, or generate Assets through the Services… | …нельзя использовать автоматические средства для доступа, взаимодействия или генерации ресурсов через услуги…).

  1. Очередь переполнена
    Внутренняя очередь задач Midjourney может вмещать до 10 задач, после чего вы получите сообщение «Очередь переполнена». Вам придется подождать и попробовать снова.

4b01c12d8c69d0bde19823baf8387216.png

Сообщения, помеченные как «Only you can see this» (Только вы можете видеть это), могут быть получены с помощью Discord Gateway AP

Желательно минимизировать или полностью исключить случаи «Очередь переполнена», поскольку существует значительная вероятность того, что Midjourney может ограничивать ваши запросы на генерацию в таких случаях и использовать это как индикатор нарушения условий использования Midjourney (…may not use automated tools to access, interact with, or generate Assets through the Services… | …нельзя использовать автоматические средства для доступа, взаимодействия или генерации ресурсов через услуги…).

Discord REST API (HTTPS) vs Discord Gateway API (WebSocket)

Как вы заметили в предыдущем абзаце, некоторые сообщения можно извлечь с использованием Discord Gateway API. В отличие от Discord REST API, который мы используем в этой статье, Discord Gateway API требует использования WebSocket API для обмена данными в реальном времени между вашим клиентским приложением и сервером Discord. Это добавляет еще один уровень сложности и, что более важно, может увеличить вероятность бана.

В подавляющем большинстве случаев вы можете создать алгоритм без использования Discord Gateway API. Мы планируем рассмотреть крайние случаи, когда использование Discord Gateway API является единственным решением, в последующих статьях.

Логика генерации с помощью Midjourney /imagine

Учитывая информацию, полученную из предыдущих абзацев, вы можете реализовать следующую простую стратегию:

  1. Убедитесь, что у вас есть хотя бы один свободный слот (job). Для этого вы можете использовать либо отдельную учетную запись Midjourney для всех ваших автоматизированных задач, либо тщательно управлять количеством вручную запущенных задач, ограничив их до максимум двух. Этот подход поможет избежать как случаев «Сообщение в очереди», так и случаев «Очередь переполнена».

  2. Перед размещением /imagine запроса выполните GET https://discord.com/api/v10/channels/channel_id/messages, чтобы получить идентификатор id первой записи. По умолчанию этот API возвращает 50 последних сообщений из канала, упорядоченных в порядке убывания, с самым свежим сообщением в начале списка.

  3. После размещения /imagine запроса с использованием POST https://discord.com/api/v10/interactions, продолжайте проверять канал каждые 3–5 секунд, выполняя GET https://discord.com/api/v10/channels/channel_id/messages. Сравнивайте идентификатор id первой записи с значением, полученным на предыдущем этапе. Если через 30 секунд вы не видите новой записи в канале, можно предположить, что у вас либо случай пре-модерации, либо «Неверного запроса», и ваш запрос, возможно, требует пересмотра.

  4. Как только вы определите, что новое сообщение действительно появилось в вашем канале, сохраните идентификатор id этого сообщения. Продолжайте выполнять GET https://discord.com/api/v10/channels/channel_id/messages каждые 20–30 секунд, чтобы проверить следующие оставшиеся случаи:

    • Успешное выполнение: когда генерация завершена, сообщение с исходным идентификатором id больше не присутствует в канале. Вместо него присутствует новое сообщение с непустым массивом компонентов components. Сгенерированное изображение можно получить из поля attachments[0].url.

    • Пост-модерация: когда сообщение с исходным идентификатором id все еще присутствует, и его поле content заканчивается на (Stopped).

    • Эфемерная модерация: когда идентификатор id первой записи совпадает с значением, полученным до размещения изображения-запроса.

Этот алгоритм позволит вам эффективно управлять процессом генерации с помощью Midjourney /imagine, учитывая различные все возможные сценарии.

JavaScript код реализующий алгоритм описанный выше

// bash
// USEAPI_DISCORD="..." USEAPI_SERVER="..." USEAPI_CHANNEL="..." node index.js

// Node 18+

const imagine_prompt = "Hologram cat in neon lights";

const discordAPI = `https://discord.com/api/v10`;
const MidjourneyAppId = `936929561302675456`;

// Load Discord settings from environment 
const discord = process.env.USEAPI_DISCORD ?? 'Discord token';
const server = process.env.USEAPI_SERVER ?? 'Discord server id number';
const channel = process.env.USEAPI_CHANNEL ?? 'Discord channel id number';

console.info({ discord, server, channel, imagine_prompt });

const sleep = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms));

const DiscordHeaders = (token) => ({
    "Content-Type": "application/json",
    "Authorization": `${token}`,
});

// https://discord.com/developers/docs/resources/channel#get-channel-messages
const GetDiscordChannelMessages = async (discord, channel) => {
    const response = await fetch(
        `${discordAPI}/channels/${channel}/messages`,
        { headers: DiscordHeaders(discord) });

    return response;
}

// Midjourney Imagine https://discord.com/api/v10/channels/channel_id/application-commands/search?type=1&include_applications=true&query=imagine
const PostDiscordImagine = async (discord, server, channel, prompt) => {
    const data = {
        "type": 2,
        "application_id": MidjourneyAppId,
        "guild_id": server,
        "channel_id": channel,
        "session_id": (new Date()).getTime(),
        "data": {
            "version": "1166847114203123795",
            "id": "938956540159881230",
            "name": "imagine",
            "type": 1,
            "options": [
                {
                    "type": 3,
                    "name": "prompt",
                    "value": prompt
                }
            ],
            "application_command": {
                "id": "938956540159881230",
                "application_id": MidjourneyAppId,
                "version": "1166847114203123795",
                "default_permission": true,
                "default_member_permissions": null,
                "type": 1,
                "nsfw": false,
                "name": "imagine",
                "description": "Create images with Midjourney",
                "dm_permission": true,
                "options": [
                    {
                        "type": 3,
                        "name": "prompt",
                        "description": "The prompt to imagine",
                        "required": true
                    }
                ]
            },
            "attachments": []
        }
    };

    const response = await fetch(`${discordAPI}/interactions`, {
        method: "POST",
        body: JSON.stringify(data),
        headers: DiscordHeaders(discord)
    });

    return response;
}

const demo = async () => {
    const getBeforeMessages = await GetDiscordChannelMessages(discord, channel);

    if (getBeforeMessages.status !== 200) {
        console.error(`Discord /messages status ${getBeforeMessages.status}`, await getBeforeMessages.json());
        process.exit(1);
    }

    const beforeMessages = await getBeforeMessages.json();

    const beforeIds = new Set();
    beforeMessages.forEach(msg => beforeIds.add(msg.id));

    await sleep(350);

    const postImagine = await PostDiscordImagine(discord, server, channel, imagine_prompt);

    if (postImagine.status != 204) {
        console.error(`Discord /interactions status ${postImagine.status}`, await postImagine.json());
        process.exit(1);
    }

    const maxPostedAttempts = 10;

    let attempt = 1;
    let postedMessage;

    // Check for message to appear in the channel for 20 seconds total 
    do {
        await sleep(2000);

        const getMessages = await GetDiscordChannelMessages(discord, channel);

        if (getMessages.status !== 200) {
            console.error(`Discord /messages status ${getMessages.status}`, await getMessages.body());
            process.exit(1);
        }

        const messages = await getMessages.json();

        // New (Waiting to start) imagine interaction
        postedMessage = messages.find(msg => !beforeIds.has(msg.id));

        if (postedMessage) {
            console.log(`Found new message ${postedMessage.id}`, postedMessage.content);
            break;
        }

        attempt++;

    } while (attempt <= maxPostedAttempts);

    if (postedMessage === undefined) {
        console.error(`Posted message not found due to moderation or an invalid prompt`);
        process.exit(1);
    }

    const termStopped = `> (Stopped)`;
    const maxGeneratedAttempts = 60;

    attempt = 1;

    let generatedMessage;

    // Check for posted message to appear in the channel with new id for 10 minutes max
    do {
        // Wait for 10 seconds before checking on message progress
        await sleep(10000);

        const getMessages = await GetDiscordChannelMessages(discord, channel);

        if (getMessages.status !== 200) {
            console.error(`Discord /messages status ${getMessages.status}`, await getMessages.body());
            process.exit(1);
        }

        const messages = await getMessages.json();

        const progress = messages.find(message =>
            message.id == postedMessage.id &&
            // Not completed 
            !message.components?.length &&
            // Not stopped 
            !message.content.endsWith(termStopped));
        if (progress) {
            console.log(`#${attempt} ${progress.id} progress`, progress.content);
        } else {
            const completed = messages.find(message =>
                !beforeIds.has(message.id) &&
                message.id !== postedMessage.id &&
                // Either completed or stopped
                (!!message.components?.length || message.content.endsWith(termStopped))
            );

            generatedMessage = (completed && !!completed.components?.length) ? completed : undefined;

            break;
        }

        attempt++;

    } while (attempt <= maxGeneratedAttempts);

    if (generatedMessage === undefined) {
        console.error(`Message not found due to post-moderation or ephemeral moderation`);
        process.exit(1);
    }

    // Successful generation
    console.info(`Completed`, generatedMessage.content);
    console.info(`Download URL`, generatedMessage.attachments[0].url);
}

demo();

© Habrahabr.ru