MediatorBot — экспериментальный приватный посредник для общения двух участников. Каждый участник пишет боту в личном Telegram-чате, а модель решает, нужно ли ответить автору, второму участнику или обоим.
Проект является MVP для технического тестирования идеи. Он не заменяет психолога, семейного терапевта или экстренную помощь.
Бот — тёплый и деятельный посредник, а не пассивный комментатор или постоянное место для накопления претензий. Для значимой незавершённой темы хороший ответ должен не только показать, что человек понят, но и внести движение: отделить факт от интерпретации, вернуть участнику его зону влияния, предложить вариант, первую фразу, ограниченную паузу или конкретный посреднический шаг.
Privacy boundary обеспечивается прежде всего выбором действия и содержания. Бот не проговаривает ограничения без повода; краткое объяснение появляется только при прямом запросе чужих слов или состояния, отказе в передаче либо открытии consent-based запроса. Простая просьба помочь сформулировать сообщение не считается разрешением немедленно отправить его партнёру.
Проактивность не является календарным check-in. Контакт оправдан, только когда он облегчает конкретный следующий шаг: безопасное начало разговора, короткий repair, проверку готовности к определённому действию или ограниченный посреднический процесс. Напоминание о конфликте без ожидаемого движения остаётся внутренней переоценкой и не отправляется.
- одна mediator session с двумя заранее разрешёнными Telegram-аккаунтами;
- приватная обработка сообщений через Telegram long polling;
- нативный индикатор
typingна время ожидания и обработки сообщения; - Fake runtime для локальной проверки без внешней модели;
- OpenAI Chat Completions и совместимые API, включая собственный endpoint;
- обязательный ответ модели через один из tool calls;
- зашифрованная SQLite-база на SQLCipher;
- восстановление истории после перезапуска;
- rolling compaction с фиксированным размером памяти и удалением только успешно сжатых сообщений;
- durable FIFO-обработка Telegram updates одной session по
UpdateIdс восстановлением после перезапуска; - ограниченные повторные попытки и quarantine неисправимых turns без блокировки следующих сообщений;
- сохранение результата модели до выполнения действий и durable delivery outbox по получателям и частям;
- уведомление участника только когда его сообщение действительно ожидает compaction;
- параллельная обработка разных session внутри одного процесса;
- lossless-разбиение длинных ответов на Telegram-сообщения до 4000 символов;
- запись подтверждённых частей под единым logical message и пропуск уже доставленных частей при восстановлении;
- graceful shutdown с прекращением приёма новой работы и ограниченным временем ожидания текущего turn;
- опциональный live heartbeat, который переоценивает уместность инициативы без фиктивного пользовательского сообщения;
- отдельные durable initiative decisions и outbox с повторной проверкой свежести перед отправкой;
- тихие часы 22:00–09:00 МСК, opt-out и жёсткий лимит инициативных контактов;
- технические логи без текстов переписки, токенов и tool arguments.
| Проект | Назначение |
|---|---|
MediatorBot.Core |
Доменная модель, application services и абстракции. Не зависит от Telegram и конкретного хранилища. |
MediatorBot.Infrastructure |
SQLCipher/SQLite persistence, Fake runtime и OpenAI-compatible runtime. |
MediatorBot.Telegram |
Telegram UI и composition root на базе TeleFlow. |
MediatorBot.Console |
Консольный стенд для локальной проверки mediation flow. |
MediatorBot.BehaviorScenarios |
Live-harness фиксированных disclosure- и proactive initiative-сценариев с Markdown-transcript. |
MediatorBot.Tests |
Unit- и integration-тесты Core, persistence, OpenAI protocol и Telegram adapter. |
- Telegram allowlist проверяет
UserIdотправителя. - Аккаунт сопоставляется с Participant A или Participant B.
- Update атомарно регистрируется и сохраняется в SQLCipher-таблицу
PendingTurns; queued updates выбираются поUpdateId. - Между turns проверяется необходимость compaction. Если она уже выполняется, ожидающий участник получает одно нейтральное уведомление; без ожидающих сообщений compaction незаметна.
- Для текущей
SessionIdзахватывается process-local lock. - Входящее сообщение сохраняется в зашифрованной базе.
- Контекст строится из фиксированной сжатой памяти, свежей общей истории, открытых mediated requests и текущего сообщения.
- Модель должна вызвать ровно один инструмент:
send_to_participant;send_to_both;open_mediated_request;resolve_mediated_request;cancel_mediated_request;no_action. Для отправки она также обязана классифицировать решение какPrivateResponse,MediatorDisclosure,ExplicitTransferилиSafetyDisclosure;no_actionполучаетNoActionавтоматически.
- Версионированный результат модели сохраняется в
PendingTurnsдо выполнения его действий и повторно используется после перезапуска. - Для каждого получателя и каждой Unicode-безопасной части до 4000 символов создаётся durable delivery plan.
- Части последовательно доставляются в Telegram и подтверждаются в outbox только после успешной доставки. В истории части одного ответа объединяются по
LogicalMessageId. - Неисправимый turn после ограниченного числа попыток помещается в quarantine и больше не блокирует FIFO.
Если при send_to_both доставка первому участнику успешна, а второму — нет, подтверждение первого сохраняется. При восстановлении первый получатель пропускается, а отправка продолжается со второго.
- .NET SDK 10;
- Telegram-бот, созданный через BotFather;
- Telegram UserId двух участников;
- API-ключ только при использовании OpenAI-compatible runtime.
Создайте MediatorBot.Telegram/appsettings.Development.json:
{
"ModelRuntime": "Fake",
"Storage": {
"DatabaseKey": "длинный-случайный-локальный-ключ"
},
"Telegram": {
"BotToken": "токен-от-BotFather",
"SessionId": "00000000-0000-0000-0000-000000000000",
"ParticipantAUserId": 111111111,
"ParticipantADisplayName": "Имя A",
"ParticipantBUserId": 222222222,
"ParticipantBDisplayName": "Имя B"
}
}Замените все значения-заглушки. Для каждой независимой тестовой сессии используйте новый непустой GUID.
PowerShell:
[guid]::NewGuid()Linux/macOS:
uuidgenФайлы appsettings.Development.json игнорируются Git и не публикуются.
В BotFather выполните:
/setjoingroups
и выберите Disable. Если бот всё же окажется в группе, супергруппе или канале, приложение попытается автоматически покинуть чат.
dotnet run --project MediatorBot.TelegramОба разрешённых участника могут проверить подключение и состояние командами:
/start;/status;/retry_failed— повторить обработку только собственных quarantined-сообщений;/help.
Fake runtime отправляет тестовый ответ автору сообщения и не обращается к внешнему API.
Измените development-конфигурацию:
{
"ModelRuntime": "OpenAI",
"Storage": {
"DatabaseKey": "длинный-случайный-локальный-ключ"
},
"OpenAI": {
"ApiKey": "ключ-провайдера",
"Endpoint": "https://api.openai.com/v1",
"Model": "gpt-4.1-mini",
"MaxOutputTokens": 1500,
"MaxAttempts": 3,
"RequestTimeoutSeconds": 120,
"RetryBaseDelayMilliseconds": 1000,
"RetryMaxDelaySeconds": 30
},
"Telegram": {
"BotToken": "токен-от-BotFather",
"SessionId": "00000000-0000-0000-0000-000000000000",
"ParticipantAUserId": 111111111,
"ParticipantADisplayName": "Имя A",
"ParticipantBUserId": 222222222,
"ParticipantBDisplayName": "Имя B"
}
}Для OpenRouter:
{
"OpenAI": {
"ApiKey": "ключ-OpenRouter",
"Endpoint": "https://openrouter.ai/api/v1",
"Model": "openai/gpt-4.1-mini",
"MaxOutputTokens": 1500,
"MaxAttempts": 3,
"RequestTimeoutSeconds": 120
}
}Выбранные endpoint и модель должны поддерживать:
- Chat Completions;
- function tools;
tool_choice: required;- параметр ограничения выходных токенов
max_tokens.
Модель не может возвращать обычный assistant text вместо действия. Такой ответ, пустой choices, некорректные arguments или несколько tool calls считаются ошибкой протокола и не доставляются участникам.
Основные параметры:
| Ключ | Описание |
|---|---|
ModelRuntime |
Fake или OpenAI. |
Storage:DatabasePath |
Путь к SQLite/SQLCipher-файлу. |
Storage:DatabaseKey |
Ключ шифрования базы; обязателен. |
Storage:MaxHistoryMessages |
Аварийный максимум свежих сообщений, передаваемых модели после compaction. |
TurnProcessing:MaxAttempts |
Максимальное число автоматических попыток turn до помещения в quarantine. |
TurnProcessing:RetryDelaySeconds |
Задержка между автоматическими попытками обработки turn. |
Compaction:Enabled |
Включает rolling compaction для OpenAI runtime. |
Compaction:TriggerMessageCount |
Порог количества свежих сообщений. |
Compaction:TriggerHistoryCharacters |
Альтернативный порог суммарного количества символов. |
Compaction:RetainRecentMessageCount |
Максимум недавних сообщений, сохраняемых дословно после compaction. |
Compaction:RetainRecentCharacters |
Целевой предел символов в сохраняемом свежем хвосте. |
Compaction:MaxSummaryCharacters |
Жёсткий общий предел четырёх секций сжатой памяти. |
Compaction:MaxOutputTokens |
Лимит ответа модели при создании сжатой памяти. |
Compaction:OperationTimeoutSeconds |
Общий timeout одной compaction. |
Compaction:RetryDelaySeconds |
Задержка до новой попытки после ошибки compaction. |
Initiative:Enabled |
Включает heartbeat проактивной оценки; по умолчанию false. |
Initiative:ShadowMode |
Сохраняет решения, но не отправляет их. Live-режим используется при false. |
Initiative:HeartbeatMinutes |
Частота дешёвой технической проверки; модель вызывается только при наступившей оценке. |
Initiative:MinimumQuietMinutes |
Минимальная пауза после последнего сообщения участника. |
Initiative:MaxHistoryMessages |
Максимум свежих сообщений в initiative-контексте. |
Initiative:RecentDecisionCount |
Максимум предыдущих initiative decisions в контексте. |
Initiative:MinimumReevaluationMinutes |
Нижняя граница назначаемой моделью повторной оценки. |
Initiative:MaximumReevaluationMinutes |
Верхняя граница повторной оценки; по умолчанию семь дней. |
Initiative:MaxContactsPerParticipantPer24Hours |
Жёсткий лимит доставленных инициатив одному участнику; по умолчанию 2. |
Initiative:MaxDeliveryAttempts |
Максимум попыток initiative delivery. |
Initiative:TimeZoneId |
Часовой пояс quiet hours; по умолчанию Europe/Moscow. |
Initiative:QuietHoursStartHour / EndHour |
Тихий интервал; по умолчанию 22:00–09:00. |
OpenAI:ApiKey |
API-ключ выбранного провайдера. |
OpenAI:Endpoint |
Базовый URL OpenAI-compatible API. |
OpenAI:Model |
Идентификатор модели у выбранного провайдера. |
OpenAI:MaxOutputTokens |
Ограничение выходных токенов. |
OpenAI:MaxAttempts |
Общее количество попыток обращения к модели. |
OpenAI:RequestTimeoutSeconds |
Общий timeout всех попыток и пауз между ними. |
OpenAI:RetryBaseDelayMilliseconds |
Начальная задержка exponential backoff. |
OpenAI:RetryMaxDelaySeconds |
Максимальная задержка, включая Retry-After. |
Telegram:BotToken |
Токен Telegram-бота. |
Telegram:SessionId |
Идентификатор активной session. |
Telegram:ParticipantAUserId |
Telegram UserId первого участника. |
Telegram:ParticipantADisplayName |
Имя, по которому медиатор обращается к первому участнику. |
Telegram:ParticipantBUserId |
Telegram UserId второго участника. |
Telegram:ParticipantBDisplayName |
Имя, по которому медиатор обращается ко второму участнику. |
Telegram:DeliveryTimeoutSeconds |
Timeout доставки сообщения в Telegram. |
Telegram:DeliveryRecordingTimeoutSeconds |
Независимый timeout фиксации успешно доставленного сообщения. |
Поддерживаются стандартные источники .NET Configuration:
appsettings.json;appsettings.{Environment}.json;- .NET User Secrets;
- environment variables;
- аргументы командной строки.
В environment variables разделитель : заменяется на __, например:
Storage__DatabaseKey
Telegram__BotToken
OpenAI__ApiKey
Секреты нельзя добавлять в appsettings.json или фиксировать в Git.
Для OpenAI runtime после завершённого turn Telegram host проверяет два порога: количество свежих сообщений и их суммарный размер. При достижении любого порога старая часть истории вместе с предыдущей памятью преобразуется отдельным обязательным tool call в полную замену четырёх секций:
- приватный контекст от Participant A;
- приватный контекст от Participant B;
- контекст и договорённости, уже известные обоим участникам;
- границы и безопасность.
Compactor обязан забывать разовые бытовые раздражения, Vent, повторы и обвинительные списки; утверждения сторон не превращаются в факты, а memory не создаёт разрешения на disclosure. Результат принимается только при корректном tool protocol и соблюдении MaxSummaryCharacters.
Замена memory и удаление охваченных сообщений выполняются одной SQLCipher-транзакцией. При ошибке предыдущая memory и все сообщения остаются неизменными, а повтор откладывается. Открытые mediated requests хранятся независимо и не удаляются. SQLite повторно использует страницы удалённых строк, поэтому файл базы может не уменьшиться сразу, но его рост стабилизируется.
Compaction выполняется между turns. Если во время неё приходят сообщения, они остаются в FIFO и каждый ожидающий участник получает одно уведомление без упоминания активности партнёра. После завершения уведомлённый участник получает нейтральное подтверждение, затем queued turns обрабатываются по UpdateId. Эти maintenance-уведомления не входят в model context.
Очередь PendingTurns переживает перезапуск: при старте Telegram host возобновляет сохранённые turns по UpdateId, а строка удаляется только после завершённой обработки. Регистрация ExternalUpdate и сохранение payload выполняются одной транзакцией, поэтому повторная доставка Telegram не создаёт второй turn. Входящее сообщение записывается идемпотентно с устойчивым turn.Id; сохранённый результат модели не вычисляется повторно, а подтверждённые outbox-части не отправляются повторно. Legacy-turn из schema v6 при первом восстановлении связывается с уже существующим входящим сообщением вместо создания дубликата. Переходы mediated requests также идемпотентны.
Каждая попытка turn фиксируется до model/delivery side effects. После TurnProcessing:MaxAttempts неисправимый turn получает статус Failed и исключается из рабочего FIFO, поэтому следующие сообщения продолжают обрабатываться. Участник видит количество только собственных failed turns через /status и может явно повторить их командой /retry_failed. Тексты ошибок, payload и provider diagnostics в командах и логах не раскрываются.
Абсолютная exactly-once доставка через Telegram Bot API недостижима: если процесс завершится после принятия сообщения Telegram, но до локальной фиксации подтверждения, часть останется в состоянии Attempting и может быть отправлена повторно с риском дубля. Автоматические повторы теперь ограничены общим лимитом попыток turn; ручной /retry_failed начинает новый ограниченный цикл. При штатной остановке host прекращает запуск новых turns, возвращает ещё не запущенным handler’ам контролируемый статус Deferred и в пределах shutdown timeout ожидает текущую операцию; незавершённые строки остаются для следующего запуска. Для нескольких экземпляров по-прежнему потребуются распределённый lock или lease.
Heartbeat относится к той же session и общей памяти пары, но использует отдельный контекст без текущего автора и фиктивного входящего сообщения. Каждое осмысленное пробуждение создаёт InitiativeDecision: фазу, уверенность, действие, адресата, краткую operational rationale, предполагаемый текст и NextEvaluationAt. Решения и доставки хранятся отдельно от пользовательских turns.
Технический heartbeat каждые 30 минут не обязательно вызывает модель. Оценка выполняется после новой participant activity либо при наступившем NextEvaluationAt; даже спокойное состояние получает дальнюю повторную оценку. Во время пользовательского turn, quiet hours или минимальной паузы модель не вызывается. LLM-запрос выполняется без удержания session lock, а перед сохранением и перед доставкой система повторно проверяет отсутствие новых сообщений и pending turns.
Будущий план никогда не является запланированной отправкой: в назначенное время ситуация оценивается заново. Live-доставка использует отдельный durable outbox и записывает сообщение в общую историю только после подтверждения Telegram. Частично доставленный ContactBoth восстанавливается по получателям и чанкам. Остаточный риск дубля после Telegram delivery и до локального checkpoint такой же, как у обычных turns.
Участник управляет инициативами командами /proactive_status, /proactive_off и /proactive_on. Обычные ответы на его сообщения продолжают работать при отключённой инициативе. По умолчанию весь режим выключен конфигурацией; включение production требует Initiative__Enabled=true.
MediatorBot.Console создаёт новую session или продолжает существующую и по очереди принимает сообщения от Participant A и Participant B.
Минимальная локальная конфигурация:
{
"ModelRuntime": "Fake",
"Storage": {
"DatabaseKey": "длинный-случайный-локальный-ключ"
}
}Запуск новой session:
dotnet run --project MediatorBot.ConsoleПродолжение существующей session:
dotnet run --project MediatorBot.Console -- --session <SessionId>Команда exit завершает работу.
Локальный HTML-аудит initiative decisions из зашифрованной БД создаётся явной командой:
dotnet run --project MediatorBot.Console -- \
--session <SessionId> \
--initiative-report ./private/initiative-report.htmlОтчёт содержит внутренние rationale и предложенные тексты, поэтому его нельзя сохранять в публичные artifacts, логи или репозиторий. На Linux файл получает mode 600.
Если участник просит что-либо уточнить у партнёра, модель может открыть open_mediated_request. Запрос сохраняется в SQLCipher отдельно от ограниченного окна истории и проходит состояния:
PendingDelivery → AwaitingResponse → Answered / Declined / NoShareableAnswer / Cancelled
Адресат получает безопасно переформулированный вопрос с явным правом отказаться, а инициатор — подтверждение, что ответ зависит от согласия адресата. Последующее сообщение адресата обрабатывается в новом Telegram turn. Содержательный разрешённый ответ закрывает запрос через Answered; отказ или приватная реакция закрывают ожидание инициатора нейтральным сообщением без пересказа формулировки и настроения адресата.
Это event-driven workflow: обработчик и session lock не удерживаются во время ожидания человека. Автоматическое завершение по таймауту пока не реализовано.
MediatorBot.BehaviorScenarios прогоняет изолированные синтетические disclosure-сценарии, включая privacy, mediation-first bridges, safety и anti-rumination. Отдельный initiative-режим проверяет активный конфликт, cooling down, просьбу дать пространство, проигнорированный check-in, позитивную реакцию и взаимную готовность к примирению.
Пример запуска через environment variables в PowerShell:
$env:OpenAI__ApiKey = "ключ-провайдера"
$env:OpenAI__Endpoint = "https://openrouter.ai/api/v1"
$env:OpenAI__Model = "идентификатор-модели"
dotnet run --project MediatorBot.BehaviorScenariosHarness не использует рабочую базу и Telegram. Для каждого сценария создаётся отдельный in-memory контекст. Transcript с синтетическими входами, ответами, адресатами и DisclosureDecision сохраняется в artifacts/behavioral/ для ручной оценки. API key и tool arguments в него не записываются. Отдельный сценарий можно запустить так:
dotnet run --project MediatorBot.BehaviorScenarios -- --Behavior:ScenarioNumber 8Проактивные сценарии запускаются отдельно и никогда не используют Telegram transport:
dotnet run --project MediatorBot.BehaviorScenarios -- --Behavior:Mode InitiativeКонтейнер собирается multi-stage Dockerfile: перед публикацией Telegram host внутри Linux SDK-образа выполняются тесты. Runtime запускается не от root, с read-only filesystem и без опубликованных портов. SQLCipher-база хранится в именованном volume mediator-bot-data.
Подготовка конфигурации в WSL/Linux:
cp deploy/mediator-bot.env.example deploy/mediator-bot.env
chmod 600 deploy/mediator-bot.envЗаполните deploy/mediator-bot.env реальными значениями. Файл исключён из Git. Перед запуском контейнера остановите другие экземпляры этого Telegram-бота, чтобы два long-polling процесса не читали updates одновременно.
docker compose build
docker compose up -d
docker compose logs -f --tail=100Обновление после изменения исходников:
docker compose build
docker compose up -dОстановка без удаления истории:
docker compose downНе используйте docker compose down -v, если volume с базой не сохранён отдельно: параметр -v удаляет mediator-bot-data вместе с историей.
Публичный GitHub Actions workflow .github/workflows/ci.yml на каждый push в master и pull request выполняет restore, Release build, весь тестовый набор, проверку Compose и сборку deployment-образа без секретов и без запуска Telegram. Dependabot еженедельно проверяет NuGet, GitHub Actions и Docker base images. Live behavioural scenarios остаются ручными.
dotnet restore
dotnet build MediatorBot.slnx
dotnet test MediatorBot.slnx- История хранится локально в SQLite, зашифрованной SQLCipher.
- Ключ базы не сохраняется рядом с базой автоматически.
- Тексты сообщений, API-ключи, bot token и tool arguments не должны попадать в технические логи.
- Неизвестные Telegram UserId отсекаются фильтром и повторной проверкой application layer.
- Core и Infrastructure не зависят от Telegram API.
- Telegram update и его payload атомарно сохраняются в
PendingTurnsдо начала обработки; само входящее сообщение сохраняется в истории до обращения к модели. - Исходящий ответ сохраняется только после подтверждённой доставки.
- После подтверждённой доставки фиксация выполняется независимо от отмены исходного Telegram update и ограничивается собственным timeout.
- Предложенные, но не отправленные initiative messages не попадают в общую conversation history.
- Initiative delivery повторно проверяет opt-out, временную паузу, quiet hours, лимит и свежесть контекста.
- Operational rationale и proposed messages хранятся только в SQLCipher и не выводятся в application logs.
Защита базы зависит от стойкости Storage:DatabaseKey и безопасности среды, в которой запущен процесс.
Логи различают ошибки провайдера и нарушения модельного протокола. Для успешного действия фиксируется только техническая классификация без текста и tool arguments:
DisclosureDecision=PrivateResponse
Возможные решения: PrivateResponse, MediatorDisclosure, ExplicitTransfer, SafetyDisclosure, NoAction.
Для provider failure выводятся только безопасные метаданные:
ProviderCode=429 ProviderErrorType=rate_limit_exceeded ProviderName=...
Для protocol failure выводится причина без содержимого ответа:
ProtocolReason=OutputTokenLimit
Типичные причины:
OutputTokenLimit— модели не хватило выходных токенов;UnexpectedAssistantText— модель вернула текст вместо tool call;MissingToolCall— tool call отсутствует;MultipleToolCalls— модель вызвала несколько инструментов;InvalidToolCall— неверное имя или arguments;NoChoices— провайдер вернул ответ без вариантов completion.
Актуальные задачи, критерии возврата к отложенным решениям и эксплуатационный backlog ведутся в TODO.md.
- Telegram host рассчитан на одну настроенную session и двух участников.
- Блокировка turn хранится в памяти процесса и не подходит для нескольких одновременно запущенных экземпляров приложения.
- Fallback между моделями и distributed lock не реализованы.
- Открытые посреднические запросы не завершаются автоматически, если адресат вообще не отвечает.
- Реакция на инициативу определяется из последующей истории вероятностно; отсутствие ответа не считается доказательством состояния отношений.
- Proactive heartbeat, как и Telegram host, пока рассчитан на один процесс без distributed lease.
- Успех зависит от того, насколько выбранная модель соблюдает обязательный tool protocol.
- Используется alpha-версия TeleFlow, закреплённая в файле проекта.
- Проект пока предназначен для контролируемого тестирования, а не для production-развёртывания.