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
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Nextcloud Talk Channel Adapter
4Implements Nextcloud Talk messaging integration using REST API
5and WebSocket for real-time communication.
6Based on HevolveBot extension patterns for Nextcloud Talk.
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"""
20from __future__ import annotations
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
39try:
40 import websockets
41 from websockets.exceptions import ConnectionClosed
42 HAS_WEBSOCKETS = True
43except ImportError:
44 HAS_WEBSOCKETS = False
46from ..base import (
47 ChannelAdapter,
48 ChannelConfig,
49 ChannelStatus,
50 Message,
51 MessageType,
52 MediaAttachment,
53 SendResult,
54 ChannelConnectionError,
55 ChannelSendError,
56 ChannelRateLimitError,
57)
59logger = logging.getLogger(__name__)
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
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
81class MessageActorType(Enum):
82 """Types of message actors."""
83 USERS = "users"
84 GUESTS = "guests"
85 BOTS = "bots"
86 BRIDGED = "bridged"
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
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
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)
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)
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)
161class NextcloudAdapter(ChannelAdapter):
162 """
163 Nextcloud Talk messaging adapter with REST API and file sharing.
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 """
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] = []
189 @property
190 def name(self) -> str:
191 return "nextcloud"
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/")
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}/")
203 def _get_headers(self) -> Dict[str, str]:
204 """Get API request headers with Basic Auth."""
205 import base64
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()
211 return {
212 "Authorization": f"Basic {auth_bytes}",
213 "OCS-APIRequest": "true",
214 "Accept": "application/json",
215 "Content-Type": "application/json",
216 }
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
224 if not self.nc_config.username:
225 logger.error("Nextcloud username not provided")
226 return False
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
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 )
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
248 self._user_id = user_info["ocs"]["data"].get("id")
249 logger.info(f"Nextcloud authenticated as: {self._user_id}")
251 # Load conversations
252 await self._load_conversations()
254 # Start polling for messages
255 self._running = True
256 self._poll_task = asyncio.create_task(self._poll_loop())
258 self.status = ChannelStatus.CONNECTED
259 return True
261 except Exception as e:
262 logger.error(f"Failed to connect to Nextcloud: {e}")
263 self.status = ChannelStatus.ERROR
264 return False
266 async def disconnect(self) -> None:
267 """Disconnect from Nextcloud server."""
268 self._running = False
270 if self._poll_task:
271 self._poll_task.cancel()
272 try:
273 await self._poll_task
274 except asyncio.CancelledError:
275 pass
277 if self._session:
278 await self._session.close()
279 self._session = None
281 self._conversations.clear()
282 self._participants_cache.clear()
283 self._last_known_message.clear()
284 self.status = ChannelStatus.DISCONNECTED
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
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
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
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
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
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
362 async def _api_delete(self, endpoint: str) -> bool:
363 """Make DELETE request to Nextcloud API."""
364 if not self._session:
365 return False
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
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
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)
387 logger.info(f"Loaded {len(self._conversations)} conversations")
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 )
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)
412 # Also check for new conversations
413 await self._load_conversations()
415 await asyncio.sleep(self.nc_config.poll_interval)
416 self._reconnect_attempts = 0
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)
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)
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
441 result = await self._api_get(f"chat/{token}", params)
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
450 # Skip system messages unless relevant
451 if msg_data.get("messageType") == "system":
452 continue
454 # Convert and dispatch
455 message = self._convert_message(token, msg_data)
456 await self._dispatch_message(message)
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
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
468 # Parse message parameters (mentions, files, etc.)
469 message_text = msg_data.get("message", "")
470 message_params = msg_data.get("messageParameters", {})
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
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 ))
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
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"))
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 )
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 }
544 # Add reply reference
545 if reply_to:
546 data["replyTo"] = int(reply_to)
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}")
555 result = await self._api_post(f"chat/{chat_id}", data)
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")
567 except Exception as e:
568 logger.error(f"Failed to send Nextcloud message: {e}")
569 return SendResult(success=False, error=str(e))
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")
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")
592 # Share file to conversation
593 data = {
594 "shareType": 10, # Share to Talk room
595 "shareWith": token,
596 "path": file_path,
597 }
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}")
607 except Exception as e:
608 logger.error(f"Failed to share file: {e}")
609 return SendResult(success=False, error=str(e))
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
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)
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
629 except Exception as e:
630 logger.error(f"Failed to upload file: {e}")
631 return None
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)
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")
650 except Exception as e:
651 logger.error(f"Failed to edit Nextcloud message: {e}")
652 return SendResult(success=False, error=str(e))
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}")
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
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
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
686 # Nextcloud Talk-specific methods
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
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
710 except Exception as e:
711 logger.error(f"Failed to create conversation: {e}")
712 return None
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
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}")
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 []
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
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
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
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
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
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
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
831 return await self._api_delete(f"reaction/{chat_id}/{message_id}?reaction={quote(reaction)}")
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 {}
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")
860 try:
861 data = {
862 "question": question,
863 "options": options,
864 "resultMode": result_mode,
865 "maxVotes": max_votes,
866 }
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")
878 except Exception as e:
879 logger.error(f"Failed to create poll: {e}")
880 return SendResult(success=False, error=str(e))
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
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}")
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
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")
931 except Exception as e:
932 logger.error(f"Failed to share rich object: {e}")
933 return SendResult(success=False, error=str(e))
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
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
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
962 def on_reaction(self, handler: Callable) -> None:
963 """Register a handler for reaction events."""
964 self._reaction_handlers.append(handler)
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.
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
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")
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")
996 config = NextcloudConfig(
997 server_url=server_url,
998 username=username,
999 app_password=app_password,
1000 **kwargs
1001 )
1002 return NextcloudAdapter(config)