Add VK callback auth support and admin demotion
Some checks are pending
Desktop Release / release (push) Waiting to run
Desktop CI / tests (push) Successful in 1m51s

This commit is contained in:
Денисов Александр Андреевич
2026-06-05 19:01:52 +03:00
parent 5a3e4c188e
commit 0a82ad7e3e
10 changed files with 520 additions and 42 deletions

View File

@@ -1,6 +1,5 @@
import os import os
import sys import sys
import time
import json import json
import threading import threading
from urllib.parse import urlparse, parse_qs, unquote from urllib.parse import urlparse, parse_qs, unquote
@@ -8,6 +7,13 @@ from urllib.parse import urlparse, parse_qs, unquote
import webview import webview
AUTH_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
)
def extract_token(url_string): def extract_token(url_string):
token = None token = None
expires_in = 3600 expires_in = 3600
@@ -50,28 +56,59 @@ def extract_token(url_string):
def main_auth(auth_url, output_path): def main_auth(auth_url, output_path):
stop_polling = threading.Event()
polling_started = threading.Event()
def poll_url(): def poll_url():
try: while not stop_polling.wait(0.75):
url = window.get_current_url() try:
except Exception: url = window.get_current_url()
url = None except Exception:
if url: continue
if not url:
continue
token, expires_in = extract_token(url) token, expires_in = extract_token(url)
if token: if not token:
data = {"token": token, "expires_in": expires_in} continue
with open(output_path, "w", encoding="utf-8") as f:
json.dump(data, f) stop_polling.set()
data = {"token": token, "expires_in": expires_in}
with open(output_path, "w", encoding="utf-8") as f:
json.dump(data, f)
try:
window.destroy() window.destroy()
return except Exception:
threading.Timer(0.5, poll_url).start() pass
return
def start_polling():
if polling_started.is_set():
return
polling_started.set()
thread = threading.Thread(target=poll_url, name="vk-auth-url-poller", daemon=True)
thread.start()
def on_loaded(): def on_loaded():
threading.Timer(0.5, poll_url).start() start_polling()
def on_closed():
stop_polling.set()
window = webview.create_window("VK Авторизация", auth_url) window = webview.create_window("VK Авторизация", auth_url)
window.events.loaded += on_loaded window.events.loaded += on_loaded
storage_path = os.path.join(os.path.dirname(output_path), "webview_profile") try:
webview.start(private_mode=False, storage_path=storage_path) window.events.closed += on_closed
except Exception:
pass
webview.start(
start_polling,
gui="edgechromium" if os.name == "nt" else None,
private_mode=True,
storage_path=None,
user_agent=AUTH_USER_AGENT,
)
stop_polling.set()
def main(): def main():

View File

