source: python/wa.py @ b73409f

Last change on this file since b73409f was b73409f, checked in by Wilmer van der Gaast <wilmer@…>, at 2015-05-28T05:34:16Z

Misc: Media messages, topic changes, and killed the stupid YowPing thread.

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