Files
chatballs/apps/internal-ui/src/features/integrations/model.ts
T
AndreyandClaude Opus 5 31985fc35c ✨ feat(vk): канал ВКонтакте — сообщество как точка входа
Сообщения сообщества ВКонтакте принимаются через Bots Long Poll: тот же
способ, что у Telegram и MAX, поэтому подключению не нужен ни публичный
адрес, ни доступ извне — установка за NAT работает наравне с остальными.

Позиция потока живёт в poll_marker, адрес сервера и ключ — в памяти
процесса: их выдают на несколько часов, и колонка под них означала бы
запись в базу на каждом цикле опроса. Ответы failed 1-3 восстанавливаются
в том же цикле, иначе подключение висело бы с протухшим ключом до
перезапуска воркера.

Идентификатор сообщества владелец не вводит: его называет сам ключ
доступа, и проверка подключения кладёт его в конфигурацию — как имя бота
у Telegram и MAX. Проверка заодно смотрит настройки сообщества: без Long
Poll и события о входящем сообщении приём невозможен, и об этом честнее
сказать сразу, а не молчать зелёным статусом. Настройки чужого сообщества
при этом не меняются. Нехватка прав у ключа объясняется словами: ВКонтакте
отвечает на неё английским «no access», из которого не видно, что включить.

Имя, логин и фото отправителя ВКонтакте в апдейте не присылает — их
забирает один users.get на пачку сообщений, а не на каждое: на оживлённом
сообществе запрос на реплику упёрся бы в частоту обращений.

Телефона и кнопки «поделиться контактом» у ВКонтакте нет, поэтому просьба
уходит обычным сообщением, как и почтой. Голосовые принимаются, но не
отправляются: провайдер ждёт ogg/opus, а композер пишет webm.

Токен уходит строкой запроса — заголовка авторизации у ВКонтакте нет.
Поэтому адрес больше нигде не печатается как есть: и журнал, и поле
последней ошибки подключения, которое видно в интерфейсе, проходят через
маскирование секретных параметров.

Проверено на живом сообществе: проверка подключения, опрос Long Poll,
приём текста, голосового и фото с подстановкой имени, логина и аватара.
Тесты транспорта и проверки подключения, каталоги переводов, ruff из
корня, tsc и vitest по internal-ui.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:02:20 +03:00

