К содержанию
FunPay · Разработчикам

Плагины для FunPay

Готовые методы ctx.funpay, любые запросы к сайту FunPay от имени аккаунта, события funpay.* и полный пример.

Содержание

Плагин для FunPay — дополнительные возможности аккаунта продавца на FunPay. Всё, что продавец может сделать на сайте FunPay, можно сделать в плагине: готовыми методами ctx.funpay (заказы, диалоги, лоты, возвраты, отзывы, поднятие) или прямым запросом к сайту через сессию аккаунта — ctx.funpay.request(). У FunPay нет публичного API: Lovsel работает с площадкой так же, как браузер продавца. Плагин не получает golden_key и cookies — запросы к FunPay выполняет Lovsel.

Общие правила создания плагина — структура файла, страницы, формы, операции, публикация — в руководстве «Как создать плагин». Здесь — только то, что относится к FunPay.

Манифест

Каждый плагин работает с одной площадкой: GGsel, FunPay или Playerok. Каталог плагинов у каждой площадки свой. Площадку объявляет поле marketplace:

python
MANIFEST = {
    "id": "studio.funpay-helper",
    "name": "Помощник FunPay",
    "version": "1.0.0",
    "author": "Studio",
    "description": "Благодарит покупателей FunPay и следит за лотами.",
    "permissions": ["orders.read", "chats.read", "chats.write", "products.read", "storage"],
    "marketplace": "funpay",             # одна площадка: плагин стоит в каталоге FunPay
}
  • Плагин FunPay не запускается, пока у пользователя не подключён FunPay: в кабинете он отмечен «ждёт подключения» и включается сам сразу после подключения.
  • Плагин FunPay не может обратиться к ctx.ggsel или ctx.playerok (будет PermissionDenied) и не получает события других площадок. Та же функция для GGsel или Playerok — отдельный плагин.
  • Покупатель видит плагин FunPay в каталоге, когда выбрал FunPay вверху кабинета.

Разрешения

Названия разрешений одинаковы для всех площадок; у плагина FunPay они открывают такие методы:

РазрешениеМетоды ctx.funpay
orders.readorders(), order()
orders.writerefund()
chats.readchats(), messages()
chats.writesend_message(), send_image()
products.readlots(), refresh_lots(), lot_fields()
products.writeset_lot_price(), set_lot_active(), save_lot(), raise_lots()
reviews.writereply_review()
balance.readbalance()
marketplace.apirequest() — любой запрос к сайту FunPay от имени аккаунта, и все методы выше

Без нужного разрешения метод выбрасывает PermissionDenied с понятным текстом. Разрешения видны покупателю до покупки и сверяются с кодом при проверке Lovsel — объявляйте то, что плагин действительно делает. Нужно всё, что продавец делает на сайте, — объявите marketplace.api («Полный доступ к API площадки»).

API ctx.funpay

Метод или свойствоЧто возвращаетОбращается к FunPay
ctx.funpay.connectedПодключён ли FunPay и объявлена ли площадка в манифесте.нет
ctx.funpay.usernameИмя продавца на FunPay.нет
ctx.funpay.orders(limit=500)Заказы, новые первыми.нет
ctx.funpay.order(order_id)Заказ с полями покупателя (fields) и отзывом (review).да
ctx.funpay.chats(limit=100)Диалоги с покупателями.нет
ctx.funpay.messages(chat_id, limit=50)Свежая переписка диалога.да
ctx.funpay.send_message(chat_id, text)Отправляет сообщение покупателю (до 2000 символов).да
ctx.funpay.send_image(chat_id, content, filename, mime)Отправляет изображение покупателю (PNG, JPEG, GIF).да
ctx.funpay.refund(order_id)Возвращает деньги покупателю — как «Вернуть деньги» на сайте. Записывается в журнал действий Lovsel.да
ctx.funpay.reply_review(order_id, text)Ответ продавца на отзыв к заказу (до 999 символов).да
ctx.funpay.lots()Лоты аккаунта.нет
ctx.funpay.refresh_lots()Перечитывает лоты с FunPay, возвращает их число.да
ctx.funpay.lot_fields(lot_id)Все поля формы редактирования лота: описания, цена, количество, параметры раздела.да
ctx.funpay.save_lot(lot_id, fields)Сохраняет лот с вашими изменениями полей; остальные поля остаются как были.да
ctx.funpay.set_lot_price(lot_id, price)Меняет цену лота для продавца.да
ctx.funpay.set_lot_active(lot_id, active)Включает или выключает лот.да
ctx.funpay.raise_lots()Поднимает лоты: по строке на игру (game, ok, wait или error).да
ctx.funpay.balance(){"available": 1250.0, "currency": "RUB"}нет
ctx.funpay.request(method, path, ...)Любой запрос к сайту FunPay от имени аккаунта — подробно.да

