FPC Plugin Docs
← На сайт Cardinal

FunPayCardinal 0.1.17.6 · FunPayAPI

Документация по написанию плагинов

Плагин FunPayCardinal — это один .py-файл в папке plugins/. Он получает доступ к аккаунту FunPay, ко всем событиям чатов и заказов и к Telegram-панели управления. Ниже — полный разбор API: хуки, события, типы, методы аккаунта, Telegram-ПУ, хранение настроек, установка зависимостей и ограничения, которые нужно учитывать.

Python 3.11+ pyTelegramBotAPI Один файл = один плагин Горячей перезагрузки нет

Что такое FunPayCardinal и где в нём плагин

FunPayCardinal (FPC) — бот-автоматизатор для FunPay. Внутри он состоит из трёх слоёв:

  • FunPayAPI — обёртка над сайтом FunPay: Account (запросы), Runner (long-polling обновлений), types (модели данных), events (события).
  • Cardinal — ядро: держит конфиги, аккаунт, списки хэндлеров, автоподнятие лотов, автовыдачу, ЧС.
  • tg_bot — Telegram-панель управления на pyTelegramBotAPI.

Плагин — это обычный модуль Python, который Cardinal импортирует на старте и у которого забирает списки функций-хэндлеров. Никакого базового класса наследовать не нужно: достаточно объявить нужные переменные уровня модуля.

Ключевая идея

Cardinal ищет в модуле плагина переменные вида BIND_TO_XXX — списки функций. Функции из этих списков попадают в общий пул хэндлеров ядра и вызываются, когда происходит соответствующее событие. Всё остальное — ваш обычный Python-код.

Раздел 02

Быстрый старт

От пустого файла до плагина, который отвечает в чат FunPay и умеет команду в Telegram.

Минимальный плагин

Создайте файл plugins/hello.py:

plugins/hello.py
from __future__ import annotations
from typing import TYPE_CHECKING
import logging

if TYPE_CHECKING:
    from cardinal import Cardinal
    from FunPayAPI.updater.events import NewMessageEvent

logger = logging.getLogger("FPC.hello")

NAME = "Hello"
VERSION = "1.0.0"
DESCRIPTION = "Отвечает на слово 'привет' в чате FunPay."
CREDITS = "@yourname"
UUID = "0f2d5a17-9c4b-4a4e-b8de-1b7a5c3f0e21"   # свой уникальный UUID4!
SETTINGS_PAGE = False
BIND_TO_DELETE = None


def on_message(c: Cardinal, e: NewMessageEvent):
    if e.message.author_id in (0, c.account.id):
        return                                  # системное / своё сообщение
    if "привет" in (e.message.text or "").lower():
        c.send_message(e.message.chat_id, "И тебе привет!", e.message.chat_name)


BIND_TO_NEW_MESSAGE = [on_message]

Это всё. Положите файл в plugins/, перезапустите бота — плагин появится в Telegram-ПУ в разделе «Плагины».

UUID обязателен и уникален

Cardinal проверяет UUID через uuid.UUID(value, version=4). Невалидный или уже занятый UUID → плагин молча не загрузится (в логе будет ошибка). Сгенерируйте свой: python -c "import uuid; print(uuid.uuid4())". Никогда не копируйте UUID чужого плагина.

Установка, логи, отладка

  • Установка: файл кладётся в plugins/ вручную или отправляется боту в Telegram (команда /upload_plugin / кнопка в списке плагинов).
  • Перезапуск обязателен. Загрузка плагинов происходит один раз в Cardinal.init(). Горячей перезагрузки нет — после правки файла перезапускайте FPC (/restart).
  • Логи: берите логгер с префиксом FPC.logging.getLogger("FPC.МойПлагин"). Тогда сообщения попадут в общий лог и в /logs. В логах поддерживаются цветовые метки вида $YELLOW…$RESET.
  • Ошибки хэндлеров не роняют бота. Cardinal.run_handlers() ловит любое исключение, пишет ошибку в лог и идёт дальше. Полный traceback виден только на уровне DEBUG.
  • Отключить файл от загрузки — первой строкой файла поставить комментарий со словом noplug: # noplug. Такой файл будет проигнорирован (удобно для вспомогательных модулей рядом с плагинами).
Импорты ядра

Папка plugins добавляется в sys.path, а рабочая директория — корень FPC. Поэтому из плагина напрямую доступны from cardinal import Cardinal, from FunPayAPI…, from tg_bot import CBT, keyboards, utils, from Utils import cardinal_tools. Все пути к файлам пишите относительно корня FPC (storage/plugins/…).

Раздел 03

Анатомия плагина

Что именно ядро читает из вашего модуля и в каком порядке.

Обязательные поля модуля

Если хотя бы одного из этих семи имён нет — плагин не загрузится (FieldNotExistsError).

ПолеТипНазначение
NAMEstrИмя в списке плагинов Telegram-ПУ.
VERSIONstrВерсия, показывается рядом с именем.
DESCRIPTIONstrОписание на карточке плагина.
CREDITSstrАвтор (обычно @username).
UUIDstrUUID4. Идентификатор плагина во всех callback'ах и в файлах вкл/выкл.
SETTINGS_PAGEboolTrue → в карточке плагина появится кнопка «Настройки», шлющая callback CBT.PLUGIN_SETTINGS:UUID:offset.
BIND_TO_DELETECallable | NoneФункция (cardinal, call), вызываемая перед удалением файла плагина через ПУ. Здесь чистят свои файлы/потоки.

Как FPC загружает плагин

  1. Берёт все *.py из plugins/ (не рекурсивно).
  2. Читает первую строку: если это комментарий со словом noplug — пропускает файл.
  3. importlib исполняет модуль целиком. Весь код верхнего уровня выполняется здесь, ещё до инициализации аккаунта.
  4. Читает семь обязательных полей, проверяет UUID.
  5. Создаёт PluginData и кладёт в cardinal.plugins[UUID]; состояние «включён» подтягивается из storage/cache/disabled_plugins.json.
  6. После загрузки всех плагинов вызывает add_handlers(): у каждой функции из BIND_TO_* проставляется атрибут plugin_uuid, функции складываются в общие списки ядра.
Код верхнего уровня — это ещё «до всего»

На момент импорта нет ни авторизованного аккаунта, ни Telegram-бота, ни профиля. Тяжёлые операции (сеть, БД, чтение больших файлов, установка зависимостей) выносите в функцию, привязанную к BIND_TO_PRE_INIT или BIND_TO_POST_INIT.

Выключение плагина в ПУ не выгружает модуль: ядро просто пропускает его хэндлеры при вызове (plugin_uuid сверяется с cardinal.plugins[uuid].enabled). Поэтому:

Отключение не останавливает ваши потоки

Telegram-хэндлеры (tg.msg_handler, tg.cbq_handler) и запущенные вами Thread регистрируются напрямую в telebot / ОС и продолжают работать при выключенном плагине. Если это важно — проверяйте флаг сами:

def is_enabled(c: Cardinal) -> bool:
    pl = c.plugins.get(UUID)
    return bool(pl and pl.enabled)

Плагин из нескольких файлов

Ядро считает плагином каждый .py в plugins/, но перед загрузкой добавляет эту папку в sys.path. Значит, вспомогательные модули можно класть рядом и импортировать по имени — главное пометить их noplug, чтобы они не грузились как отдельные плагины:

plugins/
├── myshop.py            # сам плагин с NAME/UUID/BIND_TO_*
├── myshop_api.py        # первая строка: # noplug
└── myshop_ui.py         # первая строка: # noplug
# myshop.py
from myshop_api import fetch_prices
from myshop_ui import build_settings_kb
Имена модулей глобальны

Все плагины и их модули живут в одном sys.modules. Файл utils.py или config.py в plugins/ почти гарантированно столкнётся с чужим — префиксуйте имена файлов названием плагина. По этой же причине распространять плагин удобнее одним файлом: пользователь ставит его через ПУ, и «потерять» вспомогательный модуль невозможно.

Внешние данные — шаблоны, БД, изображения — держите в storage/plugins/, а не рядом с кодом: содержимое plugins/ пользователь чистит и переустанавливает.

Раздел 04

Жизненный цикл и хуки

Шестнадцать точек привязки. Каждая — просто список функций на уровне модуля.

Таблица BIND_TO_*

ПеременнаяСигнатура хэндлераКогда срабатывает
BIND_TO_PRE_INIT(c: Cardinal)После загрузки всех плагинов, до входа в аккаунт. Telegram-бот уже создан (если включён), но ещё не запущен — здесь регистрируют хэндлеры и команды ПУ.
BIND_TO_POST_INIT(c: Cardinal)Аккаунт авторизован, профиль и лоты получены, Runner создан. Здесь можно ходить в API FunPay.
BIND_TO_PRE_START(c: Cardinal)Перед запуском основного цикла.
BIND_TO_POST_START(c: Cardinal)Сразу после старта. Обычное место для своих фоновых потоков.
BIND_TO_PRE_STOP
BIND_TO_POST_STOP
(c: Cardinal)Остановка кардинала. В текущей версии stop() фактически не вызывается — не рассчитывайте на них для сохранения данных.
BIND_TO_INIT_MESSAGE(c, e: InitialChatEvent)Первый запрос Runner'а нашёл чат. Срабатывает пачкой на старте — не шлите отсюда сообщения.
BIND_TO_MESSAGES_LIST_CHANGED(c, e: ChatsListChangedEvent)Список чатов изменился (без деталей).
BIND_TO_LAST_CHAT_MESSAGE_CHANGED(c, e: LastChatMessageChangedEvent)В чате изменилось последнее сообщение. Это «старый режим» — текст обрезан до 250 символов.
BIND_TO_NEW_MESSAGE(c, e: NewMessageEvent)Новое сообщение в истории чата с полным текстом и типом. Основной хук для чата.
BIND_TO_INIT_ORDER(c, e: InitialOrderEvent)Первый запрос Runner'а нашёл заказ.
BIND_TO_ORDERS_LIST_CHANGED(c, e: OrdersListChangedEvent)Изменилось кол-во незакрытых покупок/продаж.
BIND_TO_NEW_ORDER(c, e: NewOrderEvent)Новый оплаченный заказ.
BIND_TO_ORDER_STATUS_CHANGED(c, e: OrderStatusChangedEvent)Статус заказа изменился (закрыт, возврат…).
BIND_TO_PRE_DELIVERY(c, e: NewOrderEvent)Перед выдачей товара штатной автовыдачей.
BIND_TO_POST_DELIVERY(c, e: NewOrderEvent)После выдачи: в e лежит результат (delivered, error…).
BIND_TO_PRE_LOTS_RAISE(c, …)Объявлен в ядре, но в текущей версии не вызывается.
BIND_TO_POST_LOTS_RAISE(c, cat: Category, error_text: str)После попытки поднять лоты категории.

Порядок вызова на старте

Cardinal.init()
 ├─ регистрируются встроенные handlers.py и announcements.py
 ├─ load_plugins()          # исполняется код ваших модулей
 ├─ add_handlers()          # BIND_TO_* попадают в ядро
 ├─ создаётся TGBot, регистрируются встроенные разделы ПУ
 ├─ ▶ PRE_INIT              # ваш код: tg.msg_handler / cbq_handler / команды
 ├─ setup_commands(), запуск потока Telegram-бота
 ├─ авторизация аккаунта, Runner, загрузка профиля и лотов
 └─ ▶ POST_INIT             # ваш код: запросы к FunPay, стартовые проверки

