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