mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09: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
@@ -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