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): | |
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'] | |
200 | ||
201 | def getHCard(self): | |
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 | } | |
216 | """ | |
217 | timestamp = int(time.mktime(time.gmtime())) | |
218 | request = self._connection.get('/people/{0}/hovercard.json?_={}'.format(self['guid'], timestamp)) | |
219 | if request.status_code != 200: raise errors.UserError('could not fetch hcard for user: {0}'.format(self['guid'])) | |
220 | return request.json() | |
221 | ||
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 | 230 | class Me(): |
d95ff94a C |
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') | |
f8752360 | 235 | |
d95ff94a C |
236 | def __init__(self, connection): |
237 | self._connection = connection | |
f8752360 | 238 | |
d95ff94a C |
239 | def getInfo(self): |
240 | """This function returns the current user's attributes. | |
f8752360 | 241 | |
d95ff94a C |
242 | :returns: dict |
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) | |
f8752360 MM |
250 | |
251 | ||
dd0a4d9f | 252 | class Contacts(): |
d95ff94a C |
253 | """This class represents user's list of contacts. |
254 | """ | |
255 | def __init__(self, connection, fetch=False, set=''): | |
256 | self._connection = connection | |
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)) | |
312 | ||
313 | def add(self, user_id, aspect_ids): | |
314 | """Add user to aspects of given ids. | |
315 | ||
316 | :param user_id: user id (not guid) | |
317 | :type user_id: str | |
318 | :param aspect_ids: list of aspect ids | |
319 | :type aspect_ids: list | |
320 | """ | |
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 | |
332 | ||
333 | def remove(self, user_id, aspect_ids): | |
334 | """Remove user from aspects of given ids. | |
335 | ||
336 | :param user_id: user id | |
337 | :type user_id: str | |
338 | :param aspect_ids: list of aspect ids | |
339 | :type aspect_ids: list | |
340 | """ | |
341 | for aid in aspect_ids: Aspect(self._connection, aid).removeUser(user_id) | |
342 | ||
343 | def get(self, set='', page=0): | |
344 | """Returns list of user contacts. | |
345 | Contact is a User() who is in one or more of user's | |
346 | aspects. | |
347 | ||
348 | By default, it will return list of users who are in | |
349 | user's aspects. | |
350 | ||
351 | If `set` is `all` it will also include users who only share | |
352 | with logged user and are not in his/hers aspects. | |
353 | ||
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 | ||
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 | ||
386 | :param set: if passed could be 'all' or 'only_sharing' | |
387 | :type set: str | |
388 | """ | |
389 | params = {} | |
390 | if set: | |
391 | params['set'] = set | |
392 | params['_'] = int(time.mktime(time.gmtime())) | |
393 | if page: params['page'] = page | |
394 | ||
395 | request = self._connection.get('contacts.json', params=params) | |
396 | if request.status_code != 200: | |
397 | raise Exception('status code {0}: cannot get contacts'.format(request.status_code)) | |
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 |