aboutsummaryrefslogblamecommitdiff
path: root/external/messenger.py
blob: 684f7a0b3dfdb9361f12b416963060d20e271a62 (plain) (tree)
1
2
3
4
5
6
7
8
9



                      

                

             
           

                                              

              
 
             
                           




























                                                   





                         
 





                                                         





                                                          
 


















                                                                    
 
                                                             
 


                                                                      


                            
                                        

                  

                                                      

                                                                       
 



                                                                 
 


                            
                                                                      
                            




                                                       
                       
                         
                         

                                   
                                      

                              
                     
                                                        
                                            
             
                             
 

                                           
                                           





                                                                  
                        
                                           
                                             



                                  






                                                      






                                        




                                     
 


                                

                            

                                


                                           
                                                                    



                             









                                                                          
                                                                                
















                                                                
                                                                          
                                                                                                             

                

                                            
                         
                                                                                                      
                   
                                  
 

                                                                               
                                                                                                
 
                                            
                                                                             













                                                                              
                                                  
                                          
 




                                                                           
                                                   
 
                         
                        
 
                            

                                                           
 












                                                                       



                                                              








                                                                                     




                                                   



                                                                                                                                             

                                                            
                                                                            



                                                                                                                                  







                                                                           


                                                                   



                                   





                                                                



                                               


                                

                                                                    
 



                                                 

                                                  
                                                                      
                                              

                                          
                                                                          













                                                                    
                                              

                                         

























                                                                                                    
                                                                              

                                                                 




























                                                                                                        

                                           
                                             
                        






                                       


                                              
 
                                                                          

                                       



                                                    

                                                                                       


                                                             
                                         


                                            
                                 
             


                                 










                                                                      







                                                 
                                             

                                            



                                                                        

                                           

                                                

                                                                


                                                                    

                                                                   



                                                                            

                                                                      


                                         
                                  















                                                        

                                                                                           
                                       
                                           
                        



                                            
 



                              
#!/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 SyncerThread(threading.Thread):
    def __init__(self, client, bridge, thread_queue, *args, **kwargs):
        super(SyncerThread, self).__init__(*args, **kwargs)

        self.client = client
        self.bridge = bridge
        self.thread_queue = thread_queue

    def run(self):
        while True:
            thread = self.thread_queue.get(block=True)
            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)

            self.sync_thread_queue = queue.Queue(100)
            SyncerThread(self.client, self, self.sync_thread_queue).start()
            for thread in threads:
                self.sync_thread_queue.put(thread)

            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.sync_thread_queue.put(thread)

    def setup_joined_thread(self, thread):
        sys.stderr.write("(python) setup_joined_thread {}".format(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()