Cardinal.run()
 ├─ поток Runner.loop()
 ├─ ▶ PRE_START → ▶ POST_START
 ├─ потоки автоподнятия лотов и обновления сессии
 └─ process_events()        # бесконечный цикл: события → ваши хэндлеры
Почему хэндлеры ПУ регистрируют именно в PRE_INIT

Поток telegram.run() (polling) стартует сразу после PRE_INIT. Всё, что вы зарегистрируете позже, может не успеть к первым апдейтам, а setup_commands() уже отработает без ваших команд. Все встроенные разделы ПУ (plugins_cp, auto_delivery_cp…) сделаны как раз через BIND_TO_PRE_INIT.

Несколько хэндлеров на одном событии

# Порядок вызова = порядок в списке; сначала встроенные, потом плагины
BIND_TO_NEW_MESSAGE = [log_handler, main_handler]
BIND_TO_NEW_ORDER = [on_order]
BIND_TO_POST_START = [start_worker]

Хэндлеры выполняются синхронно и последовательно в потоке обработки событий. Долгая работа в хэндлере тормозит весь бот — выносите её в поток (см. Практики).

Раздел 05

Объект Cardinal

Первый аргумент любого хэндлера. Через него доступно всё: аккаунт, конфиги, Telegram, ЧС, профиль, другие плагины.

Основные атрибуты

АтрибутТипОписание
accountFunPayAPI.AccountАккаунт FunPay. Все сетевые методы — здесь.
telegramTGBot | NoneПанель управления. Может быть None, если Telegram выключен в конфиге — всегда проверяйте.
runnerRunner | NoneОпрос обновлений FunPay.
MAIN_CFGConfigParserconfigs/_main.cfg: секции FunPay, Telegram, BlockList, NewMessageView, Greetings, OrderConfirm, Proxy, Other.
AD_CFGConfigParserКонфиг автовыдачи (секция = название лота).
AR_CFG / RAW_AR_CFGConfigParserАвтоответчик: обработанный и «сырой».
profileUserProfileПрофиль аккаунта для хэндлеров: лоты, подкатегории.
curr_profile / tg_profileUserProfileПрофиль для авто-восстановления лотов / для Telegram-ПУ.
balanceBalanceБаланс, полученный при старте.
blacklistlist[str]Ники в ЧС. Менять — вместе с cardinal_tools.cache_blacklist().
pluginsdict[str, PluginData]Все плагины по UUID: name, version, enabled, commands, path…
proxydictПрокси в формате requests ({"http":…, "https":…}) — используйте его в своих запросах.
VERSION, start_time, instance_idstr / intВерсия FPC, время старта (unix), ID запуска.

Свойства-переключатели

Читаются из _main.cfg и удобны для проверок: autoraise_enabled, autoresponse_enabled, autodelivery_enabled, multidelivery_enabled, autorestore_enabled, autodisable_enabled, old_mode_enabled, keep_sent_messages_unread, bl_delivery_enabled, bl_response_enabled, block_tg_login и другие.

Секции _main.cfg

СекцияКлючи
[FunPay]golden_key, user_agent, autoRaise, autoResponse, autoDelivery, multiDelivery, autoRestore, autoDisable, oldMsgGetMode, locale, keepSentMessagesUnread
[Telegram]enabled, token, secretKeyHash, blockLogin, proxy
[BlockList]blockDelivery, blockResponse, blockNewMessageNotification, blockNewOrderNotification, blockCommandNotification
[NewMessageView]includeMyMessages, includeFPMessages, includeBotMessages, notifyOnly*, showImageName
[Greetings]sendGreetings, greetingsText, greetingsCooldown, onlyNewChats, ignoreSystemMessages
[OrderConfirm]sendReply, replyText, watermark
[ReviewReply]star1Replystar5Reply, star1ReplyTextstar5ReplyText
[Proxy]enable, proxy, check
[Other]watermark, requestsDelay, language
delay = int(c.MAIN_CFG["Other"]["requestsDelay"])
on = c.MAIN_CFG["FunPay"].getboolean("autoDelivery")

c.MAIN_CFG["Other"]["watermark"] = "🐦 my shop"      # запись
c.save_config(c.MAIN_CFG, "configs/_main.cfg")
Чужой конфиг — только чтение

В _main.cfg лежат golden_key, токен бота и хэш пароля от ПУ. Не логируйте эти значения, не отправляйте их наружу и не переписывайте настройки пользователя без его явного действия в интерфейсе. Свои параметры храните в storage/plugins/, а не в конфигах ядра.

Методы, которые нужны чаще всего

send_message(chat_id, message_text, chat_name=None, interlocutor_id=None, attempts=3, watermark=True) → list[Message] | None

Правильный способ написать в чат FunPay. В отличие от account.send_message(), он:

  • добавляет водяной знак из конфига (watermark=False отключает);
  • режет текст на куски по 20 строк — FunPay не принимает длинные сообщения;
  • понимает разметку: $photo=ID — отправить изображение, $sleep=1.5 — пауза в секундах, $new — принудительно начать новое сообщение;
  • делает до attempts повторов при ошибке.
c.send_message(e.message.chat_id, "Спасибо за покупку!", e.message.chat_name)
c.send_message(chat_id, "$photo=1234567890")          # только картинка
get_order_from_object(obj: OrderShortcut | Message | ChatShortcut, order_id=None) → Order | None

Достаёт полный Order из события с кэшированием и защитой от гонок: повторный вызов на том же объекте не сделает второй запрос. Для Message ID заказа вытаскивается из текста системного сообщения. Всегда используйте это вместо ручного account.get_order() в хэндлерах.

order = c.get_order_from_object(e.order)     # NewOrderEvent
if order and order.review and order.review.stars <= 2:
    ...
add_telegram_commands(uuid: str, commands: list[tuple[str, str, bool]]) → None

Регистрирует команды плагина: они появятся в карточке плагина, а при True в третьем элементе — и в меню команд бота. Кнопка «Команды» в карточке отрисовывается только если список непустой, поэтому вызывать метод стоит даже ради одной команды. Сам обработчик команды это не создаёт — его всё равно нужно зарегистрировать через tg.msg_handler(handler, commands=[...]).

cardinal.add_telegram_commands(UUID, [
    ("mystats", "статистика плагина", True),
    ("myreset", "сброс счётчиков", False),
])
run_handlers(handlers_list: list[Callable], args: tuple) → None

Вызывает список хэндлеров с перехватом ошибок. Пригодится, если ваш плагин сам порождает событие (так делает тест автовыдачи: собирает фейковый NewOrderEvent и вызывает c.run_handlers(c.new_order_handlers, (c, fake_event))).

get_exchange_rate(base: Currency, target: Currency, min_interval=60) → float
get_balance(attempts=3) → Balance  ·  update_session(attempts=3) → bool  ·  raise_lots() → int
save_config(config: ConfigParser, file_path: str)  ·  toggle_plugin(uuid)  ·  pin_plugin(uuid)
Cardinal — синглтон

Если объект кардинала нужен там, куда его не передали (например, в потоке или в telebot-хэндлере), берите его так:

from cardinal import Cardinal, get_cardinal

c = get_cardinal()      # None, если экземпляр ещё не создан

Раздел 06

События

Второй аргумент хэндлера. Все классы — в FunPayAPI.updater.events, типы — в FunPayAPI.common.enums.EventTypes.

У каждого события есть type (EventTypes), time и runner_tag — тег запроса Runner'а, по которому можно понять, что несколько событий пришли одной пачкой.

Класс событияПолезные поля
InitialChatEventchat: ChatShortcut
ChatsListChangedEvent
LastChatMessageChangedEventchat: ChatShortcut
NewMessageEventmessage: Message, stack: MessageEventsStack
InitialOrderEventorder: OrderShortcut
OrdersListChangedEventpurchases: int, sales: int
NewOrderEventorder: OrderShortcut (+ атрибуты автовыдачи, см. ниже)
OrderStatusChangedEventorder: OrderShortcut

Два режима получения сообщений

В _main.cfg есть параметр oldMsgGetMode. Он определяет, какой хук у вас вообще будет срабатывать:

Обычный режим (oldMsgGetMode = 0)Старый режим (= 1)
ХукBIND_TO_NEW_MESSAGEBIND_TO_LAST_CHAT_MESSAGE_CHANGED
ОбъектMessage — полный текст, автор, тип, изображенияChatShortcut — только последнее сообщение, до 250 символов
Запросы+1 запрос истории чатаБез доп. запросов
Поддерживайте оба режима

Встроенные хэндлеры FPC написаны так, что одна и та же функция висит на обоих хуках и различает объекты по типу. Если ваш плагин должен работать у любого пользователя — делайте так же, иначе у части людей он будет «молчать».

def on_msg(c: Cardinal, e):
    obj = e.message if hasattr(e, "message") else e.chat
    text = obj.text if hasattr(obj, "text") else obj.last_message_text
    chat_id, chat_name = obj.chat_id if hasattr(obj, "chat_id") else obj.id, obj.chat_name if hasattr(obj, "chat_name") else obj.name
    ...

BIND_TO_NEW_MESSAGE = [on_msg]
BIND_TO_LAST_CHAT_MESSAGE_CHANGED = [on_msg]

Фильтрация сообщений

В хук прилетает всё: ваши собственные сообщения, системные уведомления FunPay, сообщения бота. Минимальный набор проверок:

from FunPayAPI.common.enums import MessageTypes

def on_message(c: Cardinal, e: NewMessageEvent):
    m = e.message
    if m.author_id == 0:            # системное сообщение FunPay
        return
    if m.author_id == c.account.id: # наше собственное
        return
    if m.by_bot:                    # отправлено этим же ботом
        return
    if m.type is not MessageTypes.NON_SYSTEM:
        return                      # отзывы, оплаты, возвраты и т.д.

Типы системных сообщений (MessageTypes): ORDER_PURCHASED, ORDER_CONFIRMED, NEW_FEEDBACK, FEEDBACK_CHANGED, FEEDBACK_DELETED, NEW_FEEDBACK_ANSWER, FEEDBACK_ANSWER_CHANGED, FEEDBACK_ANSWER_DELETED, ORDER_REOPENED, REFUND, PARTIAL_REFUND, ORDER_CONFIRMED_BY_ADMIN, REFUND_BY_ADMIN, DISCORD, DEAR_VENDORS, NON_SYSTEM.

Тип определяется регулярками

get_message_type() сравнивает текст с шаблонами системных сообщений. Покупатель может написать «поддельное» сообщение и попасть под шаблон. Поэтому для важной логики всегда добавляйте проверку author_id == 0.

MessageEventsStack

Если пользователь прислал несколько сообщений подряд, они придут отдельными событиями, но с общим стеком. Это позволяет ответить один раз на всю пачку:

def on_message(c: Cardinal, e: NewMessageEvent):
    if e.stack and e.message.id != e.stack.get_stack()[-1].message.id:
        return                       # отвечаем только на последнее в пачке
    full_text = "\n".join(ev.message.text or "" for ev in e.stack.get_stack())

ID заказа из текста сообщения

from FunPayAPI.common.utils import RegularExpressions

