source: python/wa.py @ 365b25a

Last change on this file since 365b25a was 365b25a, checked in by Wilmer van der Gaast <wilmer@…>, at 2015-11-20T16:18:12Z

One callback ref fix, and show at least something for messages of an unknown
type. (Currently there's no handling for audio msgs for example.)

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