Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

facebook reactions #88

Merged
merged 6 commits into from Apr 28, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
111 changes: 75 additions & 36 deletions granary/facebook.py
Expand Up @@ -75,9 +75,9 @@
# https://developers.facebook.com/docs/graph-api/reference/user/home
# https://github.com/snarfed/granary/issues/26
API_HOME = '%s/home?offset=%d'
API_PHOTOS_UPLOADED = 'me/photos?type=uploaded&fields=id,album,comments,created_time,from,images,likes,link,name,name_tags,object_id,page_story_id,picture,privacy,shares,updated_time'
API_PHOTOS_UPLOADED = 'me/photos?type=uploaded&fields=id,album,comments,created_time,from,images,likes,link,name,name_tags,object_id,page_story_id,picture,privacy,reactions,shares,updated_time'
API_ALBUMS = '%s/albums?fields=id,count,created_time,from,link,name,privacy,type,updated_time'
API_POST_FIELDS = 'id,application,caption,comments,created_time,description,from,likes,link,message,message_tags,name,object_id,parent_id,picture,place,privacy,sharedposts,shares,source,status_type,story,to,type,updated_time,with_tags'
API_POST_FIELDS = 'id,application,caption,comments,created_time,description,from,likes,link,message,message_tags,name,object_id,parent_id,picture,place,privacy,reactions,sharedposts,shares,source,status_type,story,to,type,updated_time,with_tags'
API_SELF_POSTS = '%s/feed?offset=%d&fields=' + API_POST_FIELDS
API_OBJECT = '%s_%s?fields=' + API_POST_FIELDS # USERID_POSTID
API_SHARES = 'sharedposts?ids=%s'
Expand Down Expand Up @@ -161,6 +161,14 @@
'rsvp-maybe': API_PUBLISH_RSVP_MAYBE,
'rsvp-interested': API_PUBLISH_RSVP_INTERESTED,
}
REACTION_CONTENT = {
'LOVE': u'❤️',
'WOW': u'😲',
'HAHA': u'😆',
'SAD': u'😢',
'ANGRY': u'😡',
# nothing for LIKE (it's a like :P) or for NONE
}

FacebookId = collections.namedtuple('FacebookId', ['user', 'post', 'comment'])

Expand Down Expand Up @@ -225,9 +233,10 @@ def get_activities_response(self, user_id=None, group_id=None, app_id=None,

See method docstring in source.py for details.

Likes and *top-level* replies (ie comments) are always included. They come
from the 'comments' and 'likes' fields in the Graph API's Post object:
https://developers.facebook.com/docs/reference/api/post/#u_0_3
Likes, *top-level* replies (ie comments), and reactions are always included.
They come from the 'comments', 'likes', and 'reactions' fields in the Graph
API's Post object:
https://developers.facebook.com/docs/reference/api/post/

Threaded comments, ie comments in reply to other top-level comments, require
an additional API call, so they're only included if fetch_replies is True.
Expand Down Expand Up @@ -523,6 +532,34 @@ def get_rsvp(self, activity_user_id, event_id, user_id):
if rsvp.get('id') == user_id:
return self.rsvp_to_object(rsvp, type=field, event=event)

def get_albums(self, user_id=None):
"""Fetches and returns a user's photo albums.

Args:
user_id: string id or username. Defaults to 'me', ie the current user.

Returns:
sequence of ActivityStream album object dicts
"""
url = API_ALBUMS % (user_id if user_id is not None else 'me')
return [self.album_to_object(a) for a in self.urlopen(url, _as=list)]

def get_reaction(self, activity_user_id, activity_id, reaction_user_id,
reaction_id):
"""Fetches and returns a reaction.

Args:
activity_user_id: string id of the user who posted the original activity
activity_id: string activity id
reaction_user_id: string id of the user who reacted
reaction_id: string id of the reaction. one of:
'love', 'wow', 'haha', 'sad', 'angry'
"""
if '_' not in reaction_id: # handle just name of reaction type
reaction_id = '%s_%s_by_%s' % (activity_id, reaction_id, reaction_user_id)
return super(Facebook, self).get_reaction(activity_user_id, activity_id,
reaction_user_id, reaction_id)

def create(self, obj, include_link=source.OMIT_LINK,
ignore_formatting=False):
"""Creates a new post, comment, like, or RSVP.
Expand All @@ -539,18 +576,6 @@ def create(self, obj, include_link=source.OMIT_LINK,
return self._create(obj, preview=False, include_link=include_link,
ignore_formatting=ignore_formatting)

def get_albums(self, user_id=None):
"""Fetches and returns a user's photo albums.

Args:
user_id: string id or username. Defaults to 'me', ie the current user.

Returns:
sequence of ActivityStream album object dicts
"""
url = API_ALBUMS % (user_id if user_id is not None else 'me')
return [self.album_to_object(a) for a in self.urlopen(url, _as=list)]

def preview_create(self, obj, include_link=source.OMIT_LINK,
ignore_formatting=False):
"""Previews creating a new post, comment, like, or RSVP.
Expand Down Expand Up @@ -1060,28 +1085,42 @@ def post_to_object(self, post, is_comment=False):
message_tags = list(message_tags) # fingers crossed! :P

# tags and likes
tags = itertools.chain(post.get('to', {}).get('data', []),
post.get('with_tags', {}).get('data', []),
message_tags)
tags = (self._as(list, post.get('to', {})) +
self._as(list, post.get('with_tags', {})) +
message_tags)
obj['tags'] = [self.postprocess_object({
'objectType': OBJECT_TYPES.get(t.get('type'), 'person'),
'id': self.tag_uri(t.get('id')),
'url': self.object_url(t.get('id')),
'displayName': t.get('name'),
'startIndex': t.get('offset'),
'length': t.get('length'),
}) for t in tags]

likes = post.get('likes')
if isinstance(likes, dict):
obj['tags'] += [self.postprocess_object({
'id': '%s_liked_by_%s' % (obj['id'], like.get('id')),
'url': url + '#liked-by-%s' % like.get('id'),
'objectType': OBJECT_TYPES.get(t.get('type'), 'person'),
'id': self.tag_uri(t.get('id')),
'url': self.object_url(t.get('id')),
'displayName': t.get('name'),
'startIndex': t.get('offset'),
'length': t.get('length'),
}) for t in tags]

obj['tags'] += [self.postprocess_object({
'id': '%s_liked_by_%s' % (obj['id'], like.get('id')),
'url': url + '#liked-by-%s' % like.get('id'),
'objectType': 'activity',
'verb': 'like',
'object': {'url': url},
'author': self.user_to_actor(like),
}) for like in self._as(list, post.get('likes', {}))]

for reaction in self._as(list, post.get('reactions', {})):
id = reaction.get('id')
type = reaction.get('type', '')
content = REACTION_CONTENT.get(type)
if content:
type = type.lower()
obj['tags'].append(self.postprocess_object({
'id': '%s_%s_by_%s' % (obj['id'], type, id),
'url': url + '#%s-by-%s' % (type, id),
'objectType': 'activity',
'verb': 'like',
'verb': 'react',
'content': content,
'object': {'url': url},
'author': self.user_to_actor(like),
}) for like in likes.get('data', [])]
'author': self.user_to_actor(reaction),
}))

