Skip to main content
Veriqa поставляет четыре канала, но канальный слой — открытая точка расширения. Если ваши пользователи живут в мессенджере, которого нет в списке, вы пишете адаптер против MIT-пакета контрактов и регистрируете его в своём хосте. Без форка ядра, без патчей и без ожидания пул-реквеста. Адаптер — это один класс и один вызов регистрации:
Program.cs
По этому вызову Veriqa регистрирует адаптер, мапит его вебхук с теми же защитами, что у встроенных каналов, и показывает канал в окне входа. Полный рабочий пример — в samples/dotnet/custom-channel: библиотека адаптера и хост, который её подключает. Не пишете на .NET или работаете со стандартным образом Veriqa, который не пересобираете? Напишите адаптер отдельным сервисом на любом языке и подключите его конфигурацией — см. HTTP-адаптер канала на любом языке.

1. Проект адаптера

В зависимостях от Veriqa — только пакет контрактов:
Acme.Chat.Adapter.csproj
Veriqa.Core.Contracts — пакет под лицензией MIT: это и есть бесплатный SPI. Состав пакета и то, какие соседние по namespace типы остаются в MPL-2.0-ядре, — в README пакета (он же виден на странице пакета в NuGet). Ядро сервера (MPL-2.0) — зависимость хоста, а не вашего адаптера.

2. Реализация IChannelAdapter

Поток держат три метода; остальные — исходящие операции, которых у вашей платформы может и не быть.

ValidateWebhookAsync — гейт безопасности

Вызывается раньше вашей обработки, но не раньше всего: сначала запрос проходит демультиплексор тенантов, который на неизвестном сегменте тенанта отвечает 403, вообще не обращаясь к адаптеру, и только после этого Veriqa читает тело и собирает конверт. Вернули false — запрос получает 403 и до вашей обработки не доходит; исключение отсюда трактуется так же, как отказ (уходит в лог, ответ — 403), поэтому сломанный разбор подписи никогда не превратится в 500. Проверяйте подпись платформы здесь, за постоянное время, и отказывайте по умолчанию:

Объявите уровень проверки своего канала — и задокументируйте его

То, что ваш ValidateWebhookAsync вернул успех, ядру говорит только «проверка выполнилась и прошла». Чем она была, ядро не знает и вывести не может: закрытого перечня каналов у него нет, а самозаявление адаптера ничего не гарантировало бы — заявляет тот, кто несёт риск, то есть интегратор. Поэтому секция вашего канала несёт тот же обязательный ключ InboundVerification, что и секции поставляемых каналов:
appsettings.json
Словарь значений — signature (подпись тела ключом платформы), shared_secret (секрет, предъявляемый входящим запросом), outbound_fetch (входящих запросов нет, события забирает исходящий запрос) и none (подлинность не проверяется); полное описание — в справочнике конфигурации.
Для канала, подключённого показанным здесь способом, ключ обязателен, но на старте не проверяется. Проверка, роняющая хост на включённом канале без значения, перебирает каналы, объявившие себя на уровне ядра, — тот же набор, из которого ядро строит список включённых каналов. Вызов AddChannel регистрирует адаптер и его маршрут, но объявления на уровне ядра не создаёт, поэтому в этот набор такой канал не входит — пока вы не объявите его сами, как это делает раздел «Работа по тенантам»; с этого момента проверка распространяется и на ваш канал наравне с каналом поставки. Значение при этом читается тем же резолвером и так же доезжает до записи журнала аудита, поэтому пропуск проявится пустым атрибутом в журнале, а не отказом при запуске.
Обязанность автора адаптера — задокументировать, какое значение даёт его канал в каждом режиме, который он поддерживает. Интегратор выбирает значение по вашей документации: сам он вашу проверку не читает, а ядро её не выводит. Пример выше даёт shared_secret, потому что показанная выше проверка сравнивает заголовок с настроенным секретом; канал, забирающий события своим исходящим запросом, объявляет outbound_fetch.

ProcessInboundEventAsync — на входе сырое тело, на выходе намерение

Оба метода получают один и тот же конверт ChannelInboundRequest: HTTP-метод, сырые байты тела, заголовки и параметры строки запроса (имена сравниваются без учёта регистра, повторяющиеся значения склеены через ", "). Байты — именно байты: подпись считается ровно по ним, и между платформой и вашим HMAC не встаёт шаг декодирования. Формат тела Veriqa не интерпретирует — JSON, form-encoded, XML, что угодно; разбор своими опциями сериализации — ваша обязанность.
Намерение — это тип результата, и у каждого свой обязательный набор полей: ChannelAuthStartResult (пользователь пришёл на платформу по диплинку), ChannelAuthConfirmResult / ChannelAuthDeclineResult (нажал кнопку в вашем канале), ChannelPhoneSharedResult / ChannelPhoneStepActionResult (ответы на запрос номера телефона), ChannelUnaddressedResult или ChannelUnrelatedResult. Последние два похожи, и их легко перепутать. ChannelUnaddressedResult — это личное сообщение вашему боту от отправителя, которого вы уже проверили (голая стартовая команда, произвольный текст), не называющее транзакции; оно несёт отправителя, поэтому Veriqa может ответить на него, если регистрация объявила ответ (UnaddressedReplyText ниже). ChannelUnrelatedResult — всё, отвечать на что Veriqa не вправе: групповой чат, callback чужой кнопки, событие, не прошедшее вашу собственную проверку отправителя. Отправителя оно не несёт, и ответа не будет. Рядом есть синхронный bool OwnsInboundEvent(ChannelInboundRequest request) — дешёвый ответ «это апдейт Veriqa?» до разбора. Он нужен, если транспортом канала владеете вы и раздаёте апдейты между своим ботом и Veriqa: разбирать событие дважды не придётся. Тело по умолчанию уже есть — поиск любого маркера CallbackDataPrefixes (vq_confirm_, vq_decline_, vq_phone_skip_, vq_phone_cancel_, auth_) в сырых байтах тела, — поэтому реализовывать член не обязательно. Ответ односторонний: событие от кнопки или диплинка Veriqa даёт true всегда, но true сам по себе не обещает, что событие обрабатываемо, — поиск подстрочный, и auth_ вендорного префикса не несёт, так что слово oauth_ в сообщении пользователя тоже даст true. Если ложное срабатывание вам чем-то стоит — переопределите член точной проверкой своего wire-формата.