142 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from "../../api/client";
import { t } from "../../i18n";
export type IntegrationProvider = "OPENROUTER" | "CUSTOM" | "DEMO" | "MAX" | "TELEGRAM" | "VK" | "WEB" | "EMAIL";
export type IntegrationKind = "LLM_PROVIDER" | "MESSENGER";
export type IntegrationStatus = "UNCHECKED" | "OK" | "ERROR";
export type WebChatWidgetSummary = {
id: number;
code: string;
publicKey: string;
name: string;
status: "DRAFT" | "PUBLISHED" | "DISABLED";
channel: { id: number; code: string; name: string } | null;
};
export type Integration = {
id: number;
kind: IntegrationKind;
provider: IntegrationProvider;
name: string;
hasSecret: boolean;
// Публичный префикс ключа + маска («sk-or-••••••••») для колонки «Секрет».
secretMasked: string;
isActive: boolean;
// purpose="notifications" — сервисный бот уведомлений сотрудников (не привязан к каналу продаж).
// email/imap*/smtp* — Email-подключение (SPEC-CHATBALLS-0025 §3.1).
config: {
baseUrl: string;
defaultModel: string;
transcriptionModel: string;
proxyUrl: string;
botId: string;
botUsername: string;
botName: string;
purpose: string;
allowedOrigins: string[];
title: string;
accent: string;
greeting: string;
quickReplies: string[];
consentText: string;
consentVersion: string;
email: string;
imapHost: string;
imapPort: number;
imapSsl: boolean;
smtpHost: string;
smtpPort: number;
smtpSsl: boolean;
};
channel: { id: number; code: string; name: string } | null;
webChatWidget?: WebChatWidgetSummary | null;
status: IntegrationStatus;
lastCheckedAt: string | null;
lastError: string;
createdAt: string;
updatedAt: string;
};
type ProviderMeta = {
label: string;
kind: IntegrationKind;
secretLabel: string;
defaultBaseUrl: string;
hasModel: boolean;
// testable — есть секрет для проверки (поле в форме); checkable — доступна кнопка «Проверить»
// (у Web-виджета секрета нет, но backend проверяет привязку к каналу).
testable: boolean;
checkable: boolean;
};
export const PROVIDERS: Record<IntegrationProvider, ProviderMeta> = {
OPENROUTER: { label: "OpenRouter", kind: "LLM_PROVIDER", secretLabel: t("settings.api_key"), defaultBaseUrl: "https://openrouter.ai/api/v1", hasModel: true, testable: true, checkable: true },
// Custom — generic BYOK для любого OpenAI-compatible endpoint (ADR-CHATBALLS-0034).
// Каталога нет: модель вводится свободным текстом и читается в рантайме.
CUSTOM: { label: "Custom (OpenAI-compatible)", kind: "LLM_PROVIDER", secretLabel: t("settings.api_key"), defaultBaseUrl: "", hasModel: true, testable: true, checkable: true },
// Демо-провайдер — живой AI без ключей и сети для знакомства с системой: отвечает по знаниям агента.
DEMO: { label: t("settings.demo_provider_no_key"), kind: "LLM_PROVIDER", secretLabel: "", defaultBaseUrl: "", hasModel: false, testable: false, checkable: true },
MAX: { label: "MAX", kind: "MESSENGER", secretLabel: t("settings.bot_token"), defaultBaseUrl: "https://platform-api.max.ru", hasModel: false, testable: true, checkable: true },
TELEGRAM: { label: "Telegram", kind: "MESSENGER", secretLabel: t("settings.bot_token"), defaultBaseUrl: "https://api.telegram.org", hasModel: false, testable: true, checkable: true },
// ВКонтакте — сообщество: секрет это ключ доступа сообщества, идентификатор
// сообщества подставляет проверка подключения (ADR-CHATBALLS-0020).
VK: { label: "ВКонтакте", kind: "MESSENGER", secretLabel: t("settings.vk_community_key"), defaultBaseUrl: "https://api.vk.com/method", hasModel: false, testable: true, checkable: true },
WEB: { label: t("common.web_widget"), kind: "MESSENGER", secretLabel: "", defaultBaseUrl: "", hasModel: false, testable: false, checkable: true },
// Email — подключение-ящик IMAP/SMTP (ADR-CHATBALLS-0035); секрет — пароль приложения.
EMAIL: { label: "Email (IMAP/SMTP)", kind: "MESSENGER", secretLabel: t("common.password"), defaultBaseUrl: "", hasModel: false, testable: true, checkable: true },
};
export const STATUS_META: Record<IntegrationStatus, { label: string; bg: string; color: string }> = {
OK: { label: t("common.connected"), bg: "var(--success-bg)", color: "var(--success-text)" },
ERROR: { label: t("common.error"), bg: "var(--error-bg)", color: "var(--error-text)" },
UNCHECKED: { label: t("ai.not_checked"), bg: "var(--n-9)", color: "var(--n-4)" },
};
export const KIND_LABEL: Record<IntegrationKind, string> = {
LLM_PROVIDER: t("settings.providers"),
MESSENGER: t("common.connections"),
};
export type ChannelOption = { id: number; code: string; name: string };
// Привязка подключения выбирает агента; id карточки агента = id канала.
export const fetchChannels = () => api<{ items: ChannelOption[] }>("/api/v1/agents/").then((r) => r.items);
export const fetchLlmProviders = () =>
api<{ items: Integration[] }>("/api/v1/integrations/").then((response) =>
response.items.filter((item) => item.kind === "LLM_PROVIDER")
);
// Разрешённые домены Web-виджета (SPEC-HUB-0010 §7.1). Пустой список в проде
// запрещает все origin'ы, поэтому домены вводятся руками и обязательны.
// Принимаем три формы, которые понимает backend (webchat.services.origin_allowed):
// `example.com`, `*.example.com` и `https://example.com:8443`; `localhost` — для разработки.
const ORIGIN_RULE = /^(?:https?:\/\/)?(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/i;
// Ввод — свободный текст: домены разделяются запятой, точкой с запятой или переносом.
export function parseAllowedOrigins(input: string): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const raw of input.split(/[\s,;]+/)) {
const item = raw.trim().replace(/\/+$/, "");
const key = item.toLowerCase();
if (!item || seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
export const formatAllowedOrigins = (origins: string[]): string => origins.join(", ");
// Возвращает первый непонятный домен — форма показывает его в ошибке поля.
export const invalidAllowedOrigin = (origins: string[]): string | undefined =>
origins.find((item) => !ORIGIN_RULE.test(item));
// Публичный домен Hub для встраивания Web-виджета (SPEC-CHATBALLS-0003 §3).
// Один frontend-образ работает на любом домене (ADR-CHATBALLS-0028 §runtime frontend):
// сниппет генерируется от текущего origin в рантайме, а не от build-time аргумента.
export function webWidgetSnippet(widgetKey: string): string {
return `<script src="${window.location.origin}/chat-widget.js" data-widget-key="${widgetKey}" async></script>`;
}