@@ -1,3 +1,4 @@
import hashlib
import os import os
import shutil import shutil
import subprocess import subprocess
@@ -62,6 +63,24 @@ def ensure_project_root():
sys.exit(1) sys.exit(1)
def write_sha256_file(file_path):
if not os.path.exists(file_path):
print(f"[ERROR] Cannot create SHA256, file not found: {file_path}")
sys.exit(1)
digest = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
checksum_path = f"{file_path}.sha256"
file_name = os.path.basename(file_path)
with open(checksum_path, "w", encoding="utf-8", newline="\n") as f:
f.write(f"{digest.hexdigest()} {file_name}\n")
print(f"[OK] SHA256 created: {checksum_path}")
return checksum_path
def run_build(): def run_build():
print(f"--- 1. Running PyInstaller for {APP_NAME} v{VERSION} ---") print(f"--- 1. Running PyInstaller for {APP_NAME} v{VERSION} ---")
icon_abs_path = os.path.abspath(ICON_PATH) icon_abs_path = os.path.abspath(ICON_PATH)
@@ -149,8 +168,9 @@ def create_archive():
try: try:
# Создаем zip-архив из папки DIST_DIR # Создаем zip-архив из папки DIST_DIR
# base_name - имя файла без расширения, format - 'zip', root_dir - что упаковываем # base_name - имя файла без расширения, format - 'zip', root_dir - что упаковываем
shutil.make_archive(os.path.join("dist", ARCHIVE_NAME), 'zip', DIST_DIR) archive_path = shutil.make_archive(os.path.join("dist", ARCHIVE_NAME), 'zip', DIST_DIR)
print(f"[OK] Archive created: dist/{ARCHIVE_NAME}.zip") print(f"[OK] Archive created: {archive_path}")
write_sha256_file(archive_path)
except Exception as e: except Exception as e:
print(f"[ERROR] Failed to create archive: {e}") print(f"[ERROR] Failed to create archive: {e}")
@@ -245,6 +265,7 @@ def build_installer():
print(f"[ERROR] Installer was not created: {installer_path}") print(f"[ERROR] Installer was not created: {installer_path}")
sys.exit(1) sys.exit(1)
print(f"[OK] Installer created: {installer_path}") print(f"[OK] Installer created: {installer_path}")
write_sha256_file(installer_path)
except Exception as e: except Exception as e:
print(f"[ERROR] Failed to build installer: {e}") print(f"[ERROR] Failed to build installer: {e}")
sys.exit(1) sys.exit(1)
@@ -268,5 +289,7 @@ if __name__ == "__main__":
print("\n" + "=" * 30) print("\n" + "=" * 30)
print("BUILD COMPLETED") print("BUILD COMPLETED")
print(f"Release archive: dist/{ARCHIVE_NAME}.zip") print(f"Release archive: dist/{ARCHIVE_NAME}.zip")
print(f"Release archive SHA256: dist/{ARCHIVE_NAME}.zip.sha256")
print(f"Installer: dist/{INSTALLER_NAME}") print(f"Installer: dist/{INSTALLER_NAME}")
print(f"Installer SHA256: dist/{INSTALLER_NAME}.sha256")
print("=" * 30) print("=" * 30)

View File

@@ -0,0 +1,35 @@
server {
listen 80;
server_name vk.daemonlord.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name vk.daemonlord.ru;
ssl_certificate /etc/letsencrypt/live/vk.daemonlord.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vk.daemonlord.ru/privkey.pem;
location = /vk/callback {
proxy_pass http://127.0.0.1:8787/vk/callback;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
}
location = /health {
proxy_pass http://127.0.0.1:8787/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
location / {
return 404;
}
}

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Anabasis VK OAuth callback host
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/anabasis-chat-remove
ExecStart=/usr/bin/python3 /opt/anabasis-chat-remove/deploy/vk_callback_server.py --host 127.0.0.1 --port 8787 --quiet
Restart=always
RestartSec=3
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target

81
deploy/vk-auth-setup.md Normal file
View File

@@ -0,0 +1,81 @@
# VK OAuth callback setup
The desktop app uses this redirect URI by default:
```text
https://vk.daemonlord.ru/vk/callback
```
The public HTTPS endpoint is expected to be handled by a reverse proxy. The
backend callback host itself is plain HTTP on a non-standard local port:
```text
http://127.0.0.1:8787/vk/callback
```
## VK app settings
1. Open the VK developer dashboard.
2. Select the standalone app with ID `54454043`.
3. Make sure the app type is `Standalone application`.
4. Add the exact redirect URI:
```text
https://vk.daemonlord.ru/vk/callback
```
If the redirect URI in the OAuth request does not exactly match the app settings,
VK can return:
```json
{"error":"invalid_request","error_description":"Security Error"}
```
## Backend callback host
Run the local HTTP callback host:
```bash
python deploy/vk_callback_server.py --host 127.0.0.1 --port 8787
```
Health check:
```text
http://127.0.0.1:8787/health
```
Optional systemd unit:
```text
deploy/systemd/anabasis-vk-callback.service
```
Adjust `WorkingDirectory`, `ExecStart`, and `User` for the server path/user.
The callback page does not process or store the token. With implicit OAuth, VK
puts `access_token` in the URL fragment. The desktop webview reads that final URL
directly from the embedded browser.
## Reverse proxy
Use the nginx example:
```text
deploy/nginx/vk.daemonlord.ru.conf
```
It proxies:
```text
https://vk.daemonlord.ru/vk/callback -> http://127.0.0.1:8787/vk/callback
```
## Desktop app override
The app already defaults to `https://vk.daemonlord.ru/vk/callback`. To override it:
```powershell
$env:ANABASIS_VK_REDIRECT_URI = "https://vk.daemonlord.ru/vk/callback"
python main.py
```

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VK authorization</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: Arial, sans-serif;
color: #1f2937;
background: #f8fafc;
}
main {
width: min(520px, calc(100vw - 32px));
padding: 24px;
border: 1px solid #dbe3ef;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
}
h1 {
margin: 0 0 12px;
font-size: 20px;
line-height: 1.3;
}
p {
margin: 0;
font-size: 15px;
line-height: 1.5;
}
</style>
</head>
<body>
<main>
<h1>Авторизация VK завершена</h1>
<p>Вернитесь в приложение. Это окно можно закрыть после завершения входа.</p>
</main>
</body>
</html>