Методы без обращения к FunPay читают данные, которые Lovsel сохраняет опросом площадки каждые несколько секунд: они работают быстро и не нагружают FunPay. Методы с обращением к FunPay занимают до пары секунд — вызывайте их из действий, операций и обработчиков событий, а не при каждой отрисовке страницы.

python
orders = ctx.funpay.orders()
paid = [o for o in orders if o["status"] == "paid"]
lots = ctx.funpay.lots()
ctx.funpay.send_message(paid[0]["chat_id"], "Здравствуйте! Заказ уже в работе.")

Поля лота меняются так же, как в форме на сайте: прочитайте их, поправьте нужные и сохраните.

python
fields = ctx.funpay.lot_fields("1001")       # {"fields[summary][ru]": "...", "price": "199", "amount": "12", ...}
ctx.funpay.save_lot("1001", {"fields[desc][ru]": "Ключ придёт сразу после оплаты.", "amount": 20})

Ошибки FunPay приходят исключениями с понятным текстом — ловите их и показывайте продавцу:

python
try:
    ctx.funpay.set_lot_price("1001", 199)
except Exception as exc:
    ctx.log("Цена лота не изменена: " + str(exc), "error")

Прямой доступ к сайту FunPay

У FunPay нет публичного API: сайт работает запросами страниц и форм, и Lovsel выполняет их от имени аккаунта, как браузер продавца. ctx.funpay.request() даёт плагину ту же возможность — любой запрос, который делает сайт FunPay, через сессию аккаунта в Lovsel. Нужно разрешение marketplace.api.

python
ctx.funpay.request(method, path, params=None, data=None, json=None, headers=None, files=None,
                   ajax=False, csrf=True)
  • path — путь на сайте ("orders/trade", "lots/raise") или полный адрес на funpay.com. На другие сайты запрос с сессией аккаунта не уходит — будет MarketplaceError с кодом foreign_url.
  • params — параметры адреса, data — форма, json — тело JSON, files — файлы {"file": (имя, байты, тип)}.
  • ajax=True — заголовки запроса из JavaScript сайта (x-requested-with: XMLHttpRequest): их ждут обработчики форм FunPay.
  • csrf=True — в форму POST-запроса (data-словарь) Lovsel добавит csrf_token сессии, если его там нет.
  • Lovsel подставляет cookies и User-Agent сессии, входит заново, если сессия устарела, и обновляет csrf_token по открытым страницам. golden_key и cookies плагину не передаются и в ответ не попадают.
  • Чтения (GET) при обрыве связи повторяются, действия — нет: обрыв после отправки — MarketplaceError с кодом unknown_result, проверьте результат на сайте, прежде чем повторять.

Ответ — MarketResponse с любым HTTP-кодом: status, ok, url, headers, text, content, json(), raise_for_status(). Нет связи или FunPay не принял сессию — MarketplaceError.

Запросы, которыми пользуется сам Lovsel:

python
page = ctx.funpay.request("GET", "orders/trade")                  # «Мои продажи» — HTML страницы
lot = ctx.funpay.request("GET", "lots/offerEdit", params={"offer": "1001"})   # форма редактирования лота
raised = ctx.funpay.request("POST", "lots/raise", ajax=True,
                            data={"game_id": 41, "node_id": 265, "node_ids[]": [265]}).json()
details = ctx.funpay.request("POST", "api/orders/get",
                             json={"order_uids": ["AB12CD34"], "include": ["details", "users", "review"]}).json()

