"""CloudGram — официальный модуль (Python). Документация: https://cloudgram.ru/dev Адреса, заголовки, коды ошибок и опрос обновлений настроены внутри — снаружи только функции. Python 3.8+. Боты по умолчанию ходят через протокол CloudGram (шифрование канала + маскировка). Для протокола нужны пакеты cryptography и websocket-client: pip install cryptography websocket-client Без них модуль работает как раньше, по HTTPS (cloudgram.protocol_supported() показывает, доступен ли протокол; bot(..., protocol=False) — всегда HTTPS). import cloudgram bot = cloudgram.bot("ТОКЕН") @bot.on_message def handle(msg): msg.reply("Привет, " + msg.sender_name) bot.start() """ import base64 import json import os import secrets import threading import time import urllib.error import urllib.parse import urllib.request HOST = "https://cloudgram.ru" VERSION = "1.0.0" __all__ = ["bot", "account", "site", "session", "protocol_supported", "CloudGramError", "VERSION"] class CloudGramError(Exception): """Ошибка с текстом от сервера. code — HTTP-статус или код ошибки Bot API. error — машинный код, например "chat_not_found". """ def __init__(self, message, code=0, error=""): super().__init__(message) self.message = message self.code = code self.error = error def _default_error_text(status): # Сервер намеренно отвечает пустым 404 тем, кто не авторизован, — без # подсказки такая ошибка выглядит как «что-то пошло не так». if status in (401, 403): return "Доступ отклонён: проверьте токен или ключи" if status == 404: return "Не найдено или нет доступа: проверьте токен и id чата" if status == 429: return "Слишком много запросов — повторите позже" if status >= 500: return "Сервер CloudGram недоступен, попробуйте позже" return "Ошибка запроса (статус %s)" % status def _request(method, path, body=None, headers=None, query=None): url = HOST + path if query: clean = {k: v for k, v in query.items() if v not in (None, "")} if clean: url += "?" + urllib.parse.urlencode(clean) payload = None all_headers = { "Accept": "application/json", "User-Agent": "cloudgram-module/" + VERSION, } if body is not None: payload = json.dumps(body, ensure_ascii=False).encode("utf-8") all_headers["Content-Type"] = "application/json" if headers: all_headers.update(headers) request = urllib.request.Request(url, data=payload, headers=all_headers, method=method) try: with urllib.request.urlopen(request, timeout=60) as response: raw = response.read().decode("utf-8", "replace") status = response.status except urllib.error.HTTPError as exc: raw = exc.read().decode("utf-8", "replace") status = exc.code except urllib.error.URLError as exc: raise CloudGramError("Сеть недоступна: %s" % exc.reason, 0, "network") from None except ValueError as exc: raise CloudGramError("Не удалось отправить запрос: %s" % exc, 0, "bad_request") from None try: data = json.loads(raw) if raw else {} except ValueError: raise CloudGramError( "Сервер вернул не JSON (статус %s)" % status, status, "bad_response" ) from None if status >= 400 or data.get("ok") is False: raise CloudGramError( data.get("description") or data.get("message") or _default_error_text(status), data.get("error_code") or status, data.get("error") or "", ) return data def _parse_bot_result(status, text): # Ответ Bot API (одинаково для HTTPS и для протокола): по статусу и телу # решаем — успех или ошибка, как это делает _request. try: data = json.loads(text) if text else {} except ValueError: raise CloudGramError( "Сервер вернул не JSON (статус %s)" % status, status, "bad_response" ) from None if status >= 400 or data.get("ok") is False: raise CloudGramError( data.get("description") or data.get("message") or _default_error_text(status), data.get("error_code") or status, data.get("error") or "", ) return data def _download_binary(path, headers=None): """Скачать сырые байты (вложение сообщения). _request парсит JSON, а для файла нужен буфер — поэтому отдельная функция.""" all_headers = {"Accept": "*/*", "User-Agent": "cloudgram-module/" + VERSION} if headers: all_headers.update(headers) request = urllib.request.Request(HOST + path, headers=all_headers, method="GET") try: with urllib.request.urlopen(request, timeout=120) as response: return response.read() except urllib.error.HTTPError as exc: raise CloudGramError(_default_error_text(exc.code), exc.code, "download_failed") from None except urllib.error.URLError as exc: raise CloudGramError("Сеть недоступна: %s" % exc.reason, 0, "network") from None # ===== Протокол CloudGram (CeP) — встроенный клиент ===== # # Боты ходят НЕ прямым HTTPS, а по собственному протоколу CloudGram: двоичные # кадры, ECDH P-256 + AES-256-GCM (сквозное шифрование канала) и внешний слой # маскировки (в эфире — только шум, ни один байт не узнаётся). Криптопримитивы # те же, что в браузере и на сервере (server/cep.mjs); здесь они выражены через # пакет cryptography, значения совпадают байт-в-байт (P-256 uncompressed point, # AES-GCM с тегом в хвосте, HKDF-SHA256, AES-CTR со 128-битным счётчиком). # # Требует пакеты cryptography и websocket-client. Если их нет — протокол # недоступен, бот тихо ходит по HTTPS (как раньше). try: from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.hkdf import HKDF import websocket # пакет websocket-client (синхронный) _CEP_DEPS = True except Exception: # нет одной из библиотек — протокол недоступен _CEP_DEPS = False if _CEP_DEPS: _CEP_VERSION = 1 _FRAME_HELLO = 0x01 _FRAME_HELLO_ACK = 0x02 _FRAME_RECORD = 0x03 class _Writer: def __init__(self): self.buf = bytearray() def raw(self, b): self.buf += bytes(b) return self def u8(self, v): self.buf.append(v & 0xFF) return self def u32(self, v): self.buf += (v & 0xFFFFFFFF).to_bytes(4, "big") return self def varint(self, v): n = int(v) if n < 0: raise ValueError("CeP: отрицательные числа кодируются отдельным типом") while True: byte = n & 0x7F n >>= 7 if n > 0: byte |= 0x80 self.buf.append(byte) if n == 0: break return self def bytes_(self, b): b = bytes(b) self.varint(len(b)) self.buf += b return self def string(self, s): return self.bytes_(str("" if s is None else s).encode("utf-8")) def finish(self): return bytes(self.buf) class _Reader: def __init__(self, b): self.view = bytes(b) self.off = 0 def need(self, c): if self.off + c > len(self.view): raise ValueError("CeP: кадр обрывается посреди значения") def raw(self, c): self.need(c) out = self.view[self.off:self.off + c] self.off += c return out def u8(self): self.need(1) v = self.view[self.off] self.off += 1 return v def u32(self): return int.from_bytes(self.raw(4), "big") def varint(self): result = 0 shift = 0 for _ in range(10): byte = self.u8() result |= (byte & 0x7F) << shift if (byte & 0x80) == 0: return result shift += 7 raise ValueError("CeP: слишком длинное число") def bytes_(self): return self.raw(self.varint()) def string(self): return self.bytes_().decode("utf-8", "replace") def bool_(self): return self.u8() == 1 def _method_id(signature): h = 0x811C9DC5 for ch in signature: h ^= ord(ch) h = (h * 0x01000193) & 0xFFFFFFFF return h _BOT_API_METHOD = _method_id("botApi token:string method:string body:string = BotApiResult") def _create_key_pair(): priv = ec.generate_private_key(ec.SECP256R1()) raw = priv.public_key().public_bytes( serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint ) return priv, raw # raw = 0x04||X||Y (65 байт), как exportKey("raw") в WebCrypto def _derive_secret(priv, peer_raw): peer = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), bytes(peer_raw)) return priv.exchange(ec.ECDH(), peer) # 32 байта X — как deriveBits(256) def _hkdf(secret, salt, info, length): return HKDF( algorithm=hashes.SHA256(), length=length, salt=salt, info=info.encode("utf-8") ).derive(secret) def _derive_session(secret, client_random, server_random): salt = bytes(client_random) + bytes(server_random) material = _hkdf(secret, salt, "CeP/1 session keys", 72) return { "c2s": material[0:32], "s2c": material[32:64], "npc2s": material[64:68], "nps2c": material[68:72], } def _pad(body, max_padding=64): pad_len = secrets.token_bytes(1)[0] % (max_padding + 1) w = _Writer() w.varint(pad_len) w.raw(secrets.token_bytes(pad_len)) w.raw(body) return w.finish() def _unpad(plain): r = _Reader(plain) pad_len = r.varint() r.raw(pad_len) return r.view[r.off:] class _Session: def __init__(self, keys): self._send = AESGCM(keys["c2s"]) # роль клиента: c2s — отправка self._recv = AESGCM(keys["s2c"]) self._send_prefix = keys["npc2s"] self._recv_prefix = keys["nps2c"] self.send_counter = 0 self.recv_counter = 0 @staticmethod def _nonce(prefix, counter): return bytes(prefix) + counter.to_bytes(8, "big") def seal(self, body): counter = self.send_counter self.send_counter += 1 nonce = self._nonce(self._send_prefix, counter) aad = counter.to_bytes(8, "big") sealed = self._send.encrypt(nonce, _pad(body), aad) # ct||tag(16) w = _Writer() w.varint(counter) w.raw(sealed) return w.finish() def open(self, record): r = _Reader(record) counter = r.varint() if counter < self.recv_counter: raise ValueError("CeP: повтор записи") self.recv_counter = counter + 1 nonce = self._nonce(self._recv_prefix, counter) aad = counter.to_bytes(8, "big") plain = self._recv.decrypt(nonce, r.view[r.off:], aad) return _unpad(plain) def _frame(ftype, payload): body = bytes(payload) return (len(body) + 1).to_bytes(4, "big") + bytes([ftype]) + body def _parse_frame(b): v = bytes(b) if len(v) < 5: return None length = int.from_bytes(v[0:4], "big") if len(v) < 4 + length: return None return {"type": v[4], "payload": v[5:4 + length]} _OBF_FORBIDDEN_FIRST = { 0x44414548, 0x504F5354, 0x47455420, 0x48545450, 0x52505920, 0x02010316, 0xDDDDDDDD, 0xEEEEEEEE, 0x00000000, 0xEFEFEFEF, } def _random_obf_init(): for _ in range(64): init = secrets.token_bytes(64) first = int.from_bytes(init[0:4], "big") second = int.from_bytes(init[4:8], "big") if first in _OBF_FORBIDDEN_FIRST or second == 0: continue return init raise ValueError("CeP: не удалось подобрать init") class _Obfuscator: # Внешний слой маскировки: два потока AES-256-CTR (по направлению), # ключи и IV выведены из 64-байтного init и его разворота. Счётчик — # полные 128 бит, как length:128 в WebCrypto. Потоки stateful: каждый # update продолжает гамму с той же позиции — совпадает с be16Add(blocks). def __init__(self, init): rev = init[::-1] self._send = Cipher(algorithms.AES(init[8:40]), modes.CTR(init[40:56])).encryptor() self._recv = Cipher(algorithms.AES(rev[8:40]), modes.CTR(rev[40:56])).encryptor() def wrap(self, b): b = bytes(b) need = (4 + len(b)) % 16 pad_len = 0 if need == 0 else 16 - need plain = len(b).to_bytes(4, "big") + b + (secrets.token_bytes(pad_len) if pad_len else b"") return self._send.update(plain) def unwrap(self, message): v = bytes(message) if len(v) == 0 or len(v) % 16 != 0: raise ValueError("CeP: испорченный обфусцированный кусок") plain = self._recv.update(v) # CTR: дешифрование == шифрование length = int.from_bytes(plain[0:4], "big") if 4 + length > len(plain): raise ValueError("CeP: длина не сходится") return plain[4:4 + length] def _start_handshake(): priv, raw = _create_key_pair() random = secrets.token_bytes(16) w = _Writer() w.u8(_CEP_VERSION) w.bytes_(raw) w.bytes_(random) return priv, random, _frame(_FRAME_HELLO, w.finish()) def _finish_handshake(priv, client_random, ack_payload): r = _Reader(ack_payload) version = r.u8() if version != _CEP_VERSION: raise ValueError("CeP: версия %s не поддерживается" % version) server_public = r.bytes_() server_random = r.bytes_() secret = _derive_secret(priv, server_public) keys = _derive_session(secret, client_random, server_random) return _Session(keys) def _encode_call(request_id, method, write_params): w = _Writer() w.varint(request_id) w.u32(method) if write_params: write_params(w) return w.finish() def _decode_reply(body): r = _Reader(body) return r.varint(), r.bool_(), r def _recv_bytes(ws): data = ws.recv() if isinstance(data, str): return data.encode("utf-8", "replace") return bytes(data) class _CepClient: # Один живой CeP-канал до эндпоинта; рукопожатие лениво, соединение # переиспользуется, при обрыве поднимается заново. Синхронный, под # блокировкой — модуль и опрос бота однопоточные. def __init__(self, endpoint): self.endpoint = endpoint self.ws = None self.obf = None self.session = None self.next_id = 1 self.lock = threading.Lock() def _connect(self): ws = websocket.create_connection(self.endpoint, timeout=60, enable_multithread=True) try: init = _random_obf_init() obf = _Obfuscator(init) ws.send_binary(init) # 64 байта init — сырыми (они и так шум) priv, random, hello = _start_handshake() ws.send_binary(obf.wrap(hello)) # hello — уже под маскировкой frame = _parse_frame(obf.unwrap(_recv_bytes(ws))) if not frame or frame["type"] != _FRAME_HELLO_ACK: raise ValueError("CeP: нет ответа рукопожатия") session = _finish_handshake(priv, random, frame["payload"]) except Exception: try: ws.close() except Exception: pass raise self.ws = ws self.obf = obf self.session = session def _reset(self): try: if self.ws: self.ws.close() except Exception: pass self.ws = None self.obf = None self.session = None def call_bot_api(self, token, method, body): with self.lock: try: if self.ws is None or self.session is None: self._connect() request_id = self.next_id self.next_id += 1 call = _encode_call( request_id, _BOT_API_METHOD, lambda w: w.string(token).string(method).string(body or "{}"), ) self.ws.send_binary(self.obf.wrap(_frame(_FRAME_RECORD, self.session.seal(call)))) # Читаем, пока не придёт ответ на наш requestId; серверные # события (requestId 0) пропускаем. for _ in range(64): frame = _parse_frame(self.obf.unwrap(_recv_bytes(self.ws))) if not frame or frame["type"] != _FRAME_RECORD: continue reply_id, ok, r = _decode_reply(self.session.open(frame["payload"])) if reply_id == 0: continue if not ok: raise CloudGramError( "Протокол: " + (r.string() or "метод отклонён"), 0, "protocol" ) # BotApiResult = varint(status) + string(text) return r.varint(), r.string() raise CloudGramError("Протокол: нет ответа", 0, "protocol") except CloudGramError: self._reset() raise except Exception as exc: self._reset() raise CloudGramError("Протокол: %s" % exc, 0, "protocol") from None _cep_clients = {} _cep_clients_lock = threading.Lock() def _get_cep_client(endpoint): with _cep_clients_lock: client = _cep_clients.get(endpoint) if client is None: client = _CepClient(endpoint) _cep_clients[endpoint] = client return client # Эндпоинт протокола по умолчанию — /cep на том же хосте, что и HOST. _DEFAULT_CEP_ENDPOINT = HOST.replace("https://", "wss://").replace("http://", "ws://").rstrip("/") + "/cep" def _read_media_source(source): """Файл можно передать путём, ссылкой, bytes или base64. Путь читаем сами, чтобы не возиться с кодированием вручную. """ if isinstance(source, (bytes, bytearray)): return base64.b64encode(bytes(source)).decode("ascii") value = "" if source is None else str(source) if value.startswith(("http://", "https://", "data:")): return value try: if value and len(value) < 4096 and os.path.isfile(value): with open(value, "rb") as handle: return base64.b64encode(handle.read()).decode("ascii") except OSError: pass # не файл — считаем, что это уже base64 return value def _normalize_buttons(buttons): # Кнопки принимаем и одним рядом, и списком рядов — приводим к рядам. if not buttons: return [] if isinstance(buttons[0], (list, tuple)): return [list(row) for row in buttons] return [list(buttons)] class Message(dict): """Входящее сообщение. Работает и как словарь, и через свойства.""" def __init__(self, data, owner): super().__init__(data or {}) self._bot = owner @property def text(self): return self.get("text") or "" @property def chat_id(self): return (self.get("chat") or {}).get("id") @property def sender(self): return self.get("from") or {} @property def sender_name(self): return self.sender.get("first_name") or "" @property def media_url(self): return self.get("media_url") or "" def reply(self, text, buttons=None): """Ответить в тот же чат.""" return self._bot.send(self.chat_id, text, buttons=buttons) class Callback(dict): """Нажатие callback-кнопки. Работает и как словарь, и через свойства.""" def __init__(self, data, owner): super().__init__(data or {}) self._bot = owner @property def data(self): return self.get("data") or "" @property def id(self): return self.get("id") or "" @property def sender(self): return self.get("from") or {} def answer(self, text="", alert=False): """Ответить на нажатие.""" return self._bot.answer(self.id, text, alert=alert) class Bot: def __init__(self, token, protocol="auto", endpoint=None): """protocol: "auto" (по умолчанию) — через протокол CloudGram, если среда его поддерживает (есть cryptography и websocket-client), иначе HTTPS; "only" — строго через протокол (ошибка, если библиотек нет); False — прямой HTTPS. endpoint — адрес узла протокола (по умолчанию /cep на HOST). """ if not token or not isinstance(token, str) or ":" not in token: raise CloudGramError("Нужен токен бота вида 5:abcdef...", 0, "bad_token") self.token = token self._offset = 0 self._running = False self._interval = 1.0 self._on_message = [] self._on_command = {} self._on_callback = [] self._on_error = [] # Транспорт: по протоколу или прямой HTTPS. self._protocol_strict = protocol == "only" if protocol is False: self._cep = None elif _CEP_DEPS: self._cep = _get_cep_client(endpoint or _DEFAULT_CEP_ENDPOINT) elif self._protocol_strict: raise CloudGramError( "Протокол требует пакеты cryptography и websocket-client", 0, "protocol_unsupported", ) else: self._cep = None # среда не поддерживает CeP — тихо на HTTPS def _call_http(self, method, params=None): """Прямой HTTPS-вызов Bot API (запасной путь и режим protocol=False).""" data = _request("POST", "/bot%s/%s" % (self.token, method), body=params or {}) return data.get("result") def call(self, method, params=None): """Вызов любого метода Bot API. По умолчанию — через протокол CloudGram (шифрование канала + маскировка), с откатом на HTTPS при сбое транспорта. """ if not self._cep: return self._call_http(method, params) body = json.dumps(params or {}, ensure_ascii=False) try: status, text = self._cep.call_bot_api(self.token, method, body) except CloudGramError as exc: # Сбой транспорта протокола — разово идём напрямую, чтобы бот жил. # Строгий режим или не-протокольная ошибка — пробрасываем. if exc.error != "protocol" or self._protocol_strict: raise return self._call_http(method, params) # Разбор ответа — общий с HTTPS: статус/тело решают успех или ошибку. return _parse_bot_result(status, text).get("result") def get_me(self): """Данные бота. Заодно проверяет токен.""" return self.call("getMe") def send(self, chat_id, text, buttons=None, format=None): """Отправить текст. buttons — ряды кнопок, format — "html" или "markdown". """ params = {"chat_id": chat_id, "text": str(text)} if format: params["format"] = str(format) if buttons: params["reply_buttons"] = _normalize_buttons(buttons) return self.call("sendRichMessage", params) return self.call("sendMessage", params) def photo(self, chat_id, source, caption="", buttons=None, format=None, name=""): """Отправить картинку. source — путь к файлу, ссылка, bytes или base64. """ return self._send_media("sendPhoto", chat_id, source, caption, buttons, format, name) def video(self, chat_id, source, caption="", buttons=None, format=None, name=""): """Отправить видео (mp4). Аргументы как у photo.""" return self._send_media("sendVideo", chat_id, source, caption, buttons, format, name) def file(self, chat_id, source, caption="", buttons=None, format=None, name=""): """Отправить произвольный файл. Аргументы как у photo.""" return self._send_media("sendDocument", chat_id, source, caption, buttons, format, name) def _send_media(self, api_method, chat_id, source, caption, buttons, format, name): params = {"chat_id": chat_id, "file": _read_media_source(source)} if caption: params["caption"] = str(caption) if format: params["format"] = str(format) if name: params["filename"] = str(name) if buttons: params["reply_buttons"] = _normalize_buttons(buttons) return self.call(api_method, params) def set_commands(self, commands): """Подсказки команд: всплывают у пользователя при вводе «/». commands — список {"command": ..., "description": ...} или строк. Пустой список убирает подсказки. """ if not commands: return self.call("deleteMyCommands", {}) return self.call("setMyCommands", {"commands": list(commands)}) def get_user(self, chat_id): """Данные пользователя: id, username, когда зарегистрировался. Работает для тех, кто уже писал боту. """ return self.call("getUser", {"chat_id": chat_id}) def set_menu(self, url="", text=""): """Мини-приложение: кнопка в поле ввода, открывает ваш сайт в CloudGram. url обязательно https. Вызов без url убирает кнопку. """ if not url: return self.call("deleteMenuButton", {}) return self.call("setMenuButton", {"url": str(url), "text": str(text or "")}) def edit(self, chat_id, message_id, text, buttons=None, format=None): """Изменить своё отправленное сообщение.""" params = {"chat_id": chat_id, "message_id": str(message_id), "text": str(text)} if format: params["format"] = str(format) if buttons: params["reply_buttons"] = _normalize_buttons(buttons) return self.call("editMessage", params) def delete(self, chat_id, message_id): """Удалить своё сообщение.""" return self.call("deleteMessage", {"chat_id": chat_id, "message_id": str(message_id)}) def typing(self, chat_id, on=True): """Показать пользователю «печатает…».""" return self.call("sendChatAction", {"chat_id": chat_id, "action": "typing" if on else "cancel"}) def answer(self, callback_id, text="", alert=False): """Ответить на нажатие callback-кнопки — текст всплывёт у пользователя.""" params = {"callback_query_id": str(callback_id), "text": str(text or "")} if alert: params["show_alert"] = True return self.call("answerCallbackQuery", params) def on_callback(self, handler): """Обработчик нажатий callback-кнопок. Используется как декоратор.""" self._on_callback.append(handler) return handler def stream(self, chat_id, stream_id, text="", done=False, buttons=None): """Показать текст, который ещё генерируется. Один и тот же stream_id обновляет тот же черновик, done=True — генерация закончена. """ params = {"chat_id": chat_id, "draft_id": str(stream_id), "text": str(text or "")} if done: params["done"] = True if buttons: params["reply_buttons"] = _normalize_buttons(buttons) return self.call("sendRichMessageDraft", params) return self.call("sendMessageDraft", params) def get_updates(self, offset=None, limit=None): """Забрать обновления вручную. Обычно не нужно — есть on_message + start.""" params = {} if offset is not None: params["offset"] = offset if limit is not None: params["limit"] = limit result = self.call("getUpdates", params) return result if isinstance(result, list) else [] def on_message(self, handler): """Обработчик входящих сообщений. Используется как декоратор.""" self._on_message.append(handler) return handler def on_command(self, command): """Обработчик команды: @bot.on_command("/start").""" key = str(command or "").strip().lower() def decorator(handler): if key: self._on_command.setdefault(key, []).append(handler) return handler return decorator def on_error(self, handler): """Обработчик ошибок опроса. Используется как декоратор.""" self._on_error.append(handler) return handler def start(self): """Начать получать сообщения. Блокирует поток, пока не вызван stop().""" self._running = True while self._running: try: updates = self.get_updates(offset=self._offset, limit=100) except CloudGramError as exc: self._fail(exc) time.sleep(3) continue for update in updates: number = update.get("update_id") if isinstance(number, int) and number >= self._offset: self._offset = number + 1 try: self._dispatch(update) except Exception as exc: # обработчик пользователя не должен ронять бота self._fail(exc) # Пришла целая пачка — сразу просим следующую, не ждём паузу. if not updates: time.sleep(self._interval) def stop(self): """Прекратить получать сообщения.""" self._running = False def _dispatch(self, update): callback = update.get("callback_query") if callback: press = Callback(callback, self) for handler in self._on_callback: handler(press) return raw = update.get("message") if not raw: return message = Message(raw, self) command = message.text.strip().split(" ")[0].lower() if message.text else "" handlers = self._on_command.get(command) if handlers: for handler in handlers: handler(message) return for handler in self._on_message: handler(message) def _fail(self, exc): if self._on_error: for handler in self._on_error: handler(exc) return print("[cloudgram] %s" % exc) class Account: def __init__(self, client_id, client_secret): if not client_id or not client_secret: raise CloudGramError("Нужны client_id и client_secret", 0, "bad_credentials") self._headers = { "X-Client-Id": str(client_id), "X-Client-Secret": str(client_secret), } def me(self): """Данные вашего аккаунта.""" return _request("GET", "/api/v1/me", headers=self._headers).get("user") def chats(self): """Список ваших чатов.""" return _request("GET", "/api/v1/chats", headers=self._headers).get("chats") or [] def messages(self, chat_id, limit=None): """Сообщения чата. limit — сколько последних (до 200, по умолчанию 50).""" path = "/api/v1/chats/%s/messages" % urllib.parse.quote(str(chat_id)) data = _request("GET", path, headers=self._headers, query={"limit": limit}) return data.get("messages") or [] def send(self, chat_id, text): """Отправить текст в чат.""" path = "/api/v1/chats/%s/messages" % urllib.parse.quote(str(chat_id)) return _request("POST", path, headers=self._headers, body={"text": str(text)}).get("message") def edit(self, chat_id, message_id, text): """Изменить своё отправленное сообщение (текст уходит и в мессенджер).""" path = "/api/v1/chats/%s/messages/%s" % ( urllib.parse.quote(str(chat_id)), urllib.parse.quote(str(message_id)), ) return _request("PATCH", path, headers=self._headers, body={"text": str(text)}).get("message") def delete(self, chat_id, message_id): """Удалить своё сообщение — у себя и на платформе (у всех, где канал умеет).""" path = "/api/v1/chats/%s/messages/%s" % ( urllib.parse.quote(str(chat_id)), urllib.parse.quote(str(message_id)), ) return _request("DELETE", path, headers=self._headers) def read(self, chat_id): """Отметить чат прочитанным (сбросить счётчик непрочитанных).""" path = "/api/v1/chats/%s/read" % urllib.parse.quote(str(chat_id)) _request("POST", path, headers=self._headers, body={}) return True def forward(self, to_chat_id, from_chat_id, message_id): """Переслать сообщение (from_chat_id + message_id) в чат. Пока — только во внутренние чаты CloudGram. """ path = "/api/v1/chats/%s/forward" % urllib.parse.quote(str(to_chat_id)) return _request( "POST", path, headers=self._headers, body={"fromChatId": str(from_chat_id), "messageId": str(message_id)}, ).get("message") def contacts(self): """Контакты — люди, с кем у аккаунта есть внутренний диалог.""" return _request("GET", "/api/v1/contacts", headers=self._headers).get("contacts") or [] def media(self, chat_id, message_id): """Скачать вложение сообщения. Возвращает bytes (сырые байты файла).""" path = "/api/v1/chats/%s/messages/%s/media" % ( urllib.parse.quote(str(chat_id)), urllib.parse.quote(str(message_id)), ) return _download_binary(path, self._headers) def photo(self, chat_id, source, caption=""): """Отправить картинку: путь к файлу, ссылка, bytes или base64.""" return self._send_media(chat_id, "photo", source, caption) def video(self, chat_id, source, caption=""): """Отправить видео (mp4). Аргументы как у photo.""" return self._send_media(chat_id, "video", source, caption) def file(self, chat_id, source, caption=""): """Отправить документ — уходит без сжатия.""" return self._send_media(chat_id, "document", source, caption) def _send_media(self, chat_id, field, source, caption): path = "/api/v1/chats/%s/messages" % urllib.parse.quote(str(chat_id)) body = {"caption": str(caption or ""), field: _read_media_source(source)} return _request("POST", path, headers=self._headers, body=body).get("message") class Site: def __init__(self, redirect_uri, name=""): clean = str(redirect_uri or "").strip().rstrip("/") if not clean: raise CloudGramError("Нужен redirect_uri — адрес возврата", 0, "bad_redirect") self.redirect_uri = clean self.name = str(name or "").strip() def link(self, state=None): """Адрес страницы согласия — отправьте туда пользователя. state вернётся обратно без изменений, сверьте его при возврате. """ params = {} if self.name: params["app"] = self.name params["redirect_uri"] = self.redirect_uri if state: params["state"] = str(state) return HOST + "/authorize?" + urllib.parse.urlencode(params) def connect(self, code): """Обменять код из адреса возврата на подключение. Только на сервере.""" data = _request( "POST", "/api/oauth/token", body={"code": str(code or "").strip(), "redirectUri": self.redirect_uri}, ) return Session(data.get("token"), data.get("user")) def check_redirect(self): """Проверить, что адрес возврата разрешён владельцем CloudGram.""" data = _request("GET", "/api/oauth/check", query={"redirect_uri": self.redirect_uri}) return data.get("allowed") is True class Session: def __init__(self, token, user=None): if not token: raise CloudGramError("Нужен токен подключения", 0, "bad_token") self.token = str(token) self.user = user self._headers = {"Authorization": "Bearer " + self.token} def chats(self): """Диалоги пользователя (все мессенджеры).""" data = _request("GET", "/api/threads", headers=self._headers) return data if isinstance(data, list) else data.get("threads") or [] def messages(self, chat_id, limit=None): """Сообщения диалога.""" path = "/api/threads/%s/messages" % urllib.parse.quote(str(chat_id)) data = _request("GET", path, headers=self._headers, query={"limit": limit}) return data if isinstance(data, list) else data.get("messages") or [] def send(self, chat_id, text): """Отправить сообщение в диалог.""" path = "/api/threads/%s/messages" % urllib.parse.quote(str(chat_id)) return _request("POST", path, headers=self._headers, body={"text": str(text)}) def revoke(self): """Отключить доступ. После этого токен перестаёт работать.""" _request("POST", "/api/oauth/revoke", headers=self._headers, body={}) return True def bot(token, protocol="auto", endpoint=None): """Бот: получать сообщения и отвечать. По умолчанию ходит через протокол CloudGram (шифрование + маскировка); protocol=False — прямой HTTPS. """ return Bot(token, protocol=protocol, endpoint=endpoint) def protocol_supported(): """Поддерживает ли среда протокол CloudGram (cryptography + websocket-client).""" return _CEP_DEPS def account(client_id, client_secret): """Свой аккаунт по client_id + client_secret.""" return Account(client_id, client_secret) def site(redirect_uri, name=""): """Доступ к аккаунтам пользователей вашего сайта.""" return Site(redirect_uri, name) def session(token): """Восстановить подключение по сохранённому токену.""" return Session(token)