# Escape HTML characters: <, >, &. Have to do it manually, instead of
# reusing e.g. cgi.escape, so that we can shuffle over each tag startIndex
Expand Down
15 changes: 8 additions & 7 deletions granary/microformats2.py
Expand Up @@ -134,8 +134,9 @@ def object_to_json(obj, trim_nulls=True, entry_class='h-entry',
in_reply_tos = obj.get(
'inReplyTo', obj.get('context', {}).get('inReplyTo', []))
is_rsvp = obj_type in ('rsvp-yes', 'rsvp-no', 'rsvp-maybe')
if is_rsvp and obj.get('object'):
in_reply_tos.append(obj['object'])
if (is_rsvp or obj_type == 'react') and obj.get('object'):
objs = obj['object']
in_reply_tos.extend(objs if isinstance(objs, list) else [objs])

# TODO: more tags. most will be p-category?
ret = {
Expand Down Expand Up @@ -651,11 +652,11 @@ def render_content(obj, include_location=True, synthesize_content=True):
object_to_json(loc, default_object_type='place'),
parent_props=['p-location'])

# other tags, except likes, (re)shares, and people. they're rendered manually
# in json_to_html().
tags.pop('like', [])
tags.pop('share', [])
tags.pop('person', [])
# these are rendered manually in json_to_html()
for type in 'like', 'share', 'react', 'person':
tags.pop(type, None)

# render the rest
content += tags_to_html(tags.pop('hashtag', []), 'p-category')
content += tags_to_html(tags.pop('mention', []), 'u-mention')
content += tags_to_html(sum(tags.values(), []), 'tag')
Expand Down
24 changes: 23 additions & 1 deletion granary/source.py
Expand Up @@ -342,6 +342,24 @@ def get_like(self, activity_user_id, activity_id, like_user_id):
fetch_likes=True)
return self._get_tag(activities, 'like', like_user_id)

def get_reaction(self, activity_user_id, activity_id, reaction_user_id,
reaction_id):
"""Returns an ActivityStreams 'reaction' activity object.

Default implementation that fetches the activity and its reactions, then
searches for this specific reaction. Subclasses should override this if they
can optimize the process.

Args:
activity_user_id: string id of the user who posted the original activity
activity_id: string activity id
reaction_user_id: string id of the user who reacted
reaction_id: string id of the reaction
"""
activities = self.get_activities(user_id=activity_user_id,
activity_id=activity_id)
return self._get_tag(activities, 'react', reaction_user_id, reaction_id)

def get_share(self, activity_user_id, activity_id, share_id):
"""Returns an ActivityStreams 'share' activity object.

Expand Down Expand Up @@ -380,14 +398,18 @@ def user_to_actor(self, user):
"""
raise NotImplementedError()

def _get_tag(self, activities, verb, user_id):
def _get_tag(self, activities, verb, user_id, tag_id=None):
if not activities:
return None

user_tag_id = self.tag_uri(user_id)
if tag_id:
tag_id = self.tag_uri(tag_id)

for tag in activities[0].get('object', {}).get('tags', []):
author = tag.get('author', {})
if (tag.get('verb') == verb and
(not tag_id or tag_id == tag.get('id')) and
(author.get('id') == user_tag_id or author.get('numeric_id') == user_id)):
return tag

Expand Down
2 changes: 1 addition & 1 deletion granary/templates/user_feed.atom
Expand Up @@ -56,7 +56,7 @@
<link rel="alternate" type="text/html" href="{{ url }}" />
<link rel="ostatus:conversation" href="{{ url }}" />
{% for tag in obj.tags %}
{% if tag.url and tag.verb != 'like' and tag.verb != 'share' %}
{% if tag.url and tag.verb not in ('like', 'react', 'share') %}
<link rel="ostatus:attention" href="{{ tag.url }}" />
<link rel="mentioned" href="{{ tag.url }}" />
{% if tag.displayName and not tag.startIndex %}
Expand Down