Program.cs
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-режима в адаптере ничего не меняется — ядро оборачивает ссылку над вашим контрактом.
Пронесите идентификатор транзакции в ссылке, чтобы платформа вернула его в вебхуке:
3. Имя канала
Тип канала — контракт, а не отображаемая строка. Он обязан соответствовать паттерну: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. Эндпоинт вебхука
Регистрация канала мапит: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: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.
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
⌈220 ÷ 29⌉: 29 модулей у самого компактного QR, какой вообще возможен (версия 1
вместе с quiet zone), поэтому 8 закрывает поставляемый бокс при любой длине вашего диплинка.
Увеличили бокс — пересчитайте это значение по той же формуле.
Допустимый диапазон — 5…35. Верхняя граница — та же формула для бокса в 1000 px и тех же
29 модулей, поэтому диапазон покрывает любой бокс, помещающийся на экране, при любом канале.
Значение вне диапазона хост не запустит — но попадание в диапазон, наоборот, ничего не подтверждает:
соответствие формуле остаётся на вас.
6. Регистрация в хосте
Хост — это приложение, встраивающее Veriqa, поэтому он ссылается на ядро сервера:Program.cs
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_startVeriqa шлёт адаптеруsend-promptдо того, как ответит на вашPOSTсобытия. Адаптер, который не умеет принимать вызовы параллельно со своими запросами в Veriqa, получает таймаут вопроса, и транзакция ждёт истечения срока.
Подпись
Оба направления подписываются секретом транспорта регистрации по схеме Standard Webhooksv1 (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иhealthwebhook-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, рабочий; для отладки, небольших команд и собственного использования, номер могут заблокировать.
adapter_settings как
собственную настройку адаптера и защиту собственного публичного входа адаптера. Адаптер Twilio
вдобавок отвечает channel_cannot_continue, когда платформа не может доставить вопрос; адаптер Telegram
делит апдейты между Veriqa и вашим собственным ботом.
Адаптер Baileys рабочий и предназначен для отладки сценариев с WhatsApp, небольших команд и собственного
использования: Baileys — неофициальный клиент WhatsApp Web на вашем собственном номере, и WhatsApp может
этот номер заблокировать. Вопрос он
задаёт опросом, а сообщения забирает своей исходящей сессией. README каждого описывает его конфигурацию,
значение InboundVerification и ручной живой прогон.
Это образцы, чтобы разобраться и скопировать, а не каталог адаптеров сообщества.
InboundVerification внешнего адаптера
Правило раздела Объявите уровень проверки своего канала — и задокументируйте его
действует для внешнего адаптера без изменений: трафик платформы проверяет ваш адаптер, поэтому
значение для каждого режима приёма своего канала называете вы в своей документации, а оператор
объявляет его в секции канала. Умолчания нет. Подпись транспорта между адаптером и Veriqa проверяется
всегда, и это значение её не описывает.
Референсные адаптеры называют свои:
В той же документации перечислите ключи верхнего уровня своего
raw_metadata, несущие персональные
данные, включая нативный ID пользователя платформы: по этому перечню интегратор решает, что ему можно
хранить.