You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

621 lines
20 KiB

2 months ago
# -*- coding: utf-8 -*-
"""
:authors: python273
:license: Apache License, Version 2.0, see LICENSE file
:copyright: (c) 2019 python273
"""
from collections import defaultdict
from datetime import datetime
from enum import IntEnum
import requests
CHAT_START_ID = int(2E9) # id с которого начинаются беседы
class VkLongpollMode(IntEnum):
""" Дополнительные опции ответа
`Подробнее в документации VK API
<https://vk.com/dev/using_longpoll?f=1.+Подключение>`_
"""
#: Получать вложения
GET_ATTACHMENTS = 2
#: Возвращать расширенный набор событий
GET_EXTENDED = 2**3
#: возвращать pts для метода `messages.getLongPollHistory`
GET_PTS = 2**5
#: В событии с кодом 8 (друг стал онлайн) возвращать
#: дополнительные данные в поле `extra`
GET_EXTRA_ONLINE = 2**6
#: Возвращать поле `random_id`
GET_RANDOM_ID = 2**7
DEFAULT_MODE = sum(VkLongpollMode)
class VkEventType(IntEnum):
""" Перечисление событий, получаемых от longpoll-сервера.
`Подробнее в документации VK API
<https://vk.com/dev/using_longpoll?f=3.+Структура+событий>`__
"""
#: Замена флагов сообщения (FLAGS:=$flags)
MESSAGE_FLAGS_REPLACE = 1
#: Установка флагов сообщения (FLAGS|=$mask)
MESSAGE_FLAGS_SET = 2
#: Сброс флагов сообщения (FLAGS&=~$mask)
MESSAGE_FLAGS_RESET = 3
#: Добавление нового сообщения.
MESSAGE_NEW = 4
#: Редактирование сообщения.
MESSAGE_EDIT = 5
#: Прочтение всех входящих сообщений в $peer_id,
#: пришедших до сообщения с $local_id.
READ_ALL_INCOMING_MESSAGES = 6
#: Прочтение всех исходящих сообщений в $peer_id,
#: пришедших до сообщения с $local_id.
READ_ALL_OUTGOING_MESSAGES = 7
#: Друг $user_id стал онлайн. $extra не равен 0, если в mode был передан флаг 64.
#: В младшем байте числа extra лежит идентификатор платформы
#: (см. :class:`VkPlatform`).
#: $timestamp — время последнего действия пользователя $user_id на сайте.
USER_ONLINE = 8
#: Друг $user_id стал оффлайн ($flags равен 0, если пользователь покинул сайт и 1,
#: если оффлайн по таймауту). $timestamp — время последнего действия пользователя
#: $user_id на сайте.
USER_OFFLINE = 9
#: Сброс флагов диалога $peer_id.
#: Соответствует операции (PEER_FLAGS &= ~$flags).
#: Только для диалогов сообществ.
PEER_FLAGS_RESET = 10
#: Замена флагов диалога $peer_id.
#: Соответствует операции (PEER_FLAGS:= $flags).
#: Только для диалогов сообществ.
PEER_FLAGS_REPLACE = 11
#: Установка флагов диалога $peer_id.
#: Соответствует операции (PEER_FLAGS|= $flags).
#: Только для диалогов сообществ.
PEER_FLAGS_SET = 12
#: Удаление всех сообщений в диалоге $peer_id с идентификаторами вплоть до $local_id.
PEER_DELETE_ALL = 13
#: Восстановление недавно удаленных сообщений в диалоге $peer_id с
#: идентификаторами вплоть до $local_id.
PEER_RESTORE_ALL = 14
#: Один из параметров (состав, тема) беседы $chat_id были изменены.
#: $self — 1 или 0 (вызваны ли изменения самим пользователем).
CHAT_EDIT = 51
#: Изменение информации чата $peer_id с типом $type_id
#: $info — дополнительная информация об изменениях
CHAT_UPDATE = 52
#: Пользователь $user_id набирает текст в диалоге.
#: Событие приходит раз в ~5 секунд при наборе текста. $flags = 1.
USER_TYPING = 61
#: Пользователь $user_id набирает текст в беседе $chat_id.
USER_TYPING_IN_CHAT = 62
#: Пользователь $user_id записывает голосовое сообщение в диалоге/беседе $peer_id
USER_RECORDING_VOICE = 64
#: Пользователь $user_id совершил звонок с идентификатором $call_id.
USER_CALL = 70
#: Счетчик в левом меню стал равен $count.
MESSAGES_COUNTER_UPDATE = 80
#: Изменились настройки оповещений.
#: $peer_id — идентификатор чата/собеседника,
#: $sound — 1/0, включены/выключены звуковые оповещения,
#: $disabled_until — выключение оповещений на необходимый срок.
NOTIFICATION_SETTINGS_UPDATE = 114
class VkPlatform(IntEnum):
""" Идентификаторы платформ """
#: Мобильная версия сайта или неопознанное мобильное приложение
MOBILE = 1
#: Официальное приложение для iPhone
IPHONE = 2
#: Официальное приложение для iPad
IPAD = 3
#: Официальное приложение для Android
ANDROID = 4
#: Официальное приложение для Windows Phone
WPHONE = 5
#: Официальное приложение для Windows 8
WINDOWS = 6
#: Полная версия сайта или неопознанное приложение
WEB = 7
class VkOfflineType(IntEnum):
""" Выход из сети в событии :attr:`VkEventType.USER_OFFLINE` """
#: Пользователь покинул сайт
EXIT = 0
#: Оффлайн по таймауту
AWAY = 1
class VkMessageFlag(IntEnum):
""" Флаги сообщений """
#: Сообщение не прочитано.
UNREAD = 1
#: Исходящее сообщение.
OUTBOX = 2
#: На сообщение был создан ответ.
REPLIED = 2**2
#: Помеченное сообщение.
IMPORTANT = 2**3
#: Сообщение отправлено через чат.
CHAT = 2**4
#: Сообщение отправлено другом.
#: Не применяется для сообщений из групповых бесед.
FRIENDS = 2**5
#: Сообщение помечено как "Спам".
SPAM = 2**6
#: Сообщение удалено (в корзине).
DELETED = 2**7
#: Сообщение проверено пользователем на спам.
FIXED = 2**8
#: Сообщение содержит медиаконтент
MEDIA = 2**9
#: Приветственное сообщение от сообщества.
HIDDEN = 2**16
#: Сообщение удалено для всех получателей.
DELETED_ALL = 2**17
class VkPeerFlag(IntEnum):
""" Флаги диалогов """
#: Важный диалог
IMPORTANT = 1
#: Неотвеченный диалог
UNANSWERED = 2
class VkChatEventType(IntEnum):
""" Идентификатор типа изменения в чате """
#: Изменилось название беседы
TITLE = 1
#: Сменилась обложка беседы
PHOTO = 2
#: Назначен новый администратор
ADMIN_ADDED = 3
#: Изменены настройки беседы
SETTINGS_CHANGED = 4
#: Закреплено сообщение
MESSAGE_PINNED = 5
#: Пользователь присоединился к беседе
USER_JOINED = 6
#: Пользователь покинул беседу
USER_LEFT = 7
#: Пользователя исключили из беседы
USER_KICKED = 8
#: С пользователя сняты права администратора
ADMIN_REMOVED = 9
#: Бот прислал клавиатуру
KEYBOARD_RECEIVED = 11
MESSAGE_EXTRA_FIELDS = [
'peer_id', 'timestamp', 'text', 'extra_values', 'attachments', 'random_id'
]
MSGID = 'message_id'
EVENT_ATTRS_MAPPING = {
VkEventType.MESSAGE_FLAGS_REPLACE: [MSGID, 'flags'] + MESSAGE_EXTRA_FIELDS,
VkEventType.MESSAGE_FLAGS_SET: [MSGID, 'mask'] + MESSAGE_EXTRA_FIELDS,
VkEventType.MESSAGE_FLAGS_RESET: [MSGID, 'mask'] + MESSAGE_EXTRA_FIELDS,
VkEventType.MESSAGE_NEW: [MSGID, 'flags'] + MESSAGE_EXTRA_FIELDS,
VkEventType.MESSAGE_EDIT: [MSGID, 'mask'] + MESSAGE_EXTRA_FIELDS,
VkEventType.READ_ALL_INCOMING_MESSAGES: ['peer_id', 'local_id'],
VkEventType.READ_ALL_OUTGOING_MESSAGES: ['peer_id', 'local_id'],
VkEventType.USER_ONLINE: ['user_id', 'extra', 'timestamp'],
VkEventType.USER_OFFLINE: ['user_id', 'flags', 'timestamp'],
VkEventType.PEER_FLAGS_RESET: ['peer_id', 'mask'],
VkEventType.PEER_FLAGS_REPLACE: ['peer_id', 'flags'],
VkEventType.PEER_FLAGS_SET: ['peer_id', 'mask'],
VkEventType.PEER_DELETE_ALL: ['peer_id', 'local_id'],
VkEventType.PEER_RESTORE_ALL: ['peer_id', 'local_id'],
VkEventType.CHAT_EDIT: ['chat_id', 'self'],
VkEventType.CHAT_UPDATE: ['type_id', 'peer_id', 'info'],
VkEventType.USER_TYPING: ['user_id', 'flags'],
VkEventType.USER_TYPING_IN_CHAT: ['user_id', 'chat_id'],
VkEventType.USER_RECORDING_VOICE: ['peer_id', 'user_id', 'flags', 'timestamp'],
VkEventType.USER_CALL: ['user_id', 'call_id'],
VkEventType.MESSAGES_COUNTER_UPDATE: ['count'],
VkEventType.NOTIFICATION_SETTINGS_UPDATE: ['values']
}
def get_all_event_attrs():
keys = set()
for l in EVENT_ATTRS_MAPPING.values():
keys.update(l)
return tuple(keys)
ALL_EVENT_ATTRS = get_all_event_attrs()
PARSE_PEER_ID_EVENTS = [
k for k, v in EVENT_ATTRS_MAPPING.items() if 'peer_id' in v
]
PARSE_MESSAGE_FLAGS_EVENTS = [
VkEventType.MESSAGE_FLAGS_REPLACE,
VkEventType.MESSAGE_NEW
]
class Event(object):
""" Событие, полученное от longpoll-сервера.
Имеет поля в соответствии с `документацией
<https://vk.com/dev/using_longpoll_2?f=3.%2BСтруктура%2Bсобытий>`_.
События `MESSAGE_NEW` и `MESSAGE_EDIT` имеют (среди прочих) такие поля:
- `text` - `экранированный <https://ru.wikipedia.org/wiki/Мнемоники_в_HTML>`_ текст
- `message` - оригинальный текст сообщения.
События с полем `timestamp` также дополнительно имеют поле `datetime`.
"""
def __init__(self, raw):
self.raw = raw
self.from_user = False
self.from_chat = False
self.from_group = False
self.from_me = False
self.to_me = False
self.attachments = {}
self.message_data = None
self.message_id = None
self.timestamp = None
self.peer_id = None
self.flags = None
self.extra = None
self.extra_values = None
self.type_id = None
try:
self.type = VkEventType(self.raw[0])
self._list_to_attr(self.raw[1:], EVENT_ATTRS_MAPPING[self.type])
except ValueError:
self.type = self.raw[0]
if self.extra_values:
self._dict_to_attr(self.extra_values)
if self.type in PARSE_PEER_ID_EVENTS:
self._parse_peer_id()
if self.type in PARSE_MESSAGE_FLAGS_EVENTS:
self._parse_message_flags()
if self.type is VkEventType.CHAT_UPDATE:
self._parse_chat_info()
try:
self.update_type = VkChatEventType(self.type_id)
except ValueError:
self.update_type = self.type_id
elif self.type is VkEventType.NOTIFICATION_SETTINGS_UPDATE:
self._dict_to_attr(self.values)
self._parse_peer_id()
elif self.type is VkEventType.PEER_FLAGS_REPLACE:
self._parse_peer_flags()
elif self.type in [VkEventType.MESSAGE_NEW, VkEventType.MESSAGE_EDIT]:
self._parse_message()
elif self.type in [VkEventType.USER_ONLINE, VkEventType.USER_OFFLINE]:
self.user_id = abs(self.user_id)
self._parse_online_status()
elif self.type is VkEventType.USER_RECORDING_VOICE:
if isinstance(self.user_id, list):
self.user_id = self.user_id[0]
if self.timestamp:
self.datetime = datetime.utcfromtimestamp(self.timestamp)
def _list_to_attr(self, raw, attrs):
for i in range(min(len(raw), len(attrs))):
self.__setattr__(attrs[i], raw[i])
def _dict_to_attr(self, values):
for k, v in values.items():
self.__setattr__(k, v)
def _parse_peer_id(self):
if self.peer_id < 0: # Сообщение от/для группы
self.from_group = True
self.group_id = abs(self.peer_id)
elif self.peer_id > CHAT_START_ID: # Сообщение из беседы
self.from_chat = True
self.chat_id = self.peer_id - CHAT_START_ID
if self.extra_values and 'from' in self.extra_values:
self.user_id = int(self.extra_values['from'])
else: # Сообщение от/для пользователя
self.from_user = True
self.user_id = self.peer_id
def _parse_message_flags(self):
self.message_flags = set(
x for x in VkMessageFlag if self.flags & x
)
def _parse_peer_flags(self):
self.peer_flags = set(
x for x in VkPeerFlag if self.flags & x
)
def _parse_message(self):
if self.type is VkEventType.MESSAGE_NEW:
if self.flags & VkMessageFlag.OUTBOX:
self.from_me = True
else:
self.to_me = True
# ВК возвращает сообщения в html-escaped виде,
# при этом переводы строк закодированы как <br> и не экранированы
self.text = self.text.replace('<br>', '\n')
self.message = self.text \
.replace('&lt;', '<') \
.replace('&gt;', '>') \
.replace('&quot;', '"') \
.replace('&amp;', '&')
def _parse_online_status(self):
try:
if self.type is VkEventType.USER_ONLINE:
self.platform = VkPlatform(self.extra & 0xFF)
elif self.type is VkEventType.USER_OFFLINE:
self.offline_type = VkOfflineType(self.flags)
except ValueError:
pass
def _parse_chat_info(self):
if self.type_id == VkChatEventType.ADMIN_ADDED.value:
self.info = {'admin_id': self.info}
elif self.type_id == VkChatEventType.MESSAGE_PINNED.value:
self.info = {'conversation_message_id': self.info}
elif self.type_id in [VkChatEventType.USER_JOINED.value,
VkChatEventType.USER_LEFT.value,
VkChatEventType.USER_KICKED.value,
VkChatEventType.ADMIN_REMOVED.value]:
self.info = {'user_id': self.info}
class VkLongPoll(object):
""" Класс для работы с longpoll-сервером
`Подробнее в документации VK API <https://vk.com/dev/using_longpoll>`__.
:param vk: объект :class:`VkApi`
:param wait: время ожидания
:param mode: дополнительные опции ответа
:param preload_messages: предзагрузка данных сообщений для
получения ссылок на прикрепленные файлы
:param group_id: идентификатор сообщества
(для сообщений сообщества с ключом доступа пользователя)
"""
__slots__ = (
'vk', 'wait', 'mode', 'preload_messages', 'group_id',
'url', 'session',
'key', 'server', 'ts', 'pts'
)
#: Класс для событий
DEFAULT_EVENT_CLASS = Event
#: События, для которых можно загрузить данные сообщений из API
PRELOAD_MESSAGE_EVENTS = [
VkEventType.MESSAGE_NEW,
VkEventType.MESSAGE_EDIT
]
def __init__(self, vk, wait=25, mode=DEFAULT_MODE,
preload_messages=False, group_id=None):
self.vk = vk
self.wait = wait
self.mode = mode.value if isinstance(mode, VkLongpollMode) else mode
self.preload_messages = preload_messages
self.group_id = group_id
self.url = None
self.key = None
self.server = None
self.ts = None
self.pts = mode & VkLongpollMode.GET_PTS
self.session = requests.Session()
self.update_longpoll_server()
def _parse_event(self, raw_event):
return self.DEFAULT_EVENT_CLASS(raw_event)
def update_longpoll_server(self, update_ts=True):
values = {
'lp_version': '3',
'need_pts': self.pts
}
if self.group_id:
values['group_id'] = self.group_id
response = self.vk.method('messages.getLongPollServer', values)
self.key = response['key']
self.server = response['server']
self.url = 'https://' + self.server
if update_ts:
self.ts = response['ts']
if self.pts:
self.pts = response['pts']
def check(self):
""" Получить события от сервера один раз
:returns: `list` of :class:`Event`
"""
values = {
'act': 'a_check',
'key': self.key,
'ts': self.ts,
'wait': self.wait,
'mode': self.mode,
'version': 3
}
response = self.session.get(
self.url,
params=values,
timeout=self.wait + 10
).json()
if 'failed' not in response:
self.ts = response['ts']
if self.pts:
self.pts = response['pts']
events = [
self._parse_event(raw_event)
for raw_event in response['updates']
]
if self.preload_messages:
self.preload_message_events_data(events)
return events
elif response['failed'] == 1:
self.ts = response['ts']
elif response['failed'] == 2:
self.update_longpoll_server(update_ts=False)
elif response['failed'] == 3:
self.update_longpoll_server()
return []
def preload_message_events_data(self, events):
""" Предзагрузка данных сообщений из API
:type events: list of Event
"""
message_ids = set()
event_by_message_id = defaultdict(list)
for event in events:
if event.type in self.PRELOAD_MESSAGE_EVENTS:
message_ids.add(event.message_id)
event_by_message_id[event.message_id].append(event)
if not message_ids:
return
messages_data = self.vk.method(
'messages.getById',
{'message_ids': message_ids}
)
for message in messages_data['items']:
for event in event_by_message_id[message['id']]:
event.message_data = message
def listen(self):
""" Слушать сервер
:yields: :class:`Event`
"""
while True:
for event in self.check():
yield event