diff --git a/.gitignore b/.gitignore index 5f89590..2c2e986 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ /.venv/ -/.venv1/ -/.venv3/ +/setup.py +/build_cx/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e7f31b --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2025 Aleksandr Denisov + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fda165a --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Anabasis VK Chat Manager + +## Описание проекта + +**Anabasis VK Chat Manager** — это десктопное приложение на Python с графическим интерфейсом, разработанное для упрощения управления пользователями в чатах ВКонтакте. Оно позволяет авторизоваться через VK OAuth, просматривать список своих чатов, а также исключать или приглашать пользователей в выбранные чаты. + +Приложение спроектировано для минимизации ручных операций и повышения удобства управления групповыми беседами VK. + +## Возможности + +* **Авторизация через VK OAuth:** Безопасный процесс входа через официальный VK API. +* **Сохранение сессии:** Поддержка сохранения куки-файлов браузера для длительной авторизации в `QWebEngineView`. +* **Управление токенами:** Автоматическое сохранение и загрузка VK Access Token для удобства использования. +* **Список чатов:** Загрузка и отображение списка доступных чатов пользователя. +* **Выбор чатов:** Возможность выбора одного или нескольких чатов для выполнения операций. +* **Автоматическое определение ID пользователя:** Получение ID пользователя VK из различных форматов ссылок (например, `vk.com/id123`, `vk.com/durov`). +* **Исключение пользователей:** Удаление пользователя из выбранных чатов. +* **Приглашение пользователей:** Добавление пользователя в выбранные чаты. +* **Визуальный таймер токена:** Отображение оставшегося времени действия Access Token. +* **Информативные сообщения:** Детальные статусы операций и сообщения об ошибках. +* **Обработка ошибок:** Улучшенная обработка ошибок VK API, включая смену IP-адреса. + +## Установка + +### Готовый билд + +Скачайте последнюю доступную версию из релизов и распакуйте архив + +### Ручная установка +Для запуска приложения вам потребуется Python 3 и библиотеки `PySide6` и `vk_api`. + +1. **Клонируйте репозиторий** + +2. **Создайте и активируйте виртуальное окружение (рекомендуется):** + ```bash + python -m venv venv + # Для Windows: + .\venv\Scripts\activate + # Для macOS/Linux: + source venv/bin/activate + ``` + +3. **Установите зависимости:** + ```bash + pip install PySide6 vk_api + ``` + +## Использование + +1. **Запустите приложение:** + * *Готовый билд:* + * Запустите **AnabasisHRChatManager.exe** + * *Ручная установка:* + * ```bash + python main.py + ``` + +2. **Авторизация:** + * Нажмите кнопку "Авторизоваться через VK". + * В открывшемся окне браузера войдите в свой аккаунт ВКонтакте. + * Разрешите доступ приложению, если потребуется. + * После успешной авторизации окно закроется, и токен доступа будет сохранен. + +3. **Выбор чатов:** + * После авторизации приложение автоматически загрузит список ваших чатов. + * Отметьте галочками те чаты, с которыми хотите работать. Используйте кнопки "Выбрать все" / "Снять выбор со всех" для удобства. + * Кнопка "Обновить чаты" позволяет перезагрузить список чатов. + +4. **Управление пользователями:** + * В поле "Введите или вставьте ссылку на страницу VK" вставьте ссылку на страницу пользователя ВКонтакте (например, `vk.com/id123` или `vk.com/durov`). Приложение автоматически определит ID пользователя. + * Нажмите кнопку "ИСКЛЮЧИТЬ ПОЛЬЗОВАТЕЛЯ" для удаления пользователя из выбранных чатов. + * Нажмите кнопку "ПРИГЛАСИТЬ ПОЛЬЗОВАТЕЛЯ" для добавления пользователя в выбранные чаты. + * Опция "Показать 250 последних сообщений при добавлении" позволяет управлять видимостью истории сообщений для нового участника (Примечание: VK API может игнорировать этот параметр для `messages.addChatUser`). + +## Структура данных и конфигурация + +Приложение хранит данные в директории, специфичной для данных приложения, что соответствует рекомендациям операционных систем. + +* **`token.json`**: Файл для сохранения VK Access Token. Находится в `[AppDataLocation]/AnabasisVKChatManager/token.json`. +* **`web_engine_cache/`**: Директория для хранения куки-файлов и кэша `QWebEngineProfile`, обеспечивающая сохранение сессии внутри встроенного браузера. Находится в `[AppDataLocation]/AnabasisVKChatManager/web_engine_cache/`. + +`[AppDataLocation]` соответствует: +* Windows: `%APPDATA%` (например, `C:\Users\YourUser\AppData\Roaming`) +* macOS: `~/Library/Application Support` +* Linux: `~/.local/share` + +## Известные проблемы / Ограничения + +* Параметр `visible_messages_count` для `messages.addChatUser` может быть проигнорирован VK API согласно официальной документации. Приложение уведомит вас об этом при попытке использования. +* При смене IP-адреса, токен авторизации VK может стать недействительным, потребуется повторная авторизация. Приложение автоматически предложит её. + +## Лицензия + +Этот проект распространяется под лицензией MIT. \ No newline at end of file diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..866e98f Binary files /dev/null and b/icon.ico differ diff --git a/main.py b/main.py index f658164..deb2333 100644 --- a/main.py +++ b/main.py @@ -5,19 +5,19 @@ import time import os from PySide6.QtWidgets import (QApplication, QMainWindow, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, QMessageBox, - QComboBox, QTextBrowser, QScrollArea, QCheckBox, QHBoxLayout, QSizePolicy, QDialog) -from PySide6.QtCore import Qt, QUrl, QDateTime, Signal, QTimer # QTimer добавлен + QTextBrowser, QScrollArea, QCheckBox, QHBoxLayout, + QSizePolicy, QDialog, QTextEdit, QTabWidget, QDialogButtonBox) +from PySide6.QtCore import Qt, QUrl, QDateTime, Signal, QTimer from PySide6.QtWebEngineWidgets import QWebEngineView -from PySide6.QtWebEngineCore import QWebEnginePage +from PySide6.QtWebEngineCore import QWebEnginePage, QWebEngineProfile from urllib.parse import urlparse, parse_qs, unquote from vk_api.exceptions import VkApiError -from PySide6.QtCore import QStandardPaths # Для кроссплатформенного получения пути к данным приложения +from PySide6.QtCore import QStandardPaths # --- Управление токенами и настройками --- -# Имя файла для сохранения токена. -# Получаем путь к директории для данных приложения, затем формируем полный путь к файлу токена. APP_DATA_DIR = os.path.join(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation), "AnabasisVKChatManager") TOKEN_FILE = os.path.join(APP_DATA_DIR, "token.json") +WEB_ENGINE_CACHE_DIR = os.path.join(APP_DATA_DIR, "web_engine_cache") def save_token(token, expires_in=3600): @@ -25,10 +25,7 @@ def save_token(token, expires_in=3600): Сохраняет VK access токен и его время истечения в JSON файл. По умолчанию токен действителен 1 час (3600 секунд). """ - # Убедимся, что директория для сохранения токена существует os.makedirs(APP_DATA_DIR, exist_ok=True) - - # Вычисляем время истечения токена: текущее время + заданный срок действия expiration_time = time.time() + expires_in data = { "token": token, @@ -37,11 +34,9 @@ def save_token(token, expires_in=3600): try: with open(TOKEN_FILE, "w") as f: json.dump(data, f) - # Выводим информацию о сохранении токена и времени его истечения print( f"Токен сохранен в {TOKEN_FILE}. Срок действия истекает {QDateTime.fromSecsSinceEpoch(int(expiration_time)).toString()}") except IOError as e: - # Обрабатываем ошибки ввода/вывода при сохранении файла print(f"Ошибка сохранения токена: {e}") @@ -51,7 +46,6 @@ def load_token(): Возвращает (токен, время_истечения_unix) или (None, None). """ try: - # Проверяем, существует ли файл токена перед попыткой чтения if not os.path.exists(TOKEN_FILE): print(f"Файл токена не найден по пути {TOKEN_FILE}.") return None, None @@ -61,37 +55,30 @@ def load_token(): token = data.get("token") expiration_time = data.get("expiration_time") - # Проверяем, существует ли токен, время истечения и не просрочен ли он if token and expiration_time and expiration_time > time.time(): - # Выводим информацию о загруженном токене и его действительности print( f"Токен загружен из {TOKEN_FILE}. Действителен до {QDateTime.fromSecsSinceEpoch(int(expiration_time)).toString()}") return token, expiration_time else: print("Токен просрочен или недействителен.") - # Если токен просрочен или недействителен, удаляем файл токена if os.path.exists(TOKEN_FILE): os.remove(TOKEN_FILE) return None, None except (IOError, json.JSONDecodeError) as e: - # Обрабатываем ошибки ввода/вывода или ошибки декодирования JSON print(f"Ошибка загрузки токена: {e}") return None, None -# --- WebEnginePage для VK OAuth --- class WebEnginePage(QWebEnginePage): """ Класс для обработки навигационных запросов в QWebEngineView, специально для извлечения токена авторизации VK. """ - # Изменена сигнатура: parent для QWebEnginePage и browser_window_instance для логической связи - def __init__(self, parent=None, browser_window_instance=None): - super().__init__(parent) # Передаем графический parent в базовый класс - # Сохраняем ссылку на окно браузера для вызова его методов + # Добавлен параметр profile в конструктор, чтобы использовать пользовательский профиль + def __init__(self, profile=None, parent=None, browser_window_instance=None): + super().__init__(profile, parent) # Передаем profile в базовый класс self.parent_browser_window = browser_window_instance - # Флаг, чтобы избежать многократного извлечения токена при перенаправлении self.token_extracted = False def acceptNavigationRequest(self, url, _type, isMainFrame): @@ -99,15 +86,11 @@ class WebEnginePage(QWebEnginePage): Переопределенный метод для перехвата URL-адреса, содержащего токен доступа. """ url_string = url.toString() - # Проверяем, содержит ли URL 'access_token' и не был ли токен уже извлечен if "access_token" in url_string and not self.token_extracted: self.token_extracted = True - # Если ссылка на родительское окно браузера существует, вызываем его метод обработки токена if self.parent_browser_window: self.parent_browser_window.process_auth_url(url_string) - # Предотвращаем дальнейшую навигацию браузера после извлечения токена return False - # Для всех остальных запросов разрешаем навигацию по умолчанию return super().acceptNavigationRequest(url, _type, isMainFrame) @@ -115,22 +98,29 @@ class AuthBrowserWindow(QDialog): """ Отдельное окно-диалог для проведения OAuth авторизации VK. """ - # Сигнал, который будет испускать токен доступа и его срок действия (в секундах) token_extracted_signal = Signal(str, int) def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Авторизация VK") - self.setGeometry(350, 350, 800, 600) # Размеры окна авторизации + self.setGeometry(350, 350, 800, 600) layout = QVBoxLayout(self) self.browser = QWebEngineView() - # Изменен вызов конструктора WebEnginePage - self.browser.page = WebEnginePage(parent=self.browser, browser_window_instance=self) + + # Настройка QWebEngineProfile для сохранения куки и других данных + os.makedirs(WEB_ENGINE_CACHE_DIR, exist_ok=True) + + # ИСПРАВЛЕНО: Сделать profile атрибутом экземпляра и дать ему self как родителя + self.profile = QWebEngineProfile("AnabasisVKWebProfile", self) + self.profile.setPersistentCookiesPolicy(QWebEngineProfile.AllowPersistentCookies) + self.profile.setPersistentStoragePath(WEB_ENGINE_CACHE_DIR) + + # Создаем страницу с настроенным профилем + self.browser.page = WebEnginePage(profile=self.profile, parent=self.browser, browser_window_instance=self) self.browser.setPage(self.browser.page) layout.addWidget(self.browser) - # Статусная строка в окне авторизации self.status_label = QLabel("Ожидание авторизации...") self.status_label.setAlignment(Qt.AlignCenter) layout.addWidget(self.status_label) @@ -139,12 +129,12 @@ class AuthBrowserWindow(QDialog): """Запускает OAuth авторизацию VK в этом окне.""" auth_url = ( "https://oauth.vk.com/authorize?" - "client_id=6287487&" # Официальный client_id VK для Android + "client_id=6287487&" "display=page&" "redirect_uri=https://oauth.vk.com/blank.html&" - "scope=1073737727&" # Широкий scope для работы с сообщениями и чатами + "scope=1073737727&" "response_type=token&" - "v=5.131" # Версия API VK + "v=5.131" ) self.browser.setUrl(QUrl(auth_url)) self.status_label.setText("Пожалуйста, войдите в VK и разрешите доступ...") @@ -154,10 +144,9 @@ class AuthBrowserWindow(QDialog): Извлекает токен доступа из URL перенаправления и испускает сигнал. """ token = None - expires_in = 3600 # По умолчанию 1 час, если не найден в URL + expires_in = 3600 parsed = urlparse(url_string) - # 1. Попытка стандартного парсинга URL-параметров if parsed.fragment: params = parse_qs(parsed.fragment) else: @@ -169,9 +158,8 @@ class AuthBrowserWindow(QDialog): try: expires_in = int(params['expires_in'][0]) except ValueError: - pass # Используем значение по умолчанию, если преобразование не удалось + pass - # 2. Если стандартный парсинг не дал результат, пробуем строковый поиск по маркерам if not token: start_marker = "access_token%253D" end_marker = "%25" @@ -190,564 +178,504 @@ class AuthBrowserWindow(QDialog): raw_token = remaining_url[:amp_index] else: raw_token = remaining_url - token = unquote(raw_token) if token: - self.token_extracted_signal.emit(token, expires_in) # Испускаем сигнал с токеном и сроком действия - self.accept() # Закрываем диалог с результатом QDialog.Accepted + self.token_extracted_signal.emit(token, expires_in) + self.accept() else: QMessageBox.warning(self, "Ошибка Авторизации", "Не удалось получить токен. Проверьте URL или попробуйте еще раз.") - self.reject() # Закрываем диалог с результатом QDialog.Rejected + self.reject() + + def closeEvent(self, event): + """ + Переопределенный метод для обработки закрытия окна. + Выполняет очистку ресурсов QWebEngine, чтобы избежать предупреждения + "Release of profile requested but WebEnginePage still not deleted. Expect troubles!". + """ + current_page = self.browser.page() + if current_page: + # Снимаем страницу с QWebEngineView, чтобы View перестала ей владеть + self.browser.setPage(None) + # Планируем удаление объекта страницы + current_page.deleteLater() + + # Вызываем метод базового класса для корректного закрытия диалога + super().closeEvent(event) + + +class MultiLinkDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Ввод нескольких ссылок") + self.setMinimumSize(400, 300) + layout = QVBoxLayout(self) + label = QLabel("Вставьте ссылки на страницы VK, каждая с новой строки:") + layout.addWidget(label) + self.links_text_edit = QTextEdit() + layout.addWidget(self.links_text_edit) + button_box = QDialogButtonBox() + button_box.addButton("ОК", QDialogButtonBox.AcceptRole) + button_box.addButton("Отмена", QDialogButtonBox.RejectRole) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def get_links(self): + return [line.strip() for line in self.links_text_edit.toPlainText().strip().split('\n') if line.strip()] class VkChatManager(QMainWindow): - """ - Главное окно приложения VK Chat Manager. - Позволяет авторизоваться через VK и удалять/добавлять пользователей в чаты. - """ - def __init__(self): super().__init__() self.setWindowTitle("Anabasis Chat Manager") self.setGeometry(300, 300, 600, 800) self.token = None - self.token_expiration_time = None # Храним время истечения токена в Unix-таймстамп + self.token_expiration_time = None self.chats = [] - self.chat_checkboxes = [] + self.office_chat_checkboxes = [] + self.retail_chat_checkboxes = [] + self.other_chat_checkboxes = [] self.vk_session = None self.vk = None + self.user_ids_to_process = [] + + self.resolve_timer = QTimer(self) + self.resolve_timer.setSingleShot(True) + self.resolve_timer.setInterval(750) + self.resolve_timer.timeout.connect(self.resolve_single_user_id_from_input) self.init_ui() self.load_saved_token_on_startup() - self.setup_token_timer() # Настройка таймера + self.setup_token_timer() def init_ui(self): - """Инициализирует пользовательский интерфейс приложения.""" central_widget = QWidget() self.setCentralWidget(central_widget) - layout = QVBoxLayout() - layout.setContentsMargins(10, 10, 10, 10) # Уменьшаем общие отступы от краев окна - layout.setSpacing(5) # Уменьшаем стандартный отступ между элементами + layout = QVBoxLayout(central_widget) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(5) - # Инструкции для пользователя self.instructions = QTextBrowser() self.instructions.setPlainText( "Инструкция:\n" - "1. Нажмите 'Авторизоваться через VK'\n" - "2. В открывшемся окне войдите в свой аккаунт VK\n" - "3. Разрешите доступ приложению\n" - "4. Токен автоматически сохранится на 1 час\n" - "5. Выберите один или несколько чатов, установив галочки\n" - "6. Введите ID пользователя для удаления/добавления (можно получить по ссылке) и нажмите соответствующую кнопку" + "1. Авторизуйтесь через VK.\n" + "2. Выберите чаты.\n" + "3. Вставьте ссылку на пользователя в поле ниже. ID определится автоматически.\n" + "4. Для массовых операций, нажмите кнопку 'Список' и вставьте ссылки в окне.\n" + "5. Нажмите 'ИСКЛЮЧИТЬ' или 'ПРИГЛАСИТЬ'." ) - self.instructions.setFixedHeight(150) # Увеличена высота для инструкций + self.instructions.setFixedHeight(120) layout.addWidget(self.instructions) - # Поле для токена layout.addWidget(QLabel("Access Token VK:")) self.token_input = QLineEdit() self.token_input.setPlaceholderText("Токен появится здесь после авторизации...") self.token_input.setReadOnly(True) layout.addWidget(self.token_input) - # Метка таймера срока действия токена self.token_timer_label = QLabel("Срок действия токена: Н/Д") - self.token_timer_label.setAlignment(Qt.AlignRight) # Выравнивание по правому краю - self.token_timer_label.setStyleSheet("font-weight: bold; color: #555;") # Сделать немного более заметным + self.token_timer_label.setAlignment(Qt.AlignRight) layout.addWidget(self.token_timer_label) - # Кнопка авторизации self.auth_btn = QPushButton("Авторизоваться через VK") self.auth_btn.clicked.connect(self.start_auth) layout.addWidget(self.auth_btn) - # Секция выбора чатов + self.chat_tabs = QTabWidget() + self.chat_tabs.hide() + self.office_tab = self.create_chat_tab() + self.chat_tabs.addTab(self.office_tab, "AG Офис") + self.retail_tab = self.create_chat_tab() + self.chat_tabs.addTab(self.retail_tab, "AG Розница") + self.other_tab = self.create_chat_tab() + self.chat_tabs.addTab(self.other_tab, "Прочие") layout.addWidget(QLabel("Выберите чаты:")) - - self.chat_checkbox_layout = QVBoxLayout() - self.chat_checkbox_layout.setSpacing(2) # Меньший отступ между чекбоксами - self.chat_checkbox_widget = QWidget() - self.chat_checkbox_widget.setLayout(self.chat_checkbox_layout) - - self.chat_scroll_area = QScrollArea() - self.chat_scroll_area.setWidgetResizable(True) - self.chat_scroll_area.setWidget(self.chat_checkbox_widget) - self.chat_scroll_area.setFixedHeight(240) - self.chat_scroll_area.hide() - - self.select_all_btn = QPushButton("Выбрать все") - self.select_all_btn.clicked.connect(lambda: self.set_all_checkboxes(True)) - self.select_all_btn.setEnabled(False) - - self.deselect_all_btn = QPushButton("Снять выбор со всех") - self.deselect_all_btn.clicked.connect(lambda: self.set_all_checkboxes(False)) - self.deselect_all_btn.setEnabled(False) - + select_buttons_layout = QHBoxLayout() + self.select_all_btn = QPushButton("Выбрать все на вкладке") + self.select_all_btn.clicked.connect(lambda: self.set_all_checkboxes_on_current_tab(True)) + self.deselect_all_btn = QPushButton("Снять выбор на вкладке") + self.deselect_all_btn.clicked.connect(lambda: self.set_all_checkboxes_on_current_tab(False)) self.refresh_chats_btn = QPushButton("Обновить чаты") self.refresh_chats_btn.clicked.connect(self.load_chats) - self.refresh_chats_btn.setEnabled(False) - - select_buttons_layout = QHBoxLayout() select_buttons_layout.addWidget(self.select_all_btn) select_buttons_layout.addWidget(self.deselect_all_btn) select_buttons_layout.addWidget(self.refresh_chats_btn) layout.addLayout(select_buttons_layout) - layout.addWidget(self.chat_scroll_area) + layout.addWidget(self.chat_tabs) - # Секция получения ID по ссылке - layout.addWidget(QLabel("Получить ID по ссылке VK:")) + layout.addWidget(QLabel("Ссылка на страницу VK (ID определится автоматически):")) + link_input_layout = QHBoxLayout() self.vk_url_input = QLineEdit() - self.vk_url_input.setPlaceholderText("Введите ссылку на страницу VK (vk.com/id123 или vk.com/durov)") - layout.addWidget(self.vk_url_input) + self.vk_url_input.setPlaceholderText("https://vk.com/id1") + self.vk_url_input.textChanged.connect(self.on_vk_url_input_changed) + link_input_layout.addWidget(self.vk_url_input) - self.resolve_id_btn = QPushButton("Получить ID") - self.resolve_id_btn.clicked.connect(self.resolve_user_id_from_url) - self.resolve_id_btn.setEnabled(False) - layout.addWidget(self.resolve_id_btn) + self.multi_link_btn = QPushButton("Список") + self.multi_link_btn.setToolTip("Ввести несколько ссылок списком") + self.multi_link_btn.clicked.connect(self.open_multi_link_dialog) + link_input_layout.addWidget(self.multi_link_btn) + layout.addLayout(link_input_layout) - # Поле для ID пользователя (удаление) - layout.addWidget(QLabel("ID пользователя для УДАЛЕНИЯ:")) - self.user_remove_input = QLineEdit() - self.user_remove_input.setPlaceholderText("Введите ID пользователя...") - layout.addWidget(self.user_remove_input) - - # Кнопка удаления - self.remove_btn = QPushButton("Удалить из выбранных чатов") - self.remove_btn.setEnabled(False) - self.remove_btn.clicked.connect(self.remove_user) - layout.addWidget(self.remove_btn) - - # Поле для ID пользователя (добавление) - layout.addWidget(QLabel("ID пользователя для ДОБАВЛЕНИЯ:")) - self.user_add_input = QLineEdit() - self.user_add_input.setPlaceholderText("Введите ID пользователя...") - layout.addWidget(self.user_add_input) - - # Чекбокс для visible_messages_count + self.remove_user_btn = QPushButton("ИСКЛЮЧИТЬ ПОЛЬЗОВАТЕЛЕЙ") + self.remove_user_btn.setMinimumHeight(50) + self.remove_user_btn.clicked.connect(self.remove_user) + layout.addWidget(self.remove_user_btn) self.visible_messages_checkbox = QCheckBox("Показать 250 последних сообщений при добавлении") - self.visible_messages_checkbox.setChecked(False) # По умолчанию не отмечен layout.addWidget(self.visible_messages_checkbox) - - # Кнопка добавления - self.add_btn = QPushButton("Добавить в выбранные чаты") - self.add_btn.setEnabled(False) - self.add_btn.clicked.connect(self.add_user_to_chat) - layout.addWidget(self.add_btn) - - # Статус приложения + self.add_user_btn = QPushButton("ПРИГЛАСИТЬ ПОЛЬЗОВАТЕЛЕЙ") + self.add_user_btn.setMinimumHeight(50) + self.add_user_btn.clicked.connect(self.add_user_to_chat) + layout.addWidget(self.add_user_btn) self.status_label = QLabel("Статус: не авторизован") self.status_label.setAlignment(Qt.AlignCenter) layout.addWidget(self.status_label) - layout.addStretch(1) # Добавляем растяжение внизу + layout.addStretch(1) - central_widget.setLayout(layout) + self.set_ui_state(False) + + def on_vk_url_input_changed(self, text): + if self.vk_url_input.hasFocus(): + self.resolve_timer.start() + + def open_multi_link_dialog(self): + dialog = MultiLinkDialog(self) + if dialog.exec(): + links = dialog.get_links() + if links: + self.vk_url_input.clear() + self._process_links_list(links) + else: + QMessageBox.information(self, "Информация", "Список ссылок пуст.") + + def resolve_single_user_id_from_input(self): + url = self.vk_url_input.text().strip() + if not url: + self.user_ids_to_process.clear() + self.status_label.setText("Статус: Введите ссылку или откройте список.") + self.set_ui_state(self.token is not None) + return + self._process_links_list([url]) + + def _process_links_list(self, links_list): + if not self.vk: + QMessageBox.warning(self, "Ошибка", "Сначала авторизуйтесь.") + return + + self.user_ids_to_process.clear() + resolved_ids = [] + failed_links = [] + + self.status_label.setText("Статус: Определяю ID...") + QApplication.processEvents() + + for link in links_list: + try: + path = urlparse(link).path + screen_name = path.split('/')[-1] if path else '' + if not screen_name and len(path.split('/')) > 1: + screen_name = path.split('/')[-2] + + if not screen_name: + failed_links.append(link) + continue + + resolved_object = self.vk.utils.resolveScreenName(screen_name=screen_name) + if resolved_object and resolved_object.get('type') == 'user': + resolved_ids.append(resolved_object['object_id']) + else: + failed_links.append(link) + except Exception: + failed_links.append(link) + + self.user_ids_to_process = resolved_ids + + status_message = f"Статус: Готово к работе с {len(resolved_ids)} пользовател(ем/ями)." + if len(links_list) > 1: + self.vk_url_input.setText(f"Загружено {len(resolved_ids)}/{len(links_list)} из списка") + + if failed_links: + QMessageBox.warning(self, "Ошибка получения ID", + f"Не удалось получить ID для следующих ссылок:\n" + "\n".join(failed_links)) + + self.status_label.setText(status_message) + self.set_ui_state(self.token is not None) + + def create_chat_tab(self): + # This implementation correctly creates a scrollable area for chat lists. + tab_content_widget = QWidget() + tab_layout = QVBoxLayout(tab_content_widget) + tab_layout.setContentsMargins(0, 0, 0, 0) + tab_layout.setSpacing(0) + + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + tab_layout.addWidget(scroll_area) + + checkbox_container_widget = QWidget() + checkbox_layout = QVBoxLayout(checkbox_container_widget) + checkbox_layout.setContentsMargins(5, 5, 5, 5) + checkbox_layout.setSpacing(2) + checkbox_layout.addStretch() + + scroll_area.setWidget(checkbox_container_widget) + + return tab_content_widget def setup_token_timer(self): - """Настраивает QTimer для обновления отображения обратного отсчета токена.""" self.token_countdown_timer = QTimer(self) self.token_countdown_timer.timeout.connect(self.update_token_timer_display) - # Таймер срабатывает каждую 1000 миллисекунд (1 секунду) - # Он запускается, когда токен загружен/получен self.token_countdown_timer.start(1000) def update_token_timer_display(self): - """ - Обновляет QLabel с оставшимся временем до истечения срока действия токена. - """ if self.token_expiration_time is None: self.token_timer_label.setText("Срок действия токена: Н/Д") return - remaining_seconds = int(self.token_expiration_time - time.time()) - if remaining_seconds <= 0: self.token_timer_label.setText("Срок действия токена истек!") - self.token_countdown_timer.stop() # Останавливаем таймер - # При необходимости, повторно включаем кнопку авторизации и отключаем другие функции - self.auth_btn.setEnabled(True) - self.set_ui_state(False) # Новый метод для последовательной установки состояния UI - self.status_label.setText("Статус: Срок действия токена истек, пожалуйста, авторизуйтесь заново.") - self.token = None # Очищаем токен - self.token_expiration_time = None + if self.token_countdown_timer.isActive(): self.token_countdown_timer.stop() + self.set_ui_state(False) + self.status_label.setText("Статус: Срок действия токена истек, авторизуйтесь заново.") + self.token, self.token_expiration_time = None, None + self.token_input.clear() return - minutes, seconds = divmod(remaining_seconds, 60) hours, minutes = divmod(minutes, 60) - - # Форматируем отображение времени - time_str = "" - if hours > 0: - time_str += f"{hours:02d}ч " - if minutes > 0 or hours > 0: # Показываем минуты, если показаны часы или если минуты присутствуют - time_str += f"{minutes:02d}м " - time_str += f"{seconds:02d}с" - - self.token_timer_label.setText(f"Срок действия токена: {time_str}") + self.token_timer_label.setText(f"Срок: {hours:02d}ч {minutes:02d}м {seconds:02d}с") def set_ui_state(self, authorized): - """Устанавливает состояние элементов пользовательского интерфейса в зависимости от статуса авторизации.""" - self.select_all_btn.setEnabled(authorized) - self.deselect_all_btn.setEnabled(authorized) - self.refresh_chats_btn.setEnabled(authorized) - self.vk_url_input.setEnabled(authorized) - self.resolve_id_btn.setEnabled(authorized) - self.user_remove_input.setEnabled(authorized) - self.remove_btn.setEnabled(authorized) - self.user_add_input.setEnabled(authorized) - self.visible_messages_checkbox.setEnabled(authorized) - self.add_btn.setEnabled(authorized) + self.auth_btn.setEnabled(not authorized) + for btn in [self.select_all_btn, self.deselect_all_btn, self.refresh_chats_btn, + self.vk_url_input, self.multi_link_btn, + self.visible_messages_checkbox]: + btn.setEnabled(authorized) + has_ids = authorized and bool(self.user_ids_to_process) + self.remove_user_btn.setEnabled(has_ids) + self.add_user_btn.setEnabled(has_ids) + + self.chat_tabs.setVisible(authorized) if authorized: - self.chat_scroll_area.show() + # Когда авторизованы, задаем минимальную высоту, достаточную для ~10-12 чатов + self.chat_tabs.setMinimumHeight(300) else: - self.chat_scroll_area.hide() + # Когда не авторизованы, сбрасываем минимальную высоту + self.chat_tabs.setMinimumHeight(0) + + if not authorized: + self.user_ids_to_process.clear() + self.vk_url_input.clear() def load_saved_token_on_startup(self): - """Пытается загрузить сохраненный токен при запуске приложения.""" loaded_token, expiration_time = load_token() if loaded_token: - self.token = loaded_token - self.token_expiration_time = expiration_time - self.token_input.setText(self.token[:50] + "...") # Показываем часть токена - self.status_label.setText("Статус: авторизован (токен загружен из файла)") - self.auth_btn.setEnabled(False) # Отключаем кнопку авторизации - self.set_ui_state(True) # Устанавливаем состояние UI - self.update_token_timer_display() # Начальное обновление отображения - - # Инициализируем VK API с загруженным токеном - self.vk_session = vk_api.VkApi(token=self.token) - self.vk = self.vk_session.get_api() - self.load_chats() # Загружаем чаты + self.handle_auth_token_on_load(loaded_token, expiration_time) else: - self.status_label.setText("Статус: не авторизован (токен не найден или просрочен)") - self.set_ui_state(False) # Устанавливаем состояние UI - self.update_token_timer_display() # Обновляем отображение на Н/Д + self.set_ui_state(False) - def set_all_checkboxes(self, checked): - """ - Устанавливает состояние (выбран/не выбран) для всех чекбоксов чатов. - """ - for checkbox in self.chat_checkboxes: - checkbox.setChecked(checked) + def set_all_checkboxes_on_current_tab(self, checked): + current_index = self.chat_tabs.currentIndex() + checkbox_lists = [self.office_chat_checkboxes, self.retail_chat_checkboxes, self.other_chat_checkboxes] + if 0 <= current_index < len(checkbox_lists): + for checkbox in checkbox_lists[current_index]: + checkbox.setChecked(checked) def start_auth(self): - """ - Запускает процесс OAuth авторизации VK в новом окне. - """ - self.status_label.setText("Статус: ожидание авторизации в новом окне...") + self.status_label.setText("Статус: ожидание авторизации...") auth_window = AuthBrowserWindow(self) - # Подключаем сигнал из AuthBrowserWindow к нашему обработчику, включая expires_in - auth_window.token_extracted_signal.connect(self.handle_auth_token) + auth_window.token_extracted_signal.connect(self.handle_new_auth_token) auth_window.start_auth_flow() - auth_window.exec_() # Открываем окно как модальный диалог + auth_window.exec() - def handle_auth_token(self, token, expires_in): - """ - Обрабатывает полученный токен авторизации после закрытия окна браузера. - """ - if token: - self.token = token - # Вычисляем время истечения на основе текущего времени + expires_in - self.token_expiration_time = time.time() + expires_in - save_token(self.token, expires_in) # Сохраняем токен - self.token_input.setText(self.token[:50] + "...") # Отображаем часть токена - self.status_label.setText("Статус: авторизован") - self.auth_btn.setEnabled(False) # Отключаем кнопку авторизации - self.set_ui_state(True) # Устанавливаем состояние UI - self.update_token_timer_display() # Начальное обновление отображения - - # Инициализируем VK API с полученным токеном - self.vk_session = vk_api.VkApi(token=self.token) - self.vk = self.vk_session.get_api() - self.load_chats() # Загружаем чаты - else: - QMessageBox.warning(self, "Ошибка", "Не удалось получить токен. Попробуйте еще раз.") + def handle_new_auth_token(self, token, expires_in): + if not token: self.status_label.setText("Статус: Авторизация не удалась") - self.set_ui_state(False) # Устанавливаем состояние UI - self.update_token_timer_display() # Обновляем отображение на Н/Д + self.set_ui_state(False) + return + self.token = token + self.token_expiration_time = time.time() + expires_in + save_token(self.token, expires_in) + + self.token_input.setText(self.token[:50] + "...") + self.status_label.setText("Статус: авторизован") + self.vk_session = vk_api.VkApi(token=self.token) + self.vk = self.vk_session.get_api() + self.set_ui_state(True) + self.load_chats() + + def handle_auth_token_on_load(self, token, expiration_time): + self.token = token + self.token_expiration_time = expiration_time + + self.token_input.setText(self.token[:50] + "...") + self.status_label.setText("Статус: авторизован (токен загружен)") + self.vk_session = vk_api.VkApi(token=self.token) + self.vk = self.vk_session.get_api() + self.set_ui_state(True) + self.load_chats() + + def _clear_chat_tabs(self): + self.chats.clear() + for chk_list in [self.office_chat_checkboxes, self.retail_chat_checkboxes, self.other_chat_checkboxes]: + for checkbox in chk_list: + checkbox.setParent(None) + checkbox.deleteLater() + chk_list.clear() def load_chats(self): - """ - Загружает список чатов пользователя из VK API и заполняет их чекбоксами. - """ - # Очищаем существующие чекбоксы из макета - for i in reversed(range(self.chat_checkbox_layout.count())): - widget_to_remove = self.chat_checkbox_layout.itemAt(i).widget() - if widget_to_remove: - widget_to_remove.setParent(None) # Отвязываем от родителя - widget_to_remove.deleteLater() # Помечаем на удаление - self.chat_checkboxes.clear() # Очищаем список ссылок на чекбоксы - self.chats.clear() # Очищаем список данных о чатах + self._clear_chat_tabs() + + # Get the checkbox layouts from each tab + layouts = [ + self.office_tab.findChild(QWidget).findChild(QVBoxLayout), + self.retail_tab.findChild(QWidget).findChild(QVBoxLayout), + self.other_tab.findChild(QWidget).findChild(QVBoxLayout) + ] try: - # Получаем до 200 последних бесед, включая чаты - conversations = self.vk.messages.getConversations(count=200)['items'] - + conversations = self.vk.messages.getConversations(count=200, filter="all")['items'] for conv in conversations: - # Фильтруем только чаты (peer type 'chat') if conv['conversation']['peer']['type'] == 'chat': chat_id = conv['conversation']['peer']['local_id'] title = conv['conversation']['chat_settings']['title'] - chat_data = {'id': chat_id, 'title': title} - self.chats.append(chat_data) + self.chats.append({'id': chat_id, 'title': title}) - # Создаем новый чекбокс для каждого чата checkbox = QCheckBox(f"{title} (id: {chat_id})") - checkbox.setChecked(False) # По умолчанию не выбран - # Сохраняем chat_id в свойстве чекбокса для удобства checkbox.setProperty("chat_id", chat_id) - self.chat_checkbox_layout.addWidget(checkbox) # Добавляем чекбокс в макет - self.chat_checkboxes.append(checkbox) # Добавляем ссылку на чекбокс в список - if not self.chats: - QMessageBox.information(self, "Информация", "У вас нет доступных чатов.") - self.chat_scroll_area.hide() # Скрываем область, если чатов нет - self.select_all_btn.setEnabled(False) # Отключаем кнопки выбора - self.deselect_all_btn.setEnabled(False) - self.refresh_chats_btn.setEnabled(False) # Отключаем кнопку обновления - else: - self.chat_scroll_area.show() # Показываем область с чатами - self.select_all_btn.setEnabled(True) # Включаем кнопки выбора - self.deselect_all_btn.setEnabled(True) - self.refresh_chats_btn.setEnabled(True) # Включаем кнопку обновления + # Insert checkbox at the top of the layout (before the stretch) + if "AG офис" in title: + layouts[0].insertWidget(layouts[0].count() - 1, checkbox) + self.office_chat_checkboxes.append(checkbox) + elif "AG розница" in title: + layouts[1].insertWidget(layouts[1].count() - 1, checkbox) + self.retail_chat_checkboxes.append(checkbox) + else: + layouts[2].insertWidget(layouts[2].count() - 1, checkbox) + self.other_chat_checkboxes.append(checkbox) + self.chat_tabs.setTabText(0, f"AG Офис ({len(self.office_chat_checkboxes)})") + self.chat_tabs.setTabText(1, f"AG Розница ({len(self.retail_chat_checkboxes)})") + self.chat_tabs.setTabText(2, f"Прочие ({len(self.other_chat_checkboxes)})") except VkApiError as e: - QMessageBox.critical(self, "Ошибка", f"Не удалось загрузить чаты: {e}\n" - "Убедитесь, что у приложения есть необходимые права доступа.") - self.chat_scroll_area.hide() # Скрываем область при ошибке - self.select_all_btn.setEnabled(False) # Отключаем кнопки выбора - self.deselect_all_btn.setEnabled(False) - self.refresh_chats_btn.setEnabled(False) # Отключаем кнопку обновления - - def resolve_user_id_from_url(self): - """ - Разрешает ID пользователя VK из введенной ссылки и заполняет поля ID. - """ - if not self.vk: - QMessageBox.warning(self, "Ошибка", "Сначала авторизуйтесь!") - return - - vk_url = self.vk_url_input.text().strip() - if not vk_url: - QMessageBox.warning(self, "Ошибка", "Введите ссылку на страницу VK!") - return + QMessageBox.critical(self, "Ошибка", f"Не удалось загрузить чаты: {e}") + self.set_ui_state(False) + def get_user_info_by_id(self, user_id): try: - # Парсим URL, чтобы извлечь screen_name - parsed_url = urlparse(vk_url) - path_parts = parsed_url.path.split('/') + user = self.vk.users.get(user_ids=user_id)[0] + return f"{user.get('first_name', '')} {user.get('last_name', '')}" + except Exception: + return f"Пользователь {user_id}" - # screen_name обычно последний элемент в пути - # Обработка случая с завершающим слешем или пустым путем - screen_name = '' - if path_parts: - screen_name = path_parts[-1] - if not screen_name and len(path_parts) > 1: # если последний пустой (URL заканчивается на /) - screen_name = path_parts[-2] + def _get_selected_chats(self): + selected = [] + for chk in self.office_chat_checkboxes + self.retail_chat_checkboxes + self.other_chat_checkboxes: + if chk.isChecked(): + chat_id = chk.property("chat_id") + title = next((c['title'] for c in self.chats if c['id'] == chat_id), "") + selected.append({'id': chat_id, 'title': title}) + return selected - if not screen_name: - QMessageBox.warning(self, "Ошибка", - "Не удалось извлечь имя страницы (screen_name) из ссылки. Проверьте формат ссылки.") - return + def _execute_user_action(self, action_type): + if not self.user_ids_to_process: + QMessageBox.warning(self, "Ошибка", f"Нет ID пользователей для операции.") + return + selected_chats = self._get_selected_chats() + if not selected_chats: + QMessageBox.warning(self, "Ошибка", "Выберите хотя бы один чат.") + return - # Используем messages.resolveScreenName для получения ID пользователя - resolved_object = self.vk.utils.resolveScreenName(screen_name=screen_name) + user_infos = {uid: self.get_user_info_by_id(uid) for uid in self.user_ids_to_process} - if resolved_object and 'object_id' in resolved_object and resolved_object['type'] == 'user': - user_id = resolved_object['object_id'] - self.user_remove_input.setText(str(user_id)) - self.user_add_input.setText(str(user_id)) - QMessageBox.information(self, "Успех", f"ID пользователя: {user_id} успешно получен и заполнен в поля.") - elif resolved_object and 'type' in resolved_object and resolved_object['type'] != 'user': - QMessageBox.warning(self, "Ошибка", - f"Ссылка ведет на {resolved_object['type']}, а не на страницу пользователя. Пожалуйста, введите ссылку на страницу пользователя.") + action_verb = "исключить" if action_type == "remove" else "пригласить" + preposition = "из" if action_type == "remove" else "в" + + user_names_list = list(user_infos.values()) + user_names_str = "\n".join([f"• {name}" for name in user_names_list]) + + chat_count = len(selected_chats) + chat_str = "" + + # Финальная логика склонения с учетом падежа (для "из" и "в") + if chat_count % 10 == 1 and chat_count % 100 != 11: + if action_type == 'remove': + # из 1 выбранного чата (Родительный падеж, ед.ч.) + chat_str = f"{chat_count} выбранного чата" else: - QMessageBox.warning(self, "Ошибка", - "Не удалось найти пользователя по этой ссылке. Проверьте корректность ссылки.") + # в 1 выбранный чат (Винительный падеж, ед.ч.) + chat_str = f"{chat_count} выбранный чат" + elif 2 <= chat_count % 10 <= 4 and (chat_count % 100 < 10 or chat_count % 100 >= 20): + if action_type == 'remove': + # из 3 выбранных чатов (Родительный падеж, мн.ч.) + chat_str = f"{chat_count} выбранных чатов" + else: + # в 3 выбранных чата (Родительный падеж, ед.ч.) + chat_str = f"{chat_count} выбранных чата" + else: + # из 5 выбранных чатов / в 5 выбранных чатов (Родительный падеж, мн.ч.) + chat_str = f"{chat_count} выбранных чатов" - except VkApiError as e: - QMessageBox.critical(self, "Ошибка VK API", f"Ошибка при получении ID пользователя: {e}\n" - "Убедитесь, что ссылка корректна и у приложения есть необходимые права.") - except Exception as e: - QMessageBox.critical(self, "Неизвестная ошибка", f"Произошла непредвиденная ошибка: {e}") + msg = ( + f"Вы уверены, что хотите {action_verb} следующих пользователей:\n\n" + f"{user_names_str}\n\n" + f"{preposition} {chat_str}?" + ) + + confirm_dialog = QMessageBox(self) + confirm_dialog.setWindowTitle("Подтверждение действия") + confirm_dialog.setText(msg) + confirm_dialog.setIcon(QMessageBox.Question) + yes_button = confirm_dialog.addButton("Да", QMessageBox.YesRole) + no_button = confirm_dialog.addButton("Нет", QMessageBox.NoRole) + confirm_dialog.setDefaultButton(no_button) # "Нет" - кнопка по умолчанию + + confirm_dialog.exec() + + if confirm_dialog.clickedButton() != yes_button: + return + + results = [] + for chat in selected_chats: + for user_id, user_info in user_infos.items(): + try: + if action_type == "remove": + self.vk.messages.removeChatUser(chat_id=chat['id'], member_id=user_id) + results.append(f"✓ '{user_info}' исключен из '{chat['title']}'.") + else: + params = {'chat_id': chat['id'], 'user_id': user_id} + if self.visible_messages_checkbox.isChecked(): + params['visible_messages_count'] = 250 + self.vk.messages.addChatUser(**params) + results.append(f"✓ '{user_info}' приглашен в '{chat['title']}'.") + except VkApiError as e: + results.append(f"✗ Ошибка для '{user_info}' в '{chat['title']}': {e}") + + QMessageBox.information(self, "Результаты", "\n".join(results)) + self.vk_url_input.clear() + self.user_ids_to_process.clear() + self.set_ui_state(self.token is not None) def remove_user(self): - """ - Удаляет пользователя из всех выбранных чатов. - Перед удалением выводит окно подтверждения. - """ - if not self.token: - QMessageBox.warning(self, "Ошибка", "Сначала авторизуйтесь!") - return - - user_id_str = self.user_remove_input.text().strip() # Используем user_remove_input - if not user_id_str or not user_id_str.isdigit(): - QMessageBox.warning(self, "Ошибка", "Введите корректный ID пользователя (только цифры) для удаления!") - return - - user_id = int(user_id_str) - - selected_chat_ids = [] - selected_chat_titles = [] # Список для названий выбранных чатов - # Собираем ID и названия всех выбранных чатов - for checkbox in self.chat_checkboxes: - if checkbox.isChecked(): - chat_id = checkbox.property("chat_id") - chat_title = next((chat['title'] for chat in self.chats if chat['id'] == chat_id), - f"Чат с ID: {chat_id}") - selected_chat_ids.append(chat_id) - selected_chat_titles.append(chat_title) - - if not selected_chat_ids: - QMessageBox.warning(self, "Ошибка", "Выберите хотя бы один чат для удаления пользователя!") - return - - # Создаем текст подтверждения со списком чатов - confirmation_message = ( - f"Вы точно хотите удалить пользователя с ID: {user_id} из следующих чатов?\n\n" - "**Выбранные чаты:**\n" + "\n".join([f"- {title}" for title in selected_chat_titles]) + - "\n\nЭто действие необратимо." - ) - - # Выводим окно подтверждения - reply = QMessageBox.question(self, "Подтверждение удаления", - confirmation_message, - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) - - if reply == QMessageBox.Yes: - results = [] # Список для сбора результатов по каждому чату - for chat_id, chat_title in zip(selected_chat_ids, selected_chat_titles): - try: - self.vk.messages.removeChatUser(chat_id=chat_id, user_id=user_id) - results.append(f"✓ Пользователь {user_id} успешно удален из чата '{chat_title}' (ID: {chat_id}).") - except VkApiError as e: - error_message = str(e) - # Более детальная обработка известных ошибок VK API - if "[15] Access denied: user cannot be removed from this chat" in error_message: - results.append( - f"✗ Ошибка: Не удалось удалить пользователя {user_id} из чата '{chat_title}' (ID: {chat_id}). Доступ запрещен (нет прав админа или пользователь - создатель чата).") - elif "[100] One of the parameters specified was missing or invalid: user_id is invalid" in error_message: - results.append( - f"✗ Ошибка: Не удалось удалить пользователя {user_id} из чата '{chat_title}' (ID: {chat_id}). Некорректный ID пользователя.") - elif "[900] Cannot remove yourself" in error_message: - results.append( - f"✗ Ошибка: Не удалось удалить пользователя {user_id} из чата '{chat_title}' (ID: {chat_id}). Невозможно удалить самого себя.") - else: - results.append( - f"✗ Ошибка: Не удалось удалить пользователя {user_id} из чата '{chat_title}' (ID: {chat_id}): {e}") - except Exception as e: - results.append(f"✗ Неизвестная ошибка при удалении из чата '{chat_title}' (ID: {chat_id}): {e}") - - # Отображаем сводное сообщение со всеми результатами - QMessageBox.information(self, "Результаты удаления", "\n".join(results)) - self.user_remove_input.clear() # Очищаем поле ввода пользователя - else: - QMessageBox.information(self, "Отмена", "Операция удаления отменена.") + self._execute_user_action("remove") def add_user_to_chat(self): - """ - Добавляет пользователя во все выбранные чаты. - Перед добавлением выводит окно подтверждения. - """ - if not self.token: - QMessageBox.warning(self, "Ошибка", "Сначала авторизуйтесь!") - return - - user_id_str = self.user_add_input.text().strip() # Используем user_add_input - if not user_id_str or not user_id_str.isdigit(): - QMessageBox.warning(self, "Ошибка", "Введите корректный ID пользователя (только цифры) для добавления!") - return - - user_id = int(user_id_str) - - visible_messages_count = None - if self.visible_messages_checkbox.isChecked(): - visible_messages_count = 250 # Если галочка отмечена, устанавливаем значение 250 - - selected_chat_ids = [] - selected_chat_titles = [] # Список для названий выбранных чатов - # Собираем ID и названия всех выбранных чатов - for checkbox in self.chat_checkboxes: - if checkbox.isChecked(): - chat_id = checkbox.property("chat_id") - chat_title = next((chat['title'] for chat in self.chats if chat['id'] == chat_id), - f"Чат с ID: {chat_id}") - selected_chat_ids.append(chat_id) - selected_chat_titles.append(chat_title) - - if not selected_chat_ids: - QMessageBox.warning(self, "Ошибка", "Выберите хотя бы один чат для добавления пользователя!") - return - - # Создаем текст подтверждения со списком чатов - confirmation_message = ( - f"Вы точно хотите добавить пользователя с ID: {user_id} в следующие чаты?\n" - f"(Параметр 'Показать 250 последних сообщений' установлен: {'ДА' if visible_messages_count is not None else 'НЕТ'})\n\n" - "**Выбранные чаты:**\n" + "\n".join([f"- {title}" for title in selected_chat_titles]) + - "\n\nЭто действие может привести к отправке уведомлений пользователю." - ) - - # Выводим окно подтверждения - reply = QMessageBox.question(self, "Подтверждение добавления", - confirmation_message, - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) - - if reply == QMessageBox.Yes: - results = [] # Список для сбора результатов по каждому чату - for chat_id, chat_title in zip(selected_chat_ids, selected_chat_titles): - try: - # Создаем словарь параметров для addChatUser - params = { - 'chat_id': chat_id, - 'user_id': user_id - } - if visible_messages_count is not None: - # Добавляем visible_messages_count, как запрошено, несмотря на документацию - params['visible_messages_count'] = visible_messages_count - results.append( - f"Внимание: Параметр 'visible_messages_count' (значение: {visible_messages_count}) передан в messages.addChatUser для чата '{chat_title}' (ID: {chat_id}). Согласно официальной документации VK API, этот параметр не поддерживается для данного метода и может быть проигнорирован или привести к ошибке.") - - self.vk.messages.addChatUser(**params) # Передаем параметры как именованные аргументы - results.append(f"✓ Пользователь {user_id} успешно добавлен в чат '{chat_title}' (ID: {chat_id}).") - except VkApiError as e: - error_message = str(e) - # Детальная обработка известных ошибок VK API для добавления - if "[917] You don't have access to this chat" in error_message: - results.append( - f"✗ Ошибка: Не удалось добавить пользователя {user_id} в чат '{chat_title}' (ID: {chat_id}). Нет доступа к чату или недостаточно прав.") - elif "[935] User has been invited to this chat" in error_message: - results.append( - f"✗ Ошибка: Пользователь {user_id} уже добавлен в чат '{chat_title}' (ID: {chat_id}) или уже был приглашен.") - elif "[100] One of the parameters specified was missing or invalid: user_id is invalid" in error_message: - results.append( - f"✗ Ошибка: Не удалось добавить пользователя {user_id} из чата '{chat_title}' (ID: {chat_id}). Некорректный ID пользователя.") - else: - results.append( - f"✗ Ошибка: Не удалось добавить пользователя {user_id} в чат '{chat_title}' (ID: {chat_id}): {e}") - except Exception as e: - results.append(f"✗ Неизвестная ошибка при добавлении в чат '{chat_title}' (ID: {chat_id}): {e}") - - # Отображаем сводное сообщение со всеми результатами - QMessageBox.information(self, "Результаты добавления", "\n".join(results)) - self.user_add_input.clear() # Очищаем поле ввода пользователя - self.visible_messages_checkbox.setChecked(False) # Сбрасываем чекбокс - else: - QMessageBox.information(self, "Отмена", "Операция добавления отменена.") + self._execute_user_action("add") if __name__ == "__main__": app = QApplication(sys.argv) - # Устанавливаем стиль приложения на "Fusion". app.setStyle("Fusion") - - # Применяем стандартную палитру, предоставленную текущим стилем. app.setPalette(app.style().standardPalette()) window = VkChatManager() window.show() - sys.exit(app.exec_()) + sys.exit(app.exec()) \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..208d80c --- /dev/null +++ b/setup.py @@ -0,0 +1,76 @@ +# setup.py +import sys +import os +from cx_Freeze import setup, Executable + +# Определите имя вашего основного скрипта +main_script = "main.py" # Замените на имя вашего основного Python-файла + +# Определите имя исполняемого файла +exe_name = "AnabasisHRChatManager" # Имя вашего .exe файла + +# Определите базовый тип приложения: +# - None: Для консольных приложений (консоль будет открываться) +# - "Win32GUI": Для графических приложений на Windows (консоль не будет открываться) +# - "MacOSX": Для графических приложений на macOS +base = None +if sys.platform == "win32": + base = "Win32GUI" # Для оконных приложений на Windows без консоли +elif sys.platform == "darwin": # Для macOS + base = "MacOSX" + +# Определите опции сборки. +# Это словарь, который настраивает, какие пакеты включать/исключать, +# какие файлы добавлять и другие параметры. +build_exe_options = { + # 'packages' - список пакетов, которые cx_Freeze должен гарантированно включить. + # Это полезно для пакетов, которые динамически импортируются или не обнаруживаются автоматически. + "packages": ["os", "sys", "requests", "json", "webbrowser"], # Пример: добавьте сюда любые используемые библиотеки + # 'excludes' - список пакетов, которые cx_Freeze должен исключить. + # Это помогает уменьшить размер сборки, исключая ненужные модули. + "excludes": ["tkinter", "unittest", "PyQt5.QtWebEngineWidgets"], # Пример: исключаем tkinter, если не используется + # 'include_files' - список дополнительных файлов или папок, которые нужно включить в сборку. + # Это могут быть изображения, конфигурационные файлы, FXML-файлы (для JavaFX, не Python) и т.д. + # Формат: [('источник', 'назначение_в_сборке')] или просто ['путь/к/файлу_или_папке'] + # Например, если у вас есть папка 'resources' с иконками или другими данными: + # "include_files": ["resources/", "config.ini"], + # Если иконка находится в корне: + "include_files": [], # Добавьте сюда любые ресурсы + # 'include_msvcr' - для Windows, включает C++ Runtime Library (CRT) + # Часто необходимо для работы некоторых нативных модулей Python. + "include_msvcr": True, + # 'optimize' - уровень оптимизации (0, 1, 2). 2 - максимальная оптимизация, но может быть медленнее. + # "optimize": 0, + # 'zip_include_packages' - упаковывать ли пакеты в zip-файл внутри исполняемого файла. + # Уменьшает количество файлов, но может увеличить время запуска. + # По умолчанию cx_Freeze упаковывает большинство пакетов. + # "zip_include_packages": ["*"], + # 'build_exe' - папка для выходных файлов + "build_exe": "build_cx", +} + +# Определите исполняемые файлы +executables = [ + Executable( + script=main_script, + base=base, + target_name=f"{exe_name}.exe", # Имя конечного .exe файла + icon="icon.ico" # Путь к файлу иконки .ico + # Для создания ярлыков в меню "Пуск" можно использовать shortcut_name и shortcut_dir здесь. + # Например: + # shortcut_name=exe_name, + # shortcut_dir="StartMenuFolder" # Создаст ярлык в меню "Пуск" в корневой папке приложения + ) +] + +# Настройте метаданные вашего приложения +setup( + name=exe_name, + version="0.1", + description="Управление чатами для HR-менеджеров", + options={ + "build_exe": build_exe_options + }, + executables=executables +) +