Capabilities — что ваш канал умеет

Возможности канала объявляются фактом, а не заглушкой операции: один объект ChannelCapabilities говорит, умеет ли канал подтверждать вход у себя внутри (SupportsInChannelConfirmation), какие виды контента он принимает, умеет ли обновлять своё сообщение (SupportsMessageUpdate), показывает ли пользователю терминальный статус транзакции (DeliversOutcomeNotice), отдаёт ли локаль получателя (ProvidesRecipientLocale) и рендерит ли эмодзи (RendersEmoji). Veriqa не вызывает того, что канал не объявил, — поэтому «не умею» пишется один раз и в одном месте: О подтверждении вы сообщаете ровно один факт — собственную способность задать вопрос и получить ответ внутри канала. Как именно вы это делаете (inline-кнопки, веб внутри мессенджера, что угодно) — ваше дело, ядро способ не знает и не спрашивает. Поверхности ядра адаптер не называет: нужно ли подтверждение вообще — политика ядра, а страницу с вопросом, когда канал подтверждать у себя не умеет, показывает само ядро. Дефолт факта — «не умею»: собранный адаптер, который об этом факте ничего не знает, не получит подтверждение, которое не сумеет показать.
RendersEmoji — единственный трёхзначный факт объекта: он решает, какой из двух in-channel-формулировок адресуется терминальная квитанция, и это правило канала, а не точки показа. Не указали — сказали «собственного правила нет»: тогда за канал отвечает список мессенджеров, для которых продукт поставляет адаптер, а стороннего канала в нём нет.

Исходящие операции

SendMessageAsync(ChannelMessage) отправляет сообщение и возвращает ссылку на отправленное (ChannelMessageRef) — или null в успехе, если адресуемой ссылки у канала нет. SendConfirmationPromptAsync отправляет prompt подтверждения и возвращает такую же ссылку; хранит её Veriqa, а не вы. ReportOutcomeAsync(TransactionOutcomeNotice) — намерение «покажи пользователю терминальный статус транзакции»; какими вызовами платформы это сделать, решаете вы, и вызывается он только если канал объявил DeliversOutcomeNotice. Уведомление везёт ещё и DisplayIntent — где развёртывание хочет видеть квитанцию: на месте сообщения с вопросом (ReplacePrompt, поставочное значение) либо отдельным сообщением (NewMessage). Исполнять его вы не обязаны: заменять на вашей платформе может быть нечего, и показ исхода единственным доступным ей способом ошибкой не считается — в журнал ошибка не пишется, возвращаемый вами результат не меняется. Исполнение NewMessage — это два шага, а не один, и забывают обычно второй: доставить квитанцию новым сообщением и снять кнопки у сообщения с вопросом, не трогая его формулировку. Пропустите второй — и пользователь смотрит на завершённую транзакцию с живой кнопкой под ней: вопрос, который ничего не решает, но по-прежнему приглашает нажать. Снимайте кнопки той операцией, которая меняет разметку сообщения, не переписывая его текст; если такой операции у платформы нет, оставьте вопрос нетронутым, а не обнуляйте его текст — неисполненное намерение ошибкой не считается, а вопрос, потерявший формулировку, считается. На возвращаемый вами результат снятие кнопок не влияет: его решает доставка квитанции.

Телефон по запросу

Канал, который умеет дать номер телефона пользователя, объявляет это в Capabilities.PhoneNumber: как он получает номер и доказывает ли платформа, что номер принадлежит отправителю. Что ядро делает с номером дальше — когда спрашивает, сколько ждёт, что попадает в токен, — описано на странице Номер телефона.
Не указанный PhoneNumber — это PhoneNumberCapability.None: канал номера не даёт, и вход, которому номер обязателен, этот канал не предлагает. При Automatic кладите номер в E.164 в поле снапшота PhoneNumber, а доказана ли принадлежность — в PhoneNumberVerified (оставили null — claim выйдет как false). При OnRequest ядро вызывает SendMessageAsync с сообщением вида PhoneNumberRequest: Text — текст запроса, а PhoneNumberRequest везёт метку кнопки «поделиться номером» (ShareButtonLabel), второе действие (SecondaryAction — Skip или CancelSignIn) с его меткой (SecondaryButtonLabel) и TransactionId. Тексты приходят уже локализованными. Покажите кнопку платформы «поделиться контактом» и рядом вторую кнопку: кнопка с payload несёт CallbackDataPrefixes.PhoneSkip или CallbackDataPrefixes.PhoneCancel и следом идентификатор транзакции, а кнопка клавиатуры, которая умеет только отправлять текст, отправляет свою метку. Ответы возвращайте так:
  • присланный контакт — ChannelPhoneSharedResult с номером в том виде, в каком его передала платформа (к E.164 его приводит ядро), и снапшотом отправителя. Если контакт не прошёл проверку принадлежности вашей платформы, поставьте OwnershipRejected = true: ядро номер не возьмёт и спросит снова. Транзакции контакт не называет — запрос ядро находит по чату;
  • вторая кнопка с payload — ChannelPhoneStepActionResult с разобранными TransactionId и Action; идентификатор, который не разбирается, делает событие ChannelUnrelatedResult;
  • вторая кнопка текстом — ChannelUnaddressedResult с Text: ядро сверит его с меткой, которую отправило в этот чат.
