easybridge/external/messenger.py

527 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
import sys
import json
import signal
import threading
import queue
import pickle
import time
import traceback
from urllib.parse import unquote as UrlUnquote
import hashlib
import fbchat
from fbchat.models import *
# ---- MESSAGE TYPES ----
# ezbr -> external
CONFIGURE = "configure"
GET_USER = "get_user"
SET_USER_INFO = "set_user_info"
SET_ROOM_INFO = "set_room_info"
JOIN = "join"
INVITE = "invite"
LEAVE = "leave"
SEND = "send"
CLOSE = "close"
# external -> ezbr
JOINED = "joined"
LEFT = "left"
USER_INFO_UPDATED = "user_info_updated"
ROOM_INFO_UPDATED = "room_info_updated"
EVENT = "event"
CACHE_PUT = "cache_put"
CACHE_GET = "cache_get"
# reply messages
# ezbr -> external: all must wait for a reply!
# external -> ezbr: only CACHE_GET produces a reply
REP_OK = "rep_ok"
REP_ERROR = "rep_error"
# Event types
EVENT_JOIN = "join"
EVENT_LEAVE = "leave"
EVENT_MESSAGE = "message"
EVENT_ACTION = "action"
def mediaObjectOfURL(url):
return {
"filename": url.split("?")[0].split("/")[-1],
"url": url,
}
def stripFbLinkPrefix(url):
PREFIX = "https://l.facebook.com/l.php?u="
if url[:len(PREFIX)] == PREFIX:
return UrlUnquote(url[len(PREFIX):].split('&')[0])
else:
return url
# ---- MESSENGER CLIENT CLASS THAT HANDLES EVENTS ----
class MessengerBridgeClient(fbchat.Client):
def __init__(self, *args, **kwargs):
super(MessengerBridgeClient, self).__init__(*args, **kwargs)
self.bridge = None
def setBridge(self, bridge):
self.bridge = bridge
## Redirect all interesting events to Bridge
def onMessage(self, *args, **kwargs):
self.bridge.onMessage(*args, **kwargs)
def onPeopleAdded(self, *args, **kwargs):
self.bridge.onPeopleAdded(*args, **kwargs)
def onPersonRemoved(self, *args, **kwargs):
self.bridge.onPersonRemoved(*args, **kwargs)
def onTitleChange(self, *args, **kwargs):
self.bridge.onTitleChange(*args, **kwargs)
# ---- SEPARATE THREADS FOR INITIAL SYNC & CLIENT LISTEN ----
class InitialSyncThread(threading.Thread):
def __init__(self, client, bridge, threads, *args, **kwargs):
super(InitialSyncThread, self).__init__(*args, **kwargs)
self.client = client
self.bridge = bridge
self.threads = threads
def run(self):
sys.stderr.write("(python) fb thread list: {}\n".format(self.threads))
for thread in self.threads:
sys.stderr.write("(python) fb thread: {}\n".format(thread))
self.bridge.setup_joined_thread(thread)
class ClientListenThread(threading.Thread):
def __init__(self, client, *args, **kwargs):
super(ClientListenThread, self).__init__(*args, **kwargs)
self.client = client
def run(self):
sys.stderr.write("(python messenger) Start client.listen()\n")
self.client.listen()
# ---- MAIN LOOP THAT HANDLES REQUESTS FROM BRIDGE ----
class MessengerBridge:
def __init__(self):
self.rev_uid = {}
self.uid_map = {}
self.others_joined_map = {}
self.my_joined_rooms = {}
self.init_backlog_length = 100
def getUserId(self, user):
retval = None
if user.url is not None and not "?" in user.url:
retval = user.url.split("/")[-1]
else:
retval = user.uid
if user.uid not in self.uid_map:
self.uid_map[user.uid] = retval
self.rev_uid[retval] = user.uid
user_info = {
"display_name": user.name,
}
if user.photo is not None:
user_info["avatar"] = mediaObjectOfURL(user.photo)
self.write({
"_type": USER_INFO_UPDATED,
"user": self.getUserId(user),
"data": user_info,
})
return retval
def getUserIdFromUid(self, uid):
if uid in self.uid_map:
return self.uid_map[uid]
else:
user = self.client.fetchUserInfo(uid)[uid]
return self.getUserId(user)
def revUserId(self, user_id):
if user_id in self.rev_uid:
return self.rev_uid[user_id]
else:
return user_id
def getUserShortName(self, user):
if user.first_name != None:
return user.first_name
else:
return user.name
def run(self):
self.client = None
self.keep_running = True
self.cache_gets = {}
self.num = 0
while self.keep_running:
try:
line = sys.stdin.readline()
except KeyboardInterrupt:
sys.stderr.write("(python messenger) shutting down")
self.close()
time.sleep(5)
sys.exit(0)
sys.stderr.write("(python) reading {}\n".format(line.strip()))
cmd = json.loads(line)
try:
rep = self.handle_cmd(cmd)
if rep is None:
rep = {}
if "_type" not in rep:
rep["_type"] = REP_OK
except Exception as e:
sys.stderr.write("(python) {}\n".format(traceback.format_exc()))
rep = {
"_type": REP_ERROR,
"error": "{}".format(e)
}
rep["_id"] = cmd["_id"]
self.write(rep)
def write(self, msg):
msgstr = json.dumps(msg)
sys.stderr.write("(python) writing {}\n".format(msgstr))
sys.stdout.write(msgstr + "\n")
sys.stdout.flush()
def handle_cmd(self, cmd):
ty = cmd["_type"]
if ty == CONFIGURE:
self.init_backlog_length = int(cmd["data"]["initial_backlog"])
client_file = "/tmp/fbclient_" + hashlib.sha224(cmd["data"]["email"].encode("utf-8")).hexdigest()
try:
f = open(client_file, "rb")
self.client = pickle.load(f)
f.close()
sys.stderr.write("(python messenger) using previous client: {}\n".format(client_file))
except:
self.client = None
if self.client is None:
email, password = cmd["data"]["email"], cmd["data"]["password"]
self.client = MessengerBridgeClient(email=email, password=password, max_tries=1)
if not self.client.isLoggedIn():
return {"_type": "rep_error", "error": "Unable to login (?)"}
try:
f = open(client_file, "wb")
pickle.dump(self.client, f)
f.close()
except:
pass
self.client.setBridge(self)
threads = self.client.fetchThreadList()
# ensure we have a correct mapping for bridged user IDs to fb uids
# (this should be fast)
for thread in threads:
if thread.type == ThreadType.USER:
self.getUserId(thread)
InitialSyncThread(self.client, self, threads).start()
ClientListenThread(self.client).start()
elif ty == CLOSE:
self.close()
elif ty == GET_USER:
userId = self.getUserIdFromUid(self.client.uid)
return {"_type": REP_OK, "user": userId}
elif ty == JOIN:
self.ensure_i_joined(cmd["room"])
elif ty == LEAVE:
thread_id = cmd["room"]
self.client.removeUserFromGroup(self.client.uid, thread_id)
if thread_id in self.my_joined_rooms:
del self.my_joined_rooms[thread_id]
elif ty == INVITE:
if cmd["room"] != "":
uid = self.revUserId(cmd["user"])
self.client.addUsersToGroup([uid], cmd["room"])
elif ty == SEND:
event = cmd["data"]
if event["type"] in [EVENT_MESSAGE, EVENT_ACTION]:
attachments = []
if "attachments" in event and isinstance(event["attachments"], list):
for at in event["attachments"]:
if "url" in at:
attachments.append(at["url"])
else:
# TODO
sys.stdout.write("Unhandled: attachment without URL")
msg = Message(event["text"])
if event["type"] == EVENT_ACTION:
msg.text = "* " + event["text"]
if event["room"] != "":
if len(attachments) > 0:
msg_id = self.client.sendRemoteFiles(attachments, message=msg, thread_id=event["room"], thread_type=ThreadType.GROUP)
else:
msg_id = self.client.send(msg, thread_id=event["room"], thread_type=ThreadType.GROUP)
elif event["recipient"] != "":
uid = self.revUserId(event["recipient"])
sys.stderr.write("(python) Sending to {}\n".format(uid))
if len(attachments) > 0:
msg_id = self.client.sendRemoteFiles(attachments, message=msg, thread_id=uid, thread_type=ThreadType.USER)
else:
msg_id = self.client.send(msg, thread_id=uid, thread_type=ThreadType.USER)
else:
return {"_type": REP_ERROR, "error": "Invalid message"}
return {"_type": REP_OK, "event_id": msg_id}
elif ty == REP_OK and cmd["_id"] in self.cache_gets:
self.cache_gets[cmd["_id"]].put(cmd["value"])
else:
return {"_type": REP_ERROR, "error": "Not implemented"}
def close(self):
self.keep_running = False
self.client.stopListening()
def cache_get(self, key):
self.num += 1
num = self.num
q = queue.Queue(1)
self.cache_gets[num] = q
self.write({"_type": CACHE_GET, "_id": num, "key": key})
try:
rep = q.get(block=True, timeout=30)
except queue.Empty:
rep = ""
del self.cache_gets[num]
return rep
def cache_put(self, key, value):
self.write({"_type": CACHE_PUT, "key": key, "value": value})
# ---- Info sync ----
def ensure_i_joined(self, thread_id):
if thread_id not in self.my_joined_rooms:
self.my_joined_rooms[thread_id] = True
thread = self.client.fetchThreadInfo(thread_id)[thread_id]
self.setup_joined_thread(thread)
def setup_joined_thread(self, thread):
if thread.type == ThreadType.GROUP:
members = self.client.fetchAllUsersFromThreads([thread])
self.write({
"_type": JOINED,
"room": thread.uid,
})
self.send_room_info(thread, members)
self.send_room_members(thread, members)
self.backlog_room(thread)
def send_room_info(self, thread, members):
members.sort(key=lambda m: m.uid)
room_info = {}
if thread.name is not None:
room_info["name"] = thread.name
else:
who = [m for m in members if m.uid != self.client.uid]
if len(who) > 3:
room_info["name"] = ", ".join([self.getUserShortName(m) for m in who[:3]] + ["..."])
else:
room_info["name"] = ", ".join([self.getUserShortName(m) for m in who])
if thread.photo is not None:
room_info["picture"] = mediaObjectOfURL(thread.photo)
else:
for m in members:
if m.uid != self.client.uid and m.photo is not None:
room_info["picture"] = mediaObjectOfURL(m.photo)
break
self.write({
"_type": ROOM_INFO_UPDATED,
"room": thread.uid,
"data": room_info,
})
def send_room_members(self, thread, members):
for member in members:
sys.stderr.write("(python) fb thread member: {}\n".format(member))
self.ensureJoined(self.getUserId(member), thread.uid)
def backlog_room(self, thread):
prev_last_seen = self.cache_get("last_seen_%s"%thread.uid)
if prev_last_seen == "":
prev_last_seen = None
messages = []
found = False
while not found:
before = None
if len(messages) > 0:
before = messages[-1].timestamp
page = self.client.fetchThreadMessages(thread.uid, before=before, limit=20)
for m in page:
if m.uid == prev_last_seen or len(messages) > self.init_backlog_length:
found = True
break
else:
messages.append(m)
for m in reversed(messages):
if m.text is None:
m.text = ""
m.text = "[{}] {}".format(
time.strftime("%Y-%m-%d %H:%M %Z", time.localtime(float(m.timestamp)/1000)).strip(),
m.text)
self.onMessage(thread_id=thread.uid,
thread_type=thread.type,
message_object=m)
def ensureJoined(self, userId, room):
key = "{}--{}".format(userId, room)
if not key in self.others_joined_map:
self.write({
"_type": EVENT,
"data": {
"type": EVENT_JOIN,
"author": userId,
"room": room,
}
})
self.others_joined_map[key] = True
# ---- Event handlers ----
def onMessage(self, thread_id, thread_type, message_object, **kwargs):
self.ensure_i_joined(thread_id)
if message_object.author == self.client.uid:
# Ignore our own messages
return
sys.stderr.write("(python messenger) Got message: {}\n".format(message_object))
author = self.getUserIdFromUid(message_object.author)
event = {
"id": message_object.uid,
"type": EVENT_MESSAGE,
"author": author,
"text": message_object.text,
"attachments": []
}
if event["text"] is None:
event["text"] = ""
for at in message_object.attachments:
if isinstance(at, ImageAttachment):
full_url = self.client.fetchImageUrl(at.uid)
event["attachments"].append({
"filename": full_url.split("?")[0].split("/")[-1],
"url": full_url,
"image_size": {
"width": at.width,
"height": at.height,
},
})
elif isinstance(at, FileAttachment):
url = stripFbLinkPrefix(at.url)
event["attachments"].append({
"filename": at.name,
"url": url,
})
elif isinstance(at, AudioAttachment):
url = stripFbLinkPrefix(at.url)
event["attachments"].append({
"filename": at.filename,
"url": url,
})
else:
event["text"] += "\nUnhandled attachment: {}".format(at)
if thread_type == ThreadType.GROUP:
event["room"] = thread_id
self.ensureJoined(author, thread_id)
if event["text"] != "" or len(event["attachments"]) > 0:
self.write({"_type": EVENT, "data": event})
self.cache_put("last_seen_%s"%thread_id, message_object.uid)
def onPeopleAdded(self, added_ids, thread_id, *args, **kwargs):
for user_id in added_ids:
if user_id == self.client.uid:
self.ensure_i_joined(thread_id)
else:
self.ensureJoined(self.getUserIdFromUid(user_id), thread_id)
def onPersonRemoved(self, removed_id, thread_id, *args, **kwargs):
if removed_id == self.client.uid:
self.write({
"_type": LEFT,
"room": thread_id,
})
if thread_id in self.my_joined_rooms:
del self.my_joined_rooms[thread_id]
else:
userId = self.getUserIdFromUid(removed_id),
self.write({
"_type": EVENT,
"data": {
"type": EVENT_JOIN,
"author": userId,
"room": thread_id,
}
})
map_key = "{}--{}".format(userId, thread_id)
if map_key in self.others_joined_map:
del self.others_joined_map[map_key]
def onTitleChange(self, author_id, new_title, thread_id, thread_type, *args, **kwargs):
self.ensure_i_joined(thread_id)
if thread_type == ThreadType.GROUP:
self.write({
"_type": ROOM_INFO_UPDATED,
"room": thread_id,
"data": {"name": new_title},
})
if __name__ == "__main__":
bridge = MessengerBridge()
bridge.run()