#!/usr/bin/env python3 """Пример бота Falcon Black на Python 3.9+: только библиотека requests. Эхо (текст, фото, файлы, видео и голосовые — обратно файлом), кнопки под сообщением с нажатием, клавиатура-ответы, меню команд на трёх языках (в том числе кириллические команды), длинный опрос getUpdates. Проверен против сервера Falcon Black 01.10.2026: эхо текста, фото и файлов, кнопки (люди пока видят их списком с номерами и отвечают цифрой), меню команд. Запуск: pip install requests export FB_BOT_TOKEN='…ключ со страницы falconblack.org/bots/key…' python3 full.py Ключ — только в переменной окружения: не в коде, не в строке запроса (там он сразу отзывается) и не в переписке (там тоже). События обрабатываются по одному, по порядку: так проще читать. Пауза по 429 в одной переписке задерживает и остальные — для большого бота нужна очередь на каждого человека (как в примере на Node). """ import datetime import os import random import re import signal import sys import time import zlib import requests API = os.environ.get("FB_API", "https://falconblack.org/bot-api/v1").rstrip("/") TOKEN = os.environ.get("FB_BOT_TOKEN", "").strip() # перевод строки из .env ломает ключ # Ключ: <номер бота>:fb1_<секрет 43><сумма 6>. Сумма — CRC32 # (как в zlib) от всего, что до неё, в UTF-8, записанная 6 знаками base62 # (0-9A-Za-z), старшие слева. Проверяем до первого запроса: опечатка видна сразу. B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" def token_problem(t): m = re.fullmatch(r"(\d{10,16}:fb1_[0-9A-Za-z]{43})([0-9A-Za-z]{6})", t) if not m: return "form" n = zlib.crc32(m.group(1).encode("utf-8")) s = "" for _ in range(6): s = B62[n % 62] + s n //= 62 return None if s == m.group(2) else "checksum" _p = token_problem(TOKEN) if _p: print("Нет ключа или он не той формы. Положите ключ бота в переменную FB_BOT_TOKEN." if _p == "form" else "В ключе опечатка: не сходится его контрольная сумма. Скопируйте ключ заново " "(или выпустите новый в Гнезде ботов: /token).", file=sys.stderr) sys.exit(2) UPDATE_TYPES = ["message", "edited_message", "callback_query", "my_chat_member"] # 4.4 POLL_TIMEOUT_S = 50 # больше 50 сервер молча урежет MAX_WAIT_S = 60 # дольше минуты внутри вызова не ждём: это суточный предел (quota) MAX_TEXT = 4096 # в единицах UTF-16, после снятия разметки def log(*a): print(datetime.datetime.now().isoformat(timespec="seconds"), *a, flush=True) def warn(*a): print(datetime.datetime.now().isoformat(timespec="seconds"), "!", *a, file=sys.stderr, flush=True) session = requests.Session() session.headers["Authorization"] = "Bearer " + TOKEN # рекомендуемый вход: ключ в заголовке # ── Вызов Bot API ───────────────────────────────────────────────────────────── class ApiError(Exception): def __init__(self, status, body, request_id=None, retry_after_header=None): body = body if isinstance(body, dict) else None b = body or {} super().__init__(b.get("description") or f"HTTP {status}, ответ не JSON") self.code = b.get("error_code", status) self.key = b.get("error_key") # наш машинный код ошибки self.params = b.get("parameters") or {} ra = self.params.get("retry_after") if ra is None and retry_after_header and str(retry_after_header).isdigit(): ra = int(retry_after_header) self.retry_after = ra self.request_id = request_id # X-Request-Id: его видно и в /status self.not_json = body is None # например, страница ошибки прокси def is_(self, key, text=None): return self.key == key or (text is not None and text in str(self)) def raw_call(method, params=None, idempotency_key=None, timeout=30): # Ключ повтора: [A-Za-z0-9_-], до 64 знаков. Можно и параметром тела # falcon_idempotency_key — так его передают через библиотеки. headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {} r = session.post(f"{API}/{method}", json=params or {}, headers=headers, timeout=(10, timeout)) try: body = r.json() except ValueError: body = None if isinstance(body, dict) and body.get("ok") is True: return body.get("result") raise ApiError(r.status_code, body, r.headers.get("X-Request-Id"), r.headers.get("Retry-After")) # Что можно повторить после обрыва связи, не боясь двойного действия. # Отправку (send*) повторяем только с ключом повтора: второго сообщения не будет. REPEATABLE = {"getMe", "getWebhookInfo", "getMyCommands", "setMyCommands", "editMessageText", "editMessageReplyMarkup", "sendChatAction"} def call(method, params=None, idempotency_key=None, timeout=30): attempt = 0 while True: attempt += 1 try: return raw_call(method, params, idempotency_key, timeout) except ApiError as e: if e.code == 429 and e.key != "quota": wait = e.retry_after or 1 if wait > MAX_WAIT_S or attempt > 5: raise time.sleep(wait) continue # тот же Idempotency-Key — дубля не будет if not (e.not_json or e.code >= 500): raise error = e except requests.RequestException as e: # обрыв связи или таймаут error = e if not (method in REPEATABLE or idempotency_key) or attempt > 5: raise error time.sleep(min(2 ** attempt, 30)) REVOKE_REASON = { "reissue": "выпущен новый ключ", "owner": "хозяин отозвал ключ", "leak_message": "ключ попал в переписку", "leak_url": "ключ попал в строку запроса (?token=…)", "transfer": "бота передали другому хозяину", "bot_deleted": "бот удалён", "owner_deleted": "учётка хозяина удалена", "admin": "решение команды Falcon Black", } def explain(e): if not isinstance(e, ApiError): return f"нет связи с {API}: {type(e).__name__}" rid = f" [X-Request-Id {e.request_id}]" if e.request_id else "" if e.key == "token_malformed": return f"Сервер не узнал форму ключа. Скопируйте его заново.{rid}" if e.key == "token_checksum": return f"В ключе опечатка (не сходится сумма). Скопируйте его заново.{rid}" if e.key == "token_unknown": return f"Такого ключа нет. Выпустите новый: Гнездо ботов → /token.{rid}" if e.key in ("token_revoked", "key_in_url"): why = REVOKE_REASON.get(e.params.get("revoke_reason"), REVOKE_REASON["leak_url"] if e.key == "key_in_url" else "ключ отозван") return f"Ключ больше не действует: {why}. Выпустите новый: Гнездо ботов → /token.{rid}" if e.key == "poll_replaced": return "Запущена вторая копия бота с тем же ключом, она забрала опрос. Эта копия выходит." if e.key == "webhook_active" or (e.code == 409 and "webhook" in str(e)): return "У бота включён вебхук, поэтому длинный опрос не работает. Снимите его: deleteWebhook или Гнездо ботов → /status." if e.key == "bot_frozen": return f"Бот отключён командой Falcon Black. Подробности — в Гнезде ботов.{rid}" if e.key == "quota": return f"{e} (суточный предел; сброс в 00:00 UTC).{rid}" if e.code == 401: return f"Ключ не подходит ({e}).{rid}" return f"{e.code} {e}{f' ({e.key})' if e.key else ''}{rid}" def fatal(e): sys.exit(explain(e)) # ── Тексты на трёх языках (язык человека приходит в from.language_code) ─────── TEXT = { "ru": dict( hello="Здравствуйте{name}! Я пример бота: повторяю ваши сообщения и показываю кнопки.", from_link="Вы пришли по ссылке с меткой «{p}».", help="Что я умею:\n/кнопки — кнопки под сообщением\n/клавиатура — кнопки вместо клавиатуры\n" "/убрать — убрать их\nЛюбой другой текст я повторю.", show_buttons="Показать кнопки", show_keyboard="Показать клавиатуру", counter="Счётчик: {n}", less="‹ Меньше", more="Больше ›", copy="Скопировать код", site="Сайт для авторов ботов", keyboard_on="Нажмите кнопку внизу — или напишите то же самое руками.", kb_dice="Кубик", kb_time="Который час", kb_hide="Убрать клавиатуру", kb_hint="Выберите кнопку", keyboard_off="Клавиатуру убрал.", dice="Выпало {n}.", time="У бота сейчас {s}.", echo="Вы написали: {t}", edited="Вы исправили сообщение: {t}", photo="Ваша картинка — возвращаю.", doc="Ваш файл — возвращаю.", media="Возвращаю файлом.", no_content="Получил «{k}». Такие сообщения приходят ботам без содержимого.", place="Вы прислали место: {la}, {lo}.", unknown="Такое сообщение я не понимаю. /помощь", unknown_command="Команды /{c} у меня нет. /помощь", stale="Эта кнопка больше не работает.", ), "en": dict( hello="Hello{name}! I am an example bot: I repeat your messages and show buttons.", from_link='You came via a link tagged "{p}".', help="What I can do:\n/buttons — buttons under a message\n/keyboard — buttons instead of the keyboard\n" "/hide — hide them\nAnything else, I repeat.", show_buttons="Show buttons", show_keyboard="Show keyboard", counter="Counter: {n}", less="‹ Less", more="More ›", copy="Copy the code", site="Site for bot authors", keyboard_on="Tap a button below — or type the same text yourself.", kb_dice="Dice", kb_time="What time is it", kb_hide="Hide keyboard", kb_hint="Choose a button", keyboard_off="Keyboard hidden.", dice="You rolled {n}.", time="It is {s} where the bot runs.", echo="You wrote: {t}", edited="You edited a message: {t}", photo="Your picture — sending it back.", doc="Your file — sending it back.", media="Sending it back as a file.", no_content='Got "{k}". Such messages reach bots without content.', place="You sent a location: {la}, {lo}.", unknown="I don't understand this kind of message. /help", unknown_command="I have no /{c} command. /help", stale="This button no longer works.", ), "uk": dict( hello="Вітаю{name}! Я приклад бота: повторюю ваші повідомлення й показую кнопки.", from_link="Ви прийшли за посиланням з міткою «{p}».", help="Що я вмію:\n/кнопки — кнопки під повідомленням\n/клавіатура — кнопки замість клавіатури\n" "/прибрати — прибрати їх\nБудь-який інший текст я повторю.", show_buttons="Показати кнопки", show_keyboard="Показати клавіатуру", counter="Лічильник: {n}", less="‹ Менше", more="Більше ›", copy="Скопіювати код", site="Сайт для авторів ботів", keyboard_on="Натисніть кнопку внизу — або напишіть те саме вручну.", kb_dice="Кубик", kb_time="Котра година", kb_hide="Прибрати клавіатуру", kb_hint="Оберіть кнопку", keyboard_off="Клавіатуру прибрав.", dice="Випало {n}.", time="У бота зараз {s}.", echo="Ви написали: {t}", edited="Ви виправили повідомлення: {t}", photo="Ваша картинка — повертаю.", doc="Ваш файл — повертаю.", media="Повертаю файлом.", no_content="Отримав «{k}». Такі повідомлення приходять ботам без вмісту.", place="Ви надіслали місце: {la}, {lo}.", unknown="Такого повідомлення я не розумію. /допомога", unknown_command="Команди /{c} у мене немає. /допомога", stale="Ця кнопка більше не працює.", ), } def lang(code): c = (code or "")[:2] return c if c in ("ru", "uk") else "en" # Меню «/»: по умолчанию — английское, для ru и uk — свои (setMyCommands с language_code, 4.3). # Кириллица в командах разрешена; «start» в меню не кладём — его заменяет «Начать». # Через API команды не «исправляются»: пишите ровно так, как люди будут набирать. MENU = { "": [ {"command": "help", "description": "What this bot can do"}, {"command": "buttons", "description": "Buttons under a message"}, {"command": "keyboard", "description": "Buttons instead of the keyboard"}, {"command": "hide", "description": "Hide those buttons"}, ], "ru": [ {"command": "помощь", "description": "Что умеет бот"}, {"command": "кнопки", "description": "Кнопки под сообщением"}, {"command": "клавиатура", "description": "Кнопки вместо клавиатуры"}, {"command": "убрать", "description": "Убрать эти кнопки"}, ], "uk": [ {"command": "допомога", "description": "Що вміє бот"}, {"command": "кнопки", "description": "Кнопки під повідомленням"}, {"command": "клавіатура", "description": "Кнопки замість клавіатури"}, {"command": "прибрати", "description": "Прибрати ці кнопки"}, ], } COMMANDS = { "start": "start", "help": "help", "помощь": "help", "допомога": "help", "buttons": "buttons", "кнопки": "buttons", "keyboard": "keyboard", "клавиатура": "keyboard", "клавіатура": "keyboard", "hide": "hide", "убрать": "hide", "прибрати": "hide", } # ── Отправка ────────────────────────────────────────────────────────────────── stopped = set() # кто остановил бота или удалил учётку: писать им бесполезно (403) me = {} # getMe class Ctx: """Куда отвечаем и на какое событие. Ключ повтора = номер события + номер ответа: если программа упадёт и получит то же событие снова, сервер не пришлёт второй ответ.""" def __init__(self, chat_id, update_id): self.chat_id, self.update_id, self.n = chat_id, update_id, 0 def next_key(self): self.n += 1 return f"u{self.update_id}-{self.n}" def send(ctx, method, params): if ctx.chat_id in stopped: return None try: return call(method, {"chat_id": ctx.chat_id, **params}, idempotency_key=ctx.next_key()) except ApiError as e: # 403: человек остановил бота или удалил учётку — вычёркиваем, пока не напишет сам. if e.is_("blocked", "bot was blocked by the user") or e.is_("user_deleted", "user is deactivated"): stopped.add(ctx.chat_id) return None if e.is_("chat_not_found", "chat not found"): return None if e.is_("reply_not_found") and "reply_parameters" in params: rest = {k: v for k, v in params.items() if k != "reply_parameters"} return send(ctx, method, rest) # сообщение уже удалено — отвечаем без цитаты raise def say(ctx, text, **extra): return send(ctx, "sendMessage", {"text": text, **extra}) def utf16_len(s): return len(s.encode("utf-16-le")) // 2 def clip(s, limit=MAX_TEXT): # До 4 096 единиц UTF-16 (эмодзи — две), после снятия разметки. if utf16_len(s) <= limit: return s out, n = [], 0 for ch in s: w = 2 if ord(ch) > 0xFFFF else 1 if n + w > limit - 1: break out.append(ch) n += w return "".join(out) + "…" def utf16_slice(s, start, length): # Смещения entities — в единицах UTF-16, а строки Python считают кодовые точки. b = s.encode("utf-16-le") return b[start * 2:(start + length) * 2].decode("utf-16-le", errors="ignore") def counter_markup(n, L, with_link): T = TEXT[L] rows = [ [{"text": T["less"], "callback_data": f"cnt:{n - 1}"}, {"text": T["more"], "callback_data": f"cnt:{n + 1}"}], [{"text": T["copy"], "copy_text": {"text": "FB-2026"}}], ] if with_link: rows.append([{"text": T["site"], "url": "https://falconblack.org/bots"}]) return {"inline_keyboard": rows} def show_counter(ctx, n, L): text = TEXT[L]["counter"].format(n=n) try: return say(ctx, text, reply_markup=counter_markup(n, L, True)) except ApiError as e: # Первые 3 дня бот шлёт ссылки только хозяину (3.5; до какого числа — getMe.falcon_trust_until). if e.is_("links_new_bot"): return say(ctx, text, reply_markup=counter_markup(n, L, False)) raise def show_keyboard(ctx, L): T = TEXT[L] return say(ctx, T["keyboard_on"], reply_markup={ "keyboard": [[T["kb_dice"], T["kb_time"]], [T["kb_hide"]]], "resize_keyboard": True, "input_field_placeholder": T["kb_hint"]}) def keyboard_word(text): # Нажатие клавиатуры-ответов приходит обычным текстом — узнаём надпись на любом языке. t = text.strip().lower() for T in TEXT.values(): for word, what in (("kb_dice", "dice"), ("kb_time", "time"), ("kb_hide", "hide")): if t == T[word].lower(): return what return None CMD_RE = re.compile(r"^/(\w{1,32})(?:@([A-Za-z0-9_]{5,32}))?(?=\s|$)") # \w в Python — и кириллица def command_of(msg): text = msg.get("text") or "" head = None for e in msg.get("entities") or []: if e.get("type") == "bot_command" and e.get("offset") == 0: head = utf16_slice(text, 0, e["length"]) break if head is None: # Сервер ставит bot_command и у кириллических команд; свой разбор — только запасной. m = CMD_RE.match(text) head = m.group(0) if m else None if not head: return None name, _, at = head[1:].partition("@") if at and at.lower() != me["username"].lower(): return None # команда другому боту return name.lower(), text[len(head):].strip() # ── Обработка событий ───────────────────────────────────────────────────────── def on_command(ctx, msg, name, args, L): T = TEXT[L] what = COMMANDS.get(name) if what == "start": # «Начать» (и первое сообщение из старой версии приложения — сервер сам ставит # перед ним «/start») присылает «/start» или «/start ПАРАМЕТР». first = (msg.get("from") or {}).get("first_name") lines = [T["hello"].format(name=f", {first}" if first else "")] if args: lines.append(T["from_link"].format(p=args)) return say(ctx, "\n".join(lines), reply_markup={"inline_keyboard": [[ {"text": T["show_buttons"], "callback_data": "menu:buttons"}, {"text": T["show_keyboard"], "callback_data": "menu:keyboard"}]]}) if what == "help": return say(ctx, T["help"]) if what == "buttons": return show_counter(ctx, 0, L) if what == "keyboard": return show_keyboard(ctx, L) if what == "hide": return say(ctx, T["keyboard_off"], reply_markup={"remove_keyboard": True}) return say(ctx, T["unknown_command"].format(c=name)) def on_message(u, msg, edited): ctx = Ctx(msg["chat"]["id"], u["update_id"]) stopped.discard(ctx.chat_id) # человек пишет — значит, бот снова запущен L = lang((msg.get("from") or {}).get("language_code")) T = TEXT[L] reply_to = {"message_id": msg["message_id"]} if edited: if isinstance(msg.get("text"), str): say(ctx, clip(T["edited"].format(t=msg["text"])), reply_parameters=reply_to) return # Любое сообщение обрабатываем сразу, не требуя /start: так бот не зависит от # того, как человек пришёл. text = msg.get("text") if isinstance(text, str): cmd = command_of(msg) if cmd: return on_command(ctx, msg, cmd[0], cmd[1], L) kb = keyboard_word(text) if kb == "dice": return say(ctx, T["dice"].format(n=random.randint(1, 6))) if kb == "time": return say(ctx, T["time"].format(s=datetime.datetime.now().strftime("%H:%M"))) if kb == "hide": return say(ctx, T["keyboard_off"], reply_markup={"remove_keyboard": True}) return say(ctx, clip(T["echo"].format(t=text))) if msg.get("photo"): # Размеры идут по возрастанию, как принято в Bot API: последний — самый большой. return send(ctx, "sendPhoto", {"photo": msg["photo"][-1]["file_id"], "caption": T["photo"], "reply_parameters": reply_to}) if msg.get("document"): return send(ctx, "sendDocument", {"document": msg["document"]["file_id"], "caption": T["doc"], "reply_parameters": reply_to}) # sendDocument принимает любой свой file_id — видео, голосовое, аудио уйдут файлом. for kind in ("video", "voice", "audio"): if msg.get(kind): return send(ctx, "sendDocument", {"document": msg[kind]["file_id"], "caption": T["media"], "reply_parameters": reply_to}) if msg.get("location"): loc = msg["location"] return say(ctx, T["place"].format(la=loc["latitude"], lo=loc["longitude"])) if msg.get("falcon_type"): # стикер, опрос… return say(ctx, T["no_content"].format(k=msg["falcon_type"])) return say(ctx, T["unknown"]) def answer(cq_id, text=None): params = {"callback_query_id": cq_id} if text: params["text"] = text try: call("answerCallbackQuery", params) except ApiError as e: # Нажатию больше 15 минут (программа долго лежала) — ответить уже нельзя. Не беда. if not e.is_("callback_expired", "query is too old"): raise def on_callback(u, cq): msg = cq.get("message") or {} chat_id = (msg.get("chat") or {}).get("id", cq["from"]["id"]) # в личной переписке это номер человека ctx = Ctx(chat_id, u["update_id"]) L = lang(cq["from"].get("language_code")) T = TEXT[L] data = cq.get("data") or "" # cq.get("falcon_via"): "digit" | "label" — человек со старой версией приложения # ответил цифрой или надписью. Для этого бота разницы нет. m = re.fullmatch(r"cnt:(-?\d{1,6})", data) if m and msg: n = int(m.group(1)) answer(cq["id"], T["counter"].format(n=n)) # сначала ответ: у человека гаснет колёсико rows = (msg.get("reply_markup") or {}).get("inline_keyboard") or [] had_link = any("url" in b for row in rows for b in row) try: call("editMessageText", {"chat_id": chat_id, "message_id": msg["message_id"], "text": T["counter"].format(n=n), "reply_markup": counter_markup(n, L, had_link)}) except ApiError as e: # Двойное нажатие даёт ту же правку — «not modified», это нормально. if e.key in ("not_modified", "edit_not_found", "edit_forbidden"): return if e.is_("blocked", "bot was blocked by the user") or e.is_("user_deleted"): stopped.add(chat_id) return raise return if data == "menu:buttons": answer(cq["id"]) return show_counter(ctx, 0, L) if data == "menu:keyboard": answer(cq["id"]) return show_keyboard(ctx, L) answer(cq["id"], T["stale"]) def on_member(m): # Первый приход — left → member; «Остановить» или «Заблокировать» — member → kicked; # запуск снова — kicked → member. Приходит всегда раньше сообщения. to = (m.get("new_chat_member") or {}).get("status") if to == "kicked": stopped.add(m["chat"]["id"]) elif to == "member": stopped.discard(m["chat"]["id"]) log(f"человек {m['chat']['id']}: {(m.get('old_chat_member') or {}).get('status', '?')} → {to}") def handle(u): if "message" in u: return on_message(u, u["message"], False) if "edited_message" in u: return on_message(u, u["edited_message"], True) if "callback_query" in u: return on_callback(u, u["callback_query"]) if "my_chat_member" in u: return on_member(u["my_chat_member"]) # Незнакомый вид события — пропускаем: так требует 1.3. # ── Запуск ──────────────────────────────────────────────────────────────────── def ensure_menu(): # Меню ставим, только если оно отличается. Правка без изменений в предел не # считается (у программы свои 50 правок в сутки, у Гнезда — свои 20), но лишний # вызов незачем. Лучше всего ставить меню при выкладке, а не на каждом запуске. for code, commands in MENU.items(): where = {"language_code": code} if code else {} now = call("getMyCommands", where) or [] if [(c.get("command"), c.get("description")) for c in now] == \ [(c["command"], c["description"]) for c in commands]: continue try: call("setMyCommands", {"commands": commands, **where}) log(f"меню «{code or 'по умолчанию'}» обновлено") except ApiError as e: if e.code == 429: warn(f"меню не обновлено: {explain(e)} Бот работает дальше.") return raise def poll_loop(): offset, pause = 0, 1 try: while True: try: updates = raw_call("getUpdates", {"offset": offset, "limit": 100, "timeout": POLL_TIMEOUT_S, "allowed_updates": UPDATE_TYPES}, timeout=POLL_TIMEOUT_S + 15) pause = 1 except ApiError as e: if e.code == 429: time.sleep(e.retry_after or 5) continue # 401 (ключ; отзыв сразу обрывает висящий опрос), 403 (бот отключён), # 409 (вторая копия или вебхук), прочие 4xx — повтор не поможет. if not e.not_json and 400 <= e.code < 500: fatal(e) warn(f"getUpdates: {explain(e)} — повтор через {pause} с") time.sleep(pause) pause = min(pause * 2, 30) continue except requests.RequestException as e: warn(f"getUpdates: {explain(e)} — повтор через {pause} с") time.sleep(pause) pause = min(pause * 2, 30) continue for u in updates: try: handle(u) except (ApiError, requests.RequestException) as e: warn(f"событие {u.get('update_id')}: {explain(e)}") # Подтверждаем событие только после обработки: упадём посередине — # получим его снова, а повторных ответов не будет благодаря ключу повтора. offset = u["update_id"] + 1 except KeyboardInterrupt: log("останавливаюсь…") if offset: try: # подтвердить обработанное, чтобы после перезапуска оно не пришло снова raw_call("getUpdates", {"offset": offset, "limit": 1, "timeout": 0}) except Exception: pass log("остановлен") def _stop(*_): raise KeyboardInterrupt if __name__ == "__main__": signal.signal(signal.SIGTERM, _stop) try: me = call("getMe") log(f"бот @{me['username']} («{me['first_name']}») на связи, адрес {API}") if me.get("falcon_trust") == "new" and me.get("falcon_trust_until"): until = datetime.datetime.fromtimestamp(me["falcon_trust_until"], datetime.timezone.utc) log(f"бот новый: ссылки в сообщениях видит только хозяин до {until.isoformat()}") if call("getWebhookInfo").get("url"): fatal(ApiError(409, {"error_code": 409, "error_key": "webhook_active", "description": "webhook is active"})) ensure_menu() except (ApiError, requests.RequestException) as e: fatal(e) poll_loop()