Страницы FunPay приходят HTML — разбирайте text своим кодом. Сайт может измениться: проверяйте ответ (ok, наличие нужных полей) и показывайте продавцу понятную ошибку, если разметка стала другой.

python
from lovsel_sdk import MarketplaceError, ValidationError, ui

@app.action("check_sales")
def check_sales(ctx, data):
    try:
        page = ctx.funpay.request("GET", "orders/trade").raise_for_status()
    except MarketplaceError as exc:
        raise ValidationError("FunPay не ответил: " + exc.message)
    paid = page.text.count("Оплачен")
    return ui.result("Оплаченных заказов на первой странице: %d" % paid)

Поля данных

Заказ (orders(), order(), события): id, title (название лота), category («Игра, раздел»), amount, currency, quantity, status, status_label, buyer, buyer_id, chat_id, created_at, url. У order() дополнительно fields (поля, заполненные покупателем) и review (stars, text, reply).

statusstatus_label
paidОплачен
successВыполнен
refundedВозврат
partial_refundЧастичный возврат
pendingОжидает оплаты

В интерфейсе показывайте status_label, а не код. Суммы показывайте через ui.money(value, currency) — валюта у каждого заказа своя.

Диалог (chats()): id, buyer, buyer_id, last_text, last_at. Идентификатор диалога FunPay — строка из цифр; сообщения покупателю отправляются в диалог, а не в заказ.

Сообщение (messages()): id, text, from (buyer, seller или system), author, created_at, attachments (изображения).

Лот (lots()): id, title, category, price, currency, amount, active, auto_delivery.

События

python
@app.on("funpay.new_order")
def on_order(ctx, event):
    order = event["order"]
    ...
СобытиеДанные
funpay.new_orderorder — новый заказ.
funpay.order_statusorder, old_status — покупатель подтвердил заказ, возврат, повторное открытие.
funpay.new_chatchat_id, buyer, buyer_id, message, order — первое сообщение нового диалога.
funpay.new_messagechat_id, buyer, buyer_id, message (id, text, author, author_id, image_url, badge), order (последний заказ покупателя или None).
funpay.new_revieworder, chat_id, review (stars, text, replied).
initМагазин запущен (получают все плагины).

В каждом событии есть marketplace: "funpay". События funpay.* приходят только плагинам, объявившим FunPay, и только при подключённом аккаунте. Первое сообщение нового диалога приходит двумя событиями: funpay.new_chat и funpay.new_message.

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

Обработчик события должен работать быстро. Долгую работу запускайте операцией (ctx.operations.start).

Уведомления

Событие в центр событий, относящееся к заказу или диалогу FunPay, отправляйте с marketplace="funpay" — карточка получит отметку FunPay и встанет в ветку этого диалога:

python
ctx.notify("Заказ выдан", "Ключ отправлен покупателю", chat_id="700001", order_id="AB12CD34",
           marketplace="funpay")

needs_action=True или level="error" помещают событие в «Требуют внимания».

Оплата закупок с баланса Lovsel

Плагин может купить товар у поставщика под оплаченный заказ FunPay и оплатить покупку с единого баланса продавца в Lovsel: резерв под заказ → покупка → списание (закупка + комиссия Lovsel) → выдача покупателю. Плагин не трогает деньги сам — он вызывает ctx.wallet с разрешением wallet, а сервер проверяет заказ, сумму и защищает от двойных списаний.

python
@app.on("funpay.new_order")
def on_order(ctx, event):
    order = event["order"]
    if order.get("status") != "paid":
        return
    res = ctx.wallet.purchase(order["id"], 100, marketplace="funpay", buy=buy, check=check, title="Ключ")
    if res.ok:
        ctx.funpay.send_message(order["chat_id"], "Ваш товар: " + res.result["goods"])
        res.operation.mark_delivered()

Подробно, с нехваткой денег, зависшими запросами и возвратами, — «Единый баланс Lovsel в плагинах».

