mrDoctorWho/vk4xmpp

View on GitHub
extensions/groupchats.py

Summary

Maintainability
D
2 days
Test Coverage
# coding: utf-8
# This file is a part of VK4XMPP transport
# © simpleApps, 2013 — 2018.

# Installation:
# This extension requires 2 fields in the main config:
# 1. ConferenceServer - the address of your (or not yours?) conference server
# Bear in mind that there can be limits on the jabber server for conference per jid. Read the wiki for more details.
# 2. CHAT_LIFETIME_LIMIT - the limit of the time after that user considered inactive and will be removed. 
# Time must be formatted as text and contain the time variable measurement.
# For example: CHAT_LIFETIME_LIMIT = "28y09M21d" means chat will be removed after 28 years 9 Months 21 days from now
# You can wheter ignore or use any of these chars: smdMy.
# Used chars: s for seconds, m for minutes, d for days, M for months, y for years. The number MUST contain 2 digits as well.
# Note: if you won't set the field, plugin won't remove any chat, but still will be gathering statistics.


"""
Handles VK Multi-Dialogs
Implements XEP-0045: Multi-User Chat (over an exsisting chat)
Note: This file contains only outgoing-specific stuff (vk->xmpp)
along with the Chat class and other useful functions
The code which handles incoming stuff (xmpp->vk) is placed in the following modules:
mod_groupchat_prs for presence handling
mod_groupchat_msg for message handling
"""

MAX_UPDATE_DELAY = 3600  # 1 hour
CHAT_CLEANUP_DELAY = 86400  # 24 hours

MIN_CHAT_ID = 2000000000
OWNER_FALLBACK = 210700286


if not require("attachments") or not require("forwarded_messages"):
    raise RuntimeError("extension 'groupchats' requires 'forwarded_messages' and 'attachments'")

try:
    import mod_xhtml
except ImportError:
    mod_xhtml = None


def setAffiliation(chat, afl, jid, jidFrom=TransportID, reason=None):
    """
    Set user affiliation in a chat.
    Parameters:
        * chat - the chat to set affiliation in
        * afl - the affiliation to set to
        * jid - the user's jid whose affiliation needs to be changed
        * jidFrom - the chat's owner jid (or anyone who can set users roles)
        * reason - special reason
    """
    stanza = xmpp.Iq("set", to=chat, frm=jidFrom)
    query = xmpp.Node("query", {"xmlns": xmpp.NS_MUC_ADMIN})
    arole = query.addChild("item", {"jid": jid, "affiliation": afl})
    if reason:
        arole.setTagData("reason", reason)
    stanza.addChild(node=query)
    sender(Component, stanza)


def inviteUser(chat, jidTo, jidFrom, name):
    """
    Invite user to a chat.
    Parameters:
        * chat - the chat to invite to
        * jidTo - the user's jid who needs to be invited
        * jidFrom - the inviter's jid
        * name - the inviter's name
    """
    invite = xmpp.Message(to=chat, frm=jidFrom)
    x = xmpp.Node("x", {"xmlns": xmpp.NS_MUC_USER})
    inv = x.addChild("invite", {"to": jidTo})
    inv.setTagData("reason", _("You're invited by user «%s»") % name)
    invite.addChild(node=x)
    sender(Component, invite)


def joinChat(chat, name, jidFrom, status=None):
    """
    Join a chat.
    Parameters:
        * chat - the chat to join in
        * name - nickname
        * jidFrom - jid which will be displayed when joined
        * status - special status
    """
    prs = xmpp.Presence("%s/%s" % (chat, name), frm=jidFrom, status=status)
    prs.setTag("c", {"node": TRANSPORT_CAPS_HASH, "ver": hash, "hash": "sha-1"},
        xmpp.NS_CAPS)
    prs.setTag("x", namespace=xmpp.NS_MUC)
    sender(Component, prs)


def leaveChat(chat, jidFrom, reason=None):
    """
    Leave chat.
    Parameters:
        * chat - chat to leave from
        * jidFrom - jid to leave with
        * reason - special reason
    """
    prs = xmpp.Presence(chat, "unavailable", frm=jidFrom, status=reason)
    sender(Component, prs)