Если вы раздаёте апдейты через OwnsInboundEvent, переопределите его: тело по умолчанию узнаёт маркеры кнопок, а у присланного контакта маркера нет. Отвечайте true на событие с контактом и сохраните проверку маркеров вызовом CallbackDataPrefixes.ContainsAnyMarker(request.Body.Span) — тело члена интерфейса по умолчанию из своего класса не вызвать. У кнопки, которая отправляет обычный текст, маркера нет вовсе, поэтому в этом режиме её нажатие уходит вашему боту: Optional номер тогда пропускается по истечении окна, а Required ждёт до истечения транзакции. У встроенного адаптера Telegram то же ограничение.

Имена claims — какие ваши, а какие ядра

Всё, что вы знаете о пользователе, кладите в типизированные поля снапшота: имя, username, локаль, телефон и признак доказанной принадлежности (PhoneNumberVerified), email и признак его подтверждённости (EmailVerified) — у каждого своё поле. Ядро само выпустит из них claims; email_verified выходит только рядом с непустым email, phone_number_verified — только рядом с непустым номером. Свободные имена — это AdditionalClaims, и там действует дисциплина из трёх групп (реестр VeriqaClaimTypes): Ключ, нарушивший правило, отбрасывается с предупреждением в логе — аутентификация при этом не падает. Так что «занять» email или locale своей строкой не выйдет: положите значение в поле снапшота. Имена сравниваются без учёта регистра — Email_Verified это то же имя, что email_verified, и смена регистра резервирование не обходит. Префикс своего типа канала делает коллизии между адаптерами невозможными. Регистр решает, чьё имя, но не даёт свободы написания: свой claim принимается только в объявленном написании. При ChannelType = "acme-chat" ключ ACME-CHAT_workspace отбрасывается — пишите префикс так же, как записан тип канала (veriqa_channel — тоже как в реестре). Часть после префикса ваша: acme-chat_Workspace пройдёт. А вот два своих ключа, различающиеся только регистром, — это один claim: один из них возьмётся, второй уедет в лог предупреждением. Какое из написаний выживет — не определено: набор доезжает до проверки замороженным, и порядок его обхода задаётся раскладкой, а не тем, как вы его записали. Присылайте одно написание — регистровая пара означает потерю одного из значений.

Коды ошибок — из объявленного реестра, а не свои строки

Отказ вы возвращаете через Result<T>.Failure(code, message), и code берётся из VeriqaErrorCodes — реестра кодов, объявленного в том же MIT-пакете. Своя строка выглядит работающей, но ею вы разговариваете сами с собой: на объявленных кодах ядро ветвит поведение и метит телеметрию, а неизвестный код остаётся строкой в логе. Тело неизвестного формата — UnsupportedEventType, тело без нужных полей идентичности — IdentityExtractionFailed; семантика каждого кода — в XML-документации VeriqaErrorCodes. Один код выделен: ChannelCannotContinue («провести это подтверждение не могу»). Верните его из SendConfirmationPromptAsync, когда канал физически не может задать вопрос по этой транзакции, — и ядро завершит транзакцию неуспехом с этим же кодом причины вместо ожидания до истечения TTL. Пользователь сразу увидит в браузере сообщение об ошибке, в канал ему при этом ничего не придёт. Любой другой код отказа означает «не вышло сейчас»: транзакция остаётся ждать ретрая или своего TTL. Причину вы не сообщаете и на выбор ядра не влияете — вы объявляете только собственную неспособность, решение принимает ядро.

GetDeepLinkAsync — куда в итоге ведут кнопка и QR-код

Возвращённая ссылка — то, куда ваш канал приводит пользователя. Кнопка канала всегда открывает её как есть. QR-код несёт её же — если для вашего канала не включён hop-режим (он действует только для канала, задекларированного в хосте): тогда в QR-коде короткая одноразовая hop-ссылка на ваш сервер Veriqa, и её открытие на телефоне приводит к той же ссылке, что вернул ваш метод. Для hop-режима в адаптере ничего не меняется — ядро оборачивает ссылку над вашим контрактом. Пронесите идентификатор транзакции в ссылке, чтобы платформа вернула его в вебхуке:
Вызов должен быть идемпотентным и без побочных эффектов. Повторный вызов по той же транзакции возвращает ту же цель и не создаёт состояния: не выпускает новых секретов, ничего не пишет в хранилище и не публикует событий. Ядро вызывает метод каждый раз, когда показывает ваш канал, — на странице входа, на странице, где пользователь подтверждения выбирает канал, в ответе API серверного подтверждения — и в hop-режиме ещё раз при каждом открытии hop-ссылки человеком. Метод, который, скажем, выпускает новый одноразовый код на каждом вызове, превращает каждое обновление страницы и каждое сканирование в ещё один живой секрет. Проверить это в вашей реализации ядро не может — обещание держите вы.

3. Имя канала

