ifa-nextcloud 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/PKG-INFO +1 -1
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/pyproject.toml +1 -1
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/async_bot.py +43 -32
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/core/bot_core.py +32 -24
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/http/async_.py +19 -20
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/http/sync.py +4 -2
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/.github/workflows/publish-to-pypi.yml +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/LICENSE +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/README.md +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/reqirements.txt +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/__init__.py +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/__init__.py +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/__init__.py +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/bot.py +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/core/__init__.py +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/core/models.py +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/http/__init__.py +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/http/base.py +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/email/__init__.py +0 -0
- {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/email/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ifa_nextcloud
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Python библиотека для создания ботов в Nextcloud Talk с API, похожим на python-telegram-bot
|
|
5
5
|
Project-URL: Homepage, https://github.com/1FaKe110/nextcloud
|
|
6
6
|
Project-URL: Repository, https://github.com/1FaKe110/nextcloud.git
|
|
@@ -393,7 +393,10 @@ class AsyncBot(BotCore):
|
|
|
393
393
|
return False
|
|
394
394
|
|
|
395
395
|
async def _send_text_message_async(self, chat_id: str, text: str, reply_to_message_id: int = None) -> bool:
|
|
396
|
-
"""
|
|
396
|
+
"""
|
|
397
|
+
Отправить только текстовое сообщение (асинхронная версия).
|
|
398
|
+
Полностью повторяет логику оригинального async_bot.
|
|
399
|
+
"""
|
|
397
400
|
if not text:
|
|
398
401
|
logger.warning("Нет текста для отправки")
|
|
399
402
|
return False
|
|
@@ -403,35 +406,27 @@ class AsyncBot(BotCore):
|
|
|
403
406
|
if reply_to_message_id:
|
|
404
407
|
data['replyTo'] = reply_to_message_id
|
|
405
408
|
|
|
406
|
-
|
|
409
|
+
# Отправляем без params, как в оригинале
|
|
410
|
+
response = await self.http.post(endpoint, data=data)
|
|
407
411
|
|
|
408
|
-
|
|
409
|
-
if response.data is not None and response.data != {}:
|
|
412
|
+
# В оригинале проверяли result is not None and result != {}
|
|
413
|
+
if response.status_code in [200, 201] and response.data is not None and response.data != {}:
|
|
410
414
|
logger.success(f"Отправка текстового сообщения в {chat_id}: {text[:50]}...")
|
|
411
415
|
return True
|
|
412
416
|
|
|
413
|
-
# Fallback: пробуем без параметров
|
|
414
|
-
logger.trace("Отправка с параметрами не удалась, пробуем без них...")
|
|
415
|
-
response = await self.http.post(endpoint, data=data)
|
|
416
|
-
if response.data is not None and response.data != {}:
|
|
417
|
-
logger.success("Сообщение успешно отправлено (без параметров)")
|
|
418
|
-
return True
|
|
419
|
-
|
|
420
417
|
logger.error("Не удалось отправить текстовое сообщение")
|
|
421
418
|
return False
|
|
422
419
|
|
|
423
420
|
async def _send_message_with_file_async(
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
421
|
+
self,
|
|
422
|
+
chat_id: str,
|
|
423
|
+
text: str,
|
|
424
|
+
file: Tuple[str, bytes, str],
|
|
425
|
+
reply_to_message_id: int = None
|
|
429
426
|
) -> bool:
|
|
430
427
|
"""
|
|
431
428
|
Отправить сообщение с файлом-вложением (асинхронная версия).
|
|
432
|
-
|
|
433
|
-
Args:
|
|
434
|
-
file: Кортеж (filename, content, mime_type)
|
|
429
|
+
Полностью повторяет логику оригинального async_bot.
|
|
435
430
|
"""
|
|
436
431
|
file_name, file_content, mime_type = file
|
|
437
432
|
logger.info(f"📤 Отправка файла {file_name} в комнату {chat_id}")
|
|
@@ -482,20 +477,18 @@ class AsyncBot(BotCore):
|
|
|
482
477
|
async def _get_file_id_async(self, file_name: str) -> str:
|
|
483
478
|
"""
|
|
484
479
|
Получить ID загруженного файла через WebDAV PROPFIND (асинхронная версия).
|
|
485
|
-
|
|
486
|
-
Returns:
|
|
487
|
-
ID файла или "unknown"
|
|
480
|
+
Полностью повторяет логику оригинального async_bot.
|
|
488
481
|
"""
|
|
489
482
|
webdav_url = f"/remote.php/dav/files/{self.http.user}/Talk/{file_name}"
|
|
490
483
|
|
|
491
484
|
propfind_body = '''<?xml version="1.0"?>
|
|
492
|
-
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
</d:propfind>'''
|
|
485
|
+
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
|
486
|
+
<d:prop>
|
|
487
|
+
<oc:fileid />
|
|
488
|
+
<oc:size />
|
|
489
|
+
<d:getlastmodified />
|
|
490
|
+
</d:prop>
|
|
491
|
+
</d:propfind>'''
|
|
499
492
|
|
|
500
493
|
try:
|
|
501
494
|
response = await self.http.propfind(webdav_url, propfind_body)
|
|
@@ -529,9 +522,7 @@ class AsyncBot(BotCore):
|
|
|
529
522
|
async def _create_public_share_async(self, file_id: str, file_name: str, password: str = None) -> Optional[str]:
|
|
530
523
|
"""
|
|
531
524
|
Создать публичную ссылку на файл через Sharing API (асинхронная версия).
|
|
532
|
-
|
|
533
|
-
Returns:
|
|
534
|
-
URL публичной ссылки или None
|
|
525
|
+
Полностью повторяет логику оригинального async_bot.
|
|
535
526
|
"""
|
|
536
527
|
if not file_id or file_id == "unknown":
|
|
537
528
|
return None
|
|
@@ -809,8 +800,12 @@ class AsyncBot(BotCore):
|
|
|
809
800
|
Args:
|
|
810
801
|
poll_interval: Интервал опроса для новых комнат
|
|
811
802
|
"""
|
|
803
|
+
# Убеждаемся, что сессия создана
|
|
804
|
+
await self._ensure_session()
|
|
805
|
+
|
|
812
806
|
try:
|
|
813
807
|
rooms = await self.get_rooms()
|
|
808
|
+
logger.info(f"Синхронизация: найдено {len(rooms)} комнат")
|
|
814
809
|
|
|
815
810
|
current_rooms = set()
|
|
816
811
|
for room in rooms:
|
|
@@ -838,6 +833,7 @@ class AsyncBot(BotCore):
|
|
|
838
833
|
|
|
839
834
|
except Exception as e:
|
|
840
835
|
logger.error(f"Ошибка при синхронизации комнат: {e}")
|
|
836
|
+
logger.error(traceback.format_exc())
|
|
841
837
|
|
|
842
838
|
async def _sync_rooms_loop(self, poll_interval: float = 2, sync_interval: int = 60):
|
|
843
839
|
"""
|
|
@@ -881,10 +877,25 @@ class AsyncBot(BotCore):
|
|
|
881
877
|
# Убеждаемся, что сессия создана
|
|
882
878
|
await self._ensure_session()
|
|
883
879
|
|
|
880
|
+
# Проверяем аутентификацию
|
|
881
|
+
status = await self.check_session_status()
|
|
882
|
+
if not status.get('authenticated'):
|
|
883
|
+
logger.error(f"Ошибка аутентификации: {status}")
|
|
884
|
+
logger.error("Проверьте логин и пароль/токен приложения")
|
|
885
|
+
return
|
|
886
|
+
|
|
887
|
+
logger.info(f"Аутентификация успешна как: {status.get('user')}")
|
|
888
|
+
|
|
884
889
|
# Получаем начальный список комнат
|
|
885
890
|
rooms = await self.get_rooms()
|
|
886
891
|
logger.info(f"Найдено {len(rooms)} доступных комнат")
|
|
887
892
|
|
|
893
|
+
if not rooms:
|
|
894
|
+
logger.warning("Не найдено ни одной комнаты. Проверьте:")
|
|
895
|
+
logger.warning("1. Что бот добавлен в комнату")
|
|
896
|
+
logger.warning("2. Правильность токена приложения")
|
|
897
|
+
logger.warning("3. Права доступа бота")
|
|
898
|
+
|
|
888
899
|
self.running = True
|
|
889
900
|
|
|
890
901
|
# Запускаем опрос для каждой комнаты
|
|
@@ -7,6 +7,7 @@ import time
|
|
|
7
7
|
import os
|
|
8
8
|
import mimetypes
|
|
9
9
|
import threading
|
|
10
|
+
import traceback
|
|
10
11
|
from datetime import datetime
|
|
11
12
|
from typing import Optional, Callable, Dict, Any, List, Union, Tuple
|
|
12
13
|
from loguru import logger
|
|
@@ -66,23 +67,22 @@ class BotCore:
|
|
|
66
67
|
# ========== Вспомогательные методы для работы с API ==========
|
|
67
68
|
|
|
68
69
|
def _extract_file_from_message(self, msg_data: Dict, chat_id: str) -> List[File]:
|
|
69
|
-
"""
|
|
70
|
-
Извлечь информацию о файлах из сообщения.
|
|
71
|
-
|
|
72
|
-
Args:
|
|
73
|
-
msg_data: Данные сообщения из API
|
|
74
|
-
chat_id: ID чата
|
|
75
|
-
|
|
76
|
-
Returns:
|
|
77
|
-
Список File объектов
|
|
78
|
-
"""
|
|
70
|
+
"""Извлечь информацию о файлах из сообщения"""
|
|
79
71
|
files = []
|
|
80
72
|
|
|
81
73
|
# Способ 1: Проверяем поле messageParameters на наличие файлов
|
|
82
74
|
message_params = msg_data.get('messageParameters', {})
|
|
83
75
|
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
# ВАЖНО: message_params может быть как словарём, так и списком
|
|
77
|
+
if isinstance(message_params, dict):
|
|
78
|
+
params_iter = message_params.items()
|
|
79
|
+
elif isinstance(message_params, list):
|
|
80
|
+
params_iter = enumerate(message_params)
|
|
81
|
+
else:
|
|
82
|
+
params_iter = []
|
|
83
|
+
|
|
84
|
+
for key, param in params_iter:
|
|
85
|
+
if isinstance(param, dict) and param.get('type') == 'file':
|
|
86
86
|
file_info = {
|
|
87
87
|
'file_id': param.get('id', ''),
|
|
88
88
|
'file_name': param.get('name', 'unknown'),
|
|
@@ -97,7 +97,13 @@ class BotCore:
|
|
|
97
97
|
# Способ 2: Проверяем системные сообщения о расшаренных файлах
|
|
98
98
|
system_message = msg_data.get('systemMessage', '')
|
|
99
99
|
if system_message == 'file_shared':
|
|
100
|
-
|
|
100
|
+
if isinstance(message_params, dict):
|
|
101
|
+
file_param = message_params.get('file', {})
|
|
102
|
+
elif isinstance(message_params, list):
|
|
103
|
+
file_param = next((p for p in message_params if isinstance(p, dict) and p.get('type') == 'file'), {})
|
|
104
|
+
else:
|
|
105
|
+
file_param = {}
|
|
106
|
+
|
|
101
107
|
if file_param:
|
|
102
108
|
file_info = {
|
|
103
109
|
'file_id': file_param.get('id', ''),
|
|
@@ -113,25 +119,27 @@ class BotCore:
|
|
|
113
119
|
return files
|
|
114
120
|
|
|
115
121
|
def _get_forward_info(self, msg_data: Dict) -> Tuple[bool, Optional[str]]:
|
|
116
|
-
"""
|
|
117
|
-
Получить информацию о пересылке сообщения.
|
|
118
|
-
|
|
119
|
-
Returns:
|
|
120
|
-
(is_forwarded, forward_origin)
|
|
121
|
-
"""
|
|
122
|
+
"""Получить информацию о пересылке сообщения"""
|
|
122
123
|
is_forwarded = False
|
|
123
124
|
forward_origin = None
|
|
124
125
|
|
|
125
|
-
# Проверяем наличие информации о пересылке
|
|
126
126
|
if msg_data.get('isForwarded'):
|
|
127
127
|
is_forwarded = True
|
|
128
128
|
forward_origin = msg_data.get('forwardedFrom', {}).get('actorDisplayName', 'unknown')
|
|
129
129
|
|
|
130
|
-
# Альтернативный способ через messageParameters
|
|
131
130
|
message_params = msg_data.get('messageParameters', {})
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
|
|
132
|
+
if isinstance(message_params, dict):
|
|
133
|
+
forward_param = message_params.get('forward')
|
|
134
|
+
if forward_param:
|
|
135
|
+
is_forwarded = True
|
|
136
|
+
forward_origin = forward_param.get('name', 'unknown')
|
|
137
|
+
elif isinstance(message_params, list):
|
|
138
|
+
forward_param = next((p for p in message_params if isinstance(p, dict) and p.get('type') == 'forward'),
|
|
139
|
+
None)
|
|
140
|
+
if forward_param:
|
|
141
|
+
is_forwarded = True
|
|
142
|
+
forward_origin = forward_param.get('name', 'unknown')
|
|
135
143
|
|
|
136
144
|
return is_forwarded, forward_origin
|
|
137
145
|
|
|
@@ -72,18 +72,7 @@ class AsyncHTTPClient(BaseHTTPClient):
|
|
|
72
72
|
retry: bool = True,
|
|
73
73
|
**kwargs
|
|
74
74
|
) -> HttpResponse:
|
|
75
|
-
"""
|
|
76
|
-
Внутренний метод выполнения запроса с обработкой 401.
|
|
77
|
-
|
|
78
|
-
Args:
|
|
79
|
-
method: HTTP метод
|
|
80
|
-
url: Полный URL
|
|
81
|
-
retry: Флаг повторной попытки
|
|
82
|
-
**kwargs: Параметры для aiohttp
|
|
83
|
-
|
|
84
|
-
Returns:
|
|
85
|
-
HttpResponse объект
|
|
86
|
-
"""
|
|
75
|
+
"""Внутренний метод выполнения запроса с обработкой 401."""
|
|
87
76
|
await self._ensure_session()
|
|
88
77
|
|
|
89
78
|
# Добавляем OCS параметр format=json если его нет
|
|
@@ -95,7 +84,6 @@ class AsyncHTTPClient(BaseHTTPClient):
|
|
|
95
84
|
|
|
96
85
|
# Таймаут по умолчанию
|
|
97
86
|
if 'timeout' not in kwargs:
|
|
98
|
-
# aiohttp требует объект ClientTimeout
|
|
99
87
|
kwargs['timeout'] = aiohttp.ClientTimeout(total=30)
|
|
100
88
|
|
|
101
89
|
try:
|
|
@@ -105,19 +93,25 @@ class AsyncHTTPClient(BaseHTTPClient):
|
|
|
105
93
|
|
|
106
94
|
# При 401 пробуем переавторизоваться
|
|
107
95
|
if status_code == 401 and retry:
|
|
96
|
+
logger.warning(f"Получен 401 для {url}, пересоздаём сессию...")
|
|
108
97
|
await self._reinit_session()
|
|
109
98
|
return await self._make_request(method, url, retry=False, **kwargs)
|
|
110
99
|
|
|
111
100
|
# Парсим ответ
|
|
112
101
|
data = {}
|
|
113
|
-
if status_code in [200, 201]:
|
|
102
|
+
if status_code in [200, 201, 207]: # 207 - Multi-status для PROPFIND
|
|
114
103
|
try:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
104
|
+
# Для PROPFIND не парсим JSON
|
|
105
|
+
if 'xml' in response.headers.get('content-type', ''):
|
|
106
|
+
data = {} # XML обрабатывается отдельно
|
|
107
|
+
else:
|
|
108
|
+
json_response = json.loads(raw_text) if raw_text else {}
|
|
109
|
+
data = json_response.get('ocs', {}).get('data', {})
|
|
118
110
|
except json.JSONDecodeError:
|
|
119
|
-
|
|
111
|
+
if raw_text and not raw_text.startswith('<?xml'):
|
|
112
|
+
logger.error(f"Ошибка парсинга JSON: {raw_text[:200]}")
|
|
120
113
|
|
|
114
|
+
logger.trace(f"Request {method} {url} -> {status_code}")
|
|
121
115
|
return HttpResponse(
|
|
122
116
|
status_code=status_code,
|
|
123
117
|
data=data,
|
|
@@ -125,6 +119,9 @@ class AsyncHTTPClient(BaseHTTPClient):
|
|
|
125
119
|
headers=dict(response.headers)
|
|
126
120
|
)
|
|
127
121
|
|
|
122
|
+
except aiohttp.ClientResponseError as e:
|
|
123
|
+
logger.error(f"ClientResponseError: {e.status} {e.message}")
|
|
124
|
+
return HttpResponse(status_code=e.status, data={}, raw_text=str(e))
|
|
128
125
|
except asyncio.TimeoutError:
|
|
129
126
|
logger.error(f"Timeout при запросе к {url}")
|
|
130
127
|
return HttpResponse(status_code=408, data={}, raw_text="Timeout")
|
|
@@ -182,10 +179,12 @@ class AsyncHTTPClient(BaseHTTPClient):
|
|
|
182
179
|
endpoint: str,
|
|
183
180
|
data: Optional[Dict] = None,
|
|
184
181
|
json_data: Optional[Dict] = None,
|
|
185
|
-
files: Optional[Dict] = None
|
|
182
|
+
files: Optional[Dict] = None,
|
|
183
|
+
params: Optional[Dict] = None # Добавить этот параметр
|
|
186
184
|
) -> HttpResponse:
|
|
187
185
|
"""POST запрос"""
|
|
188
|
-
return await self.request('POST', endpoint, data=data, json_data=json_data, files=files)
|
|
186
|
+
return await self.request('POST', endpoint, data=data, json_data=json_data, files=files, params=params)
|
|
187
|
+
|
|
189
188
|
|
|
190
189
|
async def put(self, endpoint: str, data: Any, headers: Optional[Dict] = None) -> HttpResponse:
|
|
191
190
|
"""
|
|
@@ -112,6 +112,7 @@ class SyncHTTPClient(BaseHTTPClient):
|
|
|
112
112
|
logger.error(f"Ошибка запроса: {e}")
|
|
113
113
|
return HttpResponse(status_code=0, data={}, raw_text=str(e))
|
|
114
114
|
|
|
115
|
+
|
|
115
116
|
def request(
|
|
116
117
|
self,
|
|
117
118
|
method: str,
|
|
@@ -153,10 +154,11 @@ class SyncHTTPClient(BaseHTTPClient):
|
|
|
153
154
|
endpoint: str,
|
|
154
155
|
data: Optional[Dict] = None,
|
|
155
156
|
json_data: Optional[Dict] = None,
|
|
156
|
-
files: Optional[Dict] = None
|
|
157
|
+
files: Optional[Dict] = None,
|
|
158
|
+
params: Optional[Dict] = None # Добавить
|
|
157
159
|
) -> HttpResponse:
|
|
158
160
|
"""POST запрос"""
|
|
159
|
-
return self.request('POST', endpoint, data=data, json_data=json_data, files=files)
|
|
161
|
+
return self.request('POST', endpoint, data=data, json_data=json_data, files=files, params=params)
|
|
160
162
|
|
|
161
163
|
def put(self, endpoint: str, data: Any, headers: Optional[Dict] = None) -> HttpResponse:
|
|
162
164
|
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|