Сервис на Bun/TypeScript: читает Twitch-чат по WebSocket, распознаёт русскую
речь из эфира и может отвечать через AI с учётом разговора и памяти пользователей.
PostgreSQL хранит расход токенов, историю решений и пользовательские факты.
Всё запускается в Docker. React-панель позволяет добавлять комнаты Twitch,
наблюдать за агентом и участниками разговора, открывать их память и историю,
настраивать стиль общения с каждым и запускать агента отдельно в каждом канале.
Общие настройки и ключи задаются в config.json.
Документация:
- Устройство сервиса и поток данных.
- Настройки, таймауты, устаревание и лимиты.
- Эксплуатация, проверки без сети, история, backup и публикация в Git.
- Панель агента: комнаты, состояния и локальный API.
- Полный пример конфига без ключей.
Примеры JSON-логов:
{"level":30,"component":"chat","event":"chat.message","author":"username","text":"текст сообщения"}
{"level":30,"component":"speech","event":"speech.transcript","text":"распознанная речь из общего звука эфира","final":true}
Нужен установленный Docker. Bun, FFmpeg, нативная ASR-библиотека и модель GigaAM v3 и Silero VAD устанавливаются внутри образа. Python, отдельный Node.js и системный FFmpeg на компьютере не требуются.
# Только для нового клона; Twitch можно подключить через панель.
Copy-Item config.example.json config.json
# Первый запуск: подготовить локальные пароли через Docker (существующие не меняются).
docker run --rm --mount "type=bind,source=${PWD},target=/workspace" -w /workspace --entrypoint bun oven/bun:1.4.3 run scripts/prepare-database.ts
docker compose up --buildПеред запуском замените example_channel в config.json на нужный логин Twitch.
Ники и отображаемые имена в примерах и тестах вымышленные.
Откройте localhost:3000. Вход в саму панель пока не нужен,
Compose публикует порт только на локальном компьютере. Аккаунт бота подключается
кнопкой «Войти ботом»: один раз укажите Client ID своего Public-приложения Twitch
и подтвердите вход на Twitch. Токены сохраняются и обновляются сервисом.
Подробнее — настройка входа.
При первом запуске
появится комната из config.json; агент начнёт работу после нажатия кнопки.
Новые комнаты создаются с черновиками без отправки, режим чата выбирается при
добавлении. После перезапуска все комнаты снова на паузе.
После запуска комнаты бот ждёт подтверждения эфира. Пока канал оффлайн, AI не вызывается, модель распознавания не загружается, сообщения не отправляются. Статус проверяется через Twitch Helix раз в 15 секунд; дополнительные права токена не нужны. С началом эфира включаются AI и звук, приветствие отправляется при готовом чате с прежним ограничением одной попытки за день. После окончания эфира бот снова молчит и ждёт следующую трансляцию.
В панели видны анимированный агент, облачко анализа, сообщения, речь, ответы
и расход токенов текущей сессии. Список комнат хранится в томе dashboard_data,
история ответов и память пользователей — в PostgreSQL.
Остановка — Ctrl+C. По умолчанию Pino + pino-pretty выводят читаемые сообщения
в stdout. Ответ AI выделяется блоком ---, чат и речь показываются компактно.
Для машинного JSON задайте $env:TWBIT_LOG_FORMAT = 'json' перед docker compose up.
Переменная LOG_FORMAT внутри контейнера выбирает формат непосредственно.
Ошибки содержат stack trace; поля токенов скрываются.
Уровень задаётся переменной LOG_LEVEL (по умолчанию info, подробности — debug).
При первой сборке скачиваются GigaAM v3 (архив около 156 МиБ) и Silero VAD (2,2 МиБ).
Дальнейшие сборки используют Docker-кеш.
# Работа в фоне и просмотр вывода.
docker compose up -d --build
docker compose logs -f
# Остановка сервисов с сохранением тома PostgreSQL.
docker compose down
# Только чат.
docker compose run --rm twbit --chat-only
# Только речь; токен чата для этого режима не требуется.
docker compose run --rm twbit --speech-only
# Ограниченный по времени запуск для проверки.
docker compose run --rm twbit --speech-only --duration=60Аргументы CLI вместо --dashboard сохраняют прежний запуск одного канала
из конфига. Не запускайте его одновременно с агентом в той же комнате панели.
config.json находится рядом с index.ts, исключён из Git и Docker-контекста.
Compose монтирует его в контейнер только для чтения; токены не попадают в образ.
Для нового клона
скопируйте config.example.json в config.json. В панели можно войти без ручного
токена; для прежнего CLI укажите токен в поле accessToken.
Префикс oauth: необязателен. Токен не печатается в консоль.
Codex CLI и MCP для запуска не нужны.
clientId необязателен: при заполнении он проверяется на соответствие токену.
refreshToken в конфиге — прежнее поле без автоматического обновления.
Вход через панель использует своё хранилище и обновляет OAuth-токены автоматически.
Выбрана русская GigaAM v3 CTC с пунктуацией, INT8 ONNX, CPU. Silero VAD выделяет голосовые фрагменты: пауза 0,5 с завершает фразу, непрерывная речь делится примерно каждые 8 с. Перед началом фрагмента сохраняется до 300 мс исходного звука, чтобы позднее срабатывание VAD не обрезало тихое начало. Чувствительный порог 0,15 сохраняет короткие тихие ответы; при музыке его можно поднять ценой пропусков речи. Входные блоки — 100 мс; к задержке добавляются буферизация Twitch и вычисления. FFmpeg начинает с последнего доступного сегмента плейлиста.
Код приложения написан на TypeScript. GigaAM запускается через нативный
sherpa-onnx-node в отдельном Bun worker. Главный поток продолжает обслуживать
чат. FFmpeg внутри контейнера преобразует звук в mono float32 PCM 16 кГц.
Очередь обработки ограничена обратным давлением: следующий блок передаётся
после обработки предыдущего.
Звук получается анонимно через веб-плейлист Twitch. По возможности выбирается аудиодорожка, иначе минимальное качество видео с извлечением звука. Аккаунтный токен чата для запросов воспроизведения не используется. Это внутренние endpoints веб-плеера, а не официальный Helix API: ограничения просмотра, реклама, отключённый стрим или изменения Twitch могут прервать звук. При ошибке приложение повторяет подключение через 15 секунд; чат продолжает работать.
Дополнительные настройки в config.json необязательны:
{
"speech": {
"enabled": true,
"threads": 2,
"partialResults": false,
"vad": { "threshold": 0.15, "minSilenceSeconds": 0.5, "minSpeechSeconds": 0.15, "maxSpeechSeconds": 8 }
}
}threads ограничивает число CPU-потоков модели. Выдаются только законченные фразы;
partialResults должен быть false. speech.modelDir и
speech.ffmpegPath позволяют использовать свои пути; для готового образа менять их не нужно.
Исходный config.json приложение не перезаписывает.
Игра, музыка, несколько голосов, игровые имена и иностранные слова могут снижать
точность. VAD не выделяет отдельный микрофон и может принять музыку за речь.
Распознаётся общий звук эфира; автор речи остаётся unverified.
В JSON-логах фраз есть model, audioSeconds, decodeMs для оценки скорости.
Интеграция использует Chat Completions API OpenRouter; в примере конфига выбрана
модель google/gemini-4-argon через google-ai-studio/flex.
Сохранена поддержка openai/gpt-6.1-sol и Z.AI Coding Plan с glm-5.3-flash.
Ключ выбранного провайдера берётся из ai.accessToken; корневой accessToken
по-прежнему относится к Twitch. Дополнительные npm-пакеты не требуются: HTTP делает Bun.
AI включается только явным ai.enabled: true; по умолчанию он выключен.
Все настройки приведены в config.example.json, основные поля:
{
"ai": {
"enabled": false,
"delivery": "console_only",
"provider": "openrouter",
"accessToken": "YOUR_OPENROUTER_API_KEY",
"baseUrl": "https://openrouter.ai/api/v1",
"model": "google/gemini-4-argon",
"providerRouting": { "only": ["google-ai-studio/flex"], "allowFallbacks": false },
"prePrompt": "Ты участник чата. Отвечай коротко и естественно.",
"strategy": "balanced",
"strategyPrompt": "",
"reasoningEffort": "medium",
"maxTokens": 3072,
"timeoutSeconds": 12,
"batch": { "size": 12, "maxWaitSeconds": 12, "minIntervalSeconds": 8 },
"reply": { "cooldownSeconds": 40, "maxCharacters": 160, "maxWords": 24, "minConfidence": 0.7, "maxAgeSeconds": 30 },
"stream": { "reasoningEffort": "medium", "debounceSeconds": 2, "minIntervalSeconds": 4, "cooldownSeconds": 10, "maxAgeSeconds": 15 },
"review": { "enabled": true, "timeoutSeconds": 6, "maxTokens": 1536 },
"dialogue": { "enabled": true, "debounceSeconds": 1, "minIntervalSeconds": 3, "windowSeconds": 120 },
"memory": { "enabled": true, "minConfidence": 0.85, "maxItemsPerUser": 5, "maxCharacters": 4000,
"retentionDays": 90, "momentRetentionDays": 14 }
}
}prePrompt задаёт образ, стиль и общие правила. strategy выбирает приоритет:
balanced — общий разговор, streamer-first — тема эфира/стример,
chat-first — разговор зрителей. strategyPrompt добавляет свои правила стратегии.
Стандартный образ в src/ai/default-pre-prompt.ts — живой участник чата:
контекстные подколы, троллинг, уместный крепкий мат и словотворчество по ситуации.
«связь, грязь» — редкое одобрение удачной чужой шутки, со смыслом «смешно написал».
Манера — язвительная, с сухим сарказмом и колкими подколами по конкретной детали.
Модель может сама подхватить ясную смешную ситуацию в эфире: вопрос или упоминание
бота для этого не нужны. Продолжающийся рассказ сам по себе не повод молчать.
На подкол отвечает подколом, без оправданий и самоунижения; фактическую ошибку
признаёт кратко. Отказ определяет по контексту и уважает в его пределах:
ответный стёб с матом сам по себе не запрещает общение. Взаимный стёб можно продолжить,
без комментария к каждому аудиофрагменту, дежурной похвалы и натянутых метафор.
Обращение на «ты», ник адресата в текст не добавляется; эмодзи не используются.
В тексте реплики пунктуация ограничена запятыми: без длинных тире, кавычек,
скобок, многоточий и точки в конце. Ники, ссылки и дефисы внутри слов сохраняются,
правильный JSON и дословные опоры facts.quote это правило не меняет.
Это самостоятельный участник разговора: он не выступает помощником, модератором
или представителем канала, не раздаёт советы без запроса и не отвечает за автора канала.
Сам не начинает техническую самопрезентацию; на прямой вопрос о том, бот ли он,
отвечает честно, без выдуманной человеческой биографии.
Свой ai.prePrompt полностью заменяет этот образ. Изменение применяется после
перезапуска приложения; config.example.json содержит тот же стандартный текст.
Системная инструкция сокращена: характер, источники, правила ответа и JSON-контракт.
Разборы прежних ошибок остаются в тестовых сценариях.
ai.provider выбирает формат запроса: openrouter или zai. У OpenRouter baseUrl
задаёт https://openrouter.ai/api/v1; полный адрес с /chat/completions тоже допустим.
Без явного provider OpenRouter определяется по hostname, старые конфиги Z.AI продолжают работать.
Для Z.AI задайте provider=zai, baseUrl=https://api.z.ai/api/coding/paas/v4,
model=glm-5.3-flash и ключ Coding Plan в ai.accessToken.
В config.json и config.example.json выбран google/gemini-4-argon через OpenRouter.
ai.providerRouting.only задаёт точные slug доступных маршрутов; сейчас это только
google-ai-studio/flex. В API настройка уходит как provider.only, а allowFallbacks
как provider.allow_fallbacks. Ограничение действует и на генерацию, и на проверку смысла.
При недоступности Flex ответ не отправляется; смены модели или провайдера нет.
Пустой список, неверный slug и неизвестные поля маршрутизации отклоняются при старте.
reasoningEffort допускает low, medium, high или max для OpenRouter;
у Z.AI сохранены low, high, max. OpenRouter получает reasoning.effort,
для Gemini 4 Argon доступны только low, medium, high; max отклоняется при старте.
GLM — включённое thinking и reasoning_effort. Параметр temperature применяется к Z.AI;
в OpenRouter-запросах параметры сэмплирования пропускаются, используются настройки модели.
Также настраиваются maxTokens и timeoutSeconds. Если они не указаны, выбран low,
лимит вывода 3072 токена с учётом thinking и таймаут генерации 12 с.
Используются обычные запросы: ai.batch накапливает события внутри приложения,
OpenRouter Batch не используется; Flex выбирается точным маршрутом провайдера.
Провайдер и фактическая модель сохраняются в статистике, запрошенный маршрут — в истории,
а температура, которая не отправляется, в истории обозначается null.
Лимит оставляет больше места финальному JSON; при его исчерпании ответ отклоняется.
Память записывается после анализа параллельно доставке; медленная запись не держит ответ.
Для openai/gpt-6.1-sol через OpenRouter системная часть промпта остаётся постоянной,
а пример ответа для конкретной реплики находится в user.replyExample. На системном
блоке стоит явная граница кэша, prompt_cache_options={mode:explicit,ttl:30m};
меняющийся контекст после неё не записывается в кэш. session_id стабилен для канала
и назначения (twbit:<channel>:analysis или twbit:<channel>:review). Первый запрос
может оплачивать запись префикса, следующие — чтение при попадании в кэш.
Фактические попадания зависят от провайдера и минимального размера префикса.
Перед HTTP-запросом CompactPrompt заменяет UUID событий короткими ссылками e1/e2,
убирает дубли messageId, clientId, scopes и прочие неиспользуемые поля.
Тексты, точные цитаты, ники, userId, даты, память и связи ответов сохраняются.
При наличии родителя в контексте его reply.messageId совпадает с коротким id события;
для остальных родительских и собственных сообщений используются ссылки m1/m2.
Ответ модели переводится обратно в исходные ID до проверки фактов, записи памяти и
отправки. Неизвестная ссылка в решении отклоняет ответ; неверная необязательная запись
памяти отбрасывается отдельно. Короткие ID действуют только в одном запросе.
Параметры явного кэша отправляются только проверенной модели Sol через OpenRouter;
для Gemini 4 Argon они не отправляются: у него автоматический кэш провайдера,
попадания видны только по фактическому usage. Z.AI сохраняет свой формат HTTP-запросов.
ai.reasoningEffort относится к чату; у реакций на эфир отдельный
ai.stream.reasoningEffort (по умолчанию low). Проверка смысла всегда использует low.
При смене модели сохраняются уровни чата и эфира из конфига, проверка остаётся на low.
Срок эфира остаётся 15 с: более долгое мышление не разрешает отправлять поздние шутки.
Для вдумчивого диалога можно задать reasoningEffort=high, timeoutSeconds=60,
maxTokens=4096, reply.maxAgeSeconds=90. У эфира при этом сохраняются low и срок 15 с.
Один timeoutSeconds=60 не расширяет срок актуальности: фактический бюджет генерации —
минимум из таймаута API и остатка срока события за вычетом проверки и 0,5 с запаса.
Для эфира с накоплением 2 с и проверкой 4 с это обычно около 8 с. high/max в таком
окне могут не успеть; отдельный уровень эфира можно изменить явно, учитывая этот предел.
При старте и в ai.analysis логируются выбранный уровень и реальный бюджет генерации.
Истёкший срок события — ai.stale, outcome=cancelled, errorCode=RESPONSE_DEADLINE;
это пропуск запоздалой реакции. Настоящий таймаут API остаётся ошибкой TIMEOUT.
Обычный чат анализируется после 12 новых сообщений или через 12 секунд накопления. Между началами таких анализов — минимум 8 секунд, после отправленной фоновой реплики в чат — пауза 40 секунд. Обращения к боту и звук эфира имеют независимые интервалы. Одновременно обрабатывается один анализ с проверкой; в пустом канале запросов нет. Сообщения и ASR учитываются раздельно: диалог не помечает ожидающую речь как разобранную.
ai.context.earlierSpeech сохраняет более ранний разговор эфира: по умолчанию до
300 секунд и 4000 символов JSON, максимум 256 исходных фрагментов в памяти.
Соседние ASR-фрагменты соединяются дословно в короткие блоки, без вызова AI.
Они передаются как earlierSpeech в анализ и проверку смысла, сохраняются в контексте
аудита, но не получают ID новых событий и не подтверждают личные факты.
Так план игр не исчезает только из-за десяти следующих коротких реплик.
История ограничена бюджетом, относится к текущему процессу и не восстанавливается
после перезапуска. Свежесть повода по-прежнему проверяется отдельно.
Для эфира ai.stream: накопление 2 с, минимум 4 с между началами анализа,
собственная пауза 10 с. Для самостоятельных реплик чата и эфира действует общая
пауза ai.reply.cooldownSeconds (40 с); после реакции на эфир берётся максимум
двух пауз. Прямые ответы зрителям её не сдвигают и не ждут её окончания.
Аудио старше 15 с после получения ASR остаётся контекстом, но не поводом для ответа.
Возраст проверяется до генерации, после неё и после проверки смысла. Время на проверку
резервируется заранее. Устаревший ответ отбрасывается, даже если рядом появились свежие
события; повторно в очередь он не ставится. Задержка самого Twitch и ASR в эти 15 с не входит.
В контексте сохраняются последние 40 сообщений чата и 10 окончательных фрагментов
речи, максимум за 90 секунд. ai.context.maxCharacters ограничивает JSON событий
до 9000 символов; инструкции, память и последние 6 собственных реплик передаются дополнительно.
Если есть оба источника, 35% этого бюджета резервируется звуку эфира.
Лимиты и окно времени находятся в ai.context.
При переполнении в анализ попадёт наиболее свежий контекст; полного архива здесь нет.
У каждого события есть ID, время, автор и явный источник:
source: chat,speaker: streamer— автор канала написал в чат; определяется по Twitch login/user ID или broadcaster badge, а не по произвольному тексту сообщения.source: chat,speaker: viewer— сообщение зрителя.source: stream_audio,speaker: unverified— ASR общего звука эфира. Эта метка не подтверждает, что говорит именно стример: возможны игра, гости и ошибки ASR.
Модель возвращает JSON по версионированному контракту из src/ai/reply-contract.ts.
Одна и та же схема передаётся в системной инструкции и проверяется кодом:
{
"schemaVersion": 1,
"action": "reply",
"target": { "kind": "viewer", "login": "viewer_login" },
"replyToEventId": "chat:message-id",
"text": "Текст предлагаемого ответа",
"reason": "Краткая причина решения",
"confidence": 0.85,
"eventIds": ["chat:message-id"],
"facts": []
}Для личных утверждений и фактических предпосылок подкола модель должна добавить
facts: [{"eventId":"chat:message-id","quote":"Я взял блинк"}].
Код проверяет источник, адресата, наличие цитаты в исходном тексте, явное сообщение
о себе, отсутствие вопроса/отрицания/плана и наличие чисел в цитатах. Попытка
превратить совет зрителя или ASR в факт «ты уже взял блинк» отклоняется перед доставкой.
Опоры сохраняются вместе с решением в истории. facts необязателен для старого
JSON v1, но распознанное личное утверждение без опоры отклоняется и тогда.
Для обычной иронии без личного утверждения достаточно eventIds, facts=[].
Если модель всё же добавила разговорную цитату в facts, код проверяет её источник
и дословность, но не требует слов «я/мне/мой» и не запрещает отрицание.
Такая цитата по-прежнему не подтверждает придуманную покупку или точное число.
Это консервативная проверка распространённых конструкций, не универсальный
семантический фактчекер. Системная инструкция дополнительно запрещает домысливать
предметы, имена, действия и личные обстоятельства ради шутки.
Кавычки в готовом ответе допустимы только для дословной подстроки выбранного сообщения
chat. Вымышленная цитата, пересказ в кавычках и цитата только из ASR отклоняются кодом,
включая случай facts=[]. Само слово «твой» или «у тебя» больше не блокирует шутку.
Ники с цифрами и известные по точному тексту чата названия вроде Silent Hill 2
или CS2 не считаются числовым утверждением о человеке. Личные количества,
владение и совершённые действия по-прежнему требуют собственной цитаты адресата;
совпадение одной цифры или название только из ASR такого исключения не дают.
Вхождение глагола внутри другого слова тоже не считается совершённым действием.
Промпт требует одну короткую фразу; жёсткие пределы — 24 слова и 160 символов.
Длинный ответ отклоняется целиком, без обрезки посередине мысли.
После дешёвых проверок каждый непустой черновик проходит отдельную проверку смысла
той же моделью и через того же провайдера: ai.review, мышление low, по умолчанию до 6 с.
Температура 0.1 отправляется только Z.AI.
Проверка получает исходный контекст разговора, earlierSpeech, последние ответы бота
и до 6 обновлений, пришедших во время генерации. Она проверяет отрицания, стороны
сравнения, атрибуцию, выдуманные предпосылки, понятность шутки и актуальность темы.
Возвращает только accept/reject и причину. При отказе, ошибке, незавершённом JSON
или таймауте отправки нет; дополнительной попытки переписать ответ нет.
Это дополнительный API-запрос для допустимого черновика, его токены учитываются отдельно.
Для skip и ответов, отклонённых кодом, он не выполняется. Модельная проверка тоже
может ошибаться; реальные качество и задержку нужно оценивать после запуска.
action: reply — ответ на событие; start_conversation — начать разговор,
тогда replyToEventId=null; skip — молчание, тогда target=null,
replyToEventId=null, text="". target.kind: viewer, streamer или chat.
Для всего чата login=null, для зрителя нужен существующий login, для стримера —
login канала. Z.AI получает response_format: json_object; проверку полной схемы
и отношений между полями выполняет приложение.
Если модель опустила только eventIds, для skip подставляется пустой массив,
для reply — явно выбранный replyToEventId. Этот ID по-прежнему проверяется
по переданным событиям и их свежести. Для start_conversation источники не
додумываются; отсутствие массива или неверный тип остаются ошибкой формата.
Дополнительного запроса к модели для исправления JSON нет.
Приложение проверяет формат, длину, порог уверенности, свежесть и связь с новыми событиями, отсеивает точные повторы. Частичные ASR-результаты и повторные ID чата не учитываются. Инструкции внутри чата/расшифровок передаются как недоверенные цитаты.
ai.delivery=console_only оставляет ответы в консоли; ai.delivery=chat включает
реальную отправку от аккаунта корневого Twitch-токена. В обоих режимах результат
виден в логе event: ai.reply с фактическим delivery.
В JSON-режиме решение лежит в объекте decision, готовом для дальнейшего парсинга.
Поле accepted показывает, прошло ли оно фильтры приложения: true у ai.reply,
false у ai.skip. Полное решение модели сохраняется в логе и при пропуске.
В обычной консоли оно выглядит так:
---
Ответить → @viewer_login
Текст предлагаемого ответа
---
Для отправки через API нужно право user:write:chat; для существующего IRC —
chat:edit. В режиме auto при наличии только IRC-права выбирается IRC.
При явно выбранном EventSub отправка требует user:write:chat.
Права проверяются при запуске, без отправки пробного сообщения. Если права не хватает,
чтение работает, а ошибка отправки будет видна в логе.
Ответ на сообщение чата уходит с reply parent ID; начало беседы с человеком — с
одним @login. Начальное обращение к адресату, добавленное моделью, удаляется:
в треде ник отображает сам Twitch. Упоминания других людей и ники внутри фразы сохраняются.
API должен вернуть is_sent=true и ID сообщения; IRC — USERSTATE с ID.
Только после подтверждения реплика попадает в previousReplies как отправленная.
Черновики имеют delivery=console_only и не открывают диалог. Ошибка отправки,
отказ модерации или отсутствие подтверждения — ai.send_failed, без автоповтора.
Команды /..., управляющие символы и превышение 500 символов отправитель отклоняет.
При первом успешном подключении к каналу за календарный день бот отправляет
короткое приветствие без вызова AI. В корневом greeting задаются enabled,
timeZone (по умолчанию Asia/Yekaterinburg) и список messages, из которого
случайно выбирается фраза вроде всем здарова или всем привет.
Нужны ai.enabled=true, ai.delivery=chat, права отправки и включённый PostgreSQL.
В --check, --once, --speech-only и режиме консоли приветствие не отправляется.
Таблица twbit.daily_greetings хранит день отдельно для канала и аккаунта бота,
текст, статус попытки и подтверждённый ID сообщения. Уникальная отметка записывается
до отправки, поэтому переподключение, перезапуск и несколько копий сервиса не
дублируют приветствие. При сбое БД отправка пропускается; при неопределённом
результате отправки повторов в этот день нет. Переход через полночь сам по себе
не запускает приветствие, нужен новый вход в канал. Подтверждённая реплика
попадает в контекст ответов, в логе выделяется блоком chat.greeting.
Ответ на сообщение бота или точное обращение по его нику запускают анализ
после 1 секунды накопления,
с интервалом минимум 3 секунды между началами запросов. В этой ветке действуют отдельные интервалы вместо
обычных 12 событий / 12 секунд / паузы 40 секунд. Модель может выбрать skip;
ускоренная ветка принимает только ответ на одно из обращений к боту.
Сообщения прежнего собеседника без reply/упоминания остаются обычным контекстом:
автоматический follow_up по совпадению автора убран. windowSeconds (120 с)
ограничивает хранение ID собственных сообщений для распознавания reply без
метаданных автора родителя. Собственные сообщения игнорируются.
Настройки — ai.dialogue; 429 задерживает все три ветки.
Команды вроде !iq, !член, !tg остаются в контексте, но не запускают анализ,
не учитываются в размере разговорной порции и не становятся поводом для ответа.
Это относится и к командам с невидимыми символами. Разговор с другим зрителем
не открывает диалог с ботом. Адресата и смысл обращения дополнительно проверяет модель.
Окно собственных реплик для промпта и известные ID живут в памяти процесса;
аудит решений и отправок сохраняется в PostgreSQL (см. ниже).
Цепочки известных реакций вроде Kappa Kappa, отдельные эмодзи и пунктуационный
спам не открывают ускоренный диалог. Такие события остаются в контексте,
но ответ на них также отклоняется перед отправкой. Обычные короткие ответы
вроде «да» и поправки вроде «я уже 2 года не играю» этим фильтром не отбрасываются.
В priorityEventIds модель получает точные события, на которые допускается
ответ в ускоренной ветке, и соответствующий пример JSON.
Пропуск виден как ai.skip, ошибка API — как ai.error; чат и ASR продолжают работать.
После 401/403 AI приостанавливается до исправления доступа и перезапуска; после 429
выдерживается минимум 60 секунд. Ошибочная порция повторно автоматически не отправляется,
но её свежий контекст может войти в следующую порцию с новыми событиями.
Настройки читаются при старте. --check не обращается к AI. Ключи исключены из
Docker-образа вместе с config.json; в логи ключ и reasoning_content не выводятся.
При включённом AI выбранные тексты чата, логины, user ID, расшифровки и релевантная
память отправляются выбранному провайдеру OpenRouter или Z.AI.
Работающий контейнер сам новую версию исходников не подхватит.
Документация провайдера: Coding Plan endpoint, Chat Completions и параметры GLM. Отправка Twitch: API, IRC и USERSTATE. Провайдер описывает Coding Plan как тариф для coding-сценариев; допустимость и доступность сценария Twitch на вашем тарифе этим кодом не проверяются.
При ai.memory.enabled=true и включённой базе тот же AI-запрос дополнительно
возвращает необязательный memoryUpdates. Извлечение работает даже при skip,
отдельных запросов к модели для памяти нет. Некорректные предложения памяти
отбрасываются отдельно от ответа.
{
"memoryUpdates": [{
"userId": "123456", "kind": "fact", "key": "favorite_hero",
"content": "Любимый герой — Puck", "confidence": 0.95,
"eventIds": ["chat:message-id"]
}]
}fact — полезное сведение о себе (интересы, предпочтения, цели), moment —
заметный эпизод или результат. Система принимает до 5 записей на порцию с
уверенностью от 0.85. Каждый источник должен быть новым сообщением chat от
того же Twitch user ID. ASR, старые события и чужие сообщения не подтверждают
память пользователя. Отбор самоописаний и отличение шуток остаются задачей модели;
это извлечённые утверждения, а не проверенные биографические факты.
Таблица twbit.user_memories хранит краткое содержание, ID пользователя, канал,
тип/ключ темы, уверенность, время, срок действия и до 3 выбранных цитат-источников
(до 1000 символов каждая). Полного архива сообщений нет. Память разделена по каналам
и стабильным Twitch user ID, текущий ник берётся из нового события чата.
Новый факт по тому же ключу заменяет старый; повтор того же события не продлевает
срок, а более старое событие не перезаписывает новое. Моменты дедуплицируются по
источникам. Семантического объединения разных ключей пока нет.
Факты действуют 90 дней, моменты — 14. Истёкшие записи не читаются и удаляются
при следующих сохранениях в этом канале. На пользователя хранится до 40 записей.
Перед анализом читается до 5 записей о каждом из максимум 12 свежих участников;
общий JSON-бюджет — 4000 символов. В промпте память помечена как исторический,
недоверенный контекст, актуальное сообщение важнее неё. Настройки — ai.memory.
При недоступности БД анализ идёт без памяти; несохранённая порция не ставится
в отдельную очередь. Логи memory.saved, memory.filtered, memory.save_failed
показывают состояние без вывода самих фактов.
Миграция 002_user_memories.sql применится при обычном запуске приложения.
Проверки Twitch/AI используют подменённые отправители и HTTP; SQL-тест дополнительно
запускается только с TEST_DATABASE_URL на отдельной пустой тестовой базе,
создаёт схему внутри транзакции и откатывает её. Рабочий config.json тесты не читают.
При включённой БД database.history.enabled=true включает аудит в
twbit.reply_history. Миграция 003_reply_history.sql применяется при следующем
запуске. Сохранение начинается с новой версии; старые логи автоматически не импортируются.
На один анализ приходится одна итоговая запись:
sent— Twitch подтвердил отправку, есть реальныйmessage_id;console_only— черновик показан в консоли;skipped— модель выбрала молчание;rejected— предложение отклонила проверка приложения;send_failed— отправка не подтверждена, автоматического повтора нет;analysis_error/cancelled— ошибка AI или остановка приложения.
Сохраняются JSON-решение модели, причина результата, адресат и его Twitch ID,
предложенный и фактически отправляемый текст, reply parent ID, время, модель и её
параметры. context — исходный контекст с полными ID: выбранные сообщения/ASR,
newEventIds, priorityEventIds, память пользователей, предыдущие реплики и
аккаунт бота. context.modelRequest.context — фактический компактный JSON, отправленный
модели, а context.modelRequest.references — таблица коротких и исходных ID.
system_prompt хранит применённые инструкции. Для проверки ответа такой снимок
расположен в review.request. Снимок фиксируется до HTTP-вызова и сохраняется также
при ошибке или таймауте. U+0000 удаляется
при записи: PostgreSQL не может хранить этот символ в text/jsonb.
Миграция 004_reply_review.sql добавляет review и повод trigger=stream.
Она применится автоматически при следующем запуске новой версии; рабочая база при
сборке и тестах не меняется. review хранит accept/reject/unavailable, причину,
время проверки, её отдельный requestId и обновления разговора, использованные при проверке.
db:history выводит reviewTokens и reviewDurationMs отдельно от генерации.
У каждой проверки своя строка ai_requests, поэтому общая статистика расхода учитывает
оба API-запроса. В обычных логах видны тип анализа и возраст события при доставке ответа.
ID записи совпадает с twbit.ai_requests.id и analysisId в JSON-логах;
можно сопоставить расход с реальной отправкой. ai_requests.status=success
показывает успешный ответ модели, а reply_history.outcome=sent — подтверждение
Twitch. При send_failed поле outgoing_text содержит попытку отправки,
оно не доказывает доставку. Ключи и HTTP-заголовки в историю не передаются;
сохраняется только обычный текст ответа, без скрытого reasoning модели.
История — аудит. Она автоматически не подмешивается целиком обратно в промпт.
Сохраняются только порции, действительно переданные модели, а не весь поток чата.
Срок хранения по умолчанию 30 дней, настройка database.history.retentionDays
(1–365). Writer удаляет старые записи при старте и затем раз в час. При выключенном
сервисе эта очистка не выполняется.
# Последние 20 подтверждённых ответов текущего канала, без обращения к AI/Twitch.
docker compose run --rm --no-deps --entrypoint bun twbit run db:history
# Все исходы вместе с контекстом, включая причины молчания и ошибок.
docker compose run --rm --no-deps --entrypoint bun twbit run db:history --all --context --limit=30
# Можно добавить --user-id=123, --id=UUID, --outcome=send_failed и --prompt.Команда выводит JSON по одной записи в строке. --context добавляет разговор,
--prompt — системные инструкции и параметры модели. --all показывает все
исходы; без него выбирается только sent. Новую версию собирайте и запускайте
обычным docker compose up --build; миграция не требует удаления тома.
Compose запускает PostgreSQL 18.6 в сервисе postgres, постоянные данные — в
именованном томе postgres_data. Порт на компьютере не публикуется. Приложение
использует собственную роль twbit без superuser/CREATEDB/CREATEROLE;
администраторский пароль приложению не монтируется.
Пароли находятся в .secrets/, исключены из Git/образа и передаются как Docker secrets.
Файлы нужно сохранять вместе с данными; db:prepare не меняет существующие пароли.
Раздел database в конфиге включает хранилище и задаёт hostname, port, database,
username, passwordFile, tls и maxConnections (по умолчанию 2). Compose ждёт healthcheck
PostgreSQL. При запуске приложение применяет SQL-миграции из migrations/:
транзакция, advisory lock и контрольная сумма защищают от повторов и параллельного старта.
Применённые миграции не редактируйте — изменения схемы добавляются новым файлом.
В twbit.ai_requests записывается одна строка на попытку AI-запроса:
- время, канал, провайдер, запрошенная/фактическая модель и ID запроса;
- длительность, HTTP-статус, итог
success/error/cancelled, причина ошибки; - input/output/total tokens и, если провайдер сообщает, cached/reasoning tokens;
- действие модели:
reply,start_conversationилиskip.
Расход фиксируется до разбора решения: неполный или некорректный ответ тоже может
расходовать токены. Отсутствующие счётчики хранятся как NULL, а не как ноль.
Cached/reasoning — части input/output, их не нужно повторно прибавлять к total.
Миграция 005_prompt_cache_usage.sql добавляет cache_write_input_tokens в запросы
и дневную сводку, а также число запросов с известным счётчиком записи.
cached_input_tokens — чтение кэша, cache_write_input_tokens — запись; оба входят
во входные токены. Неизвестный расход остаётся NULL. Эти показатели позволяют
оценить пользу кэша с учётом платной записи префикса.
Деньги и кредиты тарифа из токенов автоматически не вычисляются.
Промпты, тексты чата, расшифровки, ответы и API-ключи в эту таблицу не пишутся.
decision_action — решение модели, оно не означает отправку в чат или прохождение
всех фильтров приложения. Память пользователей хранится отдельно в twbit.user_memories.
twbit.usage_daily даёт сводку по дню UTC, каналу, провайдеру и модели:
число запросов/ошибок, известный расход и число запросов с доступными счётчиками.
# Применить миграции / посмотреть сводку без запуска AI.
docker compose run --rm --entrypoint bun twbit run db:migrate
docker compose run --rm --entrypoint bun twbit run db:stats
# Проверить реальную запись, повторы и сводку внутри откатываемой транзакции.
docker compose run --rm --entrypoint bun twbit run db:check
# Открыть psql для просмотра базы.
docker compose exec postgres psql -U postgres -d twbitОбщий writer статистики и истории не блокирует ответы: при недоступности БД
сохраняет до 1000 записей в памяти, из них не более 100 записей истории с контекстом,
и повторяет запись раз в 10 секунд. Порции записываются в транзакции.
UUID и ON CONFLICT исключают двойной
подсчёт при повторе. При штатной остановке запись ожидается; аварийное завершение
при недоступной БД может потерять очередь в памяти. Об этом и переполнении есть error-лог.
docker compose down сохраняет том базы. Регулярные резервные копии пока не настроены.
По умолчанию транспорт выбирается после проверки токена в Twitch:
user:read:chat→ EventSub WebSocket, подпискаchannel.chat.message.chat:read→ Twitch IRC через WebSocket.
Оба транспорта получают события от Twitch без периодического опроса чата.
Для EventSub нужен пользовательский токен с user:read:chat; существующее право
chat:read относится к IRC и не заменяет его.
Зависимостей для WebSocket нет: используется встроенный клиент Bun.
# Явно потребовать EventSub; при отсутствии прав будет понятная ошибка.
docker compose run --rm twbit --chat-only --transport=eventsub
# Явно выбрать IRC или другой канал.
docker compose run --rm twbit --chat-only --transport=irc --channel=example_channel
# Проверить токен и выбор транспорта, затем выйти без подключения к чату.
docker compose run --rm twbit --check
# Получить одно новое сообщение и закрыть только собственное подключение.
docker compose run --rm twbit --chat-only --once
# Завершить режим речи после первой окончательной фразы.
docker compose run --rm twbit --speech-only --once
# Проверить загрузку модели без подключения к Twitch.
docker compose run --rm twbit --speech-only --check
# Проверить TypeScript без сети, рабочего конфига и секретов.
docker run --rm --network none --entrypoint bun twbit:local run typecheck
# Проверить обработку сообщений и переподключение с mock-клиентами.
docker run --rm --network none --entrypoint bun twbit:local test
# Распознавание встроенного WAV, тишина и повторный flush: сеть и конфиг не нужны.
docker run --rm --network none --entrypoint bun twbit:local run smoke:speech
# Отдельный образ для сравнения старой и новой ASR на двух встроенных WAV.
docker build --target benchmark -t twbit:speech-benchmark .
docker run --rm --network none twbit:speech-benchmarkПри разрыве соединения скрипт переподключается; история за время отключения не восстанавливается. Подключение независимо от существующего сборщика чата: его процесс, файлы и настройки этот скрипт не использует для управления.
index.ts— точка входа и обработка сигналов завершения.src/application.ts— общий жизненный цикл чата и распознавания.src/stream/— ожидание эфира и отдельные AI/ASR-сеансы каждой трансляции.src/dashboard/— управление комнатами, хранение их настроек, HTTP и SSE.src/runtime/events.ts— события состояния без передачи ключей в интерфейс.src/auth/— вход Twitch, проверка сессии, обновление и хранение токенов.web/— React-панель и анимированная сцена агента.scripts/build-web.ts— сборка React и CSS встроенным сборщиком Bun.src/chat-application.ts— классChatApplication: запуск, выбор транспорта, остановка.src/config.ts— загрузка, проверка настроек и аргументов командной строки.src/twitch-api.ts— классTwitchApi: авторизация и HTTP-запросы к Twitch.src/logger.ts— настройка Pino, уровней и скрытия токенов.src/log-format.ts— читаемое представление событий и блоков ответов.src/ai/ai-config.ts— настройки AI и их проверка.src/ai/conversation-buffer.ts— ограниченное окно контекста чата и эфира.src/ai/reply-prompt.ts— инструкции, стратегия и маркировка источников.src/ai/chat-client.ts— анализ разговора и проверка JSON-решения.src/ai/chat-completion.ts— общий HTTP-клиент, таймауты и учёт каждого API-запроса.src/ai/chat-request.ts— параметры Chat Completions для OpenRouter и Z.AI.src/ai/compact-prompt.ts— компактный API-контекст, восстановление ID и снимок для аудита.src/ai/reply-reviewer.ts— отдельная проверка смысла перед отправкой.src/ai/reply-scheduler.ts— независимые интервалы чата, диалога и эфира.src/ai/reply-contract.ts— версия JSON-контракта и его валидация.src/ai/reply-grounding.ts— проверка цитат и фильтр неподтверждённых личных утверждений.src/ai/analysis-contract.ts— ответ и необязательные предложения памяти.src/ai/reply-history.ts— итог анализа, контекст и результат доставки.src/ai/message-attention.ts— фильтр реакций без содержательного повода.src/ai/bot-dialogue.ts— обращения, собственные сообщения и окно диалога.src/chat-delivery.ts— интерфейс отправки и проверка исходящего текста.src/daily-greeting.tsиsrc/greeting-config.ts— приветствие при первом подключении за день.src/database/daily-greeting-repository.ts— отметки дней и итоги отправки приветствий.src/memory/— настройки, проверка источников и подмешивание памяти.src/database/postgres-memory-store.ts— хранение, обновление, выборка и очистка памяти.src/database/activity-recorder.ts— общая очередь статистики и истории.src/database/reply-history-repository.ts— запись и чтение аудита ответов.scripts/reply-history.ts— просмотр истории без запуска бота.src/ai/usage.ts— метаданные запроса и счётчики потребления.src/database/— конфигурация, пул PostgreSQL, миграции и запись статистики.migrations/— версионированные SQL-изменения схемы.src/ai/reply-assistant.ts— накопление событий, расписание анализа и вывод черновиков.src/clients/message-deduplicator.ts— ограниченная история ID для исключения дубликатов.src/clients/websocket-chat-client.ts— общий жизненный цикл соединений и переподключение.src/clients/irc-chat-client.tsиeventsub-chat-client.ts— отдельные классы протоколов.src/clients/irc-parser.ts— разбор IRC-потока с буферизацией неполных сообщений.src/speech/twitch-playback.ts— получение плейлиста звука Twitch.src/speech/ffmpeg-audio-source.ts— поток PCM от дочернего FFmpeg.src/speech/pcm-decoder.ts— сборка PCM-блоков из произвольных границ байтового потока.src/speech/speech-worker.ts— связь с worker и передача аудио с обратным давлением.src/speech/recognition.worker.ts,speech-backend.ts,gigaam-decoder.ts— GigaAM и VAD в worker.src/speech/audio-window.ts— ограниченный запас звука перед границей голоса.src/speech/tone-decoder.ts— прежний движок только для локального сравнения.src/speech/speech-transcription.ts— получение звука, обработка и восстановление подключения.
Документация: EventSub WebSocket, событие сообщения, Twitch IRC, GigaAM, Silero VAD, sherpa-onnx Node addon.