Тип канала — контракт, а не отображаемая строка. Он обязан соответствовать паттерну:
Строчный URL-safe токен, начинающийся с латинской буквы: kakaotalk, acme-chat, viber. Значение сравнивается ординально с acr_values и становится сегментом URL-пути, поэтому всё остальное (KakaoTalk, my_channel, пустая строка, значение с хвостовым переводом строки из конфигурации) валит старт с сообщением о допустимом формате. Так же fail-fast срабатывает на повторную регистрацию типа — под неё попадает и тип, уже занятый каналом поставки, потому что каналы поставки регистрируются этим же AddChannel и живут в том же реестре, — и на расхождение типа, переданного в AddChannel, со значением ChannelType вашего адаптера — они обязаны совпадать, потому что весь код ниже находит канал именно по ChannelType адаптера. Последняя проверка выполняется при старте хоста, а не при маппинге вебхуков, поэтому исходящий/polling-канал с mapWebhook: false проверяется ровно так же строго.
У последней проверки есть одна граница: перекрёстную подмену — адаптер одного канала зарегистрирован под типом другого — она ловит сверкой рантайм-типов адаптеров. Если хост навешивает сквозной декоратор на каждый IChannelAdapter (Scrutor Decorate<IChannelAdapter, …>() или Castle-прокси на весь набор), все адаптеры получают один рантайм-тип, а типы обёрнутых скрыты за ним: такая подмена становится неотличимой от легального декоратора и не отсекается — отлов в этой конфигурации best-effort. Для нативных адаптеров и per-adapter class-прокси он точен. Простой случай — тип из AddChannel не объявлен ни одним адаптером — отсекается всегда, независимо от декораторов.

4. Эндпоинт вебхука

Регистрация канала мапит:
с той же rate-limit-политикой и тем же лимитом тела в 1 МБ, что и маршруты встроенных каналов — generic-путь не позволяет обойти защиты, применённые к telegram или whatsapp. Семантика ответов: Последняя строка — безусловный инвариант: и Failure, и необработанное исключение вашего адаптера попадают в лог, а ответ всё равно 200 с пустым телом. Правило одно для всех каналов: маршруты каналов поставки обслуживает тот же обработчик, поэтому и там исключение адаптера не превращается в 500. Платформы отключают вебхуки или устраивают ретрай-штормы на non-2xx, а тело ответа никогда не раскрывает деталей ошибки. Лимит 1 МБ относится к телу запроса: тело читает сам Veriqa, один раз и целиком в память, при сборке конверта — после демультиплексора тенантов и до валидации. Превышение лимита это чтение обрывает по ходу, не дочитывая тело; отказ чтения вердиктом отказа не является, поэтому ответ — 200 и запись в лог уровня Warning, одинаково на generic-маршруте и на маршрутах встроенных каналов. Сверхлимитный апдейт теряется, но платформа не уводит вебхук в бесконечную переотправку; признак проблемы — запись в логе, а не код ответа. Если канал только исходящий или сам опрашивает платформу, зарегистрируйте его без маршрута:

Настройки регистрации

mapWebhook — единственная настройка, которую короткие перегрузки называют явно. Всё, что регистрация может объявить, живёт в ChannelRegistrationOptions, и обе перегрузки принимают его вместо флага — и форма с типом адаптера, и форма с фабрикой: Пустая строка и строка из пробелов равносильны null: канал молчит. Каналы поставки идут ровно этим путём: их статусные формулировки и handshake объявлены здесь же. Telegram и MAX объявляют StaleLinkReplyText, WhatsApp — нет: сообщение вне утверждённого шаблона там платное, и ответ на каждую протухшую ссылку стоил бы установке денег. Для своего канала решайте по тарифам его платформы. UnaddressedReplyText у всех каналов поставки — null: механизм есть, но ничего не отправляется, пока установка не задаст текст.

Verification-handshake платформы (GET)

Платформы Meta-семейства подтверждают владение вебхуком GET-запросом на тот же путь с challenge, который нужно вернуть эхом. Объявите query-параметр, который его несёт, — и Veriqa замапит GET-маршрут рядом с POST:
Подлинность handshake — ваша: GET валидирует тот же ValidateWebhookAsync, что и POST, поэтому verify-token, написание, под которым он приходит, и проверка режима — дело вашего адаптера. Veriqa знает только, из какого query-параметра взять значение для эха. Маршрут fail-closed, ровно как POST-маршрут: Не объявили — GET-маршрут не мапится вовсе, и путь отвечает 405: именно этого хочет канал, платформа которого handshake не требует.

5. Показ канала в окне входа

Зарегистрированный канал уже виден кнопкой — по умолчанию с именем-типом канала и без иконки. Реализуйте опциональный IChannelDisplayMetadata, чтобы задать название и глиф:
DisplayName — одна нелокализуемая строка: бренд-имена не переводятся, ровно как «Telegram»; пустое значение — как и длиннее 32 символов, что ломает вёрстку окна, — деградирует до типа канала. IconSvgPath — внутренняя разметка глифа во viewBox 24×24, она встраивается в страницу, поэтому это должна быть ваша статическая разметка, а не пользовательский ввод. Перед отрисовкой значение проверяется по allowlist: только простые фигуры (path, circle, ellipse, rect, line, polyline, polygon, g) с атрибутами в двойных кавычках, без style и без обработчиков on*, длина — не больше 4096 символов. Всё остальное отбрасывается с предупреждением в лог, а канал отрисовывается без иконки. Канал без фирменного цвета отрисовывается нейтральным primary-цветом окна. Статус-сообщения, которые канал отправляет пользователю, берутся из локализованных дефолтов Veriqa — задавать их не нужно. При входе это «Вход подтверждён» и «Вход отклонён», при подтверждении действия — нейтральные квитанции исхода «Подтверждено ✅», «Отклонено ❌» и «Время истекло ⌛». Сообщение об ошибке обработки ответа — «Что-то пошло не так. Вернитесь на страницу входа и начните заново.» — текст канала, от типа транзакции он не зависит.

