B Botex Archive

Extensions / Vpn

VPN Shop 2.11.0

Sells xray accounts from 3x-ui, PasarGuard and VIPST reseller panels: servers with account and traffic capacity, plan groups, wallet checkout, subscription link and QR, named config links, renewals, a free test account per customer, and scheduled 3x-ui database backups to a group.

older release stable 65 installs

You are looking at 2.11.0. The current release is 2.13.0.

Install

Install it

php bin/console ext:install Vpn --version=2.11.0

Update it later

php bin/console ext:update Vpn

Remove it

php bin/console ext:remove Vpn

Add --dry-run to any of these to see exactly which files would change before anything is written.

Readme

VPN Shop

Sells xray accounts from 3x-ui, PasarGuard and VIPST reseller

panels, paid for out of the wallet, delivered as a subscription link or a

config, plus a QR code.

Customers use two commands — /orderVpn and /myvpn — and every screen

they see is in Persian. Admins get a فروش VPN section inside /admin.

What it does

  • Servers. Each one is a panel (3x-ui, PasarGuard or VIPST) with a name,

two capacities, an on/off switch, and its own renewal rules.

  • Capacity, two ways. A limit on services and a limit on *traffic

sold*, either or both. A server with nothing left to sell is hidden

from the shop rather than shown as full. See Capacity below.

  • Plan groups. Plans are defined in groups, and each server sells

exactly one group. Two servers can share a group, so one price list can

cover a whole region.

  • Plans. Days, traffic in gigabytes, a device limit, a price in wallet

minor units, and an optional tag (تخفیفی, موقت, …) shown before the

name. The name is generated — ۳۰ گیگ - ۳۰ روزه — rather than typed:

a hand-written name drifts from the numbers beside it the first time a

plan is adjusted.

  • Ordering. Server → plan → checkout → paid from the wallet → the

account is created on the panel and the link and QR come back.

  • Renewal. The same plan list at the same prices, from /myvpn. It

can be switched off per server, and gated behind a window (under N days

left, or under N gigabytes left). Renewal ignores server capacity,

because the slot is already taken. A renewal adds its days and its

traffic to whatever is left rather than replacing them — see *Renewal

rules* below.

  • Paged service list. /myvpn shows twelve services per page with a

‹ قبلی / بعدی › row underneath. Telegram caps an inline keyboard at

100 buttons and rejects the message outright once the markup grows past

its size limit, so a customer with thirty services used to get nothing

at all. Opening one service and pressing Back returns to the page it was

picked from, not to the top of the list.

  • Delivery, per server. Each server decides what a customer is handed:

the subscription link, the config URIs, a QR of either, or any mix. See

What each server delivers below.

  • Account codes. Every account gets a code like K7QMX3TD. The

customer sees it, and an admin can search by it from the panel.

  • Config names. Every config link a customer is shown is renamed

from a pattern in the settings — <SERVER>-<ID> by default — rather

than carrying whatever the panel called it. See *What a config is

called* below.

  • Search. From /myvpn, once a customer has more than one service, a

🔎 button finds one by its code or by anything an addon has labelled it

with — a note, say. Always scoped to the person searching.

  • Free test. /testvpn gives a customer one small account, once ever.

Status, server, time, traffic and devices are set from the admin panel.

See Test accounts below.

  • Admin service management. /admin → فروش VPN → **🛠 مدیریت

سرویس‌ها** takes an account code and opens that account: everything

support can see, plus edit and delete. See Managing one account below.

/myvpn deliberately has no controls beyond renewal and search — no location

change, no on/off — because those belong to a separate extension. The

[ManageMyVpn](../ManageMyVpn/README.md) extension is that one, and it adds

its buttons through the hook below.

Managing one account

فروش VPN → 🛠 مدیریت سرویس‌ها → the code. The account opens on the same

block the customer sees, plus its owner and its panel-side name, and:

| | What it does |

| --- | --- |

| ⏳ انقضا | Sets the expiry to N days from now; 0 means no expiry |

| 📦 حجم | Sets the total allowance in gigabytes; 0 means unlimited. Usage is left alone |

| 📱 دستگاه | Sets the device limit; 0 means unlimited |

| ♻️ صفر کردن مصرف | Clears the traffic counter, changing nothing else |

| 🗑 حذف سرویس | Removes it from the panel and the bot, after a confirmation |

Each edit writes to the panel first and the row only after, so a refusal

leaves the two agreeing rather than the bot claiming something the server

never accepted. An account that had expired or run out becomes active again

the moment its limits move — otherwise the screen would keep calling it

expired after it was fixed.

