Что мы строим
Два небольших бота, один общий мозг. Файл llm.py содержит всё, что связано с API: роль, ограничитель частоты и логику повторных попыток. Боты Discord и Telegram лишь преобразуют события платформы в список сообщений и обратно. Если позже вы добавите третью платформу, вам нужно будет написать только адаптер.
Установите зависимости через pip install httpx discord.py python-telegram-bot. Экспортируйте API_KEY со вашей страницы аккаунта, а также DISCORD_TOKEN и/или TELEGRAM_TOKEN из настроек бота каждой платформы. Оба бота ниже предполагают Python 3.10 или новее.
Формат запроса соответствует стандартному формату OpenAI chat-completions, описанному в быстром старте на нескольких языках. Для работы здесь не нужен SDK.
Общее ядро: персона, лимитер и повторные попытки
Разместите опасную часть в одном месте. Приведённый модуль хранит скользящее окно временных меток запросов и ожидает, когда их количество приблизится к 240 в минуту, оставляя запас до лимита в 300 запросов в минуту на ключ. Семафор ограничивает параллельные запросы до четырёх, чтобы активный канал не отправлял двадцать запросов одновременно.
# llm.py - shared by both bots
import asyncio
import os
import time
from collections import deque
import httpx
URL = "https://api.unrestrictedaiapi.com/v1/chat/completions"
PERSONA = "You are Pip, a sardonic bar-room storyteller. Keep replies under 150 words."
_stamps = deque() # timestamps of recent calls
_gate = asyncio.Semaphore(4) # at most 4 requests in flight
_client = httpx.AsyncClient(timeout=60.0)
async def _wait_for_slot(limit=240, window=60.0):
# Stay below the 300 requests/minute per-key limit with headroom.
while True:
now = time.monotonic()
while _stamps and now - _stamps[0] > window:
_stamps.popleft()
if len(_stamps) < limit:
_stamps.append(now)
return
await asyncio.sleep(window - (now - _stamps[0]) + 0.05)
async def chat(history):
"""history: list of {"role","content"} dicts, oldest first."""
messages = [{"role": "system", "content": PERSONA}] + list(history)
async with _gate:
for attempt in range(3):
await _wait_for_slot()
r = await _client.post(
URL,
headers={"Authorization": f"Bearer {os.environ['API_KEY']}"},
json={"model": "uncensored", "messages": messages, "max_tokens": 400},
)
if r.status_code in (429, 503):
await asyncio.sleep(2 ** attempt)
continue
if r.status_code == 403:
return "I can't continue with that."
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
return "The service is busy. Try again in a moment."Посмотрите на обработку статусов. При 429 или 503 бот делает паузу на 1, затем 2 и 4 секунды перед повторной попыткой. Код 403 означает срабатывание фильтра контента, поэтому бот отвечает нейтральной фразой, а не повторяет запрос. Любая другая ошибка вызывает исключение, которое ваша библиотека платформы запишет в лог. Если вы видите 402, значит, баланс пуст или закончился пробный период; пополните предоплаченный баланс.
Адаптер Discord
Ботам Discord необходимо включить разрешение на чтение содержимого сообщений в панели разработчика и в коде, иначе msg.content будет пустым. Бот отвечает только при упоминании, что позволяет ему не читать каждое сообщение в активном сервере.
# discord_bot.py
import os
import time
from collections import defaultdict, deque
import discord
from llm import chat
intents = discord.Intents.default()
intents.message_content = True # also enable it in the developer portal
bot = discord.Client(intents=intents)
HISTORY_TURNS = 10 # user+assistant messages kept per channel
history = defaultdict(lambda: deque(maxlen=HISTORY_TURNS))
last_use = {} # user id -> last request time
COOLDOWN = 5.0 # seconds between requests per user
def chunks(text, size=1900):
return [text[i:i + size] for i in range(0, len(text), size)] or [""]
@bot.event
async def on_message(msg: discord.Message):
if msg.author.bot or bot.user not in msg.mentions:
return
# Age gate: guild text channels must be flagged NSFW. No DMs.
if not isinstance(msg.channel, discord.TextChannel) or not msg.channel.is_nsfw():
await msg.reply("I only chat in channels marked age-restricted (NSFW).")
return
now = time.monotonic()
if now - last_use.get(msg.author.id, 0) < COOLDOWN:
await msg.add_reaction("\u23f3")
return
last_use[msg.author.id] = now
text = msg.clean_content.replace(f"@{bot.user.display_name}", "").strip()
if not text:
return
h = history[msg.channel.id]
h.append({"role": "user", "content": f"{msg.author.display_name}: {text}"})
async with msg.channel.typing():
answer = await chat(h)
h.append({"role": "assistant", "content": answer})
for part in chunks(answer):
await msg.channel.send(part)
bot.run(os.environ["DISCORD_TOKEN"])История хранится в deque с параметром maxlen=10, индексированном по ID канала, поэтому каждый канал — это отдельный диалог, а старые реплики автоматически удаляются. Префикс имени пользователя перед каждой репликой позволяет модели различать людей в общей комнате. Десять сообщений — консервативное значение по умолчанию; контекстное окно поддерживает 100 000 токенов, поэтому вы можете значительно увеличить это значение, прежде чем это станет проблемой.
Адаптер Telegram
Версия python-telegram-bot v20 и выше работает асинхронно, что естественно сочетается с общим модулем. chat_data — это словарь для каждого чата, который библиотека хранит для вас, поэтому история и состояние задержки не требуют дополнительного кода для хранения данных.
# telegram_bot.py (python-telegram-bot v20+)
import os
import time
from collections import deque
from telegram import Update
from telegram.ext import (ApplicationBuilder, CommandHandler, ContextTypes,
MessageHandler, filters)
from llm import chat
async def adult(update: Update, context: ContextTypes.DEFAULT_TYPE):
context.chat_data["adult"] = True
await update.message.reply_text("Noted. Say something.")
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
"This bot produces adult fiction. Send /adult only if you are 18 or older."
)
async def talk(update: Update, context: ContextTypes.DEFAULT_TYPE):
cd = context.chat_data
if not cd.get("adult"):
await update.message.reply_text("Send /adult to confirm you are 18+.")
return
if time.monotonic() - cd.get("last", 0) < 4:
return # per-chat cooldown
cd["last"] = time.monotonic()
h = cd.setdefault("history", deque(maxlen=10))
h.append({"role": "user", "content": update.message.text})
await context.bot.send_chat_action(update.effective_chat.id, "typing")
answer = await chat(h)
h.append({"role": "assistant", "content": answer})
for i in range(0, len(answer), 4000): # Telegram limit is 4096 chars
await update.message.reply_text(answer[i:i + 4000])
app = ApplicationBuilder().token(os.environ["TELEGRAM_TOKEN"]).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("adult", adult))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, talk))
app.run_polling()run_polling() — самый простой способ запуска, не требует публичного URL. Имейте в виду, что chat_data живёт в памяти, поэтому перезапуск очищает её. Если вам нужно, чтобы история сохранялась после перезапусков, сохраняйте содержимое deque самостоятельно в файл или базу данных.
Возрастная фильтрация и правило NSFW-каналов
Этот API только для взрослых, и всё, что вы на нём построите, наследует эту ответственность. Рассматривайте возрастную фильтрацию как функцию, а не как дополнение.
- Discord: отвечайте только в каналах сервера с пометкой 18+. Проверка
channel.is_nsfw()ничего не стоит, и бот откажется отвечать в других местах, включая личные сообщения. - Telegram: требуйте явное подтверждение
/adultдля каждого чата перед генерацией и укажите правило в приветственном тексте. - Оба: не предлагайте бота в пространствах для молодёжи и немедленно отключайте доступ, если узнаете, что пользователю меньше 18 лет.
Команда подтверждения — это пропускной барьер, а не доказательство возраста. Сексуальный контент с участием несовершеннолетних всегда блокируется API с кодом 403, включая художественную литературу и ролевые игры, и ваш бот не должен пытаться обойти эту блокировку.
Запуск и тестирование без толпы
Запустите каждый бот в отдельном терминале с помощью python discord_bot.py или python telegram_bot.py. Для первого запуска создайте приватный тестовый сервер или чат только с собой. Пройдите короткий чек-лист, и вы обнаружите почти все ошибки новичков.
- Упомяните бота в не-NSFW канале и подтвердите, что он отказывается отвечать. Это доказывает работу фильтра.
- Упомяните его в помеченном канале и отправьте два сообщения быстро. Второе должно получить реакцию с песочными часами, что доказывает работу кулдауна.
- Ведите четырёхрепличный разговор, затем сослайтесь на первое сообщение. Если бот помнит, история подключена правильно.
- Временно установите неверное значение API-ключа и подтвердите, что ошибка появляется в логах, а не исчезает бесследно.
- Вставьте длинный блок текста и наблюдайте, как ответ разбивается на несколько сообщений в пределах лимита длины платформы.
Если ответы кажутся шаблонными, обычно проблема в персоне, а не в коде. Измените одно предложение в PERSONA, перезапустите и сравните. Поскольку персона живёт в общем модуле, одно изменение меняет обоих ботов одновременно, что именно и оправдывает структуру с дополнительным файлом.
Когда будете готовы к развёртыванию, подойдёт любой менеджер процессов с постоянным запуском. Боты поддерживают постоянное соединение с платформой и делают короткие исходящие HTTPS-запросы к API, поэтому им не нужны входящие порты и почти не требуется память. Добавьте политику перезапуска, чтобы сбой не оставил ваше сообщество общаться с молчащим ботом.
Лимиты запросов, размер истории и стоимость
Три регулятора управляют поведением бота под нагрузкой. Задержка для каждого пользователя не позволяет одному человеку монополизировать ключ. Глобальное окно защищает потолок в 300 запросов в минуту. Семафор сглаживает пики. Если вы запускаете несколько процессов бота на одном ключе, они делят этот потолок, поэтому уменьшите значение limit для каждого процесса соответственно.
Оцените расходы перед приглашением толпы. Предположения: каждый вызов отправляет около 1 200 входных токенов (персона плюс десять реплик) и возвращает 250 токенов. Один вызов стоит 1 200 x $0,25 / 1 000 000 + 250 x $1,00 / 1 000 000 = $0,0003 + $0,00025 = $0,00055. Сервер, выдающий 2 000 ответов в день, потратит около $1,10 в день при этих предположениях. Ваши цифры будут отличаться, поэтому логируйте поле usage неделю и пересчитайте.
Чтобы настроить саму персону, прочитайте гайд по промптам. Цены указаны на странице тарифов.
Укрепление перед публикацией бота
Храните токены вне системы контроля версий; читайте их из окружения, как показано. Ограничьте длину сообщения перед отправкой в API, чтобы одно большое вставленное сообщение не сожгло ваш баланс. Логируйте ошибки, но не текст сообщений, так как пользователи справедливо ожидают конфиденциальности чатов. Добавьте команду /reset, которая очищает deque канала — это самое быстрое исправление, когда разговор уходит в сторону. Наконец, добавьте заметную запись в профиле бота, что он пишет взрослую художественную литературу и что его ответы генерируются.
Ещё один выбор дизайна заслуживает упоминания: боты ждут полного ответа, а не потоковой передачи. Редактирование сообщения токкен за токкеном упирается в лимиты редактирования платформы, а для ответов, ограниченных примерно 150 словами, ожидание короткое. Если позже захотите живую печать в своём веб-интерфейсе, включите stream: true там, где вы контролируете транспорт. До тех пор индикатор набора текста, который вы уже отправляете, — самый дешёвый способ показать, что бот работает, и это сохраняет код достаточно коротким, чтобы прочитать его за один присест и провести аудит перед приглашением кого-либо.