View File

@@ -0,0 +1,78 @@
import argparse
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8787
CALLBACK_PATH = "/vk/callback"
HEALTH_PATH = "/health"
def _read_callback_html():
html_path = Path(__file__).resolve().parent / "vk-callback" / "index.html"
return html_path.read_bytes()
class VkCallbackHandler(BaseHTTPRequestHandler):
server_version = "AnabasisVkCallback/1.0"
def do_GET(self):
path = self.path.split("?", 1)[0]
if path == HEALTH_PATH:
self._send_text(200, "ok\n")
return
if path in ("/", CALLBACK_PATH):
self._send_html(200, _read_callback_html())
return
self._send_text(404, "not found\n")
def log_message(self, fmt, *args):
if getattr(self.server, "quiet", False):
return
super().log_message(fmt, *args)
def _send_html(self, status, body):
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_text(self, status, text):
body = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def parse_args():
parser = argparse.ArgumentParser(description="Anabasis VK OAuth callback host")
parser.add_argument("--host", default=os.getenv("ANABASIS_VK_CALLBACK_HOST", DEFAULT_HOST))
parser.add_argument("--port", type=int, default=int(os.getenv("ANABASIS_VK_CALLBACK_PORT", DEFAULT_PORT)))
parser.add_argument("--quiet", action="store_true")
return parser.parse_args()
def main():
args = parse_args()
server = ThreadingHTTPServer((args.host, args.port), VkCallbackHandler)
server.quiet = args.quiet
print(f"VK callback server listening on http://{args.host}:{args.port}{CALLBACK_PATH}")
print(f"Health check: http://{args.host}:{args.port}{HEALTH_PATH}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

156
main.py
View File

@@ -3,6 +3,7 @@ import os
import shutil import shutil
import sys import sys
import time import time
from urllib.parse import urlencode
from PySide6.QtCore import QProcess from PySide6.QtCore import QProcess
from PySide6.QtCore import QStandardPaths from PySide6.QtCore import QStandardPaths
@@ -38,18 +39,24 @@ APP_DATA_DIR = os.path.join(
TOKEN_FILE = os.path.join(APP_DATA_DIR, "token.json") TOKEN_FILE = os.path.join(APP_DATA_DIR, "token.json")
SETTINGS_FILE = os.path.join(APP_DATA_DIR, "settings.json") SETTINGS_FILE = os.path.join(APP_DATA_DIR, "settings.json")
WEB_ENGINE_CACHE_DIR = os.path.join(APP_DATA_DIR, "web_engine_cache") WEB_ENGINE_CACHE_DIR = os.path.join(APP_DATA_DIR, "web_engine_cache")
AUTH_WEBVIEW_PROFILE_DIR = os.path.join(APP_DATA_DIR, "webview_profile")
CACHE_CLEANUP_MARKER = os.path.join(APP_DATA_DIR, "pending_cache_cleanup") CACHE_CLEANUP_MARKER = os.path.join(APP_DATA_DIR, "pending_cache_cleanup")
LOG_FILE = os.path.join(APP_DATA_DIR, "app.log") LOG_FILE = os.path.join(APP_DATA_DIR, "app.log")
LOG_MAX_BYTES = 1024 * 1024 # 1 MB LOG_MAX_BYTES = 1024 * 1024 # 1 MB
LOG_BACKUP_FILE = os.path.join(APP_DATA_DIR, "app.log.1") LOG_BACKUP_FILE = os.path.join(APP_DATA_DIR, "app.log.1")
AUTH_RELOGIN_BACKOFF_SECONDS = 5.0 AUTH_RELOGIN_BACKOFF_SECONDS = 5.0
VK_APP_ID = "54454043"
VK_AUTH_SCOPE = "1073737727"
VK_API_VERSION = "5.131"
VK_AUTH_REDIRECT_URI = os.getenv("ANABASIS_VK_REDIRECT_URI", "https://vk.daemonlord.ru/vk/callback").strip() or "https://vk.daemonlord.ru/vk/callback"
# Legacy owner/repo format for GitHub-only fallback. # Legacy owner/repo format for GitHub-only fallback.
UPDATE_REPOSITORY = "" UPDATE_REPOSITORY = ""
# Full repository URL is preferred (supports GitHub/Gitea). # Full repository URL is preferred (supports GitHub/Gitea).
UPDATE_REPOSITORY_URL = "https://git.daemonlord.ru/benya/AnabasisChatRemove" UPDATE_REPOSITORY_URL = "https://git.daemonlord.ru/benya/AnabasisChatRemove"
UPDATE_CHANNEL_DEFAULT = "stable" UPDATE_CHANNEL_DEFAULT = "stable"
UPDATE_REQUEST_TIMEOUT = 8 UPDATE_REQUEST_TIMEOUT = 8
AUTH_ERROR_CONTEXTS = ("load_chats", "execute_user_action", "set_user_admin") AUTH_ERROR_CONTEXTS = ("load_chats", "execute_user_action", "set_user_admin", "unset_user_admin")
VK_AUTH_URL_OVERRIDE = ""
def get_resource_path(relative_path): def get_resource_path(relative_path):
@@ -80,8 +87,14 @@ class BulkActionWorker(QObject):
def _is_auth_error(exc): def _is_auth_error(exc):
return VkService.is_auth_error(exc, str(exc).lower()) return VkService.is_auth_error(exc, str(exc).lower())
def _role_context(self):
return "unset_user_admin" if self.action_type == "unadmin" else "set_user_admin"
def _role_action_name(self):
return "разжалования администраторов" if self.action_type == "unadmin" else "назначения администраторов"
def _emit_progress(self, processed): def _emit_progress(self, processed):
label = "admin" if self.action_type == "admin" else ("remove" if self.action_type == "remove" else "add") label = self.action_type if self.action_type in ("remove", "add", "admin", "unadmin") else "add"
self.progress.emit(processed, self.total, label) self.progress.emit(processed, self.total, label)
def run(self): def run(self):
@@ -90,7 +103,7 @@ class BulkActionWorker(QObject):
try: try:
for chat in self.selected_chats: for chat in self.selected_chats:
peer_id = None peer_id = None
if self.action_type == "admin": if self.action_type in ("admin", "unadmin", "remove"):
try: try:
peer_id = 2000000000 + int(chat["id"]) peer_id = 2000000000 + int(chat["id"])
except (ValueError, TypeError): except (ValueError, TypeError):
@@ -103,6 +116,19 @@ class BulkActionWorker(QObject):
for user_id, user_info in self.user_infos.items(): for user_id, user_info in self.user_infos.items():
try: try:
if self.action_type == "remove": if self.action_type == "remove":
try:
self.vk_call_with_retry(
self.vk.messages.setMemberRole,
peer_id=peer_id,
member_id=user_id,
role="member",
)
results.append(f"'{user_info}' разжалован перед исключением из '{chat['title']}'.")
except VkApiError as demote_exc:
if self._is_auth_error(demote_exc):
self.auth_error.emit("unset_user_admin", demote_exc, "разжалования администратора перед исключением")
return
results.append(f"Не удалось разжаловать '{user_info}' в '{chat['title']}': {demote_exc}")
self.vk_call_with_retry(self.vk.messages.removeChatUser, chat_id=chat["id"], member_id=user_id) self.vk_call_with_retry(self.vk.messages.removeChatUser, chat_id=chat["id"], member_id=user_id)
results.append(f"'{user_info}' исключен из '{chat['title']}'.") results.append(f"'{user_info}' исключен из '{chat['title']}'.")
elif self.action_type == "add": elif self.action_type == "add":
@@ -119,12 +145,20 @@ class BulkActionWorker(QObject):
role="admin", role="admin",
) )
results.append(f"'{user_info}' назначен админом в '{chat['title']}'.") results.append(f"'{user_info}' назначен админом в '{chat['title']}'.")
elif self.action_type == "unadmin":
self.vk_call_with_retry(
self.vk.messages.setMemberRole,
peer_id=peer_id,
member_id=user_id,
role="member",
)
results.append(f"'{user_info}' разжалован в '{chat['title']}'.")
else: else:
raise RuntimeError(f"Unknown action: {self.action_type}") raise RuntimeError(f"Unknown action: {self.action_type}")
except VkApiError as exc: except VkApiError as exc:
if self._is_auth_error(exc): if self._is_auth_error(exc):
context = "set_user_admin" if self.action_type == "admin" else "execute_user_action" context = self._role_context() if self.action_type in ("admin", "unadmin") else "execute_user_action"
action_name = "назначения администраторов" if self.action_type == "admin" else "выполнения операций с пользователями" action_name = self._role_action_name() if self.action_type in ("admin", "unadmin") else "выполнения операций с пользователями"
self.auth_error.emit(context, exc, action_name) self.auth_error.emit(context, exc, action_name)
return return
results.append(f"✗ Ошибка для '{user_info}' в '{chat['title']}': {exc}") results.append(f"✗ Ошибка для '{user_info}' в '{chat['title']}': {exc}")
@@ -327,6 +361,12 @@ class VkChatManager(QMainWindow):
tools_menu.addAction(make_admin_action) tools_menu.addAction(make_admin_action)
self.make_admin_action = make_admin_action self.make_admin_action = make_admin_action
unset_admin_action = QAction("Разжаловать администратора", self)
unset_admin_action.setStatusTip("Снять права администратора с выбранных пользователей в выбранных чатах")
unset_admin_action.triggered.connect(self.unset_user_admin)
tools_menu.addAction(unset_admin_action)
self.unset_admin_action = unset_admin_action
check_updates_action = QAction("Проверить обновления", self) check_updates_action = QAction("Проверить обновления", self)
check_updates_action.setStatusTip("Проверить наличие новой версии приложения") check_updates_action.setStatusTip("Проверить наличие новой версии приложения")
check_updates_action.triggered.connect(self.check_for_updates) check_updates_action.triggered.connect(self.check_for_updates)
@@ -629,6 +669,8 @@ class VkChatManager(QMainWindow):
self._clear_chat_tabs() self._clear_chat_tabs()
if hasattr(self, "make_admin_action"): if hasattr(self, "make_admin_action"):
self.make_admin_action.setEnabled(authorized and (not self._auth_ui_busy)) self.make_admin_action.setEnabled(authorized and (not self._auth_ui_busy))
if hasattr(self, "unset_admin_action"):
self.unset_admin_action.setEnabled(authorized and (not self._auth_ui_busy))
if hasattr(self, "logout_action"): if hasattr(self, "logout_action"):
self.logout_action.setEnabled(not self._auth_ui_busy) self.logout_action.setEnabled(not self._auth_ui_busy)
@@ -639,6 +681,8 @@ class VkChatManager(QMainWindow):
self.logout_action.setEnabled(not in_progress) self.logout_action.setEnabled(not in_progress)
if hasattr(self, "make_admin_action"): if hasattr(self, "make_admin_action"):
self.make_admin_action.setEnabled(not in_progress and self.token is not None) self.make_admin_action.setEnabled(not in_progress and self.token is not None)
if hasattr(self, "unset_admin_action"):
self.unset_admin_action.setEnabled(not in_progress and self.token is not None)
self.auth_btn.setEnabled((self.token is None) and (not in_progress)) self.auth_btn.setEnabled((self.token is None) and (not in_progress))
def _set_busy(self, busy, status_text=None): def _set_busy(self, busy, status_text=None):
@@ -702,7 +746,12 @@ class VkChatManager(QMainWindow):
clear_inputs_on_success=True, clear_inputs_on_success=True,
): ):
total = max(1, len(selected_chats) * len(user_infos)) total = max(1, len(selected_chats) * len(user_infos))
self._bulk_action_context = "set_user_admin" if action_type == "admin" else "execute_user_action" if action_type == "admin":
self._bulk_action_context = "set_user_admin"
elif action_type == "unadmin":
self._bulk_action_context = "unset_user_admin"
else:
self._bulk_action_context = "execute_user_action"
self._bulk_action_success_message_title = success_message_title self._bulk_action_success_message_title = success_message_title
self._bulk_clear_inputs_on_success = clear_inputs_on_success self._bulk_clear_inputs_on_success = clear_inputs_on_success
self._log_event( self._log_event(
@@ -737,7 +786,12 @@ class VkChatManager(QMainWindow):
thread.start() thread.start()
def _on_bulk_action_progress(self, processed, total, label): def _on_bulk_action_progress(self, processed, total, label):
label_text = {"remove": "исключение", "add": "приглашение", "admin": "назначение админов"}.get(label, label) label_text = {
"remove": "исключение",
"add": "приглашение",
"admin": "назначение админов",
"unadmin": "разжалование админов",
}.get(label, label)
self._update_operation_progress(processed, total, label_text) self._update_operation_progress(processed, total, label_text)
self.status_label.setText(f"Статус: выполняется {label_text} ({processed}/{max(1, total)})...") self.status_label.setText(f"Статус: выполняется {label_text} ({processed}/{max(1, total)})...")
@@ -855,18 +909,23 @@ class VkChatManager(QMainWindow):
print(f"Ошибка отложенной очистки кэша: {e}") print(f"Ошибка отложенной очистки кэша: {e}")
def _try_remove_web_cache(self): def _try_remove_web_cache(self):
if not os.path.exists(WEB_ENGINE_CACHE_DIR): cache_dirs = [WEB_ENGINE_CACHE_DIR, AUTH_WEBVIEW_PROFILE_DIR]
existing_dirs = [path for path in cache_dirs if os.path.exists(path)]
if not existing_dirs:
return return
attempts = 5 attempts = 5
last_error = None last_error = None
for _ in range(attempts): for path in existing_dirs:
try: for _ in range(attempts):
shutil.rmtree(WEB_ENGINE_CACHE_DIR) try:
last_error = None shutil.rmtree(path)
last_error = None
break
except Exception as e:
last_error = e
time.sleep(0.2)
if last_error:
break break
except Exception as e:
last_error = e
time.sleep(0.2)
if last_error: if last_error:
os.makedirs(APP_DATA_DIR, exist_ok=True) os.makedirs(APP_DATA_DIR, exist_ok=True)
with open(CACHE_CLEANUP_MARKER, "w") as f: with open(CACHE_CLEANUP_MARKER, "w") as f:
@@ -890,6 +949,19 @@ class VkChatManager(QMainWindow):
def _build_auth_command(self, auth_url, output_path): def _build_auth_command(self, auth_url, output_path):
return self.vk_service.build_auth_command(auth_url, output_path, entry_script_path=os.path.abspath(__file__)) return self.vk_service.build_auth_command(auth_url, output_path, entry_script_path=os.path.abspath(__file__))
def _build_vk_auth_url(self, display="mobile"):
if VK_AUTH_URL_OVERRIDE:
return VK_AUTH_URL_OVERRIDE
params = {
"client_id": VK_APP_ID,
"display": display,
"redirect_uri": VK_AUTH_REDIRECT_URI,
"scope": VK_AUTH_SCOPE,
"response_type": "token",
"v": VK_API_VERSION,
}
return f"https://oauth.vk.com/authorize?{urlencode(params)}"
def _on_auth_process_error(self, process_error): def _on_auth_process_error(self, process_error):
self._auth_process_error_text = f"Ошибка запуска авторизации: {process_error}" self._auth_process_error_text = f"Ошибка запуска авторизации: {process_error}"
# For failed starts Qt may not emit finished(), so release UI here. # For failed starts Qt may not emit finished(), so release UI here.
@@ -965,15 +1037,7 @@ class VkChatManager(QMainWindow):
status_text = "Статус: ожидание авторизации..." status_text = "Статус: ожидание авторизации..."
self.status_label.setText(status_text) self.status_label.setText(status_text)
auth_url = ( auth_url = self._build_vk_auth_url(display="mobile")
"https://oauth.vk.com/authorize?"
"client_id=2685278&"
"display=page&"
"redirect_uri=https://oauth.vk.com/blank.html&"
"scope=1073737727&"
"response_type=token&"
"v=5.131"
)
output_path = os.path.join(APP_DATA_DIR, "auth_result.json") output_path = os.path.join(APP_DATA_DIR, "auth_result.json")
try: try:
if os.path.exists(output_path): if os.path.exists(output_path):
@@ -1269,6 +1333,50 @@ class VkChatManager(QMainWindow):
) )
return return
def unset_user_admin(self):
"""Снимает права администратора в выбранных чатах."""
if not self.user_ids_to_process:
QMessageBox.warning(self, "Ошибка", "Нет ID пользователей для операции.")
return
selected_chats = self._get_selected_chats()
if not selected_chats:
QMessageBox.warning(self, "Ошибка", "Выберите хотя бы один чат.")
return
user_infos = {uid: self.get_user_info_by_id(uid) for uid in self.user_ids_to_process}
user_names_str = "\n".join([f"{name}" for name in user_infos.values()])
msg = (
f"Вы уверены, что хотите разжаловать следующих администраторов:\n\n"
f"{user_names_str}\n\n"
f"в {len(selected_chats)} выбранных чатах?"
)
confirm_dialog = QMessageBox(self)
confirm_dialog.setWindowTitle("Подтверждение прав")
confirm_dialog.setText(msg)
confirm_dialog.setIcon(QMessageBox.Icon.Question)
yes_button = confirm_dialog.addButton("Да", QMessageBox.ButtonRole.YesRole)
no_button = confirm_dialog.addButton("Нет", QMessageBox.ButtonRole.NoRole)
confirm_dialog.setDefaultButton(no_button)
confirm_dialog.exec()
if confirm_dialog.clickedButton() != yes_button:
return
self._start_bulk_action_worker(
action_type="unadmin",
selected_chats=selected_chats,
user_infos=user_infos,
action_label="разжалование админов",
action_button=None,
success_message_title="Результаты разжалования",
clear_inputs_on_success=True,
)
return
# Refactor overrides: keep logic in service modules and thin UI orchestration here. # Refactor overrides: keep logic in service modules and thin UI orchestration here.
def _process_links_list(self, links_list): def _process_links_list(self, links_list):
if not self.vk: if not self.vk:

