В этом уроке мы с нуля напишем полноценный плагин Lovsel — «Связки товаров». Он повторяет рабочий процесс плагина KOSell из каталога: продавец собирает связку из двух товаров, задаёт цену и срок, включает и выключает связки, а плагин ведёт учёт аренд по настоящим заказам и показывает статистику. Готовый файл — docs/examples/bundle_rent_example.py (кнопка «Пример плагина» вверху статьи); ниже он разобран по шагам.
Плагин делаем для GGsel. Каждый плагин Lovsel работает с одной площадкой: GGsel, FunPay или Playerok, и каталог плагинов у каждой площадки свой. Как сделать такой же плагин для FunPay или Playerok — в конце урока.
Что нужно знать: основы Python (функции, словари, списки). Всё остальное — в уроке. Справочник всех блоков — «Компоненты интерфейса SDK», общее руководство — «Как создать плагин».
Что получится
Плагин с пятью вкладками под шапкой — без своей боковой панели, в чёрной теме Lovsel с зелёными акцентами:
- Обзор — четыре плитки показателей с линией динамики (выручка, аренды, идущие сейчас, включённые связки), график выручки с переключателем вида и список «Скоро закончатся».
- Связки — таблица с поиском, вкладками «Все / Включены / Выключены», переключателем «Включена» прямо в строке, кнопкой «Изменить» (редактор цены и срока в панели справа) и удалением с подтверждением.
- Новая связка — мастер: товар продажи → товар выдачи → цена и срок → проверка. Товары выбираются поиском или из визуального каталога карточек.
- Товары — товары магазина GGsel и «свои товары», которые продавец добавляет вручную.
- Аренды — каждая оплата товара продажи включённой связки: покупатель, сумма, начало, окончание, статус.
- Настройки — уведомления, сообщение покупателю и цена на GGsel, с автосохранением.
Что такое связка в этом плагине: товар продажи — то, что покупатель оплачивает на GGsel; товар выдачи — то, что он получает во временное пользование; у связки своя цена и срок. Когда приходит заказ на товар продажи включённой связки, плагин записывает аренду с датой окончания.
Шаг 1. Файл и манифест
Плагин — один файл .py. Создайте bundles.py и опишите плагин:
import datetime
import uuid
from lovsel_sdk import ValidationError, WebPlugin, ui
MANIFEST = {
"id": "acme.bundles",
"name": "Связки товаров",
"version": "1.0.0",
"author": "ACME",
"description": "Связки из двух товаров с ценой и сроком: аренда по заказам GGsel.",
"category": "automation",
"marketplace": "ggsel",
"permissions": ["orders.read", "products.read", "products.write", "chats.write", "storage", "notifications"],
}
app = WebPlugin(MANIFEST)marketplace— площадка плагина, ровно одна:"ggsel","funpay"или"playerok". Плагин появится в каталоге GGsel, будет получать события GGsel и работать сctx.ggsel. Плагин без площадки или с несколькими Lovsel не примет.permissions— что плагин делает с магазином: читает заказы и товары, меняет цену товара, пишет покупателю, хранит данные, отправляет уведомления. Без разрешения метод SDK броситPermissionDeniedс понятным текстом. Разрешения покупатель видит до покупки. Понадобится метод Seller API, для которого нет готового метода, — добавьтеmarketplace.apiи вызовите его черезctx.ggsel.request(), см. «Прямой доступ к API площадки».MANIFESTсервер читает без выполнения кода — только строки, числа и списки.
Шаг 2. Данные плагина
У каждого продавца своё хранилище ctx.storage — значения JSON, которые переживают перезапуск и обновление плагина. Связки храним словарём по id, аренды — списком, настройки — словарём:
SETTINGS_DEFAULTS = {"notify_sale": True, "message_on": False,
"message": "Спасибо за покупку! Доступ к «{product}» действует {term} — до {ends}.",
"sync_price": False}
def _bundles(ctx):
return ctx.storage.get("bundles", {}) or {}
def _save_bundles(ctx, bundles):
ctx.storage.set("bundles", bundles)
def _rentals(ctx):
return ctx.storage.get("rentals", []) or []
def _settings(ctx):
return dict(SETTINGS_DEFAULTS, **(ctx.storage.get("settings", {}) or {}))Не храните данные продавца в глобальных переменных модуля: у каждого продавца своя копия плагина.
Шаг 3. Каталог товаров
Товары берём из магазина GGsel через SDK — ctx.ggsel.products() — и добавляем «свои товары», которые продавец вводит вручную (без подключённого GGsel плагин тоже работает). Чтобы id не пересекались, к номеру добавляем источник: ggsel:101 и own:3f2a….
MARKETS = {"ggsel": "GGsel", "own": "Свой товар"}
def _ggsel_products(ctx):
"""GGsel не подключён или недоступен — пустой список: страница всё равно откроется."""
if not ctx.ggsel.connected:
return []
try:
return ctx.ggsel.products()
except Exception as exc:
ctx.log("Товары GGsel недоступны: %s" % exc, "warning")
return []
def _catalog(ctx):
items = []
for p in _ggsel_products(ctx):
items.append({"id": "ggsel:%s" % p["id"], "market": "ggsel", "ref": str(p["id"]), "title": p["title"],
"price": p.get("price"), "currency": p.get("currency") or "RUB",
"active": bool(p.get("active", True)), "image": ""})
for p in ctx.storage.get("own_products", []) or []:
items.append(dict(p, market="own", active=True))
return itemsЧестно о данных: Lovsel отдаёт плагину только то, что знает о товарах GGsel — номер, название, цену, валюту, активность. Картинок товаров в SDK нет, поэтому в каталоге у них плитка с буквами названия и значком GGsel. У своих товаров продавец может указать ссылку на картинку (https://).
Шаг 4. Поиск для выбора товаров
Поле выбора field.picker не знает, откуда брать товары, — их ищет действие плагина. Оно получает текст запроса и номер страницы и возвращает ui.choices с вариантами ui.choice:
def _choice(p):
return ui.choice(p["id"], p["title"], subtitle=MARKETS[p["market"]] + " · " + p["ref"],
image=p.get("image") or None, price=p.get("price"), currency=p.get("currency"),
marketplace=p["market"] if p["market"] != "own" else None,
badge=None if p.get("active", True) else "Не активен",
tone=None if p.get("active", True) else "warning")
@app.action("search_products")
def search_products(ctx, data):
query = str(data.get("query") or "").strip().lower()
page = max(1, int(data.get("page") or 1))
found = [p for p in _catalog(ctx) if not query or query in (p["title"] + " " + p["ref"]).lower()]
size = 24
return ui.choices([_choice(p) for p in found[(page - 1) * size:page * size]], total=len(found),
note=None if found else "Подключите GGsel или добавьте свой товар на вкладке «Товары».")Кабинет вызывает это действие через 0,3 секунды после ввода и при открытии каталога (с пустым запросом). Если total больше показанного, внизу появится «Показать ещё».
Шаг 5. Мастер «Создать связку»
Поля связки нужны и в мастере, и в редакторе, поэтому описываем их один раз:
def _bundle_fields(currency="RUB"):
return {
"a": ui.field.picker("product_a", "Товар продажи", source="search_products", mode="both",
required=True, exclude=["product_b"],
hint="Что покупатель оплачивает на GGsel — по этому товару считаются аренды."),
"b": ui.field.picker("product_b", "Товар выдачи", source="search_products", mode="both",
required=True, exclude=["product_a"],
hint="Что покупатель получает во временное пользование."),
"name": ui.field.text("name", "Название связки", max_length=80),
"price": ui.field.money("price", "Цена", currency=currency, required=True, width="half"),
"term": ui.field.duration("term", "Срок", units=("hours", "days", "weeks"), unit="days", required=True,
default={"value": 7, "unit": "days"}, width="half"),
"enabled": ui.field.switch("enabled", "Включить сразу", default=True),
}mode="both"— переключатель «Список / Каталог»: поиск строками или сетка карточек с ценой.exclude=["product_b"]— товар, выбранный во втором поле, в первом недоступен: в связке два разных товара.field.moneyприходит числом (299.5),field.duration— словарём{"value": 7, "unit": "days", "minutes": 10080}.
Мастер живёт на скрытой странице — у неё нет вкладки, её открывает кнопка «Создать связку»:
@app.page("new", "Новая связка", icon="plus", hidden=True)
def new_bundle(ctx, params):
f = _bundle_fields()
return [
ui.header("Новая связка", "Выберите два товара, задайте цену и срок — связка заработает сразу.",
back="bundles"),
ui.wizard("create_bundle", [
ui.step("a", "Товар продажи", [f["a"]], description="Что покупатель оплачивает на площадке."),
ui.step("b", "Товар выдачи", [f["b"]], description="Что покупатель получает на время."),
ui.step("terms", "Цена и срок", [f["name"], f["price"], f["term"], f["enabled"]]),
ui.step("review", "Проверка", review=True, description="Всё верно? Связку можно изменить позже."),
], validate="check_step", submit="Создать связку",
cancel=ui.button("Отмена", page="bundles", variant="ghost")),
]Проверка шагов — одно действие validate. Оно получает id шага в data["__step"] и значения пройденных шагов. Ошибки показываем у полей через ValidationError, а после второго шага подставляем название и цену товара:
def _check_bundle(ctx, data, bundle_id=""):
errors = {}
if data.get("product_a") and data.get("product_a") == data.get("product_b"):
errors["product_b"] = "Выберите другой товар — в связке два разных товара"
if "price" in data and (data.get("price") is None or float(data.get("price") or 0) <= 0):
errors["price"] = "Цена должна быть больше нуля"
if errors:
raise ValidationError(errors)
@app.action("check_step")
def check_step(ctx, data):
_check_bundle(ctx, data)
if data.get("__step") == "b" and data.get("product_a") and data.get("product_b"):
catalog = _catalog(ctx)
a = next(p for p in catalog if p["id"] == data["product_a"])
b = next(p for p in catalog if p["id"] == data["product_b"])
return ui.result(values={"name": data.get("name") or "%s + %s" % (a["title"], b["title"]),
"price": data.get("price") or a.get("price")}, refresh=False)
return ui.result(refresh=False)
@app.action("create_bundle")
def create_bundle(ctx, data):
_check_bundle(ctx, data)
catalog = _catalog(ctx)
a = next(p for p in catalog if p["id"] == data["product_a"])
b = next(p for p in catalog if p["id"] == data["product_b"])
bundle = {"id": uuid.uuid4().hex[:8], "name": data.get("name") or a["title"] + " + " + b["title"],
"product_a": a["id"], "title_a": a["title"], "product_b": b["id"], "title_b": b["title"],
"price": data["price"], "currency": a.get("currency") or "RUB", "term": data["term"],
"enabled": bool(data.get("enabled", True)), "sales": 0,
"created": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")}
bundles = _bundles(ctx)
bundles[bundle["id"]] = bundle
_save_bundles(ctx, bundles)
return ui.result("Связка «%s» создана" % bundle["name"], page="bundles")На шаге «Проверка» Lovsel сам показывает выбранные товары карточками с ценой, а срок — словами («7 дней»). В полном файле _check_bundle ещё не даёт продавать один товар в двух связках, а create_bundle проверяет, что выбранные товары всё ещё есть в каталоге.
Шаг 6. Список связок
Список — таблица во всю ширину карточки. Переключатель «Включена» — колонка format="switch" с действием, «Изменить» — кнопка с редактором ui.editor, «Удалить» — в меню «⋯» с подтверждением:
def _bundle_row(b):
return {"id": b["id"], "name": b["name"], "pair": b["title_a"] + " → " + b["title_b"],
"price": b["price"], "currency": b["currency"], "term": b["term"],
"term_text": ui.duration_text(b["term"]), "sales": b.get("sales", 0), "enabled": b["enabled"],
"status": ui.badge("Включена" if b["enabled"] else "Выключена",
"success" if b["enabled"] else "neutral", value="on" if b["enabled"] else "off")}
@app.page("bundles", "Связки", icon="layers", badge=lambda ctx: len(_bundles(ctx)) or None)
def bundles_page(ctx, params):
rows = [_bundle_row(b) for b in _bundles(ctx).values()]
f = _bundle_fields()
edit = ui.editor("save_bundle", [f["name"], f["price"], f["term"]], title="Связка",
delete="delete_bundle", delete_label="Удалить связку")
create = ui.button("Создать связку", page="new", variant="primary", icon="plus")
return [ui.card(None, [ui.table(
[ui.column("name", "Связка", subtitle="pair"),
ui.column("price", "Цена", format="money", align="right"),
ui.column("term_text", "Срок"),
ui.column("sales", "Аренд", format="number", align="right"),
ui.column("enabled", "Включена", format="switch", action="toggle_bundle", sortable=False)],
rows,
searchable=True, search_placeholder="Название или товар",
filters=[ui.filter("status", options=[("on", "Включены"), ("off", "Выключены")], style="tabs")],
row_actions=[ui.button("Изменить", variant="ghost", icon="edit", dialog=edit)],
row_menu=[ui.button("Удалить", "delete_bundle", icon="trash", danger=True,
confirm="Удалить связку? Учёт прошлых аренд сохранится.")],
toolbar=[create],
empty_state=ui.empty("Связок пока нет", "Соберите первую связку из двух товаров.", action=create,
icon="layers"),
)], flush=True)]Редактор открывается из строки таблицы и сам заполняет поля значениями этой строки по ключам — поэтому в строке есть name, price и term. В действие сохранения приходит data["id"] = row["id"]:
@app.action("save_bundle")
def save_bundle(ctx, data):
bundles = _bundles(ctx)
bundle = bundles.get(str(data.get("id")))
if bundle is None:
raise ValidationError("Связка не найдена — обновите страницу")
_check_bundle(ctx, data, bundle["id"])
bundle.update(name=data.get("name") or bundle["name"], price=data["price"], term=data["term"])
_save_bundles(ctx, bundles)
return ui.result("Связка сохранена")
@app.action("toggle_bundle")
def toggle_bundle(ctx, data):
bundles = _bundles(ctx)
bundle = bundles[str(data["row"]["id"])]
bundle["enabled"] = bool(data["value"])
_save_bundles(ctx, bundles)
return ui.result("Связка включена" if bundle["enabled"] else "Связка выключена")
@app.action("delete_bundle")
def delete_bundle(ctx, data):
bundle_id = str(data.get("id") or (data.get("row") or {}).get("id") or "")
bundles = _bundles(ctx)
bundle = bundles.pop(bundle_id, None)
if bundle is None:
raise ValidationError("Связка уже удалена")
_save_bundles(ctx, bundles)
return ui.result("Связка «%s» удалена" % bundle["name"], tone="info")delete_bundle вызывают два места: редактор передаёт id, меню строки — row. На телефоне строки таблицы показываются карточками — переключатель и кнопки остаются под рукой.
Шаг 7. Аренды по заказам
Аренда начинается, когда покупатель оплатил товар продажи включённой связки. Подписываемся на новые заказы GGsel (событие new_order): заказ связан с товаром номером товара product_id.
@app.on("new_order")
def on_order(ctx, event):
order = event.get("order") or {}
order_id = str(order.get("id") or "")
rentals = _rentals(ctx)
if not order_id or any(r["order_id"] == order_id for r in rentals):
return # повторное событие по тому же заказу
product_id = "ggsel:%s" % order.get("product_id")
bundles = _bundles(ctx)
bundle = next((b for b in bundles.values() if b["enabled"] and b["product_a"] == product_id), None)
if bundle is None:
return
started = datetime.datetime.now(datetime.timezone.utc)
ends = started + datetime.timedelta(minutes=int(bundle["term"]["minutes"]))
rentals.insert(0, {"id": uuid.uuid4().hex[:10], "bundle_id": bundle["id"], "bundle": bundle["name"],
"market": "ggsel", "order_id": order_id, "buyer": order.get("buyer") or "",
"amount": float(order.get("amount") or bundle["price"]),
"currency": order.get("currency") or bundle["currency"],
"started": started.isoformat(timespec="seconds"), "ends": ends.isoformat(timespec="seconds")})
ctx.storage.set("rentals", rentals[:2000])
bundle["sales"] = int(bundle.get("sales") or 0) + 1
_save_bundles(ctx, bundles)
if _settings(ctx)["notify_sale"]:
ctx.notify("Аренда по связке «%s»" % bundle["name"], "Заказ #%s" % order_id, order_id=order_id)Площадка может прислать одно событие дважды — поэтому плагин сначала проверяет, нет ли уже аренды по этому заказу. В полном файле здесь же отправляется сообщение покупателю (ctx.ggsel.send_message), если продавец включил это в настройках. События других площадок (funpay.*, playerok.*) плагину GGsel не приходят.
Шаг 8. Обзор: показатели и график
Считаем выручку по дням за 30 дней и отдаём плитки и график:
@app.page("overview", "Обзор", icon="layout-dashboard", description="Связки, аренды и выручка за 30 дней.")
def overview(ctx, params):
bundles = list(_bundles(ctx).values())
if not bundles:
return [ui.card(None, [ui.empty("Пока нет ни одной связки", "Соберите первую — это займёт минуту.",
action=ui.button("Создать связку", page="new", variant="primary",
icon="plus"), icon="layers")], glow=True)]
today = datetime.date.today()
dates = [(today - datetime.timedelta(days=29 - i)).isoformat() for i in range(30)]
sums = dict.fromkeys(dates, 0.0)
for r in _rentals(ctx):
if r["started"][:10] in sums:
sums[r["started"][:10]] += r["amount"]
revenue = [sums[d] for d in dates]
currency = bundles[0]["currency"]
return [
ui.stats([
ui.stat("Выручка за 30 дней", sum(revenue), format="money", currency=currency,
spark=revenue[-14:], glow=True),
ui.stat("Аренд за 30 дней", len(_rentals(ctx)), icon="receipt"),
ui.stat("Связок включено", sum(1 for b in bundles if b["enabled"]), icon="layers",
hint="из %d" % len(bundles), page="bundles"),
]),
ui.card("Выручка", [
ui.chart(series=[ui.series("ggsel", "GGsel", [{"date": d, "value": v} for d, v in zip(dates, revenue)],
color="ggsel")],
format="money", currency=currency, label="Выручка связок", switch=True, id="revenue"),
], icon="trending-up"),
]spark— маленькая линия динамики в плитке,glow=True— свечение у главного показателя.ui.series(..., color="ggsel")— зелёный цвет GGsel, как в «Аналитике» Lovsel; справа (switch=True) — вид «Гладкий / Острый / Столбцы», он запоминается поid.- Пустое состояние с главной кнопкой — пока связок нет.
Шаг 9. Настройки
Настройки — отдельная вкладка с панелью ui.settings: группы слева, поля справа, изменения сохраняются сами.
@app.page("settings", "Настройки", icon="settings")
def settings_page(ctx, params):
return [ui.settings("save_settings", [
ui.settings_group("Уведомления", [
ui.field.switch("notify_sale", "Сообщать о новой аренде", hint="Событие в центре событий и в Telegram."),
], description="Что Lovsel сообщит вам."),
ui.settings_group("Сообщение покупателю", [
ui.field.switch("message_on", "Отправлять сообщение после оплаты"),
ui.field.template("message", "Текст", variables=[
ui.var("product", "Товар выдачи", "Аккаунт Steam"), ui.var("term", "Срок", "7 дней"),
ui.var("ends", "Окончание", "25.10.2026 18:00 UTC")], show_if={"field": "message_on", "equals": True}),
]),
ui.settings_group("Цены на GGsel", [
ui.field.switch("sync_price", "Ставить цену связки товару продажи"),
], description="Нужно разрешение products.write."),
], values=_settings(ctx))]
@app.action("save_settings")
def save_settings(ctx, data):
if data.get("message_on") and not str(data.get("message") or "").strip():
raise ValidationError({"message": "Напишите текст сообщения или выключите отправку"})
# Поле, скрытое условием show_if, не приходит — прежнее значение сохраняется.
ctx.storage.set("settings", dict(_settings(ctx), **{k: data[k] for k in SETTINGS_DEFAULTS if k in data}))
return ui.result("Настройки сохранены", refresh=False)Настройка «Ставить цену связки товару продажи» в полном файле меняет цену на GGsel настоящим методом SDK ctx.ggsel.set_price. Ошибку площадки плагин пишет в журнал и не мешает сохранить связку.
Шаг 10. Проверка без сервера
Сначала — стенд из командной строки: он проверит манифест и отрисует все страницы.
python -m lovsel_sdk.testing bundles.pyЗатем — автотест: товары GGsel, мастер, заказ, переключатель и удаление. plugin.submit отправляет значения так же, как кабинет: «299,5» станет числом, «7d» — сроком, а неправильные значения дадут ValidationError.
from lovsel_sdk.testing import load_plugin
def test_bundles():
plugin = load_plugin("bundles.py", products=[
{"id": 101, "title": "Аренда Steam на неделю", "price": 299, "currency": "RUB"},
{"id": 102, "title": "Аккаунт Steam", "price": 0, "currency": "RUB"},
])
assert len(plugin.action("search_products", {"query": "steam"})["items"]) == 2
wizard = plugin.page("new")[1]
fields = [f for step in wizard["steps"] for f in step["fields"]]
plugin.submit("create_bundle", fields, {"product_a": "ggsel:101", "product_b": "ggsel:102",
"price": "299,5", "term": "7d", "enabled": True})
bundle = list(plugin.ctx.storage.get("bundles").values())[0]
assert bundle["price"] == 299.5 and bundle["term"]["minutes"] == 7 * 24 * 60
plugin.emit("new_order", {"order": {"id": "9001", "product_id": 101, "chat_id": 9001, "amount": 299}})
assert plugin.ctx.storage.get("rentals")[0]["order_id"] == "9001"
plugin.action("toggle_bundle", {"row": {"id": bundle["id"]}, "value": False})
plugin.action("delete_bundle", {"id": bundle["id"]})
assert plugin.ctx.storage.get("bundles") == {}Стенд также проверит, что выбор товаров ищет существующим действием, а переключатель, редактор и настройки вызывают объявленные @app.action.
Шаг 11. Загрузка и публикация
- Откройте Плагины → Разработчикам → Создать плагин, выберите файл и создайте плагин для проверки у себя. Модерация для этого не нужна: плагин сразу появится в меню кабинета.
- Откройте плагин: соберите связку мастером, переключите её в списке, измените цену в редакторе. Проверьте светлую и тёмную тему и телефон. Ошибки страниц и действий — во вкладке «Журнал» плагина.
- Нашли проблему — исправьте код и загрузите новую сборку в кабинете разработчика; сборка сразу работает у вас.
- Когда всё готово, опубликуйте плагин — бесплатно или по своей цене — и опишите его в карточке магазина. Обновления: увеличьте
versionвMANIFEST, загрузите сборку и выпустите её — покупатели получат обновление сами, их связки и настройки вctx.storageсохранятся.
Подробно о публикации, проверке «Проверен Lovsel», ценах и обновлениях — в статье «Разработка, публикация и проверка Lovsel».
Что дальше
- Напоминание об окончании аренды — фоновая задача в
@app.on_startили операцияctx.operations, которая проверяетrentalsи вызываетctx.notify. - Выдача данных доступа покупателю — сообщением через
ctx.ggsel.send_message. - Такие же связки для FunPay или Playerok — отдельный плагин: скопируйте файл, поменяйте
id,marketplace("funpay"или"playerok"), источник товаров (ctx.funpay.lots()илиctx.playerok.items()) и событие заказа (funpay.new_order— заказ FunPay связан с лотом по названию,playerok.new_order— по товару сделкиitem_id). Плагин появится в каталоге своей площадки. - Свои картинки товаров — загрузите изображения в кабинете разработчика и передайте
image=ui.asset("имя.png")вui.choice.
Все блоки и их параметры — в справочнике «Компоненты интерфейса SDK».
Частые вопросы
Можно ли выбрать сразу два товара в одном поле?
Да: ui.field.picker("pair", "Два товара", source="search_products", multiple=True, min=2, max=2). Значение придёт списком из двух id; после выбора второго товара список свернётся.
Откуда плагин берёт товары для каталога?
Из SDK: ctx.ggsel.products() — товары магазина GGsel — плюс то, что плагин хранит сам. Нужны поля, которых нет в products(), — запросите оффер готовым методом ctx.ggsel.product(id) или любым методом Seller API через ctx.ggsel.request().
Что увидит продавец, если GGsel не подключён?
Пустой каталог с подсказкой из note и вкладку «Товары», где можно добавить свои товары вручную. Плагин GGsel запускается и без подключённого магазина, а методы ctx.ggsel не вызываются благодаря проверке connected.
Почему урок — только про GGsel?
Каждый плагин работает с одной площадкой: GGsel, FunPay или Playerok. Каталог плагинов у каждой площадки свой, и покупатель видит плагин в каталоге той площадки, с которой работает. Для другой площадки опубликуйте отдельный плагин.