source: python/wa.py @ d832164

Last change on this file since d832164 was d832164, checked in by Wilmer van der Gaast <wilmer@…>, at 2015-06-17T22:46:40Z

Restore pings and try to finish login even if WA is rate limiting the sync.

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