mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ 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:
1 parent
1ea0faf1c6
commit
31985fc35c
29 files changed
+1211
-25
No files matched your search
@@ -57,7 +57,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Chatballs takes over the first line of customer conversations. An AI agent answers from your knowledge base in Telegram, MAX, email and the chat on your website. When the agent is not confident or the customer asks for a person, the conversation goes to your team together with a notification.
|
||||
Chatballs takes over the first line of customer conversations. An AI agent answers from your knowledge base in Telegram, MAX, VK, email and the chat on your website. When the agent is not confident or the customer asks for a person, the conversation goes to your team together with a notification.
|
||||
|
||||
The platform installs on your own server with a single command. Customer data stays with you. You connect the AI model with your own key.
|
||||
|
||||
@@ -114,7 +114,7 @@ Everything else is done in **Settings**.
|
||||
| Section | What to do |
|
||||
|---|---|
|
||||
| **Platform** | Set the installation domain. The gateway issues a Let's Encrypt certificate on its own and switches to HTTPS. Outgoing SMTP mail is configured here as well: it is needed for employee invitations and password recovery. |
|
||||
| **Integrations** | Connect an AI model provider: OpenRouter, any OpenAI-compatible service or a local model. A demo provider that needs no key is available for a first look. Then connect entry points: a Telegram bot, a MAX bot, a mailbox over IMAP/SMTP or a web widget for your site. |
|
||||
| **Integrations** | Connect an AI model provider: OpenRouter, any OpenAI-compatible service or a local model. A demo provider that needs no key is available for a first look. Then connect entry points: a Telegram bot, a MAX bot, a VK community, a mailbox over IMAP/SMTP or a web widget for your site. |
|
||||
| **Agents** | Create an AI agent: who it is, how it speaks, what rules it follows. Choose the model. Attach articles from the knowledge base. |
|
||||
| **Employees** | Invite your team by email, assign roles and groups. |
|
||||
|
||||
@@ -171,7 +171,7 @@ When the agent cannot find an answer or the customer asks for a real person, the
|
||||
|
||||
### All channels in one window
|
||||
|
||||
Telegram, MAX, email and website chat land in a single conversation list. The employee sees where the customer came from and replies in the same channel.
|
||||
Telegram, MAX, VK, email and website chat land in a single conversation list. The employee sees where the customer came from and replies in the same channel.
|
||||
|
||||
### Knowledge base with semantic search
|
||||
|
||||
@@ -187,7 +187,7 @@ The widget is installed with a single line of code and runs in an isolated windo
|
||||
|
||||
### Audio and video calls from the chat
|
||||
|
||||
The customer and the employee call each other straight from the conversation without third-party services. Works in the web widget, Telegram and MAX. A relay is available for difficult networks.
|
||||
The customer and the employee call each other straight from the conversation without third-party services. Works in the web widget, Telegram, MAX and VK. A relay is available for difficult networks.
|
||||
|
||||
### Operator workspace
|
||||
|
||||
@@ -269,7 +269,7 @@ Open the integration and run the check. For bots the usual cause is a wrong toke
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Messages from Telegram or MAX do not arrive</strong></summary>
|
||||
<summary><strong>Messages from Telegram, MAX or VK do not arrive</strong></summary>
|
||||
|
||||
The background worker polls the bots. Make sure it is running:
|
||||
|
||||
|
||||
+5
-5
@@ -57,7 +57,7 @@
|
||||
|
||||
## Что это
|
||||
|
||||
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
|
||||
Chatballs берёт на себя первую линию общения с клиентами. ИИ-агент отвечает по вашей базе знаний в Telegram, MAX, ВКонтакте, электронной почте и в чате на сайте. Когда агент не уверен в ответе или клиент просит человека, диалог уходит вашим сотрудникам вместе с уведомлением.
|
||||
|
||||
Платформа ставится на ваш сервер одной командой. Данные клиентов остаются у вас. ИИ-модель вы подключаете сами по своему ключу.
|
||||
|
||||
@@ -114,7 +114,7 @@ docker compose up -d --wait
|
||||
| Раздел | Что сделать |
|
||||
|---|---|
|
||||
| **Платформа** | Укажите домен установки. Шлюз сам выпустит сертификат Let's Encrypt и переведёт работу на HTTPS. Здесь же задаётся исходящая почта по SMTP: она нужна для приглашений сотрудников и восстановления паролей. |
|
||||
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
|
||||
| **Интеграции** | Подключите провайдера ИИ-моделей: OpenRouter, любой OpenAI-совместимый сервис или локальную модель. Для первого знакомства есть демо-провайдер, которому не нужен ключ. Затем подключите точки входа: бота Telegram, бота MAX, сообщество ВКонтакте, почтовый ящик по IMAP/SMTP или веб-виджет для сайта. |
|
||||
| **Агенты** | Создайте ИИ-агента: кто он, как говорит, по каким правилам работает. Выберите модель. Прикрепите статьи из базы знаний. |
|
||||
| **Сотрудники** | Пригласите команду по почте, распределите роли и группы. |
|
||||
|
||||
@@ -171,7 +171,7 @@ docker compose pull && docker compose up -d --wait
|
||||
|
||||
### Все каналы в одном окне
|
||||
|
||||
Telegram, MAX, электронная почта и чат на сайте попадают в единый список диалогов. Сотрудник видит, откуда пришёл клиент, и отвечает в том же канале.
|
||||
Telegram, MAX, ВКонтакте, электронная почта и чат на сайте попадают в единый список диалогов. Сотрудник видит, откуда пришёл клиент, и отвечает в том же канале.
|
||||
|
||||
### База знаний с семантическим поиском
|
||||
|
||||
@@ -187,7 +187,7 @@ Telegram, MAX, электронная почта и чат на сайте по
|
||||
|
||||
### Аудио- и видеозвонки из чата
|
||||
|
||||
Клиент и сотрудник созваниваются прямо из диалога без сторонних сервисов. Работает в веб-виджете, Telegram и MAX. Для сложных сетей есть relay.
|
||||
Клиент и сотрудник созваниваются прямо из диалога без сторонних сервисов. Работает в веб-виджете, Telegram, MAX и ВКонтакте. Для сложных сетей есть relay.
|
||||
|
||||
### Рабочее место оператора
|
||||
|
||||
@@ -269,7 +269,7 @@ docker compose logs gateway
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Сообщения из Telegram или MAX не приходят</strong></summary>
|
||||
<summary><strong>Сообщения из Telegram, MAX или ВКонтакте не приходят</strong></summary>
|
||||
|
||||
Фоновый воркер опрашивает ботов. Проверьте, что он запущен:
|
||||
|
||||
|
||||
@@ -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": "Подключение не привязано к каналу — виджет не активен",
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Проверить",
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user