Coverage for integrations / channels / extensions / nextcloud_adapter.py: 33.8%

450 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2Nextcloud Talk Channel Adapter 

3 

4Implements Nextcloud Talk messaging integration using REST API 

5and WebSocket for real-time communication. 

6Based on HevolveBot extension patterns for Nextcloud Talk. 

7 

8Features: 

9- REST API integration 

10- WebSocket for real-time messaging 

11- File sharing integration with Nextcloud Files 

12- Reactions support 

13- Room/conversation management 

14- Participants management 

15- Rich object sharing 

16- Polls support 

17- Reconnection logic 

18""" 

19 

20from __future__ import annotations 

21 

22import asyncio 

23import logging 

24import os 

25import json 

26try: 

27 import aiohttp 

28 HAS_AIOHTTP = True 

29except ImportError: 

30 HAS_AIOHTTP = False 

31import hashlib 

32import hmac 

33from typing import Optional, List, Dict, Any, Callable 

34from datetime import datetime 

35from dataclasses import dataclass, field 

36from urllib.parse import urljoin, quote 

37from enum import Enum 

38 

39try: 

40 import websockets 

41 from websockets.exceptions import ConnectionClosed 

42 HAS_WEBSOCKETS = True 

43except ImportError: 

44 HAS_WEBSOCKETS = False 

45 

46from ..base import ( 

47 ChannelAdapter, 

48 ChannelConfig, 

49 ChannelStatus, 

50 Message, 

51 MessageType, 

52 MediaAttachment, 

53 SendResult, 

54 ChannelConnectionError, 

55 ChannelSendError, 

56 ChannelRateLimitError, 

57) 

58 

59logger = logging.getLogger(__name__) 

60 

61 

62class ConversationType(Enum): 

63 """Nextcloud Talk conversation types.""" 

64 ONE_TO_ONE = 1 

65 GROUP = 2 

66 PUBLIC = 3 

67 CHANGELOG = 4 

68 FORMER_ONE_TO_ONE = 5 

69 

70 

71class ParticipantType(Enum): 

72 """Participant types in a conversation.""" 

73 OWNER = 1 

74 MODERATOR = 2 

75 USER = 3 

76 GUEST = 4 

77 USER_SELF_JOINED = 5 

78 GUEST_MODERATOR = 6 

79 

80 

81class MessageActorType(Enum): 

82 """Types of message actors.""" 

83 USERS = "users" 

84 GUESTS = "guests" 

85 BOTS = "bots" 

86 BRIDGED = "bridged" 

87 

88 

89@dataclass 

90class NextcloudConfig(ChannelConfig): 

91 """Nextcloud Talk-specific configuration.""" 

92 server_url: str = "" 

93 username: str = "" 

94 password: str = "" 

95 app_password: Optional[str] = None # Recommended over password 

96 enable_file_sharing: bool = True 

97 enable_reactions: bool = True 

98 enable_polls: bool = True 

99 poll_interval: float = 2.0 # For long-polling fallback 

100 reconnect_delay: float = 5.0 

101 max_reconnect_attempts: int = 10 

102 verify_ssl: bool = True 

103 

104 

105@dataclass 

106class NextcloudConversation: 

107 """Nextcloud Talk conversation/room information.""" 

108 token: str 

109 name: str 

110 display_name: str 

111 type: ConversationType 

112 participant_type: ParticipantType 

113 read_only: bool = False 

114 has_password: bool = False 

115 has_call: bool = False 

116 unread_messages: int = 0 

117 last_activity: Optional[datetime] = None 

118 description: Optional[str] = None 

119 

120 

121@dataclass 

122class NextcloudParticipant: 

123 """Participant in a conversation.""" 

124 attendee_id: int 

125 actor_type: str 

126 actor_id: str 

127 display_name: str 

128 participant_type: ParticipantType 

129 last_ping: Optional[datetime] = None 

130 in_call: bool = False 

131 session_ids: List[str] = field(default_factory=list) 

132 

133 

134@dataclass 

135class NextcloudMessage: 

136 """Nextcloud Talk message representation.""" 

137 id: int 

138 token: str 

139 actor_type: str 

140 actor_id: str 

141 actor_display_name: str 

142 message: str 

143 timestamp: datetime 

144 message_type: str = "comment" # comment, system, command 

145 is_replyable: bool = True 

146 reference_id: Optional[str] = None 

147 parent_id: Optional[int] = None 

148 reactions: Dict[str, int] = field(default_factory=dict) 

149 message_parameters: Dict[str, Any] = field(default_factory=dict) 

150 

151 

152@dataclass 

153class RichObjectParameter: 

154 """Rich object parameter for message sharing.""" 

155 type: str # file, deck-card, talk-poll, etc. 

156 id: str 

157 name: str 

158 extra: Dict[str, Any] = field(default_factory=dict) 

159 

160 

161class NextcloudAdapter(ChannelAdapter): 

162 """ 

