Compare commits

..

25 Commits

Author SHA1 Message Date
Mads Marquart
a71835a5b8 Version up, thanks to @kapi2289 and @LukasOndrejka 2019-04-17 22:58:37 +02:00
Kacper Ziubryniewicz
73c6be1969 Add message parameter to sending location (#416) 2019-04-17 22:35:04 +02:00
Kacper Ziubryniewicz
7db7868d2b Fix forwarding replied messages (#417) 2019-04-17 22:34:19 +02:00
Mads Marquart
8e65074b11 Hotfixes
Fix mistakes introduced in #415
2019-04-17 21:56:50 +02:00
Mads Marquart
d720438aef Merge pull request #415 from carpedm20/cleanup
Cleanup
2019-04-17 21:11:18 +02:00
Mads Marquart
ec0e3a91d1 Merge branch 'master' into cleanup 2019-04-17 20:50:08 +02:00
Mads Marquart
48e7203ca6 Rename internal variable 2019-04-17 20:47:49 +02:00
Mads Marquart
4f76b79629 Merge pull request #409 from kapi2289/message-reply
Add replying to messages
2019-04-17 20:44:30 +02:00
Mads Marquart
1eeae78a9f Small refactoring
The `muteX` methods return values are now checked using `check_request`, `seq` is now parsed in `_parseMessage` and a few other things.
2019-03-31 00:16:36 +01:00
Mads Marquart
bc27f756ed Split long strings, use format when creating strings 2019-03-31 00:11:20 +01:00
Mads Marquart
6302d5fb8b Split overly nested calls 2019-03-31 00:07:29 +01:00
Mads Marquart
24e238c425 Remove superfluous whitespace 2019-03-31 00:02:49 +01:00
Mads Marquart
070f57fcc4 Refactor _graphql away 2019-03-30 21:20:20 +01:00
Kacper Ziubryniewicz
a4ce45e9b0 Add detecting replied messages while listening 2019-03-29 21:09:19 +01:00
Kacper Ziubryniewicz
a3efa7702a Add possibility to reply to messages
and to (partly) fetch the replied messages
2019-03-23 21:26:43 +01:00
LukasOndrejka
d7a5d00439 Add new colors (#393)
Color names are from https://www.htmlcsscolor.com/
2019-03-12 18:15:11 +01:00
Mads Marquart
4f0f126e48 Make Github Releases deploy in the published state 2019-03-10 16:45:16 +01:00
Mads Marquart
94c30a2440 Merge pull request #405 from carpedm20/private-api
Privatize `client`, `utils` and `graphql` submodules
2019-03-10 16:42:48 +01:00
Mads Marquart
40e9825ee0 Add deprecated public graphql module 2019-02-24 23:21:17 +01:00
Mads Marquart
ab9ca94181 Rename graphql.py -> _graphql.py 2019-02-24 23:17:36 +01:00
Mads Marquart
0f99a23af7 Add deprecated public utils module 2019-02-24 23:16:40 +01:00
Mads Marquart
bc5163adaf Rename utils.py -> _util.py 2019-02-24 23:15:12 +01:00
Mads Marquart
0561718917 Import utils.py and graphql.py directly into the global module 2019-02-24 23:08:23 +01:00
Mads Marquart
c1861627fb Make deprecated public client module 2019-02-24 23:06:29 +01:00
Mads Marquart
e5eccab871 Rename client.py -> _client.py 2019-02-24 23:01:26 +01:00
10 changed files with 5504 additions and 5461 deletions

View File

@@ -40,7 +40,7 @@ jobs:
file_glob: true
file: dist/*
skip_cleanup: true
draft: true
draft: false
on:
tags: true

View File

@@ -4,14 +4,16 @@
:copyright: (c) 2015 - 2019 by Taehoon Kim
:license: BSD 3-Clause, see LICENSE for more details.
"""
from __future__ import unicode_literals
# These imports are far too general, but they're needed for backwards compatbility.
from .utils import *
from .graphql import *
from .models import *
from .client import *
from ._client import Client
__title__ = "fbchat"
__version__ = "1.6.4"
__version__ = "1.6.5"
__description__ = "Facebook Chat (Messenger) for Python"
__copyright__ = "Copyright 2015 - 2019 by Taehoon Kim"

4240
fbchat/_client.py Normal file

File diff suppressed because it is too large Load Diff

809
fbchat/_graphql.py Normal file
View File

@@ -0,0 +1,809 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import json
import re
from .models import *
from ._util import *
# Shameless copy from https://stackoverflow.com/a/8730674
FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
WHITESPACE = re.compile(r"[ \t\n\r]*", FLAGS)
class ConcatJSONDecoder(json.JSONDecoder):
def decode(self, s, _w=WHITESPACE.match):
s_len = len(s)
objs = []
end = 0
while end != s_len:
obj, end = self.raw_decode(s, idx=_w(s, end).end())
end = _w(s, end).end()
objs.append(obj)
return objs
# End shameless copy
def graphql_color_to_enum(color):
if color is None:
return None
if not color:
return ThreadColor.MESSENGER_BLUE
color = color[2:] # Strip the alpha value
color_value = "#{}".format(color.lower())
return enum_extend_if_invalid(ThreadColor, color_value)
def get_customization_info(thread):
if thread is None or thread.get("customization_info") is None:
return {}
info = thread["customization_info"]
rtn = {
"emoji": info.get("emoji"),
"color": graphql_color_to_enum(info.get("outgoing_bubble_color")),
}
if (
thread.get("thread_type") == "GROUP"
or thread.get("is_group_thread")
or thread.get("thread_key", {}).get("thread_fbid")
):
rtn["nicknames"] = {}
for k in info.get("participant_customizations", []):
rtn["nicknames"][k["participant_id"]] = k.get("nickname")
elif info.get("participant_customizations"):
uid = thread.get("thread_key", {}).get("other_user_id") or thread.get("id")
pc = info["participant_customizations"]
if len(pc) > 0:
if pc[0].get("participant_id") == uid:
rtn["nickname"] = pc[0].get("nickname")
else:
rtn["own_nickname"] = pc[0].get("nickname")
if len(pc) > 1:
if pc[1].get("participant_id") == uid:
rtn["nickname"] = pc[1].get("nickname")
else:
rtn["own_nickname"] = pc[1].get("nickname")
return rtn
def graphql_to_sticker(s):
if not s:
return None
sticker = Sticker(uid=s["id"])
if s.get("pack"):
sticker.pack = s["pack"].get("id")
if s.get("sprite_image"):
sticker.is_animated = True
sticker.medium_sprite_image = s["sprite_image"].get("uri")
sticker.large_sprite_image = s["sprite_image_2x"].get("uri")
sticker.frames_per_row = s.get("frames_per_row")
sticker.frames_per_col = s.get("frames_per_column")
sticker.frame_rate = s.get("frame_rate")
sticker.url = s.get("url")
sticker.width = s.get("width")
sticker.height = s.get("height")
if s.get("label"):
sticker.label = s["label"]
return sticker
def graphql_to_attachment(a):
_type = a["__typename"]
if _type in ["MessageImage", "MessageAnimatedImage"]:
return ImageAttachment(
original_extension=a.get("original_extension")
or (a["filename"].split("-")[0] if a.get("filename") else None),
width=a.get("original_dimensions", {}).get("width"),
height=a.get("original_dimensions", {}).get("height"),
is_animated=_type == "MessageAnimatedImage",
thumbnail_url=a.get("thumbnail", {}).get("uri"),
preview=a.get("preview") or a.get("preview_image"),
large_preview=a.get("large_preview"),
animated_preview=a.get("animated_image"),
uid=a.get("legacy_attachment_id"),
)
elif _type == "MessageVideo":
return VideoAttachment(
width=a.get("original_dimensions", {}).get("width"),
height=a.get("original_dimensions", {}).get("height"),
duration=a.get("playable_duration_in_ms"),
preview_url=a.get("playable_url"),
small_image=a.get("chat_image"),
medium_image=a.get("inbox_image"),
large_image=a.get("large_image"),
uid=a.get("legacy_attachment_id"),
)
elif _type == "MessageAudio":
return AudioAttachment(
filename=a.get("filename"),
url=a.get("playable_url"),
duration=a.get("playable_duration_in_ms"),
audio_type=a.get("audio_type"),
)
elif _type == "MessageFile":
return FileAttachment(
url=a.get("url"),
name=a.get("filename"),
is_malicious=a.get("is_malicious"),
uid=a.get("message_file_fbid"),
)
else:
return Attachment(uid=a.get("legacy_attachment_id"))
def graphql_to_extensible_attachment(a):
story = a.get("story_attachment")
if story:
target = story.get("target")
if target:
_type = target["__typename"]
if _type == "MessageLocation":
url = story.get("url")
address = get_url_parameter(get_url_parameter(url, "u"), "where1")
try:
latitude, longitude = [float(x) for x in address.split(", ")]
address = None
except ValueError:
latitude, longitude = None, None
rtn = LocationAttachment(
uid=int(story["deduplication_key"]),
latitude=latitude,
longitude=longitude,
address=address,
)
media = story.get("media")
if media and media.get("image"):
image = media["image"]
rtn.image_url = image.get("uri")
rtn.image_width = image.get("width")
rtn.image_height = image.get("height")
rtn.url = url
return rtn
elif _type == "MessageLiveLocation":
rtn = LiveLocationAttachment(
uid=int(story["target"]["live_location_id"]),
latitude=story["target"]["coordinate"]["latitude"]
if story["target"].get("coordinate")
else None,
longitude=story["target"]["coordinate"]["longitude"]
if story["target"].get("coordinate")
else None,
name=story["title_with_entities"]["text"],
expiration_time=story["target"].get("expiration_time"),
is_expired=story["target"].get("is_expired"),
)
media = story.get("media")
if media and media.get("image"):
image = media["image"]
rtn.image_url = image.get("uri")
rtn.image_width = image.get("width")
rtn.image_height = image.get("height")
rtn.url = story.get("url")
return rtn
elif _type in ["ExternalUrl", "Story"]:
url = story.get("url")
rtn = ShareAttachment(
uid=a.get("legacy_attachment_id"),
author=story["target"]["actors"][0]["id"]
if story["target"].get("actors")
else None,
url=url,
original_url=get_url_parameter(url, "u")
if "/l.php?u=" in url
else url,
title=story["title_with_entities"].get("text"),
description=story["description"].get("text")
if story.get("description")
else None,
source=story["source"].get("text"),
attachments=[
graphql_to_subattachment(attachment)
for attachment in story.get("subattachments")
],
)
media = story.get("media")
if media and media.get("image"):
image = media["image"]
rtn.image_url = image.get("uri")
rtn.original_image_url = (
get_url_parameter(rtn.image_url, "url")
if "/safe_image.php" in rtn.image_url
else rtn.image_url
)
rtn.image_width = image.get("width")
rtn.image_height = image.get("height")
return rtn
else:
return UnsentMessage(uid=a.get("legacy_attachment_id"))
def graphql_to_subattachment(a):
_type = a["target"]["__typename"]
if _type == "Video":
media = a["media"]
return VideoAttachment(
duration=media.get("playable_duration_in_ms"),
preview_url=media.get("playable_url"),
medium_image=media.get("image"),
uid=a["target"].get("video_id"),
)
def graphql_to_live_location(a):
return LiveLocationAttachment(
uid=a["id"],
latitude=a["coordinate"]["latitude"] / (10 ** 8)
if not a.get("stopReason")
else None,
longitude=a["coordinate"]["longitude"] / (10 ** 8)
if not a.get("stopReason")
else None,
name=a.get("locationTitle"),
expiration_time=a["expirationTime"],
is_expired=bool(a.get("stopReason")),
)
def graphql_to_poll(a):
rtn = Poll(
title=a.get("title") if a.get("title") else a.get("text"),
options=[graphql_to_poll_option(m) for m in a.get("options")],
)
rtn.uid = int(a["id"])
rtn.options_count = a.get("total_count")
return rtn
def graphql_to_poll_option(a):
if a.get("viewer_has_voted") is None:
vote = None
elif isinstance(a["viewer_has_voted"], bool):
vote = a["viewer_has_voted"]
else:
vote = a["viewer_has_voted"] == "true"
rtn = PollOption(text=a.get("text"), vote=vote)
rtn.uid = int(a["id"])
rtn.voters = (
[m.get("node").get("id") for m in a.get("voters").get("edges")]
if isinstance(a.get("voters"), dict)
else a.get("voters")
)
rtn.votes_count = (
a.get("voters").get("count")
if isinstance(a.get("voters"), dict)
else a.get("total_count")
)
return rtn
def graphql_to_plan(a):
if a.get("event_members"):
rtn = Plan(
time=a.get("event_time"),
title=a.get("title"),
location=a.get("location_name"),
)
if a.get("location_id") != 0:
rtn.location_id = str(a.get("location_id"))
rtn.uid = a.get("oid")
rtn.author_id = a.get("creator_id")
guests = a.get("event_members")
rtn.going = [uid for uid in guests if guests[uid] == "GOING"]
rtn.declined = [uid for uid in guests if guests[uid] == "DECLINED"]
rtn.invited = [uid for uid in guests if guests[uid] == "INVITED"]
return rtn
elif a.get("id") is None:
rtn = Plan(
time=a.get("event_time"),
title=a.get("event_title"),
location=a.get("event_location_name"),
location_id=a.get("event_location_id"),
)
rtn.uid = a.get("event_id")
rtn.author_id = a.get("event_creator_id")
guests = json.loads(a.get("guest_state_list"))
else:
rtn = Plan(
time=a.get("time"),
title=a.get("event_title"),
location=a.get("location_name"),
)
rtn.uid = a.get("id")
rtn.author_id = a.get("lightweight_event_creator").get("id")
guests = a.get("event_reminder_members").get("edges")
rtn.going = [
m.get("node").get("id") for m in guests if m.get("guest_list_state") == "GOING"
]
rtn.declined = [
m.get("node").get("id")
for m in guests
if m.get("guest_list_state") == "DECLINED"
]
rtn.invited = [
m.get("node").get("id")
for m in guests
if m.get("guest_list_state") == "INVITED"
]
return rtn
def graphql_to_quick_reply(q, is_response=False):
data = dict()
_type = q.get("content_type").lower()
if q.get("payload"):
data["payload"] = q["payload"]
if q.get("data"):
data["data"] = q["data"]
if q.get("image_url") and _type is not QuickReplyLocation._type:
data["image_url"] = q["image_url"]
data["is_response"] = is_response
if _type == QuickReplyText._type:
if q.get("title") is not None:
data["title"] = q["title"]
rtn = QuickReplyText(**data)
elif _type == QuickReplyLocation._type:
rtn = QuickReplyLocation(**data)
elif _type == QuickReplyPhoneNumber._type:
rtn = QuickReplyPhoneNumber(**data)
elif _type == QuickReplyEmail._type:
rtn = QuickReplyEmail(**data)
return rtn
def graphql_to_message(message):
if message.get("message_sender") is None:
message["message_sender"] = {}
if message.get("message") is None:
message["message"] = {}
rtn = Message(
text=message.get("message").get("text"),
mentions=[
Mention(
m.get("entity", {}).get("id"),
offset=m.get("offset"),
length=m.get("length"),
)
for m in message.get("message").get("ranges", [])
],
emoji_size=get_emojisize_from_tags(message.get("tags_list")),
sticker=graphql_to_sticker(message.get("sticker")),
)
rtn.uid = str(message.get("message_id"))
rtn.author = str(message.get("message_sender").get("id"))
rtn.timestamp = message.get("timestamp_precise")
rtn.unsent = False
if message.get("unread") is not None:
rtn.is_read = not message["unread"]
rtn.reactions = {
str(r["user"]["id"]): enum_extend_if_invalid(MessageReaction, r["reaction"])
for r in message.get("message_reactions")
}
if message.get("blob_attachments") is not None:
rtn.attachments = [
graphql_to_attachment(attachment)
for attachment in message["blob_attachments"]
]
if message.get("platform_xmd_encoded"):
quick_replies = json.loads(message["platform_xmd_encoded"]).get("quick_replies")
if isinstance(quick_replies, list):
rtn.quick_replies = [graphql_to_quick_reply(q) for q in quick_replies]
elif isinstance(quick_replies, dict):
rtn.quick_replies = [
graphql_to_quick_reply(quick_replies, is_response=True)
]
if message.get("extensible_attachment") is not None:
attachment = graphql_to_extensible_attachment(message["extensible_attachment"])
if isinstance(attachment, UnsentMessage):
rtn.unsent = True
elif attachment:
rtn.attachments.append(attachment)
if message.get("replied_to_message") is not None:
rtn.replied_to = graphql_to_message(message["replied_to_message"]["message"])
return rtn
def graphql_to_message_reply(message):
rtn = Message(
text=message.get("body"),
mentions=[
Mention(m.get("i"), offset=m.get("o"), length=m.get("l"))
for m in json.loads(message.get("data", {}).get("prng", "[]"))
],
emoji_size=get_emojisize_from_tags(message["messageMetadata"].get("tags")),
)
metadata = message.get("messageMetadata", {})
rtn.uid = metadata.get("messageId")
rtn.author = str(metadata.get("actorFbId"))
rtn.timestamp = metadata.get("timestamp")
rtn.unsent = False
if message.get("data", {}).get("platform_xmd"):
quick_replies = json.loads(message["data"]["platform_xmd"]).get("quick_replies")
if isinstance(quick_replies, list):
rtn.quick_replies = [graphql_to_quick_reply(q) for q in quick_replies]
elif isinstance(quick_replies, dict):
rtn.quick_replies = [
graphql_to_quick_reply(quick_replies, is_response=True)
]
if message.get("attachments") is not None:
for attachment in message["attachments"]:
attachment = json.loads(attachment["mercuryJSON"])
if attachment.get("blob_attachment"):
rtn.attachments.append(
graphql_to_attachment(attachment["blob_attachment"])
)
if attachment.get("extensible_attachment"):
extensible_attachment = graphql_to_extensible_attachment(
attachment["extensible_attachment"]
)
if isinstance(extensible_attachment, UnsentMessage):
rtn.unsent = True
else:
rtn.attachments.append(extensible_attachment)
if attachment.get("sticker_attachment"):
rtn.sticker = graphql_to_sticker(attachment["sticker_attachment"])
return rtn
def graphql_to_user(user):
if user.get("profile_picture") is None:
user["profile_picture"] = {}
c_info = get_customization_info(user)
plan = None
if user.get("event_reminders"):
plan = (
graphql_to_plan(user["event_reminders"]["nodes"][0])
if user["event_reminders"].get("nodes")
else None
)
return User(
user["id"],
url=user.get("url"),
first_name=user.get("first_name"),
last_name=user.get("last_name"),
is_friend=user.get("is_viewer_friend"),
gender=GENDERS.get(user.get("gender")),
affinity=user.get("affinity"),
nickname=c_info.get("nickname"),
color=c_info.get("color"),
emoji=c_info.get("emoji"),
own_nickname=c_info.get("own_nickname"),
photo=user["profile_picture"].get("uri"),
name=user.get("name"),
message_count=user.get("messages_count"),
plan=plan,
)
def graphql_to_thread(thread):
if thread["thread_type"] == "GROUP":
return graphql_to_group(thread)
elif thread["thread_type"] == "ONE_TO_ONE":
if thread.get("big_image_src") is None:
thread["big_image_src"] = {}
c_info = get_customization_info(thread)
participants = [
node["messaging_actor"] for node in thread["all_participants"]["nodes"]
]
user = next(
p for p in participants if p["id"] == thread["thread_key"]["other_user_id"]
)
last_message_timestamp = None
if "last_message" in thread:
last_message_timestamp = thread["last_message"]["nodes"][0][
"timestamp_precise"
]
first_name = user.get("short_name")
if first_name is None:
last_name = None
else:
last_name = user.get("name").split(first_name, 1).pop().strip()
plan = None
if thread.get("event_reminders"):
plan = (
graphql_to_plan(thread["event_reminders"]["nodes"][0])
if thread["event_reminders"].get("nodes")
else None
)
return User(
user["id"],
url=user.get("url"),
name=user.get("name"),
first_name=first_name,
last_name=last_name,
is_friend=user.get("is_viewer_friend"),
gender=GENDERS.get(user.get("gender")),
affinity=user.get("affinity"),
nickname=c_info.get("nickname"),
color=c_info.get("color"),
emoji=c_info.get("emoji"),
own_nickname=c_info.get("own_nickname"),
photo=user["big_image_src"].get("uri"),
message_count=thread.get("messages_count"),
last_message_timestamp=last_message_timestamp,
plan=plan,
)
else:
raise FBchatException(
"Unknown thread type: {}, with data: {}".format(
thread.get("thread_type"), thread
)
)
def graphql_to_group(group):
if group.get("image") is None:
group["image"] = {}
c_info = get_customization_info(group)
last_message_timestamp = None
if "last_message" in group:
last_message_timestamp = group["last_message"]["nodes"][0]["timestamp_precise"]
plan = None
if group.get("event_reminders"):
plan = (
graphql_to_plan(group["event_reminders"]["nodes"][0])
if group["event_reminders"].get("nodes")
else None
)
return Group(
group["thread_key"]["thread_fbid"],
participants=set(
[
node["messaging_actor"]["id"]
for node in group["all_participants"]["nodes"]
]
),
nicknames=c_info.get("nicknames"),
color=c_info.get("color"),
emoji=c_info.get("emoji"),
admins=set([node.get("id") for node in group.get("thread_admins")]),
approval_mode=bool(group.get("approval_mode"))
if group.get("approval_mode") is not None
else None,
approval_requests=set(
node["requester"]["id"] for node in group["group_approval_queue"]["nodes"]
)
if group.get("group_approval_queue")
else None,
join_link=group["joinable_mode"].get("link"),
photo=group["image"].get("uri"),
name=group.get("name"),
message_count=group.get("messages_count"),
last_message_timestamp=last_message_timestamp,
plan=plan,
)
def graphql_to_page(page):
if page.get("profile_picture") is None:
page["profile_picture"] = {}
if page.get("city") is None:
page["city"] = {}
plan = None
if page.get("event_reminders"):
plan = (
graphql_to_plan(page["event_reminders"]["nodes"][0])
if page["event_reminders"].get("nodes")
else None
)
return Page(
page["id"],
url=page.get("url"),
city=page.get("city").get("name"),
category=page.get("category_type"),
photo=page["profile_picture"].get("uri"),
name=page.get("name"),
message_count=page.get("messages_count"),
plan=plan,
)
def graphql_queries_to_json(*queries):
"""
Queries should be a list of GraphQL objects
"""
rtn = {}
for i, query in enumerate(queries):
rtn["q{}".format(i)] = query.value
return json.dumps(rtn)
def graphql_response_to_json(content):
content = strip_to_json(content) # Usually only needed in some error cases
try:
j = json.loads(content, cls=ConcatJSONDecoder)
except Exception:
raise FBchatException("Error while parsing JSON: {}".format(repr(content)))
rtn = [None] * (len(j))
for x in j:
if "error_results" in x:
del rtn[-1]
continue
check_json(x)
[(key, value)] = x.items()
check_json(value)
if "response" in value:
rtn[int(key[1:])] = value["response"]
else:
rtn[int(key[1:])] = value["data"]
log.debug(rtn)
return rtn
class GraphQL(object):
def __init__(self, query=None, doc_id=None, params=None):
if params is None:
params = {}
if query is not None:
self.value = {"priority": 0, "q": query, "query_params": params}
elif doc_id is not None:
self.value = {"doc_id": doc_id, "query_params": params}
else:
raise FBchatUserError("A query or doc_id must be specified")
FRAGMENT_USER = """
QueryFragment User: User {
id,
name,
first_name,
last_name,
profile_picture.width(<pic_size>).height(<pic_size>) {
uri
},
is_viewer_friend,
url,
gender,
viewer_affinity
}
"""
FRAGMENT_GROUP = """
QueryFragment Group: MessageThread {
name,
thread_key {
thread_fbid
},
image {
uri
},
is_group_thread,
all_participants {
nodes {
messaging_actor {
id
}
}
},
customization_info {
participant_customizations {
participant_id,
nickname
},
outgoing_bubble_color,
emoji
},
thread_admins {
id
},
group_approval_queue {
nodes {
requester {
id
}
}
},
approval_mode,
joinable_mode {
mode,
link
},
event_reminders {
nodes {
id,
lightweight_event_creator {
id
},
time,
location_name,
event_title,
event_reminder_members {
edges {
node {
id
},
guest_list_state
}
}
}
}
}
"""
FRAGMENT_PAGE = """
QueryFragment Page: Page {
id,
name,
profile_picture.width(32).height(32) {
uri
},
url,
category_type,
city {
name
}
}
"""
SEARCH_USER = (
"""
Query SearchUser(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(user).first(<limit>) as users {
nodes {
@User
}
}
}
}
"""
+ FRAGMENT_USER
)
SEARCH_GROUP = (
"""
Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) {
viewer() {
message_threads.with_thread_name(<search>).last(<limit>) as groups {
nodes {
@Group
}
}
}
}
"""
+ FRAGMENT_GROUP
)
SEARCH_PAGE = (
"""
Query SearchPage(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(page).first(<limit>) as pages {
nodes {
@Page
}
}
}
}
"""
+ FRAGMENT_PAGE
)
SEARCH_THREAD = (
"""
Query SearchThread(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.first(<limit>) as threads {
nodes {
__typename,
@User,
@Group,
@Page
}
}
}
}
"""
+ FRAGMENT_USER
+ FRAGMENT_GROUP
+ FRAGMENT_PAGE
)

View File

@@ -68,6 +68,10 @@ class Message(object):
quick_replies = attr.ib(factory=list, converter=lambda x: [] if x is None else x)
#: Whether the message is unsent (deleted for everyone)
unsent = attr.ib(False, init=False)
#: Message ID you want to reply to
reply_to_id = attr.ib(None)
#: Replied message
replied_to = attr.ib(None, init=False)
@classmethod
def formatMentions(cls, text, *args, **kwargs):

View File

@@ -41,6 +41,11 @@ class ThreadColor(Enum):
CAMEO = "#d4a88c"
BRILLIANT_ROSE = "#ff5ca1"
BILOBA_FLOWER = "#a695c7"
TICKLE_ME_PINK = "#ff7ca8"
MALACHITE = "#1adb5b"
RUBY = "#f01d6a"
DARK_TANGERINE = "#ff9c19"
BRIGHT_TURQUOISE = "#0edcde"
@attr.s(cmp=False, init=False)

377
fbchat/_util.py Normal file
View File

@@ -0,0 +1,377 @@
# -*- coding: UTF-8 -*-
from __future__ import unicode_literals
import re
import json
from time import time
from random import random
from contextlib import contextmanager
from mimetypes import guess_type
from os.path import basename
import warnings
import logging
import requests
import aenum
from .models import *
try:
from urllib.parse import urlencode, parse_qs, urlparse
basestring = (str, bytes)
except ImportError:
from urllib import urlencode
from urlparse import parse_qs, urlparse
basestring = basestring
# Python 2's `input` executes the input, whereas `raw_input` just returns the input
try:
input = raw_input
except NameError:
pass
# Log settings
log = logging.getLogger("client")
log.setLevel(logging.DEBUG)
# Creates the console handler
handler = logging.StreamHandler()
log.addHandler(handler)
#: Default list of user agents
USER_AGENTS = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/601.1.10 (KHTML, like Gecko) Version/8.0.5 Safari/601.1.10",
"Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/22.0.1207.1 Safari/537.1",
"Mozilla/5.0 (X11; CrOS i686 2268.111.0) AppleWebKit/536.11 (KHTML, like Gecko) Chrome/20.0.1132.57 Safari/536.11",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1092.0 Safari/536.6",
]
LIKES = {
"large": EmojiSize.LARGE,
"medium": EmojiSize.MEDIUM,
"small": EmojiSize.SMALL,
"l": EmojiSize.LARGE,
"m": EmojiSize.MEDIUM,
"s": EmojiSize.SMALL,
}
GENDERS = {
# For standard requests
0: "unknown",
1: "female_singular",
2: "male_singular",
3: "female_singular_guess",
4: "male_singular_guess",
5: "mixed",
6: "neuter_singular",
7: "unknown_singular",
8: "female_plural",
9: "male_plural",
10: "neuter_plural",
11: "unknown_plural",
# For graphql requests
"UNKNOWN": "unknown",
"FEMALE": "female_singular",
"MALE": "male_singular",
# '': 'female_singular_guess',
# '': 'male_singular_guess',
# '': 'mixed',
"NEUTER": "neuter_singular",
# '': 'unknown_singular',
# '': 'female_plural',
# '': 'male_plural',
# '': 'neuter_plural',
# '': 'unknown_plural',
}
class ReqUrl(object):
"""A class containing all urls used by `fbchat`"""
SEARCH = "https://www.facebook.com/ajax/typeahead/search.php"
LOGIN = "https://m.facebook.com/login.php?login_attempt=1"
SEND = "https://www.facebook.com/messaging/send/"
UNREAD_THREADS = "https://www.facebook.com/ajax/mercury/unread_threads.php"
UNSEEN_THREADS = "https://www.facebook.com/mercury/unseen_thread_ids/"
THREADS = "https://www.facebook.com/ajax/mercury/threadlist_info.php"
MOVE_THREAD = "https://www.facebook.com/ajax/mercury/move_thread.php"
ARCHIVED_STATUS = (
"https://www.facebook.com/ajax/mercury/change_archived_status.php?dpr=1"
)
PINNED_STATUS = (
"https://www.facebook.com/ajax/mercury/change_pinned_status.php?dpr=1"
)
MESSAGES = "https://www.facebook.com/ajax/mercury/thread_info.php"
READ_STATUS = "https://www.facebook.com/ajax/mercury/change_read_status.php"
DELIVERED = "https://www.facebook.com/ajax/mercury/delivery_receipts.php"
MARK_SEEN = "https://www.facebook.com/ajax/mercury/mark_seen.php"
BASE = "https://www.facebook.com"
MOBILE = "https://m.facebook.com/"
STICKY = "https://0-edge-chat.facebook.com/pull"
PING = "https://0-edge-chat.facebook.com/active_ping"
UPLOAD = "https://upload.facebook.com/ajax/mercury/upload.php"
INFO = "https://www.facebook.com/chat/user_info/"
CONNECT = "https://www.facebook.com/ajax/add_friend/action.php?dpr=1"
REMOVE_USER = "https://www.facebook.com/chat/remove_participants/"
LOGOUT = "https://www.facebook.com/logout.php"
ALL_USERS = "https://www.facebook.com/chat/user_info_all"
SAVE_DEVICE = "https://m.facebook.com/login/save-device/cancel/"
CHECKPOINT = "https://m.facebook.com/login/checkpoint/"
THREAD_COLOR = "https://www.facebook.com/messaging/save_thread_color/?source=thread_settings&dpr=1"
THREAD_NICKNAME = "https://www.facebook.com/messaging/save_thread_nickname/?source=thread_settings&dpr=1"
THREAD_EMOJI = "https://www.facebook.com/messaging/save_thread_emoji/?source=thread_settings&dpr=1"
THREAD_IMAGE = "https://www.facebook.com/messaging/set_thread_image/?dpr=1"
THREAD_NAME = "https://www.facebook.com/messaging/set_thread_name/?dpr=1"
MESSAGE_REACTION = "https://www.facebook.com/webgraphql/mutation"
TYPING = "https://www.facebook.com/ajax/messaging/typ.php"
GRAPHQL = "https://www.facebook.com/api/graphqlbatch/"
ATTACHMENT_PHOTO = "https://www.facebook.com/mercury/attachments/photo/"
PLAN_CREATE = "https://www.facebook.com/ajax/eventreminder/create"
PLAN_INFO = "https://www.facebook.com/ajax/eventreminder"
PLAN_CHANGE = "https://www.facebook.com/ajax/eventreminder/submit"
PLAN_PARTICIPATION = "https://www.facebook.com/ajax/eventreminder/rsvp"
MODERN_SETTINGS_MENU = "https://www.facebook.com/bluebar/modern_settings_menu/"
REMOVE_FRIEND = "https://m.facebook.com/a/removefriend.php"
BLOCK_USER = "https://www.facebook.com/messaging/block_messages/?dpr=1"
UNBLOCK_USER = "https://www.facebook.com/messaging/unblock_messages/?dpr=1"
SAVE_ADMINS = "https://www.facebook.com/messaging/save_admins/?dpr=1"
APPROVAL_MODE = "https://www.facebook.com/messaging/set_approval_mode/?dpr=1"
CREATE_GROUP = "https://m.facebook.com/messages/send/?icm=1"
DELETE_THREAD = "https://www.facebook.com/ajax/mercury/delete_thread.php?dpr=1"
DELETE_MESSAGES = "https://www.facebook.com/ajax/mercury/delete_messages.php?dpr=1"
MUTE_THREAD = "https://www.facebook.com/ajax/mercury/change_mute_thread.php?dpr=1"
MUTE_REACTIONS = (
"https://www.facebook.com/ajax/mercury/change_reactions_mute_thread/?dpr=1"
)
MUTE_MENTIONS = (
"https://www.facebook.com/ajax/mercury/change_mentions_mute_thread/?dpr=1"
)
CREATE_POLL = "https://www.facebook.com/messaging/group_polling/create_poll/?dpr=1"
UPDATE_VOTE = "https://www.facebook.com/messaging/group_polling/update_vote/?dpr=1"
GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options"
SEARCH_MESSAGES = "https://www.facebook.com/ajax/mercury/search_snippets.php?dpr=1"
MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1"
UNSEND = "https://www.facebook.com/messaging/unsend_message/?dpr=1"
pull_channel = 0
def change_pull_channel(self, channel=None):
if channel is None:
self.pull_channel = (self.pull_channel + 1) % 5 # Pull channel will be 0-4
else:
self.pull_channel = channel
self.STICKY = "https://{}-edge-chat.facebook.com/pull".format(self.pull_channel)
self.PING = "https://{}-edge-chat.facebook.com/active_ping".format(
self.pull_channel
)
facebookEncoding = "UTF-8"
def now():
return int(time() * 1000)
def strip_to_json(text):
try:
return text[text.index("{") :]
except ValueError:
raise FBchatException("No JSON object found: {!r}".format(text))
def get_decoded_r(r):
return get_decoded(r._content)
def get_decoded(content):
return content.decode(facebookEncoding)
def parse_json(content):
return json.loads(content)
def get_json(r):
return json.loads(strip_to_json(get_decoded_r(r)))
def digitToChar(digit):
if digit < 10:
return str(digit)
return chr(ord("a") + digit - 10)
def str_base(number, base):
if number < 0:
return "-" + str_base(-number, base)
(d, m) = divmod(number, base)
if d > 0:
return str_base(d, base) + digitToChar(m)
return digitToChar(m)
def generateMessageID(client_id=None):
k = now()
l = int(random() * 4294967295)
return "<{}:{}-{}@mail.projektitan.com>".format(k, l, client_id)
def getSignatureID():
return hex(int(random() * 2147483648))
def generateOfflineThreadingID():
ret = now()
value = int(random() * 4294967295)
string = ("0000000000000000000000" + format(value, "b"))[-22:]
msgs = format(ret, "b") + string
return str(int(msgs, 2))
def check_json(j):
if j.get("error") is None:
return
if "errorDescription" in j:
# 'errorDescription' is in the users own language!
raise FBchatFacebookError(
"Error #{} when sending request: {}".format(
j["error"], j["errorDescription"]
),
fb_error_code=j["error"],
fb_error_message=j["errorDescription"],
)
elif "debug_info" in j["error"] and "code" in j["error"]:
raise FBchatFacebookError(
"Error #{} when sending request: {}".format(
j["error"]["code"], repr(j["error"]["debug_info"])
),
fb_error_code=j["error"]["code"],
fb_error_message=j["error"]["debug_info"],
)
else:
raise FBchatFacebookError(
"Error {} when sending request".format(j["error"]), fb_error_code=j["error"]
)
def check_request(r, as_json=True):
if not r.ok:
raise FBchatFacebookError(
"Error when sending request: Got {} response".format(r.status_code),
request_status_code=r.status_code,
)
content = get_decoded_r(r)
if content is None or len(content) == 0:
raise FBchatFacebookError("Error when sending request: Got empty response")
if as_json:
content = strip_to_json(content)
try:
j = json.loads(content)
except ValueError:
raise FBchatFacebookError("Error while parsing JSON: {!r}".format(content))
check_json(j)
log.debug(j)
return j
else:
return content
def get_jsmods_require(j, index):
if j.get("jsmods") and j["jsmods"].get("require"):
try:
return j["jsmods"]["require"][0][index][0]
except (KeyError, IndexError) as e:
log.warning(
"Error when getting jsmods_require: {}. Facebook might have changed protocol".format(
j
)
)
return None
def get_emojisize_from_tags(tags):
if tags is None:
return None
tmp = [tag for tag in tags if tag.startswith("hot_emoji_size:")]
if len(tmp) > 0:
try:
return LIKES[tmp[0].split(":")[1]]
except (KeyError, IndexError):
log.exception(
"Could not determine emoji size from {} - {}".format(tags, tmp)
)
return None
def require_list(list_):
if isinstance(list_, list):
return set(list_)
else:
return set([list_])
def mimetype_to_key(mimetype):
if not mimetype:
return "file_id"
if mimetype == "image/gif":
return "gif_id"
x = mimetype.split("/")
if x[0] in ["video", "image", "audio"]:
return "%s_id" % x[0]
return "file_id"
def get_files_from_urls(file_urls):
files = []
for file_url in file_urls:
r = requests.get(file_url)
# We could possibly use r.headers.get('Content-Disposition'), see
# https://stackoverflow.com/a/37060758
files.append(
(
basename(file_url),
r.content,
r.headers.get("Content-Type") or guess_type(file_url)[0],
)
)
return files
@contextmanager
def get_files_from_paths(filenames):
files = []
for filename in filenames:
files.append(
(basename(filename), open(filename, "rb"), guess_type(filename)[0])
)
yield files
for fn, fp, ft in files:
fp.close()
def enum_extend_if_invalid(enumeration, value):
try:
return enumeration(value)
except ValueError:
log.warning(
"Failed parsing {.__name__}({!r}). Extending enum.".format(
enumeration, value
)
)
aenum.extend_enum(enumeration, "UNKNOWN_{}".format(value).upper(), value)
return enumeration(value)
def get_url_parameters(url, *args):
params = parse_qs(urlparse(url).query)
return [params[arg][0] for arg in args if params.get(arg)]
def get_url_parameter(url, param):
return get_url_parameters(url, param)[0]

File diff suppressed because it is too large Load Diff

View File

@@ -1,765 +1,31 @@
# -*- coding: UTF-8 -*-
"""This file is here to maintain backwards compatability."""
from __future__ import unicode_literals
import json
import re
from .models import *
from .utils import *
# Shameless copy from https://stackoverflow.com/a/8730674
FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL
WHITESPACE = re.compile(r"[ \t\n\r]*", FLAGS)
class ConcatJSONDecoder(json.JSONDecoder):
def decode(self, s, _w=WHITESPACE.match):
s_len = len(s)
objs = []
end = 0
while end != s_len:
obj, end = self.raw_decode(s, idx=_w(s, end).end())
end = _w(s, end).end()
objs.append(obj)
return objs
# End shameless copy
def graphql_color_to_enum(color):
if color is None:
return None
if not color:
return ThreadColor.MESSENGER_BLUE
color = color[2:] # Strip the alpha value
color_value = "#{}".format(color.lower())
return enum_extend_if_invalid(ThreadColor, color_value)
def get_customization_info(thread):
if thread is None or thread.get("customization_info") is None:
return {}
info = thread["customization_info"]
rtn = {
"emoji": info.get("emoji"),
"color": graphql_color_to_enum(info.get("outgoing_bubble_color")),
}
if (
thread.get("thread_type") == "GROUP"
or thread.get("is_group_thread")
or thread.get("thread_key", {}).get("thread_fbid")
):
rtn["nicknames"] = {}
for k in info.get("participant_customizations", []):
rtn["nicknames"][k["participant_id"]] = k.get("nickname")
elif info.get("participant_customizations"):
uid = thread.get("thread_key", {}).get("other_user_id") or thread.get("id")
pc = info["participant_customizations"]
if len(pc) > 0:
if pc[0].get("participant_id") == uid:
rtn["nickname"] = pc[0].get("nickname")
else:
rtn["own_nickname"] = pc[0].get("nickname")
if len(pc) > 1:
if pc[1].get("participant_id") == uid:
rtn["nickname"] = pc[1].get("nickname")
else:
rtn["own_nickname"] = pc[1].get("nickname")
return rtn
def graphql_to_sticker(s):
if not s:
return None
sticker = Sticker(uid=s["id"])
if s.get("pack"):
sticker.pack = s["pack"].get("id")
if s.get("sprite_image"):
sticker.is_animated = True
sticker.medium_sprite_image = s["sprite_image"].get("uri")
sticker.large_sprite_image = s["sprite_image_2x"].get("uri")
sticker.frames_per_row = s.get("frames_per_row")
sticker.frames_per_col = s.get("frames_per_column")
sticker.frame_rate = s.get("frame_rate")
sticker.url = s.get("url")
sticker.width = s.get("width")
sticker.height = s.get("height")
if s.get("label"):
sticker.label = s["label"]
return sticker
def graphql_to_attachment(a):
_type = a["__typename"]
if _type in ["MessageImage", "MessageAnimatedImage"]:
return ImageAttachment(
original_extension=a.get("original_extension")
or (a["filename"].split("-")[0] if a.get("filename") else None),
width=a.get("original_dimensions", {}).get("width"),
height=a.get("original_dimensions", {}).get("height"),
is_animated=_type == "MessageAnimatedImage",
thumbnail_url=a.get("thumbnail", {}).get("uri"),
preview=a.get("preview") or a.get("preview_image"),
large_preview=a.get("large_preview"),
animated_preview=a.get("animated_image"),
uid=a.get("legacy_attachment_id"),
)
elif _type == "MessageVideo":
return VideoAttachment(
width=a.get("original_dimensions", {}).get("width"),
height=a.get("original_dimensions", {}).get("height"),
duration=a.get("playable_duration_in_ms"),
preview_url=a.get("playable_url"),
small_image=a.get("chat_image"),
medium_image=a.get("inbox_image"),
large_image=a.get("large_image"),
uid=a.get("legacy_attachment_id"),
)
elif _type == "MessageAudio":
return AudioAttachment(
filename=a.get("filename"),
url=a.get("playable_url"),
duration=a.get("playable_duration_in_ms"),
audio_type=a.get("audio_type"),
)
elif _type == "MessageFile":
return FileAttachment(
url=a.get("url"),
name=a.get("filename"),
is_malicious=a.get("is_malicious"),
uid=a.get("message_file_fbid"),
)
else:
return Attachment(uid=a.get("legacy_attachment_id"))
def graphql_to_extensible_attachment(a):
story = a.get("story_attachment")
if story:
target = story.get("target")
if target:
_type = target["__typename"]
if _type == "MessageLocation":
url = story.get("url")
address = get_url_parameter(get_url_parameter(url, "u"), "where1")
try:
latitude, longitude = [float(x) for x in address.split(", ")]
address = None
except ValueError:
latitude, longitude = None, None
rtn = LocationAttachment(
uid=int(story["deduplication_key"]),
latitude=latitude,
longitude=longitude,
address=address,
)
media = story.get("media")
if media and media.get("image"):
image = media["image"]
rtn.image_url = image.get("uri")
rtn.image_width = image.get("width")
rtn.image_height = image.get("height")
rtn.url = url
return rtn
elif _type == "MessageLiveLocation":
rtn = LiveLocationAttachment(
uid=int(story["target"]["live_location_id"]),
latitude=story["target"]["coordinate"]["latitude"]
if story["target"].get("coordinate")
else None,
longitude=story["target"]["coordinate"]["longitude"]
if story["target"].get("coordinate")
else None,
name=story["title_with_entities"]["text"],
expiration_time=story["target"].get("expiration_time"),
is_expired=story["target"].get("is_expired"),
)
media = story.get("media")
if media and media.get("image"):
image = media["image"]
rtn.image_url = image.get("uri")
rtn.image_width = image.get("width")
rtn.image_height = image.get("height")
rtn.url = story.get("url")
return rtn
elif _type in ["ExternalUrl", "Story"]:
url = story.get("url")
rtn = ShareAttachment(
uid=a.get("legacy_attachment_id"),
author=story["target"]["actors"][0]["id"]
if story["target"].get("actors")
else None,
url=url,
original_url=get_url_parameter(url, "u")
if "/l.php?u=" in url
else url,
title=story["title_with_entities"].get("text"),
description=story["description"].get("text")
if story.get("description")
else None,
source=story["source"].get("text"),
attachments=[
graphql_to_subattachment(attachment)
for attachment in story.get("subattachments")
],
)
media = story.get("media")
if media and media.get("image"):
image = media["image"]
rtn.image_url = image.get("uri")
rtn.original_image_url = (
get_url_parameter(rtn.image_url, "url")
if "/safe_image.php" in rtn.image_url
else rtn.image_url
)
rtn.image_width = image.get("width")
rtn.image_height = image.get("height")
return rtn
else:
return UnsentMessage(uid=a.get("legacy_attachment_id"))
def graphql_to_subattachment(a):
_type = a["target"]["__typename"]
if _type == "Video":
media = a["media"]
return VideoAttachment(
duration=media.get("playable_duration_in_ms"),
preview_url=media.get("playable_url"),
medium_image=media.get("image"),
uid=a["target"].get("video_id"),
)
def graphql_to_live_location(a):
return LiveLocationAttachment(
uid=a["id"],
latitude=a["coordinate"]["latitude"] / (10 ** 8)
if not a.get("stopReason")
else None,
longitude=a["coordinate"]["longitude"] / (10 ** 8)
if not a.get("stopReason")
else None,
name=a.get("locationTitle"),
expiration_time=a["expirationTime"],
is_expired=bool(a.get("stopReason")),
)
def graphql_to_poll(a):
rtn = Poll(
title=a.get("title") if a.get("title") else a.get("text"),
options=[graphql_to_poll_option(m) for m in a.get("options")],
)
rtn.uid = int(a["id"])
rtn.options_count = a.get("total_count")
return rtn
def graphql_to_poll_option(a):
if a.get("viewer_has_voted") is None:
vote = None
elif isinstance(a["viewer_has_voted"], bool):
vote = a["viewer_has_voted"]
else:
vote = a["viewer_has_voted"] == "true"
rtn = PollOption(text=a.get("text"), vote=vote)
rtn.uid = int(a["id"])
rtn.voters = (
[m.get("node").get("id") for m in a.get("voters").get("edges")]
if isinstance(a.get("voters"), dict)
else a.get("voters")
)
rtn.votes_count = (
a.get("voters").get("count")
if isinstance(a.get("voters"), dict)
else a.get("total_count")
)
return rtn
def graphql_to_plan(a):
if a.get("event_members"):
rtn = Plan(
time=a.get("event_time"),
title=a.get("title"),
location=a.get("location_name"),
)
if a.get("location_id") != 0:
rtn.location_id = str(a.get("location_id"))
rtn.uid = a.get("oid")
rtn.author_id = a.get("creator_id")
guests = a.get("event_members")
rtn.going = [uid for uid in guests if guests[uid] == "GOING"]
rtn.declined = [uid for uid in guests if guests[uid] == "DECLINED"]
rtn.invited = [uid for uid in guests if guests[uid] == "INVITED"]
return rtn
elif a.get("id") is None:
rtn = Plan(
time=a.get("event_time"),
title=a.get("event_title"),
location=a.get("event_location_name"),
location_id=a.get("event_location_id"),
)
rtn.uid = a.get("event_id")
rtn.author_id = a.get("event_creator_id")
guests = json.loads(a.get("guest_state_list"))
else:
rtn = Plan(
time=a.get("time"),
title=a.get("event_title"),
location=a.get("location_name"),
)
rtn.uid = a.get("id")
rtn.author_id = a.get("lightweight_event_creator").get("id")
guests = a.get("event_reminder_members").get("edges")
rtn.going = [
m.get("node").get("id") for m in guests if m.get("guest_list_state") == "GOING"
]
rtn.declined = [
m.get("node").get("id")
for m in guests
if m.get("guest_list_state") == "DECLINED"
]
rtn.invited = [
m.get("node").get("id")
for m in guests
if m.get("guest_list_state") == "INVITED"
]
return rtn
def graphql_to_quick_reply(q, is_response=False):
data = dict()
_type = q.get("content_type").lower()
if q.get("payload"):
data["payload"] = q["payload"]
if q.get("data"):
data["data"] = q["data"]
if q.get("image_url") and _type is not QuickReplyLocation._type:
data["image_url"] = q["image_url"]
data["is_response"] = is_response
if _type == QuickReplyText._type:
if q.get("title") is not None:
data["title"] = q["title"]
rtn = QuickReplyText(**data)
elif _type == QuickReplyLocation._type:
rtn = QuickReplyLocation(**data)
elif _type == QuickReplyPhoneNumber._type:
rtn = QuickReplyPhoneNumber(**data)
elif _type == QuickReplyEmail._type:
rtn = QuickReplyEmail(**data)
return rtn
def graphql_to_message(message):
if message.get("message_sender") is None:
message["message_sender"] = {}
if message.get("message") is None:
message["message"] = {}
rtn = Message(
text=message.get("message").get("text"),
mentions=[
Mention(
m.get("entity", {}).get("id"),
offset=m.get("offset"),
length=m.get("length"),
)
for m in message.get("message").get("ranges", [])
],
emoji_size=get_emojisize_from_tags(message.get("tags_list")),
sticker=graphql_to_sticker(message.get("sticker")),
)
rtn.uid = str(message.get("message_id"))
rtn.author = str(message.get("message_sender").get("id"))
rtn.timestamp = message.get("timestamp_precise")
rtn.unsent = False
if message.get("unread") is not None:
rtn.is_read = not message["unread"]
rtn.reactions = {
str(r["user"]["id"]): enum_extend_if_invalid(MessageReaction, r["reaction"])
for r in message.get("message_reactions")
}
if message.get("blob_attachments") is not None:
rtn.attachments = [
graphql_to_attachment(attachment)
for attachment in message["blob_attachments"]
]
if message.get("platform_xmd_encoded"):
quick_replies = json.loads(message["platform_xmd_encoded"]).get("quick_replies")
if isinstance(quick_replies, list):
rtn.quick_replies = [graphql_to_quick_reply(q) for q in quick_replies]
elif isinstance(quick_replies, dict):
rtn.quick_replies = [
graphql_to_quick_reply(quick_replies, is_response=True)
]
if message.get("extensible_attachment") is not None:
attachment = graphql_to_extensible_attachment(message["extensible_attachment"])
if isinstance(attachment, UnsentMessage):
rtn.unsent = True
elif attachment:
rtn.attachments.append(attachment)
return rtn
def graphql_to_user(user):
if user.get("profile_picture") is None:
user["profile_picture"] = {}
c_info = get_customization_info(user)
plan = None
if user.get("event_reminders"):
plan = (
graphql_to_plan(user["event_reminders"]["nodes"][0])
if user["event_reminders"].get("nodes")
else None
)
return User(
user["id"],
url=user.get("url"),
first_name=user.get("first_name"),
last_name=user.get("last_name"),
is_friend=user.get("is_viewer_friend"),
gender=GENDERS.get(user.get("gender")),
affinity=user.get("affinity"),
nickname=c_info.get("nickname"),
color=c_info.get("color"),
emoji=c_info.get("emoji"),
own_nickname=c_info.get("own_nickname"),
photo=user["profile_picture"].get("uri"),
name=user.get("name"),
message_count=user.get("messages_count"),
plan=plan,
)
def graphql_to_thread(thread):
if thread["thread_type"] == "GROUP":
return graphql_to_group(thread)
elif thread["thread_type"] == "ONE_TO_ONE":
if thread.get("big_image_src") is None:
thread["big_image_src"] = {}
c_info = get_customization_info(thread)
participants = [
node["messaging_actor"] for node in thread["all_participants"]["nodes"]
]
user = next(
p for p in participants if p["id"] == thread["thread_key"]["other_user_id"]
)
last_message_timestamp = None
if "last_message" in thread:
last_message_timestamp = thread["last_message"]["nodes"][0][
"timestamp_precise"
]
first_name = user.get("short_name")
if first_name is None:
last_name = None
else:
last_name = user.get("name").split(first_name, 1).pop().strip()
plan = None
if thread.get("event_reminders"):
plan = (
graphql_to_plan(thread["event_reminders"]["nodes"][0])
if thread["event_reminders"].get("nodes")
else None
)
return User(
user["id"],
url=user.get("url"),
name=user.get("name"),
first_name=first_name,
last_name=last_name,
is_friend=user.get("is_viewer_friend"),
gender=GENDERS.get(user.get("gender")),
affinity=user.get("affinity"),
nickname=c_info.get("nickname"),
color=c_info.get("color"),
emoji=c_info.get("emoji"),
own_nickname=c_info.get("own_nickname"),
photo=user["big_image_src"].get("uri"),
message_count=thread.get("messages_count"),
last_message_timestamp=last_message_timestamp,
plan=plan,
)
else:
raise FBchatException(
"Unknown thread type: {}, with data: {}".format(
thread.get("thread_type"), thread
)
)
def graphql_to_group(group):
if group.get("image") is None:
group["image"] = {}
c_info = get_customization_info(group)
last_message_timestamp = None
if "last_message" in group:
last_message_timestamp = group["last_message"]["nodes"][0]["timestamp_precise"]
plan = None
if group.get("event_reminders"):
plan = (
graphql_to_plan(group["event_reminders"]["nodes"][0])
if group["event_reminders"].get("nodes")
else None
)
return Group(
group["thread_key"]["thread_fbid"],
participants=set(
[
node["messaging_actor"]["id"]
for node in group["all_participants"]["nodes"]
]
),
nicknames=c_info.get("nicknames"),
color=c_info.get("color"),
emoji=c_info.get("emoji"),
admins=set([node.get("id") for node in group.get("thread_admins")]),
approval_mode=bool(group.get("approval_mode"))
if group.get("approval_mode") is not None
else None,
approval_requests=set(
node["requester"]["id"] for node in group["group_approval_queue"]["nodes"]
)
if group.get("group_approval_queue")
else None,
join_link=group["joinable_mode"].get("link"),
photo=group["image"].get("uri"),
name=group.get("name"),
message_count=group.get("messages_count"),
last_message_timestamp=last_message_timestamp,
plan=plan,
)
def graphql_to_page(page):
if page.get("profile_picture") is None:
page["profile_picture"] = {}
if page.get("city") is None:
page["city"] = {}
plan = None
if page.get("event_reminders"):
plan = (
graphql_to_plan(page["event_reminders"]["nodes"][0])
if page["event_reminders"].get("nodes")
else None
)
return Page(
page["id"],
url=page.get("url"),
city=page.get("city").get("name"),
category=page.get("category_type"),
photo=page["profile_picture"].get("uri"),
name=page.get("name"),
message_count=page.get("messages_count"),
plan=plan,
)
def graphql_queries_to_json(*queries):
"""
Queries should be a list of GraphQL objects
"""
rtn = {}
for i, query in enumerate(queries):
rtn["q{}".format(i)] = query.value
return json.dumps(rtn)
def graphql_response_to_json(content):
content = strip_to_json(content) # Usually only needed in some error cases
try:
j = json.loads(content, cls=ConcatJSONDecoder)
except Exception:
raise FBchatException("Error while parsing JSON: {}".format(repr(content)))
rtn = [None] * (len(j))
for x in j:
if "error_results" in x:
del rtn[-1]
continue
check_json(x)
[(key, value)] = x.items()
check_json(value)
if "response" in value:
rtn[int(key[1:])] = value["response"]
else:
rtn[int(key[1:])] = value["data"]
log.debug(rtn)
return rtn
class GraphQL(object):
def __init__(self, query=None, doc_id=None, params=None):
if params is None:
params = {}
if query is not None:
self.value = {"priority": 0, "q": query, "query_params": params}
elif doc_id is not None:
self.value = {"doc_id": doc_id, "query_params": params}
else:
raise FBchatUserError("A query or doc_id must be specified")
FRAGMENT_USER = """
QueryFragment User: User {
id,
name,
first_name,
last_name,
profile_picture.width(<pic_size>).height(<pic_size>) {
uri
},
is_viewer_friend,
url,
gender,
viewer_affinity
}
"""
FRAGMENT_GROUP = """
QueryFragment Group: MessageThread {
name,
thread_key {
thread_fbid
},
image {
uri
},
is_group_thread,
all_participants {
nodes {
messaging_actor {
id
}
}
},
customization_info {
participant_customizations {
participant_id,
nickname
},
outgoing_bubble_color,
emoji
},
thread_admins {
id
},
group_approval_queue {
nodes {
requester {
id
}
}
},
approval_mode,
joinable_mode {
mode,
link
},
event_reminders {
nodes {
id,
lightweight_event_creator {
id
},
time,
location_name,
event_title,
event_reminder_members {
edges {
node {
id
},
guest_list_state
}
}
}
}
}
"""
FRAGMENT_PAGE = """
QueryFragment Page: Page {
id,
name,
profile_picture.width(32).height(32) {
uri
},
url,
category_type,
city {
name
}
}
"""
SEARCH_USER = (
"""
Query SearchUser(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(user).first(<limit>) as users {
nodes {
@User
}
}
}
}
"""
+ FRAGMENT_USER
)
SEARCH_GROUP = (
"""
Query SearchGroup(<search> = '', <limit> = 10, <pic_size> = 32) {
viewer() {
message_threads.with_thread_name(<search>).last(<limit>) as groups {
nodes {
@Group
}
}
}
}
"""
+ FRAGMENT_GROUP
)
SEARCH_PAGE = (
"""
Query SearchPage(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.of_type(page).first(<limit>) as pages {
nodes {
@Page
}
}
}
}
"""
+ FRAGMENT_PAGE
)
SEARCH_THREAD = (
"""
Query SearchThread(<search> = '', <limit> = 10) {
entities_named(<search>) {
search_results.first(<limit>) as threads {
nodes {
__typename,
@User,
@Group,
@Page
}
}
}
}
"""
+ FRAGMENT_USER
+ FRAGMENT_GROUP
+ FRAGMENT_PAGE
)
from ._graphql import (
FLAGS,
WHITESPACE,
ConcatJSONDecoder,
graphql_color_to_enum,
get_customization_info,
graphql_to_sticker,
graphql_to_attachment,
graphql_to_extensible_attachment,
graphql_to_subattachment,
graphql_to_live_location,
graphql_to_poll,
graphql_to_poll_option,
graphql_to_plan,
graphql_to_quick_reply,
graphql_to_message,
graphql_to_message_reply,
graphql_to_user,
graphql_to_thread,
graphql_to_group,
graphql_to_page,
graphql_queries_to_json,
graphql_response_to_json,
GraphQL,
)

View File

@@ -1,377 +1,36 @@
# -*- coding: UTF-8 -*-
"""This file is here to maintain backwards compatability."""
from __future__ import unicode_literals
import re
import json
from time import time
from random import random
from contextlib import contextmanager
from mimetypes import guess_type
from os.path import basename
import warnings
import logging
import requests
import aenum
from .models import *
try:
from urllib.parse import urlencode, parse_qs, urlparse
basestring = (str, bytes)
except ImportError:
from urllib import urlencode
from urlparse import parse_qs, urlparse
basestring = basestring
# Python 2's `input` executes the input, whereas `raw_input` just returns the input
try:
input = raw_input
except NameError:
pass
# Log settings
log = logging.getLogger("client")
log.setLevel(logging.DEBUG)
# Creates the console handler
handler = logging.StreamHandler()
log.addHandler(handler)
#: Default list of user agents
USER_AGENTS = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/601.1.10 (KHTML, like Gecko) Version/8.0.5 Safari/601.1.10",
"Mozilla/5.0 (Windows NT 6.3; WOW64; ; NCT50_AAP285C84A1328) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/22.0.1207.1 Safari/537.1",
"Mozilla/5.0 (X11; CrOS i686 2268.111.0) AppleWebKit/536.11 (KHTML, like Gecko) Chrome/20.0.1132.57 Safari/536.11",
"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.6 (KHTML, like Gecko) Chrome/20.0.1092.0 Safari/536.6",
]
LIKES = {
"large": EmojiSize.LARGE,
"medium": EmojiSize.MEDIUM,
"small": EmojiSize.SMALL,
"l": EmojiSize.LARGE,
"m": EmojiSize.MEDIUM,
"s": EmojiSize.SMALL,
}
GENDERS = {
# For standard requests
0: "unknown",
1: "female_singular",
2: "male_singular",
3: "female_singular_guess",
4: "male_singular_guess",
5: "mixed",
6: "neuter_singular",
7: "unknown_singular",
8: "female_plural",
9: "male_plural",
10: "neuter_plural",
11: "unknown_plural",
# For graphql requests
"UNKNOWN": "unknown",
"FEMALE": "female_singular",
"MALE": "male_singular",
# '': 'female_singular_guess',
# '': 'male_singular_guess',
# '': 'mixed',
"NEUTER": "neuter_singular",
# '': 'unknown_singular',
# '': 'female_plural',
# '': 'male_plural',
# '': 'neuter_plural',
# '': 'unknown_plural',
}
class ReqUrl(object):
"""A class containing all urls used by `fbchat`"""
SEARCH = "https://www.facebook.com/ajax/typeahead/search.php"
LOGIN = "https://m.facebook.com/login.php?login_attempt=1"
SEND = "https://www.facebook.com/messaging/send/"
UNREAD_THREADS = "https://www.facebook.com/ajax/mercury/unread_threads.php"
UNSEEN_THREADS = "https://www.facebook.com/mercury/unseen_thread_ids/"
THREADS = "https://www.facebook.com/ajax/mercury/threadlist_info.php"
MOVE_THREAD = "https://www.facebook.com/ajax/mercury/move_thread.php"
ARCHIVED_STATUS = (
"https://www.facebook.com/ajax/mercury/change_archived_status.php?dpr=1"
)
PINNED_STATUS = (
"https://www.facebook.com/ajax/mercury/change_pinned_status.php?dpr=1"
)
MESSAGES = "https://www.facebook.com/ajax/mercury/thread_info.php"
READ_STATUS = "https://www.facebook.com/ajax/mercury/change_read_status.php"
DELIVERED = "https://www.facebook.com/ajax/mercury/delivery_receipts.php"
MARK_SEEN = "https://www.facebook.com/ajax/mercury/mark_seen.php"
BASE = "https://www.facebook.com"
MOBILE = "https://m.facebook.com/"
STICKY = "https://0-edge-chat.facebook.com/pull"
PING = "https://0-edge-chat.facebook.com/active_ping"
UPLOAD = "https://upload.facebook.com/ajax/mercury/upload.php"
INFO = "https://www.facebook.com/chat/user_info/"
CONNECT = "https://www.facebook.com/ajax/add_friend/action.php?dpr=1"
REMOVE_USER = "https://www.facebook.com/chat/remove_participants/"
LOGOUT = "https://www.facebook.com/logout.php"
ALL_USERS = "https://www.facebook.com/chat/user_info_all"
SAVE_DEVICE = "https://m.facebook.com/login/save-device/cancel/"
CHECKPOINT = "https://m.facebook.com/login/checkpoint/"
THREAD_COLOR = "https://www.facebook.com/messaging/save_thread_color/?source=thread_settings&dpr=1"
THREAD_NICKNAME = "https://www.facebook.com/messaging/save_thread_nickname/?source=thread_settings&dpr=1"
THREAD_EMOJI = "https://www.facebook.com/messaging/save_thread_emoji/?source=thread_settings&dpr=1"
THREAD_IMAGE = "https://www.facebook.com/messaging/set_thread_image/?dpr=1"
THREAD_NAME = "https://www.facebook.com/messaging/set_thread_name/?dpr=1"
MESSAGE_REACTION = "https://www.facebook.com/webgraphql/mutation"
TYPING = "https://www.facebook.com/ajax/messaging/typ.php"
GRAPHQL = "https://www.facebook.com/api/graphqlbatch/"
ATTACHMENT_PHOTO = "https://www.facebook.com/mercury/attachments/photo/"
PLAN_CREATE = "https://www.facebook.com/ajax/eventreminder/create"
PLAN_INFO = "https://www.facebook.com/ajax/eventreminder"
PLAN_CHANGE = "https://www.facebook.com/ajax/eventreminder/submit"
PLAN_PARTICIPATION = "https://www.facebook.com/ajax/eventreminder/rsvp"
MODERN_SETTINGS_MENU = "https://www.facebook.com/bluebar/modern_settings_menu/"
REMOVE_FRIEND = "https://m.facebook.com/a/removefriend.php"
BLOCK_USER = "https://www.facebook.com/messaging/block_messages/?dpr=1"
UNBLOCK_USER = "https://www.facebook.com/messaging/unblock_messages/?dpr=1"
SAVE_ADMINS = "https://www.facebook.com/messaging/save_admins/?dpr=1"
APPROVAL_MODE = "https://www.facebook.com/messaging/set_approval_mode/?dpr=1"
CREATE_GROUP = "https://m.facebook.com/messages/send/?icm=1"
DELETE_THREAD = "https://www.facebook.com/ajax/mercury/delete_thread.php?dpr=1"
DELETE_MESSAGES = "https://www.facebook.com/ajax/mercury/delete_messages.php?dpr=1"
MUTE_THREAD = "https://www.facebook.com/ajax/mercury/change_mute_thread.php?dpr=1"
MUTE_REACTIONS = (
"https://www.facebook.com/ajax/mercury/change_reactions_mute_thread/?dpr=1"
)
MUTE_MENTIONS = (
"https://www.facebook.com/ajax/mercury/change_mentions_mute_thread/?dpr=1"
)
CREATE_POLL = "https://www.facebook.com/messaging/group_polling/create_poll/?dpr=1"
UPDATE_VOTE = "https://www.facebook.com/messaging/group_polling/update_vote/?dpr=1"
GET_POLL_OPTIONS = "https://www.facebook.com/ajax/mercury/get_poll_options"
SEARCH_MESSAGES = "https://www.facebook.com/ajax/mercury/search_snippets.php?dpr=1"
MARK_SPAM = "https://www.facebook.com/ajax/mercury/mark_spam.php?dpr=1"
UNSEND = "https://www.facebook.com/messaging/unsend_message/?dpr=1"
pull_channel = 0
def change_pull_channel(self, channel=None):
if channel is None:
self.pull_channel = (self.pull_channel + 1) % 5 # Pull channel will be 0-4
else:
self.pull_channel = channel
self.STICKY = "https://{}-edge-chat.facebook.com/pull".format(self.pull_channel)
self.PING = "https://{}-edge-chat.facebook.com/active_ping".format(
self.pull_channel
)
facebookEncoding = "UTF-8"
def now():
return int(time() * 1000)
def strip_to_json(text):
try:
return text[text.index("{") :]
except ValueError:
raise FBchatException("No JSON object found: {!r}".format(text))
def get_decoded_r(r):
return get_decoded(r._content)
def get_decoded(content):
return content.decode(facebookEncoding)
def parse_json(content):
return json.loads(content)
def get_json(r):
return json.loads(strip_to_json(get_decoded_r(r)))
def digitToChar(digit):
if digit < 10:
return str(digit)
return chr(ord("a") + digit - 10)
def str_base(number, base):
if number < 0:
return "-" + str_base(-number, base)
(d, m) = divmod(number, base)
if d > 0:
return str_base(d, base) + digitToChar(m)
return digitToChar(m)
def generateMessageID(client_id=None):
k = now()
l = int(random() * 4294967295)
return "<{}:{}-{}@mail.projektitan.com>".format(k, l, client_id)
def getSignatureID():
return hex(int(random() * 2147483648))
def generateOfflineThreadingID():
ret = now()
value = int(random() * 4294967295)
string = ("0000000000000000000000" + format(value, "b"))[-22:]
msgs = format(ret, "b") + string
return str(int(msgs, 2))
def check_json(j):
if j.get("error") is None:
return
if "errorDescription" in j:
# 'errorDescription' is in the users own language!
raise FBchatFacebookError(
"Error #{} when sending request: {}".format(
j["error"], j["errorDescription"]
),
fb_error_code=j["error"],
fb_error_message=j["errorDescription"],
)
elif "debug_info" in j["error"] and "code" in j["error"]:
raise FBchatFacebookError(
"Error #{} when sending request: {}".format(
j["error"]["code"], repr(j["error"]["debug_info"])
),
fb_error_code=j["error"]["code"],
fb_error_message=j["error"]["debug_info"],
)
else:
raise FBchatFacebookError(
"Error {} when sending request".format(j["error"]), fb_error_code=j["error"]
)
def check_request(r, as_json=True):
if not r.ok:
raise FBchatFacebookError(
"Error when sending request: Got {} response".format(r.status_code),
request_status_code=r.status_code,
)
content = get_decoded_r(r)
if content is None or len(content) == 0:
raise FBchatFacebookError("Error when sending request: Got empty response")
if as_json:
content = strip_to_json(content)
try:
j = json.loads(content)
except ValueError:
raise FBchatFacebookError("Error while parsing JSON: {!r}".format(content))
check_json(j)
log.debug(j)
return j
else:
return content
def get_jsmods_require(j, index):
if j.get("jsmods") and j["jsmods"].get("require"):
try:
return j["jsmods"]["require"][0][index][0]
except (KeyError, IndexError) as e:
log.warning(
"Error when getting jsmods_require: {}. Facebook might have changed protocol".format(
j
)
)
return None
def get_emojisize_from_tags(tags):
if tags is None:
return None
tmp = [tag for tag in tags if tag.startswith("hot_emoji_size:")]
if len(tmp) > 0:
try:
return LIKES[tmp[0].split(":")[1]]
except (KeyError, IndexError):
log.exception(
"Could not determine emoji size from {} - {}".format(tags, tmp)
)
return None
def require_list(list_):
if isinstance(list_, list):
return set(list_)
else:
return set([list_])
def mimetype_to_key(mimetype):
if not mimetype:
return "file_id"
if mimetype == "image/gif":
return "gif_id"
x = mimetype.split("/")
if x[0] in ["video", "image", "audio"]:
return "%s_id" % x[0]
return "file_id"
def get_files_from_urls(file_urls):
files = []
for file_url in file_urls:
r = requests.get(file_url)
# We could possibly use r.headers.get('Content-Disposition'), see
# https://stackoverflow.com/a/37060758
files.append(
(
basename(file_url),
r.content,
r.headers.get("Content-Type") or guess_type(file_url)[0],
)
)
return files
@contextmanager
def get_files_from_paths(filenames):
files = []
for filename in filenames:
files.append(
(basename(filename), open(filename, "rb"), guess_type(filename)[0])
)
yield files
for fn, fp, ft in files:
fp.close()
def enum_extend_if_invalid(enumeration, value):
try:
return enumeration(value)
except ValueError:
log.warning(
"Failed parsing {.__name__}({!r}). Extending enum.".format(
enumeration, value
)
)
aenum.extend_enum(enumeration, "UNKNOWN_{}".format(value).upper(), value)
return enumeration(value)
def get_url_parameters(url, *args):
params = parse_qs(urlparse(url).query)
return [params[arg][0] for arg in args if params.get(arg)]
def get_url_parameter(url, param):
return get_url_parameters(url, param)[0]
from ._util import (
log,
handler,
USER_AGENTS,
LIKES,
GENDERS,
ReqUrl,
facebookEncoding,
now,
strip_to_json,
get_decoded_r,
get_decoded,
parse_json,
get_json,
digitToChar,
str_base,
generateMessageID,
getSignatureID,
generateOfflineThreadingID,
check_json,
check_request,
get_jsmods_require,
get_emojisize_from_tags,
require_list,
mimetype_to_key,
get_files_from_urls,
get_files_from_paths,
enum_extend_if_invalid,
get_url_parameters,
get_url_parameter,
)