Размер QR-кода

Окно входа рисует QR каждого канала в боксе, размер которого задан CSS-токеном --veriqa-qr-size (по умолчанию 220 px). Переопределяется он вашей таблицей стилей — её путь задаётся в Veriqa:AuthPageDesign:CustomCssPath:
Переопределяйте именно токен: он и есть штатная точка управления размером. Правило вида #veriqa-panel-acme-chat .veriqa-qr img { width: … } тот же результат даёт в обход токена и поэтому запрещено — при следующем изменении вёрстки окна оно разъедется с остальными размерами. Уменьшать размер нельзя ниже двух порогов сразу:
  • 200×200 px — минимальный физический размер мишени для камеры;
  • 2,86 px на модуль — плотность: размер бокса ÷ полное число модулей QR вместе с quiet zone.
Критерия два, и выполнение одного не заменяет другого: длинный диплинк кодируется QR более высокой версии, и при том же боксе модуль мельчает до нечитаемого, хотя 200 px формально соблюдены. Размер бокса — только половина картинки: вторая половина — разрешение исходного PNG, то есть число пикселей на модуль. Правило одно, и проверить его нужно сразу, а не только если вы увеличили бокс:
Иначе картинку растянет, а сетка модулей размоется. Почему это касается и поставляемого бокса 220 px. Дефолт PixelsPerModule = 6 рассчитан на диплинки, которые поставляет ядро: самый компактный их QR — 45 модулей, и 45 × 6 = 270 px бокс покрывают. При боксе 220 px дефолта хватает начиная с ⌈220 ÷ 6⌉ = 37 модулей. Разреженнее этого QR получается только у очень короткой ссылки: acme://c/8f3k9d — 33 модуля, 33 × 6 = 198 px, растянуто уже в поставляемом боксе. Ссылка из §2, которая проносит идентификатор транзакции (43 символа), под этот случай не попадает — она даёт 45 модулей, и дефолта хватает. Так что запись ниже нужна вам, только если вы ведёте собственный короткий код со своим маппингом на транзакцию. Хост в любом случае стартует: значение в допустимом диапазоне, а число модулей вашего QR ядру неизвестно — проверить правило можете только вы. Если это ваш случай, лечится записью для своего канала:
appsettings.json
Здесь 8 — это ⌈220 ÷ 29⌉: 29 модулей у самого компактного QR, какой вообще возможен (версия 1 вместе с quiet zone), поэтому 8 закрывает поставляемый бокс при любой длине вашего диплинка. Увеличили бокс — пересчитайте это значение по той же формуле. Допустимый диапазон — 5…35. Верхняя граница — та же формула для бокса в 1000 px и тех же 29 модулей, поэтому диапазон покрывает любой бокс, помещающийся на экране, при любом канале. Значение вне диапазона хост не запустит — но попадание в диапазон, наоборот, ничего не подтверждает: соответствие формуле остаётся на вас.

6. Регистрация в хосте

Хост — это приложение, встраивающее Veriqa, поэтому он ссылается на ядро сервера:
Program.cs
Секция конфигурации вашего канала принадлежит вам: Veriqa её не знает и не биндит. Свяжите её в хосте и передайте результат адаптеру, а секреты держите в user-secrets или секрет-сторе, не в appsettings.json. Когда зависимости адаптера резолвятся контейнером, вместо фабричной перегрузки используйте AddChannel<TAdapter>("acme-chat"). Задекларируйте канал рядом с AddChannel. AddChannel регистрирует адаптер и его маршрут, но не декларацию канала на уровне ядра (CoreChannelDeclaration) — это отдельный шаг, тот же, что делают поставляемые каналы. Именно декларация вносит канал в channels_enabled — набор каналов, доступных на уровне ядра, и всё, что читает этот набор, пропускает канал вне его: hop-режим не оборачивает его QR-код, polling не получает тенантов для запуска, стартовая проверка верификации входящих событий его не касается. Хост при этом стартует — с предупреждением (Warning) в логе, называющим канал, у которого есть адаптер, но нет декларации. Как задекларировать канал — в разделе Работа по тенантам.

7. Регистрация в списке поддерживаемых каналов

Адаптер, который уезжает в поставку Veriqa, регистрируется ещё в одном месте — в каталоге «Поддерживаемые каналы» и его версии на сайте (veriqa.app/channels). Список ведётся вручную: канал, которого в нём нет, поддерживаемым не считается, сколько бы упоминаний о нём ни было в других местах. Запись о канале даёт те же три поля, что и остальные: какие claims канал добавляет сверх обязательных, происходит ли подтверждение внутри канала и нужно ли пользователю отправлять секретное сообщение — или мессенджер сам передаёт код боту. Канал в работе заводится в группах «В разработке» или «В плане», а не во встроенных. Собственного адаптера, который остаётся у вас, это не касается: список описывает поставку Veriqa. Но у своей интеграции стоит завести такую же запись в вашей документации — вопросы к каналу задают те же.

Работа по тенантам

