diff options
-rw-r--r-- | CHANGELOG | 8 | ||||
-rw-r--r-- | Makefile | 2 | ||||
-rw-r--r-- | data/default_config.cfg | 10 | ||||
-rw-r--r-- | doc/en/configure.txt | 23 | ||||
-rw-r--r-- | doc/en/plugins.txt | 240 | ||||
-rw-r--r-- | doc/en/plugins/gpg.txt | 101 | ||||
-rw-r--r-- | doc/en/usage.txt | 6 | ||||
-rw-r--r-- | plugins/exec.py | 7 | ||||
-rw-r--r-- | plugins/figlet.py | 8 | ||||
-rw-r--r-- | plugins/gpg/__init__.py | 172 | ||||
-rw-r--r-- | plugins/gpg/gnupg.py | 975 | ||||
-rw-r--r-- | plugins/pacokick.py | 20 | ||||
-rw-r--r-- | plugins/rainbow.py | 8 | ||||
-rw-r--r-- | plugins/test.py | 4 | ||||
-rw-r--r-- | src/connection.py | 8 | ||||
-rw-r--r-- | src/contact.py | 81 | ||||
-rw-r--r-- | src/core.py | 159 | ||||
-rw-r--r-- | src/data_forms.py | 2 | ||||
-rw-r--r-- | src/events.py | 14 | ||||
-rw-r--r-- | src/plugin.py | 49 | ||||
-rw-r--r-- | src/plugin_manager.py | 68 | ||||
-rw-r--r-- | src/roster.py | 2 | ||||
-rw-r--r-- | src/tabs.py | 168 | ||||
-rw-r--r-- | src/windows.py | 64 | ||||
-rw-r--r-- | src/xhtml.py | 4 |
25 files changed, 1974 insertions, 229 deletions
@@ -2,8 +2,10 @@ This file describes the new features in each poezio release. For more detailed changelog, see the roadmap: http://dev.louiz.org/project/poezio/roadmap +* Poezio 0.7.5 - dev +- Plugin system -* Poezio 0.7.2 - dev +* Poezio 0.7.2 - 08 Nov 2011 - Huge speed improvements in both memory usage, text refresh speed and interface resize. - Chatstate notifications (in private AND in MUCs) @@ -23,13 +25,13 @@ http://dev.louiz.org/project/poezio/roadmap - a *lot* of bugfixes -* Poezio 0.7.1 - 2 Feb 2010 +* Poezio 0.7.1 - 2 Feb 2011 - /status command to globally change the status - /win command now accepts part of tab name as argument - bugfixes -* Poezio 0.7 - 14 jan 2010 +* Poezio 0.7 - 14 jan 2011 Codename ”Koshie & Mathieui” - Library changed from xmpppy to SleekXMPP - Python 3 only @@ -36,7 +36,7 @@ uninstall: rm -rf $(DESTDIR)$(MANDIR)/man1/poezio.1 doc: - find doc -name \*.txt -exec asciidoc {} \; + find doc -name \*.txt -exec asciidoc -a toc {} \; pot: xgettext src/*.py --from-code=utf-8 --keyword=_ -o locale/poezio.pot diff --git a/data/default_config.cfg b/data/default_config.cfg index e803a7d1..0ad9e328 100644 --- a/data/default_config.cfg +++ b/data/default_config.cfg @@ -31,6 +31,16 @@ jid = # If you leave this empty, the password will be asked at each startup password = +# A custom host that will be used instead of the DNS records for the server +# (anonymous or the jid’s) defined above. +# You should not need this in a "normal" use case. +custom_host = + +# A custom port to use instead of the 5222. +# This option can be combined with custom_host. +# You should not need this in a "normal" use case. +custom_port = + # the rooms you will join automatically on startup, with associated nickname or not # format : room@server.tld/nickname:room2@server.tld/nickname2 # default_nick will be used if "/nickname" is not specified diff --git a/doc/en/configure.txt b/doc/en/configure.txt index 94f8e121..5928ef3e 100644 --- a/doc/en/configure.txt +++ b/doc/en/configure.txt @@ -263,3 +263,26 @@ Configuration options Defines if all tabs are resized at the same time (if set to false) or if they are really resized only when needed (if set to true). “true” should be the most comfortable value + +*custom_host*:: [empty] + + A custom host that will be used instead of the DNS records for the server + (anonymous or the jid’s) defined above. + You should not need this in a "normal" use case. + +*custom_port*:: [empty] + + A custom port to use instead of the 5222. + This option can be combined with custom_host. + You should not need this in a "normal" use case. + +*plugins_autoload*:: [empty] + + Space separated list of plugins to load on startup. + +*plugins_dir*:: [empty] + + If plugins_dir is not set, plugins will be loaded from $XDG_DATA_HOME/poezio/plugins + You can specify another directory to use. It will be created if it does not + exist. + diff --git a/doc/en/plugins.txt b/doc/en/plugins.txt index 47ab7f01..87f09cb7 100644 --- a/doc/en/plugins.txt +++ b/doc/en/plugins.txt @@ -1,8 +1,6 @@ Plugins ======= -Currently, the plugins are in a plugin branch on the git repo. - Location -------- @@ -20,13 +18,15 @@ Methods Overridden methods ~~~~~~~~~~~~~~~~~~ -The *Plugin* class has several method that you can override for your own convenience +The *Plugin* class has several method that you can override for your own + convenience [[init]] *init*:: +self+ + This method is called when the plugin is loaded, this is where you call the other methods, for example <<add-command,add_command>>, and initialize -everything to make your plugin actually do something. <<example-1,ex 1>>, <<example-2,ex 2>> +everything to make your plugin actually do something. <<example-1,ex 1>>, + <<example-2,ex 2>> *cleanup*:: +self+ + Called when the plugin is unloaded (or when poezio is exited). Clean everything @@ -44,23 +44,58 @@ This method adds a global command to poezio. For example you can add a /foo command that the user could call when the plugin is loaded, by calling this method with _foo_ as its _name_ argument. <<example-1,ex 1>> -* _name_: (string) the name of the command (for example, if it is 'plugintest', it can -add a /plugintest command) +* _name_: (string) the name of the command (for example, if it is 'plugintest', + it can add a /plugintest command) +* _handler_: (function) the function to be called when the command is executed. +the handler takes *args* as a parameter, which is a string of what +is entered after the command. Split *args* (with _common.shell_split_) to use +that as command arguments. +* _help_: (string) the help message available for that command through the + _/help_ command. +* _completion_: (function) the completion for the args of that command. It + takes an input object as its only argument. This function should call the +_auto_completion()_ method on the input object, passing a list of possible + strings for the completion and returning the value of that call directly. +Everything else is handled by that _auto_completion()_ method (checking what + strings match, how to cycle between matches, etc). If you don’t want any + special completion for that command, just pass None (the default value). + +*del_command*:: +self+, +name+ + +This command removes a tab command added by your plugin. + +* _name_: (string) the name of the command you want to remove. + + +*add_tab_command*:: +self+, +tab_type+, +name+, +handler+, +help+, +completion+ + +This method adds a tab-custom command to poezio. For example you can add /dou +command that the user could call in a specific tab when the plugin is loaded. + + +* _tab_type_: You have to _import tabs_ in order to get tabs types. The + following are possible: +** _tabs.MucTab_: The MultiUserChat tabs +** _tabs.PrivateTab_: The Private tabs +** _tabs.ConversationTab_: The Roster tab +** _tabs.RosterInfoTab_: The MultiUserChat, Private, and Conversation tabs +** _tabs.ChatTab_: The MultiUserChat, Private, and Conversation tabs +** _tabs.MucListTab_: The MultiUserChat list tabs +* _name_: (string) the name of the command (for example, if it is 'plugintest', + it can add a /plugintest command) * _handler_: (function) the function to be called when the command is executed. the handler takes *args* as a parameter, which is a string of what is entered after the command. Split *args* (with _common.shell_split_) to use that as command arguments. -* _help_: (string) the help message available for that command through the _/help_ -command. -* _completion_: (function) the completion for the args of that command. It takes -an input object as its only argument. This function should call the -_auto_completion()_ method on the input object, passing a list of possible strings -for the completion and returning the value of that call directly. -Everything else is handled by that _auto_completion()_ method (checking what strings -match, how to cycle between matches, etc). If you don’t want any special completion -for that command, just pass None (the default value). - -*add_event_handler**: +self+, +event_name+, +handler+ + +* _help_: (string) the help message available for that command through the + _/help_ command. +* _completion_: (function) the completion for the args of that command. It + takes an input object as its only argument. This function should call the +_auto_completion()_ method on the input object, passing a list of possible + strings for the completion and returning the value of that call directly. +Everything else is handled by that _auto_completion()_ method (checking what + strings match, how to cycle between matches, etc). If you don’t want any + special completion for that command, just pass None (the default value). + +*add_event_handler**: +self+, +event_name+, +handler+ +position+ This methods adds a callback that will be called whenever the given event occurs. <<example-2,ex 2>> @@ -68,7 +103,13 @@ occurs. <<example-2,ex 2>> This can be a sleekxmpp event, or a poezio event. See the list of <<events-list,all available events>>. * _handler_: The method that will be called whenever the event occurs. -It must accept the arguments specified for that event in the <<events-list,events list>>. +It must accept the arguments specified for that event in the + <<events-list,events list>>. +* _position_: Optional, this argument will specify the position of the handler + in the handler list for this event. It is 0 by default, and will only be used + with the internal poezio events described below. + + Attributes ---------- @@ -78,14 +119,20 @@ Config By default, each plugin has a PluginConfig object accessible with *self.config*. *PluginConfig.read*:: +self+ + -Reads the config file from $XDG_CONFIG_HOME/poezio/plugins/plugin_name.cfg, it is called upon initialization, so you should not need it. +Reads the config file from $XDG_CONFIG_HOME/poezio/plugins/plugin_name.cfg, it + is called upon initialization, so you should not need it. *PluginConfig.write*:: +self+ + Writes the config to the same file mentioned previously. Core ~~~~ -Each plugin has a reference to the Core object accessible with *self.core*, that allows you to do about anything with poezio. Remember to use it only when you need it, and to use the functions described in this documentation only, even if much more is available. If your plugin needs to do something not available with these methods, please do a feature request instead of doing some dirty hacks with some other methods. +Each plugin has a reference to the Core object accessible with *self.core*, + that allows you to do about anything with poezio. Remember to use it only when + you need it, and to use the functions described in this documentation only, + even if much more is available. If your plugin needs to do something not + available with these methods, please do a feature request instead of doing + some dirty hacks with some other methods. Core methods ^^^^^^^^^^^^ @@ -95,24 +142,163 @@ CAUTION: TODO [[events-list]] Events list ----------- -CAUTION: TODO + +Poezio events +~~~~~~~~~~~~~ + +*muc_say*:: +message+, +tab+ + +The handlers for this event are called whenever you say something in a MUC + (through the /say command or by direct input). The parameters given to the + handlers are: + +* _message_: Message to be sent. +* _tab_: Tab in which the message will be sent. + +*muc_say_after*:: +message+, +tab+ + +The handlers for this event are called whenever you say something in a MUC + (through the /say command or by direct input). The difference with muc_say is + just that *muc_say_after* hook is called AFTER the xhtm-im body has been + generated. So you *MUST* not insert any color attribute in the body using this + hook. The hook is less safe that *muc_say* and most of the time you should not + use it at all. The parameters given to the handlers are: + +* _message_: Message to be sent. +* _tab_: Tab in which the message will be sent. + +*private_say*:: +message+, +tab+ + +The handlers for this event are called whenever you say something in a private + conversaton in a MUC (through the /say command or by direct input). The + parameters given to the handlers are: + +* _message_: Message to be sent. +* _tab_: Tab in which the message will be sent. + +*private_say_after*:: +message+, +tab+ + +The handlers for this event are called whenever you say something in a MUC + (through the /say command or by direct input). The difference with private_say + is just that *private_say_after* hook is called AFTER the xhtm-im body has + been generated and the message has been displayed on the conversation, this + means that if you modify the body, the message that will be sent will not be + the same that the one displayed in the text buffer. So you *MUST* not insert + any color attribute in the body using this hook. The hook is less safe that + *private_say* and most of the time you should not use it at all. The + parameters given to the handlers are: + +* _message_: Message to be sent. +* _tab_: Tab in which the message will be sent. + +*conversation_say*:: +message+, +tab+ + +The handlers for this event are called whenever you say something in direct + conversation (through the /say command or by direct input). The parameters + given to the handler are: + +* _message_: Message to be sent. +* _tab_: Tab in which the message will be sent. + +*conversation_say_after*:: +message+, +tab+ + +The handlers for this event are called whenever you say something in a MUC + (through the /say command or by direct input). The difference with + *conversation_say* is just that *conversation_say_after* hook is called AFTER + the xhtm-im body has been generated and the message has been displayed on the + conversation, this means that if you modify the body, the message that will be + sent will not be the same that the one displayed in the text buffer. So you + *MUST* not insert any color attribute in the body using this hook. The hook is + less safe that *conversation_say* and most of the time you should not use it at + all. The parameters given to the handlers are: + +* _message_: Message to be sent. +* _tab_: Tab in which the message will be sent. + +*muc_msg*:: +message+, +tab+ + +The handlers for this event are called whenever you receive a message in a MUC. + The parameters given to the handler are: + +* _message_: Message received. +* _tab_: Tab in which the message was received. + +*private_msg*:: +message+, +tab+ + +The handlers for this event are called whenever you receive a message in a + private conversation in a MUC. The parameters given to the handler are: + +* _message_: Message received. +* _tab_: Tab in which the message was received. + +*conversation_msg*:: +message+, +tab+ + +The handlers for this event are called whenever you receive a message in a + direct conversation. The parameters given to the handler are: + +* _message_: Message received. +* _tab_: Tab in which the message was received. + +*normal_chatstate*:: +message+, +tab+ + +The handlers for this events are called whenever you receive a chatstate in a + direct conversation. The parameters given to this handler are: + +* _message_: Chatstate received. +* _tab_: Tab of the concerned conversation + +*muc_chatstate*:: +message+, +tab+ + +The handlers for this events are called whenever you receive a chatstate in a + MUC. The parameters given to this handler are: + +* _message_: Chatstate received. +* _tab_: Tab of the concerned MUC. + +*private_chatstate*:: +message+, +tab+ + +The handlers for this events are called whenever you receive a chatstate in a + private conversation in a MUC. The parameters given to this handler are: + +* _message_: Chatstate received. +* _tab_: Tab of the concerned conversation. + +*normal_presence*:: +presence+, +resource+ + +The handlers for this events are called whenever you receive a presence from + one of your contacts. The parameters given to this handler are: + +* _presence_: Presence received. +* _resource_: The resource that emitted the presence. + +*muc_presence*:: +presence+, +tab+ + +The handlers for this events are called whenever you receive a presence from + someone in a MUC. The parameters given to this handler are: + +* _presence_: Presence received. +* _tab_: Tab of the concerned MUC. + +*send_normal_presence*:: +presence+ + +The handlers for this events are called whenever you send a presence “normal” +presence, i.e. a presence for your contacts or some direct JIDs, but *not* in a +MUC. The parameters given to this handler are: + +* _presence_: The presence about to be sent. + + +SleekXMPP events +~~~~~~~~~~~~~~~~ + +For the sleekxmpp events, please refer to the + https://github.com/fritzy/SleekXMPP/wiki/Event-Index[sleekxmpp event index]. + + +CAUTION: Plugins are in an experimental state and this API might change + slightly in a near future. You have to be aware of that while making a plugin. Examples -------- [[example-1]] .Add a simple command that sends "Hello World!" into the conversation -===================================================================== +================================================================= [source,python] --------------- class Plugin(BasePlugin): def init(self): self.add_command('hello', self.command_hello, "Usage: /hello\nHello: Send 'Hello World!'", None) - def command_hello(self, args): + def command_hello(self, arg): self.core.send_message('Hello World!') --------------- -===================================================================== +================================================================= [[example-2]] @@ -122,11 +308,11 @@ class Plugin(BasePlugin): --------------- class Plugin(BasePlugin): def init(self): - self.add_event_handler('groupchat_message', self.on_groupchat_message) + self.add_event_handler('muc_msg', self.on_groupchat_message) - def on_groupchat_message(self, message): + def on_groupchat_message(self, message, tab): if message['mucnick'] == "Partauche": - self.core.send_message('tg', to=message.getMucroom()) + tab.command_say('tg') --------------- ===================================================================== diff --git a/doc/en/plugins/gpg.txt b/doc/en/plugins/gpg.txt new file mode 100644 index 00000000..9e87b6c7 --- /dev/null +++ b/doc/en/plugins/gpg.txt @@ -0,0 +1,101 @@ +GPG +=== + +This plugin implements the +link:http://xmpp.org/extensions/xep-0027.html[XEP-0027] “Current Jabber OpenPGP +Usage”. + +This is a plugin used to encrypt one-to-one conversation using the PGP +encryption method. You can use it if you want really good privacy. Without this +encryption, your messages are encrypted *at least* from your client (poezio) to +your server. The message is decrypted by your server and you cannot control the +encryption method of your messages from your server to your contact’s server +(unless you are your own server’s administrator), nor from your contact’s +server to your contact’s client. + +This plugin does end-to-end encryption. This means that *only* your contact can +decrypt your messages, and it is fully encrypted during *all* its travel +through the internet. + +Note that if you are having an encrypted conversation with a contact, you can +*not* send XHTML-IM messages to him. They will be remove and be replaced by +plain text messages. + +Installation and configuration +------------------------------ + +You should autoload this plugin, as it will send your signed presence directly +on login, making it easier for your contact’s clients to know that you are +supporting GPG encryption. To do that, use the _plugins_autoload_ configuration +option. + +You need to create a plugin configuration file. Create a file named _gpg.cfg_ +into your plugins configuration directory (_~/.config/poezio/plugins_ by +default), and fill it like this: + +[source,python] +--------------------------------------------------------------------- +[Poezio] +keyid = 091F9C78 +passphrase = your OPTIONAL passphrase + +[keys] +example@jabber.org = E3CFCDE2 +juliet@xmpp.org = EF27ABCD +--------------------------------------------------------------------- + +The *Poezio* section is about your key. You need to specify the keyid, for the +key you want to use. You can as well provide a passphrase. If you don’t, you +should use a gpg agent or something like that that will ask your passphrase +whenever you need it. + +The *keys* section contains your contact’s id keys. For each contact you want +to have encrypted conversations with, add her/his JID associated with the keyid +of his/her key. + +And that’s it, now you need to talk directly to the *full* jid of your +contacts. Poezio doesn’t let you encrypt messages whom recipients is a bare +JID. + +Additionnal information on GnuPG +-------------------------------- + +Create a key +~~~~~~~~~~~~ + +To create a personal key, use +================== +gpg --gen-key +================== +and fill the instructions + + +Keyid +~~~~~ +The keyid (required in the gpg.cfg configuration file) is a 8 character-long +key. You can get the ones you created or imported by using the command +======================= +gpg --list-keys +======================= +You will get something like + +--------------------------------------------------------------------- +pub 4096R/01234567 2011-11-11 +uid Your Name Here (comment) <email@example.org> +sub 4096R/AAFFBBCC 2011-11-11 + +pub 2048R/12345678 2011-11-12 [expire: 2011-11-22] +uid A contact’s name (comment) <fake@fake.fr> +sub 2048R/FFBBAACC 2011-11-12 [expire: 2011-11-22] +--------------------------------------------------------------------- + +In this example, the keyids are *01234567* and *12345678*. + +Share your key +~~~~~~~~~~~~~~ +Use +=========================== +gpg --send-keys --keyserver pgp.mit.edu <keyid> +=========================== +to upload you public key on a public server. + diff --git a/doc/en/usage.txt b/doc/en/usage.txt index f11ab5ed..30bdf886 100644 --- a/doc/en/usage.txt +++ b/doc/en/usage.txt @@ -211,6 +211,10 @@ These commands work in *any* tab. */theme*:: Reload the theme defined in the config file. +*/presence <jid> [type] [status]*:: Send a directed presence to _jid_ using _type_ and _status_ if provided. + +*/rawxml*:: Send a custom XML stanza. + */list [server.tld]*:: Get the list of public chatrooms in the specified server . @@ -242,6 +246,8 @@ These commands will work in any conversation tab (MultiUserChat, Private, or to begin with a _/_). Note that you can also send message starting with a _/_ by starting it with _//_. +*/xhtml <custom xhtml>*:: Send a custom xhtml message to the current tab. + MultiUserChat tab commands ~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/plugins/exec.py b/plugins/exec.py index f7f451df..c729b555 100644 --- a/plugins/exec.py +++ b/plugins/exec.py @@ -22,12 +22,7 @@ class Plugin(BasePlugin): self.core.command_help('exec') return try: - cut_command = shlex.split(command) - except Exception as e: - self.core.information('Failed to parse command: %s' % (e,), 'Error') - return - try: - process = subprocess.Popen(cut_command, stdout=subprocess.PIPE) + process = subprocess.Popen(['sh', '-c', command], stdout=subprocess.PIPE) except OSError as e: self.core.information('Failed to execute command: %s' % (e,), 'Error') return diff --git a/plugins/figlet.py b/plugins/figlet.py index cf885352..4d147956 100644 --- a/plugins/figlet.py +++ b/plugins/figlet.py @@ -3,11 +3,11 @@ import subprocess class Plugin(BasePlugin): def init(self): - self.add_poezio_event_handler('muc_say', self.figletize) - self.add_poezio_event_handler('conversation_say', self.figletize) - self.add_poezio_event_handler('private_say', self.figletize) + self.add_event_handler('muc_say', self.figletize) + self.add_event_handler('conversation_say', self.figletize) + self.add_event_handler('private_say', self.figletize) - def figletize(self, msg): + def figletize(self, msg, tab): process = subprocess.Popen(['figlet', msg['body']], stdout=subprocess.PIPE) result = process.communicate()[0].decode('utf-8') msg['body'] = result diff --git a/plugins/gpg/__init__.py b/plugins/gpg/__init__.py new file mode 100644 index 00000000..f1b97575 --- /dev/null +++ b/plugins/gpg/__init__.py @@ -0,0 +1,172 @@ +from gpg import gnupg +from xml.etree import cElementTree as ET +import xml.sax.saxutils + +import logging +log = logging.getLogger(__name__) + +from plugin import BasePlugin + +from tabs import ConversationTab + +NS_SIGNED = "jabber:x:signed" +NS_ENCRYPTED = "jabber:x:encrypted" + + +SIGNED_ATTACHED_MESSAGE = """-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA1 + +%(clear)s +-----BEGIN PGP SIGNATURE----- +Version: GnuPG + +%(data)s +-----END PGP SIGNATURE----- +""" + + +ENCRYPTED_MESSAGE = """-----BEGIN PGP MESSAGE----- +Version: GnuPG + +%(data)s +-----END PGP MESSAGE-----""" + + +class Plugin(BasePlugin): + def init(self): + self.contacts = {} + # a dict of {full-JID: 'signed'/'valid'/'invalid'/'disabled'} + # Whenever we receive a signed presence from a JID, we add it to this + # dict, this way we know if we can encrypt the messages we will send to + # this JID. + # If that resource sends a non-signed presence, then we remove it + # from that dict and stop encrypting our messages. + # 'disabled' means that the user do NOT want to encrypt its messages + # even if the key is valid. + self.gpg = gnupg.GPG() + self.keyid = self.config.get('keyid', '') or None + self.passphrase = self.config.get('passphrase', '') or None + if not self.keyid: + self.core.information('No GPG keyid provided in the configuration', 'Warning') + + self.add_event_handler('send_normal_presence', self.sign_presence) + self.add_event_handler('normal_presence', self.on_normal_presence) + self.add_event_handler('conversation_say_after', self.on_conversation_say) + self.add_event_handler('conversation_msg', self.on_conversation_msg) + + self.add_tab_command(ConversationTab, 'gpg', self.command_gpg, "Usage: /gpg <force|disable>\nGpg: Force or disable gpg encryption with this fulljid.", lambda the_input: the_input.auto_completion(['force', 'disable'])) + ConversationTab.add_information_element('gpg', self.display_encryption_status) + + def cleanup(self): + self.send_unsigned_presence() + ConversationTab.remove_information_element('gpg') + self.del_tab_command(ConversationTab, 'gpg') + + def sign_presence(self, presence): + """ + Sign every normal presence we send + """ + signed_element = ET.Element('{%s}x' % (NS_SIGNED,)) + t = self.gpg.sign(presence['status'], keyid=self.keyid, passphrase=self.passphrase, detach=True) + if not t: + self.core.information('Could not sign presence. Disabling GPG module', 'Info') + self.core.plugin_manager.unload('gpg') + return + text = xml.sax.saxutils.escape(str(t)) + signed_element.text = self.remove_gpg_headers(text) + presence.append(signed_element) + + def send_unsigned_presence(self): + """ + Send our current presence, to everyone, but unsigned, to indicate + that we cannot/do not want to encrypt/decrypt messages. + """ + current_presence = self.core.get_status() + self.core.command_status('%s %s' % (current_presence.show or 'available', current_presence.message,)) + + def on_normal_presence(self, presence, resource): + """ + Check if it’s signed, if it is and we can verify the signature, + add 'valid' or 'invalid' into the dict. If it cannot be verified, just add + 'signed'. Otherwise, do nothing. + """ + signed = presence.find('{%s}x' % (NS_SIGNED,)) + bare = presence['from'].bare + full = presence['from'].full + if signed is None: + if bare in self.contacts.keys(): + del self.contacts[bare] + return + if self.config.has_section('keys') and bare in self.config.options('keys'): + to_verify = SIGNED_ATTACHED_MESSAGE % {'clear': presence['status'], + 'data': signed.text} + verify = self.gpg.verify(to_verify) + if verify: + self.contacts[full] = 'valid' + else: + self.contacts[full] = 'invalid' + else: + self.contacts[full] = 'signed' + + def on_conversation_say(self, message, tab): + """ + Check if the contact has a signed AND veryfied signature. + If yes, encrypt the message with her key. + """ + to = message['to'] + if not message['body']: + # there’s nothing to encrypt if this is a chatstate, for example + return + signed = to.full in self.contacts.keys() + if signed: + veryfied = self.contacts[to.full] == 'valid' + else: + veryfied = False + if veryfied: + # remove the xhtm_im body if present, because that + # cannot be encrypted. + del message['xhtml_im'] + encrypted_element = ET.Element('{%s}x' % (NS_ENCRYPTED,)) + encrypted_element.text = self.remove_gpg_headers(xml.sax.saxutils.escape(str(self.gpg.encrypt(message['body'], self.config.get(to.bare, '', section='keys'))))) + message.append(encrypted_element) + message['body'] = 'This message has been encrypted.' + + def on_conversation_msg(self, message, tab): + """ + Check if the message is encrypted, and decrypt it if we can. + """ + encrypted = message.find('{%s}x' % (NS_ENCRYPTED,)) + fro = message['from'] + if encrypted is not None: + if self.config.has_section('keys') and fro.bare in self.config.options('keys'): + keyid = self.config.get(fro.bare, '', 'keys') + decrypted = self.gpg.decrypt(ENCRYPTED_MESSAGE % {'data': str(encrypted.text)}, passphrase=self.passphrase) + if not decrypted: + self.core.information('Could not decrypt message from %s' % (fro.full),) + return + message['body'] = str(decrypted) + + def display_encryption_status(self, jid): + """ + Returns the status of encryption for the associated jid. This is to be used + in the ConversationTab’s InfoWin. + """ + if jid.full not in self.contacts.keys(): + return '' + return ' GPG Key: %s' % self.contacts[jid.full] + + def command_gpg(self, args): + # TODO + return + + def remove_gpg_headers(self, text): + lines = text.splitlines() + while lines[0].strip() != '': + lines.pop(0) + while lines[0].strip() == '': + lines.pop(0) + res = [] + for line in lines: + if not line.startswith('---'): + res.append(line) + return '\n'.join(res) diff --git a/plugins/gpg/gnupg.py b/plugins/gpg/gnupg.py new file mode 100644 index 00000000..e9e45711 --- /dev/null +++ b/plugins/gpg/gnupg.py @@ -0,0 +1,975 @@ +""" A wrapper for the 'gpg' command:: + +Portions of this module are derived from A.M. Kuchling's well-designed +GPG.py, using Richard Jones' updated version 1.3, which can be found +in the pycrypto CVS repository on Sourceforge: + +http://pycrypto.cvs.sourceforge.net/viewvc/pycrypto/gpg/GPG.py + +This module is *not* forward-compatible with amk's; some of the +old interface has changed. For instance, since I've added decrypt +functionality, I elected to initialize with a 'gnupghome' argument +instead of 'keyring', so that gpg can find both the public and secret +keyrings. I've also altered some of the returned objects in order for +the caller to not have to know as much about the internals of the +result classes. + +While the rest of ISconf is released under the GPL, I am releasing +this single file under the same terms that A.M. Kuchling used for +pycrypto. + +Steve Traugott, stevegt@terraluna.org +Thu Jun 23 21:27:20 PDT 2005 + +This version of the module has been modified from Steve Traugott's version +(see http://trac.t7a.org/isconf/browser/trunk/lib/python/isconf/GPG.py) by +Vinay Sajip to make use of the subprocess module (Steve's version uses os.fork() +and so does not work on Windows). Renamed to gnupg.py to avoid confusion with +the previous versions. + +Modifications Copyright (C) 2008-2011 Vinay Sajip. All rights reserved. + +A unittest harness (test_gnupg.py) has also been added. +""" +import locale + +__author__ = "Vinay Sajip" +__date__ = "$25-Jan-2011 11:40:48$" + +try: + from io import StringIO + from io import TextIOWrapper + from io import BufferedReader + from io import BufferedWriter +except ImportError: + from cStringIO import StringIO + class BufferedReader: pass + class BufferedWriter: pass + +import locale +import logging +import os +import socket +from subprocess import Popen +from subprocess import PIPE +import sys +import threading + +try: + import logging.NullHandler as NullHandler +except ImportError: + class NullHandler(logging.Handler): + def handle(self, record): + pass +try: + unicode + _py3k = False +except NameError: + _py3k = True + +logger = logging.getLogger(__name__) +if not logger.handlers: + logger.addHandler(NullHandler()) + +def _copy_data(instream, outstream): + # Copy one stream to another + sent = 0 + if hasattr(sys.stdin, 'encoding'): + enc = sys.stdin.encoding + else: + enc = 'ascii' + while True: + data = instream.read(1024) + if len(data) == 0: + break + sent += len(data) + logger.debug("sending chunk (%d): %r", sent, data[:256]) + try: + outstream.write(data) + except UnicodeError: + outstream.write(data.encode(enc)) + except: + # Can sometimes get 'broken pipe' errors even when the data has all + # been sent + logger.exception('Error sending data') + break + try: + outstream.close() + except IOError: + logger.warning('Exception occurred while closing: ignored', exc_info=1) + logger.debug("closed output, %d bytes sent", sent) + +def _threaded_copy_data(instream, outstream): + wr = threading.Thread(target=_copy_data, args=(instream, outstream)) + wr.setDaemon(True) + logger.debug('data copier: %r, %r, %r', wr, instream, outstream) + wr.start() + return wr + +def _write_passphrase(stream, passphrase, encoding): + passphrase = '%s\n' % passphrase + passphrase = passphrase.encode(encoding) + stream.write(passphrase) + logger.debug("Passphrase written") + +def _is_sequence(instance): + return isinstance(instance,list) or isinstance(instance,tuple) + +def _wrap_input(inp): + if isinstance(inp, BufferedWriter): + oldinp = inp + inp = TextIOWrapper(inp) + logger.debug('wrapped input: %r -> %r', oldinp, inp) + return inp + +def _wrap_output(outp): + if isinstance(outp, BufferedReader): + oldoutp = outp + outp = TextIOWrapper(outp) + logger.debug('wrapped output: %r -> %r', oldoutp, outp) + return outp + +#The following is needed for Python2.7 :-( +def _make_file(s): + try: + rv = StringIO(s) + except (TypeError, UnicodeError): + from io import BytesIO + rv = BytesIO(s) + return rv + +def _make_binary_stream(s, encoding): + try: + if _py3k: + if isinstance(s, str): + s = s.encode(encoding) + else: + if type(s) is not str: + s = s.encode(encoding) + from io import BytesIO + rv = BytesIO(s) + except ImportError: + rv = StringIO(s) + return rv + +class GPG(object): + "Encapsulate access to the gpg executable" + def __init__(self, gpgbinary='gpg', gnupghome=None, verbose=False, use_agent=False): + """Initialize a GPG process wrapper. Options are: + + gpgbinary -- full pathname for GPG binary. + + gnupghome -- full pathname to where we can find the public and + private keyrings. Default is whatever gpg defaults to. + """ + self.gpgbinary = gpgbinary + self.gnupghome = gnupghome + self.verbose = verbose + self.use_agent = use_agent + self.encoding = locale.getpreferredencoding() + if self.encoding is None: # This happens on Jython! + self.encoding = sys.stdin.encoding + if gnupghome and not os.path.isdir(self.gnupghome): + os.makedirs(self.gnupghome,0x1C0) + p = self._open_subprocess(["--version"]) + result = Verify() # any result will do for this + self._collect_output(p, result, stdin=p.stdin) + if p.returncode != 0: + raise ValueError("Error invoking gpg: %s: %s" % (p.returncode, + result.stderr)) + + def _open_subprocess(self, args, passphrase=False): + # Internal method: open a pipe to a GPG subprocess and return + # the file objects for communicating with it. + cmd = [self.gpgbinary, '--status-fd 2 --no-tty'] + if self.gnupghome: + cmd.append('--homedir "%s" ' % self.gnupghome) + if passphrase: + cmd.append('--batch --passphrase-fd 0') + if self.use_agent: + cmd.append('--use-agent') + cmd.extend(args) + cmd = ' '.join(cmd) + if self.verbose: + print(cmd) + logger.debug("%s", cmd) + return Popen(cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=PIPE) + + def _read_response(self, stream, result): + # Internal method: reads all the output from GPG, taking notice + # only of lines that begin with the magic [GNUPG:] prefix. + # + # Calls methods on the response object for each valid token found, + # with the arg being the remainder of the status line. + lines = [] + while True: + line = stream.readline() + if not isinstance(line, str): + line = line.decode('utf-8') + lines.append(line) + if self.verbose: + print(line) + logger.debug("%s", line.rstrip()) + if line == "": break + line = line.rstrip() + if line[0:9] == '[GNUPG:] ': + # Chop off the prefix + line = line[9:] + L = line.split(None, 1) + keyword = L[0] + if len(L) > 1: + value = L[1] + else: + value = "" + result.handle_status(keyword, value) + result.stderr = ''.join(lines) + + def _read_data(self, stream, result): + # Read the contents of the file from GPG's stdout + chunks = [] + while True: + data = stream.read(1024) + if len(data) == 0: + break + logger.debug("chunk: %r" % data[:256]) + chunks.append(data) + if _py3k: + # Join using b'' or '', as appropriate + result.data = type(data)().join(chunks) + else: + result.data = ''.join(chunks) + + def _collect_output(self, process, result, writer=None, stdin=None): + """ + Drain the subprocesses output streams, writing the collected output + to the result. If a writer thread (writing to the subprocess) is given, + make sure it's joined before returning. If a stdin stream is given, + close it before returning. + """ + stderr = _wrap_output(process.stderr) + rr = threading.Thread(target=self._read_response, args=(stderr, result)) + rr.setDaemon(True) + logger.debug('stderr reader: %r', rr) + rr.start() + + stdout = process.stdout # _wrap_output(process.stdout) + dr = threading.Thread(target=self._read_data, args=(stdout, result)) + dr.setDaemon(True) + logger.debug('stdout reader: %r', dr) + dr.start() + + dr.join() + rr.join() + if writer is not None: + writer.join() + process.wait() + if stdin is not None: + try: + stdin.close() + except IOError: + pass + stderr.close() + stdout.close() + + def _handle_io(self, args, file, result, passphrase=None, binary=False): + "Handle a call to GPG - pass input data, collect output data" + # Handle a basic data call - pass data to GPG, handle the output + # including status information. Garbage In, Garbage Out :) + p = self._open_subprocess(args, passphrase is not None) + if not binary and not isinstance(file, BufferedReader): + stdin = _wrap_input(p.stdin) + else: + stdin = p.stdin + if passphrase: + _write_passphrase(stdin, passphrase, self.encoding) + writer = _threaded_copy_data(file, stdin) + self._collect_output(p, result, writer, stdin) + return result + + # + # SIGNATURE METHODS + # + def sign(self, message, **kwargs): + """sign message""" + f = _make_binary_stream(message, self.encoding) + result = self.sign_file(f, **kwargs) + f.close() + return result + + def sign_file(self, file, keyid=None, passphrase=None, clearsign=True, + detach=False, binary=False): + """sign file""" + logger.debug("sign_file: %s", file) + if binary: + args = ['-s'] + else: + args = ['-sa'] + # You can't specify detach-sign and clearsign together: gpg ignores + # the detach-sign in that case. + if detach: + args.append("--detach-sign") + elif clearsign: + args.append("--clearsign") + if keyid: + args.append("--default-key %s" % keyid) + result = Sign(self.encoding) + #We could use _handle_io here except for the fact that if the + #passphrase is bad, gpg bails and you can't write the message. + #self._handle_io(args, _make_file(message), result, passphrase=passphrase) + p = self._open_subprocess(args, passphrase is not None) + try: + stdin = p.stdin + if passphrase: + _write_passphrase(stdin, passphrase, self.encoding) + writer = _threaded_copy_data(file, stdin) + except IOError: + logging.exception("error writing message") + writer = None + self._collect_output(p, result, writer, stdin) + return result + + def verify(self, data): + """Verify the signature on the contents of the string 'data' + + >>> gpg = GPG(gnupghome="keys") + >>> input = gpg.gen_key_input(Passphrase='foo') + >>> key = gpg.gen_key(input) + >>> assert key + >>> sig = gpg.sign('hello',keyid=key.fingerprint,passphrase='bar') + >>> assert not sig + >>> sig = gpg.sign('hello',keyid=key.fingerprint,passphrase='foo') + >>> assert sig + >>> verify = gpg.verify(sig.data) + >>> assert verify + + """ + f = _make_binary_stream(data, self.encoding) + result = self.verify_file(f) + f.close() + return result + + def verify_file(self, file, data_filename=None): + "Verify the signature on the contents of the file-like object 'file'" + logger.debug('verify_file: %r, %r', file, data_filename) + result = Verify() + args = ['--verify'] + if data_filename is None: + self._handle_io(args, file, result, binary=True) + else: + logger.debug('Handling detached verification') + import tempfile + fd, fn = tempfile.mkstemp(prefix='pygpg') + s = file.read() + file.close() + logger.debug('Wrote to temp file: %r', s) + os.write(fd, s) + os.close(fd) + args.append(fn) + args.append(data_filename) + try: + p = self._open_subprocess(args) + self._collect_output(p, result, stdin=p.stdin) + finally: + os.unlink(fn) + return result + + # + # KEY MANAGEMENT + # + + def import_keys(self, key_data): + """ import the key_data into our keyring + + >>> import shutil + >>> shutil.rmtree("keys") + >>> gpg = GPG(gnupghome="keys") + >>> input = gpg.gen_key_input() + >>> result = gpg.gen_key(input) + >>> print1 = result.fingerprint + >>> result = gpg.gen_key(input) + >>> print2 = result.fingerprint + >>> pubkey1 = gpg.export_keys(print1) + >>> seckey1 = gpg.export_keys(print1,secret=True) + >>> seckeys = gpg.list_keys(secret=True) + >>> pubkeys = gpg.list_keys() + >>> assert print1 in seckeys.fingerprints + >>> assert print1 in pubkeys.fingerprints + >>> str(gpg.delete_keys(print1)) + 'Must delete secret key first' + >>> str(gpg.delete_keys(print1,secret=True)) + 'ok' + >>> str(gpg.delete_keys(print1)) + 'ok' + >>> str(gpg.delete_keys("nosuchkey")) + 'No such key' + >>> seckeys = gpg.list_keys(secret=True) + >>> pubkeys = gpg.list_keys() + >>> assert not print1 in seckeys.fingerprints + >>> assert not print1 in pubkeys.fingerprints + >>> result = gpg.import_keys('foo') + >>> assert not result + >>> result = gpg.import_keys(pubkey1) + >>> pubkeys = gpg.list_keys() + >>> seckeys = gpg.list_keys(secret=True) + >>> assert not print1 in seckeys.fingerprints + >>> assert print1 in pubkeys.fingerprints + >>> result = gpg.import_keys(seckey1) + >>> assert result + >>> seckeys = gpg.list_keys(secret=True) + >>> pubkeys = gpg.list_keys() + >>> assert print1 in seckeys.fingerprints + >>> assert print1 in pubkeys.fingerprints + >>> assert print2 in pubkeys.fingerprints + + """ + result = ImportResult() + logger.debug('import_keys: %r', key_data[:256]) + data = _make_binary_stream(key_data, self.encoding) + self._handle_io(['--import'], data, result, binary=True) + logger.debug('import_keys result: %r', result.__dict__) + data.close() + return result + + def delete_keys(self, fingerprints, secret=False): + which='key' + if secret: + which='secret-key' + if _is_sequence(fingerprints): + fingerprints = ' '.join(fingerprints) + args = ["--batch --delete-%s %s" % (which, fingerprints)] + result = DeleteResult() + p = self._open_subprocess(args) + self._collect_output(p, result, stdin=p.stdin) + return result + + def export_keys(self, keyids, secret=False): + "export the indicated keys. 'keyid' is anything gpg accepts" + which='' + if secret: + which='-secret-key' + if _is_sequence(keyids): + keyids = ' '.join(keyids) + args = ["--armor --export%s %s" % (which, keyids)] + p = self._open_subprocess(args) + # gpg --export produces no status-fd output; stdout will be + # empty in case of failure + #stdout, stderr = p.communicate() + result = DeleteResult() # any result will do + self._collect_output(p, result, stdin=p.stdin) + logger.debug('export_keys result: %r', result.data) + return result.data.decode(self.encoding, 'replace') + + def list_keys(self, secret=False): + """ list the keys currently in the keyring + + >>> import shutil + >>> shutil.rmtree("keys") + >>> gpg = GPG(gnupghome="keys") + >>> input = gpg.gen_key_input() + >>> result = gpg.gen_key(input) + >>> print1 = result.fingerprint + >>> result = gpg.gen_key(input) + >>> print2 = result.fingerprint + >>> pubkeys = gpg.list_keys() + >>> assert print1 in pubkeys.fingerprints + >>> assert print2 in pubkeys.fingerprints + + """ + + which='keys' + if secret: + which='secret-keys' + args = "--list-%s --fixed-list-mode --fingerprint --with-colons" % (which,) + args = [args] + p = self._open_subprocess(args) + + # there might be some status thingumy here I should handle... (amk) + # ...nope, unless you care about expired sigs or keys (stevegt) + + # Get the response information + result = ListKeys() + self._collect_output(p, result, stdin=p.stdin) + lines = result.data.decode(self.encoding, 'replace').splitlines() + valid_keywords = 'pub uid sec fpr sub'.split() + for line in lines: + if self.verbose: + print(line) + logger.debug("line: %r", line.rstrip()) + if not line: + break + L = line.strip().split(':') + if not L: + continue + keyword = L[0] + if keyword in valid_keywords: + getattr(result, keyword)(L) + return result + + def gen_key(self, input): + """Generate a key; you might use gen_key_input() to create the + control input. + + >>> gpg = GPG(gnupghome="keys") + >>> input = gpg.gen_key_input() + >>> result = gpg.gen_key(input) + >>> assert result + >>> result = gpg.gen_key('foo') + >>> assert not result + + """ + args = ["--gen-key --batch"] + result = GenKey() + f = _make_file(input) + self._handle_io(args, f, result) + f.close() + return result + + def gen_key_input(self, **kwargs): + """ + Generate --gen-key input per gpg doc/DETAILS + """ + parms = {} + for key, val in list(kwargs.items()): + key = key.replace('_','-').title() + parms[key] = val + parms.setdefault('Key-Type','RSA') + parms.setdefault('Key-Length',1024) + parms.setdefault('Name-Real', "Autogenerated Key") + parms.setdefault('Name-Comment', "Generated by gnupg.py") + try: + logname = os.environ['LOGNAME'] + except KeyError: + logname = os.environ['USERNAME'] + hostname = socket.gethostname() + parms.setdefault('Name-Email', "%s@%s" % (logname.replace(' ', '_'), + hostname)) + out = "Key-Type: %s\n" % parms.pop('Key-Type') + for key, val in list(parms.items()): + out += "%s: %s\n" % (key, val) + out += "%commit\n" + return out + + # Key-Type: RSA + # Key-Length: 1024 + # Name-Real: ISdlink Server on %s + # Name-Comment: Created by %s + # Name-Email: isdlink@%s + # Expire-Date: 0 + # %commit + # + # + # Key-Type: DSA + # Key-Length: 1024 + # Subkey-Type: ELG-E + # Subkey-Length: 1024 + # Name-Real: Joe Tester + # Name-Comment: with stupid passphrase + # Name-Email: joe@foo.bar + # Expire-Date: 0 + # Passphrase: abc + # %pubring foo.pub + # %secring foo.sec + # %commit + + # + # ENCRYPTION + # + def encrypt_file(self, file, recipients, sign=None, + always_trust=False, passphrase=None, + armor=True, output=None): + "Encrypt the message read from the file-like object 'file'" + args = ['--encrypt'] + if armor: # create ascii-armored output - set to False for binary output + args.append('--armor') + if output: # write the output to a file with the specified name + if os.path.exists(output): + os.remove(output) # to avoid overwrite confirmation message + args.append('--output %s' % output) + if not _is_sequence(recipients): + recipients = (recipients,) + for recipient in recipients: + args.append('--recipient %s' % recipient) + if sign: + args.append("--sign --default-key %s" % sign) + if always_trust: + args.append("--always-trust") + result = Crypt(self.encoding) + self._handle_io(args, file, result, passphrase=passphrase, binary=True) + logger.debug('encrypt result: %r', result.data) + return result + + def encrypt(self, data, recipients, **kwargs): + """Encrypt the message contained in the string 'data' + + >>> import shutil + >>> if os.path.exists("keys"): + ... shutil.rmtree("keys") + >>> gpg = GPG(gnupghome="keys") + >>> input = gpg.gen_key_input(passphrase='foo') + >>> result = gpg.gen_key(input) + >>> print1 = result.fingerprint + >>> input = gpg.gen_key_input() + >>> result = gpg.gen_key(input) + >>> print2 = result.fingerprint + >>> result = gpg.encrypt("hello",print2) + >>> message = str(result) + >>> assert message != 'hello' + >>> result = gpg.decrypt(message) + >>> assert result + >>> str(result) + 'hello' + >>> result = gpg.encrypt("hello again",print1) + >>> message = str(result) + >>> result = gpg.decrypt(message) + >>> result.status + 'need passphrase' + >>> result = gpg.decrypt(message,passphrase='bar') + >>> result.status + 'decryption failed' + >>> assert not result + >>> result = gpg.decrypt(message,passphrase='foo') + >>> result.status + 'decryption ok' + >>> str(result) + 'hello again' + >>> result = gpg.encrypt("signed hello",print2,sign=print1) + >>> result.status + 'need passphrase' + >>> result = gpg.encrypt("signed hello",print2,sign=print1,passphrase='foo') + >>> result.status + 'encryption ok' + >>> message = str(result) + >>> result = gpg.decrypt(message) + >>> result.status + 'decryption ok' + >>> assert result.fingerprint == print1 + + """ + data = _make_binary_stream(data, self.encoding) + result = self.encrypt_file(data, recipients, **kwargs) + data.close() + return result + + def decrypt(self, message, **kwargs): + data = _make_binary_stream(message, self.encoding) + result = self.decrypt_file(data, **kwargs) + data.close() + return result + + def decrypt_file(self, file, always_trust=False, passphrase=None, + output=None): + args = ["--decrypt"] + if output: # write the output to a file with the specified name + if os.path.exists(output): + os.remove(output) # to avoid overwrite confirmation message + args.append('--output %s' % output) + if always_trust: + args.append("--always-trust") + result = Crypt(self.encoding) + self._handle_io(args, file, result, passphrase, binary=True) + logger.debug('decrypt result: %r', result.data) + return result + +class Verify(object): + "Handle status messages for --verify" + + def __init__(self): + self.valid = False + self.fingerprint = self.creation_date = self.timestamp = None + self.signature_id = self.key_id = None + self.username = None + + def __nonzero__(self): + return self.valid + + __bool__ = __nonzero__ + + def handle_status(self, key, value): + if key in ("TRUST_UNDEFINED", "TRUST_NEVER", "TRUST_MARGINAL", + "TRUST_FULLY", "TRUST_ULTIMATE", "RSA_OR_IDEA"): + pass + elif key in ("PLAINTEXT", "PLAINTEXT_LENGTH"): + pass + elif key == "IMPORT_RES": + # If auto-key-retrieve option is enabled, this can happen + pass + elif key == "BADSIG": + self.valid = False + self.key_id, self.username = value.split(None, 1) + elif key == "GOODSIG": + self.valid = True + self.key_id, self.username = value.split(None, 1) + elif key == "VALIDSIG": + (self.fingerprint, + self.creation_date, + self.sig_timestamp, + self.expire_timestamp) = value.split()[:4] + elif key == "SIG_ID": + (self.signature_id, + self.creation_date, self.timestamp) = value.split() + elif key == "ERRSIG": + self.valid = False + (self.key_id, + algo, hash_algo, + cls, + self.timestamp) = value.split()[:5] + elif key == "NO_PUBKEY": + self.valid = False + self.key_id = value + else: + raise ValueError("Unknown status message: %r" % key) + +class ImportResult(object): + "Handle status messages for --import" + + counts = '''count no_user_id imported imported_rsa unchanged + n_uids n_subk n_sigs n_revoc sec_read sec_imported + sec_dups not_imported'''.split() + def __init__(self): + self.imported = [] + self.results = [] + self.fingerprints = [] + for result in self.counts: + setattr(self, result, None) + + def __nonzero__(self): + if self.not_imported: return False + if not self.fingerprints: return False + return True + + __bool__ = __nonzero__ + + ok_reason = { + '0': 'Not actually changed', + '1': 'Entirely new key', + '2': 'New user IDs', + '4': 'New signatures', + '8': 'New subkeys', + '16': 'Contains private key', + } + + problem_reason = { + '0': 'No specific reason given', + '1': 'Invalid Certificate', + '2': 'Issuer Certificate missing', + '3': 'Certificate Chain too long', + '4': 'Error storing certificate', + } + + def handle_status(self, key, value): + if key == "IMPORTED": + # this duplicates info we already see in import_ok & import_problem + pass + elif key == "NODATA": + self.results.append({'fingerprint': None, + 'problem': '0', 'text': 'No valid data found'}) + elif key == "IMPORT_OK": + reason, fingerprint = value.split() + reasons = [] + for code, text in list(self.ok_reason.items()): + if int(reason) | int(code) == int(reason): + reasons.append(text) + reasontext = '\n'.join(reasons) + "\n" + self.results.append({'fingerprint': fingerprint, + 'ok': reason, 'text': reasontext}) + self.fingerprints.append(fingerprint) + elif key == "IMPORT_PROBLEM": + try: + reason, fingerprint = value.split() + except: + reason = value + fingerprint = '<unknown>' + self.results.append({'fingerprint': fingerprint, + 'problem': reason, 'text': self.problem_reason[reason]}) + elif key == "IMPORT_RES": + import_res = value.split() + for i in range(len(self.counts)): + setattr(self, self.counts[i], int(import_res[i])) + elif key == "KEYEXPIRED": + self.results.append({'fingerprint': None, + 'problem': '0', 'text': 'Key expired'}) + elif key == "SIGEXPIRED": + self.results.append({'fingerprint': None, + 'problem': '0', 'text': 'Signature expired'}) + else: + raise ValueError("Unknown status message: %r" % key) + + def summary(self): + l = [] + l.append('%d imported'%self.imported) + if self.not_imported: + l.append('%d not imported'%self.not_imported) + return ', '.join(l) + +class ListKeys(list): + ''' Handle status messages for --list-keys. + + Handle pub and uid (relating the latter to the former). + + Don't care about (info from src/DETAILS): + + crt = X.509 certificate + crs = X.509 certificate and private key available + sub = subkey (secondary key) + ssb = secret subkey (secondary key) + uat = user attribute (same as user id except for field 10). + sig = signature + rev = revocation signature + pkd = public key data (special field format, see below) + grp = reserved for gpgsm + rvk = revocation key + ''' + def __init__(self): + self.curkey = None + self.fingerprints = [] + self.uids = [] + + def key(self, args): + vars = (""" + type trust length algo keyid date expires dummy ownertrust uid + """).split() + self.curkey = {} + for i in range(len(vars)): + self.curkey[vars[i]] = args[i] + self.curkey['uids'] = [] + if self.curkey['uid']: + self.curkey['uids'].append(self.curkey['uid']) + del self.curkey['uid'] + self.curkey['subkeys'] = [] + self.append(self.curkey) + + pub = sec = key + + def fpr(self, args): + self.curkey['fingerprint'] = args[9] + self.fingerprints.append(args[9]) + + def uid(self, args): + self.curkey['uids'].append(args[9]) + self.uids.append(args[9]) + + def sub(self, args): + subkey = [args[4],args[11]] + self.curkey['subkeys'].append(subkey) + + def handle_status(self, key, value): + pass + +class Crypt(Verify): + "Handle status messages for --encrypt and --decrypt" + def __init__(self, encoding): + Verify.__init__(self) + self.data = '' + self.ok = False + self.status = '' + self.encoding = encoding + + def __nonzero__(self): + if self.ok: return True + return False + + __bool__ = __nonzero__ + + def __str__(self): + return self.data.decode(self.encoding, 'replace') + + def handle_status(self, key, value): + if key in ("ENC_TO", "USERID_HINT", "GOODMDC", "END_DECRYPTION", + "BEGIN_SIGNING", "NO_SECKEY"): + pass + elif key in ("NEED_PASSPHRASE", "BAD_PASSPHRASE", "GOOD_PASSPHRASE", + "MISSING_PASSPHRASE", "DECRYPTION_FAILED"): + self.status = key.replace("_", " ").lower() + elif key == "NEED_PASSPHRASE_SYM": + self.status = 'need symmetric passphrase' + elif key == "BEGIN_DECRYPTION": + self.status = 'decryption incomplete' + elif key == "BEGIN_ENCRYPTION": + self.status = 'encryption incomplete' + elif key == "DECRYPTION_OKAY": + self.status = 'decryption ok' + self.ok = True + elif key == "END_ENCRYPTION": + self.status = 'encryption ok' + self.ok = True + elif key == "INV_RECP": + self.status = 'invalid recipient' + elif key == "KEYEXPIRED": + self.status = 'key expired' + elif key == "SIG_CREATED": + self.status = 'sig created' + elif key == "SIGEXPIRED": + self.status = 'sig expired' + else: + Verify.handle_status(self, key, value) + +class GenKey(object): + "Handle status messages for --gen-key" + def __init__(self): + self.type = None + self.fingerprint = None + + def __nonzero__(self): + if self.fingerprint: return True + return False + + __bool__ = __nonzero__ + + def __str__(self): + return self.fingerprint or '' + + def handle_status(self, key, value): + if key in ("PROGRESS", "GOOD_PASSPHRASE", "NODATA"): + pass + elif key == "KEY_CREATED": + (self.type,self.fingerprint) = value.split() + else: + raise ValueError("Unknown status message: %r" % key) + +class DeleteResult(object): + "Handle status messages for --delete-key and --delete-secret-key" + def __init__(self): + self.status = 'ok' + + def __str__(self): + return self.status + + problem_reason = { + '1': 'No such key', + '2': 'Must delete secret key first', + '3': 'Ambigious specification', + } + + def handle_status(self, key, value): + if key == "DELETE_PROBLEM": + self.status = self.problem_reason.get(value, + "Unknown error: %r" % value) + else: + raise ValueError("Unknown status message: %r" % key) + +class Sign(object): + "Handle status messages for --sign" + def __init__(self, encoding): + self.type = None + self.fingerprint = None + self.encoding = encoding + + def __nonzero__(self): + return self.fingerprint is not None + + __bool__ = __nonzero__ + + def __str__(self): + return self.data.decode(self.encoding, 'replace') + + def handle_status(self, key, value): + if key in ("USERID_HINT", "NEED_PASSPHRASE", "BAD_PASSPHRASE", + "GOOD_PASSPHRASE", "BEGIN_SIGNING"): + pass + elif key == "SIG_CREATED": + (self.type, + algo, hashalgo, cls, + self.timestamp, self.fingerprint + ) = value.split() + else: + raise ValueError("Unknown status message: %r" % key) diff --git a/plugins/pacokick.py b/plugins/pacokick.py new file mode 100644 index 00000000..3ecef7a8 --- /dev/null +++ b/plugins/pacokick.py @@ -0,0 +1,20 @@ +from random import choice +from tabs import MucTab + +from plugin import BasePlugin + +class Plugin(BasePlugin): + def init(self): + self.add_command('pacokick', self.command_kick, '/pacokick <nick> [reason]\nPacokick: kick a random user.') + + def command_kick(self, arg): + tab = self.core.current_tab() + if isinstance(tab, MucTab): + kickable = list(filter(lambda x: x.affiliation in ('none', 'member'), tab.users)) + if kickable: + to_kick = choice(kickable) + if to_kick: + to_kick = to_kick.nick + tab.command_kick(to_kick + ' ' +arg) + else: + self.core.information('No one to kick :(', 'Info') diff --git a/plugins/rainbow.py b/plugins/rainbow.py index 0f242027..3c4813d0 100644 --- a/plugins/rainbow.py +++ b/plugins/rainbow.py @@ -12,9 +12,9 @@ def rand_color(): class Plugin(BasePlugin): def init(self): - self.add_poezio_event_handler('muc_say', self.rainbowize) - self.add_poezio_event_handler('private_say', self.rainbowize) - self.add_poezio_event_handler('conversation_say', self.rainbowize) + self.add_event_handler('muc_say', self.rainbowize) + self.add_event_handler('private_say', self.rainbowize) + self.add_event_handler('conversation_say', self.rainbowize) - def rainbowize(self, msg): + def rainbowize(self, msg, tab): msg['body'] = ''.join(['%s%s' % (rand_color(),char,) for char in xhtml.clean_text(msg['body'])]) diff --git a/plugins/test.py b/plugins/test.py index 0d5cdb0a..13ba1e9c 100644 --- a/plugins/test.py +++ b/plugins/test.py @@ -5,14 +5,10 @@ class Plugin(BasePlugin): self.add_command('plugintest', self.command_plugintest, 'Test command') self.add_event_handler('message', self.on_message) self.core.information("Plugin loaded") - self.core.connect('enter', self.on_enter) def cleanup(self): self.core.information("Plugin unloaded") - def on_enter(self, line): - self.core.information('Text sent: {}'.format(line)) - def on_message(self, message): self.core.information("Test plugin received message: {}".format(message)) diff --git a/src/connection.py b/src/connection.py index f407dfe9..0be94097 100644 --- a/src/connection.py +++ b/src/connection.py @@ -51,7 +51,7 @@ class Connection(sleekxmpp.ClientXMPP): self.register_plugin('xep_0085') if config.get('send_poezio_info', 'true') == 'true': info = {'name':'poezio', - 'version':'0.7.2-dev'} + 'version':'0.7.5-dev'} if config.get('send_os_info', 'true') == 'true': info['os'] = common.get_os_info() self.register_plugin('xep_0092', pconfig=info) @@ -63,9 +63,11 @@ class Connection(sleekxmpp.ClientXMPP): # With anon auth. # (domain, config.get('port', 5222)) custom_host = config.get('custom_host', '') - custom_port = config.get('custom_port', -1) - if custom_host and custom_port != -1: + custom_port = config.get('custom_port', 5222) + if custom_host: res = self.connect((custom_host, custom_port), reattempt=False) + elif custom_port != 5222: + res = self.connect((self.boundjid.host, custom_port), reattempt=False) else: res = self.connect(reattempt=False) if not res: diff --git a/src/contact.py b/src/contact.py index 99c24a32..1874ff59 100644 --- a/src/contact.py +++ b/src/contact.py @@ -26,30 +26,37 @@ class Resource(object): self._presence = 'unavailable' self._priority = 0 - def get_jid(self): + @property + def jid(self): return self._jid def __repr__(self): return '%s' % self._jid - def set_priority(self, priority): - assert isinstance(priority, int) - self._priority = priority - - def get_priority(self): + @property + def priority(self): return self._priority - def set_presence(self, pres): - self._presence = pres + @priority.setter + def priority(self, value): + assert isinstance(value, int) + self._priority = value - def get_presence(self): + @property + def presence(self): return self._presence - def get_status(self): + @presence.setter + def presence(self, value): + self._presence = value + + @property + def status(self): return self._status - def set_status(self, s): - self._status = s + @status.setter + def status(self, value): + self._status = value class Contact(object): """ @@ -66,16 +73,14 @@ class Contact(object): self._ask = None self._groups = [] # a list of groups the contact is in - def get_groups(self): - """ - Return the groups the contact is in - """ + @property + def groups(self): + """Groups the contact is in""" return self._groups - def get_bare_jid(self): - """ - Just get the bare_jid or the contact - """ + @property + def bare_jid(self): + """The bare_jid or the contact""" return self._jid def get_highest_priority_resource(self): @@ -84,7 +89,7 @@ class Contact(object): """ ret = None for resource in self._resources: - if not ret or ret.get_priority() < resource.get_priority(): + if not ret or ret.priority < resource.priority: ret = resource return ret @@ -94,7 +99,7 @@ class Contact(object): (the first, or any subsequent one) """ def f(o): - return o.get_priority() + return o.priority self._resources.append(resource) self._resources = sorted(self._resources, key=f, reverse=True) @@ -109,7 +114,7 @@ class Contact(object): Like 'remove_resource' but just by knowing the full jid """ for resource in self._resources: - if resource.get_jid().full == fulljid: + if resource.jid == fulljid: self._resources.remove(resource) return assert False @@ -119,7 +124,7 @@ class Contact(object): Return the resource with the given fulljid """ for resource in self._resources: - if resource.get_jid().full == fulljid: + if resource.jid.full == fulljid: return resource return None @@ -129,24 +134,30 @@ class Contact(object): """ self._folded = not self._folded - def set_name(self, name): - self._display_name = name - - def get_name(self): + @property + def name(self): return self._display_name - def set_ask(self, ask): - self._ask = ask + @name.setter + def name(self, value): + self._display_name = value - def get_ask(self): + @property + def ask(self): return self._ask - def set_subscription(self, sub): - self._subscription = sub + @ask.setter + def ask(self, value): + self._ask = value - def get_subscription(self): + @property + def subscription(self): return self._subscription + @subscription.setter + def subscription(self, value): + self._subscription = value + def get_nb_resources(self): """ Get the number of connected resources @@ -157,7 +168,7 @@ class Contact(object): """ Return all resources, sorted by priority """ - compare_resources = lambda x: x.get_priority() + compare_resources = lambda x: x.priority return sorted(self._resources, key=compare_resources) def __repr__(self): diff --git a/src/core.py b/src/core.py index e2ba8ce1..1f93324d 100644 --- a/src/core.py +++ b/src/core.py @@ -15,6 +15,7 @@ import threading import traceback from datetime import datetime +from xml.etree import cElementTree as ET from inspect import getargspec @@ -25,6 +26,7 @@ import singleton import collections from sleekxmpp.xmlstream.stanzabase import JID +from sleekxmpp.xmlstream.stanzabase import StanzaBase log = logging.getLogger(__name__) @@ -131,6 +133,8 @@ class Core(object): 'load': (self.command_load, _('Usage: /load <plugin>\nLoad: Load the specified plugin'), self.plugin_manager.completion_load), 'unload': (self.command_unload, _('Usage: /unload <plugin>\nUnload: Unload the specified plugin'), self.plugin_manager.completion_unload), 'plugins': (self.command_plugins, _('Usage: /plugins\nPlugins: Show the plugins in use.'), None), + 'presence': (self.command_presence, _('Usage: /presence <JID> [type] [status]\nPresence: Send a directed presence to <JID> and using [type] and [status] if provided.'), None), + 'rawxml': (self.command_rawxml, _('Usage: /rawxml\nRawXML: Send a custom xml stanza.'), None), } self.key_func = { @@ -177,8 +181,6 @@ class Core(object): self.connected_events = {} - self.autoload_plugins() - def autoload_plugins(self): plugins = config.get('plugins_autoload', '') for plugin in plugins.split(): @@ -325,6 +327,7 @@ class Core(object): tab = self.get_tab_of_conversation_with_jid(message['from'], False) if not tab: return False + self.events.trigger('normal_chatstate', message, tab) tab.chatstate = state if tab == self.current_tab(): tab.refresh_info_header() @@ -335,6 +338,7 @@ class Core(object): tab = self.get_tab_by_name(message['from'].full, tabs.PrivateTab) if not tab: return + self.events.trigger('private_chatstate', message, tab) tab.chatstate = state if tab == self.current_tab(): tab.refresh_info_header() @@ -346,6 +350,7 @@ class Core(object): room_from = message.getMucroom() tab = self.get_tab_by_name(room_from, tabs.MucTab) if tab and tab.get_user_by_name(nick): + self.events.trigger('muc_chatstate', message, tab) tab.get_user_by_name(nick).chatstate = state if tab == self.current_tab(): tab.user_win.refresh(tab.users) @@ -372,13 +377,13 @@ class Core(object): return # If a resource got offline, display the message in the conversation with this # precise resource. - self.add_information_message_to_conversation_tab(jid.full, '\x195}%s is \x191}offline' % (resource.get_jid().full)) + self.add_information_message_to_conversation_tab(jid.full, '\x195}%s is \x191}offline' % (resource.jid.full)) contact.remove_resource(resource) # Display the message in the conversation with the bare JID only if that was # the only resource online (i.e. now the contact is completely disconnected) if not contact.get_highest_priority_resource(): # No resource left: that was the last one self.add_information_message_to_conversation_tab(jid.bare, '\x195}%s is \x191}offline' % (jid.bare)) - self.information('\x193}%s \x195}is \x191}offline' % (resource.get_jid().bare), "Roster") + self.information('\x193}%s \x195}is \x191}offline' % (resource.jid.bare), "Roster") if isinstance(self.current_tab(), tabs.RosterInfoTab): self.refresh_window() @@ -392,21 +397,22 @@ class Core(object): resource = contact.get_resource_by_fulljid(jid.full) assert not resource resource = Resource(jid.full) + self.events.trigger('normal_presence', presence, resource) status = presence['type'] status_message = presence['status'] priority = presence.getPriority() or 0 - resource.set_status(status_message) - resource.set_presence(status) - resource.set_priority(priority) + resource.status = status_message + resource.presence = status + resource.priority = priority self.add_information_message_to_conversation_tab(jid.full, '\x195}%s is \x194}online' % (jid.full)) if not contact.get_highest_priority_resource(): # No connected resource yet: the user's just connecting if time.time() - self.connection_time > 12: # We do not display messages if we recently logged in if status_message: - self.information("\x193}%s \x195}is \x194}online\x195} (\x19o%s\x195})" % (resource.get_jid().bare, status_message), "Roster") + self.information("\x193}%s \x195}is \x194}online\x195} (\x19o%s\x195})" % (resource.jid.bare, status_message), "Roster") else: - self.information("\x193}%s \x195}is \x194}online\x195}" % resource.get_jid().bare, "Roster") + self.information("\x193}%s \x195}is \x194}online\x195}" % resource.jid.bare, "Roster") self.add_information_message_to_conversation_tab(jid.bare, '\x195}%s is \x194}online' % (jid.bare)) contact.add_resource(resource) if isinstance(self.current_tab(), tabs.RosterInfoTab): @@ -453,13 +459,16 @@ class Core(object): Called when we are connected and authenticated """ self.connection_time = time.time() + self.autoload_plugins() self.information(_("Authentication success.")) self.information(_("Your JID is %s") % self.xmpp.boundjid.full) if not self.xmpp.anon: # request the roster self.xmpp.getRoster() # send initial presence - self.xmpp.makePresence().send() + pres = self.xmpp.make_presence() + self.events.trigger('send_normal_presence', pres) + pres.send() rooms = config.get('rooms', '') if rooms == '' or not isinstance(rooms, str): return @@ -487,6 +496,7 @@ class Core(object): from_room = presence['from'].bare tab = self.get_tab_by_name(from_room, tabs.MucTab) if tab: + self.events.trigger('muc_presence', presence, tab) tab.handle_presence(presence) def rename_private_tabs(self, room_name, old_nick, new_nick): @@ -560,13 +570,14 @@ class Core(object): jid = message['from'] nick_from = jid.resource room_from = jid.bare + body = xhtml.get_body_from_message_stanza(message) tab = self.get_tab_by_name(jid.full, tabs.PrivateTab) # get the tab with the private conversation if not tab: # It's the first message we receive: create the tab - tab = self.open_private_window(room_from, nick_from, False) + if body: + tab = self.open_private_window(room_from, nick_from, False) if not tab: return - self.events.trigger('private_msg', message) - body = xhtml.get_body_from_message_stanza(message) + self.events.trigger('private_msg', message, tab) if not body: return tab.add_message(body, time=None, nickname=nick_from, @@ -604,8 +615,8 @@ class Core(object): conversation = self.get_tab_by_name(jid.bare, tabs.ConversationTab) if not conversation: if create: - # We create the conversation with the bare Jid if nothing was found - conversation = self.open_conversation_window(jid.bare, False) + # We create the conversation with the full Jid if nothing was found + conversation = self.open_conversation_window(jid.full, False) else: conversation = None return conversation @@ -615,15 +626,17 @@ class Core(object): When receiving "normal" messages (from someone in our roster) """ jid = message['from'] - self.events.trigger('conversation_msg', message) body = xhtml.get_body_from_message_stanza(message) + conversation = self.get_tab_of_conversation_with_jid(jid, create=False) if not body: if message['type'] == 'error': self.information(self.get_error_message_from_error_stanza(message), 'Error') return conversation = self.get_tab_of_conversation_with_jid(jid, create=True) + self.events.trigger('conversation_msg', message, conversation) + body = xhtml.get_body_from_message_stanza(message) if roster.get_contact_by_jid(jid.bare): - remote_nick = roster.get_contact_by_jid(jid.bare).get_name() or jid.user + remote_nick = roster.get_contact_by_jid(jid.bare).name or jid.user else: remote_nick = jid.user conversation._text_buffer.add_message(body, nickname=remote_nick, nick_color=get_theme().COLOR_REMOTE_USER) @@ -645,18 +658,24 @@ class Core(object): jid = presence['from'] contact = roster.get_contact_by_jid(jid.bare) if not contact: - return - resource = contact.get_resource_by_fulljid(jid.full) + resource = None + else: + resource = contact.get_resource_by_fulljid(jid.full) + self.events.trigger('normal_presence', presence, resource) if not resource: return status = presence['type'] status_message = presence['status'] priority = presence.getPriority() or 0 - resource.set_presence(status) - resource.set_priority(priority) - resource.set_status(status_message) + resource.presence = status + resource.priority = priority + resource.status = status_message + tab = self.get_tab_of_conversation_with_jid(jid, create=False) if isinstance(self.current_tab(), tabs.RosterInfoTab): self.refresh_window() + elif self.current_tab() == tab: + tab.refresh() + self.doupdate() def on_roster_update(self, iq): """ @@ -670,19 +689,19 @@ class Core(object): contact = Contact(jid) roster.add_contact(contact, jid) if 'ask' in item.attrib: - contact.set_ask(item.attrib['ask']) + contact.ask = item.attrib['ask'] else: - contact.set_ask(None) + contact.ask = None if 'name' in item.attrib: - contact.set_name(item.attrib['name']) + contact.name = item.attrib['name'] else: - contact.set_name(None) + contact.name = None if item.attrib['subscription']: - contact.set_subscription(item.attrib['subscription']) + contact.subscription = item.attrib['subscription'] groups = item.findall('{jabber:iq:roster}group') roster.edit_groups_of_contact(contact, [group.text for group in groups]) if item.attrib['subscription'] == 'remove': - roster.remove_contact(contact.get_bare_jid()) + roster.remove_contact(contact.bare_jid) if isinstance(self.current_tab(), tabs.RosterInfoTab): self.refresh_window() @@ -690,16 +709,30 @@ class Core(object): """ Triggered whenever a presence stanza with a type of subscribe, subscribed, unsubscribe, or unsubscribed is received. """ + jid = presence['from'].bare + contact = roster.get_contact_by_jid(jid) if presence['type'] == 'subscribe': - jid = presence['from'].bare - contact = roster.get_contact_by_jid(jid) if not contact: contact = Contact(jid) roster.add_contact(contact, jid) + log.debug("CONTACT: %s" % contact) + if contact.subscription in ('from', 'both'): + log.debug('FROM OR BOTH') + return + elif contact.subscription in ('to'): + log.debug('TO') + self.xmpp.sendPresence(pto=jid, ptype='subscribed') + self.xmpp.sendPresence(pto=jid, ptype='') + return roster.edit_groups_of_contact(contact, []) - contact.set_ask('asked') + contact.ask = 'asked' self.get_tab_by_number(0).state = 'highlight' self.information('%s wants to subscribe to your presence'%jid, 'Roster') + elif presence['type'] == 'unsubscribed': + self.information('%s unsubscribed you from his presence'%jid, 'Roster') + elif presence['type'] == 'unsubscribe': + self.information('%s unsubscribed from your presence'%jid, 'Roster') + if isinstance(self.current_tab(), tabs.RosterInfoTab): self.refresh_window() @@ -1056,7 +1089,7 @@ class Core(object): if tab.get_user_by_name(nick_from) and\ tab.get_user_by_name(nick_from) in tab.ignores: return - self.events.trigger('muc_msg', message) + self.events.trigger('muc_msg', message, tab) body = xhtml.get_body_from_message_stanza(message) if body: date = date if delayed == True else None @@ -1124,6 +1157,7 @@ class Core(object): if msg: pres['status'] = msg pres['type'] = show + self.events.trigger('send_normal_presence', pres) pres.send() current = self.current_tab() if isinstance(current, tabs.MucTab) and current.joined and show in ('away', 'xa'): @@ -1135,6 +1169,44 @@ class Core(object): if isinstance(current, tabs.MucTab) and current.joined and show not in ('away', 'xa'): current.send_chat_state('active') + def command_rawxml(self, arg): + """" + /rawxml <xml stanza> + """ + if not arg: + return + + try: + StanzaBase(self.xmpp, xml=ET.fromstring(arg)).send() + except: + import traceback + self.information(_('Could not send custom stanza'), 'Error') + log.debug(_("Could not send custom stanza:\n") + traceback.format_exc()) + + def command_presence(self, arg): + """ + /presence <JID> [type] [status] + """ + args = common.shell_split(arg) + if len(args) == 1: + jid, type, status = args[0], None, None + elif len(args) == 2: + jid, type, status = args[0], args[1], None + elif len(args) == 3: + jid, type, status = args[0], args[1], args[2] + else: + return + if type == 'available': + type = None + try: + pres = self.xmpp.make_presence(pto=jid, ptype=type, pstatus=status) + self.events.trigger('send_normal_presence', pres) + pres.send() + except : + import traceback + self.information(_('Could not send directed presence'), 'Error') + log.debug(_("Could not send directed presence:\n") + traceback.format_exc()) + def completion_status(self, the_input): return the_input.auto_completion([status for status in possible_show], ' ') @@ -1371,7 +1443,12 @@ class Core(object): else: tab.own_nick = nick tab.users = [] - self.enable_private_tabs(room) + if tab and tab.joined: + self.enable_private_tabs(room) + tab.state = "normal" + if tab == self.current_tab(): + tab.refresh() + self.doupdate() def get_bookmark_nickname(self, room_name): """ @@ -1532,7 +1609,9 @@ class Core(object): Displays an informational message in the "Info" buffer """ nb_lines = self.information_buffer.add_message(msg, nickname=typ) - if typ != '' and typ.lower() in config.get('information_buffer_popup_on', + if isinstance(self.current_tab(), tabs.RosterInfoTab): + self.refresh_window() + elif typ != '' and typ.lower() in config.get('information_buffer_popup_on', 'error roster warning help info').split(): popup_time = config.get('popup_time', 4) + (nb_lines - 1) * 2 self.pop_information_win_up(nb_lines, popup_time) @@ -1585,15 +1664,15 @@ class Core(object): when enter is pressed on the roster window """ if isinstance(roster_row, Contact): - if not self.get_conversation_by_jid(roster_row.get_bare_jid()): - self.open_conversation_window(roster_row.get_bare_jid()) + if not self.get_conversation_by_jid(roster_row.bare_jid): + self.open_conversation_window(roster_row.bare_jid) else: - self.focus_tab_named(roster_row.get_bare_jid()) + self.focus_tab_named(roster_row.bare_jid) if isinstance(roster_row, Resource): - if not self.get_conversation_by_jid(roster_row.get_jid().full): - self.open_conversation_window(roster_row.get_jid().full) + if not self.get_conversation_by_jid(roster_row.jid.full): + self.open_conversation_window(roster_row.jid.full) else: - self.focus_tab_named(roster_row.get_jid().full) + self.focus_tab_named(roster_row.jid.full) self.refresh_window() def remove_timed_event(self, event): diff --git a/src/data_forms.py b/src/data_forms.py index 8445d3d2..8f19e41b 100644 --- a/src/data_forms.py +++ b/src/data_forms.py @@ -23,6 +23,7 @@ class DataFormsTab(Tab): A tab contaning various window type, displaying a form that the user needs to fill. """ + plugin_commands = {} def __init__(self, form, on_cancel, on_send, kwargs): Tab.__init__(self) self._form = form @@ -41,6 +42,7 @@ class DataFormsTab(Tab): self.key_func['^G'] = self.on_cancel self.key_func['^Y'] = self.on_send self.resize() + self.update_commands() def on_cancel(self): self._on_cancel(self._form) diff --git a/src/events.py b/src/events.py index 22d60ddf..e94acb80 100644 --- a/src/events.py +++ b/src/events.py @@ -20,14 +20,22 @@ class EventHandler(object): """ def __init__(self): self.events = { - # when you are highlighted in a muc tab 'highlight': [], 'muc_say': [], + 'muc_say_after': [], 'conversation_say': [], + 'conversation_say_after': [], 'private_say': [], + 'private_say_after': [], 'conversation_msg': [], 'private_msg': [], 'muc_msg': [], + 'normal_chatstate': [], + 'muc_chatstate': [], + 'private_chatstate': [], + 'normal_presence': [], + 'muc_presence': [], + 'send_normal_presence': [], } def add_event_handler(self, name, callback, position=0): @@ -35,7 +43,7 @@ class EventHandler(object): Add a callback to a given event. Note that if that event name doesn’t exist, it just returns False. If it was successfully added, it returns True - position: 0 means insert a the beginning, -1 means end + position: 0 means insert at the beginning, -1 means end """ if name not in self.events: return False @@ -49,7 +57,7 @@ class EventHandler(object): def trigger(self, name, *args, **kwargs): """ - Call all the callbacks associated to the given event name + Call all the callbacks associated to the given event name. """ callbacks = self.events[name] for callback in callbacks: diff --git a/src/plugin.py b/src/plugin.py index 80bc4dfc..4dd88697 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -50,9 +50,7 @@ class SafetyMetaclass(type): class BasePlugin(object, metaclass=SafetyMetaclass): """ - Class that all plugins derive from. Any methods beginning with command_ - are interpreted as a command and beginning with on_ are interpreted as - event handlers + Class that all plugins derive from. """ def __init__(self, plugin_manager, core, plugins_conf_dir): @@ -65,28 +63,57 @@ class BasePlugin(object, metaclass=SafetyMetaclass): self.init() def init(self): + """ + Method called at the creation of the plugin. + Do not overwrite __init__ and use this instead. + """ pass def cleanup(self): + """ + Called when the plugin is unloaded. + Overwrite this if you want to erase or save things before the plugin is disabled. + """ pass def unload(self): self.cleanup() def add_command(self, name, handler, help, completion=None): + """ + Add a global command. + You cannot overwrite the existing commands. + """ return self.plugin_manager.add_command(self.__module__, name, handler, help, completion) def del_command(self, name): + """ + Remove a global command. + This only works if the command was added by the plugin + """ return self.plugin_manager.del_command(self.__module__, name) - def add_event_handler(self, event_name, handler): - return self.plugin_manager.add_event_handler(self.__module__, event_name, handler) + def add_tab_command(self, tab_type, name, handler, help, completion=None): + """ + Add a command only for a type of tab. + """ + return self.plugin_manager.add_tab_command(self.__module__, tab_type, name, handler, help, completion) + + def del_tab_command(self, tab_type, name): + """ + Delete a command added through add_tab_command. + """ + return self.plugin_manager.del_tab_command(self.__module__, tab_type, name) + + def add_event_handler(self, event_name, handler, position=0): + """ + Add an event handler to the event event_name. + An optional position in the event handler list can be provided. + """ + return self.plugin_manager.add_event_handler(self.__module__, event_name, handler, position) def del_event_handler(self, event_name, handler): + """ + Remove 'handler' from the event list for 'event_name'. + """ return self.plugin_manager.del_event_handler(self.__module__, event_name, handler) - - def add_poezio_event_handler(self, event_name, handler, position=0): - return self.plugin_manager.add_poezio_event_handler(self.__module__, event_name, handler, position) - - def del_poezio_event_handler(self, event_name, handler): - return self.plugin_manager.del_poezio_event_handler(self.__module__, event_name, handler) diff --git a/src/plugin_manager.py b/src/plugin_manager.py index bdf94a5b..fe4d2b7e 100644 --- a/src/plugin_manager.py +++ b/src/plugin_manager.py @@ -1,6 +1,7 @@ import imp import os import sys +import tabs from config import config from gettext import gettext as _ @@ -34,9 +35,9 @@ class PluginManager(object): self.plugins = {} # module name -> plugin object self.commands = {} # module name -> dict of commands loaded for the module self.event_handlers = {} # module name -> list of event_name/handler pairs loaded for the module - self.poezio_event_handlers = {} + self.tab_commands = {} #module name -> dict of tab types; tab type -> commands loaded by the module - def load(self, name): + def load(self, name, notify=True): if name in self.plugins: self.unload(name) @@ -60,25 +61,31 @@ class PluginManager(object): self.modules[name] = module self.commands[name] = {} + self.tab_commands[name] = {} self.event_handlers[name] = [] - self.poezio_event_handlers[name] = [] self.plugins[name] = module.Plugin(self, self.core, plugins_conf_dir) + if notify: + self.core.information('Plugin %s loaded' % name, 'Info') - def unload(self, name): + def unload(self, name, notify=True): if name in self.plugins: try: for command in self.commands[name].keys(): del self.core.commands[command] + for tab in list(self.tab_commands[name].keys()): + for command in self.tab_commands[name][tab]: + self.del_tab_command(name, getattr(tabs, tab), command[0]) + del self.tab_commands[name][tab] for event_name, handler in self.event_handlers[name]: - self.core.xmpp.del_event_handler(event_name, handler) - for handler in self.poezio_event_handlers[name]: - self.core.events.del_event_handler(None, handler) + self.del_event_handler(name, event_name, handler) self.plugins[name].unload() del self.plugins[name] del self.commands[name] + del self.tab_commands[name] del self.event_handlers[name] - del self.poezio_event_handlers[name] + if notify: + self.core.information('Plugin %s unloaded' % name, 'Info') except Exception as e: import traceback self.core.information(_("Could not unload plugin (may not be safe to try again): ") + traceback.format_exc()) @@ -89,6 +96,29 @@ class PluginManager(object): if name in self.core.commands: del self.core.commands[name] + def add_tab_command(self, module_name, tab_type, name, handler, help, completion=None): + commands = self.tab_commands[module_name] + t = tab_type.__name__ + if not t in commands: + commands[t] = [] + commands[t].append((name, handler, help, completion)) + for tab in self.core.tabs: + if isinstance(tab, tab_type): + tab.add_plugin_command(name, handler, help, completion) + + def del_tab_command(self, module_name, tab_type, name): + commands = self.tab_commands[module_name] + t = tab_type.__name__ + if not t in commands: + return + for command in commands[t]: + if command[0] == name: + commands[t].remove(command) + del tab_type.plugin_commands[name] + for tab in self.core.tabs: + if isinstance(tab, tab_type) and name in tab.commands: + del tab.commands[name] + def add_command(self, module_name, name, handler, help, completion=None): if name in self.core.commands: raise Exception(_("Command '%s' already exists") % (name,)) @@ -97,26 +127,22 @@ class PluginManager(object): commands[name] = (handler, help, completion) self.core.commands[name] = (handler, help, completion) - def add_event_handler(self, module_name, event_name, handler): + def add_event_handler(self, module_name, event_name, handler, position=0): eh = self.event_handlers[module_name] eh.append((event_name, handler)) - self.core.xmpp.add_event_handler(event_name, handler) + if event_name in self.core.events.events: + self.core.events.add_event_handler(event_name, handler, position) + else: + self.core.xmpp.add_event_handler(event_name, handler) def del_event_handler(self, module_name, event_name, handler): - self.core.xmpp.del_event_handler(event_name, handler) + if event_name in self.core.events.events: + self.core.events.del_event_handler(None, handler) + else: + self.core.xmpp.del_event_handler(event_name, handler) eh = self.event_handlers[module_name] eh = list(filter(lambda e : e != (event_name, handler), eh)) - def add_poezio_event_handler(self, module_name, event_name, handler, position): - eh = self.poezio_event_handlers[module_name] - eh.append(handler) - self.core.events.add_event_handler(event_name, handler, position) - - def del_poezio_event_handler(self, module_name, event_name, handler): - self.core.events.del_event_handler(None, handler) - eh = self.poezio_event_handlers[module_name] - eh = list(filter(lambda e : e != handler, eh)) - def completion_load(self, the_input): """ completion function that completes the name of the plugins, from diff --git a/src/roster.py b/src/roster.py index df84d4d8..5f214bb0 100644 --- a/src/roster.py +++ b/src/roster.py @@ -234,7 +234,7 @@ class RosterGroup(object): def compare_contact(a): if not a.get_highest_priority_resource(): return 0 - show = a.get_highest_priority_resource().get_presence() + show = a.get_highest_priority_resource() if show not in PRESENCE_PRIORITY: return 5 return PRESENCE_PRIORITY[show] diff --git a/src/tabs.py b/src/tabs.py index 6f100741..472a15fa 100644 --- a/src/tabs.py +++ b/src/tabs.py @@ -46,6 +46,7 @@ from os import getenv, path from logger import logger from datetime import datetime, timedelta +from xml.etree import cElementTree as ET SHOW_NAME = { 'dnd': _('busy'), @@ -91,6 +92,7 @@ class Tab(object): # and use them in on_input self.commands = {} # and their own commands + @property def core(self): if not Tab.tab_core: @@ -220,6 +222,18 @@ class Tab(object): def on_input(self, key): pass + def add_plugin_command(self, name, handler, help, completion=None): + if name in self.plugin_commands or name in self.commands: + return + self.plugin_commands[name] = (handler, help, completion) + self.commands[name] = (handler, help, completion) + self.update_commands() + + def update_commands(self): + for c in self.plugin_commands: + if not c in self.commands: + self.commands[name] = self.plugin_commands[c] + def on_lose_focus(self): """ called when this tab loses the focus. @@ -275,6 +289,7 @@ class ChatTab(Tab): Also, ^M is already bound to on_enter And also, add the /say command """ + plugin_commands = {} def __init__(self): Tab.__init__(self) self._text_buffer = TextBuffer() @@ -294,7 +309,9 @@ class ChatTab(Tab): self.commands['say'] = (self.command_say, _("""Usage: /say <message>\nSay: Just send the message. Useful if you want your message to begin with a '/'."""), None) + self.commands['xhtml'] = (self.command_xhtml, _("Usage: /xhtml <custom xhtml>\nXHTML: Send custom XHTML."), None) self.chat_state = None + self.update_commands() def last_words_completion(self): """ @@ -324,6 +341,29 @@ class ChatTab(Tab): self.command_say(xhtml.convert_simple_to_full_colors(txt)) self.cancel_paused_delay() + def command_xhtml(self, arg): + """" + /xhtml <custom xhtml> + """ + if not arg: + return + try: + body = xhtml.clean_text(xhtml.xhtml_to_poezio_colors(arg)) + ET.fromstring(arg) + except: + self.core.information('Could not send custom xhtml', 'Error') + return + + msg = self.core.xmpp.make_message(self.get_name()) + msg['body'] = body + msg['xhtml_im'] = arg + if isinstance(self, MucTab): + msg['type'] = 'groupchat' + if isinstance(self, ConversationTab): + self.core.add_message_to_text_buffer(self._text_buffer, body, None, self.core.own_nick) + self.refresh() + msg.send() + def send_chat_state(self, state, always_send=False): """ Send an empty chatstate message @@ -402,6 +442,7 @@ class MucTab(ChatTab): It contains an userlist, an input, a topic, an information and a chat zone """ message_type = 'groupchat' + plugin_commands = {} def __init__(self, jid, nick): ChatTab.__init__(self) self.own_nick = nick @@ -445,6 +486,7 @@ class MucTab(ChatTab): self.commands['clear'] = (self.command_clear, _('Usage: /clear\nClear: Clear the current buffer.'), None) self.resize() + self.update_commands() def scroll_user_list_up(self): self.user_win.scroll_up() @@ -722,13 +764,14 @@ class MucTab(ChatTab): # trigger the event BEFORE looking for colors. # This lets a plugin insert \x19xxx} colors, that will # be converted in xhtml. - self.core.events.trigger('muc_say', msg) + self.core.events.trigger('muc_say', msg, self) if msg['body'].find('\x19') != -1: msg['xhtml_im'] = xhtml.poezio_colors_to_html(msg['body']) msg['body'] = xhtml.clean_text(msg['body']) if config.get('send_chat_states', 'true') == 'true' and self.remote_wants_chatstates is not False: msg['chat_state'] = needed self.cancel_paused_delay() + self.core.events.trigger('muc_say_after', msg, self) msg.send() self.chat_state = needed @@ -1027,7 +1070,6 @@ class MucTab(ChatTab): self.core.doupdate() hide_exit_join = config.get('hide_exit_join', -1) if config.get('hide_exit_join', -1) >= -1 else -1 if hide_exit_join == -1 or user.has_talked_since(hide_exit_join): - log.debug("\n\nALLO: USERCOLOR: %s\n\n" % user.color.__repr__()) color = user.color[0] if config.get('display_user_color_in_join_part', '') == 'true' else 3 if not jid.full: leave_msg = _('\x191}%(spec)s \x19%(color)d}%(nick)s\x195} has left the room') % {'nick':from_nick, 'color':color, 'spec':get_theme().CHAR_QUIT} @@ -1186,6 +1228,7 @@ class PrivateTab(ChatTab): The tab containg a private conversation (someone from a MUC) """ message_type = 'chat' + plugin_commands = {} def __init__(self, name, nick): ChatTab.__init__(self) self.own_nick = nick @@ -1204,6 +1247,7 @@ class PrivateTab(ChatTab): self.resize() self.parent_muc = self.core.get_tab_by_name(JID(name).bare, MucTab) self.on = True + self.update_commands() def completion(self): self.complete_commands(self.input) @@ -1217,7 +1261,7 @@ class PrivateTab(ChatTab): # trigger the event BEFORE looking for colors. # This lets a plugin insert \x19xxx} colors, that will # be converted in xhtml. - self.core.events.trigger('private_say', msg) + self.core.events.trigger('private_say', msg, self) self.core.add_message_to_text_buffer(self._text_buffer, msg['body'], None, self.core.own_nick or self.own_nick) if msg['body'].find('\x19') != -1: msg['xhtml_im'] = xhtml.poezio_colors_to_html(msg['body']) @@ -1225,6 +1269,7 @@ class PrivateTab(ChatTab): if config.get('send_chat_states', 'true') == 'true' and self.remote_wants_chatstates is not False: needed = 'inactive' if self.core.status.show in ('xa', 'away') else 'active' msg['chat_state'] = needed + self.core.events.trigger('private_say_after', msg, self) msg.send() self.cancel_paused_delay() self.text_win.refresh() @@ -1381,6 +1426,7 @@ class RosterInfoTab(Tab): """ A tab, splitted in two, containing the roster and infos """ + plugin_commands = {} def __init__(self): Tab.__init__(self) self.name = "Roster" @@ -1415,6 +1461,7 @@ class RosterInfoTab(Tab): self.commands['import'] = (self.command_import, _("Usage: /import [/path/to/file]\nImport: Import your contacts from /path/to/file if specified, or $HOME/poezio_contacts if not."), None) self.commands['clear_infos'] = (self.command_clear_infos, _("Usage: /clear_infos\nClear Infos: Use this command to clear the info buffer."), None) self.resize() + self.update_commands() def resize(self): if not self.visible: @@ -1450,8 +1497,8 @@ class RosterInfoTab(Tab): args = args.split() if not args: item = self.roster_win.selected_row - if isinstance(item, Contact) and item.get_ask() == 'asked': - jid = item.get_bare_jid() + if isinstance(item, Contact) and item.ask == 'asked': + jid = item.bare_jid else: self.core.information('No subscription to deny') return @@ -1471,7 +1518,6 @@ class RosterInfoTab(Tab): self.core.information(_('No JID specified'), 'Error') return self.core.xmpp.sendPresence(pto=jid, ptype='subscribe') - self.core.xmpp.sendPresence(pto=jid, ptype='subscribed') def command_name(self, args): """ @@ -1488,10 +1534,10 @@ class RosterInfoTab(Tab): self.core.information(_('No such JID in roster'), 'Error') return - groups = set(contact.get_groups()) - subscription = contact.get_subscription() + groups = set(contact.groups) + subscription = contact.subscription if self.core.xmpp.update_roster(jid, name=name, groups=groups, subscription=subscription): - contact.set_name(name) + contact.name = name def command_groupadd(self, args): """ @@ -1508,7 +1554,7 @@ class RosterInfoTab(Tab): self.core.information(_('No such JID in roster'), 'Error') return - new_groups = set(contact.get_groups()) + new_groups = set(contact.groups) if group in new_groups: self.core.information(_('JID already in group'), 'Error') return @@ -1519,8 +1565,8 @@ class RosterInfoTab(Tab): except KeyError: pass - name = contact.get_name() - subscription = contact.get_subscription() + name = contact.name + subscription = contact.subscription if self.core.xmpp.update_roster(jid, name=name, groups=new_groups, subscription=subscription): roster.edit_groups_of_contact(contact, new_groups) @@ -1539,7 +1585,7 @@ class RosterInfoTab(Tab): self.core.information(_('No such JID in roster'), 'Error') return - new_groups = set(contact.get_groups()) + new_groups = set(contact.groups) try: new_groups.remove('none') except KeyError: @@ -1549,8 +1595,8 @@ class RosterInfoTab(Tab): return new_groups.remove(group) - name = contact.get_name() - subscription = contact.get_subscription() + name = contact.name + subscription = contact.subscription if self.core.xmpp.update_roster(jid, name=name, groups=new_groups, subscription=subscription): roster.edit_groups_of_contact(contact, new_groups) @@ -1564,13 +1610,17 @@ class RosterInfoTab(Tab): else: item = self.roster_win.selected_row if isinstance(item, Contact): - jid = item.get_bare_jid() + jid = item.bare_jid else: self.core.information('No roster item to remove') return + self.core.xmpp.sendPresence(pto=jid, ptype='unavailable') self.core.xmpp.sendPresence(pto=jid, ptype='unsubscribe') self.core.xmpp.sendPresence(pto=jid, ptype='unsubscribed') - self.core.xmpp.del_roster_item(jid=jid) + try: + self.core.xmpp.del_roster_item(jid=jid) + except: + pass def command_import(self, arg): """ @@ -1625,7 +1675,7 @@ class RosterInfoTab(Tab): """ From with any JID presence in the roster """ - jids = [contact.get_bare_jid() for contact in roster.get_contacts()] + jids = [contact.bare_jid for contact in roster.get_contacts()] return the_input.auto_completion(jids, '') def completion_name(self, the_input): @@ -1635,7 +1685,7 @@ class RosterInfoTab(Tab): n += 1 if n == 2: - jids = [contact.get_bare_jid() for contact in roster.get_contacts()] + jids = [contact.bare_jid for contact in roster.get_contacts()] return the_input.auto_completion(jids, '') return False @@ -1646,7 +1696,7 @@ class RosterInfoTab(Tab): n += 1 if n == 2: - jids = [contact.get_bare_jid() for contact in roster.get_contacts()] + jids = [contact.bare_jid for contact in roster.get_contacts()] return the_input.auto_completion(jids, '') elif n == 3: groups = [group.name for group in roster.get_groups() if group.name != 'none'] @@ -1661,13 +1711,13 @@ class RosterInfoTab(Tab): n += 1 if n == 2: - jids = [contact.get_bare_jid() for contact in roster.get_contacts()] + jids = [contact.bare_jid for contact in roster.get_contacts()] return the_input.auto_completion(jids, '') elif n == 3: contact = roster.get_contact_by_jid(args[1]) if not contact: return False - groups = list(contact.get_groups()) + groups = list(contact.groups) try: groups.remove('none') except ValueError: @@ -1680,8 +1730,8 @@ class RosterInfoTab(Tab): Complete the first argument from the list of the contact with ask=='subscribe' """ - jids = [contact.get_bare_jid() for contact in roster.get_contacts()\ - if contact.get_ask() == 'asked'] + jids = [contact.bare_jid for contact in roster.get_contacts()\ + if contact.ask == 'asked'] return the_input.auto_completion(jids, '') def command_accept(self, args): @@ -1691,14 +1741,20 @@ class RosterInfoTab(Tab): args = args.split() if not args: item = self.roster_win.selected_row - if isinstance(item, Contact) and item.get_ask() == 'asked': - jid = item.get_bare_jid() + if isinstance(item, Contact) and item.ask == 'asked': + jid = item.bare_jid else: self.core.information('No subscription to accept') return else: jid = args[0] self.core.xmpp.sendPresence(pto=jid, ptype='subscribed') + self.core.xmpp.sendPresence(pto=jid, ptype='') + contact = roster.get_contact_by_jid(jid) + if not contact: + return + if contact.subscription in ('to', 'none'): + self.core.xmpp.sendPresence(pto=jid, ptype='subscribe') def refresh(self): if self.need_resize: @@ -1864,6 +1920,8 @@ class ConversationTab(ChatTab): """ The tab containg a normal conversation (not from a MUC) """ + plugin_commands = {} + additional_informations = {} message_type = 'chat' def __init__(self, jid): ChatTab.__init__(self) @@ -1882,6 +1940,18 @@ class ConversationTab(ChatTab): self.commands['version'] = (self.command_version, _('Usage: /version\nVersion: Get the software version of the current interlocutor (usually its XMPP client and Operating System).'), None) self.commands['info'] = (self.command_info, _('Usage: /info\nInfo: Get the status of the contact.'), None) self.resize() + self.update_commands() + + @staticmethod + def add_information_element(plugin_name, callback): + """ + Lets a plugin add its own information to the ConversationInfoWin + """ + ConversationTab.additional_informations[plugin_name] = callback + + @staticmethod + def remove_information_element(plugin_name): + del ConversationTab.additional_informations[plugin_name] def completion(self): self.complete_commands(self.input) @@ -1894,7 +1964,7 @@ class ConversationTab(ChatTab): # and before displaying the message in the window # This lets a plugin insert \x19xxx} colors, that will # be converted in xhtml. - self.core.events.trigger('conversation_say', msg) + self.core.events.trigger('conversation_say', msg, self) self.core.add_message_to_text_buffer(self._text_buffer, msg['body'], None, self.core.own_nick) if msg['body'].find('\x19') != -1: msg['xhtml_im'] = xhtml.poezio_colors_to_html(msg['body']) @@ -1902,6 +1972,7 @@ class ConversationTab(ChatTab): if config.get('send_chat_states', 'true') == 'true' and self.remote_wants_chatstates is not False: needed = 'inactive' if self.core.status.show in ('xa', 'away') else 'active' msg['chat_state'] = needed + self.core.events.trigger('conversation_say_after', msg, self) msg.send() logger.log_message(JID(self.get_name()).bare, self.core.own_nick, line) self.cancel_paused_delay() @@ -1916,7 +1987,7 @@ class ConversationTab(ChatTab): else: resource = contact.get_highest_priority_resource() if resource: - self._text_buffer.add_message("\x195}Status: %s\x193}" %resource.get_status(), None, None, None, None, None) + self._text_buffer.add_message("\x195}Status: %s\x193}" %resource.status, None, None, None, None, None) self.refresh() self.core.doupdate() @@ -1954,13 +2025,13 @@ class ConversationTab(ChatTab): log.debug(' TAB Refresh: %s'%self.__class__.__name__) self.text_win.refresh() self.upper_bar.refresh(self.get_name(), roster.get_contact_by_jid(self.get_name())) - self.info_header.refresh(self.get_name(), roster.get_contact_by_jid(self.get_name()), self.text_win, self.chatstate) + self.info_header.refresh(self.get_name(), roster.get_contact_by_jid(self.get_name()), self.text_win, self.chatstate, ConversationTab.additional_informations) self.info_win.refresh() self.tab_win.refresh() self.input.refresh() def refresh_info_header(self): - self.info_header.refresh(self.get_name(), roster.get_contact_by_jid(self.get_name()), self.text_win, self.chatstate) + self.info_header.refresh(self.get_name(), roster.get_contact_by_jid(self.get_name()), self.text_win, self.chatstate, ConversationTab.additional_informations) self.input.refresh() def get_name(self): @@ -1976,17 +2047,38 @@ class ConversationTab(ChatTab): return False def on_lose_focus(self): + contact = roster.get_contact_by_jid(self.get_name()) + jid = JID(self.get_name()) + if contact: + if jid.resource: + resource = contact.get_resource_by_fulljid(jid.full) + else: + resource = contact.get_highest_priority_resource() + else: + resource = None self.state = 'normal' self.text_win.remove_line_separator() self.text_win.add_line_separator() - if config.get('send_chat_states', 'true') == 'true' and not self.input.get_text() or not self.input.get_text().startswith('//'): - self.send_chat_state('inactive') + if config.get('send_chat_states', 'true') == 'true' and (not self.input.get_text() or not self.input.get_text().startswith('//')): + if resource: + self.send_chat_state('inactive') def on_gain_focus(self): + contact = roster.get_contact_by_jid(self.get_name()) + jid = JID(self.get_name()) + if contact: + if jid.resource: + resource = contact.get_resource_by_fulljid(jid.full) + else: + resource = contact.get_highest_priority_resource() + else: + resource = None + self.state = 'current' curses.curs_set(1) - if config.get('send_chat_states', 'true') == 'true' and not self.input.get_text() or not self.input.get_text().startswith('//'): - self.send_chat_state('active') + if config.get('send_chat_states', 'true') == 'true' and (not self.input.get_text() or not self.input.get_text().startswith('//')): + if resource: + self.send_chat_state('active') def on_scroll_up(self): self.text_win.scroll_up(self.text_win.height-1) @@ -2016,6 +2108,7 @@ class MucListTab(Tab): A tab listing rooms from a specific server, displaying various information, scrollable, and letting the user join them, etc """ + plugin_commands = {} def __init__(self, server): Tab.__init__(self) self.state = 'normal' @@ -2046,6 +2139,7 @@ class MucListTab(Tab): self.listview.refresh() self.tab_win.refresh() self.input.refresh() + self.update_commands() def resize(self): if not self.visible: @@ -2220,7 +2314,7 @@ def jid_and_name_match(contact, txt): """ if not txt: return True - if txt in JID(contact.get_bare_jid()).user: + if txt in JID(contact.bare_jid).user: return True return False @@ -2231,9 +2325,9 @@ def jid_and_name_match_slow(contact, txt): """ if not txt: return True # Everything matches when search is empty - user = JID(contact.get_bare_jid()).user + user = JID(contact.bare_jid).user if diffmatch(txt, user): return True - if contact.get_name() and diffmatch(txt, contact.get_name()): + if contact.name and diffmatch(txt, contact.name): return True return False diff --git a/src/windows.py b/src/windows.py index 2253b871..d7471d40 100644 --- a/src/windows.py +++ b/src/windows.py @@ -368,19 +368,19 @@ class ConversationInfoWin(InfoWin): about the user we are talking to """ color_show = {'xa': lambda: get_theme().COLOR_STATUS_XA, - 'none': lambda: get_theme().COLOR_STATUS_ONLINE, - '': lambda: get_theme().COLOR_STATUS_ONLINE, - 'available': lambda: get_theme().COLOR_STATUS_ONLINE, - 'dnd': lambda: get_theme().COLOR_STATUS_DND, - 'away': lambda: get_theme().COLOR_STATUS_AWAY, - 'chat': lambda: get_theme().COLOR_STATUS_CHAT, - 'unavailable': lambda: get_theme().COLOR_STATUS_UNAVAILABLE + 'none': lambda: get_theme().COLOR_STATUS_ONLINE, + '': lambda: get_theme().COLOR_STATUS_ONLINE, + 'available': lambda: get_theme().COLOR_STATUS_ONLINE, + 'dnd': lambda: get_theme().COLOR_STATUS_DND, + 'away': lambda: get_theme().COLOR_STATUS_AWAY, + 'chat': lambda: get_theme().COLOR_STATUS_CHAT, + 'unavailable': lambda: get_theme().COLOR_STATUS_UNAVAILABLE } def __init__(self): InfoWin.__init__(self) - def refresh(self, jid, contact, window, chatstate): + def refresh(self, jid, contact, window, chatstate, informations): # contact can be None, if we receive a message # from someone not in our roster. In this case, we display # only the maximum information from the message we can get. @@ -405,9 +405,17 @@ class ConversationInfoWin(InfoWin): self.write_resource_information(resource) self.print_scroll_position(window) self.write_chatstate(chatstate) + self.write_additional_informations(informations, jid) self.finish_line(get_theme().COLOR_INFORMATION_BAR) self._refresh() + def write_additional_informations(self, informations, jid): + """ + Write all informations added by plugins by getting the + value returned by the callbacks. + """ + for key in informations: + self.addstr(informations[key](jid), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) def write_resource_information(self, resource): """ Write the informations about the resource @@ -415,7 +423,7 @@ class ConversationInfoWin(InfoWin): if not resource: presence = "unavailable" else: - presence = resource.get_presence() + presence = resource.presence color = RosterWin.color_show[presence]() self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) self.addstr(" ", to_curses_attr(color)) @@ -428,7 +436,7 @@ class ConversationInfoWin(InfoWin): if not contact: self.addstr("(contact not in roster)", to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) return - display_name = contact.get_name() or contact.get_bare_jid() + display_name = contact.name or contact.bare_jid self.addstr('%s '%(display_name), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) def write_contact_jid(self, jid): @@ -468,7 +476,7 @@ class ConversationStatusMessageWin(InfoWin): self._refresh() def write_status_message(self, resource): - self.addstr(resource.get_status(), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + self.addstr(resource.status, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) class MucInfoWin(InfoWin): """ @@ -1471,14 +1479,14 @@ class RosterWin(Win): presence = 'unavailable' nb = '' else: - presence = resource.get_presence() + presence = resource.presence nb = ' (%s)' % (contact.get_nb_resources(),) color = RosterWin.color_show[presence]() - if contact.get_name(): - display_name = '%s (%s)%s' % (contact.get_name(), - contact.get_bare_jid(), nb,) + if contact.name: + display_name = '%s (%s)%s' % (contact.name, + contact.bare_jid, nb,) else: - display_name = '%s%s' % (contact.get_bare_jid(), nb,) + display_name = '%s%s' % (contact.bare_jid, nb,) self.addstr(y, 0, ' ') self.addstr(" ", to_curses_attr(color)) if resource: @@ -1488,7 +1496,7 @@ class RosterWin(Win): self.addstr(display_name, to_curses_attr(get_theme().COLOR_SELECTED_ROW)) else: self.addstr(display_name) - if contact.get_ask() == 'asked': + if contact.ask == 'asked': self.addstr('?', to_curses_attr(get_theme().COLOR_HIGHLIGHT_NICK)) self.finish_line() @@ -1496,12 +1504,12 @@ class RosterWin(Win): """ Draw a specific resource line """ - color = RosterWin.color_show[resource.get_presence()]() + color = RosterWin.color_show[resource.presence]() self.addstr(y, 4, " ", to_curses_attr(color)) if colored: - self.addstr(y, 6, resource.get_jid().full, to_curses_attr(get_theme().COLOR_SELECTED_ROW)) + self.addstr(y, 6, resource.jid.full, to_curses_attr(get_theme().COLOR_SELECTED_ROW)) else: - self.addstr(y, 6, resource.get_jid().full) + self.addstr(y, 6, resource.jid.full) self.finish_line() def get_selected_row(self): @@ -1517,22 +1525,22 @@ class ContactInfoWin(Win): """ resource = contact.get_highest_priority_resource() if contact: - jid = contact.get_bare_jid() + jid = contact.bare_jid else: - jid = jid or resource.get_jid().full + jid = jid or resource.jid.full if resource: - presence = resource.get_presence() + presence = resource.presence else: presence = 'unavailable' self.addstr(0, 0, '%s (%s)'%(jid, presence,), to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) self.finish_line(get_theme().COLOR_INFORMATION_BAR) - self.addstr(1, 0, 'Subscription: %s' % (contact.get_subscription(),)) - if contact.get_ask(): + self.addstr(1, 0, 'Subscription: %s' % (contact.subscription,)) + if contact.ask: self.addstr(' ') - if contact.get_ask() == 'asked': - self.addstr('Ask: %s' % (contact.get_ask(),), to_curses_attr(get_theme().COLOR_HIGHLIGHT_NICK)) + if contact.ask == 'asked': + self.addstr('Ask: %s' % (contact.ask,), to_curses_attr(get_theme().COLOR_HIGHLIGHT_NICK)) else: - self.addstr('Ask: %s' % (contact.get_ask(),)) + self.addstr('Ask: %s' % (contact.ask,)) self.finish_line() diff --git a/src/xhtml.py b/src/xhtml.py index 99e0bf01..e7a045fa 100644 --- a/src/xhtml.py +++ b/src/xhtml.py @@ -258,7 +258,9 @@ def xhtml_to_poezio_colors(text): if key == 'background-color': pass#shell += '\x191' elif key == 'color': - shell += '\x19%d}' % get_color(value) + color = get_color(value) + if color != -1: + shell += '\x19%d}' % color elif key == 'font-style': shell += '\x19i' elif key == 'font-weight': |