B Botex Archive

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.

older release stable 36 installs

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