Мультитенантность входит в этот SPI, а не вынесена за его границы. Свой канал читает креды, тексты и лимиты тенанта тем же способом, что и каналы поставки, — через те же публичные части и по одному правилу: тенанта называет тот, кто законно его знает, и называет один раз. Набор тенантов даёт IPollingTenantSource. Получите его из контейнера и спросите, для каких тенантов ваш канал активен; элемент null — дефолтный неявный тенант self-hosted-установки. У этого есть одно предусловие: набор строится из объявлений зарегистрированных каналов на уровне ядра, а AddChannel объявления не создаёт — он регистрирует адаптер и его маршрут. Поэтому объявите свой канал в хосте, рядом с вызовом AddChannel:
Program.cs
Оба факта читаются делегатами, и каналы поставки замыкают их ровно на эти два аксессора. isEnabled спрашивают заново при каждом построении доступного набора, поэтому он обязан читать источник, переживающий перечитывание конфигурации: монитор, а не объект настроек, связанный один раз, — такой заморозил бы правку на стороне развёртывания. usesPolling — решение на старте, и каналы поставки читают его через IOptions. Обоим нужна секция в контейнере (builder.Services.Configure<AcmeChatOptions>(builder.Configuration.GetSection("AcmeChat"))) — на одну строку больше, чем ручное связывание, которое §6 делает для самого адаптера. Тот же isEnabled вводит ваш канал в channels_enabled уровня ядра. Без объявления набор тенантов возвращается пустым и пуллинг не стартует, а проверка старта, о которой говорит замечание в §2, вашего канала не касается; с объявлением включённый канал, которому причитается значение InboundVerification, роняет хост ровно как канал поставки. Тенанта называйте скоупом, вокруг обработки, на своём фоновом пути:
Своя проба здоровья работает вне запроса и отчитывается о самой установке, поэтому дефолтного тенанта она называет явно — ChannelTenantContext.BeginScope(null), — а не умолчанием: чтение кред без единого открытого скоупа неотличимо от пути, забывшего назвать тенанта, и различить их за вас ниже по стеку некому. На вебхуке скоуп не открывайте. Тенант входящего запроса уже назван — маршрутом (/api/channels/{channelType}/webhook/{tenant}), до вызова вашего адаптера. Свой скоуп, открытый там, перенацелит все чтения кред ниже него, включая чтения ядра, на выбранного вами тенанта вместо названного хостом. Свои креды вы резолвите, а не получаете. Объявите свой ключ и резолвите его публичным IConfigurationResolver с ResolutionContext.ForTenant(tenantId) — тем же каноничным резолвером и тем же порядком уровней, которым идут каналы поставки. Публичная фабрика клиентов IChannelClientFactory для этого не нужна: она строит и кеширует клиента на пару (канал, тенант), а это другая работа. Как объявляется свой ключ — каталог, адрес на каждом уровне, регистрация — описано в Свои ключи конфигурации.

HTTP-адаптер канала на любом языке

Всё, что выше, — путь .NET: ваш адаптер работает внутри хоста. Второму пути не нужен ни .NET, ни код в хосте. HTTP-канал — адаптер из поставки Veriqa, который стоит в хосте вместо вашего и передаёт работу по HTTP внешнему адаптеру — сервису, который вы пишете на любом языке и запускаете рядом с Veriqa или в своём периметре. Канал заводится конфигурацией.
Wire-контракт между Veriqa и внешним адаптером — версии 1.0, он выходит в статусе preview. Объявление его стабильным — отдельное решение; до него ревизия контракта ещё может его изменить.

Модель транспорта

  • Платформа говорит с вашим адаптером, а не с Veriqa. Адаптер сам принимает вебхуки платформы или поллит её, проверяет их и сам отвечает на challenge-хендшейк платформы. GET-маршрута для HTTP-канала Veriqa не маппит.
  • Адаптер постит события в Veriqa на маршруты, которые есть у каждого канала: /api/channels/{channelType}/webhook или /api/channels/{channelType}/webhook/{tenant}. Событие по транзакции уходит на маршрут тенанта этой транзакции — того, что пришёл в поле tenant вызовов deep-link и send-prompt этой транзакции; null — маршрут без сегмента тенанта. Второй раз Veriqa тенанта не сообщит: связь «транзакция → тенант» храните у себя.
  • Свой публичный вход адаптер защищает сам. Rate-limit Veriqa охраняет её собственные маршруты; вход, который адаптер открывает платформе, нуждается в своей защите от мусорного трафика.
  • Veriqa вызывает адаптер по одному адресу, Http:BaseUrl, — за всем, что ей нужно от канала.
  • Креды платформы остаются у адаптера. Veriqa их не запрашивает и не хранит.
  • Одна регистрация — один внешний адаптер: один адрес, один секрет транспорта, одни факты канала. Без Http:Tenant она обслуживает всех тенантов установки. Тенант со своей реализацией получает свою регистрацию под своим именем (например shop-a-viber), и Http:Tenant называет этого тенанта.
  • Стандартный образ Veriqa регистрирует HTTP-каналы всегда: канал добавляется переменными окружения и перезапуском контейнера, без пересборки образа. Embedded-хост подключает пакет Veriqa.Core.ChannelAdapter.Http и добавляет один вызов:
Program.cs

Конфигурация

