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 +7 -0
- matteridge/__main__.py +9 -0
- matteridge/api.py +677 -0
- matteridge/cache.py +155 -0
- matteridge/config.py +4 -0
- matteridge/contact.py +341 -0
- matteridge/events.py +206 -0
- matteridge/gateway.py +62 -0
- matteridge/group.py +155 -0
- matteridge/session.py +437 -0
- matteridge/util.py +106 -0
- matteridge/websocket.py +156 -0
- matteridge-0.2.0.dist-info/METADATA +73 -0
- matteridge-0.2.0.dist-info/RECORD +17 -0
- matteridge-0.2.0.dist-info/WHEEL +5 -0
- matteridge-0.2.0.dist-info/entry_points.txt +2 -0
- matteridge-0.2.0.dist-info/top_level.txt +1 -0
matteridge/__init__.py
ADDED
matteridge/__main__.py
ADDED
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__)
|