def chatMessage(chat, text, jidFrom, subj=None, timestamp=0):
    """
    Sends a message to the chat
    """
    message = xmpp.Message(chat, typ="groupchat")
    if timestamp:
        timestamp = time.gmtime(timestamp)
        message.setTimestamp(time.strftime("%Y%m%dT%H:%M:%S", timestamp))
    if not subj:
        message.setBody(text)
    else:
        message.setSubject(text)
    message.setFrom(jidFrom)
    executeHandlers("msg03g", (message, chat, jidFrom))
    sender(Component, message)


def setChatConfig(chat, jidFrom, exterminate=False, cb=None, args={}):
    """
    Sets the chat config
    """
    iq = xmpp.Iq("set", to=chat, frm=jidFrom)
    query = iq.addChild("query", namespace=xmpp.NS_MUC_OWNER)
    if exterminate:
        query.addChild("destroy")
    else:
        form = utils.buildDataForm(fields=[
            {"var": "FORM_TYPE", "type": "hidden", "value": xmpp.NS_MUC_ROOMCONFIG},
            {"var": "muc#roomconfig_membersonly", "type": "boolean", "value": "1"},
            {"var": "muc#roomconfig_publicroom", "type": "boolean", "value": "0"},
            {"var": "muc#roomconfig_persistentroom", "type": "boolean", "value": "1"},
            {"var": "muc#roomconfig_whois", "value": "anyone"}],
            type="submit")
        query.addChild(node=form)
    sender(Component, iq, cb, args)


def handleOutgoingChatMessage(user, vkChat):
    """
    Handles outging VK messages and sends them to XMPP
    """
    # peer_id for newer APIs
    chatID = vkChat.get("peer_id", 0) - MIN_CHAT_ID

    if chatID > 0:
        # check if the groupchats support enabled in user's settings
        if not user.settings.groupchats:
            return (MSG_SKIP, "")

        if not hasattr(user, "chats"):
            user.chats = {}

        chatJID = "%s_chat#%s@%s" % (user.vk.userID, chatID, ConferenceServer)
        chat = createChat(user, chatJID)
        if not chat.initialized:
            contents = Chat.getVKChat(user, chatID)
            if contents:
                owner = contents.get("admin_id", OWNER_FALLBACK)
                title = contents.get("title")
                users = contents.get("users")
                chat.init(owner, chatID, chatJID, title, time.time(), users)
        if not chat.created:
            if chat.creation_failed:
                return (MSG_SKIP, "")
            # we can add user, vkChat to the create() method to prevent losing or messing up the messages
            chat.create(user)
        # read the comments above the handleMessage function
        if not chat.created:
            time.sleep(1.5)
        chat.handleMessage(user, vkChat)
        return (MSG_SKIP, "")
    return (MSG_APPEND, "")


def createChat(user, source):
    """
    Creates a chat
    Args:
        user: the User object
        source: the chat's jid
    """
    if not hasattr(user, "chats"):
        user.chats = {}
    if source in user.chats:
        chat = user.chats[source]
    else:
        user.chats[source] = chat = Chat()
    return chat