Deleting is best effort on the panel and final in the bot: a server that is

gone, or a client an operator already removed by hand, must not leave a row

nobody can clear. The admin is told which of the two happened. Order history

is kept — it is the record that somebody paid.

Whatever another extension adds to an account (pausing, revoking, moving,

notes) appears above these buttons, on the same screen.

Extending an account screen

Another extension can add buttons and lines to the account screens — the

one in /myvpn and the one an admin gets from the code search — without

this extension knowing anything about it. Implement

Extensions\Vpn\Support\AccountAddon and name the class from your own

Extension:

```php

public static function vpnAccountAddons(): array

{

return [AccountControls::class];

}

```

AccountAddons finds it by looking for that method on every enabled

extension, the same way core collects commands and jobs. Addons are called

while a screen is being rendered, so they must be cheap and must not send

anything; anything one throws is logged and costs only its own buttons.

Implement AccountMatcher alongside it to make your own data findable from

the /myvpn search. The shop can only search what it stores — the account

code — so it asks each addon whether an account is the one the customer

meant. The candidates are always that customer's own accounts, so an addon

is never handed a query and a search can never widen past what its owner

already has.

Implement DeliveryAddon to put a button on the message that hands over a

service. That is a different moment from the account screen and usually

wants a different button: the screen is somewhere the customer navigated

to, so the button there leads further in, while the hand-over arrives

seconds after a payment and the useful button is whatever they want in the

next ten seconds. It may land on a photo caption, so keep it to one or two

short labels.

Handing a service over

Each of these messages carries what that moment needs and nothing else —

not expiry, not traffic, not location unless it is the point. They are

read once, standing up, and anything extra is in the way of the one thing

the customer opened the message for. What the plan bought is on the

service's own screen in /myvpn, a tap away.

| Moment | Text | Buttons | QR |

| --- | --- | --- | --- |

| Purchase | headline, service code, links | copy, plus any DeliveryAddon | yes |

| Renewal | headline, new volume, new expiry | back | no |

| Revoke / move | what happened, then the links | copy, back | yes |

A renewal is deliberately not a hand-over. Nothing about how the

customer connects has changed — same link, same configs, same client — so

there is nothing to scan and nothing to copy, and sending the link again

would invite them to re-add a service they already have. Two numbers

moved, and those are the whole message.

The other two are one message wherever it fits. With QR on, the text is

the caption of its own QR rather than a message followed by a picture

of itself, so scanning and copying happen in the same place and a purchase

costs one entry in the chat instead of three.

The copy button carries what the message handed over, which is the

server's decision rather than the button's: a server that sends the

subscription link puts the link on the clipboard, and one that sends

configs puts the first config there. It used to offer the subscription

link either way, so a config-only server copied a link its own message

never mentioned.

Two limits decide the shape, and both are Telegram's. A caption holds 1024

characters against a message's 4096, so an account whose configs push it

past that is sent as text with its QRs following — the old shape. And a

copy button carries at most 256 characters, with the whole message

refused if it is longer, so a config URI over that is left in the text to

select by hand rather than risked.

Requirements

  • The wallet (core). No job worker of its own — see **Keeping figures

current** below.

  • chillerlan/php-qrcode, already added to the project's composer.json.

Run composer install after pulling. The QR is rendered to a PNG

without GD, so no image extension is needed.

Install

```bash

php bin/console ext:install Vpn

php bin/console ext:enable Vpn

```

Then, in the bot, open /admin → فروش VPN and set things up in this

order. Opening the section also puts its three screens on the reply

keyboard — 🖥 سرورها, 📦 گروه‌های پلن, 🛠 مدیریت سرویس‌ها — so

they stay one tap away however far the chat has scrolled; 🏠 Admin

goes back to the rest of the panel:

1. گروه‌های پلن → گروه جدید — a group must exist before a server can

point at one.

2. Add plans inside that group — days, gigabytes, devices, price, and an

optional tag. No name is asked for; it is built from the first two.

3. سرورها → افزودن سرور — the flow asks for the panel type, a

display name, the panel URL, admin credentials, the inbound (or group

ids), a subscription base URL, the plan group, and the two capacities

(services, then traffic). When it finishes it immediately tests the

connection and tells you what happened.

How the panel answers

Every button that changes something replaces its screen with

loading ... ⏳ while the work happens, then replaces that with what

happened — the sentence on its own, over one بازگشت button back to

the screen the button was on:

```

♻️ تمدید برای این سرور باز شد.

[بازگشت]

```

The result is never a line prepended to a redraw of the screen. A

sentence above a screen the admin has already read is buried, and the

redraw leaves a stale claim about something that happened once sitting

