Точки расширения¶
Панель вызывает функции, которые экспортирует main.js. Какие из них она будет звать,
решает provides в манифесте (манифест). На этой странице описано, когда
вызывается каждый экспорт, что он получает, что должен вернуть и что будет при ошибке.
Общие правила для всех:
- Аргумент и ответ — JSON. Функция получает один аргумент, а вернуть может любое
JSON-значение.
undefinedи функции в ответе теряются. - Один вызов за раз. Вызовы плагина обслуживаются в порядке поступления: пока он выполняет один, следующий ждёт. Вызов, который ждёт дольше своего времени, завершается как «занят». Поэтому тяжёлая задача cron задерживает события этого плагина, а решения (регистрация, цена) и меню бота её не ждут: если плагин занят, они считают, что плагин не возражает.
- Время ограничено. Сверх лимита вызов прерывается ошибкой
interrupted. - Сбои останавливают плагин. После 10 неудачных вызовов подряд панель останавливает
плагин и сама пробует запустить его снова (подробнее).
Сбои
onHttpиwebhookоплаты пишутся в лог, но не считаются. - Точкам нужны права. У каждой точки ниже указано право, которое ей нужно; манифест без него не проходит проверку.
- Память не сохраняется между вызовами. Глобальные переменные живут, пока жива VM, а
панель может пересоздать её в любой момент: после смены настроек, при росте памяти, после
ошибки. Состояние храните в
panel.kvилиpanel.db.
| Экспорт | Когда | Время |
|---|---|---|
onEnable |
при включении и при каждом старте панели | 10 с |
onEvent |
событие панели | 10 с |
| cron-задача | по расписанию | 60 с |
payment.* |
оплата | 15 с |
onHttp |
запрос по адресу плагина | 10 с |
userFields |
открыта карточка пользователя | 0,3 с |
onAction |
админ нажал кнопку плагина | 30 с |
widget |
открыт обзор | 2 с |
subBlocks |
открыта страница подписки | 0,3 с |
channel.send |
сообщение, которое бот не доставил | 15 с |
beforeSignup, beforeDeviceBind, quotePrice |
решение панели | 0,3 с |
bot.menu |
меню пользовательского бота | 0,3 с |
bot.onCallback, bot.onCommand |
нажатие или команда в боте | 3 с |
transformSubscription |
клиент обновляет подписку | 0,3 с |
onEnable¶
export function onEnable() {
if (!panel.kv.get("installed_at")) panel.kv.set("installed_at", Date.now());
}
Необязательный экспорт. Вызывается после миграций, когда плагин включают, и при каждом старте
панели для включённых плагинов. Если onEnable бросает исключение, плагин не включится, а
текст ошибки увидит оператор. Здесь удобно проверить настройки (например, токен — запросом к
сервису) и подготовить данные.
События — onEvent¶
"permissions": ["users.view", "billing.view"],
"provides": {"events": ["user.created", "payment.paid"]}
/** @param {PanelEvent} e */
export function onEvent(e) {
// e = {id: "3f1c…", event: "payment.paid", created_at: 1767225600, data: {...}}
if (e.event === "payment.paid") panel.log.info("оплата", e.data.amount_rub);
}
- Права.
users.view; для событийpayment.,plan.,balance.,referral.иpromo.ещёbilling.view; событиямnode.*права не нужны. - Формат и поля.
e.dataто же, что у вебхуков панели. Полный список событий и их полей — в разделе «Events» docs/api.md. - Доставка хотя бы один раз. Одно и то же событие может прийти дважды. Используйте
e.idили уникальный ключ в своей базе, как в уроке. - Повторы при ошибке. Если
onEventбросил исключение, событие придёт снова через 10 с, 30 с, 2 мин и 10 мин — всего до 5 попыток. - Порядок не гарантирован между событиями разных пользователей и при повторах.
- Зацикливание. Если плагин три минуты подряд получает больше 300 событий в минуту и сам
делает больше 100 изменений в минуту через
panel.api, панель его останавливает: так ведёт себя плагин, который отвечает на собственные изменения (наuser.limits_changedменяет лимиты). Массовые события панели — сброс трафика у тысяч пользователей — плагин, который их только читает, не останавливают.
Часто нужные события:
| Событие | Когда |
|---|---|
user.created, user.registered |
пользователь создан в панели или зарегистрировался сам (бот, Mini App, сайт) |
user.expiring |
до конца подписки 14, 7, 3 и 1 день (data.stage) |
user.expired, user.limited |
подписка кончилась; трафик исчерпан |
user.traffic_low |
израсходовано 80% трафика |
payment.created, payment.paid, payment.refunded |
заказ создан, оплачен, возвращён |
plan.changed, plan.cancelled |
тариф сменился или отменён |
user.device_bound, user.device_limited |
новое устройство; упёрся в лимит |
user.telegram_linked |
к аккаунту привязан Telegram |
node.down, node.up, xray.down, xray.up |
сервер или Xray пропал и вернулся |
incident.created, incident.updated, incident.resolved, incident.deleted |
сбои и работы на странице статуса: опубликованы, изменены или дополнены ходом событий, завершены, удалены |
user.message, broadcast.sent |
сообщение или рассылка от оператора |
Задачи по расписанию¶
"provides": {"cron": [{"name": "sync", "schedule": "*/15 * * * *"}]}
export function sync() { /* … */ }
- Расписание — пять полей в часовом поясе панели (см. манифест).
- Без наложений. Если предыдущий запуск ещё идёт, новый пропускается.
- Ошибки. Исключение попадает в лог и считается ошибкой; повтора нет — задача просто запустится в следующий раз по расписанию.
Способ оплаты — payment¶
"permissions": ["payments.manage"],
"provides": {"payment": {"label": {"ru": "Оплата картой (Lava)", "en": "Card (Lava)"}, "note": "…"}}
export const payment = {
create({ amount_rub, order_id, description, return_url, webhook_url, email }) {
// создать счёт у платёжной системы
return { provider_id: "inv-123", pay_url: "https://pay.example/inv-123" };
},
status(provider_id) {
return { status: "paid", amount_kopecks: 15000, currency: "RUB" };
},
webhook({ body, headers }) {
// проверить подпись! headers — имена в нижнем регистре
return { provider_id: "inv-123", status: "paid", amount_kopecks: 15000, currency: "RUB" };
},
};
Способ появляется в Настройки → Оплата как plugin.<id>, оператор включает его как
любой другой.
| Функция | Получает | Возвращает |
|---|---|---|
create |
amount_rub (целые рубли), order_id, description, return_url (куда вернуть плательщика), webhook_url (куда платёжной системе слать уведомления), email (может быть пустым) |
{provider_id, pay_url}: id счёта и http(s)-ссылка на оплату |
status |
provider_id (строка) |
{status, amount_kopecks, currency} |
webhook |
{body, headers} — тело уведомления строкой и заголовки |
{provider_id, status, amount_kopecks, currency} |
- Адрес уведомлений:
https://<панель>/<секретный путь>/plugin.<id>— этоwebhook_urlвcreate, он же показан в карточке способа оплаты. Пока у панели не задан домен, адрес пустой. Если платёжная система принимает адрес вместе со счётом, передайте его там, и оператору ничего не придётся вписывать в её кабинет. Если не принимает — оператор копирует адрес из карточки в кабинет. - Право:
payments.manage. - Статусы:
paid,pending,cancelled,refunded. - Сумма обязательна. Для
paidиrefundedнужныamount_kopecksиcurrency. Тариф выдаётся, только если сумма и валюта совпадают с заказом. - Проверка уведомлений.
webhookобязан проверить, что уведомление настоящее (panel.crypto.hmac/verify). Если платёжная система ничего не подписывает — вызовитеpayment.status(id)и верните его ответ, а не тело. - Пропущенные уведомления. Если уведомление не пришло, панель сама опрашивает
statusдля открытых заказов. - Ошибки. Исключение в
create— плательщик видит ошибку. Вwebhook— уведомление отклоняется и попадает в журнал уведомлений оплаты; платёжной системе панель всё равно отвечает200 ok, а заказ затем подтвердит опросstatus. Сбойwebhookпишется в лог, но к остановке плагина не считается.
Полный пример — examples/plugins/lava.
Запросы из интернета — onHttp¶
"provides": {"http": true}
export function onHttp({ method, path, query, headers, body, ip }) {
if (query.token !== panel.config.token) return { status: 403, body: "forbidden" };
return { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ok: true }) };
}
- Адрес:
https://<панель>/<секретный путь>/x/<id>/<path>— он показан в карточке плагина после включения.pathначинается с/,query— объект «параметр → значение»,headers— имена в нижнем регистре,body— строка,ip— адрес клиента. - Ответ:
{status, headers, body}, гдеbody— строка. Тело в обе стороны до 1 МБ. - Безопасность:
- Ответ отдаётся с
Content-Security-Policy: sandbox,nosniffиno-store: страница плагина не может выполнять скрипты от имени панели. Set-Cookie, защитные и CORS-заголовки,Clear-Site-Data,Refresh,LinkиService-Worker-Allowedиз ответа отбрасываются, cookie запроса плагину не передаются.- Ответ без
Content-Typeили с JavaScript-типом отдаётся какtext/plain. - Проверка звонящего — на плагине: подпись, токен из настроек.
- Ошибки пишутся в лог, но к остановке плагина не считаются: отклонять поддельные запросы — его работа.
Пример — examples/plugins/shop-webhook.
Карточка пользователя — userFields¶
"permissions": ["users.view"],
"provides": {"user_fields": [{"key": "email", "label": "Почта"}, {"key": "ltv", "label": {"ru": "Выручка", "en": "LTV"}}]}
export function userFields(userId) {
const row = panel.db.query("SELECT email, ltv FROM clients WHERE user_id = ?", userId)[0];
return row ? { email: row.email, ltv: `${row.ltv} ₽` } : {};
}
Получает id пользователя (число) и возвращает {ключ: значение}. Показываются только ключи из
манифеста, значения — как текст до 1000 символов. Если плагин не ответил за 0,3 с или упал,
его блок просто не появляется.
- Право:
users.view. - Кто видит. Админы со всеми правами, выданными плагину.
Кнопки в админке — onAction¶
"provides": {"actions": [
{"key": "receipt", "label": "Выслать чек", "scope": "user", "confirm": true},
{"key": "export", "label": "Выгрузить", "scope": "users", "perm": "users.view"},
{"key": "sync", "label": "Синхронизировать", "scope": "global", "perm": "settings.manage"}
]}
export function onAction({ key, user_ids }) {
if (key === "receipt") { /* user_ids = [42] */ }
return { ok: true, message: "Готово" };
}
scope |
Где кнопка | user_ids |
|---|---|---|
user |
в карточке пользователя | один id |
users |
в массовых действиях списка пользователей | от 1 до 1000 id |
global |
на карточке плагина | [] |
- Кто видит кнопку. Админы с правом
perm(по умолчаниюusers.manage) и со всеми правами, выданными плагину. - Подтверждение.
confirm: trueспрашивает «вы уверены?» перед нажатием. - Ответ.
messageпоказывается тостом.ok: falseпоказывает его как ошибку, исключение — тоже. - Журнал. Каждое нажатие записывается в журнал панели, а изменения через
panel.api— от имени плагина.
Виджеты обзора — widget¶
"provides": {"widgets": [{"key": "today", "label": "Оплаты сегодня"}]}
export function widget(key) {
return { type: "stat", value: 12, hint: "+3 к вчера" };
// { type: "table", columns: ["Тариф", "Шт."], rows: [["Std", 10], ["Max", 2]] }
// { type: "list", items: ["первое", "второе"] }
}
Ответ — до 64 КБ, кэшируется на минуту. Если плагин не ответил за 2 с, виджет показывает «нет данных». Виджеты видят админы со всеми правами, выданными плагину.
Страница подписки — subBlocks¶
"permissions": ["users.view"],
"provides": {"sub_blocks": true}
export function subBlocks({ user, lang }) {
// user = {id, name, status, expire_at, plan_id, data_limit, used, telegram_id}
return [
{ type: "notice", text: "Скидка 20% до пятницы" },
{ type: "markdown", text: "**Новый сервер** в Нидерландах" },
{ type: "button", label: "Продлить", url: "https://vpn.example/pay" },
];
}
- Блоки. До 10 блоков:
text,notice(выделенный),markdown(без сырого HTML),button(толькоhttps://). Текст — до 1000 символов. - Право:
users.view. - Время и кэш. Ответ ждут до 0,3 с, он кэшируется на 5 минут на пользователя и язык.
Если
subBlocksупал, плагин не зовут 30 с. - Своя страница. Если у оператора своя страница, она получает те же блоки как
blocksвGET /v1/users/{id}/subscription.
Канал доставки — channel (экспериментально)¶
"permissions": ["users.view"],
"provides": {"channel": {"label": "E-mail"}}, "experimental": ["channel"]
export const channel = {
send(msg) {
// msg = {event_id, kind: "message"|"auto_message"|"broadcast"|"notice", notice, text, buttons, users: [...], data}
for (const u of msg.users) { /* отправить письмо u.external_id */ }
},
};
- Что приходит. Канал получает сообщения, рассылки и напоминания (
user.expiring,user.traffic_low) для тех, до кого не дотянулся бот панели: у пользователя нет Telegram, бот заблокирован или выключен. - Право:
users.view. - Формат.
text— Telegram-HTML. - Время. 15 с на
send. - Повторы. Доставка повторяется, как у событий. Чтобы не отправить дважды, запоминайте
event_idвместе с id пользователя.
Тип ChannelMessage — в rospanel.d.ts, пример — examples/plugins/email.
Решения — beforeSignup, beforeDeviceBind, quotePrice¶
"permissions": ["users.view", "billing.manage"],
"provides": {"hooks": ["beforeSignup", "beforeDeviceBind"], "price": true}, "experimental": ["price"]
export function beforeSignup(req) {
// req = {channel: "web"|"telegram"|"miniapp"|"bot", telegram_id, username, external_id, ip, ref, source, lang}
if (req.ip && panel.kv.get("ban/" + req.ip)) return { allow: false, reason: "banned" };
return { allow: true };
}
export function beforeDeviceBind(req) {
// req = {user_id, hwid, device_os, device_model, user_agent, ip, count, cap, lang}
return req.device_os === "Windows" ? false : { allow: true };
}
export function quotePrice(req) {
// req = {user_id, plan_id, plan, periods, devices, base_rub, lang}
return req.plan === "Standard" ? { price_rub: req.base_rub - 50, note: "loyal" } : null;
}
Общие правила:
- Права.
hooksнужноusers.view,price—billing.manage. - Не возражаю по умолчанию. Ошибка, таймаут (0,3 с) или занятый плагин означают «не возражаю»: сломанный плагин никому не мешает зарегистрироваться или заплатить.
- Только чтение. Внутри решений
panel.apiработает только на чтение (GET). - Несколько плагинов. Их спрашивают по порядку, первый отказ побеждает.
- Причина отказа.
reason(иnoteу цены) — ключ изi18n/плагина или просто текст. Его читает человек: сайт получаетerr.pluginDenied, бот и Mini App показывают текст.
| Экспорт | Когда спрашивают | Ответ |
|---|---|---|
beforeSignup |
перед новым аккаунтом из бота, Mini App или POST /v1/signup. Про существующий аккаунт и про возврат отвязанного не спрашивают |
{allow, reason} или false |
beforeDeviceBind |
перед тем, как новое для пользователя устройство займёт слот (если включён лимит устройств и клиент прислал HWID). Отказ этому устройству помнится минуту | {allow, reason} или false |
quotePrice |
при расчёте цены тарифа и продления, которые пользователь покупает сам; base_rub — цена после скидки за период, до промокода. Автопродление с баланса списывает обычную цену |
{price_rub, note} или null |
- Границы цены. Цена принимается от половины
base_rubдоbase_rub. Другая цена игнорируется, а предупреждение пишется в лог плагина. - Кэш и промокод. Цена кэшируется на минуту на пользователя, тариф, период и устройства. Промокод применяется поверх неё. Заказ сохраняет цену, с которой создан.
Пример — examples/plugins/channel-gate.
Пользовательский бот — bot¶
"permissions": ["users.view"],
"provides": {"bot": {"menu": true, "commands": [{"command": "bonus", "description": {"ru": "Бонус дня", "en": "Daily bonus"}}]}}
export const bot = {
menu({ user, lang }) {
return [{ text: "🎁 Бонус", data: "bonus" }, { text: "Сайт", url: "https://vpn.example" }];
},
onCallback({ user, data, lang }) {
return { text: "Бонус начислен", buttons: [[{ text: "Ещё", data: "bonus" }]] };
},
onCommand({ user, command, args, lang }) {
return "Команда /" + command + " " + args;
},
};
- Право:
users.view. user.{id, name, telegram_id, username};id= 0, если у этого Telegram нет аккаунта.menu. Кнопки встают под встроенными кнопками меню, до 6 от плагина. Ответ ждут до 0,3 с, он кэшируется на 5 минут. Внутриmenupanel.apiтолько читает (GET). Еслиmenuупал, его не зовут 30 с.- Нажатие. Приходит в
onCallbackсdataкнопки.px:<id>:<data>должно уместиться в 64 байта, так чтоdata— короткая строка.dataприсылает Telegram-клиент пользователя: проверяйте её, как любой ввод. - Команды. Попадают в меню команд бота рядом с
/startи приходят вonCommand;args— текст после команды. - Ответ. Строка или
{text, buttons}: текст обычный (панель его экранирует),buttons— ряды кнопок (до 8 рядов по 3) или плоский список. Кнопка сdataвозвращается вonCallback, сurl(толькоhttps://) открывает ссылку. Кнопку «Меню» бот добавляет сам. - Сбой. На ответ 3 с; если плагин упал или не успел, человек видит «Сейчас это недоступно. Попробуйте позже».
Конфиги подписки — transformSubscription (экспериментально)¶
"permissions": ["users.manage"],
"provides": {"subscription": true}, "experimental": ["subscription"]
export function transformSubscription({ user, format, links, config }) {
if (format === "links") return { links: links.map((l) => l.replace(/#.*/, "#" + encodeURIComponent("Мой VPN"))) };
if (format === "singbox") { config.route.rules.unshift({ domain_suffix: ["example.ru"], outbound: "direct" }); return { config }; }
return null; // как есть
}
- Право:
users.manage. - Форматы.
links— массив ссылок,clash—configстрокой YAML,singboxиxray—configкак JSON-объект (у Xray может быть и массив). - Ответ. Та же форма или
null. - Проверка ответа.
- Ссылки должны быть прокси-ссылками (
vless://,hysteria2://,trojan://,ss://, …), их может быть не больше чем вдвое больше исходных плюс 50. - Clash остаётся текстом с
proxies:, у sing-box непустыеoutbounds. - Если проверка не прошла, клиент получает профиль панели, а в лог плагина пишется предупреждение.
- Только чтение. Внутри
panel.apiтолько читает (GET). - Нагрузка. Через это проходит каждое обновление подписки. Ответ кэшируется на 5 минут на пользователя, формат и содержимое входа. Без изменения входа повторные обновления плагин не вызывают. Если преобразование упало, плагин не зовут 30 с: клиенты получают профиль панели.
Тема страницы подписки — theme¶
"provides": {"theme": true}
theme/theme.css стили поверх стилей страницы
theme/bg.webp картинки (png, jpg, webp, gif, svg) и шрифты (woff2, woff)
:root { --bg: #0b1020; --surface: #141a2e; --ink: #e8ecf8; --muted: #9aa3bf; }
body { background-image: url(bg.webp); }
.btn.alt { color: var(--ink); }
- Что меняет тема. Только вид: страница и все её функции (оплата, устройства, Telegram) остаются панельными.
- Переменные.
--brand,--brand-dark,--on-brand,--accent-fg,--bg,--surface,--ink,--muted,--success-fg,--warning-fg,--danger-fg. Классы страницы (.btn,.btn.alt…) тоже можно стилизовать, но они могут меняться между версиями. - Ничего извне.
url()— только имя файла изtheme/илиdata:image/…,data:font/….- Запрещены
@import,image-set(,expression(,javascript:и адреса (://,//). Экранирование CSS раскрывается до проверки. - Встроенный SVG в
data:image/svg+xmlможет содержатьxmlns="http://…". - Страница подписки ещё и сама не грузит картинки и шрифты не с панели (CSP).
- Причина: страница, которая грузит что-то со стороннего хоста, висит там, где этот хост заблокирован.
- Размеры. До 50 файлов, 1 МБ каждый, 3 МБ вместе.
- Несколько тем. Если включено несколько, действует первая по порядку.
main.js у темы может быть пустым: export {};. Пример —
examples/plugins/theme-night.