Каждая секция Veriqa:Channels:{channelType} с подсекцией Http становится одним HTTP-каналом под именем секции:
appsettings.json
Тот же канал переменными окружения, вместе с секретом транспорта — держите его там или в хранилище секретов, а не в appsettings.json. Тип канала с дефисом из shell не экспортировать, но environment: compose-файла и docker run -e его принимают:
Что важно знать о ключах (полный перечень — в справочнике конфигурации):
  • Факты канала и кнопку задаёт оператор — ключами Http:Capabilities:* (по ключу на свойство ChannelCapabilities), Http:DisplayName и Http:IconSvgPath. Сетевого манифеста нет: Veriqa не спрашивает адаптер, что он умеет. Незаданный факт получает безопасное умолчание ChannelCapabilities — все булевы false, единственный вид контента plain_text, своего правила об эмодзи у канала нет (RendersEmoji не задан).
  • Http:Tenant называет тенанта-владельца регистрации. Она обслуживает только его; у остальных тенантов этого канала нет — ни на входящем маршруте, ни в событиях, ни в исходящих вызовах. Ключ не задан — регистрация обслуживает всех тенантов установки.
  • Http:AdapterSettings — непрозрачная строка до 8192 символов, которую Veriqa пересылает дословно в каждом вызове и никогда не читает. У неё уровни ядра и тенанта; значение тенанта заменяет значение ядра целиком.
  • Http:AllowInsecureHttp разрешает базовый URL со схемой http. Только для изолированной внутренней сети (соседний контейнер); при старте Veriqa пишет предупреждение.
  • Все значения Http:* фиксируются на старте, кроме Http:AdapterSettings, который резолвится на каждый вызов. Остальные меняются только перезапуском.
  • Имя канала — в формате из раздела Имя канала, а имена каналов поставки Veriqa зарезервированы, даже если встроенный канал в хост не подключён: max, telegram, whatsapp, email. Резерв растёт с выходом новых встроенных каналов.
  • Ошибка конфигурации, найденная на старте, останавливает его с сообщением, называющим ключ или секцию канала, — например: базовый URL не задан или не абсолютный, базовый URL со схемой http без Http:AllowInsecureHttp, не задан Http:TransportSecret, секрет не в форме whsec_, один секрет у двух регистраций, запрещённая схема диплинка, таймаут не в форме hh:mm:ss, Http:AdapterSettings длиннее 8192 символов, зарезервированное имя или имя, которое уже занято другим каналом хоста. Пустой Http:Tenant — тоже ошибка: чтобы обслуживать всех тенантов, ключ удаляют.
В отличие от канала через AddChannel (замечание в §2), у HTTP-канала InboundVerification проверяется на старте: регистрация объявляет канал ядру, и включённый HTTP-канал без значения роняет хост.

Wire-контракт

Каждый вызов Veriqa — подписанный POST JSON-конверта на Http:BaseUrl: version, operation, channel_type (один вход может обслуживать несколько регистраций), tenant (или null), adapter_settings (или null) и payload. Ответ — 200 с конвертом из version и ровно одного из двух: result или error (code и message). Поля каждого payload, результата и события — в машиночитаемой JSON-схеме провода; гайд их не повторяет. Правила, которых схема не выражает:
  • Коды ошибок. Коды, которые называет провод, — в схеме, и любой другой код тоже принимается. Код error пишется в лог и больше ничего не решает, за одним исключением: channel_cannot_continue в ответе на send-prompt сразу завершает транзакцию отказом. Любой другой error действует по своей операции, и после него вызов не повторяется:
    • send-prompt — транзакция остаётся открытой и ждёт нового auth_start пользователя или истечения срока;
    • deep-link — у пользователя нет входа в ваш канал: страница входа рисуется без него;
    • send-message, report-outcome — отказ пишется в лог и учитывается в метриках, и только.
  • deep-link идемпотентен: повторный вызов по той же транзакции возвращает тот же url и не создаёт нового состояния.
  • У ответа есть границы. Ответ читается не более 1 МБ и сверх лимита отвергается, редиректы не выполняются, диплинк принимается только абсолютным URI со схемой https или из Http:DeepLinkSchemes, не длиннее 2048 символов.
  • Таймауты и повторы. Попытка ждёт Http:OperationTimeout (по умолчанию 10 с); deep-link — Http:DeepLinkTimeout (2 с), потому что стоит на пути страницы входа. send-prompt, send-message и report-outcome повторяются при транспортном отказе — нет соединения, таймаут, 5xx, 429 — до трёх раз, все попытки в пределах Http:RetryBudget (20 с). Больше не повторяется ничего: 200 с error, 3xx, прочие 4xx, слишком большой или некорректный ответ. deep-link и health — одна попытка.
  • Отвергайте конверт события на входе вызовов. Тело с event вместо operation, пришедшее на Http:BaseUrl, — ваше же событие, отражённое обратно; ответ на него — что угодно, кроме 200 с result.
  • Обслуживайте вызовы, пока открыт свой запрос. На auth_start Veriqa шлёт адаптеру send-prompt до того, как ответит на ваш POST события. Адаптер, который не умеет принимать вызовы параллельно со своими запросами в Veriqa, получает таймаут вопроса, и транзакция ждёт истечения срока.

Подпись

Оба направления подписываются секретом транспорта регистрации по схеме Standard Webhooks v1 (HMAC-SHA256) — библиотека для неё есть в любом языке. Заголовки — webhook-id, webhook-timestamp и webhook-signature; метка времени принимается в пределах 5 минут в обе стороны от часов получателя.
  • Ротация без простоя. Новый секрет — в Http:TransportSecret, старый — в Http:PreviousTransportSecret: отправитель ставит обе подписи, получатель принимает любую. Пока предыдущий секрет задан, Veriqa пишет при старте предупреждение.
  • Ответы не подписываются: их подлинность держится на соединении, которое Veriqa открыла по настроенному адресу, — поэтому по умолчанию требуется https.
  • Неверная подпись — 403, без конверта и без какого-либо эффекта, в обе стороны: так Veriqa отвечает на ваше событие без подписи или с неверной подписью, и так же обязан отвечать на такой вызов ваш адаптер.
  • У каждой регистрации свой секрет. Общий секрет позволил бы одной регистрации выдать себя за другую, поэтому хост не стартует.