res = RegularExpressions()
found = res.ORDER_ID.findall(str(e.message))   # ['#ABCD1234']
if found:
    order = c.account.get_order(found[0][1:])

В RegularExpressions лежат готовые шаблоны для всех системных сообщений (ORDER_PURCHASED, ORDER_CONFIRMED, NEW_FEEDBACK, REFUND, PRODUCTS_AMOUNT, EXCHANGE_RATE…). Класс — синглтон, создавать можно свободно.

Раздел 07

Типы данных

Модели из FunPayAPI.types — то, что вы получаете из событий и методов аккаунта.

Message

ПолеТипКомментарий
idintID сообщения.
textstr | NoneNone, если это изображение.
chat_id / chat_nameint|str / strЧат и ник собеседника.
interlocutor_idint | NoneID собеседника.
author / author_idstr / intauthor_id == 0 → системное сообщение FunPay.
typeMessageTypesСм. выше.
by_bot / by_vertexboolОтправлено этим ботом / через Vertex.
image_link / image_namestr | NoneЕсли сообщение — изображение.
badge, is_support, is_moderation, is_arbitration, is_autoreply, is_employeestr / boolБэйджи FunPay (поддержка, модерация, автоответ).
initiator_username / initiator_idstr / intКто совершил действие в системном сообщении.
i_am_seller / i_am_buyerbool | NoneНаша роль в заказе (для системных сообщений).
buyer_viewingBuyerViewingКакой лот сейчас смотрит собеседник.
htmlstrИсходный HTML — на случай, если нужного поля нет.

str(message) возвращает текст сообщения.

ChatShortcut

Виджет чата со страницы /chat/: id, name, last_message_text (≤250 символов), last_message_type, unread, node_msg_id, user_msg_id, last_by_bot, html.

OrderShortcut

Приходит в NewOrderEvent / OrderStatusChangedEvent. Это «карточка» заказа из списка продаж, без деталей лота.

