Add VK callback auth support and admin demotion
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
from urllib.parse import urlparse, parse_qs, unquote
|
||||
@@ -8,6 +7,13 @@ from urllib.parse import urlparse, parse_qs, unquote
|
||||
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):
|
||||
token = None
|
||||
expires_in = 3600
|
||||
@@ -50,28 +56,59 @@ def extract_token(url_string):
|
||||
|
||||
|
||||
def main_auth(auth_url, output_path):
|
||||
stop_polling = threading.Event()
|
||||
polling_started = threading.Event()
|
||||
|
||||
def poll_url():
|
||||
try:
|
||||
url = window.get_current_url()
|
||||
except Exception:
|
||||
url = None
|
||||
if url:
|
||||
while not stop_polling.wait(0.75):
|
||||
try:
|
||||
url = window.get_current_url()
|
||||
except Exception:
|
||||
continue
|
||||
if not url:
|
||||
continue
|
||||
|
||||
token, expires_in = extract_token(url)
|
||||
if token:
|
||||
data = {"token": token, "expires_in": expires_in}
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
if not token:
|
||||
continue
|
||||
|
||||
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()
|
||||
return
|
||||
threading.Timer(0.5, poll_url).start()
|
||||
except Exception:
|
||||
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():
|
||||
threading.Timer(0.5, poll_url).start()
|
||||
start_polling()
|
||||
|
||||
def on_closed():
|
||||
stop_polling.set()
|
||||
|
||||
window = webview.create_window("VK Авторизация", auth_url)
|
||||
window.events.loaded += on_loaded
|
||||
storage_path = os.path.join(os.path.dirname(output_path), "webview_profile")
|
||||
webview.start(private_mode=False, storage_path=storage_path)
|
||||
try:
|
||||
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():
|
||||
|
||||
27
build.py
27
build.py
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -62,6 +63,24 @@ def ensure_project_root():
|
||||
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():
|
||||
print(f"--- 1. Running PyInstaller for {APP_NAME} v{VERSION} ---")
|
||||
icon_abs_path = os.path.abspath(ICON_PATH)
|
||||
@@ -149,8 +168,9 @@ def create_archive():
|
||||
try:
|
||||
# Создаем zip-архив из папки DIST_DIR
|
||||
# base_name - имя файла без расширения, format - 'zip', root_dir - что упаковываем
|
||||
shutil.make_archive(os.path.join("dist", ARCHIVE_NAME), 'zip', DIST_DIR)
|
||||
print(f"[OK] Archive created: dist/{ARCHIVE_NAME}.zip")
|
||||
archive_path = shutil.make_archive(os.path.join("dist", ARCHIVE_NAME), 'zip', DIST_DIR)
|
||||
print(f"[OK] Archive created: {archive_path}")
|
||||
write_sha256_file(archive_path)
|
||||
except Exception as 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}")
|
||||
sys.exit(1)
|
||||
print(f"[OK] Installer created: {installer_path}")
|
||||
write_sha256_file(installer_path)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to build installer: {e}")
|
||||
sys.exit(1)
|
||||
@@ -268,5 +289,7 @@ if __name__ == "__main__":
|
||||
print("\n" + "=" * 30)
|
||||
print("BUILD COMPLETED")
|
||||
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 SHA256: dist/{INSTALLER_NAME}.sha256")
|
||||
print("=" * 30)
|
||||
|
||||
35
deploy/nginx/vk.daemonlord.ru.conf
Normal file
35
deploy/nginx/vk.daemonlord.ru.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
15
deploy/systemd/anabasis-vk-callback.service
Normal file
15
deploy/systemd/anabasis-vk-callback.service
Normal 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
81
deploy/vk-auth-setup.md
Normal 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
|
||||
```
|
||||
46
deploy/vk-callback/index.html
Normal file
46
deploy/vk-callback/index.html
Normal 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>
|
||||
78
deploy/vk_callback_server.py
Normal file
78
deploy/vk_callback_server.py
Normal 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
156
main.py
@@ -3,6 +3,7 @@ import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from PySide6.QtCore import QProcess
|
||||
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")
|
||||
SETTINGS_FILE = os.path.join(APP_DATA_DIR, "settings.json")
|
||||
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")
|
||||
LOG_FILE = os.path.join(APP_DATA_DIR, "app.log")
|
||||
LOG_MAX_BYTES = 1024 * 1024 # 1 MB
|
||||
LOG_BACKUP_FILE = os.path.join(APP_DATA_DIR, "app.log.1")
|
||||
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.
|
||||
UPDATE_REPOSITORY = ""
|
||||
# Full repository URL is preferred (supports GitHub/Gitea).
|
||||
UPDATE_REPOSITORY_URL = "https://git.daemonlord.ru/benya/AnabasisChatRemove"
|
||||
UPDATE_CHANNEL_DEFAULT = "stable"
|
||||
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):
|
||||
@@ -80,8 +87,14 @@ class BulkActionWorker(QObject):
|
||||
def _is_auth_error(exc):
|
||||
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):
|
||||
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)
|
||||
|
||||
def run(self):
|
||||
@@ -90,7 +103,7 @@ class BulkActionWorker(QObject):
|
||||
try:
|
||||
for chat in self.selected_chats:
|
||||
peer_id = None
|
||||
if self.action_type == "admin":
|
||||
if self.action_type in ("admin", "unadmin", "remove"):
|
||||
try:
|
||||
peer_id = 2000000000 + int(chat["id"])
|
||||
except (ValueError, TypeError):
|
||||
@@ -103,6 +116,19 @@ class BulkActionWorker(QObject):
|
||||
for user_id, user_info in self.user_infos.items():
|
||||
try:
|
||||
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)
|
||||
results.append(f"✓ '{user_info}' исключен из '{chat['title']}'.")
|
||||
elif self.action_type == "add":
|
||||
@@ -119,12 +145,20 @@ class BulkActionWorker(QObject):
|
||||
role="admin",
|
||||
)
|
||||
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:
|
||||
raise RuntimeError(f"Unknown action: {self.action_type}")
|
||||
except VkApiError as exc:
|
||||
if self._is_auth_error(exc):
|
||||
context = "set_user_admin" if self.action_type == "admin" else "execute_user_action"
|
||||
action_name = "назначения администраторов" if self.action_type == "admin" else "выполнения операций с пользователями"
|
||||
context = self._role_context() if self.action_type in ("admin", "unadmin") else "execute_user_action"
|
||||
action_name = self._role_action_name() if self.action_type in ("admin", "unadmin") else "выполнения операций с пользователями"
|
||||
self.auth_error.emit(context, exc, action_name)
|
||||
return
|
||||
results.append(f"✗ Ошибка для '{user_info}' в '{chat['title']}': {exc}")
|
||||
@@ -327,6 +361,12 @@ class VkChatManager(QMainWindow):
|
||||
tools_menu.addAction(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.setStatusTip("Проверить наличие новой версии приложения")
|
||||
check_updates_action.triggered.connect(self.check_for_updates)
|
||||
@@ -629,6 +669,8 @@ class VkChatManager(QMainWindow):
|
||||
self._clear_chat_tabs()
|
||||
if hasattr(self, "make_admin_action"):
|
||||
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"):
|
||||
self.logout_action.setEnabled(not self._auth_ui_busy)
|
||||
|
||||
@@ -639,6 +681,8 @@ class VkChatManager(QMainWindow):
|
||||
self.logout_action.setEnabled(not in_progress)
|
||||
if hasattr(self, "make_admin_action"):
|
||||
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))
|
||||
|
||||
def _set_busy(self, busy, status_text=None):
|
||||
@@ -702,7 +746,12 @@ class VkChatManager(QMainWindow):
|
||||
clear_inputs_on_success=True,
|
||||
):
|
||||
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_clear_inputs_on_success = clear_inputs_on_success
|
||||
self._log_event(
|
||||
@@ -737,7 +786,12 @@ class VkChatManager(QMainWindow):
|
||||
thread.start()
|
||||
|
||||
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.status_label.setText(f"Статус: выполняется {label_text} ({processed}/{max(1, total)})...")
|
||||
|
||||
@@ -855,18 +909,23 @@ class VkChatManager(QMainWindow):
|
||||
print(f"Ошибка отложенной очистки кэша: {e}")
|
||||
|
||||
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
|
||||
attempts = 5
|
||||
last_error = None
|
||||
for _ in range(attempts):
|
||||
try:
|
||||
shutil.rmtree(WEB_ENGINE_CACHE_DIR)
|
||||
last_error = None
|
||||
for path in existing_dirs:
|
||||
for _ in range(attempts):
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
last_error = None
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
time.sleep(0.2)
|
||||
if last_error:
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
time.sleep(0.2)
|
||||
if last_error:
|
||||
os.makedirs(APP_DATA_DIR, exist_ok=True)
|
||||
with open(CACHE_CLEANUP_MARKER, "w") as f:
|
||||
@@ -890,6 +949,19 @@ class VkChatManager(QMainWindow):
|
||||
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__))
|
||||
|
||||
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):
|
||||
self._auth_process_error_text = f"Ошибка запуска авторизации: {process_error}"
|
||||
# For failed starts Qt may not emit finished(), so release UI here.
|
||||
@@ -965,15 +1037,7 @@ class VkChatManager(QMainWindow):
|
||||
status_text = "Статус: ожидание авторизации..."
|
||||
self.status_label.setText(status_text)
|
||||
|
||||
auth_url = (
|
||||
"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"
|
||||
)
|
||||
auth_url = self._build_vk_auth_url(display="mobile")
|
||||
output_path = os.path.join(APP_DATA_DIR, "auth_result.json")
|
||||
try:
|
||||
if os.path.exists(output_path):
|
||||
@@ -1269,6 +1333,50 @@ class VkChatManager(QMainWindow):
|
||||
)
|
||||
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.
|
||||
def _process_links_list(self, links_list):
|
||||
if not self.vk:
|
||||
|
||||
@@ -31,6 +31,15 @@ class AutoUpdateServiceTests(unittest.TestCase):
|
||||
expected = hashlib.sha256(payload).hexdigest()
|
||||
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):
|
||||
script = AutoUpdateService.build_update_script(
|
||||
app_dir=r"C:\Apps\AnabasisManager",
|
||||
|
||||
@@ -27,7 +27,7 @@ class MainContractsTests(unittest.TestCase):
|
||||
return ast.walk(node)
|
||||
|
||||
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:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
@@ -37,6 +37,52 @@ class MainContractsTests(unittest.TestCase):
|
||||
return
|
||||
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):
|
||||
method = self._find_method("check_for_updates")
|
||||
has_guard = False
|
||||
|
||||
Reference in New Issue
Block a user