class Chat(object):
    """
    Class used to handle multi-user dialogs
    """
    def __init__(self):
        self.created = False
        self.invited = False
        self.initialized = False
        self.exists = False
        self.creation_failed = False
        self.owner_nickname = None
        self.source = None
        self.jid = None
        self.owner = None
        self.subject = None
        self.creation_date = None
        self.id = 0
        self.last_update = 0
        self.raw_users = {}
        self.users = {}

    def init(self, owner, id, jid, subject, date, users=[]):
        """
        Assigns an id and other needed attributes to the class object
        Args:
            owner: owner's id (str)
            id: chat's id (int)
            jid: chat's jid (str)
            subject: chat's subject
            date: the chat creation date
            users: dictionary of ids, id: {"name": nickname, "jid": jid}
        """
        self.id = id
        self.jid = jid
        self.owner = owner
        self.raw_users = users
        self.subject = subject
        self.creation_date = date
        self.initialized = True

    def create(self, user):
        """
        Creates a chat, joins it and sets the config
        """
        logger.debug("groupchats: creating %s. Users: %s; owner: %s (jid: %s)",
            self.jid, self.raw_users, self.owner, user.source)
        exists = runDatabaseQuery("select user from groupchats where jid=?", (self.jid,), many=True)
        if exists:
            self.exists = True
            logger.debug("groupchats: groupchat %s exists in the database (jid: %s)",
                self.jid, user.source)
        else:
            logger.debug("groupchats: groupchat %s will be added to the database (jid: %s)",
                self.jid, user.source)
            runDatabaseQuery("insert into groupchats (jid, owner, user, last_used) values (?,?,?,?)",
                (self.jid, TransportID, user.source, time.time()), True)

        name = user.vk.getName(self.owner)
        self.users[TransportID] = {"name": name, "jid": TransportID}
        # We join to the chat with the room owner's name to set the room subject from their name.
        joinChat(self.jid, name, TransportID, "Lost in time.")
        setChatConfig(self.jid, TransportID, False, self.onConfigSet, {"user": user})

    def initialize(self, user, chat):
        """
        Initializes chat object: 
            1) requests users list if required
            2) makes them members
            3) invites the user 
            4) sets the chat subject
        Parameters:
            chat: chat's jid
        """
        if not self.raw_users:
            vkChat = self.getVKChat(user, self.id)
            if not vkChat:
                raise RuntimeError("Unable to retrieve VK chat users list for user %s with chat id: %s" % (user, self.id))
            self.raw_users = vkChat["users"]

        name = "@%s" % TransportID
        setAffiliation(chat, "member", user.source)
        if not self.invited:
            inviteUser(chat, user.source, TransportID, user.vk.getName(self.owner))
            logger.debug("groupchats: user has been invited to chat %s (jid: %s)", chat, user.source)
            self.invited = True
        self.setSubject(self.subject, self.creation_date)
        joinChat(chat, name, TransportID, "Lost in time.")  # let's rename ourselves
        self.users[TransportID] = {"name": name, "jid": TransportID}

    def update(self, userObject):
        """
        Updates chat users and sends messages
        Uses two user lists to prevent losing of any of them
        """
        vkChat = self.getVKChat(userObject, self.id)
        all_users = vkChat.get("users", [])
        everyone = all_users + self.users.keys()
        everyone = sorted(everyone)[:CHAT_USERS_LIMIT]
        # how would it get in there?
        if TransportID in everyone:
            everyone.remove(TransportID)
        if userObject.vk.getUserPreferences()[0] in everyone:
            everyone.remove(userObject.vk.getUserPreferences()[0])

        for user in everyone:
            jid = vk2xmpp(user)
            userId = int(user)
            existingUser = self.users.get(userId)
            if not existingUser:
                logger.debug("groupchats: user %s has joined the chat %s (jid: %s)",
                    user, self.jid, userObject.source)
                # TODO: Transport MUST NOT request the name for each user it sees.
                # It should be done with a list of users
                # E.g. requesting a list of users and get a list of names
                name = userObject.vk.getName(user)
                if not name:
                    logger.error("groupchats: unable to get user name"
                        + " for %s in chat %s, data: %s (jid: %s)",
                        user,
                        self.jid,
                        userData,
                        userObject.source)
                    name = "undefined"
                self.users[userId] = {"name": name, "jid": jid}
                setAffiliation(self.jid, "member", jid)
                joinChat(self.jid, name, jid)

            elif user not in all_users:
                logger.debug("groupchats: user %s has left the chat %s (jid: %s)",
                    user, self.jid, userObject.source)
                leaveChat(self.jid, jid)
                del self.users[user]

        subject = vkChat.get("title")
        if subject and subject != self.subject:
            self.setSubject(subject)
        self.raw_users = all_users

    def setSubject(self, subject, date=None):
        """
        Changes the chat subject
        """
        chatMessage(self.jid, subject, TransportID, True, date)
        self.subject = subject

    def onConfigSet(self, cl, stanza, user):
        """
        A callback which called after attempt to create the chat
        """
        frm = stanza.getFrom()
        if not frm:
            logger.critical("no from in stanza! %s", stanza)
            self.creation_failed = True
            return
        chat = frm.getStripped()
        if xmpp.isResultNode(stanza):
            self.created = True
            logger.debug("groupchats: stanza \"result\" received from %s, "
                  "continuing initialization (jid: %s)", chat, user.source)
            utils.execute(self.initialize, (user, chat))
        else:
            logger.error("groupchats: couldn't set room %s config, the answer is: %s (jid: %s)",
                chat, str(stanza), user.source)
            self.creation_failed = True

    # there's a possibility to mess up here if many messages were sent before we created the chat 
    # we have to send the messages immendiately as soon as possible, so delay can mess the messages up
    def handleMessage(self, user, vkChat, retry=True):
        """
        Handle incoming (VK -> XMPP) messages
        """
        if self.created:
            self.update(user)
            body = escape("", uhtml(vkChat["text"]))
            body += parseAttachments(user, vkChat)[1]
            body += parseForwardedMessages(user, vkChat)[1]
            if body:
                chatMessage(self.jid, body, vk2xmpp(vkChat["from_id"]), None)
        else:
            source = "unknown"
            userObject = self.getUserObject(self.jid)
            if userObject:
                source = userObject.source
            # todo: FULL leave on error and try to create the chat again
            logger.warning("groupchats: chat %s wasn't created well,"
                + " so trying to create it again (jid: %s)."
                + "Is it possible that you have groupchat limit on the server?",
                self.jid, source)
            if retry:
                # TODO: We repeat it twice on each message. We shouldn't.
                self.handleMessage(user, vkChat, False)

    def isUpdateRequired(self):
        """
        Tells whether it's required to update the chat's last_used time
        Returns:
            True if required
        """
        if not self.source:
            return False
        if not self.last_update:
            return True
        if (time.time() - self.last_update) > MAX_UPDATE_DELAY:
            return True
        return False

    @staticmethod
    @api.repeat(3, dict, RuntimeError)
    def getVKChat(user, id):
        """
        Get vk chat by id
        """
        chat = user.vk.method("messages.getChat", {"chat_id": id})
        if not chat:
            raise RuntimeError("Unable to get a chat! User: %s, id: %s" % (user, id))
        users = chat.get("users", [])
        chat["users"] = sorted(users)
        return chat

    @staticmethod
    def getParts(source):
        """
        Split the source and return required parts
        """
        node, domain = source.split("@", 1)
        if "_chat#" not  in node:
            return (None, None, None)
        if "/" in domain:
            domain = domain.split("/")[0]
        creator, id = node.split("_chat#", 1)
        creator = int(creator)
        id = int(id)
        return (creator, id, domain)

    @staticmethod
    def getUserObject(source):
        """
        Gets user object by chat jid
        """
        user = None
        parts = Chat.getParts(source)
        if len(parts) == 3:
            creator, id, domain = parts
        else:
            logger.error("groupchats: we didn't get all parts! parts: %s. (jid: %s)", repr(parts), source)
            return None
        if creator and domain == ConferenceServer:
            user = Chat.getUserByID(creator)
        if not user:
            jid = runDatabaseQuery("select user from groupchats where jid=?", (source,), many=False)
            if jid:
                jid = jid[0]
                return Users.get(jid)
        return user

    @staticmethod
    def getUserByID(id):
        for user in Users.values():
            if hasattr(user, "vk"):
                if user.vk.getUserPreferences()[0] == id:
                    return user
        return None


