Commit | Line | Data |
---|---|---|
74edacee MM |
1 | #!/usr/bin/env python3 |
2 | ||
3 | import json | |
beaa09fb | 4 | import re |
03ffc9ea | 5 | import warnings |
0c28bf0b | 6 | import time |
03ffc9ea | 7 | |
beaa09fb | 8 | from diaspy.streams import Outer |
d589deff | 9 | from diaspy.models import Aspect |
65b1f099 | 10 | from diaspy import errors |
7c6fbe5b | 11 | from diaspy import search |
beaa09fb MM |
12 | |
13 | ||
6d8d47ce MM |
14 | def sephandle(handle): |
15 | """Separate Diaspora* handle into pod pod and user. | |
16 | ||
17 | :returns: two-tuple (pod, user) | |
18 | """ | |
19 | if re.match('^[a-zA-Z]+[a-zA-Z0-9_-]*@[a-z0-9.]+\.[a-z]+$', handle) is None: | |
615edb73 | 20 | raise errors.InvalidHandleError('{0}'.format(handle)) |
6d8d47ce MM |
21 | handle = handle.split('@') |
22 | pod, user = handle[1], handle[0] | |
23 | return (pod, user) | |
24 | ||
25 | ||
488d7ff6 | 26 | class User(): |
beaa09fb MM |
27 | """This class abstracts a D* user. |
28 | This object goes around the limitations of current D* API and will | |
29 | extract user data using black magic. | |
30 | However, no chickens are harmed when you use it. | |
17d8f406 | 31 | |
7b99bf75 JR |
32 | The parameter fetch should be either 'posts', 'data' or 'none'. By |
33 | default it is 'posts' which means in addition to user data, stream | |
3cf4514e MM |
34 | will be fetched. If user has not posted yet diaspy will not be able |
35 | to extract the information from his/her posts. Since there is no official | |
36 | way to do it we rely on user posts. If this will be the case user | |
7b99bf75 | 37 | will be notified with appropriate exception message. |
3cf4514e | 38 | |
7b99bf75 JR |
39 | If fetch is 'data', only user data will be fetched. If the user is |
40 | not found, no exception will be returned. | |
41 | ||
42 | When creating new User() one can pass either guid, handle and/or id as | |
43 | optional parameters. GUID takes precedence over handle when fetching | |
44 | user stream. When fetching user data, handle is required. | |
beaa09fb | 45 | """ |
d84905af MM |
46 | @classmethod |
47 | def parse(cls, connection, data): | |
48 | person = data.get('person') | |
49 | if person is None: | |
50 | raise errors.KeyMissingFromFetchedData('person', data) | |
51 | ||
52 | guid = person.get('guid') | |
53 | if guid is None: | |
54 | raise errors.KeyMissingFromFetchedData('guid', person) | |
55 | ||
56 | handle = person.get('diaspora_id') | |
57 | if handle is None: | |
58 | raise errors.KeyMissingFromFetchedData('diaspora_id', person) | |
59 | ||
60 | person_id = person.get('id') | |
61 | if person_id is None: | |
62 | raise errors.KeyMissingFromFetchedData('id', person) | |
63 | ||
0c28bf0b | 64 | return User(connection, guid, handle, id, data=data) |
d84905af | 65 | |
0c28bf0b | 66 | def __init__(self, connection, guid='', handle='', fetch='posts', id=0, data=None): |
beaa09fb | 67 | self._connection = connection |
6cd1bae0 | 68 | self.stream = [] |
b9fb4030 JR |
69 | self.data = { |
70 | 'guid': guid, | |
71 | 'handle': handle, | |
1e192d06 | 72 | 'id': id, |
b9fb4030 | 73 | } |
0c28bf0b C |
74 | self.photos = [] |
75 | if data: self.data.update( data ) | |
76 | if fetch: self._fetch(fetch) | |
beaa09fb MM |
77 | |
78 | def __getitem__(self, key): | |
79 | return self.data[key] | |
80 | ||
1e192d06 | 81 | def __str__(self): |
0f452d5d | 82 | return self.data.get('guid', '<guid missing>') |
1e192d06 MM |
83 | |
84 | def __repr__(self): | |
0f452d5d | 85 | return '{0} ({1})'.format(self.handle(), self.guid()) |
1e192d06 | 86 | |
87cf6f93 | 87 | def handle(self): |
0c28bf0b | 88 | if 'handle' in self.data: return self['handle'] |
87cf6f93 MM |
89 | return self.data.get('diaspora_id', 'Unknown handle') |
90 | ||
91 | def guid(self): | |
92 | return self.data.get('guid', '<guid missing>') | |
93 | ||
e2c368a8 MM |
94 | def id(self): |
95 | return self.data['id'] | |
96 | ||
415337b6 | 97 | def _fetchstream(self): |
e4c9633a | 98 | self.stream = Outer(self._connection, guid=self['guid']) |
415337b6 | 99 | |
9aa1c960 MM |
100 | def _fetch(self, fetch): |
101 | """Fetch user posts or data. | |
102 | """ | |
b9fb4030 | 103 | if fetch == 'posts': |
0c28bf0b | 104 | if self.handle() and not self['guid']: self.fetchhandle() |
9aa1c960 | 105 | else: self.fetchguid() |
f50cbea3 | 106 | elif fetch == 'data' and self['handle']: |
1f779f83 | 107 | self.fetchprofile() |
b9fb4030 | 108 | |
7c6fbe5b | 109 | def _finalize_data(self, data): |
6d8d47ce | 110 | """Adjustments are needed to have similar results returned |
f50cbea3 | 111 | by search feature and fetchguid()/fetchhandle(). |
6d8d47ce | 112 | """ |
d4f2d9ac | 113 | return data |
beaa09fb | 114 | |
a8fdc14a MM |
115 | def _postproc(self, request): |
116 | """Makes necessary modifications to user data and | |
117 | sets up a stream. | |
118 | ||
119 | :param request: request object | |
120 | :type request: request | |
beaa09fb | 121 | """ |
6d8d47ce | 122 | if request.status_code != 200: raise Exception('wrong error code: {0}'.format(request.status_code)) |
d4f2d9ac MM |
123 | data = request.json() |
124 | self.data = self._finalize_data(data) | |
beaa09fb | 125 | |
1f779f83 | 126 | def fetchhandle(self, protocol='https'): |
7b99bf75 | 127 | """Fetch user data and posts using Diaspora handle. |
beaa09fb | 128 | """ |
f50cbea3 | 129 | pod, user = sephandle(self['handle']) |
73a9e0d3 | 130 | request = self._connection.get('{0}://{1}/u/{2}.json'.format(protocol, pod, user), direct=True) |
a8fdc14a | 131 | self._postproc(request) |
415337b6 | 132 | self._fetchstream() |
beaa09fb | 133 | |
5ef21061 C |
134 | def fetchguid(self, fetch_stream=True): |
135 | """Fetch user data and posts (if fetch_stream is True) using guid. | |
beaa09fb | 136 | """ |
f50cbea3 MM |
137 | if self['guid']: |
138 | request = self._connection.get('people/{0}.json'.format(self['guid'])) | |
3311aa0c | 139 | self._postproc(request) |
5ef21061 | 140 | if fetch_stream: self._fetchstream() |
3311aa0c MM |
141 | else: |
142 | raise errors.UserError('GUID not set') | |
3cf4514e | 143 | |
5131bd9e MM |
144 | def fetchprofile(self): |
145 | """Fetches user data. | |
0c28bf0b C |
146 | """ |
147 | data = search.Search(self._connection).user(self.handle()) | |
03ffc9ea | 148 | if not data: |
0c28bf0b | 149 | raise errors.UserError('user with handle "{0}" has not been found on pod "{1}"'.format(self.handle(), self._connection.pod)) |
03ffc9ea | 150 | else: |
0c28bf0b | 151 | self.data.update( data[0] ) |
5131bd9e | 152 | |
564efb9e | 153 | def aspectMemberships(self): |
0c28bf0b C |
154 | if 'contact' in self.data: |
155 | return self.data.get('contact', {}).get('aspect_memberships', []) | |
156 | else: | |
157 | return self.data.get('aspect_memberships', []) | |
158 | ||
159 | def getPhotos(self): | |
160 | """ | |
161 | --> GET /people/{GUID}/photos.json HTTP/1.1 | |
162 | ||
163 | <-- HTTP/1.1 200 OK | |
164 | ||
165 | { | |
166 | "photos":[ | |
167 | { | |
168 | "id":{photo_id}, | |
169 | "guid":"{photo_guid}", | |
170 | "created_at":"2018-03-08T23:48:31.000Z", | |
171 | "author":{ | |
172 | "id":{author_id}, | |
173 | "guid":"{author_guid}", | |
174 | "name":"{author_name}", | |
175 | "diaspora_id":"{diaspora_id}", | |
176 | "avatar":{"small":"{avatar_url_small}","medium":"{avatar_url_medium}","large":"{avatar_url_large}"} | |
177 | }, | |
178 | "sizes":{ | |
179 | "small":"{photo_url}", | |
180 | "medium":"{photo_url}", | |
181 | "large":"{photo_url}" | |
182 | }, | |
183 | "dimensions":{"height":847,"width":998}, | |
184 | "status_message":{ | |
185 | "id":{post_id} | |
186 | } | |
187 | },{ .. | |
188 | } | |
189 | ||
190 | if there are no photo's it returns: | |
191 | {"photos":[]} | |
192 | """ | |
193 | ||
194 | request = self._connection.get('/people/{0}/photos.json'.format(self['guid'])) | |
195 | if request.status_code != 200: raise errors.UserError('could not fetch photos for user: {0}'.format(self['guid'])) | |
196 | ||
197 | json = request.json() | |
198 | if json: self.photos = json['photos'] | |
199 | return json['photos'] | |
564efb9e | 200 | |
39af9756 | 201 | def getHCard(self): |
0c28bf0b C |
202 | """Returns json containing user HCard. |
203 | --> /people/{guid}/hovercard.json?_={timestamp} | |
204 | ||
205 | <-- HTTP/2.0 200 OK | |
206 | { | |
207 | "id":123, | |
208 | "guid":"1234567890abcdef", | |
209 | "name":"test", | |
210 | "diaspora_id":"batman@test.test", | |
211 | "contact":false, | |
212 | "profile":{ | |
213 | "avatar":"https://nicetesturl.url/image.jpg", | |
214 | "tags":["tag1", "tag2", "tag3", "tag4", "tag5"]} | |
215 | } | |
39af9756 | 216 | """ |
0c28bf0b C |
217 | timestamp = int(time.mktime(time.gmtime())) |
218 | request = self._connection.get('/people/{0}/hovercard.json?_={}'.format(self['guid'], timestamp)) | |
39af9756 | 219 | if request.status_code != 200: raise errors.UserError('could not fetch hcard for user: {0}'.format(self['guid'])) |
0c28bf0b | 220 | return request.json() |
39af9756 | 221 | |
0c28bf0b C |
222 | def deletePhoto(self, photo_id): |
223 | """ | |
224 | --> DELETE /photos/{PHOTO_ID} HTTP/1.1 | |
225 | <-- HTTP/1.1 204 No Content | |
226 | """ | |
227 | request = self._connection.delete('/photos/{0}'.format(photo_id)) | |
228 | if request.status_code != 204: raise errors.UserError('could not delete photo_id: {0}'.format(photo_id)) | |
dd0a4d9f | 229 | |
f8752360 MM |
230 | class Me(): |
231 | """Object represetnting current user. | |
232 | """ | |
233 | _userinfo_regex = re.compile(r'window.current_user_attributes = ({.*})') | |
234 | _userinfo_regex_2 = re.compile(r'gon.user=({.*});gon.preloads') | |
235 | ||
236 | def __init__(self, connection): | |
237 | self._connection = connection | |
238 | ||
39af9756 | 239 | def getInfo(self): |
f8752360 MM |
240 | """This function returns the current user's attributes. |
241 | ||
1e578db9 | 242 | :returns: dict |
f8752360 MM |
243 | """ |
244 | request = self._connection.get('bookmarklet') | |
245 | userdata = self._userinfo_regex.search(request.text) | |
246 | if userdata is None: userdata = self._userinfo_regex_2.search(request.text) | |
247 | if userdata is None: raise errors.DiaspyError('cannot find user data') | |
248 | userdata = userdata.group(1) | |
249 | return json.loads(userdata) | |
250 | ||
251 | ||
dd0a4d9f MM |
252 | class Contacts(): |
253 | """This class represents user's list of contacts. | |
254 | """ | |
0c28bf0b | 255 | def __init__(self, connection, fetch=False, set=''): |
dd0a4d9f | 256 | self._connection = connection |
0c28bf0b C |
257 | self.contacts = None |
258 | if fetch: self.contacts = self.get(set) | |
259 | ||
260 | def __getitem__(self, index): | |
261 | return self.contacts[index] | |
262 | ||
263 | def addAspect(self, name, visible=False): | |
264 | """ | |
265 | --> POST /aspects HTTP/1.1 | |
266 | --> {"person_id":null,"name":"test","contacts_visible":false} | |
267 | ||
268 | <-- HTTP/1.1 200 OK | |
269 | ||
270 | Add new aspect. | |
271 | ||
272 | TODO: status_code's | |
273 | ||
274 | :param name: aspect name to add | |
275 | :type name: str | |
276 | :param visible: sets if contacts in aspect are visible for each and other | |
277 | :type visible: bool | |
278 | :returns: JSON from request | |
279 | """ | |
280 | data = { | |
281 | 'person_id': None, | |
282 | 'name': name, | |
283 | 'contacts_visible': visible | |
284 | } | |
285 | headers={'content-type': 'application/json', | |
286 | 'accept': 'application/json' } | |
287 | request = self._connection.tokenFrom('contacts').post('aspects', headers=headers, data=json.dumps(data)) | |
288 | ||
289 | if request.status_code == 400: | |
290 | raise errors.AspectError('duplicate record, aspect alreadt exists: {0}'.format(request.status_code)) | |
291 | elif request.status_code != 200: | |
292 | raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) | |
293 | ||
294 | new_aspect = request.json() | |
295 | self._connection.userdata()['aspects'].append( new_aspect ) | |
296 | ||
297 | return new_aspect | |
298 | ||
299 | def deleteAspect(self, aspect_id): | |
300 | """ | |
301 | --> POST /aspects/{ASPECT_ID} HTTP/1.1 | |
302 | _method=delete&authenticity_token={TOKEN} | |
303 | Content-Type: application/x-www-form-urlencoded | |
304 | ||
305 | <-- HTTP/1.1 302 Found | |
306 | Content-Type: text/html; charset=utf-8 | |
307 | """ | |
308 | request = self._connection.tokenFrom('contacts').delete('aspects/{}'.format( aspect_id )) | |
309 | ||
310 | if request.status_code != 200: # since we don't post but delete | |
311 | raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) | |
dd0a4d9f | 312 | |
d589deff MM |
313 | def add(self, user_id, aspect_ids): |
314 | """Add user to aspects of given ids. | |
dd0a4d9f | 315 | |
0c28bf0b | 316 | :param user_id: user id (not guid) |
d589deff MM |
317 | :type user_id: str |
318 | :param aspect_ids: list of aspect ids | |
319 | :type aspect_ids: list | |
dd0a4d9f | 320 | """ |
0c28bf0b C |
321 | # TODO update self.contacts |
322 | # Returns {"aspect_id":123,"person_id":123} | |
323 | for aid in aspect_ids: | |
324 | new_aspect_membership = Aspect(self._connection, aid).addUser(user_id) | |
325 | ||
326 | # user. | |
327 | if new_aspect_membership: | |
328 | for user in self.contacts: | |
329 | if int(user.data['person_id']) == int(user_id): | |
330 | user.data['aspect_memberships'].append( new_aspect_membership ) | |
331 | return new_aspect_membership | |
27f09973 | 332 | |
d589deff MM |
333 | def remove(self, user_id, aspect_ids): |
334 | """Remove user from aspects of given ids. | |
27f09973 | 335 | |
0c28bf0b | 336 | :param user_id: user id |
d589deff MM |
337 | :type user_id: str |
338 | :param aspect_ids: list of aspect ids | |
339 | :type aspect_ids: list | |
27f09973 | 340 | """ |
7a818fdb | 341 | for aid in aspect_ids: Aspect(self._connection, aid).removeUser(user_id) |
27f09973 | 342 | |
0c28bf0b | 343 | def get(self, set='', page=0): |
27f09973 | 344 | """Returns list of user contacts. |
d589deff MM |
345 | Contact is a User() who is in one or more of user's |
346 | aspects. | |
347 | ||
7a818fdb MM |
348 | By default, it will return list of users who are in |
349 | user's aspects. | |
350 | ||
d589deff MM |
351 | If `set` is `all` it will also include users who only share |
352 | with logged user and are not in his/hers aspects. | |
7a818fdb | 353 | |
d589deff MM |
354 | If `set` is `only_sharing` it will return users who are only |
355 | sharing with logged user and ARE NOT in his/hers aspects. | |
356 | ||
0c28bf0b C |
357 | # On "All contacts" button diaspora |
358 | on the time of testing this I had 20 contacts and 10 that | |
359 | where only sharing with me. So 30 in total. | |
360 | ||
361 | --> GET /contacts?set=all HTTP/1.1 | |
362 | <-- HTTP/1.1 200 OK | |
363 | returned 25 contacts (5 only sharing with me) | |
364 | ||
365 | --> GET /contacts.json?page=1&set=all&_=1524410225376 HTTP/1.1 | |
366 | <-- HTTP/1.1 200 OK | |
367 | returned the same list as before. | |
368 | ||
369 | --> GET /contacts.json?page=2&set=all&_=1524410225377 HTTP/1.1 | |
370 | <-- HTTP/1.1 200 OK | |
371 | returned the other 5 that where only sharing with me. | |
372 | ||
373 | --> GET /contacts.json?page=3&set=all&_=1524410225378 HTTP/1.1 | |
374 | <-- HTTP/1.1 200 OK | |
375 | returned empty list. | |
376 | ||
377 | It appears that /contacts?set=all returns a maximum of 25 | |
378 | contacts. | |
379 | ||
380 | So if /contacts?set=all returns 25 contacts then request next | |
381 | page until page returns a list with less then 25. I don't see a | |
382 | reason why we should request page=1 'cause the previous request | |
383 | will be the same. So begin with page=2 if /contacts?set=all | |
384 | returns 25. | |
385 | ||
d589deff MM |
386 | :param set: if passed could be 'all' or 'only_sharing' |
387 | :type set: str | |
27f09973 | 388 | """ |
d589deff | 389 | params = {} |
0c28bf0b C |
390 | if set: |
391 | params['set'] = set | |
392 | params['_'] = int(time.mktime(time.gmtime())) | |
393 | if page: params['page'] = page | |
d589deff MM |
394 | |
395 | request = self._connection.get('contacts.json', params=params) | |
27f09973 MM |
396 | if request.status_code != 200: |
397 | raise Exception('status code {0}: cannot get contacts'.format(request.status_code)) | |
0c28bf0b C |
398 | |
399 | json = request.json() | |
400 | users = [User.parse(self._connection, each) for each in json] | |
401 | if len(json) == 25: | |
402 | if not page: page = 1 | |
403 | users += self.get(set=set, page=page+1) | |
404 | return users |