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