matteridge 0.2.0__py3-none-any.whl

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.
matteridge/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from slidge.util.util import get_version # noqa: F401
2
+
3
+ from . import config, contact, gateway, group, session
4
+
5
+ __all__ = ("config", "contact", "gateway", "group", "session")
6
+
7
+ __version__ = "v0.2.0"
matteridge/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from slidge import entrypoint
2
+
3
+
4
+ def main() -> None:
5
+ entrypoint("matteridge")
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
matteridge/api.py ADDED
@@ -0,0 +1,677 @@
1
+ import asyncio
2
+ import functools
3
+ import io
4
+ import json
5
+ import logging
6
+ from time import time
7
+ from typing import (
8
+ AsyncIterator,
9
+ Awaitable,
10
+ Callable,
11
+ Optional,
12
+ ParamSpec,
13
+ TypeVar,
14
+ Union,
15
+ )
16
+
17
+ import aiohttp
18
+ import emoji
19
+ from async_lru import alru_cache
20
+ from httpx import AsyncClient
21
+ from httpx import Response as HTTPResponse
22
+ from httpx import codes as http_codes
23
+ from mattermost_api_reference_client.api.channels import (
24
+ create_direct_channel,
25
+ get_channel,
26
+ get_channel_members,
27
+ get_channel_stats,
28
+ get_channels_for_team_for_user,
29
+ get_channels_for_user,
30
+ view_channel,
31
+ )
32
+ from mattermost_api_reference_client.api.files import get_file, upload_file
33
+ from mattermost_api_reference_client.api.posts import (
34
+ create_post,
35
+ delete_post,
36
+ get_posts_for_channel,
37
+ update_post,
38
+ )
39
+ from mattermost_api_reference_client.api.reactions import (
40
+ delete_reaction,
41
+ get_reactions,
42
+ save_reaction,
43
+ )
44
+ from mattermost_api_reference_client.api.status import (
45
+ get_user_status,
46
+ get_users_statuses_by_ids,
47
+ unset_user_custom_status,
48
+ update_user_custom_status,
49
+ update_user_status,
50
+ )
51
+ from mattermost_api_reference_client.api.teams import (
52
+ get_team,
53
+ get_team_by_name,
54
+ get_team_icon,
55
+ get_teams_for_user,
56
+ )
57
+ from mattermost_api_reference_client.api.users import (
58
+ get_profile_image,
59
+ get_user,
60
+ get_user_by_username,
61
+ get_users_by_ids,
62
+ login,
63
+ )
64
+ from mattermost_api_reference_client.client import AuthenticatedClient, Client
65
+ from mattermost_api_reference_client.models import (
66
+ Channel,
67
+ LoginJsonBody,
68
+ Post,
69
+ Reaction,
70
+ StatusOK,
71
+ UpdateUserCustomStatusJsonBody,
72
+ UpdateUserStatusJsonBody,
73
+ User,
74
+ ViewChannelJsonBody,
75
+ )
76
+ from mattermost_api_reference_client.models.create_post_json_body import (
77
+ CreatePostJsonBody,
78
+ )
79
+ from mattermost_api_reference_client.models.update_post_json_body import (
80
+ UpdatePostJsonBody,
81
+ )
82
+ from mattermost_api_reference_client.models.upload_file_multipart_data import (
83
+ UploadFileMultipartData,
84
+ )
85
+ from mattermost_api_reference_client.types import UNSET, File, Response, Unset
86
+ from slixmpp.exceptions import XMPPError
87
+ from slixmpp.types import ErrorConditions
88
+
89
+ from .cache import Cache
90
+ from .events import StatusType
91
+ from .util import demojize, emojize_single
92
+
93
+
94
+ class MattermostException(XMPPError):
95
+ ERROR_TYPES: dict[int, ErrorConditions] = {
96
+ http_codes.BAD_REQUEST: "bad-request",
97
+ http_codes.UNAUTHORIZED: "not-authorized",
98
+ http_codes.FORBIDDEN: "forbidden",
99
+ http_codes.NOT_FOUND: "item-not-found",
100
+ http_codes.SERVICE_UNAVAILABLE: "service-unavailable",
101
+ }
102
+
103
+ def __init__(self, resp: Response):
104
+ content_str = resp.content.decode()
105
+ try:
106
+ content_dict = json.loads(content_str)
107
+ except json.JSONDecodeError:
108
+ text = content_str
109
+ self.mm_error_id = None
110
+ else:
111
+ text = content_dict.get("message")
112
+ self.mm_error_id = content_dict.get("id")
113
+ super().__init__(
114
+ self.ERROR_TYPES.get(resp.status_code, "internal-server-error"), text
115
+ )
116
+ self.is_expired_session = (
117
+ self.mm_error_id == "api.context.session_expired.app_error"
118
+ )
119
+
120
+
121
+ class RetryHTTPClient(AsyncClient):
122
+ DEFAULT_RETRY = 5.0
123
+
124
+ def __init__(self, *a, **k):
125
+ super().__init__(*a, **k)
126
+ self._max_request_per_second = 10
127
+ self._last_request = 0
128
+ self._remaining_requests = 10
129
+
130
+ def _update_counters(self, resp: HTTPResponse):
131
+ limit = resp.headers.get("X-Ratelimit-Limit")
132
+ if limit:
133
+ self._max_request_per_second = int(limit)
134
+ remaining = resp.headers.get("X-RateLimit-Remaining")
135
+ if remaining:
136
+ self._remaining_requests = int(remaining)
137
+
138
+ async def request(self, *a, **k) -> HTTPResponse: # type:ignore
139
+ while True:
140
+ if self._remaining_requests < 2:
141
+ await asyncio.sleep(1 / self._max_request_per_second)
142
+ resp = await super().request(*a, **k)
143
+ self._update_counters(resp)
144
+ if resp.status_code == http_codes.TOO_MANY_REQUESTS:
145
+ if "X-Ratelimit-Reset" in resp.headers:
146
+ # MM's custom rate limit header
147
+ sleep = time() - int(resp.headers["X-Ratelimit-Reset"])
148
+ elif "Retry-After" in resp.headers:
149
+ sleep = int(resp.headers["Retry-After"])
150
+ else:
151
+ sleep = self.DEFAULT_RETRY
152
+ await asyncio.sleep(sleep)
153
+ else:
154
+ return resp
155
+
156
+
157
+ class ReplyAPIClient(AuthenticatedClient):
158
+ def __init__(self, *a, **k):
159
+ super().__init__(*a, **k)
160
+ self._async_client = RetryHTTPClient(
161
+ base_url=self._base_url,
162
+ cookies=self._cookies,
163
+ headers=self._headers,
164
+ timeout=self._timeout,
165
+ verify=self._verify_ssl,
166
+ follow_redirects=self._follow_redirects,
167
+ **self._httpx_args,
168
+ )
169
+
170
+
171
+ class MattermostClient:
172
+ # TODO: this should be autogenerated using a template in mattermost_api_reference_client
173
+
174
+ def __init__(self, base_url: str, cache: Cache, *args, **kwargs):
175
+ self.http = client = AuthenticatedClient(base_url, *args, **kwargs)
176
+ self.base_url = base_url
177
+ self._cache = cache
178
+ cache.add_server(base_url)
179
+ self.mm_id: asyncio.Future[str] = asyncio.get_running_loop().create_future()
180
+ self.me: asyncio.Future[User] = asyncio.get_running_loop().create_future()
181
+
182
+ # https://discuss.python.org/t/using-concatenate-and-paramspec-with-a-keyword-argument
183
+ # A partial would be more elegant, but we lose type-checking of the
184
+ # return type (type checking of the params just does not work at all)
185
+ # mypy doesn't even properly type check things here, but pycharm seems
186
+ # to manage to understand the type hints, some of them at least
187
+ # auth = functools.partial(call_authenticated, client=client)
188
+ def auth(
189
+ func: Callable[..., Awaitable[Response[T]]],
190
+ force_json_decode=False,
191
+ use_json_body=False,
192
+ ) -> Callable[..., Awaitable[T]]:
193
+ return authenticated_call(
194
+ func,
195
+ client,
196
+ force_json_decode=force_json_decode,
197
+ use_json_body=use_json_body,
198
+ )
199
+
200
+ def auth_bytes(
201
+ func: Callable[..., Awaitable[Response[T]]],
202
+ use_json_body=False,
203
+ return_none_when_not_found=False,
204
+ ) -> Callable[..., Awaitable[bytes]]:
205
+ return authenticated_call_return_content(
206
+ func,
207
+ client,
208
+ use_json_body=use_json_body,
209
+ return_none_when_not_found=return_none_when_not_found,
210
+ )
211
+
212
+ self._get_user = auth(get_user.asyncio_detailed)
213
+ self.get_user_status = auth(get_user_status.asyncio_detailed)
214
+ self._get_users_by_ids = auth(
215
+ get_users_by_ids.asyncio_detailed, use_json_body=True
216
+ )
217
+ self.get_users_statuses_by_ids = auth(
218
+ get_users_statuses_by_ids.asyncio_detailed, use_json_body=True
219
+ )
220
+ self._get_user_by_username = auth(
221
+ get_user_by_username.asyncio_detailed,
222
+ )
223
+ self._update_user_custom_status = auth(
224
+ update_user_custom_status.asyncio_detailed, use_json_body=True
225
+ )
226
+ self._update_user_status = auth(
227
+ update_user_status.asyncio_detailed, use_json_body=True
228
+ )
229
+
230
+ self.get_team = auth(get_team.asyncio_detailed)
231
+ self.get_teams_for_user = auth(get_teams_for_user.asyncio_detailed)
232
+ self.get_team_by_name = auth(get_team_by_name.asyncio_detailed)
233
+
234
+ self.create_direct_channel = auth(
235
+ create_direct_channel.asyncio_detailed, use_json_body=True
236
+ )
237
+ self.get_channel = auth(get_channel.asyncio_detailed)
238
+ self.get_channel_members = paginated(auth(get_channel_members.asyncio_detailed))
239
+ self.get_channels_for_user = auth(get_channels_for_user.asyncio_detailed)
240
+ self.get_channels_for_team_for_user = auth(
241
+ get_channels_for_team_for_user.asyncio_detailed
242
+ )
243
+ self.get_channel_stats = auth(get_channel_stats.asyncio_detailed)
244
+ self._view_channel = auth(view_channel.asyncio_detailed, use_json_body=True)
245
+
246
+ self.create_post = auth(create_post.asyncio_detailed, use_json_body=True)
247
+ self.delete_post = auth(delete_post.asyncio_detailed)
248
+ self._get_posts_for_channel = auth(get_posts_for_channel.asyncio_detailed)
249
+ self._update_post = auth(update_post.asyncio_detailed, use_json_body=True)
250
+
251
+ self.get_profile_image = auth_bytes(get_profile_image.asyncio_detailed)
252
+ self.get_file = auth_bytes(get_file.asyncio_detailed)
253
+ # since we are going to fetch the team icon for each MUC (=channel),
254
+ # let's cache it. and since it's images, let's not cache it forever
255
+ self.get_team_icon: Callable[[str], Awaitable[Optional[bytes]]] = alru_cache(
256
+ maxsize=100, ttl=600
257
+ )(
258
+ auth_bytes( # type:ignore
259
+ get_team_icon.asyncio_detailed,
260
+ return_none_when_not_found=True,
261
+ )
262
+ )
263
+
264
+ self._save_reaction = auth(save_reaction.asyncio_detailed, use_json_body=True)
265
+ self._get_reactions = auth(get_reactions.asyncio_detailed)
266
+ self._delete_reaction = auth(delete_reaction.asyncio_detailed)
267
+
268
+ @staticmethod
269
+ async def get_token(base_url: str, login_id: str, password: str):
270
+ client = Client(base_url)
271
+ resp = await login.asyncio_detailed(
272
+ json_body=LoginJsonBody(login_id=login_id, password=password), client=client
273
+ )
274
+ raise_maybe(resp)
275
+ return resp.headers["Token"]
276
+
277
+ async def login(self):
278
+ log.debug("Login")
279
+ me = await self.get_user("me")
280
+ my_id = me.id
281
+ if not my_id:
282
+ raise RuntimeError("Could not login")
283
+ try:
284
+ self.me.set_result(me)
285
+ self.mm_id.set_result(my_id)
286
+ except asyncio.InvalidStateError:
287
+ # if re-login is called
288
+ pass
289
+ log.debug("Me: %s", me)
290
+
291
+ async def get_user(self, user_id: str) -> User:
292
+ user = await self._get_user(user_id)
293
+ assert user.id
294
+ assert user.username
295
+ self._cache.add_user(self.base_url, user.id, user.username)
296
+ return user
297
+
298
+ async def get_user_by_username(self, username: str) -> User:
299
+ user = await self._get_user_by_username(username)
300
+ assert user.id
301
+ assert user.username
302
+ self._cache.add_user(self.base_url, user.id, user.username)
303
+ return user
304
+
305
+ async def get_users_by_ids(self, user_ids: list[str]) -> list[User]:
306
+ users = await self._get_users_by_ids(user_ids)
307
+ for u in users:
308
+ assert u.id
309
+ assert u.username
310
+ self._cache.add_user(self.base_url, u.id, u.username)
311
+ return users
312
+
313
+ async def get_username_by_user_id(self, user_id: str) -> str:
314
+ cached = self._cache.get_by_user_id(self.base_url, user_id)
315
+ if cached and cached.username:
316
+ return cached.username
317
+ user = await self.get_user(user_id)
318
+ return user.username # type:ignore
319
+
320
+ async def get_user_id_by_username(self, username: str) -> str:
321
+ cached = self._cache.get_by_username(self.base_url, username)
322
+ if cached and cached.user_id:
323
+ return cached.user_id
324
+ user = await self.get_user_by_username(username)
325
+ return user.id # type:ignore
326
+
327
+ async def get_other_username_from_direct_channel_id(
328
+ self, channel_id: str
329
+ ) -> Optional[str]:
330
+ cached = self._cache.get_user_by_direct_channel_id(
331
+ self.base_url, await self.mm_id, channel_id
332
+ )
333
+ if not cached:
334
+ return None
335
+ if not cached.username:
336
+ return await self.get_username_by_user_id(cached.user_id)
337
+ return cached.username
338
+
339
+ async def __get_other_user_id_from_direct_channel_name(self, channel: Channel):
340
+ assert channel.name
341
+ for user_id in channel.name.split("__"):
342
+ if user_id != await self.mm_id:
343
+ cached_user = self._cache.get_by_user_id(self.base_url, user_id)
344
+ if cached_user is None:
345
+ username = await self.get_username_by_user_id(user_id)
346
+ self._cache.add_user(self.base_url, user_id, username)
347
+ assert channel.id
348
+ self._cache.add_direct_channel(
349
+ self.base_url, await self.mm_id, user_id, channel.id
350
+ )
351
+ return user_id
352
+ raise ValueError("This is not a direct channel", channel)
353
+
354
+ async def get_channels(self) -> list[Channel]:
355
+ channels = await self.get_channels_for_user("me")
356
+ log.debug("Channels: %s", channels)
357
+
358
+ if not channels:
359
+ # happens on INRIA's matternost, maybe disabled by admin instance?
360
+ channels = []
361
+ for team in await self.get_teams_for_user("me"):
362
+ if not team.id:
363
+ log.warning("Team without ID")
364
+ continue
365
+
366
+ team_channels = await self.get_channels_for_team_for_user("me", team.id)
367
+
368
+ if not team_channels:
369
+ log.warning("Team without channels")
370
+ continue
371
+
372
+ for channel in team_channels:
373
+ channels.append(channel)
374
+ return channels
375
+
376
+ async def get_contacts(self):
377
+ user_ids = []
378
+ for c in await self.get_channels():
379
+ if c.type != "D":
380
+ continue
381
+ if not c.last_post_at:
382
+ # there is no real notion of "contact" on mattermost,
383
+ # but because we regularly poll contact's statuses, we consider
384
+ # contacts people we already exchanged messages with
385
+ log.debug("Ignoring empty direct channel: %s", c)
386
+ continue
387
+ assert isinstance(c.name, str)
388
+ try:
389
+ user_ids.append(
390
+ await self.__get_other_user_id_from_direct_channel_name(c)
391
+ )
392
+ except ValueError:
393
+ # note to self
394
+ pass
395
+ return user_ids
396
+
397
+ async def send_message_to_user(
398
+ self, username: str, text: str, thread: Optional[str] = None
399
+ ) -> str:
400
+ await self.mm_id
401
+
402
+ other = await self.get_user_by_username(username)
403
+ if not other.id:
404
+ raise XMPPError("internal-server-error")
405
+ return await self.send_message_to_user_id(other.id, text, thread)
406
+
407
+ async def send_message_to_user_id(
408
+ self, user_id: str, text: str, thread: Optional[str] = None
409
+ ) -> str:
410
+ direct_channel_id = await self.get_direct_channel_id(user_id)
411
+ return await self.send_message_to_channel(direct_channel_id, text, thread)
412
+
413
+ async def __send_or_create_thread(
414
+ self, post: CreatePostJsonBody, thread: Optional[str] = None
415
+ ) -> Post:
416
+ post.root_id = thread or UNSET
417
+ try:
418
+ msg = await self.create_post(post)
419
+ except XMPPError as e:
420
+ if e.condition != "bad-request":
421
+ raise
422
+ log.debug("Looks like it's a new thread")
423
+ post.root_id = UNSET
424
+ msg = await self.create_post(post)
425
+ return msg
426
+
427
+ async def send_message_to_channel(
428
+ self, channel_id: str, text: str, thread: Optional[str] = None
429
+ ):
430
+ msg = await self.__send_or_create_thread(
431
+ CreatePostJsonBody(channel_id=channel_id, message=text), thread
432
+ )
433
+
434
+ if not msg.id:
435
+ # This "never" happens, it's probably just a bad open api schema or
436
+ # the api client generator that mistypes it as possibly unset.
437
+ raise XMPPError("internal-server-error", "The message has no message ID")
438
+
439
+ return msg.id
440
+
441
+ async def send_message_with_file(self, channel_id: str, file_id: str, thread=None):
442
+ r = await self.__send_or_create_thread(
443
+ CreatePostJsonBody(channel_id=channel_id, file_ids=[file_id], message=""),
444
+ thread,
445
+ )
446
+
447
+ return r.id
448
+
449
+ async def get_direct_channel_id(self, user_id: str) -> str:
450
+ cached = self._cache.get_direct_channel_id(
451
+ self.base_url, await self.mm_id, user_id
452
+ )
453
+ if cached:
454
+ return cached
455
+ direct_channel = await self.create_direct_channel([await self.mm_id, user_id])
456
+ if not direct_channel or not direct_channel.id:
457
+ raise RuntimeError("Could not create direct channel")
458
+ username = await self.get_username_by_user_id(user_id)
459
+ self._cache.add_user(self.base_url, user_id, username)
460
+ self._cache.add_direct_channel(
461
+ self.base_url, await self.mm_id, user_id, direct_channel.id
462
+ )
463
+ return direct_channel.id
464
+
465
+ async def update_post(self, post_id: str, body: str):
466
+ await self._update_post(
467
+ post_id,
468
+ json_body=UpdatePostJsonBody(id=post_id, message=body),
469
+ )
470
+
471
+ async def get_latest_post_id_for_channel(
472
+ self, channel_id: str
473
+ ) -> Optional[Union[str, Unset]]:
474
+ cache = self._cache.msg_id_get(await self.mm_id, channel_id)
475
+ if cache is not None:
476
+ return cache
477
+
478
+ async for post in self.get_posts_for_channel(channel_id, per_page=1):
479
+ last = post
480
+ break
481
+ else:
482
+ return None
483
+ if post.id:
484
+ self._cache.msg_id_store(await self.mm_id, channel_id, post.id)
485
+ return last.id
486
+
487
+ async def get_posts_for_channel(
488
+ self,
489
+ channel_id: str,
490
+ per_page: Optional[int] = 60,
491
+ before: Optional[Union[str, Unset]] = None,
492
+ ) -> AsyncIterator[Post]:
493
+ """
494
+ Returns posts from the most recent to the oldest one
495
+
496
+ :param channel_id:
497
+ :param per_page:
498
+ :param before: a msg id, return messages before this one
499
+ :return : posts with decreasing created_at timestamp
500
+ """
501
+ while True:
502
+ post_list = await self._get_posts_for_channel(
503
+ channel_id,
504
+ per_page=per_page,
505
+ before=before, # , page=page
506
+ )
507
+ posts = post_list.posts
508
+ if not posts:
509
+ break
510
+ if not post_list.order:
511
+ break
512
+ if not posts.additional_properties:
513
+ break
514
+ posts_dict = posts.additional_properties
515
+ for post_id in post_list.order:
516
+ yield posts_dict[post_id]
517
+ before = post_list.prev_post_id
518
+ if not before:
519
+ break
520
+
521
+ async def upload_file(
522
+ self, channel_id: str, url: str, http_response: aiohttp.ClientResponse
523
+ ):
524
+ data = await http_response.read()
525
+ req = UploadFileMultipartData(
526
+ files=File(file_name=url.split("/")[-1], payload=io.BytesIO(data)),
527
+ channel_id=channel_id,
528
+ )
529
+ r = await upload_file.asyncio(client=self.http, multipart_data=req)
530
+ if not r or r.file_infos is None or not r.file_infos or len(r.file_infos) != 1:
531
+ raise RuntimeError(r)
532
+ return r.file_infos[0].id
533
+
534
+ async def react(self, post_id: str, emoji: str):
535
+ return await self._save_reaction(
536
+ Reaction(
537
+ user_id=await self.mm_id,
538
+ post_id=post_id,
539
+ emoji_name=demojize(emoji),
540
+ )
541
+ )
542
+
543
+ async def get_reactions(self, post_id: str) -> set[tuple[str, str]]:
544
+ try:
545
+ r = await self._get_reactions(post_id)
546
+ except TypeError:
547
+ # posts with no reaction trigger
548
+ # File "/mattermost_api_reference_client/api/reactions/get_reactions.py", line 31, in _parse_response
549
+ # for response_200_item_data in _response_200:
550
+ # TypeError: 'NoneType' object is not iterable
551
+ # either mattermost-api-client bug or bad openapi schema
552
+ return set()
553
+ return {(x.user_id, emojize_single(x.emoji_name)) for x in r} # type:ignore
554
+
555
+ async def delete_reaction(self, post_id: str, emoji: str):
556
+ emoji_name = demojize(emoji)
557
+ await self._delete_reaction(await self.mm_id, post_id, emoji_name=emoji_name)
558
+
559
+ async def view_channel(self, channel_id: str):
560
+ await self._view_channel(
561
+ await self.mm_id, json_body=ViewChannelJsonBody(channel_id=channel_id)
562
+ )
563
+
564
+ async def set_user_status(self, status: StatusType, text: Optional[str] = None):
565
+ my_id = await self.mm_id
566
+ await self._update_user_status(
567
+ my_id,
568
+ json_body=UpdateUserStatusJsonBody(user_id=my_id, status=status),
569
+ )
570
+ if text:
571
+ try:
572
+ emo_str = next(emoji.analyze(text, False, True))
573
+ except StopIteration:
574
+ emo_alias = "speech_balloon"
575
+ else:
576
+ emo_alias = demojize(emo_str.chars)
577
+ await self._update_user_custom_status(
578
+ user_id=my_id,
579
+ json_body=UpdateUserCustomStatusJsonBody(emoji=emo_alias, text=text),
580
+ )
581
+ else:
582
+ await unset_user_custom_status.asyncio_detailed(
583
+ user_id=my_id, client=self.http
584
+ )
585
+
586
+
587
+ P = ParamSpec("P")
588
+ T = TypeVar("T")
589
+
590
+
591
+ async def call_with_args_or_json_body(
592
+ func: Callable[..., Awaitable[Response[T]]], client, use_json_body: bool, *a, **k
593
+ ) -> Response[T]:
594
+ if use_json_body:
595
+ json_body = k.pop("json_body", None)
596
+ if not json_body:
597
+ json_body = a[0]
598
+ a = a[1:]
599
+ resp = await func(*a, **k, json_body=json_body, client=client)
600
+ else:
601
+ resp = await func(*a, **k, client=client)
602
+ raise_maybe(resp)
603
+ return resp
604
+
605
+
606
+ def authenticated_call(
607
+ func: Callable[..., Awaitable[Response[T]]],
608
+ client: AuthenticatedClient,
609
+ force_json_decode=False,
610
+ use_json_body=False,
611
+ ) -> Callable[..., Awaitable[T]]:
612
+ async def wrapped(*a: P.args, **k: P.kwargs): # type:ignore
613
+ resp = await call_with_args_or_json_body(func, client, use_json_body, *a, **k)
614
+ if force_json_decode:
615
+ return json.loads(resp.content)
616
+ return resp.parsed
617
+
618
+ return wrapped
619
+
620
+
621
+ def authenticated_call_return_content(
622
+ func: Callable[..., Awaitable[Response]],
623
+ client: AuthenticatedClient,
624
+ use_json_body=False,
625
+ return_none_when_not_found=False,
626
+ ) -> Callable[..., Awaitable[bytes]]:
627
+ async def wrapped(*a: P.args, **k: P.kwargs): # type:ignore
628
+ try:
629
+ resp = await call_with_args_or_json_body(
630
+ func, client, use_json_body, *a, **k
631
+ )
632
+ except XMPPError as e:
633
+ if e.condition == "item-not-found" and return_none_when_not_found:
634
+ return None
635
+ raise
636
+ return resp.content
637
+
638
+ return wrapped
639
+
640
+
641
+ def paginated(
642
+ func: Callable[..., Awaitable[list[T]]],
643
+ ) -> Callable[..., AsyncIterator[T]]:
644
+ @functools.wraps(func)
645
+ async def wrapped(*a, **k):
646
+ page = 0
647
+ while True:
648
+ result = await func(*a, **k, page=page)
649
+ if not result:
650
+ break
651
+ for r in result:
652
+ yield r
653
+ page += 1
654
+
655
+ return wrapped
656
+
657
+
658
+ def raise_maybe(resp: Response):
659
+ status = resp.status_code
660
+ if status < 200 or status >= 300:
661
+ raise MattermostException(resp)
662
+ if isinstance(resp.parsed, StatusOK) and (resp.parsed.status or "").lower() != "ok":
663
+ raise XMPPError("internal-server-error", str(resp.parsed.status))
664
+
665
+
666
+ def get_client_from_registration_form(f: dict[str, Optional[str]], cache: Cache):
667
+ url = f["url"].rstrip("/") or "" # type:ignore
668
+ return MattermostClient(
669
+ url,
670
+ cache,
671
+ verify_ssl=f["strict_ssl"],
672
+ timeout=5,
673
+ token=f["token"],
674
+ )
675
+
676
+
677
+ log = logging.getLogger(__name__)