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

Создаём плагин как KOSell: связки товаров

Пошаговый урок: выбор товаров из каталога, мастер, список с переключателями, статистика и публикация.

Содержание

В этом уроке мы с нуля напишем полноценный плагин 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 и опишите плагин:

python
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, аренды — списком, настройки — словарём:

python
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….

python
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://).

Поле выбора field.picker не знает, откуда брать товары, — их ищет действие плагина. Оно получает текст запроса и номер страницы и возвращает ui.choices с вариантами ui.choice:

python
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. Мастер «Создать связку»

Поля связки нужны и в мастере, и в редакторе, поэтому описываем их один раз:

python
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}.

Мастер живёт на скрытой странице — у неё нет вкладки, её открывает кнопка «Создать связку»:

python
@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, а после второго шага подставляем название и цену товара:

python
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, «Удалить» — в меню «⋯» с подтверждением:

python
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"]:

python
@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.

python
@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 дней и отдаём плитки и график:

python
@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: группы слева, поля справа, изменения сохраняются сами.

python
@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. Проверка без сервера

Сначала — стенд из командной строки: он проверит манифест и отрисует все страницы.

bash
python -m lovsel_sdk.testing bundles.py

Затем — автотест: товары GGsel, мастер, заказ, переключатель и удаление. plugin.submit отправляет значения так же, как кабинет: «299,5» станет числом, «7d» — сроком, а неправильные значения дадут ValidationError.

python
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. Загрузка и публикация

  1. Откройте Плагины → Разработчикам → Создать плагин, выберите файл и создайте плагин для проверки у себя. Модерация для этого не нужна: плагин сразу появится в меню кабинета.
  2. Откройте плагин: соберите связку мастером, переключите её в списке, измените цену в редакторе. Проверьте светлую и тёмную тему и телефон. Ошибки страниц и действий — во вкладке «Журнал» плагина.
  3. Нашли проблему — исправьте код и загрузите новую сборку в кабинете разработчика; сборка сразу работает у вас.
  4. Когда всё готово, опубликуйте плагин — бесплатно или по своей цене — и опишите его в карточке магазина. Обновления: увеличьте 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. Каталог плагинов у каждой площадки свой, и покупатель видит плагин в каталоге той площадки, с которой работает. Для другой площадки опубликуйте отдельный плагин.