on a screen they keep coming back to. Refusals use the same shape, with

room to say what to clear first.

What each panel needs

3x-ui

| Question | What to send |

| --- | --- |

| آدرس پنل | Base URL including the secret path, e.g. https://host:2053/abc123 |

| نام کاربری / رمز | A panel admin login |

| شناسه‌ی اینباند | The numeric inbound id new clients are added to |

| آدرس پایه‌ی اشتراک | The subscription server base, e.g. https://host:2096/sub. Optional now: without it the config links are built from the inbound instead |

The driver logs in at POST /login for a session cookie, then uses

GET /panel/api/inbounds/get/{id} (falling back to list),

POST /panel/api/inbounds/addClient, updateClient/{clientId},

{inbound}/resetClientTraffic/{email}, {inbound}/delClient/{clientId}

and GET /panel/api/inbounds/getClientTraffics/{email}. A client is

named by its email field, which is where the generated username

goes, but addressed by whichever field its protocol uses -- see the

table further down.

**A new client copies its protocol settings off the clients already on

the inbound.** 3x-ui keeps a few of these per client even though they

belong to the inbound, and they have to match or the connection is

refused:

| Protocol | Copied | Why |

| --- | --- | --- |

| vmess | security | the cipher the inbound's clients agree on — auto, chacha20-poly1305, … |

| vless | flow | an inbound built for xtls-rprx-vision refuses a client without it |

| trojan | flow | same |

Whatever the first client carrying that field uses is what a new one

gets, falling back to the panel's own default. The account's own value

wins where it has one, so a renewal — which rewrites the whole client —

never changes a cipher somebody set deliberately. The share link is

spelled with the same values rather than a hardcoded auto.

Getting this wrong is silent: the link is well-formed, the customer's app

accepts it, and it simply never connects. Shadowsocks method is not

copied — there the cipher belongs to the inbound's own settings and a

client carrying one of its own is rejected by xray.

3x-ui never returns a subscription link over the API, so the link is built

as <subscription base>/<subscription id>. Without a base URL there is

no subscription link at all, and delivery falls back to config links

built from the inbound. The subscription id starts out as the

username and only differs after an account has been revoked, which is what

stops the old link resolving; it is kept in vpn_accounts.remote_sub_id

and is what every renewal writes back, so a renewal cannot hand an account

its revoked link again.

PasarGuard

| Question | What to send |

| --- | --- |

| آدرس پنل | Base URL, e.g. https://host:8000 |

| نام کاربری / رمز | A panel admin login |

| شناسه‌ی گروه‌ها | Group ids the account joins, e.g. 1,2 |

| آدرس پایه‌ی اشتراک | -, unless the panel returns a relative link |

The driver takes a bearer token from POST /api/admin/token (password

grant, cached for five minutes) and uses POST /api/user,

GET|PUT|DELETE /api/user/{username}, POST /api/user/{username}/reset

and POST /api/user/{username}/revoke_sub.

The panel returns subscription_url itself; if it comes back relative it

is prefixed with the subscription base, or the panel URL.

That URL is an opaque token and contains no username:

```

https://sub2.example.site/sub/djMsMTM4OTgsMTc5MDE4MjA1Ng.ui6j3cNuN3_9iOAG…

```

So a PasarGuard link is never derived — nothing in the account row could

rebuild it. What the panel returned is stored and handed out as-is, and a

sync writes back whatever the panel says now, which is also how a

subscription revoked on the panel itself catches up. Only 3x-ui links

are assembled, as <آدرس پایه‌ی اشتراک>/<subscription id>, because 3x-ui

returns no subscription URL over its API at all.

A sync normally learns the link from the listing, which costs nothing

extra. Some builds leave subscription_url out of /api/users, though,

and then a listing can never correct a row — it says nothing about the

link at all. So an account whose stored link is empty, or still holds

an assembled <base>/<username> from before this was fixed, is asked for

by name through GET /api/user/{username}. That is one extra request per

broken account, once; a row already holding a real token is never asked

about, and 3x-ui and VIPST are never asked at all.

The name used is the panel's own, taken from the listing, not the one

the row derives. A client sold under an earlier naming scheme still

answers to the name it was created with — the reconciler maps that name

to the current one for its own bookkeeping and never renames anything on

the panel — so asking for the derived name would be a 404 for every one

of those customers.

Their subscription itself keeps working, because the panel serves it

under the name it has. What does not work for such a client is

anything the bot asks the panel to do to it — renew, pause, delete —

since those look it up under the derived name and will not find it. That

is by design, and the way out is to rename the client on the panel to

