Extensions
/
VpnUpdater
VPN Updater 2.0.0
Keeps the shop's accounts and the panels agreeing: adopts accounts created on a panel by hand, forgets rows whose client is gone, deletes services that ran out days ago, and warns customers before their traffic or time runs out.
You are looking at 2.0.0. The current release is 2.3.2.
Install
Install it
php bin/console ext:install VpnUpdater --version=2.0.0
Update it later
php bin/console ext:update VpnUpdater
Remove it
php bin/console ext:remove VpnUpdater
Add --dry-run to any of these to see exactly which files
would change before anything is written.
Readme
VPN Updater
Keeps the shop's idea of an account and the panel's agreeing, and warns a
customer before theirs runs out.
The shop writes to a panel when somebody buys, renews, or is managed.
Nothing tells it when an operator deletes a client by hand, adds one
outside the bot, or when a service quietly expires and sits there. That
drift is what this extension closes, on a schedule, in both directions.
Requires the VPN Shop
An addition to [Vpn](../Vpn/README.md) 2.2.0 or newer, declared in its
extension.json:
```json
"requires": { "extensions": { "Vpn": ">=2.2.0" } }
```
Core enforces that: ext:install and ext:enable refuse while the shop is
missing, disabled, or older, and refuse to disable or remove the shop while
this is enabled (--force overrides). If the shop's files disappear anyway,
this extension contributes nothing rather than crashing the bot.
The floor is not cosmetic. Reconciling needs PanelInterface::accounts(),
which the shop gained in 1.3.0, and reading an owner back out of a panel
name needs VpnSettings::parseRemoteName(), which it gained in 1.6.0.
Install
```bash
php bin/console ext:install VpnUpdater
```
Then turn it on: /admin → همگامسازی VPN → 🟢 فعال کردن.
It does not arm itself. Installing adds two tables and an admin screen
and nothing else runs. The shop can schedule its own sync from the first
sale, because a sale is a moment; a janitor has no such moment, and
install() is static with no container to schedule through. More to the
point: this thing deletes accounts, and an extension that starts deleting
the moment it lands is a bad trade for the one command it saves.
Both jobs need the worker running:
```bash
php bin/console jobs:work
```
The two jobs
reconcile — every two minutes by default. Per server: read the panel once,
adopt clients the shop has never heard of, write the panel's usage and
expiry onto the rows that matched, forget rows whose client is gone.
Then, once, over every account: delete the ones that ran out days ago.
remind — every minute by default. Reads the stored figures
rather than the panels; the reconcile job is what keeps those current.
Asking every panel about every account at that rate would be the most
expensive thing this bot does, and a reminder a minute behind the panel is
still a reminder.
Both are keyed, so arming twice leaves one job rather than two racing each
other across the same panels.
The panel screen shows 🔴 غیرفعال whenever a job would not actually
run — not only when it was never armed, but when it stopped: paused,
failed out of its attempts, or finished. Pressing 🟢 فعال کردن again
repairs the row rather than finding one already there and leaving it.
Reconciling, and why it is so careful
The asymmetry that shapes every guard here: adopting a client that turns out
to be somebody else's costs an admin one delete. Deleting rows because a
panel had a bad minute costs customers their service.
So, in order:
Step 2 is also where the shop's stored usage, expiry and status come
from. The listing already carries all three for every account, so the
figures are written from it directly. The shop used to run a sync job
that re-read each account from its panel one at a time; that job is
gone, and this replaced it — one request per server instead of one or
two per customer. A bot running the shop without this extension no
longer refreshes those figures on a schedule at all.
1. A server that throws is skipped entirely. Unreachable, misconfigured
or refusing all mean no answer, and none of them mean no accounts.
The pass moves to the next server; nothing on this one is judged.
2. **A panel answering "no accounts" while the shop has rows there is
refused**, unless allow_empty_panel says otherwise. It is the one shape
a broken read and a genuinely empty panel share.
3. Refresh what matched, from the listing already in hand.
4. Forget rows whose client is gone — the row only; the client is
already gone. A row younger than min_age_minutes is never judged
missing, because a purchase writes the panel first and the row second and
that row may be mid-flight.
5. Adopt last, and never fatally. Adoption is the only step that writes
a row, so it is the only one that can fail on a database constraint. It
used to run first, and a single client it could not insert threw straight
out of the pass — taking refreshing and forgetting with it, for that
server and every server after it, on every run, for ever. Now each
adoption is attempted on its own and a refusal is counted as
adoptFailed, not thrown.
6. Delete last of all, so an account adopted in step 5 has its grace
period measured from now rather than being removed in the same breath.
Adopted accounts are matched by the name the panel knows them by, looked up
across every server rather than just this one: an account that moved
between locations can leave its old client behind when the old panel refuses
the delete, and adopting that copy would sell the same service twice.
Adopting versus recovering
The shop names its clients bot<user id>-<account code> (see
[Vpn](../Vpn/README.md)), so most of what gets adopted is not a stranger at
all — it is this bot's own account with its row missing: a database
restored from an older backup, or a purchase whose panel write landed and
whose row did not.
Those are recovered, not adopted: the name says whose the account is and
what code it was sold under, so it goes back to that customer, under that
code — which means the code in their earlier messages, and in any support
thread, still finds it.
Two things are confirmed rather than trusted, because a name outlives what
it points at:
- the user must still exist. A name referring to a deleted user does not
resurrect them; that account falls back to the admin.
- the code must still be free. If something else has taken it since, a
fresh one is issued — two accounts sharing a code would break every lookup
that assumes one.
A client named anything else is not adopted at all. There is one way to
name an account (see [Vpn](../Vpn/README.md)) and this is not it, so there
is no identity such a client could be given that its own name would agree
with — and nothing would recognise it as adopted next time round, so it
would be adopted again on every pass, for ever. It is counted as foreign,
reported, and left exactly where it is.
forget_missing reads the panel, and only the panel
The direction matters and only goes one way:
```
panel state → local database cleanup
```
Never the reverse. forget_missing deletes rows. It never deletes,
renames, disables or edits a client on a panel — the client it is looking
for is by definition already absent, and every other client on that panel is
somebody else's business.
For each row, exactly one name is expected:
```php
RemoteName::for((int) $account->user_id, (string) $account->code)
```
The code goes in exactly as it is stored. If that precise name is not in the
listing, the client is missing and the row goes — with the reminders and
grace clock recorded against it.
Case is part of the identity. bot1-4LXE3NZC and bot1-4lxe3nzc are
two different clients, and one is not evidence for the other. A row expecting
the first, on a panel holding only the second, is a row whose client is
missing: it is forgotten, and the client that is there is left untouched.
Nothing here folds case, and no guard exists to keep a row alive because
something similar is on the panel — such a guard existed briefly and was
removed, because it contradicted this switch outright.
There is one consequence worth knowing about. The code column is unique,
and MySQL compares it case-insensitively, so a code this extension reads
as free — identity is decided by an exact comparison, which no collation can
soften — can still collide with a differently cased row. That is why the row
is forgotten before adoption is attempted, and why a refused adoption is
counted rather than thrown. In practice: the stale row goes, and on the same
pass the client that genuinely exists is recovered under its own exact code.
Two further things are confirmed rather than trusted, because a name
outlives what it points at. Both are counted as orphaned and left alone:
- the user it names must still exist;
- the code it names must still be free, or two accounts would share one.
Foreign clients are ignored, never touched
The panel listing is filtered once, at the top of each server's pass: any
client whose name does not parse as bot<user id>-<code> is counted as
foreign ignored and dropped. From that point it does not exist as far as
this extension is concerned.
It is not adopted, not refreshed into a row, not renamed, not paused, not
moved, and — importantly — not deleted. It belongs to somebody else or
was made by hand, and neither is this extension's business.
It also cannot stand in for a Botex account. Presence is decided by the
account's own exact canonical name, so a foreign client on the same server
never protects a row whose client has genuinely gone, and never keeps one
alive that should be forgotten.
There is no case-folding anywhere in this. bot1-abc and bot1-ABC are
different accounts; a client under one of them says nothing about the
other. See [Vpn](../Vpn/README.md) for what that means when upgrading a
live install.
A recovered account has no plan: the row that knew which plan it was
sold on is precisely the row that went missing, and inventing one would
invent a renewal price.
There is no adopt_owner setting any more. It only ever answered "whose is
this unnamed client?", and that question cannot arise now.
Deleting exhausted services
An expired account and a drained one are the same outcome for a customer and
two different measurements here. An expiry date is on the account; running
out of traffic is only ever observed. So the first sighting of an unusable
account is written to vpnupdater_grace, and the clock runs from there —
for expired accounts too, never from the expiry date itself. While the sweep
runs on its schedule the two are minutes apart; they only differ for an
account nobody was watching (the first pass after arming, or one just
adopted), and that account gets the full grace period rather than being
deleted on the pass that found it.
After grace_days (2), the account is deleted from the panel and from the
shop — in that order, and only in that order.
This deletion is strict, unlike the one an admin presses. If the panel
refuses, times out, or cannot be reached, the row is kept exactly as it is,
the grace clock is left running so the next pass is due to try again, and
the reason is recorded. Nothing about the account changes.
Dropping the row anyway would turn a five-minute panel outage into a client
that is still live, still carrying traffic, and no longer known to the shop
— because the row that named it is precisely the row that was deleted.
There is no way back from that, and there is an obvious way back from
trying again in two minutes. The pass reports these as
kept after a failed delete.
The first sighting never deletes on the same pass that discovered it, and an
account that becomes usable again — renewed, topped up — has its clock
cleared. An account that drained, was topped up, and drained again months
later is measured from the second time, not the first.
Set delete_on_grace to false to keep marking without ever deleting.
Reminders
Two ladders, traffic and time, crossed downwards:
| | thresholds |
|---|---|
| traffic remaining | 5 GB, 2 GB, 100 MB |
| time remaining | 3 days, 1 day, 2 hours |
The rules that make this bearable to receive rather than a stream of noise:
- one message per threshold per subscription window, ever;
- when several are crossed at once — a customer who used six gigabytes
between two runs — only the lowest is announced, and the ones jumped
over are recorded as said so they cannot fire later, out of order, telling
someone with 80 MB left that they are down to 2 GB;
- a renewal changes the window and every threshold arms again. The window is
a fingerprint of expiry plus allowance, so nothing has to notice that a
renewal happened;
- a threshold is only announced if the account was seen above it in the
same window. The first time a window is seen — a purchase, a renewal, the
first pass after arming — every mark it already starts under is recorded
silently. A 2 GB or one-day plan is therefore not told it is running out a
minute after it was bought; it hears about the next mark down when it
actually gets there;
- an account with no limit at all is not reminded about it: "unlimited" has
no threshold to cross, and a warning that it is nearly gone would simply
be false;
- a service that has already stopped working is not reminded either. The
grace sweep speaks for those.
A customer who blocked the bot does not fail the run, and is not retried
into a loop: the threshold is recorded before the message is attempted.
The admin screen
/admin → همگامسازی VPN shows whether it is armed, when each job next
runs, how many times each has run and failed, the last error, the rules
currently in force, and how many accounts are sitting in the grace table.
🔄 اجرای تطبیق and ⏳ اجرای یادآوری only move a job's next run
forward; the work still happens in the worker. Walking every panel inside a
callback would hold a webhook open for as long as the slowest server takes,
and Telegram would have retried the press long before that finished.
Turning it off stops the deletions and the reminders. The screen says so.
Settings
extensions/VpnUpdater/settings.json, all editable from the settings store:
| key | default | meaning |
|---|---|---|
| reconcile_interval | 120 | seconds between panel passes (min 60) |
| remind_interval | 60 | seconds between reminder passes (min 30) |
| adopt_unknown | true | take panel-created clients into the shop |
| forget_missing | true | drop rows whose client is gone from the panel |
| min_age_minutes | 10 | never judge a row younger than this |
| allow_empty_panel | false | trust a panel that reports zero accounts |
| grace_days | 2 | days after running out before deletion |
| delete_on_grace | true | actually delete, rather than only marking |
| notify_grace | true | tell the owner when their service is deleted |
| traffic_thresholds | [5 GB, 2 GB, 100 MB] | bytes, largest first |
| time_thresholds | [3 d, 1 d, 2 h] | seconds, largest first |
| notify_reminders | true | send the reminders at all |
Intervals are clamped only against a typo — a panel pass can go as tight
as a minute, and the reminder pass, which talks to no panel at all, as
tight as thirty seconds. Both threshold lists are sorted largest-first
whatever order they are written in.
Change a setting with php bin/console ext:set VpnUpdater <key> <value>
rather than by editing settings.json: that file is the shipped
default, and an update replaces the extension folder wholesale, while
overrides live in storage/extension-settings/ and survive.
A changed interval is applied by pressing 🟢 فعال کردن again — arming
rewrites a job whose cadence no longer matches the settings, and leaves a
healthy one at the right cadence alone.
Tables
vpnupdater_reminders — one row per (account, kind, threshold, window),
unique, which is what makes "once, ever" true even with two workers. A row
with threshold 0 only marks the window as seen; it was never sent and is
left out of the count on the admin screen.
vpnupdater_grace — one row per account, unique, holding the first moment
it was seen with nothing left.
Both are dropped by ext:remove. Accounts are left exactly as they are, on
the panel and in the shop: removing the janitor is not a decision about what
it had been tidying.
Releases
| Version | Published | Size | Files |
|---|---|---|---|
| 2.3.2 current | 7 Oct 2026 | 47.2 KB | 18 |
| 2.3.1 | 23 Sep 2026 | 43.9 KB | 18 |
| 2.3.0 | 21 Sep 2026 | 40.1 KB | 17 |
| 2.2.1 | 15 Sep 2026 | 40 KB | 17 |
| 2.2.0 | 14 Sep 2026 | 36.8 KB | 17 |
| 2.1.0 | 14 Sep 2026 | 36.9 KB | 17 |
| 2.0.0 | 14 Sep 2026 | 35.8 KB | 17 |
| 1.3.1 | 14 Sep 2026 | 34.8 KB | 17 |
| 1.3.0 | 14 Sep 2026 | 34.7 KB | 17 |
| 1.2.0 | 14 Sep 2026 | 34.8 KB | 17 |
| 1.1.0 | 13 Sep 2026 | 32.8 KB | 17 |
| 1.0.0 | 13 Sep 2026 | 29.8 KB | 17 |