View File

@@ -31,6 +31,15 @@ class AutoUpdateServiceTests(unittest.TestCase):
expected = hashlib.sha256(payload).hexdigest() expected = hashlib.sha256(payload).hexdigest()
self.assertEqual(AutoUpdateService.sha256_file(str(path)), expected) self.assertEqual(AutoUpdateService.sha256_file(str(path)), expected)
def test_extract_sha256_from_build_sidecar_format(self):
digest = "b" * 64
text = f"{digest} AnabasisManager-2.2.5.zip\n"
extracted = AutoUpdateService.extract_sha256_from_text(
text,
"AnabasisManager-2.2.5.zip",
)
self.assertEqual(extracted, digest)
def test_build_update_script_contains_core_vars(self): def test_build_update_script_contains_core_vars(self):
script = AutoUpdateService.build_update_script( script = AutoUpdateService.build_update_script(
app_dir=r"C:\Apps\AnabasisManager", app_dir=r"C:\Apps\AnabasisManager",

View File

@@ -27,7 +27,7 @@ class MainContractsTests(unittest.TestCase):
return ast.walk(node) return ast.walk(node)
def test_auth_error_contexts_contains_only_supported_contexts(self): def test_auth_error_contexts_contains_only_supported_contexts(self):
expected_contexts = {"load_chats", "execute_user_action", "set_user_admin"} expected_contexts = {"load_chats", "execute_user_action", "set_user_admin", "unset_user_admin"}
for node in self.module.body: for node in self.module.body:
if isinstance(node, ast.Assign): if isinstance(node, ast.Assign):
for target in node.targets: for target in node.targets:
@@ -37,6 +37,52 @@ class MainContractsTests(unittest.TestCase):
return return
self.fail("AUTH_ERROR_CONTEXTS assignment not found") self.fail("AUTH_ERROR_CONTEXTS assignment not found")
def test_unset_user_admin_method_exists(self):
self._find_method("unset_user_admin")
def test_uses_custom_standalone_vk_app(self):
constants = {}
for node in self.module.body:
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
continue
target = node.targets[0]
if isinstance(target, ast.Name):
try:
constants[target.id] = ast.literal_eval(node.value)
except Exception:
continue
self.assertEqual(constants.get("VK_APP_ID"), "54454043")
self.assertIn("ANABASIS_VK_REDIRECT_URI", self.main_source)
self.assertIn("https://vk.daemonlord.ru/vk/callback", self.main_source)
def test_remove_action_demotes_member_before_removing(self):
bulk_worker = None
for node in self.module.body:
if isinstance(node, ast.ClassDef) and node.name == "BulkActionWorker":
bulk_worker = node
break
self.assertIsNotNone(bulk_worker, "BulkActionWorker class not found")
run_method = None
for node in bulk_worker.body:
if isinstance(node, ast.FunctionDef) and node.name == "run":
run_method = node
break
self.assertIsNotNone(run_method, "BulkActionWorker.run method not found")
has_member_role = False
has_remove_call = False
for node in ast.walk(run_method):
if isinstance(node, ast.keyword) and node.arg == "role":
if isinstance(node.value, ast.Constant) and node.value.value == "member":
has_member_role = True
if isinstance(node, ast.Attribute) and node.attr == "removeChatUser":
has_remove_call = True
self.assertTrue(has_member_role, "remove action must demote admins with role='member'")
self.assertTrue(has_remove_call, "remove action must still call removeChatUser")
def test_check_for_updates_has_reentry_guard(self): def test_check_for_updates_has_reentry_guard(self):
method = self._find_method("check_for_updates") method = self._find_method("check_for_updates")
has_guard = False has_guard = False