def updateLastUsed(chat):
    """
    Updates the last_used field in the database
    Args:
        chat: the Chat object
    """
    runDatabaseQuery("update groupchats set last_used=? where jid=?", (time.time(), chat.source), set=True)


def exterminateChats(user=None, chats=[]):
    """
    Calls a Dalek for exterminate the chat
    The chats argument must be a list of tuples
    """
    def exterminated(cl, stanza, jid):
        """
        The callback that's being called when the stanza we sent's got an answer
        Args:
            cl: the xmpp.Client object
            stanza: the result stanza
            jid: the jid stanza's sent from (?)
        """
        frm = stanza.getFrom()
        if not frm:
            logger.critical("no from in stanza! %s", stanza)
            return
        chat = frm.getStripped()
        if xmpp.isResultNode(stanza):
            logger.debug("groupchats: target exterminated! Yay! target:%s (jid: %s)", chat, jid)
        else:
            logger.debug("groupchats: explain! Explain! "
                "The chat wasn't exterminated! Target: %s (jid: %s)", chat, jid)
            logger.error("groupchats: got stanza: %s (jid: %s)", str(stanza), jid)

    if user and not chats:
        chats = runDatabaseQuery("select jid, owner, user from groupchats where user=?", (user.source,))

    # current chats
    userChats = getattr(user, "chats", [])
    for (jid, owner, source) in chats:
        server = owner
        if "@" in owner:
            server = owner.split("@")[1]
        if server == TransportID:
            joinChat(jid, "Dalek", owner, "Exterminate!")
            logger.debug("groupchats: going to exterminate %s, owner:%s (jid: %s)", jid, owner, source)
            setChatConfig(jid, owner, True, exterminated, {"jid": jid})
            # remove the chat from current
            if jid in userChats:
                del userChats[jid]
        else:
            # if we try to send from another jid with prosody, we'll be killed
            logger.warning("Warning: Was the transport moved from other domain? Groupchat %s deletion skipped.", jid)
        runDatabaseQuery("delete from groupchats where jid=?", (jid,), set=True)


