Coverage for slidge/util/types.py: 96%
186 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 03:59 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 03:59 +0000
1"""
2Typing stuff
3"""
5import contextlib
6import re
7import warnings
8from collections.abc import AsyncIterator, Iterable
9from dataclasses import dataclass, fields
10from datetime import datetime
11from enum import IntEnum
12from functools import cached_property
13from pathlib import Path
14from typing import (
15 IO,
16 TYPE_CHECKING,
17 Any,
18 Literal,
19 NamedTuple,
20 TypeAlias,
21 TypedDict,
22 TypeVar,
23 Union,
24)
26import aiohttp
27from slixmpp import Message, Presence
28from slixmpp.types import PresenceShows, PresenceTypes, ResourceDict # noqa: F401
30if TYPE_CHECKING:
31 from ..contact import LegacyContact, LegacyRoster
32 from ..core.gateway import BaseGateway
33 from ..core.session import BaseSession
34 from ..group import LegacyBookmarks, LegacyMUC
35 from ..group.participant import LegacyParticipant
37AnySession: TypeAlias = "BaseSession[Any]"
38AnyGateway: TypeAlias = "BaseGateway[AnySession]"
39AnyMUC: TypeAlias = "LegacyMUC[Any]"
40AnyBookmarks: TypeAlias = "LegacyBookmarks[Any]"
41AnyRoster: TypeAlias = "LegacyRoster[Any]"
42AnyParticipant: TypeAlias = "LegacyParticipant[Any]"
44LegacyContactType = TypeVar("LegacyContactType", bound="LegacyContact")
45LegacyMUCType = TypeVar("LegacyMUCType", bound=AnyMUC)
46LegacyParticipantType = TypeVar("LegacyParticipantType", bound=AnyParticipant)
48SessionType = TypeVar("SessionType", bound=AnySession)
49AnyRecipient = Union["LegacyContact", AnyMUC]
50RecipientType = TypeVar("RecipientType", bound=AnyRecipient)
51Sender = Union["LegacyContact", "AnyParticipant"]
53ChatState = Literal["active", "composing", "gone", "inactive", "paused"]
54ProcessingHint = Literal["no-store", "markable", "store"]
55Marker = Literal["acknowledged", "received", "displayed"]
56FieldType = Literal[
57 "boolean",
58 "fixed",
59 "text-single",
60 "text-multi",
61 "jid-single",
62 "jid-multi",
63 "list-single",
64 "list-multi",
65 "text-private",
66]
67MucAffiliation = Literal["owner", "admin", "member", "outcast", "none"]
68MucRole = Literal["visitor", "participant", "moderator", "none"]
69# https://xmpp.org/registrar/disco-categories.html#client
70ClientType = Literal[
71 "bot", "console", "game", "handheld", "pc", "phone", "sms", "tablet", "web"
72]
73AttachmentDisposition = Literal["attachment", "inline"]
76@dataclass
77class MessageReference:
78 """
79 A "message reply", ie a "quoted message" (:xep:`0461`)
81 At the very minimum, the legacy message ID attribute must be set, but to
82 ensure that the quote is displayed in all XMPP clients, the author must also
83 be set (use the string "user" if the slidge user is the author of the referenced
84 message).
85 The body is used as a fallback for XMPP clients that do not support :xep:`0461`
86 of that failed to find the referenced message.
87 """
89 legacy_id: str
90 author: Union[Literal["user"], AnyParticipant, "LegacyContact"] | None = None
91 body: str | None = None
94@dataclass
95class LegacyAttachment:
96 """
97 A file attachment to a message
99 At the minimum, one of the ``path``, ``steam``, ``data`` or ``url`` attribute
100 has to be set
102 To be used with :meth:`.LegacyContact.send_files` or
103 :meth:`.LegacyParticipant.send_files`
104 """
106 path: Path | str | None = None
107 name: str | None = None
108 stream: IO[bytes] | None = None
109 aio_stream: AsyncIterator[bytes] | None = None
110 data: bytes | None = None
111 content_type: str | None = None
112 legacy_file_id: str | None = None
113 url: str | None = None
114 caption: str | None = None
115 """
116 A caption for this specific image. For a global caption for a list of attachments,
117 use the ``body`` parameter of :meth:`.AttachmentMixin.send_files`
118 """
119 disposition: AttachmentDisposition | None = None
120 is_sticker: bool = False
121 size: int | None = None
123 def __post_init__(self) -> None:
124 if all(
125 x is None
126 for x in (self.path, self.stream, self.data, self.url, self.aio_stream)
127 ):
128 raise TypeError("There is not data in this attachment", self)
130 if isinstance(self.path, str):
131 self.path = Path(self.path)
133 if self.is_sticker:
134 if self.disposition == "attachment":
135 warnings.warn(
136 "Sticker declared as 'attachment' disposition, changing it to 'inline'"
137 )
138 self.disposition = "inline"
140 def format_for_user(self) -> str:
141 if self.name:
142 name = self.name
143 elif self.path:
144 name = self.path.name # type:ignore[union-attr]
145 elif self.url:
146 name = self.url
147 else:
148 name = ""
150 if self.caption:
151 name = f"{name}: {self.caption}" if name else self.caption
153 return name
155 def __str__(self) -> str:
156 attrs = ", ".join(
157 f"{f.name}={getattr(self, f.name)!r}"
158 for f in fields(self)
159 if getattr(self, f.name) is not None and f.name != "data"
160 )
161 if self.data is not None:
162 data_str = f"data=<{len(self.data)} bytes>"
163 to_join = (attrs, data_str) if attrs else (data_str,)
164 attrs = ", ".join(to_join)
165 return f"Attachment({attrs})"
168class MucType(IntEnum):
169 """
170 The type of group, private, public, anonymous or not.
171 """
173 GROUP = 0
174 """
175 A private group, members-only and non-anonymous, eg a family group.
176 """
177 CHANNEL = 1
178 """
179 A public group, aka an anonymous channel.
180 """
181 CHANNEL_NON_ANONYMOUS = 2
182 """
183 A public group where participants' legacy IDs are visible to everybody.
184 """
187PseudoPresenceShow = PresenceShows | Literal[""]
190MessageOrPresenceTypeVar = TypeVar("MessageOrPresenceTypeVar", bound=Message | Presence)
193class LinkPreview(NamedTuple):
194 """
195 Embedded metadata from :xep:`0511`.
197 See <https://ogp.me/>_.
198 """
200 about: str
201 """
202 URL of the link.
203 """
204 title: str | None
205 """
206 Title of the linked page.
207 """
208 description: str | None
209 """
210 A description of the page.
211 """
212 url: str | None
213 """
214 The canonical URL of the link.
215 """
216 image: str | Path | bytes | None
217 """
218 An image representing the link. If it is a string, it should represent a URL to an image.
219 """
220 type: str | None
221 """
222 Type of the link destination.
223 """
224 site_name: str | None
225 """
226 Name of the web site.
227 """
229 @property
230 def is_empty(self) -> bool:
231 return not any(x for x in self)
234class Mention(NamedTuple):
235 contact: "LegacyContact"
236 start: int
237 end: int
240class Hat(NamedTuple):
241 uri: str
242 title: str
243 hue: float | None = None
246class UserPreferences(TypedDict):
247 sync_avatar: bool
248 sync_presence: bool
251class MamMetadata(NamedTuple):
252 id: str
253 sent_on: datetime
256class HoleBound(NamedTuple):
257 id: str
258 timestamp: datetime
261class CachedPresence(NamedTuple):
262 last_seen: datetime | None = None
263 ptype: PresenceTypes | None = None
264 pstatus: str | None = None
265 pshow: PresenceShows | None = None
268class Avatar(NamedTuple):
269 path: Path | None = None
270 unique_id: str | None = None
271 url: str | None = None
272 data: bytes | None = None
275class SpaceMetadata(NamedTuple):
276 creator_legacy_id: str | None = None
277 name: str | None = None
278 description: str | None = None
279 member_count: int | None = None
280 owner_legacy_ids: Iterable[str] = []
283@dataclass
284class Reply:
285 msg_id: str
286 to: "LegacyContact | LegacyParticipant[Any] | Literal['user'] | None"
287 fallback: str | None = None
289 @cached_property
290 def fallback_no_quote_mark(self) -> str | None:
291 """
292 Return multi-line text without leading quote marks (i.e. the ">" character).
293 """
294 if not self.fallback:
295 return None
296 return re.sub(_STRIP_QUOTE_RE, "", self.fallback).strip()
299_STRIP_QUOTE_RE = re.compile(r"^>\s*", flags=re.MULTILINE)
302@dataclass
303class XMPPAttachment:
304 url: str
305 is_sticker: bool = False
306 cid: str | None = None
307 content_type: str | None = None
309 @contextlib.asynccontextmanager
310 async def get(self) -> AsyncIterator[aiohttp.ClientResponse]:
311 async with (
312 aiohttp.ClientSession() as session,
313 session.get(self.url) as response,
314 ):
315 yield response
318@dataclass
319class _AbstractXMPPMessage:
320 body: str | None = None
321 link_previews: tuple[LinkPreview, ...] = ()
322 attachments: tuple[XMPPAttachment, ...] = ()
323 mentions: tuple[Mention, ...] = ()
324 replace: str | None = None
325 reply: Reply | None = None
326 thread: str | None = None
329class XMPPAttachmentMessage(_AbstractXMPPMessage):
330 body: str | None
331 attachments: tuple[XMPPAttachment, *tuple[XMPPAttachment, ...]]
334class XMPPTextMessage(_AbstractXMPPMessage):
335 body: str
336 attachments: tuple[()]
339XMPPMessage = XMPPAttachmentMessage | XMPPTextMessage
342@dataclass
343class Sticker:
344 path: Path
345 content_type: str | None
346 hashes: dict[str, str]
347 fallback: str | None = None
348 reply: Reply | None = None
349 thread: str | None = None