✨ 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>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-17 11:02:20 +03:00
1 parent 1ea0faf1c6
commit 31985fc35c
29 files changed
+1211 -25

No files matched your search

@@ -40,6 +40,7 @@ from chatballs.identity.models import AuditEvent
PROVIDER_CODE = {
"MAX": "MAX",
"TELEGRAM": "TG",
"VK": "VK",
"WEB": "WEB",
"EMAIL": "EMAIL",
}
@@ -0,0 +1,303 @@
"""Транспорт ВКонтакте: разбор апдейтов, курсор Long Poll, отправка.
Тесты идут без базы: транспорту нужны только поля подключения, поэтому вместо
записи в базу здесь простая заглушка. Сеть закрыта подменой вызова API и
опроса сервера событий.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest import mock
from django.test import SimpleTestCase
from chatballs.conversations.transports import vk, vk_media, vk_send
from chatballs.conversations.transports.errors import PollFailed
from chatballs.integrations.checks import VkRejected
SERVER = {"server": "https://lp.vk.com/wh1", "key": "lp-key", "ts": "100"}
PROFILE = {"id": 77, "first_name": "Иван", "last_name": "Петров", "screen_name": "ivan", "photo_100": "https://vk.com/ivan.jpg"}
def _integration(**config):
return SimpleNamespace(
id=1,
secret="vk-community-token",
poll_marker="",
config={"bot_id": "42", **config},
organization=SimpleNamespace(language="ru"),
)
def _update(**message):
payload = {"from_id": 77, "peer_id": 77, "id": 500, "text": "Здравствуйте", **message}
return {"type": "message_new", "object": {"message": payload}}
class _Api:
"""Подмена вызова API: ответ на метод и журнал обращений."""
def __init__(self, **responses):
self.responses = {"users.get": [PROFILE], "groups.getLongPollServer": SERVER, **responses}
self.calls: list[tuple[str, dict]] = []
def __call__(self, integration, method, params=None, *, post=False):
self.calls.append((method, dict(params or {})))
response = self.responses.get(method)
if isinstance(response, Exception):
raise response
return response
def methods(self) -> list[str]:
return [method for method, _params in self.calls]
def params(self, method: str) -> dict:
return next(params for name, params in self.calls if name == method)
class VkInboundTests(SimpleTestCase):
def setUp(self) -> None:
vk.reset()
self.addCleanup(vk.reset)
def _poll(self, check_result, *, api=None, integration=None):
api = api or _Api()
integration = integration or _integration()
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", return_value=check_result) as request:
messages, marker = vk.poll_updates(integration)
return messages, marker, api, request
def test_message_becomes_inbound_with_profile_from_one_request(self) -> None:
api = _Api()
messages, marker, api, _request = self._poll(
{"ts": "101", "updates": [_update(), _update(id=501, text="Ещё вопрос")]},
api=api,
)
self.assertEqual(marker, "101")
self.assertEqual([message.text for message in messages], ["Здравствуйте", "Ещё вопрос"])
self.assertEqual(messages[0].external_id, "500")
self.assertEqual(messages[0].user_id, "77")
self.assertEqual(messages[0].chat_id, "77")
self.assertEqual(messages[0].display_name, "Иван Петров")
self.assertEqual(messages[0].username, "ivan")
self.assertEqual(messages[0].avatar_url, "https://vk.com/ivan.jpg")
# Профиль спрашивается один раз на пачку, а не на каждое сообщение.
self.assertEqual(api.methods().count("users.get"), 1)
self.assertEqual(api.params("users.get")["user_ids"], "77")
def test_community_own_message_and_other_events_are_ignored(self) -> None:
messages, _marker, _api, _request = self._poll(
{
"ts": "102",
"updates": [
_update(from_id=-42),
{"type": "group_join", "object": {"user_id": 77}},
],
}
)
self.assertEqual(messages, [])
def test_profile_failure_does_not_lose_the_message(self) -> None:
api = _Api(**{"users.get": VkRejected("VK отклонил запрос")})
messages, _marker, _api, _request = self._poll({"ts": "103", "updates": [_update()]}, api=api)
self.assertEqual([message.text for message in messages], ["Здравствуйте"])
self.assertEqual(messages[0].display_name, "")
def test_first_cycle_takes_position_from_vk_and_next_one_from_the_marker(self) -> None:
api = _Api()
integration = _integration()
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", return_value={"ts": "101", "updates": []}) as request:
vk.poll_updates(integration)
integration.poll_marker = "101"
vk.poll_updates(integration)
self.assertIn("ts=100", request.call_args_list[0].args[0])
self.assertIn("ts=101", request.call_args_list[1].args[0])
# Адрес Long Poll выдаётся один раз и живёт в памяти процесса.
self.assertEqual(api.methods().count("groups.getLongPollServer"), 1)
def test_zero_position_of_a_fresh_community_is_kept(self) -> None:
# Сообществу, которому ещё не писали, ВКонтакте отдаёт ts=0. Пустая
# позиция в опросе возвращает ts=-1 — курсор, с которого поток уже не
# читается, и первое же сообщение клиента прошло бы мимо.
api = _Api()
api.responses["groups.getLongPollServer"] = {**SERVER, "ts": 0}
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", return_value={"ts": "0", "updates": []}) as request:
_messages, marker = vk.poll_updates(_integration())
self.assertIn("ts=0", request.call_args_list[0].args[0])
self.assertEqual(marker, "0")
def test_outdated_position_is_retried_with_the_one_vk_returned(self) -> None:
api = _Api()
integration = _integration()
integration.poll_marker = "90"
answers = [{"failed": 1, "ts": "100"}, {"ts": "101", "updates": [_update()]}]
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", side_effect=answers) as request:
messages, marker = vk.poll_updates(integration)
self.assertEqual(marker, "101")
self.assertEqual(len(messages), 1)
self.assertIn("ts=100", request.call_args_list[1].args[0])
def test_expired_key_takes_a_new_one_and_keeps_the_position(self) -> None:
api = _Api()
api.responses["groups.getLongPollServer"] = {**SERVER, "key": "fresh-key", "ts": "900"}
integration = _integration()
integration.poll_marker = "90"
answers = [{"failed": 2}, {"ts": "91", "updates": []}]
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", side_effect=answers) as request:
_messages, marker = vk.poll_updates(integration)
self.assertEqual(marker, "91")
retry_url = request.call_args_list[1].args[0]
self.assertIn("key=fresh-key", retry_url)
self.assertIn("ts=90", retry_url)
def test_lost_history_takes_both_the_key_and_the_position(self) -> None:
api = _Api()
api.responses["groups.getLongPollServer"] = {**SERVER, "key": "fresh-key", "ts": "900"}
integration = _integration()
integration.poll_marker = "90"
answers = [{"failed": 3}, {"ts": "901", "updates": []}]
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", side_effect=answers) as request:
_messages, marker = vk.poll_updates(integration)
self.assertEqual(marker, "901")
self.assertIn("ts=900", request.call_args_list[1].args[0])
def test_broken_connection_is_a_poll_failure_and_forgets_the_session(self) -> None:
api = _Api()
integration = _integration()
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", side_effect=TimeoutError("timed out")):
with self.assertRaises(PollFailed):
vk.poll_updates(integration)
self.assertEqual(vk._sessions, {})
def test_token_never_leaks_into_the_failure_text(self) -> None:
api = _Api()
leak = OSError("HTTP Error 401: https://api.vk.com/method/users.get?access_token=vk1.a.SECRET&v=5.199")
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", side_effect=leak):
with self.assertRaises(PollFailed) as failure:
vk.poll_updates(_integration())
self.assertNotIn("vk1.a.SECRET", str(failure.exception))
self.assertIn("access_token=***", str(failure.exception))
def test_group_is_asked_once_when_the_connection_was_never_checked(self) -> None:
api = _Api(**{"groups.getById": {"groups": [{"id": 42, "name": "Acme"}]}})
integration = _integration()
integration.config.pop("bot_id")
with mock.patch.object(vk.vk_api, "call", api):
with mock.patch.object(vk, "request_json", return_value={"ts": "101", "updates": []}):
vk.poll_updates(integration)
self.assertEqual(api.params("groups.getLongPollServer")["group_id"], "42")
class VkAttachmentTests(SimpleTestCase):
def setUp(self) -> None:
vk.reset()
self.addCleanup(vk.reset)
def test_photo_takes_the_largest_size(self) -> None:
attachment = {
"type": "photo",
"photo": {"sizes": [
{"url": "https://vk.com/small.jpg", "width": 75, "height": 75},
{"url": "https://vk.com/large.jpg", "width": 1280, "height": 960},
]},
}
files = vk_media.file_attachments({"attachments": [attachment]})
self.assertEqual(len(files), 1)
self.assertEqual(files[0].url, "https://vk.com/large.jpg")
self.assertTrue(files[0].is_image)
def test_document_keeps_its_name_and_size(self) -> None:
attachment = {"type": "doc", "doc": {"url": "https://vk.com/f.pdf", "title": "Счёт.pdf", "size": 2048}}
files = vk_media.file_attachments({"attachments": [attachment]})
self.assertEqual(files[0].name, "Счёт.pdf")
self.assertEqual(files[0].content_type, "application/pdf")
self.assertEqual(files[0].size, 2048)
self.assertFalse(files[0].is_image)
def test_voice_message_is_taken_in_ogg(self) -> None:
attachment = {
"type": "audio_message",
"audio_message": {"duration": 7, "link_ogg": "https://vk.com/v.ogg", "link_mp3": "https://vk.com/v.mp3"},
}
url, duration, mime, unavailable = vk_media.voice_attachment({"attachments": [attachment]})
self.assertEqual((url, duration, mime, unavailable), ("https://vk.com/v.ogg", 7, "audio/ogg", False))
def test_voice_without_a_link_still_reaches_the_operator(self) -> None:
_url, _duration, _mime, unavailable = vk_media.voice_attachment(
{"attachments": [{"type": "audio_message", "audio_message": {"duration": 3}}]}
)
self.assertTrue(unavailable)
def test_attachment_the_channel_cannot_show_does_not_swallow_the_message(self) -> None:
update = _update(text="", attachments=[{"type": "video", "video": {"id": 1}}])
with mock.patch.object(vk, "customer_language", return_value="ru"):
message = vk._normalize(_integration(), update)
self.assertIsNotNone(message)
self.assertTrue(message.text)
class VkOutboundTests(SimpleTestCase):
def test_text_goes_out_by_post_with_a_deduplication_id(self) -> None:
api = _Api(**{"messages.send": {"response": 1}})
posts = []
def record(integration, method, params=None, *, post=False):
posts.append(post)
return api(integration, method, params, post=post)
with mock.patch.object(vk_send.vk_api, "call", record):
self.assertTrue(vk_send.send_text(_integration(), chat_id="77", user_id="77", text="Ответ"))
params = api.params("messages.send")
self.assertEqual(params["peer_id"], "77")
self.assertEqual(params["message"], "Ответ")
self.assertTrue(params["random_id"])
self.assertEqual(posts, [True])
def test_call_invite_carries_a_link_button(self) -> None:
api = _Api(**{"messages.send": {"response": 1}})
with mock.patch.object(vk_send.vk_api, "call", api):
with mock.patch.object(vk_send, "customer_language", return_value="ru"):
sent = vk_send.send_call_invite(
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://hub.test/calls/abc"
)
self.assertTrue(sent)
self.assertIn("https://hub.test/calls/abc", api.params("messages.send")["keyboard"])
def test_rejected_send_is_reported_as_failure(self) -> None:
api = _Api(**{"messages.send": VkRejected("ВКонтакте отклонил запрос (7): access denied")})
with mock.patch.object(vk_send.vk_api, "call", api):
self.assertFalse(vk_send.send_text(_integration(), chat_id="77", user_id="", text="Ответ"))
def test_photo_is_uploaded_and_attached(self) -> None:
api = _Api(
**{
"photos.getMessagesUploadServer": {"upload_url": "https://upload.vk.com/1"},
"photos.saveMessagesPhoto": [{"owner_id": 5, "id": 9}],
"messages.send": {"response": 1},
}
)
with mock.patch.object(vk_send.vk_api, "call", api):
with mock.patch.object(
vk_media, "request_json_multipart", return_value={"server": "1", "photo": "[]", "hash": "h"}
):
sent = vk_send.send_file(
_integration(),
chat_id="77",
user_id="77",
content=b"binary",
filename="photo.jpg",
content_type="image/jpeg",
caption="Схема",
)
self.assertTrue(sent)
self.assertEqual(api.params("messages.send")["attachment"], "photo5_9")
self.assertEqual(api.params("messages.send")["message"], "Схема")
@@ -2,6 +2,8 @@ from chatballs.conversations.transports import backoff
from chatballs.conversations.transports import email as _email
from chatballs.conversations.transports import max as _max
from chatballs.conversations.transports import telegram as _telegram
from chatballs.conversations.transports import vk as _vk
from chatballs.conversations.transports import vk_send as _vk_send
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import t
from chatballs.integrations.models import IntegrationProvider
@@ -9,6 +11,7 @@ from chatballs.integrations.models import IntegrationProvider
_POLL = {
IntegrationProvider.MAX: _max.poll_updates,
IntegrationProvider.TELEGRAM: _telegram.poll_updates,
IntegrationProvider.VK: _vk.poll_updates,
IntegrationProvider.EMAIL: _email.poll_updates,
}
def _web_noop(integration, *, chat_id: str, user_id: str, text: str) -> bool:
@@ -19,6 +22,7 @@ def _web_noop(integration, *, chat_id: str, user_id: str, text: str) -> bool:
_SEND = {
IntegrationProvider.MAX: _max.send_text,
IntegrationProvider.TELEGRAM: _telegram.send_text,
IntegrationProvider.VK: _vk_send.send_text,
IntegrationProvider.WEB: _web_noop,
IntegrationProvider.EMAIL: _email.send_text,
}
@@ -28,6 +32,9 @@ _SEND = {
_CONTACT_REQUEST = {
IntegrationProvider.MAX: _max.send_contact_request,
IntegrationProvider.TELEGRAM: _telegram.send_contact_request,
# ВКонтакте телефон не отдаёт и кнопки для этого не имеет — просьба
# уходит обычным сообщением, как и почтой.
IntegrationProvider.VK: _vk_send.send_text,
IntegrationProvider.WEB: _web_noop,
# Email: кнопок нет — просьба уходит обычным письмом.
IntegrationProvider.EMAIL: _email.send_text,
@@ -37,6 +44,7 @@ _CONTACT_REQUEST = {
_CONTACT_ACK = {
IntegrationProvider.MAX: _max.send_text,
IntegrationProvider.TELEGRAM: _telegram.send_contact_ack,
IntegrationProvider.VK: _vk_send.send_text,
IntegrationProvider.WEB: _web_noop,
IntegrationProvider.EMAIL: _email.send_text,
}
@@ -46,6 +54,7 @@ _CONTACT_ACK = {
_CALL_INVITE = {
IntegrationProvider.MAX: _max.send_call_invite,
IntegrationProvider.TELEGRAM: _telegram.send_call_invite,
IntegrationProvider.VK: _vk_send.send_call_invite,
}
# Провайдеры-мессенджеры, у которых есть транспорт приёма/отправки.
@@ -119,6 +128,7 @@ def _web_file_noop(integration, *, chat_id: str, user_id: str, content: bytes, f
_FILE_SEND = {
IntegrationProvider.TELEGRAM: _telegram.send_file,
IntegrationProvider.MAX: _max.send_file,
IntegrationProvider.VK: _vk_send.send_file,
IntegrationProvider.EMAIL: _email.send_file,
IntegrationProvider.WEB: _web_file_noop,
}
@@ -132,6 +142,8 @@ def download_file(integration, inbound_file) -> tuple[bytes, str]:
return content, inbound_file.content_type or guessed
if integration.provider == IntegrationProvider.MAX and inbound_file.url:
return _max.download_file(integration, inbound_file.url, inbound_file.content_type)
if integration.provider == IntegrationProvider.VK and inbound_file.url:
return _vk.download_file(integration, inbound_file.url, inbound_file.content_type)
raise ValueError(t("conversations.file_download_unsupported"))
@@ -181,6 +193,9 @@ def download_avatar(integration, inbound) -> tuple[bytes, str] | None:
if integration.provider == IntegrationProvider.MAX and inbound.avatar_url:
content, _content_type = _max.download_file(integration, inbound.avatar_url, "")
return (content, source) if content else None
if integration.provider == IntegrationProvider.VK and inbound.avatar_url:
content, _content_type = _vk.download_file(integration, inbound.avatar_url, "")
return (content, source) if content else None
return None
@@ -191,6 +206,8 @@ def download_voice(integration, inbound) -> tuple[bytes, str]:
return _telegram.download_voice(integration, inbound.voice_file_id)
if integration.provider == IntegrationProvider.MAX and inbound.voice_url:
return _max.download_voice(integration, inbound.voice_url)
if integration.provider == IntegrationProvider.VK and inbound.voice_url:
return _vk.download_voice(integration, inbound.voice_url, inbound.voice_mime)
raise ValueError(t("conversations.voice_download_unsupported"))
@@ -0,0 +1,236 @@
"""VK community transport: приём сообщений (Bots Long Poll API).
``groups.getLongPollServer`` отдаёт адрес сервера, ключ и позицию в потоке
событий, дальше сервер опрашивается ``act=a_check``. Отправка живёт в
``vk_send``, разбор вложений — в ``vk_media``, доступ к API — в ``vk_api``.
Позиция потока хранится в ``poll_marker``, а адрес сервера и ключ — в памяти
процесса: их выдают на несколько часов, и колонка под них означала бы запись в
базу на каждом цикле опроса (``transports.backoff`` устроен так же).
"""
from __future__ import annotations
import logging
import urllib.parse
from dataclasses import dataclass, replace
from django.conf import settings
from chatballs.conversations.transports import vk_api, vk_media
from chatballs.conversations.transports.base import InboundMessage, first, request_json
from chatballs.conversations.transports.errors import PollFailed
from chatballs.i18n import customer_language, t
from chatballs.integrations.checks import vk_group
from chatballs.integrations.outbound import mask_url_secrets
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class _Session:
server: str
key: str
group_id: str
_sessions: dict[int, _Session] = {}
def reset() -> None:
"""Забыть выданные адреса Long Poll (тесты, смена токена подключения)."""
_sessions.clear()
def _ts(value: object, fallback: str = "") -> str:
"""Позиция потока строкой.
Ноль — это тоже позиция: у сообщества, которому ещё не писали, ВКонтакте
отдаёт ``ts: 0``. Обычное ``or`` приняло бы её за отсутствие значения, а
опрос с пустой позицией возвращает ``ts: -1`` — курсор, с которого поток
уже не читается.
"""
return fallback if value is None or value == "" else str(value)
def _group_id(integration) -> str:
"""Идентификатор сообщества: из настроек подключения или у самого ВКонтакте.
Проверка подключения кладёт его в конфигурацию, но опрос обязан работать и
до первой проверки: иначе подключение, созданное в обход кнопки
«Проверить», молча не принимало бы сообщения.
"""
configured = str(integration.config.get("bot_id") or "")
if configured:
return configured
group_id = str(vk_group(vk_api.call(integration, "groups.getById")).get("id") or "")
if not group_id:
raise PollFailed(t("conversations.vk_no_group"))
return group_id
def _open_session(integration, group_id: str = "") -> tuple[_Session, str]:
"""Новый адрес Long Poll; вторым значением — позиция потока от ВКонтакте."""
group = group_id or _group_id(integration)
response = vk_api.call(integration, "groups.getLongPollServer", {"group_id": group}) or {}
session = _Session(
server=str(response.get("server") or ""),
key=str(response.get("key") or ""),
group_id=group,
)
if not session.server or not session.key:
raise PollFailed(t("conversations.vk_longpoll_unavailable"))
_sessions[integration.id] = session
return session, _ts(response.get("ts"))
def _check(integration, session: _Session, ts: str) -> dict:
query = urllib.parse.urlencode(
{
"act": "a_check",
"key": session.key,
"ts": ts,
"wait": settings.CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS,
}
)
return request_json(f"{session.server}?{query}", proxy_url=vk_api.proxy(integration))
def _recover(integration, session: _Session, ts: str, data: dict) -> tuple[dict, str]:
"""Ответ ``failed``: позиция устарела (1) либо ключ и история потеряны (2, 3).
Восстанавливаемся тут же, а не на следующем цикле: иначе подключение висело
бы с протухшим ключом, а поллер всё это время считал бы, что сообщений
просто нет.
"""
failed = int(data.get("failed") or 0)
if failed == 1:
ts = _ts(data.get("ts"), ts)
else:
session, fresh_ts = _open_session(integration, session.group_id)
# Потеря истории (3) обесценивает и позицию: со старой сервер не отдаст
# ничего. Протухший ключ (2) позицию не трогает.
ts = ts if failed == 2 else fresh_ts
retried = _check(integration, session, ts)
if retried.get("failed"):
raise PollFailed(t("conversations.vk_longpoll_failed", code=retried.get("failed")))
return retried, ts
def poll_updates(integration) -> tuple[list[InboundMessage], str]:
if not integration.secret:
return [], integration.poll_marker
try:
session = _sessions.get(integration.id)
ts = integration.poll_marker
if session is None or not ts:
session, fresh_ts = _open_session(integration, session.group_id if session else "")
ts = ts or fresh_ts
data = _check(integration, session, ts)
if data.get("failed"):
data, ts = _recover(integration, session, ts, data)
messages = _messages(integration, data.get("updates") or [])
except vk_api.CALL_ERRORS as error:
# Сессия могла протухнуть вместе со связью — следующая попытка начнёт с
# нового адреса, а не с сохранённого мёртвого.
_sessions.pop(integration.id, None)
raise PollFailed(mask_url_secrets(error)) from error
return messages, _ts(data.get("ts"), ts)
def _messages(integration, updates: list) -> list[InboundMessage]:
inbound = [message for message in (_normalize(integration, u) for u in updates) if message is not None]
return _with_profiles(integration, inbound)
def _normalize(integration, update: dict) -> InboundMessage | None:
if update.get("type") != "message_new":
return None
payload = update.get("object") or {}
# С версии 5.103 сообщение лежит в object.message, до неё — прямо в object.
message = payload.get("message") or payload
from_id = message.get("from_id")
peer_id = message.get("peer_id")
external_id = first(message, "id", "conversation_message_id")
if from_id is None or peer_id is None or external_id is None:
return None
if int(from_id) < 0:
# Сообщение самого сообщества (ответ из диалогов ВКонтакте) — не входящее.
return None
voice_url, voice_duration, voice_mime, voice_unavailable = vk_media.voice_attachment(message)
files = vk_media.file_attachments(message)
text = _text(integration, message, has_content=bool(files or voice_url or voice_unavailable))
if not text and not files and not voice_url and not voice_unavailable:
return None
return InboundMessage(
external_id=str(external_id),
user_id=str(from_id),
chat_id=str(peer_id),
text=text,
display_name="",
voice_url=voice_url,
voice_duration=voice_duration,
voice_mime=voice_mime,
voice_unavailable=voice_unavailable,
files=files,
)
def _text(integration, message: dict, *, has_content: bool) -> str:
"""Текст реплики; для непоказуемого вложения — след вместо пустоты."""
text = str(message.get("text") or "")
unsupported = vk_media.unsupported_attachments(message)
if unsupported:
logger.info("VK attachment types not shown to the operator: %s", ", ".join(unsupported))
if text or has_content or not unsupported:
return text
# Вложение, которое канал показать не может: пустой текст обернулся бы
# потерей реплики, поэтому оператор видит хотя бы её след.
return t("conversations.attachment_unsupported", language=customer_language(integration.organization))
def _with_profiles(integration, messages: list[InboundMessage]) -> list[InboundMessage]:
"""Имя, логин и фото отправителей — одним запросом на пачку.
В апдейте ВКонтакте профиля нет, его отдаёт ``users.get``. Спрашивать его на
каждое сообщение значило бы упереться в частоту обращений на оживлённом
сообществе, поэтому запрос один на цикл опроса.
"""
user_ids = sorted({message.user_id for message in messages if message.user_id.isdigit()})
if not user_ids:
return messages
try:
response = vk_api.call(
integration,
"users.get",
{"user_ids": ",".join(user_ids), "fields": "photo_100,screen_name"},
)
except vk_api.CALL_ERRORS as error:
# Без профиля сообщение всё равно доезжает: имя контакта уточнится на
# следующем сообщении, а терять реплику из-за справки нельзя.
logger.warning("VK users.get failed for integration %s: %s", integration.id, mask_url_secrets(error))
return messages
profiles = {str(user.get("id")): user for user in (response or []) if isinstance(user, dict)}
return [_with_profile(message, profiles.get(message.user_id)) for message in messages]
def _with_profile(message: InboundMessage, profile: dict | None) -> InboundMessage:
if not profile:
return message
name = " ".join(part for part in (profile.get("first_name"), profile.get("last_name")) if part)
screen_name = str(profile.get("screen_name") or "")
return replace(
message,
display_name=str(name or screen_name),
username=screen_name,
avatar_url=str(profile.get("photo_100") or ""),
)
def download_file(integration, url: str, content_type: str) -> tuple[bytes, str]:
"""Скачивание вложения по прямому адресу из апдейта."""
return vk_api.download(integration, url), content_type or "application/octet-stream"
def download_voice(integration, url: str, content_type: str = "") -> tuple[bytes, str]:
return vk_api.download(integration, url), content_type or "audio/ogg"
@@ -0,0 +1,50 @@
"""Доступ к API ВКонтакте: адрес подключения, прокси, вызов метода, скачивание.
Ключ доступа сообщества уходит параметром запроса — заголовка авторизации у
ВКонтакте нет. Поэтому ни один адрес не печатается как есть: и журнал, и статус
подключения получают его через ``mask_url_secrets``.
"""
from __future__ import annotations
import http.client
import json
import urllib.error
from chatballs.conversations.transports.base import download_bytes
from chatballs.integrations.checks import DEFAULT_VK_BASE_URL, VkRejected, vk_call
from chatballs.integrations.outbound import host_of
NETWORK_ERRORS = (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError)
# Отказ провайдера и обрыв связи обрабатываются одинаково: подключение на этом
# цикле не работает, а чем именно — видно из текста ошибки.
CALL_ERRORS = (VkRejected, *NETWORK_ERRORS)
def base(integration) -> str:
return (integration.config.get("base_url") or DEFAULT_VK_BASE_URL).rstrip("/")
def proxy(integration) -> str:
return integration.config.get("proxy_url", "")
def call(integration, method: str, params: dict | None = None, *, post: bool = False):
"""Метод API ВКонтакте от имени подключения; возвращает содержимое response."""
return vk_call(
base_url=base(integration),
method=method,
secret=integration.secret,
params=params,
proxy_url=proxy(integration),
post=post,
)
def download(integration, url: str) -> bytes:
"""Скачивание по прямому адресу из ответа провайдера.
Хост из ``base_url`` подключения владелец назвал сам, поэтому он остаётся
разрешённым, даже если ведёт внутрь сети (chatballs.integrations.outbound).
"""
return download_bytes(url, proxy_url=proxy(integration), allowed_host=host_of(base(integration)))
@@ -0,0 +1,167 @@
"""Вложения ВКонтакте: разбор входящих и загрузка исходящих.
Разбор здесь чистый — на вход словарь сообщения, на выход значения для
``InboundMessage``. Загрузка идёт в три шага (получить адрес загрузки, залить
файл, сохранить его у провайдера), поэтому наружу она принимает вызов API и
прокси подключения, а не само подключение: так модуль не зависит от транспорта
и проверяется без базы.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from chatballs.conversations.transports.base import (
InboundFile,
first,
guess_content_type,
request_json_multipart,
safe_filename,
)
logger = logging.getLogger(__name__)
# Вложения, которые канал умеет показать оператору. Остальные (видео, товар,
# запись на стене) ВКонтакте отдаёт только по отдельному запросу с правами,
# которых у сообщества нет.
KNOWN_ATTACHMENTS = ("photo", "doc", "audio_message", "sticker", "graffiti")
def photo_url(photo: dict) -> str:
"""Самый крупный размер фотографии.
Набор размеров зависит от исходника, поэтому выбираем по площади, а не по
буквенному типу: у маленьких снимков крупных типов просто нет.
"""
sizes = [size for size in (photo.get("sizes") or []) if size.get("url")]
if not sizes:
return ""
largest = max(sizes, key=lambda size: int(size.get("width") or 0) * int(size.get("height") or 0))
return str(largest.get("url") or "")
def voice_attachment(message: dict) -> tuple[str, int, str, bool]:
"""Голосовое сообщение: адрес, длительность, mime и признак «нечего скачать».
Последнее значение означает, что голосовое в сообщении было, а адреса в нём
нет. Такая реплика всё равно доезжает до оператора заглушкой: молча терять
сказанное клиентом нельзя.
"""
for attachment in message.get("attachments") or []:
if attachment.get("type") != "audio_message":
continue
payload = attachment.get("audio_message") or {}
ogg = str(payload.get("link_ogg") or "")
url = ogg or str(payload.get("link_mp3") or "")
duration = int(payload.get("duration") or 0)
return url, duration, ("audio/ogg" if ogg else "audio/mpeg") if url else "", not url
return "", 0, "", False
def file_attachments(message: dict) -> tuple[InboundFile, ...]:
"""Фото, документы, стикеры и граффити — каждый становится своей репликой."""
files: list[InboundFile] = []
for attachment in message.get("attachments") or []:
kind = attachment.get("type")
if kind == "photo":
url = photo_url(attachment.get("photo") or {})
if url:
files.append(InboundFile(name="photo.jpg", content_type="image/jpeg", url=url, is_image=True))
elif kind in ("sticker", "graffiti"):
url = _image_from_list(attachment.get(kind) or {})
if url:
files.append(InboundFile(name=f"{kind}.png", content_type="image/png", url=url, is_image=True))
elif kind == "doc":
document = attachment.get("doc") or {}
url = str(document.get("url") or "")
if not url:
continue
name = safe_filename(document.get("title") or "", "document")
mime = guess_content_type(name)
files.append(
InboundFile(
name=name,
content_type=mime,
size=int(document.get("size") or 0),
url=url,
is_image=mime.startswith("image/"),
)
)
return tuple(files)
def unsupported_attachments(message: dict) -> tuple[str, ...]:
"""Типы вложений, которые разобрать не удалось, — для журнала."""
return tuple(
str(attachment.get("type") or "")
for attachment in message.get("attachments") or []
if attachment.get("type") not in KNOWN_ATTACHMENTS
)
def _image_from_list(payload: dict) -> str:
"""Самая крупная картинка стикера или граффити."""
images = [image for image in (payload.get("images") or []) if image.get("url")]
if not images:
return str(payload.get("url") or "")
largest = max(images, key=lambda image: int(image.get("width") or 0) * int(image.get("height") or 0))
return str(largest.get("url") or "")
def upload_attachment(
*,
api: Callable[..., object],
proxy_url: str,
peer_id: str,
content: bytes,
filename: str,
content_type: str,
) -> str:
"""Загрузить файл и вернуть строку attachment для messages.send.
Фото и документы у ВКонтакте загружаются разными парами методов, но шаги
одинаковы: получить адрес загрузки, отправить файл на него, сохранить
результат. Пустая строка — загрузка не удалась, отправку продолжать нечем.
"""
as_photo = content_type in ("image/jpeg", "image/png", "image/gif", "image/webp")
if as_photo:
server = api("photos.getMessagesUploadServer", {"peer_id": peer_id})
uploaded = _upload(server, proxy_url=proxy_url, field="photo", filename=filename, content=content, content_type=content_type)
if not uploaded.get("photo"):
return ""
saved = api(
"photos.saveMessagesPhoto",
{"server": uploaded.get("server", ""), "photo": uploaded.get("photo", ""), "hash": uploaded.get("hash", "")},
post=True,
)
item = (saved or [{}])[0] if isinstance(saved, list) else {}
return _attachment_id("photo", item)
server = api("docs.getMessagesUploadServer", {"type": "doc", "peer_id": peer_id})
uploaded = _upload(server, proxy_url=proxy_url, field="file", filename=filename, content=content, content_type=content_type)
if not uploaded.get("file"):
return ""
saved = api("docs.save", {"file": uploaded.get("file", "")}, post=True)
item = (saved or {}).get("doc") or {} if isinstance(saved, dict) else {}
return _attachment_id("doc", item)
def _upload(server: object, *, proxy_url: str, field: str, filename: str, content: bytes, content_type: str) -> dict:
upload_url = str((server or {}).get("upload_url") or "") if isinstance(server, dict) else ""
if not upload_url:
return {}
return request_json_multipart(
upload_url,
fields={},
file_field=field,
filename=filename,
content=content,
content_type=content_type,
proxy_url=proxy_url,
)
def _attachment_id(kind: str, item: dict) -> str:
owner_id = first(item, "owner_id", default="")
item_id = first(item, "id", default="")
return f"{kind}{owner_id}_{item_id}" if owner_id != "" and item_id != "" else ""
@@ -0,0 +1,84 @@
"""VK community transport: отправка сообщений (messages.send).
Текст, приглашение на звонок кнопкой-ссылкой и файл оператора. Голосовых здесь
нет намеренно: ВКонтакте принимает голосовое только в ogg/opus, а композер
записывает webm/opus, поэтому VK не зарегистрирован в ``_VOICE_SEND`` и
интерфейс не предлагает записать голосовое в этом канале.
"""
from __future__ import annotations
import functools
import json
import logging
import random
from chatballs.conversations.transports import vk_api, vk_media
from chatballs.i18n import customer_language, t
from chatballs.integrations.outbound import mask_url_secrets
logger = logging.getLogger(__name__)
def _send(integration, *, chat_id: str, user_id: str, params: dict) -> bool:
target = chat_id or user_id
if not integration.secret or not target:
return False
body = {
"peer_id": target,
# random_id обязателен: по нему ВКонтакте отбрасывает повторы. Отправку
# мы не повторяем, поэтому значение своё на каждый вызов.
"random_id": random.getrandbits(31),
**params,
}
try:
# POST: ответ агента длиннее, чем разумно укладывать в адрес запроса.
vk_api.call(integration, "messages.send", body, post=True)
return True
except vk_api.CALL_ERRORS as error:
logger.warning("VK send failed for integration %s: %s", integration.id, mask_url_secrets(error))
return False
def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool:
return _send(integration, chat_id=chat_id, user_id=user_id, params={"message": text})
def _caption(integration, key: str) -> str:
"""Подпись кнопки читает клиент — язык организации, а не язык запроса."""
return t(key, language=customer_language(integration.organization))
def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url: str) -> bool:
# Приглашение на онлайн-звонок: кнопка-ссылка под сообщением.
button = {"action": {"type": "open_link", "link": url, "label": _caption(integration, "conversations.button_join_call")}}
keyboard = {"inline": True, "buttons": [[button]]}
return _send(
integration,
chat_id=chat_id,
user_id=user_id,
params={"message": text, "keyboard": json.dumps(keyboard, ensure_ascii=False)},
)
def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filename: str, content_type: str, caption: str = "") -> bool:
"""Файл оператора: загрузка у провайдера, затем сообщение со ссылкой на неё."""
target = chat_id or user_id
if not integration.secret or not target:
return False
try:
attachment = vk_media.upload_attachment(
api=functools.partial(vk_api.call, integration),
proxy_url=vk_api.proxy(integration),
peer_id=target,
content=content,
filename=filename,
content_type=content_type,
)
except vk_api.CALL_ERRORS as error:
logger.warning("VK upload failed for integration %s: %s", integration.id, mask_url_secrets(error))
return False
if not attachment:
return False
params = {"attachment": attachment, **({"message": caption[:4000]} if caption else {})}
return _send(integration, chat_id=chat_id, user_id=user_id, params=params)
@@ -474,6 +474,10 @@ MESSAGES: dict[str, object] = {
"conversations.file_download_unsupported": "File download is not supported for this provider",
"conversations.telegram_getfile_failed": "Telegram getFile failed",
"conversations.voice_download_unsupported": "Voice download is not supported for this provider",
"conversations.attachment_unsupported": "An attachment this channel cannot show",
"conversations.vk_no_group": "Could not identify the VK community — check the connection",
"conversations.vk_longpoll_unavailable": "VK did not return an address for receiving messages",
"conversations.vk_longpoll_failed": "VK interrupted message delivery (code {code})",
"portals.article_object_required": "Article must be an object",
"portals.locale_string": "Locale must be a string",
"portals.summary_string": "Summary must be a string",
@@ -516,6 +520,11 @@ MESSAGES: dict[str, object] = {
"integrations.check_endpoint_answered": "The endpoint answered {status}",
"integrations.check_key_accepted": "the key was accepted",
"integrations.check_bot_connected": "the bot is connected",
"integrations.check_vk_rejected": "VK rejected the request ({code}): {reason}",
"integrations.check_vk_scopes": "The access key lacks the required permissions: issue it with the community messages and community management scopes",
"integrations.check_vk_no_group": "The key does not belong to a community: a community access key is required, not a personal one",
"integrations.check_vk_longpoll_off": "Event delivery is off in the community: turn on the Long Poll API in the community management",
"integrations.check_vk_message_event_off": "The incoming message event is not enabled in the Long Poll API settings",
"integrations.check_demo": "Demo provider: answers from the agent's knowledge, with no external calls or keys",
"integrations.check_unsupported": "Checking is not supported for this connection type",
"integrations.check_web_not_bound": "The connection is not bound to a channel — the widget is inactive",
@@ -478,6 +478,10 @@ MESSAGES: dict[str, object] = {
"conversations.file_download_unsupported": "Этот канал не поддерживает скачивание файлов",
"conversations.telegram_getfile_failed": "Telegram не отдал файл",
"conversations.voice_download_unsupported": "Этот канал не поддерживает скачивание голосовых",
"conversations.attachment_unsupported": "Вложение, которое канал не может показать",
"conversations.vk_no_group": "Не удалось определить сообщество ВКонтакте — проверьте подключение",
"conversations.vk_longpoll_unavailable": "ВКонтакте не выдал адрес для приёма сообщений",
"conversations.vk_longpoll_failed": "ВКонтакте прервал приём сообщений (код {code})",
"portals.article_object_required": "Статья должна быть объектом",
"portals.locale_string": "Поле «locale» — строка",
"portals.summary_string": "Поле «summary» — строка",
@@ -520,6 +524,11 @@ MESSAGES: dict[str, object] = {
"integrations.check_endpoint_answered": "Эндпоинт ответил {status}",
"integrations.check_key_accepted": "ключ принят",
"integrations.check_bot_connected": "бот подключён",
"integrations.check_vk_rejected": "ВКонтакте отклонил запрос ({code}): {reason}",
"integrations.check_vk_scopes": "У ключа доступа нет нужных прав: выпустите его с правами «Сообщения сообщества» и «Управление сообществом»",
"integrations.check_vk_no_group": "Ключ не принадлежит сообществу: нужен ключ доступа сообщества, а не пользователя",
"integrations.check_vk_longpoll_off": "В сообществе выключен приём событий: включите Long Poll API в управлении сообществом",
"integrations.check_vk_message_event_off": "В настройках Long Poll API не включено событие «Входящее сообщение»",
"integrations.check_demo": "Демо-провайдер: отвечает по знаниям агента, без внешних запросов и ключей",
"integrations.check_unsupported": "Проверка для этого типа подключения не поддерживается",
"integrations.check_web_not_bound": "Подключение не привязано к каналу — виджет не активен",
+127 -2
View File
@@ -7,6 +7,9 @@ identity method are not interchangeable:
- Telegram: GET {base}/bot<token>/getMe (token in the path).
- MAX: GET {base}/me, header `Authorization: <token>` (raw token; the
query-param access_token is no longer supported).
- VK: GET {base}/groups.getById + groups.getLongPollSettings, token in
the query string (VK has no auth header); errors come back with
HTTP 200 and an `error` body.
Each check returns (ok, detail, meta) and never raises; meta may carry
{"bot_username": ...} parsed from the provider's identity response.
@@ -21,10 +24,12 @@ import re
import smtplib
import urllib.error
import urllib.request
from urllib.parse import urlencode
from django.conf import settings
from chatballs.i18n import t, tn
from chatballs.integrations.outbound import mask_url_secrets
from chatballs.integrations.proxy import build_opener
logger = logging.getLogger(__name__)
@@ -34,6 +39,12 @@ DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# рабочий и с валидным сертификатом — platform-api.max.ru.
DEFAULT_MAX_BASE_URL = "https://platform-api.max.ru"
DEFAULT_TELEGRAM_BASE_URL = "https://api.telegram.org"
DEFAULT_VK_BASE_URL = "https://api.vk.com/method"
# Версия API ВКонтакте: параметр обязателен в каждом запросе.
VK_API_VERSION = "5.199"
# Метод недоступен ключу с такими правами: Bots Long Poll требует прав
# «Сообщения сообщества» и «Управление сообществом».
VK_ACCESS_DENIED = 15
CheckResult = tuple[bool, str, dict]
@@ -50,6 +61,24 @@ def _get(url: str, *, headers: dict[str, str] | None = None, proxy_url: str = ""
return response.status, data
def _post_form(url: str, body: str, *, proxy_url: str = "") -> tuple[int, dict]:
"""POST application/x-www-form-urlencoded — форма, которую ждёт ВКонтакте."""
opener = build_opener(proxy_url)
request = urllib.request.Request(
url,
data=body.encode("utf-8"),
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with opener.open(request, timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT) as response:
payload = response.read().decode("utf-8")
try:
data = json.loads(payload) if payload else {}
except json.JSONDecodeError:
data = {}
return response.status, data
def _error_reason(body: str) -> str:
"""Короткая причина из ответа провайдера.
@@ -88,7 +117,7 @@ def _http_failure(error: urllib.error.HTTPError) -> str:
logger.warning(
"Integration check rejected: HTTP %s %s — %s",
error.code,
getattr(error, "url", ""),
mask_url_secrets(getattr(error, "url", "")),
body[:500],
)
reason = _error_reason(body)
@@ -105,7 +134,7 @@ def _safe(fn) -> CheckResult:
except urllib.error.HTTPError as error:
return False, _http_failure(error), {}
except (urllib.error.URLError, TimeoutError, OSError) as error:
return False, t("integrations.check_no_connection", error=error), {}
return False, t("integrations.check_no_connection", error=mask_url_secrets(error)), {}
def check_openrouter(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult:
@@ -242,6 +271,102 @@ def check_telegram(*, secret: str, base_url: str, proxy_url: str = "") -> CheckR
return _safe(run)
class VkRejected(Exception):
"""ВКонтакте отклонил запрос: текст уже пригоден для показа человеку."""
def __init__(self, message: str, code: int = 0) -> None:
super().__init__(message)
self.code = code
def vk_call(*, base_url: str, method: str, secret: str, params: dict | None = None, proxy_url: str = "", post: bool = False):
"""Вызов метода API ВКонтакте; возвращает содержимое поля response.
Токен уходит параметром запроса — заголовка авторизации у ВКонтакте нет.
Поэтому адрес нигде не печатается как есть: и журнал, и статус подключения
получают его через ``mask_url_secrets``.
Ошибку ВКонтакте отдаёт кодом 200 и телом ``error``, так что проверять
статус недостаточно: отозванный токен выглядел бы успешной проверкой.
"""
base = (base_url or DEFAULT_VK_BASE_URL).rstrip("/")
query = urlencode({**(params or {}), "access_token": secret, "v": VK_API_VERSION})
if post:
status, data = _post_form(f"{base}/{method}", query, proxy_url=proxy_url)
else:
status, data = _get(f"{base}/{method}?{query}", proxy_url=proxy_url)
error = data.get("error")
if isinstance(error, dict):
raise VkRejected(
t(
"integrations.check_vk_rejected",
code=error.get("error_code", ""),
reason=str(error.get("error_msg") or "")[:160],
),
code=int(error.get("error_code") or 0),
)
if status != 200:
raise VkRejected(t("integrations.check_provider_answered", provider="ВКонтакте", status=status))
return data.get("response")
def vk_group(response) -> dict:
"""Сообщество из ответа groups.getById.
Форма ответа зависит от версии API: до 5.199 это список, дальше объект с
полем groups. Подключение переживает обе.
"""
items = response.get("groups") if isinstance(response, dict) else response
first_group = (items or [None])[0] if isinstance(items, list) else None
return first_group if isinstance(first_group, dict) else {}
def check_vk(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult:
"""Сообщество ВКонтакте: кто мы и включён ли приём сообщений.
Ключ доступа сообщества сам называет сообщество, поэтому его идентификатор
владельцу вводить не нужно — как имя бота у Telegram и MAX, он попадает в
конфигурацию результатом проверки.
Выключенный Long Poll — это ошибка подключения: принимать сообщения в таком
состоянии невозможно. Включаем не мы: настройки чужого сообщества меняет
его владелец.
"""
if not secret:
return False, t("integrations.check_bot_token_missing"), {}
def run() -> CheckResult:
try:
group = vk_group(vk_call(base_url=base_url, method="groups.getById", secret=secret, proxy_url=proxy_url))
if not group.get("id"):
return False, t("integrations.check_vk_no_group"), {}
group_id = str(group["id"])
long_poll = vk_call(
base_url=base_url,
method="groups.getLongPollSettings",
secret=secret,
params={"group_id": group_id},
proxy_url=proxy_url,
)
except VkRejected as error:
# 15 — метод недоступен ключу с такими правами. Без подсказки
# владелец видел бы английское «no access» и не знал, что чинить.
if error.code == VK_ACCESS_DENIED:
return False, t("integrations.check_vk_scopes"), {}
return False, str(error), {}
name = str(group.get("name") or "")
screen_name = str(group.get("screen_name") or "")
meta = {"bot_id": group_id, "bot_username": screen_name, "bot_name": name or screen_name}
settings_payload = long_poll if isinstance(long_poll, dict) else {}
if not settings_payload.get("is_enabled"):
return False, t("integrations.check_vk_longpoll_off"), meta
if not (settings_payload.get("events") or {}).get("message_new"):
return False, t("integrations.check_vk_message_event_off"), meta
return True, f"ВКонтакте: {name or screen_name}", meta
return _safe(run)
def check_demo(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult:
"""Демо-провайдер не ходит в сеть — всегда готов."""
return True, t("integrations.check_demo"), {}
@@ -1,15 +1,21 @@
"""Функции точки входа: что разрешено клиенту и оператору в диалогах через
эту интеграцию — голосовые сообщения и онлайн-звонки (аудио/видео).
Звонки технически возможны там, где есть доставка приглашения: Telegram, MAX
(кнопка-ссылка) и Web-виджет (баннер в виджете). Почта звонки не поддерживает.
Звонки технически возможны там, где есть доставка приглашения: Telegram, MAX,
ВКонтакте (кнопка-ссылка) и Web-виджет (баннер в виджете). Почта звонки не
поддерживает.
"""
from __future__ import annotations
from chatballs.integrations.models import Integration, IntegrationProvider
CALL_PROVIDERS = (IntegrationProvider.TELEGRAM, IntegrationProvider.MAX, IntegrationProvider.WEB)
CALL_PROVIDERS = (
IntegrationProvider.TELEGRAM,
IntegrationProvider.MAX,
IntegrationProvider.VK,
IntegrationProvider.WEB,
)
def supports_calls(integration: Integration | None) -> bool:
@@ -0,0 +1,29 @@
"""Подключение-сообщество ВКонтакте: новое значение provider (choices)."""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("integrations", "0008_encrypted_column_width"),
]
operations = [
migrations.AlterField(
model_name="integration",
name="provider",
field=models.CharField(
choices=[
("OPENROUTER", "OpenRouter"),
("CUSTOM", "Custom (OpenAI-compatible)"),
("DEMO", "Демо-провайдер (без ключа)"),
("MAX", "MAX"),
("TELEGRAM", "Telegram"),
("VK", "ВКонтакте"),
("WEB", "Web-виджет"),
("EMAIL", "Email (IMAP/SMTP)"),
],
max_length=16,
),
),
]
@@ -18,6 +18,7 @@ class IntegrationProvider(models.TextChoices):
DEMO = "DEMO", "Демо-провайдер (без ключа)"
MAX = "MAX", "MAX"
TELEGRAM = "TELEGRAM", "Telegram"
VK = "VK", "ВКонтакте"
WEB = "WEB", "Web-виджет"
EMAIL = "EMAIL", "Email (IMAP/SMTP)"
@@ -36,6 +37,8 @@ PROVIDER_KIND = {
IntegrationProvider.DEMO: IntegrationKind.LLM_PROVIDER,
IntegrationProvider.MAX: IntegrationKind.MESSENGER,
IntegrationProvider.TELEGRAM: IntegrationKind.MESSENGER,
# Сообщество ВКонтакте: приём через Bots Long Poll, отправка messages.send.
IntegrationProvider.VK: IntegrationKind.MESSENGER,
IntegrationProvider.WEB: IntegrationKind.MESSENGER,
# Email-ящик — транспорт диалогов наравне с ботами (ADR-CHATBALLS-0035).
IntegrationProvider.EMAIL: IntegrationKind.MESSENGER,
@@ -19,6 +19,7 @@ OpenAI-совместимый сервер или собственный Bot API
from __future__ import annotations
import ipaddress
import re
import socket
from urllib.parse import urlsplit
@@ -27,6 +28,23 @@ from chatballs.i18n import t
HTTP_SCHEMES = frozenset({"http", "https"})
PROXY_SCHEMES = frozenset({"http", "https", "socks5", "socks5h"})
# Параметры запроса, значение которых нельзя показывать ни в журнале, ни в
# статусе подключения. У части провайдеров (ВКонтакте) секрет иначе не
# передать: заголовка авторизации у них нет, токен уходит строкой запроса.
_SECRET_QUERY_RE = re.compile(
r"(access_token|api_key|key|password|token)=([^&\s'\"]+)", re.IGNORECASE
)
def mask_url_secrets(text: object) -> str:
"""Строка с адресом без значений секретных параметров запроса.
Сообщение об ошибке от urllib несёт в себе сам адрес, а адрес — токен.
Без этой замены токен сообщества оседал бы в журнале установки и в поле
последней ошибки подключения, которое видно в интерфейсе.
"""
return _SECRET_QUERY_RE.sub(lambda match: f"{match.group(1)}=***", str(text or ""))
class OutboundUrlRejected(ValueError):
"""Адрес не разрешён политикой исходящих запросов."""
@@ -236,6 +236,7 @@ _CHECKS = {
IntegrationProvider.DEMO: checks.check_demo,
IntegrationProvider.MAX: checks.check_max,
IntegrationProvider.TELEGRAM: checks.check_telegram,
IntegrationProvider.VK: checks.check_vk,
}
@@ -0,0 +1,103 @@
"""Проверка подключения ВКонтакте: кто мы и включён ли приём сообщений.
Сеть закрыта подменой HTTP-вызова: проверка обязана разбирать ответ, а не
ходить наружу. База здесь не нужна — проверка работает с секретом и адресом.
"""
from __future__ import annotations
import urllib.error
from unittest import mock
from django.test import SimpleTestCase
from chatballs.integrations import checks
GROUP = {"groups": [{"id": 42, "name": "Acme Support", "screen_name": "acme"}]}
LONG_POLL_ON = {"is_enabled": True, "events": {"message_new": 1}, "api_version": "5.199"}
def _answers(*responses):
"""Ответы VK по порядку вызовов: (status, payload)."""
return mock.patch.object(checks, "_get", side_effect=[(200, response) for response in responses])
class VkCheckTests(SimpleTestCase):
def test_community_is_recognized_and_remembered(self) -> None:
with _answers({"response": GROUP}, {"response": LONG_POLL_ON}) as get:
ok, detail, meta = checks.check_vk(secret="community-token", base_url="")
self.assertTrue(ok)
self.assertIn("Acme Support", detail)
self.assertEqual(meta, {"bot_id": "42", "bot_username": "acme", "bot_name": "Acme Support"})
# Идентификатор сообщества владелец не вводит: его называет сам токен.
self.assertIn("groups.getById", get.call_args_list[0].args[0])
self.assertIn("group_id=42", get.call_args_list[1].args[0])
def test_older_api_shape_is_accepted(self) -> None:
with _answers({"response": [{"id": 42, "name": "Acme Support"}]}, {"response": LONG_POLL_ON}):
ok, _detail, meta = checks.check_vk(secret="community-token", base_url="")
self.assertTrue(ok)
self.assertEqual(meta["bot_id"], "42")
def test_missing_token_is_reported_before_any_request(self) -> None:
with mock.patch.object(checks, "_get") as get:
ok, detail, _meta = checks.check_vk(secret="", base_url="")
self.assertFalse(ok)
self.assertTrue(detail)
get.assert_not_called()
def test_rejected_token_is_an_error_even_with_http_200(self) -> None:
# ВКонтакте отвечает на отозванный ключ кодом 200 и телом error.
rejection = {"error": {"error_code": 5, "error_msg": "User authorization failed"}}
with _answers(rejection):
ok, detail, meta = checks.check_vk(secret="revoked", base_url="")
self.assertFalse(ok)
self.assertIn("User authorization failed", detail)
self.assertEqual(meta, {})
def test_key_without_the_long_poll_permissions_says_what_to_fix(self) -> None:
# ВКонтакте отвечает на нехватку прав кодом 15 и английским «no access»:
# владельцу из него не видно, какое право включить.
denied = {"error": {"error_code": 15, "error_msg": "Access denied: no access to call this method."}}
with _answers({"response": GROUP}, denied):
ok, detail, _meta = checks.check_vk(secret="messages-only", base_url="")
self.assertFalse(ok)
self.assertIn("Управление сообществом", detail)
def test_personal_token_without_a_community_is_rejected(self) -> None:
with _answers({"response": {"groups": []}}):
ok, detail, _meta = checks.check_vk(secret="user-token", base_url="")
self.assertFalse(ok)
self.assertTrue(detail)
def test_disabled_long_poll_is_an_error(self) -> None:
with _answers({"response": GROUP}, {"response": {"is_enabled": False, "events": {}}}):
ok, detail, meta = checks.check_vk(secret="community-token", base_url="")
self.assertFalse(ok)
self.assertIn("Long Poll", detail)
# Сообщество уже опознано — идентификатор пригодится следующей проверке.
self.assertEqual(meta["bot_id"], "42")
def test_long_poll_without_the_message_event_is_an_error(self) -> None:
with _answers({"response": GROUP}, {"response": {"is_enabled": True, "events": {"message_new": 0}}}):
ok, detail, _meta = checks.check_vk(secret="community-token", base_url="")
self.assertFalse(ok)
self.assertTrue(detail)
def test_broken_connection_does_not_raise(self) -> None:
with mock.patch.object(checks, "_get", side_effect=urllib.error.URLError("no route")):
ok, detail, _meta = checks.check_vk(secret="community-token", base_url="")
self.assertFalse(ok)
self.assertIn("no route", detail)
def test_token_never_leaks_into_the_status(self) -> None:
failure = urllib.error.HTTPError(
"https://api.vk.com/method/groups.getById?access_token=vk1.a.SECRET&v=5.199",
403,
"Forbidden",
{},
None,
)
with mock.patch.object(checks, "_get", side_effect=failure):
_ok, detail, _meta = checks.check_vk(secret="vk1.a.SECRET", base_url="")
self.assertNotIn("vk1.a.SECRET", detail)
@@ -34,7 +34,7 @@ export const isImageAttachment = (message: Pick<ApiMessage, "attachmentContentTy
export type HistoryItem = {
id: number;
channelName: string;
provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB" | null;
provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB" | null;
lifecycle: "OPEN" | "CLOSED" | "SPAM";
createdAt: string;
lastActivityAt: string;
@@ -69,7 +69,7 @@ export type ApiConversation = {
id: number;
channel: { id: number; code: string; name: string };
// voiceMessages/audioCalls/videoCalls — что разрешено в точке входа («Настройки → Голосовые и звонки»).
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
connection: { id: number; provider: "EMAIL" | "MAX" | "TELEGRAM" | "VK" | "WEB"; name: string; voiceMessages?: boolean; audioCalls?: boolean; videoCalls?: boolean } | null;
// Контакт — единственный источник identity диалога.
// phone появляется после явного шаринга контакта; username (@логин TG/MAX) — только в detail-режиме.
contact: { id: number; name: string; email?: string; phone?: string; username?: string; avatarUrl?: string; description?: string; company?: string; city?: string } | null;
@@ -145,6 +145,7 @@ const PROVIDER_CHANNEL: Record<string, ChannelKey> = {
EMAIL: "EMAIL",
MAX: "MAX",
TELEGRAM: "TG",
VK: "VK",
WEB: "WEB",
};
@@ -10,7 +10,7 @@ export type ListTab = "all" | "mine" | "queue" | "onMe";
// Порядок инбокса. Считает его сервер: список приходит окном, и сортировать в
// браузере было бы нечего.
export type ListSort = "activity" | "waiting";
export type ChannelKey = "EMAIL" | "MAX" | "TG" | "WEB";
export type ChannelKey = "EMAIL" | "MAX" | "TG" | "VK" | "WEB";
// Элемент списка диалогов (бывш. SalesDialog). Полностью generic.
export type ConversationListItem = {
@@ -1,7 +1,7 @@
import { api } from "../../api/client";
import { t } from "../../i18n";
export type IntegrationProvider = "OPENROUTER" | "CUSTOM" | "DEMO" | "MAX" | "TELEGRAM" | "WEB" | "EMAIL";
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 = {
@@ -78,6 +78,9 @@ export const PROVIDERS: Record<IntegrationProvider, ProviderMeta> = {
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 },
@@ -1,7 +1,7 @@
import { Dropdown } from "antd";
import { useState } from "react";
import { Icon, MaxLogo, TelegramLogo } from "../../shared/icons";
import { Icon, MaxLogo, TelegramLogo, VkLogo } from "../../shared/icons";
import { Button, ToneBadge } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import { PROVIDERS, STATUS_META, type Integration, type IntegrationProvider } from "./model";
@@ -12,6 +12,7 @@ import { t } from "../../i18n";
const TILE_CLASS: Partial<Record<IntegrationProvider, string>> = {
TELEGRAM: "integration-tile--telegram",
MAX: "integration-tile--max",
VK: "integration-tile--vk",
WEB: "integration-tile--web",
EMAIL: "integration-tile--email",
};
@@ -31,6 +32,7 @@ export function ConnectionIcon({ provider }: { provider: IntegrationProvider })
<span className={`product-icon integration-tile ${tile}`}>
{provider === "TELEGRAM" && <TelegramLogo size={20} />}
{provider === "MAX" && <MaxLogo size={20} />}
{provider === "VK" && <VkLogo size={20} />}
{provider === "WEB" && <Icon name="message" size={20} strokeWidth={1.9} />}
{provider === "EMAIL" && <Icon name="mail" size={20} strokeWidth={1.9} />}
</span>
@@ -70,6 +70,11 @@
background: color-mix(in srgb, #5b5bd6 14%, var(--surface-card));
}
.integration-tile--vk {
color: #0077ff;
background: color-mix(in srgb, #0077ff 14%, var(--surface-card));
}
.integration-tile--web {
color: var(--primary-text);
background: var(--primary-bg);
@@ -9,8 +9,8 @@ import { t } from "../../../i18n";
export type ClientDetailTab = "overview" | "dialogs" | "ids" | "audit";
const PROVIDER_TO_CHANNEL: Record<string, ClientChannelCode> = { EMAIL: "EMAIL", MAX: "MAX", TELEGRAM: "TG", WEB: "WEB" };
const PROVIDER_LABEL: Record<string, string> = { EMAIL: "Email", MAX: "MAX", TELEGRAM: "Telegram", WEB: t("common.web_widget") };
const PROVIDER_TO_CHANNEL: Record<string, ClientChannelCode> = { EMAIL: "EMAIL", MAX: "MAX", TELEGRAM: "TG", VK: "VK", WEB: "WEB" };
const PROVIDER_LABEL: Record<string, string> = { EMAIL: "Email", MAX: "MAX", TELEGRAM: "Telegram", VK: "ВКонтакте", WEB: t("common.web_widget") };
// Подпись и цвет режима диалога — те же, что в чате и в списке контактов.
const MODE_META: Record<ClientDialogMode, { label: string; color: string; bg: string; dot: string }> = {
@@ -9,7 +9,7 @@ import { fmt, t } from "../../../i18n";
export { channelMap };
export type ClientChannelCode = "EMAIL" | "MAX" | "TG" | "WEB";
export type ClientChannelCode = "EMAIL" | "MAX" | "TG" | "VK" | "WEB";
export type ClientSortKey = "last" | "open";
export type ClientDropdown = "agents" | "channels";
export type ClientAgentRef = { id: number; code: string; name: string };
@@ -49,6 +49,7 @@ export const channelOptions: Array<{ code: ClientChannelCode; name: string; colo
{ code: "EMAIL", name: "Email", color: channelMap.EMAIL.color },
{ code: "MAX", name: "MAX", color: channelMap.MAX.color },
{ code: "TG", name: "Telegram", color: channelMap.TG.color },
{ code: "VK", name: "ВКонтакте", color: channelMap.VK.color },
{ code: "WEB", name: t("common.web_widget"), color: channelMap.WEB.color },
];
+1
View File
@@ -1481,6 +1481,7 @@ export const en: Record<MessageKey, Message> = {
"settings.back_section_list": "Back to the section list",
"settings.bot": "Bot",
"settings.bot_token": "Bot token",
"settings.vk_community_key": "Community access key",
"settings.bots_email_web_widget_entry": "Bots, email and the web widget are the entry points for conversations. Each is bound to the agent that answers first.",
"settings.bucket": "Bucket",
"settings.check": "Check",
+1
View File
@@ -1482,6 +1482,7 @@ export const ru = {
"settings.back_section_list": "К списку разделов",
"settings.bot": "Бот",
"settings.bot_token": "Токен бота",
"settings.vk_community_key": "Ключ доступа сообщества",
"settings.bots_email_web_widget_entry": "Боты, почта и Web-виджет — точки входа диалогов. Каждая привязана к агенту, который отвечает первым.",
"settings.bucket": "Бакет",
"settings.check": "Проверить",
+8
View File
@@ -57,6 +57,14 @@ export function MaxLogo({ size = 24 }: { size?: number }) {
);
}
export function VkLogo({ size = 24 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor">
<path d="m9.489.004.729-.003h3.564l.73.003.914.01.433.007.418.011.403.014.388.016.374.021.36.025.345.03.333.033c1.74.196 2.933.616 3.833 1.516.9.9 1.32 2.092 1.516 3.833l.034.333.029.346.025.36.02.373.025.588.012.41.013.644.009.915.004.98-.001 3.313-.003.73-.01.914-.007.433-.011.418-.014.403-.016.388-.021.374-.025.36-.03.345-.033.333c-.196 1.74-.616 2.933-1.516 3.833-.9.9-2.092 1.32-3.833 1.516l-.333.034-.346.029-.36.025-.373.02-.588.025-.41.012-.644.013-.915.009-.98.004-3.313-.001-.73-.003-.914-.01-.433-.007-.418-.011-.403-.014-.388-.016-.374-.021-.36-.025-.345-.03-.333-.033c-1.74-.196-2.933-.616-3.833-1.516-.9-.9-1.32-2.092-1.516-3.833l-.034-.333-.029-.346-.025-.36-.02-.373-.025-.588-.012-.41-.013-.644-.009-.915-.004-.98.001-3.313.003-.73.01-.914.007-.433.011-.418.014-.403.016-.388.021-.374.025-.36.03-.345.033-.333c.196-1.74.616-2.933 1.516-3.833.9-.9 2.092-1.32 3.833-1.516l.333-.034.346-.029.36-.025.373-.02.588-.025.41-.012.644-.013.915-.009ZM6.79 7.3H4.05c.13 6.24 3.25 9.99 8.72 9.99h.31v-3.57c2.01.2 3.53 1.67 4.14 3.57h2.84c-.78-2.84-2.83-4.41-4.11-5.01 1.28-.74 3.08-2.54 3.51-4.98h-2.58c-.56 1.98-2.22 3.78-3.8 3.95V7.3H10.5v6.92c-1.6-.4-3.62-2.34-3.71-6.92Z" />
</svg>
);
}
export function Icon({ name, size = 17, strokeWidth = 1.8 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "bellOff" | "chevron" | "chevronLeft" | "chevronRight" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "alert" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "play" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "inbox" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "close" | "folder" | "grip" | "reply" | "sort" | "collapseLeft" | "mic" | "text" | "pin" | "smile" | "globe" | "doc" | "sparkles" | "database" | "sun" | "sunny" | "moon" | "monitor" | "laptop" | "smartphone" | "arrowDown" | "link" | "paint" | "widget" | "danger" | "image" | "file" | "bold" | "italic" | "code" | "numlist" | "quote" | "table" | "attach" | "undo" | "upload" | "import" | "thumbUp" | "thumbDown" | "book" | "move"; size?: number; strokeWidth?: number }) {
const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth, strokeLinecap: "round", strokeLinejoin: "round" } as const;
const paths: Record<typeof name, ReactNode> = {
+5 -2
View File
@@ -1,18 +1,19 @@
import { t } from "../i18n";
/* Провайдеры подключений: MAX / Telegram / Web Chat / Email.
/* Провайдеры подключений: MAX / Telegram / ВКонтакте / Web Chat / Email.
Общий словарь для всего приложения — раньше он лежал в демо-данных диалогов,
и продуктовые экраны каналов зависели от файла с примерами.
Провайдеры различаются точкой и коротким лейблом на своих токенах
(ADR-HUB-0013), а не бренд-логотипами. */
export type ProviderKey = "EMAIL" | "MAX" | "TG" | "WEB";
export type ProviderKey = "EMAIL" | "MAX" | "TG" | "VK" | "WEB";
export const providerMeta: Record<ProviderKey, { label: string; short: string; color: string; bg: string }> = {
EMAIL: { label: "Email", short: "Email", color: "#d48806", bg: "#fff7e6" },
MAX: { label: "MAX", short: "MAX", color: "#6b5be0", bg: "#f2f0ff" },
TG: { label: "Telegram", short: "TG", color: "#2f8fd0", bg: "#eaf6fd" },
VK: { label: "ВКонтакте", short: "VK", color: "#0077ff", bg: "#e8f2ff" },
WEB: { label: "Web Chat", short: "Web", color: "#0f9b8e", bg: "#e8f7f4" },
};
@@ -22,6 +23,7 @@ const BY_CODE: Record<string, ProviderKey> = {
MAX: "MAX",
TELEGRAM: "TG",
TG: "TG",
VK: "VK",
WEB: "WEB",
WEBCHAT: "WEB",
};
@@ -37,6 +39,7 @@ export const channelMap = {
EMAIL: { full: "Email", color: "#d48806", bg: "color-mix(in srgb, #fa8c16 14%, var(--surface-card))" },
MAX: { full: "MAX", color: "#5b5bd6", bg: "color-mix(in srgb, #5b5bd6 14%, var(--surface-card))" },
TG: { full: "Telegram", color: "#229ed9", bg: "color-mix(in srgb, #229ed9 14%, var(--surface-card))" },
VK: { full: "ВКонтакте", color: "#0077ff", bg: "color-mix(in srgb, #0077ff 14%, var(--surface-card))" },
WEB: { full: t("common.web_widget"), color: "#0f9b8e", bg: "color-mix(in srgb, #0f9b8e 14%, var(--surface-card))" },
} satisfies Record<ProviderKey, { full: string; color: string; bg: string }>;