Идемпотентность

  • webhook-id вызовов send-prompt, send-message и report-outcome — ключ идемпотентности. Повтор с ключом, который вы уже успешно обслужили, ничего нового не отправляет и возвращает результат первого вызова — ту же ссылку на сообщение. У deep-link и health webhook-id уникален, но ключом не является.
  • webhook-id события выбираете вы: уникальный на событие и неизменный при повторной доставке того же события. Из него Veriqa строит ключи вызванных событием send-prompt и report-outcome, поэтому повторно доставленное событие не задаёт вопрос дважды.
  • Неуспешная попытка ключ не связывает: следующая попытка с ним обслуживается заново.
  • Срок хранения выбирает оператор адаптера, с двумя нижними границами: не меньше бюджета повторов Veriqa (Http:RetryBudget), а для ключа, рождённого событием, — ещё и не меньше окна, в котором сам адаптер может доставить это событие повторно.

Версия провода и статус preview

Каждый конверт в обе стороны несёт version — версию провода отправителя, major.minor; первая — 1.0, в статусе preview. В пределах мажорной версии изменения только аддитивны — новое необязательное поле, операция, значение перечисления или тип события, — и у каждого объявлено поведение получателя, который его не знает: незнакомое поле игнорируется, на незнакомую операцию — ответ unsupported_operation, незнакомый вариант вопроса показывается как вопрос без деталей, незнакомое событие Veriqa считает unrelated. Удаление или переименование поля, смена его смысла — только новой мажорной версией. На вызов другой мажорной версии отвечайте unsupported_wire_version; Veriqa, в свою очередь, событие другой мажорной версии не обрабатывает, а такой ответ считает ответом вне контракта. Каждое событие несёт и adapter_version — версию вашего адаптера в semver.

Conformance-набор

Veriqa поставляет исполняемый conformance-набор, который проверяет адаптер на соответствие проводу, на каком бы языке тот ни был написан. Он проверяет три вещи:
  • вызовы через настоящую обёртку — каждую операцию, повтор с тем же ключом, неверный секрет и метку времени вне окна. Эти вызовы делает тот самый код HTTP-канала, что работает в Veriqa, поэтому набор не может разойтись с Veriqa;
  • некорректные вызовы, которых Veriqa не делает, — без подписи, с незнакомыми полем, вариантом или операцией, чужой мажорной версии, конверт события, отражённый на вход вызовов, — их шлёт отдельный отправитель;
  • приёмник событий — он играет Veriqa для ваших событий: проверяет подпись и схему каждого, вызывает send-prompt до ответа на auth_start и проверяет, что каждое событие пришло на маршрут тенанта своей транзакции.
Набор — контейнерный образ, собирается из корня репозитория:
Запускайте его рядом с адаптером, с конфигурацией канала теми же ключами Veriqa__Channels__<type>__*, что читает Veriqa, и собственными ключами под Conformance__*: обязательны ChannelType и ChannelUserId; Mode, Tenants и ReportPath — по желанию. В interactive и automatic набор стоит на месте Veriqa: слушает порт 8080 образа на тех же путях, /api/channels/{type}/webhook и /api/channels/{type}/webhook/{tenant}, поэтому адаптер меняет только базовый адрес Veriqa, на который постит. Сколько ждать события — Conformance__EventTimeoutSeconds. Набор печатает по строке на сценарий — PASS <id>, FAIL <id>: <причина> или N/A <id>: <причина>, — пишет JSON-отчёт в Conformance__ReportPath и завершается кодом 0 (ни один сценарий не упал), 1 (упал хотя бы один) или 2 (прогон провести не удалось; сообщение называет, что исправить). Полный перечень сценариев — в README src/core/Veriqa.Core.ChannelAdapter.Http.Conformance. Машиночитаемая схема провода (JSON Schema) поставляется вместе с набором: в образе — /schema/http-channel-wire-v1.schema.json, в репозитории — src/core/Veriqa.Core.ChannelAdapter.Http.Conformance/Schema/.

Референсные адаптеры

Три внешних адаптера в репозитории показывают весь контракт на настоящей платформе:
  • samples/python/custom-channel-telegram/ — Telegram на Python;
  • samples/node/custom-channel-whatsapp-twilio/ — WhatsApp через Twilio на Node;
  • samples/node/custom-channel-whatsapp-baileys/ — WhatsApp через Baileys на Node, рабочий; для отладки, небольших команд и собственного использования, номер могут заблокировать.
Каждый запускается рядом с Veriqa из своей папки:
Вместе они показывают все операции и все события своего канала, подпись в обе стороны, идемпотентность (повтор не шлёт второго сообщения), креды по тенанту, adapter_settings как собственную настройку адаптера и защиту собственного публичного входа адаптера. Адаптер Twilio вдобавок отвечает channel_cannot_continue, когда платформа не может доставить вопрос; адаптер Telegram делит апдейты между Veriqa и вашим собственным ботом. Адаптер Baileys рабочий и предназначен для отладки сценариев с WhatsApp, небольших команд и собственного использования: Baileys — неофициальный клиент WhatsApp Web на вашем собственном номере, и WhatsApp может этот номер заблокировать. Вопрос он задаёт опросом, а сообщения забирает своей исходящей сессией. README каждого описывает его конфигурацию, значение InboundVerification и ручной живой прогон. Это образцы, чтобы разобраться и скопировать, а не каталог адаптеров сообщества.

InboundVerification внешнего адаптера

Правило раздела Объявите уровень проверки своего канала — и задокументируйте его действует для внешнего адаптера без изменений: трафик платформы проверяет ваш адаптер, поэтому значение для каждого режима приёма своего канала называете вы в своей документации, а оператор объявляет его в секции канала. Умолчания нет. Подпись транспорта между адаптером и Veriqa проверяется всегда, и это значение её не описывает. Референсные адаптеры называют свои: В той же документации перечислите ключи верхнего уровня своего raw_metadata, несущие персональные данные, включая нативный ID пользователя платформы: по этому перечню интегратор решает, что ему можно хранить.