source: python/wa.py @ 114154c

Last change on this file since 114154c was 114154c, checked in by Wilmer van der Gaast <wilmer@…>, at 2015-06-17T23:53:08Z

Reshuffle message queue handling.

  • Property mode set to 100755
File size: 16.1 KB
RevLine 
[63b017e]1#!/usr/bin/python
2
[b73409f]3import collections
[63b017e]4import logging
5import threading
[2b4402f]6import time
[63b017e]7
8import yowsup
9
10from yowsup.layers.auth                        import YowAuthenticationProtocolLayer
[5f8ad281]11from yowsup.layers.protocol_acks               import YowAckProtocolLayer
12from yowsup.layers.protocol_chatstate          import YowChatstateProtocolLayer
13from yowsup.layers.protocol_contacts           import YowContactsIqProtocolLayer
14from yowsup.layers.protocol_groups             import YowGroupsProtocolLayer
15from yowsup.layers.protocol_ib                 import YowIbProtocolLayer
16from yowsup.layers.protocol_iq                 import YowIqProtocolLayer
[63b017e]17from yowsup.layers.protocol_messages           import YowMessagesProtocolLayer
[5f8ad281]18from yowsup.layers.protocol_notifications      import YowNotificationsProtocolLayer
19from yowsup.layers.protocol_presence           import YowPresenceProtocolLayer
20from yowsup.layers.protocol_privacy            import YowPrivacyProtocolLayer
21from yowsup.layers.protocol_profiles           import YowProfilesProtocolLayer
[63b017e]22from yowsup.layers.protocol_receipts           import YowReceiptProtocolLayer
23from yowsup.layers.network                     import YowNetworkLayer
24from yowsup.layers.coder                       import YowCoderLayer
[2b4402f]25from yowsup.stacks import YowStack, YowStackBuilder
[63b017e]26from yowsup.common import YowConstants
27from yowsup.layers import YowLayerEvent
28from yowsup.stacks import YowStack, YOWSUP_CORE_LAYERS
29from yowsup import env
30
[5f8ad281]31from yowsup.layers.interface                             import YowInterfaceLayer, ProtocolEntityCallback
[63b017e]32from yowsup.layers.protocol_acks.protocolentities        import *
[5f8ad281]33from yowsup.layers.protocol_chatstate.protocolentities   import *
34from yowsup.layers.protocol_contacts.protocolentities    import *
35from yowsup.layers.protocol_groups.protocolentities      import *
[63b017e]36from yowsup.layers.protocol_ib.protocolentities          import *
37from yowsup.layers.protocol_iq.protocolentities          import *
38from yowsup.layers.protocol_media.mediauploader import MediaUploader
[5f8ad281]39from yowsup.layers.protocol_media.protocolentities       import *
40from yowsup.layers.protocol_messages.protocolentities    import *
41from yowsup.layers.protocol_notifications.protocolentities import *
42from yowsup.layers.protocol_presence.protocolentities    import *
43from yowsup.layers.protocol_privacy.protocolentities     import *
[63b017e]44from yowsup.layers.protocol_profiles.protocolentities    import *
[5f8ad281]45from yowsup.layers.protocol_receipts.protocolentities    import *
[63b017e]46from yowsup.layers.axolotl.protocolentities.iq_key_get import GetKeysIqProtocolEntity
47from yowsup.layers.axolotl import YowAxolotlLayer
48from yowsup.common.tools import ModuleTools
49
50import implugin
51
52logger = logging.getLogger("yowsup.layers.network.layer")
53logger.setLevel(logging.DEBUG)
54ch = logging.StreamHandler()
55ch.setLevel(logging.DEBUG)
56logger.addHandler(ch)
57
[114154c]58"""
59TODO/Things I'm unhappy about:
60
61The randomness of where which bits/state live, in the implugin and the
62yowsup layer. Can't really merge this but at least state should live in
63one place.
64
65Mix of silly CamelCase and proper_style. \o/
66
67Most important: This is NOT thread-clean. implugin can call into yowsup
68cleanly by throwing closures into a queue, but there's no mechanism in
69the opposite direction, I'll need to cook up some hack to make this
70possible through bjsonrpc's tiny event loop. I think I know how...
71
72And more. But let's first get this into a state where it even works..
73"""
[2700925]74
75# Tried this but yowsup is not passing back the result, will have to update the library. :-(
76class GetStatusIqProtocolEntity(IqProtocolEntity):
77        def __init__(self, jids=None):
78                super(GetStatusIqProtocolEntity, self).__init__("status", None, _type="get", to="s.whatsapp.net")
79                self.jids = jids or []
80
81        def toProtocolTreeNode(self):
82                from yowsup.structs import ProtocolTreeNode
83               
84                node = super(GetStatusIqProtocolEntity, self).toProtocolTreeNode()
85                sr = ProtocolTreeNode("status")
86                node.addChild(sr)
87                for jid in self.jids:
88                        sr.addChild(ProtocolTreeNode("user", {"jid": jid}))
89                return node
90
91
[63b017e]92class BitlBeeLayer(YowInterfaceLayer):
93
94        def __init__(self, *a, **kwa):
95                super(BitlBeeLayer, self).__init__(*a, **kwa)
[114154c]96                # Offline messages are sent while we're still logging in.
97                self.msg_queue = []
[63b017e]98
99        def receive(self, entity):
[2b4402f]100                print "Received: %r" % entity
[b09ce17]101                #print entity
[63b017e]102                super(BitlBeeLayer, self).receive(entity)
103
104        def Ship(self, entity):
105                """Send an entity into Yowsup, but through the correct thread."""
106                print "Queueing: %s" % entity.getTag()
[b09ce17]107                #print entity
[63b017e]108                def doit():
109                        self.toLower(entity)
110                self.getStack().execDetached(doit)
111
112        @ProtocolEntityCallback("success")
113        def onSuccess(self, entity):
114                self.b = self.getStack().getProp("org.bitlbee.Bijtje")
115                self.cb = self.b.bee
116                self.b.yow = self
[a852b2b]117               
118                self.cb.log("Authenticated, syncing contact list")
119               
120                # We're done once this set is empty.
[d832164]121                self.todo = set(["contacts", "groups", "ping"])
[a852b2b]122               
123                # Supposedly WA can also do national-style phone numbers without
124                # a + prefix BTW (relative to I guess the user's country?). I
125                # don't want to support this at least for now.
126                numbers = [("+" + x.split("@")[0]) for x in self.cb.get_local_contacts()]
127                self.toLower(GetSyncIqProtocolEntity(numbers))
[cb1b973]128                self.toLower(ListGroupsIqProtocolEntity())
[d832164]129                self.b.keepalive()
[a852b2b]130               
[b09ce17]131                try:
132                        self.toLower(PresenceProtocolEntity(name=self.b.setting("name")))
133                except KeyError:
134                        pass
[a852b2b]135
136        def check_connected(self, done):
[114154c]137                if not self.todo:
[a852b2b]138                        return
139                self.todo.remove(done)
140                if not self.todo:
141                        self.cb.connected()
[114154c]142                        self.flush_msg_queue()
143       
144        def flush_msg_queue(self):
145                for msg in self.msg_queue:
146                        self.onMessage(msg)
147                self.msg_queue = None
[63b017e]148       
149        @ProtocolEntityCallback("failure")
150        def onFailure(self, entity):
151                self.b = self.getStack().getProp("org.bitlbee.Bijtje")
152                self.cb = self.b.bee
153                self.cb.error(entity.getReason())
154                self.cb.logout(False)
[5f8ad281]155
156        def onEvent(self, event):
[b73409f]157                # TODO: Make this work without, hmm, over-recursing. (This handler
158                # getting called when we initiated the disconnect, which upsets yowsup.)
159                if event.getName() == "orgopenwhatsapp.yowsup.event.network.disconnected":
160                        self.cb.error(event.getArg("reason"))
161                        self.cb.logout(True)
[5f8ad281]162                        self.getStack().execDetached(self.daemon.StopDaemon)
[b73409f]163                else:
164                        print "Received event: %s name %s" % (event, event.getName())
[5f8ad281]165       
166        @ProtocolEntityCallback("presence")
167        def onPresence(self, pres):
[a852b2b]168                if pres.getFrom() == self.b.account["user"]:
169                        # WA returns our own presence. Meh.
170                        return
171               
[c82a88d]172                # Online/offline is not really how WA works. Let's show everyone
173                # as online but unavailable folks as away. This also solves the
174                # problem of offline->IRC /quit causing the persons to leave chat
175                # channels as well (and not reappearing there when they return).
176                status = 8 | 1  # MOBILE | ONLINE
177                if pres.getType() == "unavailable":
178                        status |= 4  # AWAY
[b09ce17]179                self.cb.buddy_status(pres.getFrom(), status, None, None)
[a852b2b]180               
[cb1b973]181                try:
182                        # Last online time becomes idle time which I guess is
183                        # sane enough?
184                        self.cb.buddy_times(pres.getFrom(), 0, int(pres.getLast()))
[2446e4c]185                except (ValueError, TypeError):
186                        # Could be "error" or, more likely, "deny", or None.
[cb1b973]187                        pass
[63b017e]188       
189        @ProtocolEntityCallback("message")
190        def onMessage(self, msg):
[114154c]191                if self.todo:
192                        # We're still logging in, so wait.
193                        self.msg_queue.append(msg)
194                        return
[63b017e]195
[114154c]196                self.b.show_message(msg)
[b73409f]197
198                # ACK is required! So only use return above in case of errors.
199                # (So that we will/might get a retry after restarting.)
200                self.toLower(OutgoingReceiptProtocolEntity(msg.getId(), msg.getFrom()))
[b09ce17]201
[63b017e]202        @ProtocolEntityCallback("receipt")
203        def onReceipt(self, entity):
[5f8ad281]204                ack = OutgoingAckProtocolEntity(entity.getId(), entity.getTag(),
205                                                entity.getType(), entity.getFrom())
[63b017e]206                self.toLower(ack)
207
[2b4402f]208        @ProtocolEntityCallback("iq")
209        def onIq(self, entity):
210                if isinstance(entity, ResultSyncIqProtocolEntity):
211                        return self.onSyncResult(entity)
[433c90b]212                elif isinstance(entity, ListParticipantsResultIqProtocolEntity):
213                        return self.b.chat_join_participants(entity)
[cb1b973]214                elif isinstance(entity, ListGroupsResultIqProtocolEntity):
215                        return self.onListGroupsResult(entity)
[d832164]216                elif "ping" in self.todo: # Pong has no type, sigh.
217                        if "contacts" in self.todo:
218                                # Shitty Whatsapp rejected the sync request, and
219                                # annoying Yowsup doesn't inform on error responses.
220                                # So instead, if we received no response to it but
221                                # did get our ping back, declare failure.
222                                self.onSyncResultFail()
223                        self.check_connected("ping")
[2b4402f]224       
225        def onSyncResult(self, entity):
[433c90b]226                # TODO HERE AND ELSEWHERE: Thread idiocy happens when going
[2b4402f]227                # from here to the IMPlugin. Check how bjsonrpc lets me solve that.
[a852b2b]228                for num, jid in entity.inNumbers.iteritems():
229                        self.toLower(SubscribePresenceProtocolEntity(jid))
230                        self.cb.add_buddy(jid, "")
[2b4402f]231                if entity.outNumbers:
[433c90b]232                        self.cb.error("Not on WhatsApp: %s" %
233                                      ", ".join(entity.outNumbers.keys()))
[2b4402f]234                if entity.invalidNumbers:
[433c90b]235                        self.cb.error("Invalid numbers: %s" %
[c82a88d]236                                      ", ".join(entity.invalidNumbers))
[2b4402f]237
[d832164]238                #self.getStatuses(entity.inNumbers.values())
239                self.check_connected("contacts")
240
241        def onSyncResultFail(self):
242                # Whatsapp rate-limits sync stanzas, so in case of failure
243                # just assume all contacts are valid.
244                for jid in self.cb.get_local_contacts():
245                        self.toLower(SubscribePresenceProtocolEntity(jid))
246                        self.cb.add_buddy(jid, "")
247                #self.getStatuses?
[a852b2b]248                self.check_connected("contacts")
249
[cb1b973]250        def onListGroupsResult(self, groups):
251                """Save group info for later if the user decides to join."""
252                for g in groups.getGroups():
253                        jid = g.getId()
254                        if "@" not in jid:
255                                jid += "@g.us"
[b73409f]256                        group = self.b.groups[jid]
257                       
258                        # Save it. We're going to mix ListGroups elements and
259                        # Group-Subject notifications there, which don't have
260                        # consistent fieldnames for the same bits of info \o/
261                        g.getSubjectTimestamp = g.getSubjectTime
262                        group["topic"] = g
[cb1b973]263
[a852b2b]264                self.check_connected("groups")
265
[d832164]266        def getStatuses(self, contacts):
267                return # Disabled since yowsup won't give us the result...
268                self.toLower(GetStatusIqProtocolEntity(contacts))
269                self.todo.add("statuses")
270
[2b4402f]271        @ProtocolEntityCallback("notification")
272        def onNotification(self, ent):
273                if isinstance(ent, StatusNotificationProtocolEntity):
274                        return self.onStatusNotification(ent)
[b73409f]275                elif isinstance(ent, SubjectGroupsNotificationProtocolEntity):
276                        return self.onGroupSubjectNotification(ent)
[cb1b973]277
[2b4402f]278        def onStatusNotification(self, status):
279                print "New status for %s: %s" % (status.getFrom(), status.status)
[cb1b973]280                self.bee.buddy_status_msg(status.getFrom(), status.status)
[b73409f]281       
282        def onGroupSubjectNotification(self, sub):
283                print "New /topic for %s: %s" % (sub.getFrom(), sub.getSubject())
284                group = self.b.groups[sub.getFrom()]
285                group["topic"] = sub
286                id = group.get("id", None)
287                if id is not None:
288                        self.cb.chat_topic(id, sub.getSubjectOwner(),
289                                           sub.getSubject(), sub.getSubjectTimestamp())
[cb1b973]290
291        @ProtocolEntityCallback("media")
292        def onMedia(self, med):
293                """Your PC better be MPC3 compliant!"""
294                print "YAY MEDIA! %r" % med
295                print med
[2b4402f]296
[b09ce17]297        #@ProtocolEntityCallback("chatstate")
298        #def onChatstate(self, entity):
299        #       print(entity)
[5f8ad281]300
301
[63b017e]302class YowsupDaemon(threading.Thread):
303        daemon = True
304        stack = None
305
306        class Terminate(Exception):
307                pass
308
309        def run(self):
310                try:
311                        self.stack.loop(timeout=0.2, discrete=0.2, count=1)
312                except YowsupDaemon.Terminate:
313                        print "Exiting loop!"
314                        pass
315       
316        def StopDaemon(self):
317                # Ugly, but yowsup offers no "run single iteration" version
318                # of their event loop :-(
319                raise YowsupDaemon.Terminate
320
[2b4402f]321
[63b017e]322class YowsupIMPlugin(implugin.BitlBeeIMPlugin):
323        NAME = "wa"
324        SETTINGS = {
325                "cc": {
[a852b2b]326                        # Country code. Seems to be required for registration only.
[63b017e]327                        "type": "int",
328                },
329                "name": {
330                        "flags": 0x100, # NULL_OK
331                },
332        }
[b09ce17]333        AWAY_STATES = ["Away"]
[5f8ad281]334        ACCOUNT_FLAGS = 14 # HANDLE_DOMAINS + STATUS_MESSAGE + LOCAL_CONTACTS
[63b017e]335        # TODO: HANDLE_DOMAIN in right place (add ... ... nick bug)
[d832164]336        PING_INTERVAL = 299 # seconds
[5f8ad281]337
[63b017e]338        def login(self, account):
339                self.stack = self.build_stack(account)
340                self.daemon = YowsupDaemon(name="yowsup")
341                self.daemon.stack = self.stack
342                self.daemon.start()
343                self.bee.log("Started yowsup thread")
[5f8ad281]344               
[b73409f]345                self.groups = collections.defaultdict(dict)
[b09ce17]346                self.groups_by_id = {}
[63b017e]347
[d832164]348                self.next_ping = None
349
[63b017e]350        def keepalive(self):
[d832164]351                if self.next_ping and (time.time() < self.next_ping):
352                        return
353                self.yow.Ship(PingIqProtocolEntity(to="s.whatsapp.net"))
354                self.next_ping = time.time() + self.PING_INTERVAL
[63b017e]355
356        def logout(self):
357                self.stack.broadcastEvent(YowLayerEvent(YowNetworkLayer.EVENT_STATE_DISCONNECT))
358                self.stack.execDetached(self.daemon.StopDaemon)
359
360        def buddy_msg(self, to, text, flags):
361                msg = TextMessageProtocolEntity(text, to=to)
362                self.yow.Ship(msg)
363
364        def add_buddy(self, handle, _group):
[a852b2b]365                self.yow.Ship(GetSyncIqProtocolEntity(
366                    ["+" + handle.split("@")[0]], mode=GetSyncIqProtocolEntity.MODE_DELTA))
[63b017e]367
368        def remove_buddy(self, handle, _group):
369                self.yow.Ship(UnsubscribePresenceProtocolEntity(handle))
370
[b09ce17]371        def set_away(self, state, status):
372                print "Trying to set status to %r, %r" % (state, status)
373                if state:
374                        # Only one option offered so None = available, not None = away.
375                        self.yow.Ship(AvailablePresenceProtocolEntity())
376                else:
377                        self.yow.Ship(UnavailablePresenceProtocolEntity())
378                if status:
379                        self.yow.Ship(SetStatusIqProtocolEntity(status))
[63b017e]380
381        def set_set_name(self, _key, value):
[2b4402f]382                self.yow.Ship(PresenceProtocolEntity(name=value))
[63b017e]383
[b09ce17]384        def chat_join(self, id, name, _nick, _password, settings):
385                print "New chat created with id: %d" % id
[cb1b973]386                group = self.groups[name]
[b73409f]387                group.update({"id": id, "name": name})
[cb1b973]388                self.groups_by_id[id] = group
389               
[b73409f]390                gi = group.get("topic", None)
[cb1b973]391                if gi:
392                        self.bee.chat_topic(id, gi.getSubjectOwner(),
[b73409f]393                                            gi.getSubject(), gi.getSubjectTimestamp())
[cb1b973]394               
395                # WA doesn't really have a concept of joined or not, just
[b73409f]396                # long-term membership. Let's just sync state (we have
397                # basic info but not yet a member list) and ACK the join
398                # once that's done.
[433c90b]399                self.yow.Ship(ParticipantsGroupsIqProtocolEntity(name))
400
401        def chat_join_participants(self, entity):
402                group = self.groups[entity.getFrom()]
403                id = group["id"]
404                for p in entity.getParticipants():
405                        if p != self.account["user"]:
406                                self.bee.chat_add_buddy(id, p)
[cb1b973]407
[433c90b]408                # Add the user themselves last to avoid a visible join flood.
[b09ce17]409                self.bee.chat_add_buddy(id, self.account["user"])
[cb1b973]410                for msg in group.setdefault("queue", []):
[114154c]411                        self.b.show_message(msg)
[cb1b973]412                del group["queue"]
[b09ce17]413       
414        def chat_msg(self, id, text, flags):
415                msg = TextMessageProtocolEntity(text, to=self.groups_by_id[id]["name"])
416                self.yow.Ship(msg)
417
[cb1b973]418        def chat_leave(self, id):
419                # WA never really let us leave, so just disconnect id and jid.
420                group = self.groups_by_id[id]
421                del self.groups_by_id[id]
422                del group["id"]
423
[63b017e]424        def build_stack(self, account):
[b09ce17]425                self.account = account
[63b017e]426                creds = (account["user"].split("@")[0], account["pass"])
427
[2b4402f]428                stack = (YowStackBuilder()
429                         .pushDefaultLayers(False)
430                         .push(BitlBeeLayer)
431                         .build())
[63b017e]432                stack.setProp(YowAuthenticationProtocolLayer.PROP_CREDENTIALS, creds)
433                stack.setProp(YowNetworkLayer.PROP_ENDPOINT, YowConstants.ENDPOINTS[0])
434                stack.setProp(YowCoderLayer.PROP_DOMAIN, YowConstants.DOMAIN)
435                stack.setProp(YowCoderLayer.PROP_RESOURCE, env.CURRENT_ENV.getResource())
[dcfa886]436                try:
437                        stack.setProp(YowIqProtocolLayer.PROP_PING_INTERVAL, 0)
438                except AttributeError:
439                        # Ping setting only exists since May 2015.
440                        from yowsup.layers.protocol_iq.layer import YowPingThread
441                        YowPingThread.start = lambda x: None
442
[63b017e]443                stack.setProp("org.bitlbee.Bijtje", self)
444
445                stack.broadcastEvent(YowLayerEvent(YowNetworkLayer.EVENT_STATE_CONNECT))
446
447                return stack
448
[b73409f]449
[114154c]450        # Not RPCs from here on.
451        def show_message(self, msg):
452                if hasattr(msg, "getBody"):
453                        text = msg.getBody()
454                elif hasattr(msg, "getCaption") and hasattr(msg, "getMediaUrl"):
455                        lines = []
456                        if msg.getMediaUrl():
457                                lines.append(msg.getMediaUrl())
458                        else:
459                                lines.append("<Broken link>")
460                        if msg.getCaption():
461                                lines.append(msg.getCaption())
462                        text = "\n".join(lines)
463
464                if msg.getParticipant():
465                        group = self.groups[msg.getFrom()]
466                        if "id" in group:
467                                self.bee.chat_msg(group["id"], msg.getParticipant(), text, 0, msg.getTimestamp())
468                        else:
469                                self.bee.log("Warning: Activity in room %s" % msg.getFrom())
470                                self.groups[msg.getFrom()].setdefault("queue", []).append(msg)
471                else:
472                        self.bee.buddy_msg(msg.getFrom(), text, 0, msg.getTimestamp())
473
474
[63b017e]475implugin.RunPlugin(YowsupIMPlugin, debug=True)
Note: See TracBrowser for help on using the repository browser.