def initChatsTable():
    """
    Initializes database if it doesn't exist
    """
    def checkColumns():
        """
        Checks and adds additional column(s) into the groupchats table
        """
        info = runDatabaseQuery("pragma table_info(groupchats)")
        names = [col[1] for col in info]
        if "nick" not in names:
            logger.warning("groupchats: adding \"nick\" column to groupchats table")
            runDatabaseQuery("alter table groupchats add column nick text", set=True)

    runDatabaseQuery("create table if not exists groupchats "
        "(jid text, owner text,"
        "user text, last_used integer, nick text)", set=True)
    checkColumns()
    return True


def cleanTheChatsUp():
    """
    Calls Dalek(s) to exterminate inactive users or their chats, whatever they catch
    """
    chats = runDatabaseQuery("select jid, owner, last_used, user from groupchats")
    result = []
    for (jid, owner, last_used, user) in chats:
        if jid and owner:
            if (time.time() - last_used) >= utils.TimeMachine(CHAT_LIFETIME_LIMIT):
                result.append((jid, owner, user))
                logger.debug("groupchats: time for %s expired (jid: %s)", jid, user)
    if result:
        exterminateChats(chats=result)
    utils.runThread(cleanTheChatsUp, delay=CHAT_CLEANUP_DELAY)


def initChatExtension():
    """
    Initializes the extension"
    """
    if initChatsTable():
        if isdef("CHAT_LIFETIME_LIMIT"):
            cleanTheChatsUp()
        else:
            logger.warning("not starting chats cleaner because CHAT_LIFETIME_LIMIT is not set")
    if not isdef("CHAT_USERS_LIMIT"):
        global CHAT_USERS_LIMIT
        CHAT_USERS_LIMIT = 30


if isdef("ConferenceServer") and ConferenceServer:
    # G is for Groupchats. That's it.
    Handlers["msg03g"] = []

    GLOBAL_USER_SETTINGS["groupchats"] = {"label": "Handle groupchats",
        "desc": "If set, transport would create xmpp-chatrooms for VK Multi-Dialogs", "value": 1}

    GLOBAL_USER_SETTINGS["show_all_chat_users"] = {"label": "Show all chat users",
        "desc": "If set, transport will show ALL users in a conference", "value": 0}

    TRANSPORT_SETTINGS["destroy_on_leave"] = {"label": "Destroy groupchat if user leaves it", "value": 0}

    TransportFeatures.add(xmpp.NS_GROUPCHAT)
    registerHandler("msg01", handleOutgoingChatMessage)
    registerHandler("evt01", initChatExtension)
    registerHandler("evt03", exterminateChats)
    logger.info("extension groupchats is loaded")

else:
    del setAffiliation, inviteUser, joinChat, leaveChat, \
        handleOutgoingChatMessage, chatMessage, Chat, \
        exterminateChats, cleanTheChatsUp, initChatExtension