Хорошие практики

  • Не отправляйте покупателю одно и то же сообщение повторно: храните отметку в ctx.storage (например, thanked:<id заказа>).
  • Не опрашивайте FunPay в цикле: данные заказов, диалогов и лотов уже есть в orders(), chats(), lots().
  • FunPay ограничивает частоту сообщений и поднятие лотов (обычно раз в несколько часов на игру) — ошибки приходят исключениями, повторяйте действие позже. Прямые запросы подчиняются тем же правилам площадки.
  • Не отвечайте автоматически на сообщения с badge (поддержка и арбитраж FunPay).
  • У лотов игровой валюты на FunPay своя форма: set_lot_price, set_lot_active и save_lot их не меняют. Такие лоты пропускайте или повторите запрос формы сайта через ctx.funpay.request.
  • Возврат денег необратим: вызывайте ctx.funpay.refund() только после подтверждения продавца.

Проверка без FunPay

Тестовый стенд SDK подставляет тестовый аккаунт: сообщения никуда не уходят, а складываются в plugin.funpay_sent.

python
from lovsel_sdk.testing import load_plugin

plugin = load_plugin("my_plugin.py", funpay={
    "orders": [{"id": "AB12CD34", "title": "Ключ Steam", "amount": 199, "currency": "RUB",
                "status": "paid", "buyer": "anna", "chat_id": "700001"}],
    "chats": [{"id": "700001", "buyer": "anna", "last_text": "Здравствуйте"}],
    "lots": [{"id": "1001", "title": "Ключ Steam", "price": 199}],
})
plugin.ctx.funpay.send_message("700001", "Спасибо!")
assert plugin.funpay_sent == [{"chat_id": "700001", "text": "Спасибо!"}]

Прямые запросы на стенде отвечают из параметра api (ключ — "МЕТОД путь"), а возвраты, ответы на отзывы и изменения лотов складываются в plugin.market_actions:

python
plugin = load_plugin("my_plugin.py", funpay={"orders": [{"id": "AB12CD34", "status": "paid"}]},
                     api={"GET orders/trade": "<h1>Мои продажи</h1><span>Оплачен</span>"})
assert plugin.ctx.funpay.request("GET", "orders/trade").ok
plugin.ctx.funpay.refund("AB12CD34")
assert plugin.market_actions[-1] == {"marketplace": "funpay", "action": "refund", "order_id": "AB12CD34"}

Из командной строки: python -m lovsel_sdk.testing my_plugin.py — проверка манифеста и отрисовка страниц.

Пример: благодарность и сводка лотов

python
from lovsel_sdk import WebPlugin, ui

MANIFEST = {
    "id": "studio.funpay-thanks",
    "name": "Благодарность FunPay",
    "version": "1.0.0",
    "author": "Studio",
    "description": "Благодарит за оплату и показывает сводку лотов FunPay.",
    "category": "messages",
    "permissions": ["orders.read", "chats.write", "products.read", "storage"],
    "marketplace": "funpay",
}

app = WebPlugin(MANIFEST)

app.settings([
    ui.field.textarea("text", "Текст благодарности", default="Спасибо за покупку! Скоро всё пришлю."),
])


@app.page("overview", "Обзор", icon="layout-dashboard")
def overview(ctx, params):
    if not ctx.funpay.connected:
        return [ui.empty("FunPay не подключён", "Подключите аккаунт в разделе «Подключение».")]
    lots = ctx.funpay.lots()
    active = [l for l in lots if l["active"]]
    return [
        ui.stats([
            ui.stat("Лотов", len(lots)),
            ui.stat("Активных", len(active)),
            ui.stat("Поблагодарили", ctx.storage.get("thanked_count", 0)),
        ]),
        ui.card("Активные лоты", [
            ui.table([ui.column("title", "Лот"), ui.column("price", "Цена", align="right", format="money")],
                     active, empty_text="Активных лотов нет"),
        ]),
    ]


@app.on("funpay.new_order")
def thank(ctx, event):
    order = event["order"]
    key = "thanked:" + str(order["id"])
    if order["status"] != "paid" or not order["chat_id"] or ctx.storage.get(key):
        return
    ctx.funpay.send_message(order["chat_id"], ctx.settings["text"])
    ctx.storage.set(key, True)
    ctx.storage.incr("thanked_count")

