diff --git a/README.md b/README.md
index aae25b8..4407969 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,12 @@
- **Telegram-уведомления** — подключение/отключение, сервер вверх/вниз, истечение подписки, авто-восстановление.
- **Двуязычный интерфейс** RU/EN + подсказки `?` у каждого действия.
- **Версии и обновления** — обновление и откат прямо из панели.
+- **Уведомления per-client** — 🔔 у каждого клиента: включить/выключить Telegram-оповещения о подключении/отключении.
+- **Telegram-бот** — управление клиентами командами прямо из чата:
+ - `/add <имя>` — создать клиента (бот пришлёт команду установки)
+ - `/del <имя>` — удалить клиента
+ - `/list` — список клиентов
+ (Работает только для чата из настроек `tg_chat_id`.)
---
diff --git a/VERSION b/VERSION
index 3eefcb9..9084fa2 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.0.0
+1.1.0
diff --git a/app.py b/app.py
index 4d13888..4ea52f9 100644
--- a/app.py
+++ b/app.py
@@ -72,6 +72,108 @@ def tg_send(text):
pass
+def notify_on(cid):
+ return load_settings().get("client_notify", {}).get(cid, True)
+
+
+def create_client_cli(name):
+ """Create a client (add + assign + fanout + install link). Returns (ok, html_msg). Used by web + Telegram bot."""
+ import re as _re
+ name = (name or "").strip()
+ if not name or not _re.match(r"^[a-zA-Z0-9_-]+$", name):
+ return False, "Имя: латиница, цифры, _ и - (без пробелов)."
+ if os.path.isdir(os.path.join(CLIENTS_DIR, name)):
+ return False, "Клиент %s уже существует." % name
+ script = "%s/repo/server/scripts/phobos-client.sh" % PHOBOS_DIR
+ try:
+ subprocess.check_output([script, "add", name], text=True, timeout=45,
+ stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()})
+ except subprocess.CalledProcessError as e:
+ return False, "Ошибка создания: %s" % (e.output or "")[-300:]
+ try:
+ auto_assign_server(name); fanout_router_config(name)
+ except Exception:
+ pass
+ cmd = ""
+ try:
+ out = subprocess.check_output([script, "link", name], text=True, timeout=25,
+ stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()})
+ for ln in out.splitlines():
+ if ln.strip().startswith("curl") or "/init/" in ln:
+ cmd = ln.strip(); break
+ except Exception:
+ pass
+ msg = "\u2705 Клиент %s создан." % name
+ if cmd:
+ msg += "\n\nКоманда установки на роутер:\n%s" % cmd
+ return True, msg
+
+
+def delete_client_cli(cid):
+ cid = (cid or "").strip()
+ if not cid or not os.path.isdir(os.path.join(CLIENTS_DIR, cid)):
+ return False, "Клиент %s не найден." % cid
+ script = "%s/repo/server/scripts/phobos-client.sh" % PHOBOS_DIR
+ try:
+ subprocess.check_output([script, "remove", cid], text=True, timeout=25,
+ stderr=subprocess.STDOUT, env={**os.environ, **_load_server_env()})
+ except subprocess.CalledProcessError as e:
+ return False, "Ошибка удаления: %s" % (e.output or "")[-300:]
+ try:
+ st = load_settings()
+ st.get("client_assignments", {}).pop(cid, None)
+ st.get("client_notify", {}).pop(cid, None)
+ save_settings(st)
+ except Exception:
+ pass
+ return True, "\U0001F5D1 Клиент %s удалён." % cid
+
+
+def telegram_bot():
+ """Poll Telegram getUpdates; allow the admin chat to /add, /del, /list clients."""
+ import urllib.request
+ offset = 0
+ while True:
+ try:
+ s = load_settings()
+ token = s.get("tg_bot_token", "")
+ chat = str(s.get("tg_chat_id", ""))
+ if not token or not chat:
+ time.sleep(15); continue
+ url = "https://api.telegram.org/bot%s/getUpdates?timeout=30&offset=%d" % (token, offset)
+ data = json.loads(urllib.request.urlopen(url, timeout=40).read())
+ for upd in data.get("result", []):
+ offset = upd["update_id"] + 1
+ m = upd.get("message") or upd.get("channel_post")
+ if not m:
+ continue
+ if str(m.get("chat", {}).get("id", "")) != chat:
+ continue
+ text = (m.get("text") or "").strip()
+ if not text.startswith("/"):
+ continue
+ parts = text.split()
+ cmd = parts[0].split("@")[0].lower()
+ arg = parts[1] if len(parts) > 1 else ""
+ if cmd in ("/add", "/new", "/create"):
+ reply = "Использование: /add <имя>" if not arg else create_client_cli(arg)[1]
+ elif cmd in ("/del", "/delete", "/remove", "/rm"):
+ reply = "Использование: /del <имя>" if not arg else delete_client_cli(arg)[1]
+ elif cmd in ("/list", "/ls"):
+ cls = [c.get("client_id", "") for c in get_clients()]
+ reply = ("Клиенты (%d):\n" % len(cls)) + "\n".join("\u2022 " + c for c in cls) if cls else "Клиентов нет."
+ elif cmd in ("/help", "/start"):
+ reply = ("Команды PCA Phobos:\n"
+ "/add <имя> — создать клиента\n"
+ "/del <имя> — удалить клиента\n"
+ "/list — список клиентов")
+ else:
+ continue
+ tg_send(reply)
+ except Exception:
+ time.sleep(10)
+
+
def get_wg_peers():
"""Parse `wg show wg0` to get active peers with transfer/handshake info."""
try:
@@ -554,13 +656,15 @@ def session_monitor():
client_id, real_ip = key
label = labels.get(real_ip, "")
name = f"{label} ({real_ip})" if label else real_ip
- tg_send(f"🟢 {client_id} connected — {name}")
+ if notify_on(client_id):
+ tg_send(f"🟢 {client_id} connected — {name}")
for key in prev_session_keys - current_keys:
client_id, real_ip = key
label = labels.get(real_ip, "")
name = f"{label} ({real_ip})" if label else real_ip
- tg_send(f"🔴 {client_id} disconnected — {name}")
+ if notify_on(client_id):
+ tg_send(f"🔴 {client_id} disconnected — {name}")
prev_session_keys = current_keys
@@ -599,6 +703,8 @@ def session_monitor():
monitor_thread = threading.Thread(target=session_monitor, daemon=True)
monitor_thread.start()
+bot_thread = threading.Thread(target=telegram_bot, daemon=True)
+bot_thread.start()
PAGE = """
@@ -874,6 +980,15 @@ def clients_page():
except subprocess.CalledProcessError as e:
msg = f'