163 Nextcloud Talk messaging adapter with REST API and file sharing. 

164 

165 Usage: 

166 config = NextcloudConfig( 

167 server_url="https://nextcloud.example.com", 

168 username="bot", 

169 app_password="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx", 

170 ) 

171 adapter = NextcloudAdapter(config) 

172 adapter.on_message(my_handler) 

173 await adapter.start() 

174 """ 

175 

176 def __init__(self, config: NextcloudConfig): 

177 super().__init__(config) 

178 self.nc_config: NextcloudConfig = config 

179 self._session: Optional[aiohttp.ClientSession] = None 

180 self._poll_task: Optional[asyncio.Task] = None 

181 self._user_id: Optional[str] = None 

182 self._conversations: Dict[str, NextcloudConversation] = {} 

183 self._participants_cache: Dict[str, List[NextcloudParticipant]] = {} 

184 self._last_known_message: Dict[str, int] = {} 

185 self._reconnect_attempts: int = 0 

186 self._running: bool = False 

187 self._reaction_handlers: List[Callable] = [] 

188 

189 @property 

190 def name(self) -> str: 

191 return "nextcloud" 

192 

193 @property 

194 def _api_url(self) -> str: 

195 """Get OCS API base URL.""" 

196 return urljoin(self.nc_config.server_url, "/ocs/v2.php/apps/spreed/api/v4/") 

197 

198 @property 

199 def _dav_url(self) -> str: 

200 """Get WebDAV base URL for file operations.""" 

201 return urljoin(self.nc_config.server_url, f"/remote.php/dav/files/{self.nc_config.username}/") 

202 

203 def _get_headers(self) -> Dict[str, str]: 

204 """Get API request headers with Basic Auth.""" 

205 import base64 

206 

207 password = self.nc_config.app_password or self.nc_config.password 

208 auth_string = f"{self.nc_config.username}:{password}" 

209 auth_bytes = base64.b64encode(auth_string.encode()).decode() 

210 

211 return { 

212 "Authorization": f"Basic {auth_bytes}", 

213 "OCS-APIRequest": "true", 

214 "Accept": "application/json", 

215 "Content-Type": "application/json", 

216 } 

217 

218 async def connect(self) -> bool: 

219 """Connect to Nextcloud Talk server.""" 

220 if not self.nc_config.server_url: 

221 logger.error("Nextcloud server URL not provided") 

222 return False 

223 

224 if not self.nc_config.username: 

225 logger.error("Nextcloud username not provided") 

226 return False 

227 

228 password = self.nc_config.app_password or self.nc_config.password 

229 if not password: 

230 logger.error("Nextcloud password or app password not provided") 

231 return False 

232 

233 try: 

234 # Create HTTP session 

235 ssl_context = None if self.nc_config.verify_ssl else False 

236 connector = aiohttp.TCPConnector(ssl=ssl_context) 

237 self._session = aiohttp.ClientSession( 

238 headers=self._get_headers(), 

239 connector=connector, 

240 ) 

241 

242 # Verify authentication by getting user info 

243 user_info = await self._api_get("../../../cloud/user") 

244 if not user_info or "ocs" not in user_info: 

245 logger.error("Failed to authenticate with Nextcloud") 

246 return False 

247 

248 self._user_id = user_info["ocs"]["data"].get("id") 

249 logger.info(f"Nextcloud authenticated as: {self._user_id}") 

250 

251 # Load conversations 

252 await self._load_conversations() 

253 

254 # Start polling for messages 

255 self._running = True 

256 self._poll_task = asyncio.create_task(self._poll_loop()) 

257 

258 self.status = ChannelStatus.CONNECTED 

259 return True 

260 

261 except Exception as e: 

262 logger.error(f"Failed to connect to Nextcloud: {e}") 

263 self.status = ChannelStatus.ERROR 

264 return False 

265 

266 async def disconnect(self) -> None: 

267 """Disconnect from Nextcloud server.""" 

268 self._running = False 

269 

270 if self._poll_task: 

271 self._poll_task.cancel() 

272 try: 

273 await self._poll_task 

274 except asyncio.CancelledError: 

275 pass 

276 

277 if self._session: 

278 await self._session.close() 

279 self._session = None 

280 

281 self._conversations.clear() 

282 self._participants_cache.clear() 

283 self._last_known_message.clear() 

284 self.status = ChannelStatus.DISCONNECTED 

285 

286 async def _api_get( 

287 self, 

288 endpoint: str, 

289 params: Optional[Dict[str, Any]] = None, 

290 ) -> Optional[Dict[str, Any]]: 

291 """Make GET request to Nextcloud API.""" 

292 if not self._session: 

293 return None 

294 

295 try: 

296 url = urljoin(self._api_url, endpoint) 

297 async with self._session.get(url, params=params) as response: 

298 if response.status == 200: 

299 return await response.json() 

300 elif response.status == 429: 

301 raise ChannelRateLimitError() 

302 else: 

303 logger.error(f"API GET {endpoint} failed: {response.status}") 

304 return None 

305 except ChannelRateLimitError: 

306 raise 

307 except Exception as e: 

308 logger.error(f"API GET {endpoint} error: {e}") 

309 return None 

310 

311 async def _api_post( 

312 self, 

313 endpoint: str, 

314 data: Optional[Dict[str, Any]] = None, 

315 ) -> Optional[Dict[str, Any]]: 

316 """Make POST request to Nextcloud API.""" 

317 if not self._session: 

318 return None 

319 

320 try: 

321 url = urljoin(self._api_url, endpoint) 

322 async with self._session.post(url, json=data) as response: 

323 if response.status in (200, 201): 

324 return await response.json() 

325 elif response.status == 429: 

326 raise ChannelRateLimitError() 

327 else: 

328 error_text = await response.text() 

329 logger.error(f"API POST {endpoint} failed: {response.status} - {error_text}") 

330 return None 

331 except ChannelRateLimitError: 

332 raise 

333 except Exception as e: 

334 logger.error(f"API POST {endpoint} error: {e}") 

335 return None 

336 

337 async def _api_put( 

338 self, 

339 endpoint: str, 

340 data: Optional[Dict[str, Any]] = None, 

341 ) -> Optional[Dict[str, Any]]: 

342 """Make PUT request to Nextcloud API.""" 

343 if not self._session: 

344 return None 

345 

346 try: 

347 url = urljoin(self._api_url, endpoint) 

348 async with self._session.put(url, json=data) as response: 

349 if response.status == 200: 

350 return await response.json() 

351 elif response.status == 429: 

352 raise ChannelRateLimitError() 

353 else: 

354 logger.error(f"API PUT {endpoint} failed: {response.status}") 

355 return None 

356 except ChannelRateLimitError: 

357 raise 

358 except Exception as e: 

359 logger.error(f"API PUT {endpoint} error: {e}") 

360 return None 

361 

362 async def _api_delete(self, endpoint: str) -> bool: 

363 """Make DELETE request to Nextcloud API.""" 

364 if not self._session: 

365 return False 

366 

367 try: 

368 url = urljoin(self._api_url, endpoint) 

369 async with self._session.delete(url) as response: 

370 return response.status in (200, 204) 

371 except Exception as e: 

372 logger.error(f"API DELETE {endpoint} error: {e}") 

373 return False 

374 

375 async def _load_conversations(self) -> None: 

376 """Load all conversations the bot is part of.""" 

377 result = await self._api_get("room") 

378 if result and "ocs" in result: 

379 for conv_data in result["ocs"]["data"]: 

380 conv = self._parse_conversation(conv_data) 

381 self._conversations[conv.token] = conv 

382 

383 # Initialize last known message ID 

384 if conv_data.get("lastMessage"): 

385 self._last_known_message[conv.token] = conv_data["lastMessage"].get("id", 0) 

386 

387 logger.info(f"Loaded {len(self._conversations)} conversations") 

388 

389 def _parse_conversation(self, data: Dict[str, Any]) -> NextcloudConversation: 

390 """Parse conversation data from API response.""" 

391 return NextcloudConversation( 

392 token=data.get("token", ""), 

393 name=data.get("name", ""), 

394 display_name=data.get("displayName", ""), 

395 type=ConversationType(data.get("type", 2)), 

396 participant_type=ParticipantType(data.get("participantType", 3)), 

397 read_only=data.get("readOnly", 0) == 1, 

398 has_password=data.get("hasPassword", False), 

399 has_call=data.get("hasCall", False), 

400 unread_messages=data.get("unreadMessages", 0), 

401 last_activity=datetime.fromtimestamp(data["lastActivity"]) if data.get("lastActivity") else None, 

402 description=data.get("description"), 

403 ) 

404 

405 async def _poll_loop(self) -> None: 

406 """Poll for new messages from all conversations.""" 

407 while self._running: 

408 try: 

409 for token in list(self._conversations.keys()): 

410 await self._poll_conversation(token) 

411 

412 # Also check for new conversations 

413 await self._load_conversations() 

414 

415 await asyncio.sleep(self.nc_config.poll_interval) 

416 self._reconnect_attempts = 0 

417 

418 except asyncio.CancelledError: 

419 break 

420 except Exception as e: 

421 logger.error(f"Poll error: {e}") 

422 self._reconnect_attempts += 1 

423 if self._reconnect_attempts > self.nc_config.max_reconnect_attempts: 

424 self.status = ChannelStatus.ERROR 

425 break 

426 await asyncio.sleep(self.nc_config.reconnect_delay) 

427 

428 async def _poll_conversation(self, token: str) -> None: 

429 """Poll for new messages in a specific conversation.""" 

430 last_id = self._last_known_message.get(token, 0) 

431 

432 # Get messages since last known 

433 params = { 

434 "lookIntoFuture": 1, 

435 "limit": 100, 

436 "setReadMarker": 0, 

437 } 

438 if last_id > 0: 

439 params["lastKnownMessageId"] = last_id 

440 

441 result = await self._api_get(f"chat/{token}", params) 

442 

443 if result and "ocs" in result: 

444 messages = result["ocs"]["data"] 

445 for msg_data in messages: 

446 # Skip own messages 

447 if msg_data.get("actorId") == self._user_id: 

448 continue 

449 

450 # Skip system messages unless relevant 

451 if msg_data.get("messageType") == "system": 

452 continue 

453 

454 # Convert and dispatch 

455 message = self._convert_message(token, msg_data) 

456 await self._dispatch_message(message) 

457 

458 # Update last known message ID 

459 msg_id = msg_data.get("id", 0) 

460 if msg_id > self._last_known_message.get(token, 0): 

461 self._last_known_message[token] = msg_id 

462 

463 def _convert_message(self, token: str, msg_data: Dict[str, Any]) -> Message: 

464 """Convert Nextcloud Talk message to unified Message format.""" 

465 conv = self._conversations.get(token) 

466 is_group = conv.type != ConversationType.ONE_TO_ONE if conv else True 

467 

468 # Parse message parameters (mentions, files, etc.) 

469 message_text = msg_data.get("message", "") 

470 message_params = msg_data.get("messageParameters", {}) 

471 

472 # Process file attachments 

473 media = [] 

474 for param_name, param_data in message_params.items(): 

475 if param_data.get("type") == "file": 

476 media_type = MessageType.DOCUMENT 

477 mime_type = param_data.get("mimetype", "") 

478 if mime_type.startswith("image/"): 

479 media_type = MessageType.IMAGE 

480 elif mime_type.startswith("video/"): 

481 media_type = MessageType.VIDEO 

482 elif mime_type.startswith("audio/"): 

483 media_type = MessageType.AUDIO 

484 

485 media.append(MediaAttachment( 

486 type=media_type, 

487 file_id=str(param_data.get("id")), 

488 file_name=param_data.get("name"), 

489 mime_type=mime_type, 

490 file_size=param_data.get("size"), 

491 url=param_data.get("link"), 

492 )) 

493 

494 # Check for bot mention 

495 is_mentioned = False 

496 for param_name, param_data in message_params.items(): 

497 if param_data.get("type") == "user" and param_data.get("id") == self._user_id: 

498 is_mentioned = True 

499 break 

500 

501 # Get reply-to ID 

502 reply_to_id = None 

503 parent = msg_data.get("parent") 

504 if parent: 

505 reply_to_id = str(parent.get("id")) 

506 

507 return Message( 

508 id=str(msg_data.get("id", "")), 

509 channel=self.name, 

510 sender_id=msg_data.get("actorId", ""), 

511 sender_name=msg_data.get("actorDisplayName", ""), 

512 chat_id=token, 

513 text=message_text, 

514 media=media, 

515 reply_to_id=reply_to_id, 

516 timestamp=datetime.fromtimestamp(msg_data.get("timestamp", 0)), 

517 is_group=is_group, 

518 is_bot_mentioned=is_mentioned, 

519 raw={ 

520 "message_type": msg_data.get("messageType"), 

521 "actor_type": msg_data.get("actorType"), 

522 "reference_id": msg_data.get("referenceId"), 

523 "reactions": msg_data.get("reactions", {}), 

524 "message_parameters": message_params, 

525 "conversation_name": conv.display_name if conv else None, 

526 }, 

527 ) 

528 

529 async def send_message( 

530 self, 

531 chat_id: str, 

532 text: str, 

533 reply_to: Optional[str] = None, 

534 media: Optional[List[MediaAttachment]] = None, 

535 buttons: Optional[List[Dict]] = None, 

536 ) -> SendResult: 

537 """Send a message to a Nextcloud Talk conversation.""" 

538 try: 

539 data = { 

540 "message": text, 

541 "actorDisplayName": self.nc_config.username, 

542 } 

543 

544 # Add reply reference 

545 if reply_to: 

546 data["replyTo"] = int(reply_to) 

547 

548 # Handle file attachments 

549 if media and self.nc_config.enable_file_sharing: 

550 for m in media: 

551 file_result = await self._share_file(chat_id, m) 

552 if not file_result.success: 

553 logger.warning(f"Failed to share file: {file_result.error}") 

554 

555 result = await self._api_post(f"chat/{chat_id}", data) 

556 

557 if result and "ocs" in result: 

558 msg_data = result["ocs"]["data"] 

559 return SendResult( 

560 success=True, 

561 message_id=str(msg_data.get("id")), 

562 raw=msg_data, 

563 ) 

564 else: 

565 return SendResult(success=False, error="Failed to send message") 

566 

567 except Exception as e: 

568 logger.error(f"Failed to send Nextcloud message: {e}") 

569 return SendResult(success=False, error=str(e)) 

570 

571 async def _share_file( 

572 self, 

573 token: str, 

574 media: MediaAttachment, 

575 ) -> SendResult: 

576 """Share a file from Nextcloud Files to a conversation.""" 

577 if not self._session: 

578 return SendResult(success=False, error="Not connected") 

579 

580 try: 

581 # If file path provided, upload to Nextcloud first 

582 if media.file_path: 

583 upload_result = await self._upload_file(media.file_path) 

584 if not upload_result: 

585 return SendResult(success=False, error="Failed to upload file") 

586 file_path = upload_result 

587 elif media.file_id: 

588 file_path = media.file_id 

589 else: 

590 return SendResult(success=False, error="No file source") 

591 

592 # Share file to conversation 

593 data = { 

594 "shareType": 10, # Share to Talk room 

595 "shareWith": token, 

596 "path": file_path, 

597 } 

598 

599 share_url = urljoin(self.nc_config.server_url, "/ocs/v2.php/apps/files_sharing/api/v1/shares") 

600 async with self._session.post(share_url, json=data) as response: 

601 if response.status in (200, 201): 

602 return SendResult(success=True) 

603 else: 

604 error_text = await response.text() 

605 return SendResult(success=False, error=f"Share failed: {error_text}") 

606 

607 except Exception as e: 

608 logger.error(f"Failed to share file: {e}") 

609 return SendResult(success=False, error=str(e)) 

610 

611 async def _upload_file(self, file_path: str) -> Optional[str]: 

612 """Upload a file to Nextcloud Files.""" 

613 if not self._session: 

614 return None 

615 

616 try: 

617 file_name = os.path.basename(file_path) 

618 upload_path = f"Talk/{file_name}" 

619 url = urljoin(self._dav_url, upload_path) 

620 

621 with open(file_path, "rb") as f: 

622 async with self._session.put(url, data=f) as response: 

623 if response.status in (201, 204): 

624 return f"/{upload_path}" 

625 else: 

626 logger.error(f"File upload failed: {response.status}") 

627 return None 

628 

629 except Exception as e: 

630 logger.error(f"Failed to upload file: {e}") 

631 return None 

632 

633 async def edit_message( 

634 self, 

635 chat_id: str, 

636 message_id: str, 

637 text: str, 

638 buttons: Optional[List[Dict]] = None, 

639 ) -> SendResult: 

640 """Edit an existing Nextcloud Talk message.""" 

641 try: 

642 data = {"message": text} 

643 result = await self._api_put(f"chat/{chat_id}/{message_id}", data) 

644 

645 if result and "ocs" in result: 

646 return SendResult(success=True, message_id=message_id) 

647 else: 

648 return SendResult(success=False, error="Failed to edit message") 

649 

650 except Exception as e: 

651 logger.error(f"Failed to edit Nextcloud message: {e}") 

652 return SendResult(success=False, error=str(e)) 

653 

654 async def delete_message(self, chat_id: str, message_id: str) -> bool: 

655 """Delete a Nextcloud Talk message.""" 

656 return await self._api_delete(f"chat/{chat_id}/{message_id}") 

657 

658 async def send_typing(self, chat_id: str) -> None: 

659 """Send typing indicator (not supported in Nextcloud Talk).""" 

660 # Nextcloud Talk doesn't have a typing indicator API 

661 pass 

662 

663 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]: 

664 """Get information about a Nextcloud Talk conversation.""" 

665 conv = self._conversations.get(chat_id) 

666 if not conv: 

667 # Try to fetch from API 

668 result = await self._api_get(f"room/{chat_id}") 

669 if result and "ocs" in result: 

670 conv = self._parse_conversation(result["ocs"]["data"]) 

671 self._conversations[chat_id] = conv 

672 

673 if conv: 

674 return { 

675 "token": conv.token, 

676 "name": conv.name, 

677 "display_name": conv.display_name, 

678 "type": conv.type.name, 

679 "read_only": conv.read_only, 

680 "has_call": conv.has_call, 

681 "unread_messages": conv.unread_messages, 

682 "description": conv.description, 

683 } 

684 return None 

685 

686 # Nextcloud Talk-specific methods 

687 

688 async def create_conversation( 

689 self, 

690 room_type: int = 2, # 2 = group, 3 = public 

691 invite: Optional[str] = None, 

692 room_name: str = "", 

693 ) -> Optional[str]: 

694 """Create a new conversation.""" 

695 try: 

696 data = {"roomType": room_type} 

697 if invite: 

698 data["invite"] = invite 

699 if room_name: 

700 data["roomName"] = room_name 

701 

702 result = await self._api_post("room", data) 

703 if result and "ocs" in result: 

704 conv_data = result["ocs"]["data"] 

705 conv = self._parse_conversation(conv_data) 

706 self._conversations[conv.token] = conv 

707 return conv.token 

708 return None 

709 

710 except Exception as e: 

711 logger.error(f"Failed to create conversation: {e}") 

712 return None 

713 

714 async def add_participant( 

715 self, 

716 token: str, 

717 user_id: str, 

718 ) -> bool: 

719 """Add a participant to a conversation.""" 

720 try: 

721 data = { 

722 "newParticipant": user_id, 

723 "source": "users", 

724 } 

725 result = await self._api_post(f"room/{token}/participants", data) 

726 return result is not None 

727 except Exception as e: 

728 logger.error(f"Failed to add participant: {e}") 

729 return False 

730 

731 async def remove_participant( 

732 self, 

733 token: str, 

734 attendee_id: int, 

735 ) -> bool: 

736 """Remove a participant from a conversation.""" 

737 return await self._api_delete(f"room/{token}/attendees/{attendee_id}") 

738 

739 async def get_participants(self, token: str) -> List[NextcloudParticipant]: 

740 """Get participants of a conversation.""" 

741 try: 

742 result = await self._api_get(f"room/{token}/participants") 

743 if result and "ocs" in result: 

744 participants = [] 

745 for p_data in result["ocs"]["data"]: 

746 participant = NextcloudParticipant( 

747 attendee_id=p_data.get("attendeeId"), 

748 actor_type=p_data.get("actorType"), 

749 actor_id=p_data.get("actorId"), 

750 display_name=p_data.get("displayName"), 

751 participant_type=ParticipantType(p_data.get("participantType", 3)), 

752 in_call=p_data.get("inCall", 0) > 0, 

753 session_ids=p_data.get("sessionIds", []), 

754 ) 

755 participants.append(participant) 

756 self._participants_cache[token] = participants 

757 return participants 

758 return [] 

759 except Exception as e: 

760 logger.error(f"Failed to get participants: {e}") 

761 return [] 

762 

763 async def set_conversation_name(self, token: str, name: str) -> bool: 

764 """Set conversation display name.""" 

765 try: 

766 result = await self._api_put(f"room/{token}", {"roomName": name}) 

767 if result and "ocs" in result: 

768 if token in self._conversations: 

769 self._conversations[token].display_name = name 

770 return True 

771 return False 

772 except Exception as e: 

773 logger.error(f"Failed to set conversation name: {e}") 

774 return False 

775 

776 async def set_conversation_description(self, token: str, description: str) -> bool: 

777 """Set conversation description.""" 

778 try: 

779 result = await self._api_put(f"room/{token}/description", {"description": description}) 

780 return result is not None 

781 except Exception as e: 

782 logger.error(f"Failed to set conversation description: {e}") 

783 return False 

784 

785 async def leave_conversation(self, token: str) -> bool: 

786 """Leave a conversation.""" 

787 result = await self._api_delete(f"room/{token}/participants/self") 

788 if result: 

789 self._conversations.pop(token, None) 

790 self._last_known_message.pop(token, None) 

791 return result 

792 

793 async def delete_conversation(self, token: str) -> bool: 

794 """Delete a conversation (must be moderator).""" 

795 result = await self._api_delete(f"room/{token}") 

796 if result: 

797 self._conversations.pop(token, None) 

798 self._last_known_message.pop(token, None) 

799 return result 

800 

801 async def add_reaction( 

802 self, 

803 chat_id: str, 

804 message_id: str, 

805 reaction: str, 

806 ) -> bool: 

807 """Add a reaction to a message.""" 

808 if not self.nc_config.enable_reactions: 

809 return False 

810 

811 try: 

812 result = await self._api_post( 

813 f"reaction/{chat_id}/{message_id}", 

814 {"reaction": reaction} 

815 ) 

816 return result is not None 

817 except Exception as e: 

818 logger.error(f"Failed to add reaction: {e}") 

819 return False 

820 

821 async def remove_reaction( 

822 self, 

823 chat_id: str, 

824 message_id: str, 

825 reaction: str, 

826 ) -> bool: 

827 """Remove a reaction from a message.""" 

828 if not self.nc_config.enable_reactions: 

829 return False 

830 

831 return await self._api_delete(f"reaction/{chat_id}/{message_id}?reaction={quote(reaction)}") 

832 

833 async def get_reactions( 

834 self, 

835 chat_id: str, 

836 message_id: str, 

837 ) -> Dict[str, List[str]]: 

838 """Get reactions on a message.""" 

839 try: 

840 result = await self._api_get(f"reaction/{chat_id}/{message_id}") 

841 if result and "ocs" in result: 

842 return result["ocs"]["data"] 

843 return {} 

844 except Exception as e: 

845 logger.error(f"Failed to get reactions: {e}") 

846 return {} 

847 

848 async def create_poll( 

849 self, 

850 chat_id: str, 

851 question: str, 

852 options: List[str], 

853 result_mode: int = 0, # 0 = public, 1 = hidden 

854 max_votes: int = 0, # 0 = unlimited 

855 ) -> SendResult: 

856 """Create a poll in a conversation.""" 

857 if not self.nc_config.enable_polls: 

858 return SendResult(success=False, error="Polls disabled") 

859 

860 try: 

861 data = { 

862 "question": question, 

863 "options": options, 

864 "resultMode": result_mode, 

865 "maxVotes": max_votes, 

866 } 

867 

868 result = await self._api_post(f"poll/{chat_id}", data) 

869 if result and "ocs" in result: 

870 poll_data = result["ocs"]["data"] 

871 return SendResult( 

872 success=True, 

873 message_id=str(poll_data.get("id")), 

874 raw=poll_data, 

875 ) 

876 return SendResult(success=False, error="Failed to create poll") 

877 

878 except Exception as e: 

879 logger.error(f"Failed to create poll: {e}") 

880 return SendResult(success=False, error=str(e)) 

881 

882 async def vote_poll( 

883 self, 

884 chat_id: str, 

885 poll_id: int, 

886 option_ids: List[int], 

887 ) -> bool: 

888 """Vote on a poll.""" 

889 try: 

890 result = await self._api_post( 

891 f"poll/{chat_id}/{poll_id}", 

892 {"optionIds": option_ids} 

893 ) 

894 return result is not None 

895 except Exception as e: 

896 logger.error(f"Failed to vote on poll: {e}") 

897 return False 

898 

899 async def close_poll(self, chat_id: str, poll_id: int) -> bool: 

900 """Close a poll.""" 

901 return await self._api_delete(f"poll/{chat_id}/{poll_id}") 

902 

903 async def share_rich_object( 

904 self, 

905 chat_id: str, 

906 object_type: str, 

907 object_id: str, 

908 meta_data: Dict[str, Any], 

909 reference_id: Optional[str] = None, 

910 ) -> SendResult: 

911 """Share a rich object (deck card, location, etc.) to a conversation.""" 

912 try: 

913 data = { 

914 "objectType": object_type, 

915 "objectId": object_id, 

916 "metaData": json.dumps(meta_data), 

917 } 

918 if reference_id: 

919 data["referenceId"] = reference_id 

920 

921 result = await self._api_post(f"chat/{chat_id}/share", data) 

922 if result and "ocs" in result: 

923 msg_data = result["ocs"]["data"] 

924 return SendResult( 

925 success=True, 

926 message_id=str(msg_data.get("id")), 

927 raw=msg_data, 

928 ) 

929 return SendResult(success=False, error="Failed to share rich object") 

930 

931 except Exception as e: 

932 logger.error(f"Failed to share rich object: {e}") 

933 return SendResult(success=False, error=str(e)) 

934 

935 async def set_read_marker(self, chat_id: str, message_id: int) -> bool: 

936 """Mark messages as read up to a specific message.""" 

937 try: 

938 result = await self._api_post( 

939 f"chat/{chat_id}/read", 

940 {"lastReadMessage": message_id} 

941 ) 

942 return result is not None 

943 except Exception as e: 

944 logger.error(f"Failed to set read marker: {e}") 

945 return False 

946 

947 async def download_file(self, file_path: str) -> Optional[bytes]: 

948 """Download a file from Nextcloud Files.""" 

949 if not self._session: 

950 return None 

951 

952 try: 

953 url = urljoin(self._dav_url, file_path.lstrip("/")) 

954 async with self._session.get(url) as response: 

955 if response.status == 200: 

956 return await response.read() 

957 return None 

958 except Exception as e: 

959 logger.error(f"Failed to download file: {e}") 

960 return None 

961 

962 def on_reaction(self, handler: Callable) -> None: 

963 """Register a handler for reaction events.""" 

964 self._reaction_handlers.append(handler) 

965 

966 

967def create_nextcloud_adapter( 

968 server_url: str = None, 

969 username: str = None, 

970 app_password: str = None, 

971 **kwargs 

972) -> NextcloudAdapter: 

973 """ 

974 Factory function to create Nextcloud Talk adapter. 

975 

976 Args: 

977 server_url: Nextcloud server URL (or set NEXTCLOUD_URL env var) 

978 username: Username (or set NEXTCLOUD_USERNAME env var) 

979 app_password: App password (or set NEXTCLOUD_APP_PASSWORD env var) 

980 **kwargs: Additional config options 

981 

982 Returns: 

983 Configured NextcloudAdapter 

984 """ 

985 server_url = server_url or os.getenv("NEXTCLOUD_URL") 

986 username = username or os.getenv("NEXTCLOUD_USERNAME") 

987 app_password = app_password or os.getenv("NEXTCLOUD_APP_PASSWORD") 

988 

989 if not server_url: 

990 raise ValueError("Nextcloud server URL required") 

991 if not username: 

992 raise ValueError("Nextcloud username required") 

993 if not app_password: 

994 raise ValueError("Nextcloud app password required") 

995 

996 config = NextcloudConfig( 

997 server_url=server_url, 

998 username=username, 

999 app_password=app_password, 

1000 **kwargs 

1001 ) 

1002 return NextcloudAdapter(config)