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.
Files changed (20) hide show
  1. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/PKG-INFO +1 -1
  2. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/pyproject.toml +1 -1
  3. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/async_bot.py +43 -32
  4. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/core/bot_core.py +32 -24
  5. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/http/async_.py +19 -20
  6. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/http/sync.py +4 -2
  7. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/.github/workflows/publish-to-pypi.yml +0 -0
  8. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/LICENSE +0 -0
  9. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/README.md +0 -0
  10. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/reqirements.txt +0 -0
  11. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/__init__.py +0 -0
  12. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/__init__.py +0 -0
  13. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/__init__.py +0 -0
  14. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/bot.py +0 -0
  15. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/core/__init__.py +0 -0
  16. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/core/models.py +0 -0
  17. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/http/__init__.py +0 -0
  18. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/bot/http/base.py +0 -0
  19. {ifa_nextcloud-0.2.2 → ifa_nextcloud-0.2.4}/src/nextcloud/email/__init__.py +0 -0
  20. {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.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ifa_nextcloud"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  authors = [
9
9
  {name = "ifake", email = "your@email.com"},
10
10
  ]
@@ -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
- params = {'lookIntoFuture': 0, 'setReadMarker': 0}
409
+ # Отправляем без params, как в оригинале
410
+ response = await self.http.post(endpoint, data=data)
407
411
 
408
- response = await self.http.post(endpoint, data=data, params=params)
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
- self,
425
- chat_id: str,
426
- text: str,
427
- file: Tuple[str, bytes, str],
428
- reply_to_message_id: int = None
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
- <d:prop>
494
- <oc:fileid />
495
- <oc:size />
496
- <d:getlastmodified />
497
- </d:prop>
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
- for key, param in message_params.items():
85
- if param.get('type') == 'file':
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
- file_param = message_params.get('file', {})
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
- if message_params.get('forward'):
133
- is_forwarded = True
134
- forward_origin = message_params.get('forward', {}).get('name', 'unknown')
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
- json_response = json.loads(raw_text) if raw_text else {}
116
- # Извлекаем ocs.data как в оригинальном боте
117
- data = json_response.get('ocs', {}).get('data', {})
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
- logger.error(f"Ошибка парсинга JSON: {raw_text[:200]}")
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