Плагин для FunPay — дополнительные возможности аккаунта продавца на FunPay. Всё, что продавец может сделать на сайте FunPay, можно сделать в плагине: готовыми методами ctx.funpay (заказы, диалоги, лоты, возвраты, отзывы, поднятие) или прямым запросом к сайту через сессию аккаунта — ctx.funpay.request(). У FunPay нет публичного API: Lovsel работает с площадкой так же, как браузер продавца. Плагин не получает golden_key и cookies — запросы к FunPay выполняет Lovsel.
Общие правила создания плагина — структура файла, страницы, формы, операции, публикация — в руководстве «Как создать плагин». Здесь — только то, что относится к FunPay.
Манифест
Каждый плагин работает с одной площадкой: GGsel, FunPay или Playerok. Каталог плагинов у каждой площадки свой. Площадку объявляет поле marketplace:
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.read | orders(), order() |
orders.write | refund() |
chats.read | chats(), messages() |
chats.write | send_message(), send_image() |
products.read | lots(), refresh_lots(), lot_fields() |
products.write | set_lot_price(), set_lot_active(), save_lot(), raise_lots() |
reviews.write | reply_review() |
balance.read | balance() |
marketplace.api | request() — любой запрос к сайту 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 занимают до пары секунд — вызывайте их из действий, операций и обработчиков событий, а не при каждой отрисовке страницы.
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"], "Здравствуйте! Заказ уже в работе.")Поля лота меняются так же, как в форме на сайте: прочитайте их, поправьте нужные и сохраните.
fields = ctx.funpay.lot_fields("1001") # {"fields[summary][ru]": "...", "price": "199", "amount": "12", ...}
ctx.funpay.save_lot("1001", {"fields[desc][ru]": "Ключ придёт сразу после оплаты.", "amount": 20})Ошибки FunPay приходят исключениями с понятным текстом — ловите их и показывайте продавцу:
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.
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:
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, наличие нужных полей) и показывайте продавцу понятную ошибку, если разметка стала другой.
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).
status | status_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.
События
@app.on("funpay.new_order")
def on_order(ctx, event):
order = event["order"]
...| Событие | Данные |
|---|---|
funpay.new_order | order — новый заказ. |
funpay.order_status | order, old_status — покупатель подтвердил заказ, возврат, повторное открытие. |
funpay.new_chat | chat_id, buyer, buyer_id, message, order — первое сообщение нового диалога. |
funpay.new_message | chat_id, buyer, buyer_id, message (id, text, author, author_id, image_url, badge), order (последний заказ покупателя или None). |
funpay.new_review | order, 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 и встанет в ветку этого диалога:
ctx.notify("Заказ выдан", "Ключ отправлен покупателю", chat_id="700001", order_id="AB12CD34",
marketplace="funpay")needs_action=True или level="error" помещают событие в «Требуют внимания».
Оплата закупок с баланса Lovsel
Плагин может купить товар у поставщика под оплаченный заказ FunPay и оплатить покупку с единого баланса продавца в Lovsel: резерв под заказ → покупка → списание (закупка + комиссия Lovsel) → выдача покупателю. Плагин не трогает деньги сам — он вызывает ctx.wallet с разрешением wallet, а сервер проверяет заказ, сумму и защищает от двойных списаний.
@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.
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:
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 — проверка манифеста и отрисовка страниц.
Пример: благодарность и сводка лотов
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. |
Главное из кода:
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:
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».