Колонка format="money" берёт валюту из поля currency каждой строки — у лотов FunPay оно заполнено.

Полный пример: отзывы и напоминания

Файл docs/examples/funpay_reviews.py (кнопка «Пример плагина» вверху статьи) — готовый плагин для FunPay с интерфейсом, как у встроенных разделов Lovsel, в голубом цвете FunPay.

Что он делает:

  • после оплаты заказа (funpay.new_order, статус paid) здоровается с покупателем;
  • когда покупатель подтвердил заказ (funpay.order_status, статус success), просит оставить отзыв;
  • на отзыв 4–5★ (funpay.new_review) отвечает благодарностью, а отзыв 1–3★ отмечает в «Требуют внимания», чтобы продавец ответил лично.
СтраницаКомпоненты SDK
Обзорui.stats со средней оценкой и переходом на страницы, круговая диаграмма ui.chart(kind="donut"), «Требуют внимания» со ссылкой на заказ.
Сценарииui.tabs(param="tab") — вкладка на сценарий: ui.toggle «Отправлять сообщение» и форма с field.template и предпросмотром в чате FunPay.
ЗаказыТаблица с вкладками-фильтрами по статусу, поиском, колонкой-аватаром покупателя и скрытой колонкой в меню «Колонки»; нажатие на строку открывает заказ.
ЗаказСкрытая страница: ui.steps «Оплачен → Подтверждён → Отзыв», ui.keyvalue, лента действий плагина ui.timeline, переписка в окне сбоку.
ОтзывыДва фильтра таблицы: вкладки «Ждут решения / Решены» и кнопка-фильтр по оценке; счётчик нерешённых отзывов в меню.
ИнструкцияПункт внизу меню: ui.list_view и ui.message_preview.

Главное из кода:

python
app = WebPlugin(MANIFEST, layout="sidebar")


@app.on("funpay.order_status")
def on_status(ctx, event):
    order = event.get("order") or {}
    if order.get("status") == "success":        # покупатель подтвердил заказ
        _send(ctx, "confirmed", order)


@app.page("scenarios", "Сценарии", icon="messages", group="Рабочее пространство")
def scenarios_page(ctx, params):
    items = _scenarios(ctx)
    tabs = [ui.tab(key, base["title"], [
        ui.toggle(key, "Отправлять сообщение", items[key]["enabled"], action="toggle_scenario",
                  description=base["hint"]),
        ui.card(None, [ui.form("save_" + key, [
            ui.field.hidden("scenario", key),
            ui.field.template("text", "Текст сообщения", variables=VARIABLES, required=True),
        ], values={"text": items[key]["text"]}, action="save_scenario", submit="Сохранить текст")]),
    ], icon=base["icon"]) for key, base in SCENARIOS.items()]
    return [ui.header("Сценарии", "Каждый сценарий можно включить отдельно."), ui.tabs(tabs, param="tab")]


@app.action("load_chat")
def load_chat(ctx, data):
    messages = ctx.funpay.messages(data.get("chat_id"), limit=20)
    return ui.result(refresh=False, dialog=ui.dialog("Переписка", [ui.chat([
        {"text": m["text"], "author": "Вы" if m["from"] == "seller" else "Покупатель",
         "direction": "out" if m["from"] == "seller" else "in"} for m in messages if m.get("text")
    ], marketplace="funpay")], side=True))

Проверить без FunPay:

python
from lovsel_sdk.testing import load_plugin

plugin = load_plugin("docs/examples/funpay_reviews.py", funpay={
    "orders": [{"id": "AB12CD34", "title": "Ключ Steam", "amount": 199, "status": "paid", "buyer": "anna",
                "chat_id": "700001"}],
})
plugin.emit("funpay.new_order", {"order": {"id": "AB12CD34", "status": "paid", "chat_id": "700001",
                                           "buyer": "anna", "title": "Ключ Steam"}})
assert plugin.funpay_sent[0]["chat_id"] == "700001"
blocks = plugin.page("orders")                 # таблица заказов проверена на ошибки описания

Как устроены компоненты и что ещё умеют таблицы, формы и мастера — в статье «Компоненты интерфейса SDK».