bot<user id>-<code>.

VIPST

VIPST is not a panel you own — it is somebody else's infrastructure sold

through a reseller API. That difference is visible in every answer below.

| Question | What to send |

| --- | --- |

| آدرس پنل | https://vipst.avalshecan.site (with or without a trailing /api/v1) |

| نام حساب | Anything, or -. It is a note for you; nothing is sent |

| کلید API | The vipst_… key from API Keys in the VIPST panel |

| شناسه‌ی لوکیشن | The numeric location id. The step lists the ones your key can sell, with 🟢/🔴 for capacity |

| آدرس پایه‌ی اشتراک | -. There is no subscription address |

The key is a bearer token on every call and carries your whole account, so

it goes in the password column with the same warning as a panel password:

delete the message after adding the server.

The driver uses POST /clients, GET|PATCH|DELETE /clients/{uid},

POST /clients/{uid}/toggle, POST /clients/{uid}/reset-traffic,

GET /clients/{uid}/link, GET /clients, GET /locations and

GET /auth/me.

A client is addressed by uid, not by name. On the other two panels

the generated username is the primary key. VIPST issues its own uid

and treats the name as a free-text remark, so the uid is what lands in

vpn_accounts.remote_uuid and what every later call uses. The remark

still carries bot<user id>-<code>, which is what the reconciler matches

on and what a human sees in the VIPST panel.

If a row ever loses its uid — the one case is a client created on VIPST

whose row then failed to save — the driver finds it again with

GET /clients?q=<remark>, matching the remark exactly and

case-sensitively. That is what lets the shop take back a client it

created for a sale it could not record.

You sell a location, not a server. VIPST picks a healthy server with

spare capacity and fails over if the first refuses, and it deliberately

never says which one it chose — there is no server name, address or

inbound id in any response. So there is nothing to build a subscription

URL from and none is asked for: the config URI the API hands back is

delivered instead, which is why a new VIPST server defaults to sending

کانفیگ + QR کانفیگ rather than a subscription link.

**Two conversions happen at the boundary, and both round in the

customer's favour.** VIPST takes whole days and whole gigabytes where

this extension works in absolute timestamps and bytes, so expiry is

ceil of the days remaining and traffic is ceil of the gigabytes.

Rounding the other way would take minutes off a renewal or shave the last

fraction of a gigabyte off an allowance somebody paid for.

Two things this API cannot do, said out loud because neither errors:

  • No device limit. There is no hwid or device field, so a plan

promising three devices is unenforceable here. The number is never

sent, and the server screen in the admin panel says so.

  • No revoke. Nothing rotates a client's credentials. The nearest

route, GET /clients/{uid}/link?refresh=true, rebuilds the URI from

the server's current configuration and issues nothing new — whoever

holds the old config still has access. Offering that as a revoke would

be a lie told to a customer who just asked to lock somebody out, so

revoke() refuses with a message saying the service has to be deleted

and recreated instead.

Listing is scoped to the server's location, because one reseller key sees

every client it owns across every location and a Botex server row is one

location. Without the filter, a reconciliation pass over one server would

see the clients of another server row as strangers and offer to delete

them. Clients VIPST has marked deleted are skipped: it keeps their

history,

Releases

Version Published Size Files
2.13.0 current 4 Nov 2026 232.6 KB 111
2.12.0 12 Oct 2026 217.9 KB 102
2.11.0 12 Oct 2026 202.5 KB 94
2.10.0 2 Oct 2026 195.6 KB 92
2.9.3 23 Sep 2026 183.8 KB 88
2.9.2 23 Sep 2026 183.6 KB 88
2.9.1 23 Sep 2026 182.8 KB 88
2.8.2 23 Sep 2026 178.9 KB 88
2.8.1 21 Sep 2026 177.9 KB 88
2.8.0 21 Sep 2026 177.3 KB 88
2.7.1 21 Sep 2026 168.7 KB 86
2.6.0 21 Sep 2026 157.5 KB 82
2.5.0 20 Sep 2026 152.4 KB 81
2.4.0 20 Sep 2026 149.5 KB 81
2.2.0 15 Sep 2026 134.7 KB 80
2.1.0 14 Sep 2026 134 KB 80
2.0.0 14 Sep 2026 132.7 KB 80
1.7.1 14 Sep 2026 130.5 KB 80
1.7.0 14 Sep 2026 129.9 KB 80
1.6.0 14 Sep 2026 127.4 KB 80
1.5.0 13 Sep 2026 123.1 KB 79
1.3.0 13 Sep 2026 122.8 KB 80
1.1.0 10 Sep 2026 121.1 KB 80