ПолеТип
id (без #), description, htmlstr
price, currency, amountfloat, Currency, int
buyer_username, buyer_id, chat_idstr, int, int|str
statusOrderStatuses: PAID, CLOSED, REFUNDED, PARTIALLY_REFUNDED, UNPAID
datedatetime
subcategory, subcategory_nameSubCategory, str

Order

Полный заказ (account.get_order() / c.get_order_from_object()): status, subcategory, fields, sum, currency, amount, buyer_id/username, seller_id/username, chat_id, review, player, server, side, order_secrets.

Полезные свойства: title, short_description, full_description, payment_msg, lot_params, lot_params_text, lot_params_dict, character_name, а также get_field(key) / get_field_value(key).

Review: stars, text, reply, anonymous, hidden, html.

Лоты: LotShortcut, MyLotShortcut, LotFields

  • LotShortcut — лот в профиле/выдаче: id, server, side, description (он же title), amount, price, currency, subcategory, auto (автовыдача FunPay), public_link; для лотов из таблицы поиска ещё seller, promo, attributes.
  • MyLotShortcut — свой лот из get_my_subcategory_lots(), с состоянием активности.
  • LotFields — форма редактирования лота. Ключевое для изменения лотов из плагина.

LotFields: title_ru/title_en, description_ru/en, payment_msg_ru/en, price, amount, active, deactivate_after_sale, images, auto_delivery, secrets, subcategory, currency, public_link, private_link, calc_result.

renew_fields() перед сохранением

Свойства экземпляра и словарь полей, который уходит на FunPay, — это разные вещи. После изменения любого свойства нужно вызвать renew_fields(), иначе уйдут старые значения.

fields = c.account.get_lot_fields(lot_id)
fields.price = round(fields.price * 0.95, 2)
fields.active = True
c.account.save_lot(fields.renew_fields())

UserProfile, Category, SubCategory

profile = c.account.get_user(c.account.id)
profile.get_lots()                    # list[LotShortcut]
profile.get_lot(lot_id)               # LotShortcut | None
profile.get_sorted_lots(1)            # {lot_id: LotShortcut}
profile.get_sorted_lots(2)            # {SubCategory: {lot_id: LotShortcut}}
profile.get_sorted_lots(3)            # {SubCategoryTypes: {lot_id: LotShortcut}}
profile.get_common_lots(); profile.get_currency_lots()

SubCategory: id, name, type (COMMON / CURRENCY), category, fullname, свойства is_lots, is_chips, is_currency, метод telegram_text(). Category: id, name, position, get_subcategories().

Balance, Currency, Wallet

Balance: total_rub, available_rub, total_usd, available_usd, total_eur, available_eur. CurrencyRUB/USD/EUR/UNKNOWN, str(Currency.RUB), .coderub. Wallet (enum) — направления вывода: QIWI, BINANCE, TRC, CARD_RUB, CARD_USD, CARD_EUR, WEBMONEY, YOUMONEY.

Раздел 08

Account API

cardinal.account — прямые запросы к FunPay. Каждый вызов = HTTP-запрос, поэтому кэшируйте и не спамьте.

Осторожно с частотой

FunPay отдаёт 429 и временные баны за частые запросы. Никогда не делайте запросы в цикле без пауз, не вызывайте get_user() на каждое сообщение и не поднимайте лоты чаще, чем разрешает сайт. При RequestFailedError со статусом 503/403/429 ядро само ждёт до 60 секунд — придерживайтесь той же логики.

Чаты и сообщения

send_message(chat_id, text=None, chat_name=None, interlocutor_id=None, image_id=None, add_to_ignore_list=True, update_last_saved_message=False, leave_as_unread=False) → Message
send_image(chat_id, image: int | str | IO[bytes], chat_name=None, …) → Message
upload_image(image: str | IO[bytes], type_: "chat" | "offer") → int
get_chat_history(chat_id, last_message_id=None, interlocutor_username=None, from_id=0) → list[Message]
get_chats(update=False) → dict[int, ChatShortcut]  ·  request_chats() → list[ChatShortcut]
get_chat(chat_id, with_history=True) → Chat  ·  get_chat_by_name(name, make_request=False)  ·  get_chat_by_id(chat_id, make_request=False)

Для отправки в чат из плагина предпочитайте cardinal.send_message() — он разбивает текст и ретраит. Прямой account.send_message() нужен, когда водяной знак и разбиение мешают.

Заказы

get_order(order_id, include_details=True, …) → Order  ·  get_order_shortcut(order_id) → OrderShortcut
get_orders_by_ids(*order_ids, …) → dict[str, Order] — до 10 заказов за раз
get_sales(start_from=None, include_paid=True, include_closed=True, include_refunded=True, exclude_ids=None, id=None, buyer=None, state=None, game=None, section=None, server=None, side=None, …) → tuple[next_id, list[OrderShortcut], locale, subcategories]
refund(order_id)  ·  send_review(order_id, text, rating=5) → str  ·  delete_review(order_id) → str

get_sales() отдаёт страницу продаж; для обхода всех страниц передавайте полученный next_id в start_from, пока он не станет None. Между страницами обязательно делайте паузу.

Лоты и категории

get_lot_fields(lot_id) → LotFields  ·  save_lot(lot_fields)  ·  delete_lot(lot_id)
get_my_subcategory_lots(subcategory_id) → list[MyLotShortcut]
get_subcategory_public_lots(subcategory_type, subcategory_id) → list[LotShortcut] — чужие лоты, основа для демпинга
get_lot_page(lot_id) → LotPage  ·  get_chip_fields(subcategory_id) → ChipFields  ·  save_chip(chip_fields)
raise_lots(category_id, subcategories=None, exclude=None) → bool  ·  get_raise_modal(category_id) → dict
calc(subcategory_type, subcategory_id=None, game_id=None, price=1000) → CalcResult — комиссия и способы оплаты
categories / subcategories / get_sorted_categories() / get_sorted_subcategories() / get_category(id) / get_subcategory(type, id)

Аккаунт, баланс, вывод

get(update_phpsessid=False) → Account  ·  is_initiated → bool  ·  logout()
get_balance(lot_id) → Balance  ·  get_exchange_rate(currency) → (float, Currency)
get_wallets() → list[Wallet]  ·  save_wallets(wallets)  ·  withdraw(currency, wallet, amount, address) → float
get_user(user_id) → UserProfile  ·  get_buyer_viewing(buyer_id)  ·  get_buyers_viewing(*ids)

Поля аккаунта: id, username, active_sales, active_purchases, currency, total_balance, csrf_token, phpsessid, golden_key, locale, proxy.

Свой запрос к FunPay

Для нестандартных страниц есть низкоуровневый account.method("get"|"post", api_method, headers, payload, raise_not_200=False) — он сам подставит куки, user-agent, прокси и обработает локаль.

Раздел 09

Автовыдача

Хуки PRE_DELIVERY / POST_DELIVERY позволяют подменить товар, дополнить сообщение или отреагировать на результат.

Цепочка при новом заказе выглядит так:

NEW_ORDER
 ├─ log_new_order_handler
 ├─ setup_event_attributes_handler   # ищет лот в конфиге автовыдачи, вешает атрибуты на event
 ├─ send_new_order_notification_handler
 └─ deliver_product_handler
      ├─ проверки: autoDelivery, ЧС, disable у лота
      ├─ ▶ PRE_DELIVERY
      ├─ deliver_goods()             # достаёт товар из файла и шлёт покупателю
      └─ ▶ POST_DELIVERY

Атрибуты, которые ядро вешает на NewOrderEvent

АтрибутКогда заполненСмысл
config_section_nameNEW_ORDERНазвание секции лота в auto_delivery.cfg или None.
config_section_objNEW_ORDERSectionProxy с настройками лота (response, productsFileName, disable, disableMultiDelivery).
lot_id / lot_shortcutNEW_ORDERНайденный лот в вашем профиле.
deliveredPOST_DELIVERYТовар отправлен.
delivery_textPOST_DELIVERYИтоговый текст выдачи.
goods_delivered / goods_leftPOST_DELIVERYСколько выдано / осталось в файле (-1 = бесконечно).
error / error_textPOST_DELIVERY1 и текст, если выдача не удалась.

Читайте их через getattr(e, "delivered", False) — на «чужих» событиях (например, при тесте автовыдачи) атрибутов может не быть.

Пример: подменить текст выдачи

config_section_obj — это живой конфиг, а не копия

e.config_section_objSectionProxy из cardinal.AD_CFG. Присвоение в cfg["response"] меняет настройку лота в памяти навсегда: при следующем заказе вы допишете приписку ещё раз, потом ещё — и покупатель получит её десять раз подряд. Если подменяете текст, сохраните оригинал в PRE_DELIVERY и верните его в POST_DELIVERY. Заказы обрабатываются последовательно, поэтому такой паре ничего не мешает.

_ORIGINAL = {}

def pre_delivery(c: Cardinal, e: NewOrderEvent):
    cfg = getattr(e, "config_section_obj", None)
    name = getattr(e, "config_section_name", None)
    if cfg is None or name is None:
        return
    _ORIGINAL[name] = cfg["response"]                  # запомнили оригинал
    cfg["response"] = cfg["response"] + "\n\nПромокод на след. покупку: " + issue_promo()


def restore(c: Cardinal, e: NewOrderEvent):
    name = getattr(e, "config_section_name", None)
    if name in _ORIGINAL:
        c.AD_CFG[name]["response"] = _ORIGINAL.pop(name)   # вернули как было


def post_delivery(c: Cardinal, e: NewOrderEvent):
    if getattr(e, "error", 0):
        c.telegram and c.telegram.send_notification(
            f"❌ Не выдал заказ {e.order.id}: {getattr(e, 'error_text', '')}")
        return
    if (left := getattr(e, "goods_left", -1)) != -1 and left < 5:
        c.telegram and c.telegram.send_notification(f"⚠️ Товара осталось: {left}")


BIND_TO_PRE_DELIVERY = [pre_delivery]
BIND_TO_POST_DELIVERY = [restore, post_delivery]      # восстановление — первым

Собственная выдача без конфига FPC

Если лота нет в auto_delivery.cfg, штатная выдача не запустится и хуки PRE/POST не сработают. Тогда работайте прямо в BIND_TO_NEW_ORDER:

def on_new_order(c: Cardinal, e: NewOrderEvent):
    if "Мой особый лот" not in e.order.description:
        return
    order = c.get_order_from_object(e.order)          # полные данные, если нужны
    chat_id = (chat := c.account.get_chat_by_name(e.order.buyer_username)) and chat.id or e.order.chat_id
    for _ in range(e.order.amount or 1):
        c.send_message(chat_id, issue_key(), e.order.buyer_username)

Подстановки в текстах

Чтобы плагин вёл себя так же, как встроенная автовыдача, прогоняйте свои шаблоны через те же функции.

ФункцияПеременные
cardinal_tools.format_order_text(text, order)
принимает OrderShortcut или Order
$username, $order_id, $order_link, $order_desc, $order_title, $order_params, $order_desc_and_params, $order_desc_or_params, $category, $category_fullname, $game, $date, $date_text, $full_date_text, $time, $full_time
cardinal_tools.format_msg_text(text, obj)
принимает Message или ChatShortcut
$username, $message_text, $chat_id, $chat_name, $date, $date_text, $full_date_text, $time, $full_time

$product в этот список не входит: его подставляет сама автовыдача в deliver_goods(), уже после форматирования, вместо взятых из товарного файла строк. А $photo=ID, $sleep=СЕК и $new — не переменные, а разметка сообщения, которую разбирает cardinal.send_message() (см. Объект Cardinal).

Обратите внимание: $username и $chat_name подставляются через safe_text() — между символами вставляется невидимый разделитель, чтобы ник не превратился в ссылку-упоминание.

Раздел 10

Telegram-панель

cardinal.telegram — обёртка TGBot над pyTelegramBotAPI. Внутри — обычный telebot.TeleBot в cardinal.telegram.bot с parse_mode="HTML".

Telegram может быть выключен

Первой строкой любой инициализации: if not cardinal.telegram: return. Иначе плагин упадёт у всех, кто не подключил ПУ.

Регистрация хэндлеров

tg.msg_handler(handler, **kwargs) — kwargs идут в telebot: commands=[…], content_types=[…], func=…
tg.cbq_handler(handler, func: Callable[[CallbackQuery], bool], **kwargs)
tg.mdw_handler(handler, **kwargs) — middleware
tg.file_handler(state: str, handler) — приём документа/фото в заданном состоянии

Все три обёртки ловят исключения внутри вашего хэндлера и пишут их в лог, так что бот не падает.

def init(cardinal: Cardinal):
    if not cardinal.telegram:
        return
    tg, bot = cardinal.telegram, cardinal.telegram.bot

    def cmd_stats(m: Message):
        bot.reply_to(m, f"Заказов обработано: <b>{COUNTER}</b>")

    def cb_reset(c: CallbackQuery):
        global COUNTER
        COUNTER = 0
        bot.answer_callback_query(c.id, "Сброшено", show_alert=False)

    tg.msg_handler(cmd_stats, commands=["mystats"])
    tg.cbq_handler(cb_reset, lambda c: c.data == f"{CBT_PREFIX}:reset")
    cardinal.add_telegram_commands(UUID, [("mystats", "статистика плагина", True)])


BIND_TO_PRE_INIT = [init]
Уникальный префикс callback_data

Все плагины и само ядро живут в одном telebot. Фильтры проверяются по очереди, и слишком широкий фильтр (lambda c: "switch" in c.data) перехватит чужие кнопки. Всегда начинайте callback_data с уникального префикса, например f"MyPlugin_{UUID[:8]}", и сравнивайте через startswith. Помните про лимит Telegram: 64 байта на callback_data.

Состояния и ввод текста

Правильный способ «спросить у пользователя значение» — механизм состояний FPC, а не register_next_step_handler: состояния видит вся ПУ, есть кнопка «Отмена», и чужой ввод не перехватывается.

tg.set_state(chat_id, message_id, user_id, state: str, data: dict | None = None)
tg.get_state(chat_id, user_id) → dict | None  ·  tg.check_state(chat_id, user_id, state) → bool  ·  tg.clear_state(chat_id, user_id, del_msg=False)
STATE_PRICE = f"{CBT_PREFIX}_wait_price"

def act_set_price(c: CallbackQuery):
    msg = bot.send_message(c.message.chat.id, "Введите новую цену:",
                           reply_markup=CLEAR_STATE_BTN())
    tg.set_state(c.message.chat.id, msg.id, c.from_user.id, STATE_PRICE, {"lot": 123})
    bot.answer_callback_query(c.id)

def set_price(m: Message):
    data = tg.get_state(m.chat.id, m.from_user.id)["data"]
    tg.clear_state(m.chat.id, m.from_user.id, True)
    try:
        price = float(m.text.replace(",", "."))
    except ValueError:
        bot.reply_to(m, "Нужно число, например 149.90")
        return
    SETTINGS["price"] = price; save_config()
    bot.reply_to(m, "✅ Цена обновлена")

tg.cbq_handler(act_set_price, lambda c: c.data == f"{CBT_PREFIX}:setprice")
tg.msg_handler(set_price, func=lambda m: tg.check_state(m.chat.id, m.from_user.id, STATE_PRICE))

Кнопка «Отмена» — готовая: from tg_bot.static_keyboards import CLEAR_STATE_BTN (callback CBT.CLEAR_STATE сбрасывает состояние сам).

Чтобы принять файл, зарегистрируйте tg.file_handler(STATE_X, handler) — он вызовется, когда пользователь в состоянии STATE_X пришлёт document или photo.

Уведомления

tg.send_notification(text, keyboard=None, notification_type=NotificationTypes.other, photo=None, pin=False)

Рассылает во все чаты, где включён этот тип уведомлений. Типы (tg_bot.utils.NotificationTypes): bot_start, new_message, command, new_order, order_confirmed, review, lots_restore, lots_deactivate, delivery, lots_raise, other, announcement, ad, critical, important_announcement.

Для плагинов — тип other

Он и стоит по умолчанию. Не используйте critical / important_announcement: их нельзя отключить, и пользователь не сможет заглушить ваш плагин. Тяжёлые уведомления шлите из отдельного потока — send_notification ходит по всем чатам подряд:

from threading import Thread
Thread(target=c.telegram.send_notification, args=(text,), daemon=True).start()

HTML и экранирование

Бот работает в parse_mode="HTML". Любой текст от пользователя или из FunPay (ники, описания лотов) экранируйте, иначе сообщение не отправится:

from tg_bot import utils
bot.send_message(chat_id, f"Покупатель: <b>{utils.escape(order.buyer_username)}</b>")

Полезное из tg_bot.utils и keyboards

  • utils.escape(text), utils.bool_to_text(v, on="🟢", off="🔴"), utils.split_by_limit(list, 4096);
  • utils.add_navigation_buttons(keyboard, curr_offset, max_on_page, elements_on_page, elements_amount, callback_text, extra=None) — готовая пагинация;
  • utils.get_offset(index, max_on_page);
  • keyboards.* — клавиатуры ядра, static_keyboards — постоянные кнопки.

Авторизация и доступ

Доступ к ПУ получают только пользователи, приславшие боту секретный ключ; их список лежит в tg.authorized_users ({user_id: {...}}) и сохраняется между запусками.

Ваши хэндлеры уже защищены

Ядро регистрирует фильтры reg_admin (для сообщений) и ignore_unauthorized_users (для callback'ов) в TGBot.init(), то есть до выполнения BIND_TO_PRE_INIT. telebot проверяет хэндлеры в порядке регистрации и останавливается на первом подошедшем, поэтому апдейты от неавторизованных пользователей до плагинов не доходят.

Дополнительная проверка нужна, только если вы хотите ограничить доступ ещё сильнее — например, разрешить команду одному конкретному администратору:

OWNER_ID = 123456789
tg.msg_handler(cmd_danger, commands=["wipe"], func=lambda m: m.from_user.id == OWNER_ID)

Учтите: авторизация выдаётся пользователю, а уведомления настраиваются на чат. В групповом чате ПУ команду может вызвать любой авторизованный участник.

Ограничения Telegram API

ОграничениеЗначениеЧто делать
Длина сообщения4096 символовutils.split_by_limit(lines, 4096) и слать частями
Подпись к фото1024 символаДлинный текст — отдельным сообщением
callback_data64 байтаКириллица — 2 байта на символ; храните индексы, а не текст
Кнопок в ряду8 (разумно 3–5)K(row_width=2), .row(...)
Частота отправки~30 сообщений/сек, ~20/мин в группуМассовые рассылки — с паузами

Два исключения, которые встречаются постоянно:

from telebot.apihelper import ApiTelegramException

try:
    bot.edit_message_text(text, chat_id, message_id, reply_markup=kb)
except ApiTelegramException as ex:
    if "message is not modified" not in str(ex):   # текст и клавиатура не изменились
        raise

Второе — 403 Forbidden: bot was blocked by the user: чат надо убирать из своих рассылок. Встроенный send_notification() делает это сам, ваши собственные рассылки должны так же.

Приём файлов

STATE_FILE = f"{CBT_PREFIX}_wait_file"

def act_upload(c: CallbackQuery):
    msg = bot.send_message(c.message.chat.id, "Пришлите .txt файл", reply_markup=CLEAR_STATE_BTN())
    tg.set_state(msg.chat.id, msg.id, c.from_user.id, STATE_FILE)
    bot.answer_callback_query(c.id)

def upload(m: Message):
    tg.clear_state(m.chat.id, m.from_user.id, True)
    if not m.document or not m.document.file_name.endswith(".txt"):
        bot.reply_to(m, "Нужен файл .txt")
        return
    data = bot.download_file(bot.get_file(m.document.file_id).file_path)
    with open("storage/plugins/myplugin_data.txt", "wb") as f:
        f.write(data)
    bot.reply_to(m, f"✅ Принято, {len(data)} байт")

tg.cbq_handler(act_upload, lambda c: c.data == f"{CBT_PREFIX}:upload")
tg.file_handler(STATE_FILE, upload)     # именно file_handler, не msg_handler

Локализация текстов плагина

ПУ переведена на русский, английский и украинский. В Localizer есть два метода специально для плагинов — тексты хранятся с префиксом UUID, поэтому не конфликтуют с ключами ядра и других плагинов.

from locales.localizer import Localizer

localizer = Localizer()

for lang, texts in {
    "ru": {"greet": "Привет, {}!", "saved": "Сохранено"},
    "en": {"greet": "Hi, {}!",     "saved": "Saved"},
    "uk": {"greet": "Привіт, {}!", "saved": "Збережено"},
}.items():
    for key, value in texts.items():
        localizer.add_translation(UUID, key, value, lang)


def _p(key: str, *args) -> str:
    return localizer.plugin_translate(UUID, key, *args)

bot.send_message(chat_id, _p("greet", username))

Регистрируйте переводы в BIND_TO_PRE_INIT. translate() при отсутствии ключа возвращает само имя ключа — если в интерфейсе появился myplugin_greet, значит перевод не зарегистрирован. Язык берётся из настроек FPC; отдельным аргументом language= можно принудительно выбрать другой.

Описания команд в add_telegram_commands() тоже прогоняются через локализатор, поэтому туда можно передавать как готовый текст, так и ключ перевода.

Раздел 11

Страница настроек плагина

Поставьте SETTINGS_PAGE = True — и в карточке плагина появится кнопка «Настройки». Нажатие шлёт callback CBT.PLUGIN_SETTINGS:UUID:offset, обработать его — ваша задача.

from tg_bot import CBT
from telebot.types import InlineKeyboardMarkup as K, InlineKeyboardButton as B, CallbackQuery

CBT_PREFIX = "MyPlugin"          # уникальный префикс своих callback'ов

def open_settings(c: CallbackQuery):
    kb = K()
    kb.add(B(f"Автоответ: {'🟢' if SETTINGS['enabled'] else '🔴'}",
             callback_data=f"{CBT_PREFIX}:toggle:enabled"))
    kb.add(B(f"Задержка: {SETTINGS['delay']} c", callback_data=f"{CBT_PREFIX}:set:delay"))
    kb.add(B("◀️ Назад", callback_data=f"{CBT.EDIT_PLUGIN}:{UUID}:0"))
    bot.edit_message_text("⚙️ Настройки MyPlugin", c.message.chat.id, c.message.id, reply_markup=kb)
    bot.answer_callback_query(c.id)

def toggle(c: CallbackQuery):
    key = c.data.split(":")[2]
    SETTINGS[key] = not SETTINGS[key]
    save_config()
    open_settings(c)

tg.cbq_handler(open_settings, lambda c: c.data.startswith(f"{CBT.PLUGIN_SETTINGS}:{UUID}"))
tg.cbq_handler(toggle, lambda c: c.data.startswith(f"{CBT_PREFIX}:toggle:"))
Куда ведёт «Назад»

CBT.EDIT_PLUGIN:{UUID}:{offset} — карточка плагина. CBT.PLUGINS_LIST:{offset} — список плагинов. offset — позиция прокрутки списка; если не следите за ним, передавайте 0.

Полезные константы CBT

КонстантаЧто делает
CBT.PLUGIN_SETTINGSОткрыть настройки плагина: …:UUID:offset.
CBT.EDIT_PLUGINКарточка плагина.
CBT.PLUGINS_LISTСписок плагинов.
CBT.CLEAR_STATEСбросить состояние пользователя (кнопка «Отмена»).
CBT.EMPTYКнопка-заглушка, ничего не делает.
CBT.MAIN, CBT.CATEGORY, CBT.SWITCHМеню настроек ядра и переключение параметров конфига.
CBT.SEND_FP_MESSAGEОтвет в чат FunPay из Telegram: …:node_id:username.

Удаление плагина

def on_delete(cardinal: Cardinal, call: CallbackQuery):
    """Вызывается перед удалением файла плагина через ПУ."""
    STOP_EVENT.set()
    if os.path.exists("storage/plugins/myplugin.json"):
        os.remove("storage/plugins/myplugin.json")

BIND_TO_DELETE = on_delete

Раздел 12

Хранение данных

У плагинов есть своя папка: storage/plugins/. Всё остальное в storage/ принадлежит ядру.

ПутьЧто там
storage/plugins/Ваши файлы. Обычно один JSON на плагин.
storage/cache/Кэш ядра: ЧС, отключённые/закреплённые плагины, прокси, «старые» пользователи.
storage/products/Файлы товаров автовыдачи.
configs/_main.cfg, auto_response.cfg, auto_delivery.cfg.
logs/Логи FPC.

Шаблон конфига плагина

import json, os

CFG_PATH = "storage/plugins/myplugin.json"
DEFAULTS = {"enabled": True, "delay": 30, "text": "Спасибо за покупку!"}
SETTINGS = dict(DEFAULTS)


def load_config():
    global SETTINGS
    data = {}
    if os.path.exists(CFG_PATH):
        try:
            with open(CFG_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
        except (json.JSONDecodeError, OSError):
            logger.warning("Битый конфиг, беру значения по умолчанию")
    SETTINGS = {**DEFAULTS, **data}     # новые ключи добавятся при обновлении плагина
    save_config()


def save_config():
    os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True)
    with open(CFG_PATH, "w", encoding="utf-8") as f:
        json.dump(SETTINGS, f, indent=4, ensure_ascii=False)
Совместимость при обновлениях

Пользователи переустанавливают плагины поверх старых. Всегда мерджите загруженный конфиг с DEFAULTS (как выше) и не удаляйте старые ключи молча — иначе после обновления настройки слетят. При выпуске версии, ломающей формат, меняйте имя файла или добавляйте поле "version" и мигрируйте.

Утилиты ядра (Utils.cardinal_tools)

  • load_blacklist() / cache_blacklist(list) — чёрный список;
  • get_products(path, amount) / add_products(path, products, at_zero_position=False) / count_products(path) — работа с файлами товаров;
  • format_order_text(text, order) / format_msg_text(text, obj) — подстановки переменных;
  • time_to_str(seconds), get_month_name(n), safe_text(text);
  • hash_password(p) / check_password(p, hashed);
  • restart_program(), shut_down() — использовать очень аккуратно.

Раздел 13

Исключения

FunPayAPI.common.exceptions. Все сетевые ошибки наследуют RequestFailedError, у которого есть status_code, url, response и короткое описание short_str().

ИсключениеКогда
AccountNotInitiatedErrorМетод аккаунта вызван до account.get() (например, из кода верхнего уровня плагина).
UnauthorizedErrorПротух golden_key.
RequestFailedErrorОтвет не 200. Смотрите status_code: 429 — слишком часто, 503/403 — защита FunPay.
MessageNotDeliveredErrorСообщение не ушло (error_message, chat_id).
RaiseErrorНе удалось поднять лоты; wait_time — сколько ждать.
LotParsingError / LotSavingErrorОшибка чтения/сохранения лота; в errors — ошибки по полям.
ImageUploadError, FeedbackEditingError, RefundError, WithdrawErrorСоответствующие операции.
import FunPayAPI

try:
    c.account.raise_lots(category_id)
except FunPayAPI.exceptions.RaiseError as ex:
    wait = ex.wait_time or 60
    logger.warning(f"Поднятие не удалось: {ex.short_str()}, жду {wait} с")
except FunPayAPI.exceptions.RequestFailedError as ex:
    if ex.status_code in (429, 503, 403):
        time.sleep(60)
    logger.debug("TRACEBACK", exc_info=True)

Раздел 14

Сторонние библиотеки

Плагин — один файл, положить рядом requirements.txt некуда. Поэтому зависимости плагин ставит сам, в момент загрузки. Здесь важен и способ установки, и то, каким интерпретатором она выполняется — ошибка в любом из двух пунктов приводит к «библиотека вроде поставилась, а импорт всё равно падает».

Что уже установлено

Прежде чем что-то ставить, проверьте — возможно, нужное уже есть. В окружении FunPay Cardinal всегда доступны:

ПакетВерсия в FPCИмпорт
requests2.28.1import requests
requests_toolbelt0.10.1используется ядром
pytelegrambotapi4.15.2import telebot
beautifulsoup4 + lxml≥4.11.1 / ≥5.3.0from bs4 import BeautifulSoup
pillow≥9.3.0from PIL import Image
psutil, colorama, bcrypt, pysocksпо имени пакета

Плюс вся стандартная библиотека Python. Задачи вроде HTTP-запросов, парсинга HTML, работы с картинками, JSON, SQLite, потоками и планировщиком решаются без единой новой зависимости — это всегда предпочтительный вариант.

Как ставить правильно

Рабочий способ ровно один: вызвать pip тем же интерпретатором, под которым запущен бот, отдельным процессом, и обязательно ограничить urllib3.

Канонический блок установки — в самом начале файла плагина
import os
import sys
import tempfile
import importlib
import subprocess

# FunPayCardinal использует requests_toolbelt 0.10.1, который импортирует
# urllib3.contrib.appengine. В urllib3 2.x этого модуля нет. Если pip при
# установке зависимостей плагина подтянет urllib3 2.x, бот перестанет
# запускаться вообще — ImportError на старте, до всех плагинов.
# Constraints-файл запрещает pip трогать urllib3 при любой установке.
_CONSTRAINTS = os.path.join(tempfile.gettempdir(), "myplugin_pip_constraints.txt")
try:
    with open(_CONSTRAINTS, "w", encoding="utf-8") as f:
        f.write("urllib3<2\n")
except OSError:
    _CONSTRAINTS = None


def _ensure(module: str, package: str | None = None) -> bool:
    """Импортирует module, при необходимости ставит package."""
    try:
        importlib.import_module(module)
        return True
    except ImportError:
        pass

    cmd = [sys.executable, "-m", "pip", "install", package or module, "-q"]
    if _CONSTRAINTS:
        cmd += ["-c", _CONSTRAINTS]
    try:
        subprocess.check_call(cmd, stdout=subprocess.DEVNULL,
                              stderr=subprocess.DEVNULL, timeout=300)
    except Exception:
        return False

    importlib.invalidate_caches()          # иначе свежий пакет не найдётся
    try:
        importlib.import_module(module)
        return True
    except ImportError:
        return False


for _mod, _pkg in (("bs4", "beautifulsoup4"), ("pytz", "pytz"), ("yarl", "yarl")):
    _ensure(_mod, _pkg)

Разберём, почему каждая строка именно такая.

sys.executable, а не pip

Команда pip install ставит пакет в то окружение, на которое настроен исполняемый файл pip, а бот может работать под другим Python: venv из install-fpc.sh, portable-сборка на Windows, python3.11 при системном python3.9. Установка формально завершится успехом, а импорт всё равно упадёт. [sys.executable, "-m", "pip", …] гарантирует то же окружение, в котором крутится бот.

Имя модуля ≠ имя пакета

Проверять надо импортом модуля, а ставить — по имени дистрибутива. Они часто различаются: bs4beautifulsoup4, PILpillow, telebotpyTelegramBotAPI, dateutilpython-dateutil, cv2opencv-python, yamlPyYAML. Если проверять по имени пакета, установка будет запускаться при каждом старте бота.

Constraints-файл вместо аргумента "urllib3<2"

Главная причина, по которой бот перестаёт запускаться после установки плагина

Любой пакет, зависящий от свежего requests, тянет за собой urllib3 2.x. После этого падает уже само ядро — requests_toolbelt импортирует удалённый в urllib3 2.0 модуль urllib3.contrib.appengine. Бот не стартует, и внешне это выглядит как «плагин сломал всё».

Передача -c constraints.txt запрещает pip повышать urllib3 при разрешении зависимостей любой глубины. Простое добавление "urllib3<2" в список пакетов слабее: оно фиксирует только прямую установку, а транзитивное обновление всё равно может произойти.

importlib.invalidate_caches() после установки

Python кэширует содержимое директорий sys.path. Если процесс уже пытался импортировать модуль и не нашёл его, то сразу после pip install импорт в том же процессе снова провалится — интерпретатор возьмёт ответ из кэша. invalidate_caches() сбрасывает его. Без этой строки установка «не срабатывает» до перезапуска бота.

Таймаут и подавление вывода

pip может уйти в долгую сборку из исходников или зависнуть на сети. Загрузка плагинов синхронна: пока pip работает, бот не стартует. timeout=300 ограничивает ожидание, stdout/stderr=DEVNULL не даёт pip засорить консоль FPC (его вывод не проходит через логгер и ломает читаемость лога).

Не используйте pip._internal

В старых плагинах встречается from pip._internal.cli.main import main и затем main(["install", pkg]). Так делать не стоит: это приватный API pip, он меняется между версиями, выполняется внутри процесса бота (при ошибке может вызвать SystemExit и уронить загрузку), не умеет таймаута и оставляет за собой изменённое глобальное состояние. Внешний процесс через subprocess надёжнее.

Порядок установки: зависимости раньше основного пакета

Для «тяжёлых» пакетов с большим деревом зависимостей одна команда pip install X часто ведёт себя хуже, чем несколько последовательных: сначала ставятся зависимости, по одной, и только в конце — сам пакет.

Пример: tonutils (транспорт — aiohttp, ячейки — ton-core)
# Порядок — снизу вверх по дереву зависимостей: сначала листья,
# потом то, что их использует, и в самом конце — основной пакет.
_DEPS = (                          # (пакет для pip, модуль для проверки)
    ("attrs", "attr"),             # нужен aiohttp
    ("multidict", "multidict"),    # нужен yarl / aiohttp
    ("propcache", "propcache"),    # нужен yarl / aiohttp
    ("frozenlist", "frozenlist"),  # нужен aiosignal / aiohttp
    ("aiohappyeyeballs", "aiohappyeyeballs"),
    ("aiosignal", "aiosignal"),    # требует frozenlist
    ("yarl", "yarl"),              # требует multidict, propcache
    ("aiohttp", "aiohttp"),        # транспорт
    ("ton-core", "ton_core"),      # слой ячеек/BOC
)

for _pkg, _mod in _DEPS:
    _ensure(_mod, _pkg)            # каждая зависимость — отдельным вызовом pip

_ensure("tonutils", "tonutils")    # основной пакет — последним

Что этот приём реально даёт

  • Установка перестаёт быть «всё или ничего». pip install tonutils — одна транзакция: если хотя бы одна транзитивная зависимость не собралась, pip откатывает всё и не ставит ничего, даже те десять пакетов, что уже скачались. По одной — прогресс сохраняется, и повторный запуск доделывает остаток.
  • Видно, что именно упало. В общей установке ошибка тонет в длинном выводе резолвера. Поштучно вы получаете точное имя пакета и можете написать его пользователю в отчёте.
  • Резолвер не уходит в перебор. Когда требования не удовлетворены, pip начинает backtracking: качает десятки версий подряд, подбирая совместимый набор. На слабом VPS это минуты ожидания и иногда обрыв по таймауту. Если зависимости уже стоят, при установке основного пакета резолверу нечего перебирать.
  • Меньше шансов упереться в память и таймаут. Пакеты с C-расширениями, для которых нет готового wheel под этот Python, собираются из исходников. Несколько сборок в одной транзакции — это один длинный процесс, который легче упирается в лимит; отдельные вызовы имеют каждый свой timeout.
  • Пропуск уже установленного. Проверив _already(dep), вы не трогаете то, что и так есть в окружении FPC, — а значит, не рискуете его обновить.
Чего этот приём НЕ гарантирует

Предустановка зависимостей не отменяет разрешения версий. Когда дойдёт очередь до основного пакета, pip всё равно сверит требования и, если поставленная вами версия им не подходит, спокойно заменит её на другую — вместе с её собственными зависимостями. Порядок сам по себе не защищает от нежелательного обновления: от этого защищает только constraints-файл, а гарантированно — --no-deps.

Жёсткий вариант: --no-deps

Если список зависимостей выверен и все они уже установлены, ставьте основной пакет с запретом трогать что-либо ещё:

cmd = [sys.executable, "-m", "pip", "install", "--no-deps", "tonutils", "-q"]

Тогда pip физически не сможет обновить urllib3 или подменить requests. Обратная сторона — ответственность за полноту списка на вас: пропустили зависимость, и пакет упадёт уже при импорте. Поэтому --no-deps применяют только к пакетам с проверенным деревом, а сам импорт после установки обязательно проверяют.

Как узнать список зависимостей

python -m pip install --dry-run tonutils    # покажет, что было бы установлено (pip 22.2+)
python -m pip show tonutils                 # поле Requires у уже установленного пакета

Порядок внутри списка составляйте от листьев к корню: сначала пакеты без зависимостей (attrs, multidict), затем те, что их используют (yarl, aiosignal), затем крупные узлы (aiohttp). Для pip такой порядок не обязателен — он в любом случае разберётся, — но при нём каждый шаг ставится «начисто», без резолва.

Аккуратнее с флагом -U

pip install -U X обновляет не только X, но и его зависимости до последних совместимых версий, даже если установленные уже подходят. Именно так в окружение неожиданно приезжает urllib3 2.x. Используйте -U только там, где действительно нужна свежая версия, и обязательно вместе с constraints. По умолчанию — без -U: если пакет уже стоит, pip его не тронет.

Проверка результата и отчёт пользователю

Установка «прошла успешно» и «библиотека работает» — не одно и то же: пакет мог поставиться, а нужный класс появиться только в более новой версии. Поэтому финальный шаг — импорт того, ради чего всё затевалось:

from importlib.metadata import version, PackageNotFoundError

def verify() -> tuple[list[str], bool]:
    lines, ok_all = [], True
    try:
        lines.append(f"📦 tonutils {version('tonutils')}")
    except PackageNotFoundError:
        lines.append("📦 tonutils не установлен")
        return lines, False

    importlib.invalidate_caches()
    for module, cls in (("tonutils.contracts.wallet", "WalletV5R1"),
                        ("tonutils.contracts.wallet", "WalletV4R2")):
        try:
            m = importlib.import_module(module)
            ok = hasattr(m, cls)
            lines.append(f"{'🟢' if ok else '🔴'} {cls}")
            ok_all &= ok
        except Exception as e:
            ok_all = False
            lines.append(f"🔴 {cls} — {type(e).__name__}: {str(e)[:60]}")
    return lines, ok_all

Если установка запускается по команде из Telegram, выполняйте её в отдельном потоке, показывайте «⏳ ставлю…» и затем редактируйте сообщение отчётом по каждому пакету. Пользователь видит, какой именно шаг не прошёл, и может доустановить руками.

Когда нет готового wheel

Частая причина отказа — пакет с C-расширениями, для которого нет собранного колеса под конкретную версию Python (особенно на свежих 3.13+ и на нестандартных архитектурах). pip пытается собрать из исходников и падает на отсутствии компилятора. Полезно включать в отчёт версию интерпретатора — sys.version_info — тогда сразу понятно, что дело в окружении, а не в плагине.

Почему установка не срабатывает

СимптомПричинаЧто делать
pip пишет «Successfully installed», импорт падаетПакет ушёл в другое окружениеСтавить через sys.executable -m pip
Импорт падает только на первом запуске, после перезапуска работаетКэш путей импортаimportlib.invalidate_caches() после установки
Бот вообще перестал стартовать после установки плагинаПоднялся urllib3 2.xConstraints с urllib3<2; лечится pip install "urllib3<2"
PermissionError / Access denied при установкеСистемный Python без прав записиПовторить с флагом --user (см. ниже)
Установка каждый раз при стартеПроверяется имя пакета, а не модуляПроверять importlib.import_module("bs4")
Бот «висит» на стартеpip собирает пакет из исходниковtimeout, ленивая установка по требованию
externally-managed-environment (Debian 12+, Ubuntu 24.04)PEP 668Запускать FPC в venv; как крайняя мера — --break-system-packages

Запасной вариант с --user

    try:
        subprocess.check_call(cmd, stdout=subprocess.DEVNULL,
                              stderr=subprocess.DEVNULL, timeout=300)
    except subprocess.CalledProcessError:
        try:                                  # нет прав на системный site-packages
            subprocess.check_call(cmd + ["--user"], stdout=subprocess.DEVNULL,
                                  stderr=subprocess.DEVNULL, timeout=300)
        except Exception:
            return False

Ленивая установка: не тормозить старт бота

Если библиотека нужна не всегда (например, matplotlib только для команды с графиком), не ставьте её при загрузке модуля. Ставьте при первом обращении и корректно сообщайте пользователю, если не вышло:

def ensure_matplotlib() -> bool:
    try:
        import matplotlib  # noqa: F401
        return True
    except ImportError:
        pass
    ok = _ensure("matplotlib")
    if not ok:
        logger.warning(f"{LOGGER_PREFIX} matplotlib не установился")
    return ok


def cmd_chart(m: Message):
    if not ensure_matplotlib():
        bot.reply_to(m, "График недоступен: не удалось установить matplotlib.\n"
                        "Установите вручную: <code>pip install matplotlib \"urllib3<2\"</code>")
        return
    import matplotlib
    matplotlib.use("Agg")            # обязательно: на сервере нет дисплея
    ...

Долгая доустановка в фоне

Компоненты, которые качают сотни мегабайт (браузеры Playwright, модели), запускайте отдельным потоком, чтобы не блокировать загрузку плагинов:

def _ensure_chromium():
    # команда идемпотентна: если браузер уже стоит — завершится мгновенно
    try:
        subprocess.check_call([sys.executable, "-m", "playwright", "install", "chromium"],
                              stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=600)
    except Exception:
        pass

threading.Thread(target=_ensure_chromium, daemon=True).start()
Итоговое правило
  • Сначала проверить, нет ли пакета в списке уже установленных.
  • Ставить только sys.executable -m pip, отдельным процессом, с timeout.
  • Всегда передавать constraints с urllib3<2.
  • Проверять наличие по имени модуля, ставить по имени пакета.
  • После установки — importlib.invalidate_caches() и повторный импорт.
  • Пакет с большим деревом — зависимости по одной и раньше него, при выверенном списке дополнительно --no-deps.
  • Всё, что дольше пары секунд, — лениво или в фоновом потоке.
  • После установки проверять не факт установки, а импорт того, что реально нужно.
  • Не удалось поставить — плагин должен продолжать работать без этой функции и понятно об этом сказать.

Раздел 15

Рекомендации по разработке

Ограничения платформы и типичные причины, по которым плагин мешает работе бота.

Не блокируйте поток событий

Все хэндлеры событий выполняются последовательно в одном цикле. time.sleep(60) внутри хэндлера останавливает обработку всех сообщений и заказов. Всё долгое — в поток:

from threading import Thread

def on_new_order(c: Cardinal, e: NewOrderEvent):
    Thread(target=heavy_work, args=(c, e), daemon=True, name="MyPlugin-order").start()

Фоновые циклы запускайте один раз в BIND_TO_POST_START и обязательно с daemon=True, обёрнутыми в try/except внутри цикла — иначе одна ошибка убьёт поток навсегда.

Прокси

Если у пользователя настроен прокси, ваши собственные HTTP-запросы должны идти через него — иначе FunPay увидит два разных IP:

requests.get(url, proxies=cardinal.proxy or None, timeout=10)
Доменный прокси нельзя писать в _main.cfg

Валидатор конфига принимает только IPv4-адреса; прокси с доменным именем в [Proxy] proxy уронит бота на старте. Такие прокси применяйте программно — присвоением cardinal.account.proxy в BIND_TO_PRE_INIT.

Защитное программирование

  • Проверяйте cardinal.telegram на None перед любым обращением к ПУ.
  • Проверяйте наличие атрибутов события через getattr(e, "…", default): у фейковых событий (тест автовыдачи) их может не быть.
  • Ловите исключения в потоках. Ядро перехватывает ошибки только в хэндлерах, потоки — ваша ответственность.
  • Не бросайте исключения из BIND_TO_PRE_INIT — ошибка будет проглочена, но плагин останется наполовину проинициализированным.
  • Не читайте MAIN_CFG напрямую там, где есть свойство (c.autodelivery_enabled вместо c.MAIN_CFG["FunPay"].getboolean("autoDelivery")).
  • Всегда encoding="utf-8" при работе с файлами: на Windows дефолтная кодировка не UTF-8, и кириллица превратится в мусор.

Ограничения FunPay

  • Сообщение длиннее ~20 строк не отправится целиком — используйте cardinal.send_message(), он режет сам.
  • Слишком частая отправка сообщений разным пользователям вызывает флуд-ошибку; аккаунт помечает время в account.last_flood_err_time / last_multiuser_flood_err_time.
  • Лоты игровой валюты (SubCategoryTypes.CURRENCY) поднимать нельзя — ядро их пропускает, делайте так же.
  • get_user(), get_sales(), get_lot_fields() — тяжёлые запросы. Кэшируйте результат хотя бы на минуту.

Поведение в ПУ

  • Уникальный префикс во всех callback_data, фильтры через startswith, а не in.
  • Всегда вызывайте bot.answer_callback_query(c.id) — иначе у пользователя кнопка «крутится».
  • Экранируйте пользовательский текст через utils.escape().
  • Не используйте bot.register_next_step_handler в плагинах для ПУ: он перехватывает следующее сообщение пользователя глобально и ломает чужие диалоги. Используйте состояния FPC.
  • Логируйте с префиксом плагина: logging.getLogger("FPC.MyPlugin").

Раздел 16

Отладка и диагностика

Ядро проглатывает ошибки плагинов, чтобы бот не падал. Обратная сторона — сломанный плагин ведёт себя как «просто ничего не делает». Вот как выяснять, что произошло.

Плагин не появился в списке

Проверяйте по порядку — это закрывает почти все случаи:

  1. Файл лежит именно в plugins/, расширение .py, не во вложенной папке.
  2. Первая строка не содержит noplug.
  3. Объявлены все семь полей: NAME, VERSION, DESCRIPTION, CREDITS, UUID, SETTINGS_PAGE, BIND_TO_DELETE. Отсутствие любого — FieldNotExistsError.
  4. UUID — валидный UUID4 и не совпадает с другим плагином.
  5. Модуль импортируется без ошибок. Синтаксическая ошибка или падение кода верхнего уровня → в логе Не удалось загрузить плагин.
  6. Бот перезапущен после добавления файла.

Быстрая проверка синтаксиса и импорта, не поднимая бота (из корня FPC, тем же интерпретатором):

python -c "import importlib.util as u, sys; sys.path.insert(0,'plugins'); \
s=u.spec_from_file_location('p','plugins/myplugin.py'); m=u.module_from_spec(s); s.loader.exec_module(m); \
print(m.NAME, m.VERSION, m.UUID)"

Логи

Причина ошибки в хэндлере пишется на уровне ERROR, а полный traceback — только на DEBUG. Поэтому в своих except повторяйте приём ядра:

try:
    risky()
except Exception:
    logger.error(f"{LOGGER_PREFIX} не удалось выполнить операцию")
    logger.debug("TRACEBACK", exc_info=True)
  • Логи пишутся в logs/log.log с ротацией по 20 МБ (до 25 файлов); из Telegram — команды /logs и /del_logs.
  • В консоль идёт только INFO и выше, в файл — DEBUG. Значит logger.debug("TRACEBACK", exc_info=True) на экране не появится, но в файле будет. Traceback всегда ищите в logs/log.log.
  • В LOGGER_CONFIG (Utils/logger.py) настроены логгеры main, FunPayAPI, FPC, TGBot, TeleBot. Имя FPC.МойПлагин наследует настройки FPC — поэтому именно такой префикс и нужен: произвольное имя вроде myplugin в файл не попадёт.
  • Цветовые метки в тексте сообщения: $YELLOW, $CYAN, $MAGENTA, $BLUE, $GREEN, $BLACK, $WHITE, фоновые $B_* и $RESET — возврат к цвету уровня. В файле они автоматически вырезаются.
  • Не пишите в лог golden_key, токены, пароли и содержимое товаров: пользователи отправляют логи в поддержку.

Как воспроизвести событие, не дожидаясь его

Ждать реального заказа ради проверки хэндлера не нужно. Есть два способа.

Штатный тест автовыдачи — команда /test_lot в ПУ. Ядро собирает OrderShortcut с ID ADTEST и прогоняет через него всю цепочку NEW_ORDER, включая ваши хэндлеры. Учтите: у такого заказа нет реальных данных, get_order_from_object() вернёт None.

Собственный тестовый прогон — временная команда в ПУ, которая берёт настоящий заказ и вызывает ваш обработчик:

from FunPayAPI.common.utils import random_tag
from FunPayAPI.updater.events import NewOrderEvent

def cmd_test(m: Message):
    order_id = m.text.split(maxsplit=1)[1]           # /mytest ABCD1234
    shortcut = cardinal.account.get_order_shortcut(order_id)
    event = NewOrderEvent(random_tag(), shortcut)
    cardinal.run_handlers(cardinal.new_order_handlers, (cardinal, event))
    bot.reply_to(m, "Прогнал цепочку NEW_ORDER")

Так же тестируются сообщения: NewMessageEvent(tag, message_obj), где message_obj берётся из account.get_chat_history(chat_id)[-1]. Тег генерируется через FunPayAPI.common.utils.random_tag().

Симптомы и причины

Что видноОбычная причина
Плагин в списке, но хэндлер не срабатываетФункция не попала в BIND_TO_*; список объявлен выше самой функции; плагин выключен в ПУ
Работает на одном аккаунте, молчит на другомРазные oldMsgGetMode: нужен хэндлер и на NEW_MESSAGE, и на LAST_CHAT_MESSAGE_CHANGED
Кнопка в ПУ «крутится» и ничего не происходитЗабыт bot.answer_callback_query(c.id)
Чужая кнопка открывает ваше менюСлишком широкий фильтр callback'а (in вместо startswith и без префикса)
Сообщение в Telegram не отправляетсяНеэкранированный HTML в тексте — нужен utils.escape()
Бот перестал реагировать на всёБлокирующая операция в хэндлере события — весь цикл встал
Сообщение в чат FunPay уходит обрезаннымОтправка через account.send_message() без разбиения; нужен cardinal.send_message()
Хэндлер сработал дваждыФункция добавлена в два списка BIND_TO_*, срабатывающих на одно событие
После обновления плагина слетели настройкиКонфиг не смерджен с DEFAULTS

Перед отдачей плагина пользователю

  • Запустить бота с плагином «с нуля»: без файла конфига, без установленных зависимостей.
  • Проверить работу при выключенном Telegram ([Telegram] enabled = 0).
  • Выключить плагин в ПУ и убедиться, что он действительно перестал действовать.
  • Удалить плагин через ПУ и проверить, что BIND_TO_DELETE прибрал за собой.
  • Прогнать основной сценарий дважды подряд — ловятся ошибки состояния и повторной инициализации.

Раздел 17

Типовые задачи

Готовые фрагменты кода под самые частые сценарии.

Команда в чате FunPay

def on_message(c: Cardinal, e: NewMessageEvent):
    m = e.message
    if m.author_id in (0, c.account.id) or m.by_bot:
        return
    if (m.text or "").strip().lower() == "!сколько":
        left = cardinal_tools.count_products("storage/products/keys.txt")
        c.send_message(m.chat_id, f"В наличии: {left} шт.", m.chat_name)

BIND_TO_NEW_MESSAGE = [on_message]

Уведомление о заказе с кнопкой ответа

from tg_bot import CBT, utils
from telebot.types import InlineKeyboardMarkup as K, InlineKeyboardButton as B

def on_new_order(c: Cardinal, e: NewOrderEvent):
    if not c.telegram:
        return
    chat = c.account.get_chat_by_name(e.order.buyer_username)
    chat_id = chat.id if chat else e.order.chat_id
    kb = K().add(B("✉️ Ответить", callback_data=f"{CBT.SEND_FP_MESSAGE}:{chat_id}:{e.order.buyer_username}"))
    text = (f"🛒 <b>{utils.escape(e.order.description)}</b>\n"
            f"👤 {utils.escape(e.order.buyer_username)} · {e.order.price} {e.order.currency}")
    Thread(target=c.telegram.send_notification, args=(text, kb), daemon=True).start()

BIND_TO_NEW_ORDER = [on_new_order]

Реакция на отзыв

from FunPayAPI.common.enums import MessageTypes

def on_review(c: Cardinal, e: NewMessageEvent):
    if e.message.type not in (MessageTypes.NEW_FEEDBACK, MessageTypes.FEEDBACK_CHANGED):
        return
    if e.message.author_id != 0:
        return
    order = c.get_order_from_object(e.message)
    if not order or not order.review:
        return
    if order.review.stars <= 2:
        logger.warning(f"Плохой отзыв на {order.id}: {order.review.text}")
        c.telegram and c.telegram.send_notification(f"⭐ {order.review.stars} по заказу {order.id}")

BIND_TO_NEW_MESSAGE = [on_review]

Изменить цену всех своих лотов подкатегории

def discount(c: Cardinal, subcategory_id: int, k: float = 0.95):
    for lot in c.account.get_my_subcategory_lots(subcategory_id):
        try:
            fields = c.account.get_lot_fields(lot.id)
            if fields.price is None:
                continue
            fields.price = round(fields.price * k, 2)
            c.account.save_lot(fields.renew_fields())
            logger.info(f"Лот {lot.id}: новая цена {fields.price}")
        except Exception:
            logger.debug("TRACEBACK", exc_info=True)
        time.sleep(2)          # пауза обязательна

Фоновый цикл

STOP = threading.Event()

def worker(c: Cardinal):
    while not STOP.wait(SETTINGS["interval"]):
        pl = c.plugins.get(UUID)
        if not pl or not pl.enabled:
            continue                      # плагин выключен в ПУ
        try:
            do_work(c)
        except Exception:
            logger.error("Ошибка в фоновом цикле")
            logger.debug("TRACEBACK", exc_info=True)

def start_worker(c: Cardinal):
    Thread(target=worker, args=(c,), daemon=True, name="MyPlugin-worker").start()

BIND_TO_POST_START = [start_worker]

Пагинация в Telegram-ПУ

from tg_bot import utils

MAX_ON_PAGE = 5

def open_list(c: CallbackQuery):
    offset = int(c.data.split(":")[-1])
    items = list(DATA.items())
    kb = K()
    for key, val in items[offset:offset + MAX_ON_PAGE]:
        kb.add(B(f"{key} — {val}", callback_data=f"{CBT_PREFIX}:item:{key}:{offset}"))
    utils.add_navigation_buttons(kb, offset, MAX_ON_PAGE, len(items[offset:offset + MAX_ON_PAGE]),
                                 len(items), f"{CBT_PREFIX}:list")
    kb.add(B("◀️ Назад", callback_data=f"{CBT.EDIT_PLUGIN}:{UUID}:0"))
    bot.edit_message_text("Список", c.message.chat.id, c.message.id, reply_markup=kb)
    bot.answer_callback_query(c.id)

tg.cbq_handler(open_list, lambda c: c.data.startswith(f"{CBT_PREFIX}:list:"))

Отправить изображение в чат FunPay

image_id = c.account.upload_image("storage/plugins/manual.png", type_="chat")
c.send_message(chat_id, f"$photo={image_id}", chat_name)     # ID можно переиспользовать

Изображение выгружается один раз и живёт на сервере FunPay — кэшируйте полученный image_id в своём конфиге, а не грузите файл при каждой отправке. Для картинки лота нужен type_="offer".

Добавить покупателя в чёрный список

from Utils import cardinal_tools

def ban(c: Cardinal, username: str):
    if username in c.blacklist:
        return False
    c.blacklist.append(username)
    cardinal_tools.cache_blacklist(c.blacklist)   # без этого потеряется при перезапуске
    return True

Обойти все продажи постранично

def iter_sales(c: Cardinal, max_pages: int = 20):
    start_from, seen = None, []
    for _ in range(max_pages):
        next_id, orders, _locale, _subcats = c.account.get_sales(
            start_from=start_from, exclude_ids=[o.id for o in seen])
        seen.extend(orders)
        if not next_id:
            break
        start_from = next_id
        time.sleep(2)                 # без паузы прилетит 429
    return seen

Метод возвращает кортеж (next_id, orders, locale, subcategories). Есть фильтры: buyer, state, game, server, side, include_paid/closed/refunded — используйте их, чтобы не тянуть лишние страницы.

Сообщение после подтверждения заказа

def on_status(c: Cardinal, e: OrderStatusChangedEvent):
    if e.order.status is not OrderStatuses.CLOSED:
        return
    chat = c.account.get_chat_by_name(e.order.buyer_username)
    if chat:
        c.send_message(chat.id, "Спасибо! Будем рады видеть вас снова 🤝", e.order.buyer_username)

BIND_TO_ORDER_STATUS_CHANGED = [on_status]

Раздел 18

Полный пример плагина

Метаданные, конфиг в JSON, страница настроек с переключателем и вводом текста, хук на заказ, фоновый поток. Скелет, из которого можно делать что угодно.

plugins/thanks.py
"""
Thanks — благодарит покупателя после закрытия заказа
и раз в сутки шлёт в Telegram сводку по продажам.
"""
from __future__ import annotations

import json
import os
import time
import logging
import threading
from typing import TYPE_CHECKING

from telebot.types import InlineKeyboardMarkup as K, InlineKeyboardButton as B, Message, CallbackQuery

from tg_bot import CBT, utils
from tg_bot.static_keyboards import CLEAR_STATE_BTN
from FunPayAPI.common.enums import OrderStatuses

if TYPE_CHECKING:
    from cardinal import Cardinal
    from FunPayAPI.updater.events import OrderStatusChangedEvent

logger = logging.getLogger("FPC.Thanks")
LOGGER_PREFIX = "[THANKS]"

# ─── Метаданные ────────────────────────────────────────────────
NAME = "Thanks"
VERSION = "1.0.0"
DESCRIPTION = "Благодарит покупателя после подтверждения заказа + суточная сводка."
CREDITS = "@yourname"
UUID = "3f1c0b8e-58d2-4e6a-9a1f-7c2b4d9e05aa"
SETTINGS_PAGE = True

# ─── Конфиг ────────────────────────────────────────────────────
CFG_PATH = "storage/plugins/thanks.json"
DEFAULTS = {
    "enabled": True,
    "text": "Спасибо за покупку! Будем рады видеть вас снова 🤝",
    "digest": True,
}
SETTINGS = dict(DEFAULTS)
CBT_PREFIX = "Thanks"
STATE_TEXT = "Thanks_wait_text"
STOP = threading.Event()
COUNTER = {"day": time.strftime("%d"), "orders": 0, "sum": 0.0}


def save_config():
    os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True)
    with open(CFG_PATH, "w", encoding="utf-8") as f:
        json.dump(SETTINGS, f, indent=4, ensure_ascii=False)


def load_config():
    global SETTINGS
    data = {}
    if os.path.exists(CFG_PATH):
        try:
            with open(CFG_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
        except (json.JSONDecodeError, OSError):
            logger.warning(f"{LOGGER_PREFIX} битый конфиг, беру значения по умолчанию")
    SETTINGS = {**DEFAULTS, **data}
    save_config()


def is_enabled(c: Cardinal) -> bool:
    pl = c.plugins.get(UUID)
    return bool(pl and pl.enabled and SETTINGS["enabled"])


# ─── Telegram-панель ───────────────────────────────────────────
def init(cardinal: Cardinal):
    load_config()
    if not cardinal.telegram:
        logger.info(f"{LOGGER_PREFIX} Telegram выключен — работает только логика чата.")
        return
    tg, bot = cardinal.telegram, cardinal.telegram.bot

    def settings_kb() -> K:
        kb = K()
        kb.add(B(f"Благодарность: {utils.bool_to_text(SETTINGS['enabled'])}",
                 callback_data=f"{CBT_PREFIX}:toggle:enabled"))
        kb.add(B(f"Суточная сводка: {utils.bool_to_text(SETTINGS['digest'])}",
                 callback_data=f"{CBT_PREFIX}:toggle:digest"))
        kb.add(B("✏️ Изменить текст", callback_data=f"{CBT_PREFIX}:edit_text"))
        kb.add(B("◀️ Назад", callback_data=f"{CBT.EDIT_PLUGIN}:{UUID}:0"))
        return kb

    def open_settings(c: CallbackQuery):
        text = (f"⚙️ <b>{NAME} v{VERSION}</b>\n\n"
                f"Текущий текст:\n<code>{utils.escape(SETTINGS['text'])}</code>")
        bot.edit_message_text(text, c.message.chat.id, c.message.id, reply_markup=settings_kb())
        bot.answer_callback_query(c.id)

    def toggle(c: CallbackQuery):
        key = c.data.split(":")[2]
        SETTINGS[key] = not SETTINGS[key]
        save_config()
        open_settings(c)

    def act_edit_text(c: CallbackQuery):
        msg = bot.send_message(c.message.chat.id, "Пришлите новый текст благодарности:",
                               reply_markup=CLEAR_STATE_BTN())
        tg.set_state(msg.chat.id, msg.id, c.from_user.id, STATE_TEXT)
        bot.answer_callback_query(c.id)

    def edit_text(m: Message):
        tg.clear_state(m.chat.id, m.from_user.id, True)
        SETTINGS["text"] = m.text
        save_config()
        bot.reply_to(m, "✅ Текст сохранён",
                     reply_markup=K().add(B("◀️ К настройкам",
                                            callback_data=f"{CBT.PLUGIN_SETTINGS}:{UUID}:0")))

    def cmd_stats(m: Message):
        bot.reply_to(m, f"📊 За сегодня: <b>{COUNTER['orders']}</b> заказ(ов) "
                        f"на <b>{COUNTER['sum']:.2f}</b>")

    tg.cbq_handler(open_settings, lambda c: c.data.startswith(f"{CBT.PLUGIN_SETTINGS}:{UUID}"))
    tg.cbq_handler(toggle, lambda c: c.data.startswith(f"{CBT_PREFIX}:toggle:"))
    tg.cbq_handler(act_edit_text, lambda c: c.data == f"{CBT_PREFIX}:edit_text")
    tg.msg_handler(edit_text, func=lambda m: tg.check_state(m.chat.id, m.from_user.id, STATE_TEXT))
    tg.msg_handler(cmd_stats, commands=["thanks_stats"])
    cardinal.add_telegram_commands(UUID, [("thanks_stats", "статистика Thanks", True)])
    logger.info(f"{LOGGER_PREFIX} v{VERSION} инициализирован.")


# ─── Логика ────────────────────────────────────────────────────
def on_status_changed(c: Cardinal, e: OrderStatusChangedEvent):
    if e.order.status is not OrderStatuses.CLOSED:
        return
    COUNTER["orders"] += 1
    COUNTER["sum"] += e.order.price
    if not is_enabled(c):
        return
    chat = c.account.get_chat_by_name(e.order.buyer_username)
    chat_id = chat.id if chat else e.order.chat_id
    c.send_message(chat_id, SETTINGS["text"], e.order.buyer_username)
    logger.info(f"{LOGGER_PREFIX} поблагодарил {e.order.buyer_username} за {e.order.id}")


def digest_worker(c: Cardinal):
    while not STOP.wait(600):
        try:
            today = time.strftime("%d")
            if today == COUNTER["day"]:
                continue
            if SETTINGS["digest"] and c.telegram:
                c.telegram.send_notification(
                    f"📅 Итоги дня: <b>{COUNTER['orders']}</b> заказ(ов), "
                    f"<b>{COUNTER['sum']:.2f}</b>")
            COUNTER.update(day=today, orders=0, sum=0.0)
        except Exception:
            logger.error(f"{LOGGER_PREFIX} ошибка в сводке")
            logger.debug("TRACEBACK", exc_info=True)


def start_worker(c: Cardinal):
    threading.Thread(target=digest_worker, args=(c,), daemon=True, name="Thanks-digest").start()


def on_delete(c: Cardinal, call: CallbackQuery):
    STOP.set()
    if os.path.exists(CFG_PATH):
        os.remove(CFG_PATH)


BIND_TO_PRE_INIT = [init]
BIND_TO_POST_START = [start_worker]
BIND_TO_ORDER_STATUS_CHANGED = [on_status_changed]
BIND_TO_DELETE = on_delete
Чек-лист перед публикацией
  • Свой UUID4, осмысленные NAME/VERSION/DESCRIPTION.
  • cardinal.telegram проверен на None.
  • Уникальный префикс во всех callback_data, ≤64 байт.
  • Конфиг мерджится с DEFAULTS — обновление не ломает настройки.
  • Долгие операции в потоках, в циклах — try/except и паузы.
  • Файлы открываются с encoding="utf-8", пути — от корня FPC.
  • Нет лишних pip-зависимостей; если есть — установка через sys.executable -m pip с constraints urllib3<2.
  • Плагин корректно ведёт себя выключенным и удаляется через BIND_TO_DELETE.
Документация собрана по исходникам FunPayCardinal 0.1.17.6 (cardinal.py, handlers.py, tg_bot/, FunPayAPI/) и по практике разработки плагинов. Проверяйте сигнатуры в исходниках своей версии FPC — API между версиями меняется.