diff options
86 files changed, 2275 insertions, 1020 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 83518f72..e8bd5415 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -79,7 +79,7 @@ pytest-3.9: pytest-3.10: stage: test - image: python:3.10-rc + image: python:3.10 script: - apt-get update && apt-get install -y libidn11-dev - git clone https://lab.louiz.org/poezio/slixmpp.git @@ -90,18 +90,19 @@ pytest-3.10: - python3 setup.py install - py.test -v test/ -pylint-latest: - stage: lint - image: python:3 +pytest-3.11: + stage: test + image: python:3.11-rc + allow_failure: true script: - apt-get update && apt-get install -y libidn11-dev - git clone https://lab.louiz.org/poezio/slixmpp.git - - pip3 install pylint pyasn1-modules cffi --upgrade + - pip3 install pytest pyasn1-modules cffi --upgrade - cd slixmpp - python3 setup.py install - cd .. - python3 setup.py install - - pylint -E poezio + - py.test -v test/ pylint-plugins: stage: lint @@ -115,11 +116,19 @@ pylint-plugins: - python3 setup.py install - pylint -E plugins -mypy: +mypy-fixed: stage: lint image: python:3 script: - - pip3 install mypy - - pip install "typed_ast>=1.4.0,<1.5.0" types-pkg_resources + - pip3 install mypy==0.971 types-setuptools + - mypy --ignore-missing-imports ./poezio + - mypy --ignore-missing-imports ./plugins + +mypy-latest: + stage: lint + image: python:3 + allow_failure: true + script: + - pip3 install mypy types-setuptools - mypy --ignore-missing-imports ./poezio - mypy --ignore-missing-imports ./plugins @@ -1,16 +1,142 @@ This file describes the new features in each poezio release. -* Poezio 0.14 - dev - -# Minor Changes +* Poezio XXX-dev + +* Poezio 0.14 + +# Configuration changes: + +- ADDED: `mam_sync`, `mam_sync_limit`. +- ADDED: `autocolor_tab_names`. +- CHANGED: `ca_cert_path` is commented out. Poezio will attempt to guess + common CA bundle paths. The option can be used to overwrite this behaviour. +- REMOVED: `force_remote_bookmarks`. +- REMOVED: options related to activity, mood, gaming, tune: + `enable_user_activity`, `enable_user_mood`, `enable_user_gaming`, + `enable_user_tune`, `display_activity_notifications`, + `display_mood_notifications`, `display_gaming_notifications`, + `display_tune_notifications`. +- REMOVED: `deterministic_muc_colors`. + +# Changes + +- DEPRECATION: /leave is deprecated. Use /part or /close instead. +- REMOVED: rich presence (activity, mood, gaming, tune) from poezio, alongside + with configuration options and commands: /activity, /mood, + and /gaming. These are moved to the new user_extras plugin. +- REMOVED: non-deterministic nick colors in MUC. +- REMOVED: XEP-0319 support removed for privacy and performance. +- REMOVED: Support for pypy3 with the removal of poopt.py +- ADDED: New /request_voice command for moderated rooms +- ADDED: /join: support using an XMPP URI (xmpp:...?join) +- ADDED: /destroy_room: new optional reason and altroom arguments +- ADDED: Newlines now considered as word separator in input manipulation (#3411) +- ADDED: Colored tab names or number using Consistent Color Generation behind + `autocolor_tab_names` (thanks jonas!) +- Stop highlighting on MUC history messages just because they're delayed +- Stop displaying the traceback in debug log when /xhtml fails +- Ensure launch.sh can be use outside the repository (Thanks kaliko) +- Detect `/set option = value` pattern and do as it `/set option value` was + called. (#3517) +- Newlines taken into account on completion (#3385) +- Allow resizing certificate confirmation window (#3371) +- Make theming more configurable around nicks +- Log MUC <destroy/> payload in the information buffer +- impromptu: wait for room subject before configuring and inviting +- Impromptu: rooms now have pronouceable short names +- impromptu: ensure a room is empty before joining +- impromptu: now uses mediated invites +- Fetch from MAM by default when use_log is False. +- xhtml: Add a new line after a blockquote + +## Bug fixes +- Ensure bookmark is present before removing it in /close. +- Ensure bookmarks are saved correctly on method config change, and on /close. +- Ensure nick is added to bookmark when specified +- Do not crash on bookmark without a nickname (Thanks Ge0rG) +- Ensure the correct tab is bookmarked on /bookmark and /join +- /bookmark: treat empty nick as no nick to avoid failing on empty resource +- Fix closing a tab not in bookmarks +- Disco barejid instead of domains on sent carbons. Follow-up of 5e40437. + (Thanks Ge0rG) +- Only use JID internally when handling affiliations. Add nick if present. + (#3536) +- /last_activity: prevent traceback +- Fix bad error handling when checking bookmarks storage +- `/join / password` works again +- Report available presence in tabs correctly +- /display_correction: now reports the correct time for private messages (#3532) +- Fix composing indicators not showing (#3527) +- Fix pasting text in data forms and bookmarkstab (#3519) +- Fix /me logging (#3432) +- Retrieve nick colors from the correct section +- Do not scroll right by default in dataforms/bookmark text +- Hack around the time limit for topic messages +- Ensure MUC-PM logging filenames are generated as expected (Thanks Ge0rG, southerntofu) +- Fix poezio displaying many times the same participant in the user list. +- Fix default dataform field handling (#3554) +- Fix MUCListTab not joining selected MUCs (#3553) +- Fix /color completion (Thanks eijebong) +- /info: Don't display comma before status message if not available + +## Plugins + +- ADDED: untrackme plugin. based on remove_get_trackers. +- ADDED: user_extras plugin. /activity, /mood and /gaming moved from core. +- ADDED: sticker plugin. +- DEPRECATED: remove_get_trackers +- REMOVED: /irc_login from the irc plugin. - Reorder: Prevent GapTabs from being serialized and ignore when serialized as they're recreated automatically. -- Ensure bookmark is present before removing it in /close. +- Code: prevent traceback when not enough arguments +- Link: Add support for aesgcm, gemini and gopher URIs +- Contact: iterate all data forms (Thanks Ge0rG) +- Fix plugins (embed, lastlog, otr, quote, time_marker) to use poezio.ui.types +- Disco: Added error handling +- IRC: Fix the plugin to work with the various refactors, and use + irc.jabberfr.org as a default gateway + +## API + +- BREAKING: E2EEPlugin.decrypt's `tab` parameter is now of type + Optional[ChatTab] instead of ChatTab. +- BREAKING: E2EEPlugin.supported_tab_types is now required +- BREAKING: decrypt method is now async +- E2EEPlugin decrypts encrypted messages even when they have no body. +- E2EEPlugin lets through already encrypted messages without giving them + to the user lib (poezio-omemo, for example). +- Correctly pass realjid to decrypt call for MUC messages +- /<encryption_name>_fingerprint command is added. Plugins can implement + `get_fingerprints` and `format_fingerprint` for it to return a (formatted) + value. -# Bug fixes +# Under the hood -- Reorder: Fix traceback on serialized gap tabs. +- Moved development from 'master' to 'main' branch +- Lots of type hints added (decorators, multiuserchat, shlex, common, muctab, + etc.) fixing many bugs +- Lots of event handlers and calls are now async in poezio. Many callbacks removed. +- Lots of refactoring +- Performance improvements: + - Trim all messages by 24 bytes on 64-bit systems + - Reduce log parsing by a lot +- No more safeJID calls. (#3457) +- Rework some features to use slixmpp's API instead of custom poezio code + (i.e., muc's set_subject, set_role, set_affiliation, destroy_room, + cancel_config, set_room_config, and most events) +- Split commands from Core +- Require typing_extensions package for python +- Require setuptools package explicitly because of pkg_resources' import + (Thanks Thomas) +- Replace asyncio.ensure_future with asyncio.create_task + +# Meta + +- Improve README, badges, new text, more links +- Update install.txt with instructions for guix (Thanks Raghav) +- Remove references to dev.louiz.org. Everything is happening at + lab.louiz.org. * Poezio 0.13.1 @@ -1,20 +1,674 @@ -Copyright (c) 2010-2018 Florent Le Coz, Mathieu Pasquet and Emmanuel Gil Peyrot + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 -This software is provided 'as-is', without any express or implied -warranty. In no event will the authors be held liable for any damages -arising from the use of this software. + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. -Permission is granted to anyone to use this software for any purpose, -including commercial applications, and to alter it and redistribute it -freely, subject to the following restrictions: + Preamble - 1. The origin of this software must not be misrepresented; you must not - claim that you wrote the original software. If you use this software - in a product, an acknowledgment in the product documentation would be - appreciated but is not required. + The GNU General Public License is a free, copyleft license for +software and other kinds of works. - 2. Altered source versions must be plainly marked as such, and must not be - misrepresented as being the original software. + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. - 3. This notice may not be removed or altered from any source - distribution. + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<http://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<http://www.gnu.org/philosophy/why-not-lgpl.html>.
\ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 962aa000..6f4000db 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ recursive-include doc/source * +recursive-include tools include data/poezio.1 include data/io.poez.Poezio.appdata.xml include data/io.poez.Poezio.desktop @@ -95,7 +95,7 @@ License Poezio is Free Software. (learn more: http://www.gnu.org/philosophy/free-sw.html) -Poezio is released under the zlib License. +Poezio is released under the GPL-3.0+ License. Please read the COPYING file for details. The artwork logo was made by Gaëtan Ribémont and released under @@ -148,7 +148,7 @@ Thanks .. |python versions| image:: https://img.shields.io/pypi/pyversions/poezio.svg -.. |license| image:: https://img.shields.io/badge/license-zlib-blue.svg +.. |license| image:: https://img.shields.io/badge/license-gpl--3.0--or--later-blue.svg .. |discuss| image:: https://inverse.chat/badge.svg?room=poezio@muc.poez.io :target: https://chat.jabberfr.org/converse.js/poezio@muc.poez.io diff --git a/data/default_config.cfg b/data/default_config.cfg index 9f284f07..8e926c0e 100644 --- a/data/default_config.cfg +++ b/data/default_config.cfg @@ -79,12 +79,14 @@ certificate = # value to the services default. #whitespace_interval = 300 -# Path to the certificate authenticating the Authority +# Path to the certificate authenticating the Authority. # A server may have several certificates, but if it uses a CA, it will often # keep the same for obvious reasons, so this is a good option if your server # does this, rather than skipping all verifications. # This is not affected by ignore_certificate -ca_cert_path = +# Poezio attempts to guess this value automatically if empty. To override this +# behaviour, set the value to another path. +#ca_cert_path = # Auto-reconnects you when you get disconnected from the server #auto_reconnect = true diff --git a/data/doap.xml b/data/doap.xml index 656b4d8e..6a1330b7 100644 --- a/data/doap.xml +++ b/data/doap.xml @@ -20,7 +20,7 @@ <developer-forum rdf:resource="xmpp:poezio@muc.poez.io?join"/> <support-forum rdf:resource="xmpp:poezio@muc.poez.io?join"/> - <license rdf:resource="https://git.poez.io/poezio/plain/COPYING"/> + <license rdf:resource="https://lab.louiz.org/poezio/poezio/-/raw/main/COPYING"/> <!-- See https://github.com/ewilderj/doap/issues/49 --> <language>en</language> @@ -454,9 +454,9 @@ <implements> <xmpp:SupportedXep> <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0319.html"/> - <xmpp:status>complete</xmpp:status> + <xmpp:status>removed</xmpp:status> <xmpp:version>1.0</xmpp:version> - <xmpp:since>0.10</xmpp:since> + <xmpp:since>0.14</xmpp:since> </xmpp:SupportedXep> </implements> <implements> @@ -515,7 +515,7 @@ <xmpp:status>partial</xmpp:status> <xmpp:version>0.3</xmpp:version> <xmpp:since>0.13</xmpp:since> - <xmpp:note>Available at https://lab.louiz.org/poezio/poezio-omemo. 1:1 only</xmpp:note> + <xmpp:note>Available at https://lab.louiz.org/poezio/poezio-omemo. UI largely missing, trust management missing</xmpp:note> </xmpp:SupportedXep> </implements> <implements> @@ -526,9 +526,24 @@ <xmpp:since>0.13</xmpp:since> </xmpp:SupportedXep> </implements> + <implements> + <xmpp:SupportedXep> + <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0454.html"/> + <xmpp:status>partial</xmpp:status> + <xmpp:version>0.1</xmpp:version> + <xmpp:since>0.14</xmpp:since> + <xmpp:note>No thumbnails support</xmpp:note> + </xmpp:SupportedXep> + </implements> + <release> <Version> + <revision>0.14</revision> + <created>2022-04-10</created> + <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.14/poezio-v0.14.tar.gz"/> + </Version> + <Version> <revision>0.13.1</revision> <created>2020-05-31</created> <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.13.1/poezio-v0.13.1.tar.gz"/> diff --git a/data/io.poez.Poezio.appdata.xml b/data/io.poez.Poezio.appdata.xml index a408a980..d6f479a3 100644 --- a/data/io.poez.Poezio.appdata.xml +++ b/data/io.poez.Poezio.appdata.xml @@ -4,7 +4,7 @@ <id>io.poez.Poezio</id> <name>Poezio</name> <summary>Poezio is a free console XMPP client</summary> - <project_license>Zlib</project_license> + <project_license>GPL-3.0+</project_license> <metadata_license>CC0-1.0</metadata_license> <developer_name>Poezio Team</developer_name> <icon type="remote">https://poez.io/img/logo.svg</icon> @@ -34,7 +34,7 @@ <p>Features</p> <ul> - <li>Encryption: TLS, OTR, always chat with encryption.</li> + <li>Encryption: TLS, OMEMO, OTR, always chat with encryption.</li> <li>Chatrooms: Discuss on your favorite topics with your friends or strangers.</li> <li>Carbon copies: Switch devices while staying in the same conversations without losing messages.</li> <li>Plugins: Add the features you want through official or other plugins.</li> @@ -68,6 +68,7 @@ </provides> <releases> + <release version="0.14" date="2022-04-10"/> <release version="0.13.1" date="2020-05-31"/> <release version="0.13" date="2020-05-24"/> <release version="0.12.1" date="2018-09-12"/> diff --git a/data/poezio_logo.svg b/data/poezio_logo.svg index 7848f8c6..30a93907 100644 --- a/data/poezio_logo.svg +++ b/data/poezio_logo.svg @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="UTF-8"?> -<svg width="162.31mm" height="155.71mm" version="1.1" viewBox="0 0 162.31 155.71" xmlns="http://www.w3.org/2000/svg"> +<svg version="1.1" viewBox="0 -3.3 162.31 162.31" xmlns="http://www.w3.org/2000/svg"> <defs> <linearGradient id="a" x1="-236.83" x2="-194.71" y1="320.77" y2="225.75" gradientTransform="matrix(3.7795 0 0 3.7795 -851.06 -1965.2)" gradientUnits="userSpaceOnUse"> <stop stop-color="#c6b8a3" offset="0"/> diff --git a/data/poezio_logs.1 b/data/poezio_logs.1 index 0be422aa..48b787da 100644 --- a/data/poezio_logs.1 +++ b/data/poezio_logs.1 @@ -70,5 +70,5 @@ Copyright \(co 2016 Tanguy Ortolo .PP This manual page was written for the Debian system (and may be used by others). .PP -Permission is granted to copy, distribute and/or modify this document under the terms of the Zlib License. +Permission is granted to copy, distribute and/or modify this document under the terms of the GPL-3.0+ License. .sp diff --git a/data/scripts-manpages.xml b/data/scripts-manpages.xml index 241ba6ea..06cf79ba 100644 --- a/data/scripts-manpages.xml +++ b/data/scripts-manpages.xml @@ -62,7 +62,7 @@ man(1), man(7), http://www.tldp.org/HOWTO/Man-Page/ <para>This manual page was written for the Debian system (and may be used by others).</para> <para>Permission is granted to copy, distribute and/or modify this - document under the terms of the Zlib License.</para> + document under the terms of the GPL-3.0+ License.</para> </legalnotice> </info> diff --git a/doc/source/conf.py b/doc/source/conf.py index af30d8ba..74547057 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -46,16 +46,16 @@ master_doc = 'index' # General information about the project. project = 'Poezio' -copyright = '%s, Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot' % time.strftime('%Y') +copyright = '%s, Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot - Maxime Buquet' % time.strftime('%Y') # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.13' +version = '0.14' # The full version, including alpha/beta/rc tags. -release = '0.13.1' +release = '0.14' add_function_parentheses = True @@ -195,7 +195,7 @@ latex_elements = { # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'PoezioDoc.tex', 'Poezio Documentation', - 'Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot', 'manual'), + 'Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot - Maxime Buquet', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -225,11 +225,11 @@ latex_documents = [ # (source start file, name, description, authors, manual section). man_pages = [ ('configuration', 'poezio.cfg', 'Poezio Configuration File', - ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot'], 7), + ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot', 'Maxime Buquet'], 7), ('keys', 'poezio.keys', 'Poezio Key Bindings', - ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot'], 7), + ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot', 'Maxime Buquet'], 7), ('commands', 'poezio.commands', 'Poezio Commands', - ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot'], 7), + ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot', 'Maxime Buquet'], 7), ] # If true, show URL addresses after external links. @@ -243,7 +243,7 @@ man_pages = [ # dir menu entry, description, category) texinfo_documents = [ ('index', 'PoezioDoc', 'Poezio Documentation', - 'Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot', 'PoezioDoc', 'Poezio Documentation', + 'Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot - Maxime Buquet', 'PoezioDoc', 'Poezio Documentation', 'Miscellaneous'), ] diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 56f546d8..c28f38fa 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -10,8 +10,8 @@ or use the :term:`/set` command to edit some of its values directly from poezio. This file is also used to configure key bindings, but this is explained in the :ref:`keys-page` documentation file. -That file is read at each startup and the configuration is saved when poezio -is closed. +The configuration is read at each startup or when the `/reload` command is +issued, and it is updated after every `/set` command. This configuration file **requires** all global options to be in a section named [Poezio]. Some other options can be in optional sections and will @@ -81,15 +81,6 @@ and certificate validation. you know what you are doing, see the :ref:`ciphers` dedicated section for more details. - default_muc_service - - **Default value:** ``[empty]`` - - If specified, will be used instead of the MUC service provided by - the user domain. - - .. versionadded:: 0.13 - force_encryption **Default value:** ``true`` @@ -142,6 +133,15 @@ Options related to account configuration, nickname… This option can be combined with :term:`custom_host`. You should not need this in a "normal" use case. + default_muc_service + + **Default value:** ``[empty]`` + + If specified, will be used instead of the MUC service provided by + the user domain. + + .. versionadded:: 0.13 + default_nick **Default value:** ``[empty]`` @@ -617,6 +617,14 @@ or the way messages are displayed. If set to true, the color of the nick will be used in chatroom information messages, instead of the default color from the theme. + autocolor_tab_names + + **Default value:** ``false`` + + If ``true``, uses deterministic coloration for tab names or tab + numbers in the activity bar, using Consistent Color Generation + (XEP-0392). + enable_vertical_tab_list **Default value:** ``true`` diff --git a/doc/source/plugins/index.rst b/doc/source/plugins/index.rst index 42578be8..c1222c84 100644 --- a/doc/source/plugins/index.rst +++ b/doc/source/plugins/index.rst @@ -211,6 +211,11 @@ Plugin index Adds convenient aliases to /status (/away, etc). + Sticker + :ref:`Documentation <sticker-plugin>` + + Opens a graphical sticker picker and sends the selected one. + Tell :ref:`Documentation <tell-plugin>` @@ -342,6 +347,7 @@ Plugin index simple_notify spam status + sticker tell time_marker uptime diff --git a/doc/source/plugins/sticker.rst b/doc/source/plugins/sticker.rst new file mode 100644 index 00000000..815fb141 --- /dev/null +++ b/doc/source/plugins/sticker.rst @@ -0,0 +1,6 @@ +.. _sticker-plugin: + +Sticker +======= + +.. automodule:: sticker @@ -1,20 +1,20 @@ #!/bin/sh -cd $(dirname "$(readlink -f "$0")") +proj_dir=$(dirname "$(readlink -f "$0")") if [ -z "$POEZIO_VENV" ] then POEZIO_VENV="poezio-venv" fi -if [ -e .git ] +if [ -e "$proj_dir/.git" ] then - args=$(git show --format='%h %ci' | head -n1) + args=$(git -C "$proj_dir" show --format='%h %ci' | head -n1) else - args="0.13.1-dev" + args="0.14-dev" fi -if [ -e "$POEZIO_VENV" ] +if [ -e "$proj_dir/$POEZIO_VENV" ] then - PYTHON3="$POEZIO_VENV/bin/python3" + PYTHON3="$proj_dir/$POEZIO_VENV/bin/python3" else echo "" echo "WARNING: Not using the up-to-date launch format" @@ -25,5 +25,5 @@ else fi $PYTHON3 -c 'import sys;(print("Python 3.7 or newer is required") and exit(1)) if sys.version_info < (3, 7) else exit(0)' || exit 1 -exec "$PYTHON3" -m poezio --custom-version "$args" "$@" +PYTHONPATH="$proj_dir:$PYTHONPATH" exec "$PYTHON3" -m poezio --custom-version "$args" "$@" diff --git a/plugins/amsg.py b/plugins/amsg.py index 4cd6c055..3b81085a 100644 --- a/plugins/amsg.py +++ b/plugins/amsg.py @@ -29,7 +29,7 @@ class Plugin(BasePlugin): short='Broadcast a message', help='Broadcast the message to all the joined rooms.') - def command_amsg(self, args): + async def command_amsg(self, args): for room in self.core.tabs: if isinstance(room, MucTab) and room.joined: - room.command_say(args) + await room.command_say(args) diff --git a/plugins/b64.py b/plugins/b64.py index 1fec6123..82300a0f 100644 --- a/plugins/b64.py +++ b/plugins/b64.py @@ -4,7 +4,7 @@ # # Copyright © 2019 Maxime “pep” Buquet <pep@bouah.net> # -# Distributed under terms of the zlib license. +# Distributed under terms of the GPL-3.0+ license. """ Usage @@ -23,10 +23,17 @@ This plugin also respects security guidelines listed in XEP-0419. """ from base64 import b64decode, b64encode -from poezio.plugin_e2ee import E2EEPlugin -from poezio.tabs import ChatTab +from typing import List, Optional from slixmpp import Message, JID -from typing import Optional + +from poezio.plugin_e2ee import E2EEPlugin +from poezio.tabs import ( + ChatTab, + MucTab, + PrivateTab, + DynamicConversationTab, + StaticConversationTab, +) class Plugin(E2EEPlugin): @@ -39,14 +46,22 @@ class Plugin(E2EEPlugin): # This encryption mechanism is using <body/> as a container replace_body_with_eme = False - async def decrypt(self, message: Message, jid: Optional[JID], _tab: ChatTab) -> None: + # In what tab is it ok to use this plugin. Here we want all of them + supported_tab_types = ( + MucTab, + PrivateTab, + DynamicConversationTab, + StaticConversationTab, + ) + + async def decrypt(self, message: Message, jid: Optional[JID], _tab: Optional[ChatTab]) -> None: """ Decrypt base64 """ body = message['body'] message['body'] = b64decode(body.encode()).decode() - async def encrypt(self, message: Message, jid: Optional[JID], _tab: ChatTab) -> None: + async def encrypt(self, message: Message, _jid: Optional[List[JID]], _tab: ChatTab) -> None: """ Encrypt to base64 """ diff --git a/plugins/contact.py b/plugins/contact.py index a3a0514b..13dcc42f 100644 --- a/plugins/contact.py +++ b/plugins/contact.py @@ -13,6 +13,7 @@ Usage """ from poezio.plugin import BasePlugin +from slixmpp.exceptions import IqError, IqTimeout from slixmpp.jid import InvalidJID CONTACT_TYPES = ['abuse', 'admin', 'feedback', 'sales', 'security', 'support'] @@ -25,12 +26,6 @@ class Plugin(BasePlugin): help='Get the Contact Addresses of a JID') def on_disco(self, iq): - if iq['type'] == 'error': - error_condition = iq['error']['condition'] - error_text = iq['error']['text'] - message = 'Error getting Contact Addresses from %s: %s: %s' % (iq['from'], error_condition, error_text) - self.api.information(message, 'Error') - return info = iq['disco_info'] contacts = [] # iterate all data forms, in case there are multiple @@ -45,15 +40,21 @@ class Plugin(BasePlugin): field_value = values[var] if field_value: value = sep.join(field_value) if isinstance(field_value, list) else field_value - contacts.append('%s: %s' % (title, value)) + contacts.append(f'{title}: {value}') if contacts: self.api.information('\n'.join(contacts), 'Contact Info') else: - self.api.information('No Contact Addresses for %s' % iq['from'], 'Error') + self.api.information(f'No Contact Addresses for {iq["from"]}', 'Error') async def command_disco(self, jid): try: iq = await self.core.xmpp.plugin['xep_0030'].get_info(jid=jid, cached=False) self.on_disco(iq) - except InvalidJID as e: - self.api.information('Invalid JID “%s”: %s' % (jid, e), 'Error') + except InvalidJID as exn: + self.api.information(f'Invalid JID “{jid}”: {exn}', 'Error') + except (IqError, IqTimeout,) as exn: + ifrom = exn.iq['from'] + condition = exn.iq['error']['condition'] + text = exn.iq['error']['text'] + message = f'Error getting Contact Addresses from {ifrom}: {condition}: {text}' + self.api.information(message, 'Error') diff --git a/plugins/disco.py b/plugins/disco.py index 52963d39..d15235f6 100644 --- a/plugins/disco.py +++ b/plugins/disco.py @@ -18,6 +18,7 @@ Usage from poezio.plugin import BasePlugin from poezio.decorators import command_args_parser from slixmpp.jid import InvalidJID +from slixmpp.exceptions import IqError, IqTimeout class Plugin(BasePlugin): @@ -94,8 +95,12 @@ class Plugin(BasePlugin): self.on_info(iq) elif type_ == 'items': iq = await self.core.xmpp.plugin['xep_0030'].get_items( - jid=jid, node=node, cached=False + jid=jid, node=node ) self.on_items(iq) except InvalidJID as e: self.api.information('Invalid JID “%s”: %s' % (jid, e), 'Error') + except IqError as e: + self.api.information('Received iq error while querying “%s”: %s' % (jid, e), 'Error') + except IqTimeout: + self.api.information('Received no reply querying “%s”…' % jid, 'Error') diff --git a/plugins/embed.py b/plugins/embed.py index 3ce2a9d3..4a68f035 100644 --- a/plugins/embed.py +++ b/plugins/embed.py @@ -29,8 +29,8 @@ class Plugin(BasePlugin): help='Embed an image url into the contact\'s client', usage='<image_url>') - def embed_image_url(self, url): - tab = self.api.current_tab() + def embed_image_url(self, url, tab=None): + tab = tab or self.api.current_tab() message = self.core.xmpp.make_message(tab.jid) message['body'] = url message['oob']['url'] = url diff --git a/plugins/exec.py b/plugins/exec.py index 0786c86f..68f24486 100644 --- a/plugins/exec.py +++ b/plugins/exec.py @@ -95,4 +95,4 @@ class Plugin(BasePlugin): else: self.api.run_command('/help exec') return - asyncio.ensure_future(self.async_exec(command, arg)) + asyncio.create_task(self.async_exec(command, arg)) diff --git a/plugins/irc.py b/plugins/irc.py index a7650ec4..f3aa7b63 100644 --- a/plugins/irc.py +++ b/plugins/irc.py @@ -20,9 +20,9 @@ Global configuration :sorted: gateway - **Default:** ``irc.poez.io`` + **Default:** ``irc.jabberfr.org`` - The JID of the IRC gateway to use. If empty, irc.poez.io will be + The JID of the IRC gateway to use. If empty, irc.jabberfr.org will be used. Please try to run your own, though, it’s painless to setup. initial_connect @@ -46,17 +46,6 @@ section name, and the following options: .. glossary:: :sorted: - - login_command - **Default:** ``[empty]`` - - The command used to identify with the services (e.g. ``IDENTIFY mypassword``). - - login_nick - **Default:** ``[empty]`` - - The nickname to whom the auth command will be sent. - nickname **Default:** ``[empty]`` @@ -77,14 +66,6 @@ Commands .. glossary:: :sorted: - /irc_login - **Usage:** ``/irc_login [server1] [server2]…`` - - Authenticate with the specified servers if they are correctly - configured. If no servers are provided, the plugin will try - them all. (You need to set :term:`login_nick` and - :term:`login_command` as well) - /irc_join **Usage:** ``/irc_join <room or server>`` @@ -109,9 +90,9 @@ Example configuration .. code-block:: ini [irc] - gateway = irc.poez.io + gateway = irc.jabberfr.org - [irc.freenode.net] + [irc.libera.chat] nickname = mynick login_nick = nickserv login_command = identify mypassword @@ -131,32 +112,28 @@ Example configuration import asyncio +from typing import Optional, Tuple, List, Any +from slixmpp.jid import JID, InvalidJID + from poezio.plugin import BasePlugin from poezio.decorators import command_args_parser from poezio.core.structs import Completion -from poezio import common from poezio import tabs class Plugin(BasePlugin): - def init(self): - if self.config.get('initial_connect', True): - asyncio.ensure_future( + default_config = { + 'irc': { + "initial_connect": True, + "gateway": "irc.jabberfr.org", + } + } + + def init(self) -> None: + if self.config.getbool('initial_connect'): + asyncio.create_task( self.initial_connect() ) - - self.api.add_command( - 'irc_login', - self.command_irc_login, - usage='[server] [server]…', - help=('Connect to the specified servers if they ' - 'exist in the configuration and the login ' - 'options are set. If not is given, the ' - 'plugin will try all the sections in the ' - 'configuration.'), - short='Login to irc servers with nickserv', - completion=self.completion_irc_login) - self.api.add_command( 'irc_join', self.command_irc_join, @@ -183,11 +160,11 @@ class Plugin(BasePlugin): 'example.com "hi there"`'), short='Open a private conversation with an IRC user') - async def join(self, gateway, server): + async def join(self, gateway: str, server: JID) -> None: "Join irc rooms on a server" - nick = self.config.get_by_tabname( + nick: str = self.config.get_by_tabname( 'nickname', server, default='') or self.core.own_nick - rooms = self.config.get_by_tabname( + rooms: List[str] = self.config.get_by_tabname( 'rooms', server, default='').split(':') joins = [] for room in rooms: @@ -196,12 +173,25 @@ class Plugin(BasePlugin): await asyncio.gather(*joins) - async def initial_connect(self): - gateway = self.config.get('gateway', 'irc.poez.io') - sections = self.config.sections() + async def initial_connect(self) -> None: + gateway: str = self.config.getstr('gateway') + sections: List[str] = self.config.sections() - for section in (s for s in sections if s != 'irc'): + sections_jid = [] + for sect in sections: + if sect == 'irc': + continue + try: + sect_jid = JID(sect) + if sect_jid != sect_jid.server: + self.api.information(f'Invalid server: {sect}', 'Warning') + continue + except InvalidJID: + self.api.information(f'Invalid server: {sect}', 'Warning') + continue + sections_jid.append(sect_jid) + for section in sections_jid: room_suffix = '%{}@{}'.format(section, gateway) already_opened = False @@ -210,125 +200,40 @@ class Plugin(BasePlugin): already_opened = True break - login_command = self.config.get_by_tabname( - 'login_command', section, default='') - login_nick = self.config.get_by_tabname( - 'login_nick', section, default='') - nick = self.config.get_by_tabname( - 'nickname', section, default='') or self.core.own_nick - if login_command and login_nick: - - def login(gw, sect, log_nick, log_cmd, room_suff): - dest = '{}%{}'.format(log_nick, room_suff) - self.core.xmpp.send_message( - mto=dest, mbody=log_cmd, mtype='chat') - delayed = self.api.create_delayed_event( - 5, self.join, gw, sect) - self.api.add_timed_event(delayed) - - if not already_opened: - self.core.command.join(room_suffix + '/' + nick) - delayed = self.api.create_delayed_event( - 5, login, gateway, section, login_nick, login_command, - room_suffix[1:]) - self.api.add_timed_event(delayed) - else: - login(gateway, section, login_nick, login_command, - room_suffix[1:]) - elif not already_opened: + if not already_opened: await self.join(gateway, section) - @command_args_parser.quoted(0, -1) - def command_irc_login(self, args): - """ - /irc_login [server] [server]… - """ - gateway = self.config.get('gateway', 'irc.poez.io') - if args: - not_present = [] - sections = self.config.sections() - for section in args: - if section not in sections: - not_present.append(section) - continue - login_command = self.config.get_by_tabname( - 'login_command', section, default='') - login_nick = self.config.get_by_tabname( - 'login_nick', section, default='') - if not login_command and not login_nick: - not_present.append(section) - continue - - room_suffix = '%{}@{}'.format(section, gateway) - dest = '{}%{}'.format(login_nick, room_suffix[1:]) - self.core.xmpp.send_message( - mto=dest, mbody=login_command, mtype='chat') - if len(not_present) == 1: - self.api.information( - 'Section %s does not exist or is not configured' % - not_present[0], 'Warning') - elif len(not_present) > 1: - self.api.information( - 'Sections %s do not exist or are not configured' % - ', '.join(not_present), 'Warning') - else: - sections = self.config.sections() - - for section in (s for s in sections if s != 'irc'): - login_command = self.config.get_by_tabname( - 'login_command', section, default='') - login_nick = self.config.get_by_tabname( - 'login_nick', section, default='') - if not login_nick and not login_command: - continue - - room_suffix = '%{}@{}'.format(section, gateway) - dest = '{}%{}'.format(login_nick, room_suffix[1:]) - self.core.xmpp.send_message( - mto=dest, mbody=login_command, mtype='chat') - - def completion_irc_login(self, the_input): - """ - completion for /irc_login - """ - args = the_input.text.split() - if '' in args: - args.remove('') - pos = the_input.get_argument_position() - sections = self.config.sections() - if 'irc' in sections: - sections.remove('irc') - for section in args: - try: - sections.remove(section) - except: - pass - return Completion(the_input.new_completion, sections, pos) - @command_args_parser.quoted(1, 1) - async def command_irc_join(self, args): + async def command_irc_join(self, args: Optional[List[str]]) -> None: """ /irc_join <room or server> """ if not args: - return self.core.command.help('irc_join') - sections = self.config.sections() + self.core.command.help('irc_join') + return + sections: List[str] = self.config.sections() if 'irc' in sections: sections.remove('irc') - if args[0] in sections and self.config.get_by_tabname( - 'rooms', args[0]): - await self.join_server_rooms(args[0]) + if args[0] in sections: + try: + section_jid = JID(args[0]) + except InvalidJID: + self.api.information(f'Invalid address: {args[0]}', 'Error') + return + #self.config.get_by_tabname('rooms', section_jid) + await self.join_server_rooms(section_jid) else: await self.join_room(args[0]) @command_args_parser.quoted(1, 1) - def command_irc_query(self, args): + def command_irc_query(self, args: Optional[List[str]]) -> None: """ Open a private conversation with the given nickname, on the current IRC server. """ if args is None: - return self.core.command.help('irc_query') + self.core.command.help('irc_query') + return current_tab_info = self.get_current_tab_irc_info() if not current_tab_info: return @@ -343,14 +248,14 @@ class Plugin(BasePlugin): else: self.core.command.message('{}'.format(jid)) - async def join_server_rooms(self, section): + async def join_server_rooms(self, section: JID) -> None: """ Join all the rooms configured for a section (section = irc server) """ - gateway = self.config.get('gateway', 'irc.poez.io') - rooms = self.config.get_by_tabname('rooms', section).split(':') - nick = self.config.get_by_tabname('nickname', section) + gateway: str = self.config.getstr('gateway') + rooms: List[str] = self.config.get_by_tabname('rooms', section).split(':') + nick: str = self.config.get_by_tabname('nickname', section) if nick: nick = '/' + nick else: @@ -360,7 +265,7 @@ class Plugin(BasePlugin): for room in rooms: await self.core.command.join(room + suffix) - async def join_room(self, name): + async def join_room(self, name: str) -> None: """ Join a room with only its name and the current tab """ @@ -368,20 +273,24 @@ class Plugin(BasePlugin): if not current_tab_info: return server, gateway = current_tab_info + try: + server_jid = JID(server) + except InvalidJID: + return room = '{}%{}@{}'.format(name, server, gateway) - if self.config.get_by_tabname('nickname', server): - room += '/' + self.config.get_by_tabname('nickname', server) + if self.config.get_by_tabname('nickname', server_jid.bare): + room += '/' + self.config.get_by_tabname('nickname', server_jid.bare) await self.core.command.join(room) - def get_current_tab_irc_info(self): + def get_current_tab_irc_info(self) -> Optional[Tuple[str, str]]: """ Return a tuple with the irc server and the gateway hostnames of the current tab. If the current tab is not an IRC channel or private conversation, a warning is displayed and None is returned """ - gateway = self.config.get('gateway', 'irc.poez.io') + gateway: str = self.config.getstr('gateway') current = self.api.current_tab() current_jid = current.jid if not current_jid.server == gateway: @@ -404,11 +313,11 @@ class Plugin(BasePlugin): return None return server, gateway - def completion_irc_join(self, the_input): + def completion_irc_join(self, the_input: Any) -> Completion: """ completion for /irc_join """ - sections = self.config.sections() + sections: List[str] = self.config.sections() if 'irc' in sections: sections.remove('irc') return Completion(the_input.new_completion, sections, 1) diff --git a/plugins/lastlog.py b/plugins/lastlog.py index 70ea53c1..1c48fa06 100644 --- a/plugins/lastlog.py +++ b/plugins/lastlog.py @@ -5,7 +5,7 @@ # Copyright © 2018 Maxime “pep” Buquet # Copyright © 2019 Madhur Garg # -# Distributed under terms of the zlib license. See the COPYING file. +# Distributed under terms of the GPL-3.0+ license. See the COPYING file. """ Search provided string in the buffer and return all results on the screen diff --git a/plugins/marquee.py b/plugins/marquee.py index 9319a7f6..66ec8b70 100644 --- a/plugins/marquee.py +++ b/plugins/marquee.py @@ -34,6 +34,7 @@ Configuration """ +import asyncio from poezio.plugin import BasePlugin from poezio import tabs from poezio import xhtml @@ -62,10 +63,12 @@ class Plugin(BasePlugin): 'Replicate the <marquee/> behavior in a message') @command_args_parser.raw - def command_marquee(self, args): + async def command_marquee(self, args): + if not args: + return None tab = self.api.current_tab() args = xhtml.clean_text(xhtml.convert_simple_to_full_colors(args)) - tab.command_say(args) + await tab.command_say(args) is_muctab = isinstance(tab, tabs.MucTab) msg_id = tab.last_sent_message["id"] jid = tab.jid diff --git a/plugins/ping.py b/plugins/ping.py index 46ce4efc..cc987bf0 100644 --- a/plugins/ping.py +++ b/plugins/ping.py @@ -123,7 +123,7 @@ class Plugin(BasePlugin): jid = arg if not arg: jid = self.api.current_tab().jid - asyncio.ensure_future( + asyncio.create_task( self.command_ping(jid) ) @@ -140,7 +140,7 @@ class Plugin(BasePlugin): jid = JID(arg) except InvalidJID: return self.api.information('Invalid JID: %s' % arg, 'Error') - asyncio.ensure_future( + asyncio.create_task( self.command_ping(jid.full) ) @@ -156,7 +156,7 @@ class Plugin(BasePlugin): res = current.get_highest_priority_resource() if res is not None: jid =res.jid - asyncio.ensure_future( + asyncio.create_task( self.command_ping(jid) ) diff --git a/plugins/reorder.py b/plugins/reorder.py index d4ab464b..158b89bb 100644 --- a/plugins/reorder.py +++ b/plugins/reorder.py @@ -118,7 +118,7 @@ def parse_runtime_tablist(tablist): i += 1 result = check_tab(tab) # Don't serialize gap tabs as they're recreated automatically - if result != 'empty': + if result != 'empty' and isinstance(tab, tuple(TEXT_TO_TAB.values())): props.append((i, '%s:%s' % (result, tab.jid.full))) return props diff --git a/plugins/send_delayed.py b/plugins/send_delayed.py index e8b00027..92ed97c1 100644 --- a/plugins/send_delayed.py +++ b/plugins/send_delayed.py @@ -18,6 +18,7 @@ This plugin adds a command to the chat tabs. """ +import asyncio from poezio.plugin import BasePlugin from poezio.core.structs import Completion from poezio.decorators import command_args_parser @@ -74,6 +75,6 @@ class Plugin(BasePlugin): tab = args[0] # anything could happen to the tab during the interval try: - tab.command_say(args[1]) + asyncio.ensure_future(tab.command_say(args[1])) except: pass diff --git a/plugins/sticker.py b/plugins/sticker.py new file mode 100644 index 00000000..c9deacc0 --- /dev/null +++ b/plugins/sticker.py @@ -0,0 +1,97 @@ +''' +This plugin lets the user select and send a sticker from a pack of stickers. + +The protocol used here is based on XEP-0363 and XEP-0066, while a future +version may use XEP-0449 instead. + +Command +------- + +.. glossary:: + /sticker + **Usage:** ``/sticker <pack>`` + + Opens a picker tool, and send the sticker which has been selected. + +Configuration options +--------------------- + +.. glossary:: + sticker_picker + **Default:** ``poezio-sticker-picker`` + + The command to invoke as a sticker picker. A sample one is provided in + tools/sticker-picker. + + stickers_dir + **Default:** ``XDG_DATA_HOME/poezio/stickers`` + + The directory under which the sticker packs can be found. +''' + +import asyncio +import concurrent.futures +from poezio import xdg +from poezio.plugin import BasePlugin +from poezio.config import config +from poezio.decorators import command_args_parser +from poezio.core.structs import Completion +from pathlib import Path +from asyncio.subprocess import PIPE, DEVNULL + +class Plugin(BasePlugin): + dependencies = {'upload'} + + def init(self): + # The command to use as a picker helper. + self.picker_command = config.getstr('sticker_picker') or 'poezio-sticker-picker' + + # Select and create the stickers directory. + directory = config.getstr('stickers_dir') + if directory: + self.directory = Path(directory).expanduser() + else: + self.directory = xdg.DATA_HOME / 'stickers' + self.directory.mkdir(parents=True, exist_ok=True) + + self.upload = self.refs['upload'] + self.api.add_command('sticker', self.command_sticker, + usage='<sticker pack>', + short='Send a sticker', + help='Send a sticker, with a helper GUI sticker picker', + completion=self.completion_sticker) + + def command_sticker(self, pack): + ''' + Sends a sticker + ''' + if not pack: + self.api.information('Missing sticker pack argument.', 'Error') + return + async def run_command(tab, path: Path): + try: + process = await asyncio.create_subprocess_exec( + self.picker_command, path, stdout=PIPE, stderr=PIPE) + sticker, stderr = await process.communicate() + except FileNotFoundError as err: + self.api.information('Failed to launch the sticker picker: %s' % err, 'Error') + return + else: + if process.returncode != 0: + self.api.information('Sticker picker failed: %s' % stderr.decode(), 'Error') + return + if sticker: + filename = sticker.decode().rstrip() + self.api.information('Sending sticker %s' % filename, 'Info') + await self.upload.send_upload(path / filename, tab) + tab = self.api.current_tab() + path = self.directory / pack + asyncio.create_task(run_command(tab, path)) + + def completion_sticker(self, the_input): + ''' + Completion for /sticker + ''' + txt = the_input.get_text()[9:] + directories = [directory.name for directory in self.directory.glob(txt + '*')] + return Completion(the_input.auto_completion, directories, quotify=False) diff --git a/plugins/tell.py b/plugins/tell.py index 614c1ef5..cd72a9e5 100644 --- a/plugins/tell.py +++ b/plugins/tell.py @@ -25,6 +25,7 @@ This plugin defines two new commands for chatroom tabs: List all queued messages for the current chatroom. """ +import asyncio from poezio.plugin import BasePlugin from poezio.core.structs import Completion from poezio.decorators import command_args_parser @@ -66,7 +67,7 @@ class Plugin(BasePlugin): if nick not in self.tabs[tab]: return for i in self.tabs[tab][nick]: - tab.command_say("%s: %s" % (nick, i)) + asyncio.ensure_future(tab.command_say("%s: %s" % (nick, i))) del self.tabs[tab][nick] @command_args_parser.ignored diff --git a/plugins/upload.py b/plugins/upload.py index c702dc49..6926c075 100644 --- a/plugins/upload.py +++ b/plugins/upload.py @@ -40,6 +40,12 @@ class Plugin(BasePlugin): if not self.core.xmpp['xep_0363']: raise Exception('slixmpp XEP-0363 plugin failed to load') + if not self.core.xmpp['xep_0454']: + self.api.information( + 'slixmpp XEP-0454 plugin failed to load. ' + 'Will not be able to encrypt uploaded files.', + 'Warning', + ) for _class in (tabs.PrivateTab, tabs.StaticConversationTab, tabs.DynamicConversationTab, tabs.MucTab): self.api.add_tab_command( _class, @@ -50,9 +56,12 @@ class Plugin(BasePlugin): short='Upload a file', completion=self.completion_filename) - async def upload(self, filename) -> Optional[str]: + async def upload(self, filename, encrypted=False) -> Optional[str]: try: - url = await self.core.xmpp['xep_0363'].upload_file(filename) + upload_file = self.core.xmpp['xep_0363'].upload_file + if encrypted: + upload_file = self.core.xmpp['xep_0454'].upload_file + url = await upload_file(filename) except UploadServiceNotFound: self.api.information('HTTP Upload service not found.', 'Error') return None @@ -66,10 +75,10 @@ class Plugin(BasePlugin): return None return url - async def send_upload(self, filename): - url = await self.upload(filename) + async def send_upload(self, filename, tab, encrypted=False): + url = await self.upload(filename, encrypted) if url is not None: - self.embed.embed_image_url(url) + self.embed.embed_image_url(url, tab) @command_args_parser.quoted(1) def command_upload(self, args): @@ -78,7 +87,9 @@ class Plugin(BasePlugin): return filename, = args filename = expanduser(filename) - asyncio.ensure_future(self.send_upload(filename)) + tab = self.api.current_tab() + encrypted = self.core.xmpp['xep_0454'] and tab.e2e_encryption is not None + asyncio.create_task(self.send_upload(filename, tab, encrypted)) @staticmethod def completion_filename(the_input): diff --git a/plugins/user_extras.py b/plugins/user_extras.py index ad49a142..96559111 100644 --- a/plugins/user_extras.py +++ b/plugins/user_extras.py @@ -91,10 +91,7 @@ Configuration """ -from asyncio import ( - ensure_future, - gather, -) +import asyncio from functools import reduce from typing import Dict @@ -174,10 +171,10 @@ class Plugin(BasePlugin): ] for name, handler in handlers: self.core.xmpp.del_event_handler(name, handler) - ensure_future(self._stop()) + asyncio.create_task(self._stop()) async def _stop(self): - await gather( + await asyncio.gather( self.core.xmpp.plugin['xep_0108'].stop(), self.core.xmpp.plugin['xep_0107'].stop(), self.core.xmpp.plugin['xep_0196'].stop(), diff --git a/plugins/vcard.py b/plugins/vcard.py index ef70dc79..b0c8e396 100644 --- a/plugins/vcard.py +++ b/plugins/vcard.py @@ -266,7 +266,7 @@ class Plugin(BasePlugin): self.api.information('Invalid JID: %s' % arg, 'Error') return - asyncio.ensure_future( + asyncio.create_task( self._get_vcard(jid) ) @@ -290,7 +290,7 @@ class Plugin(BasePlugin): jid = JID(arg) except InvalidJID: return self.api.information('Invalid JID: %s' % arg, 'Error') - asyncio.ensure_future( + asyncio.create_task( self._get_vcard(jid) ) @@ -301,11 +301,11 @@ class Plugin(BasePlugin): return current = self.api.current_tab().selected_row if isinstance(current, Resource): - asyncio.ensure_future( + asyncio.create_task( self._get_vcard(JID(current.jid).bare) ) elif isinstance(current, Contact): - asyncio.ensure_future( + asyncio.create_task( self._get_vcard(current.bare_jid) ) diff --git a/poezio/colors.py b/poezio/colors.py index 346e1fd0..62566c77 100644 --- a/poezio/colors.py +++ b/poezio/colors.py @@ -1,7 +1,6 @@ from typing import Tuple, Dict, List, Union import curses import hashlib -import math from . import hsluv diff --git a/poezio/common.py b/poezio/common.py index d4d09f9f..6b7d2bfe 100644 --- a/poezio/common.py +++ b/poezio/common.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Various useful functions. """ @@ -14,7 +14,7 @@ from datetime import ( timezone, ) from pathlib import Path -from typing import Dict, List, Optional, Tuple, Union, Any +from typing import Dict, List, Optional, Tuple, Union import os import subprocess @@ -23,7 +23,7 @@ import string import logging import itertools -from slixmpp import JID, InvalidJID, Message +from slixmpp import Message from poezio.poezio_shlex import shlex log = logging.getLogger(__name__) diff --git a/poezio/config.py b/poezio/config.py index 9c2201e7..4eb43cad 100644 --- a/poezio/config.py +++ b/poezio/config.py @@ -28,7 +28,7 @@ ConfigValue = Union[str, int, float, bool] ConfigDict = Dict[str, Dict[str, ConfigValue]] -DEFSECTION = "Poezio" +USE_DEFAULT_SECTION = '__DEFAULT SECTION PLACEHOLDER__' DEFAULT_CONFIG: ConfigDict = { 'Poezio': { @@ -37,6 +37,7 @@ DEFAULT_CONFIG: ConfigDict = { 'after_completion': ',', 'alternative_nickname': '', 'auto_reconnect': True, + 'autocolor_tab_names': False, 'autorejoin_delay': '5', 'autorejoin': False, 'beep_on': 'highlight private invite disconnect', @@ -173,6 +174,7 @@ class Config: configparser: PoezioConfigParser file_name: Path default: ConfigDict + default_section: str = 'Poezio' def __init__(self, file_name: Path, default: Optional[ConfigDict] = None) -> None: self.configparser = PoezioConfigParser() @@ -196,13 +198,15 @@ class Config: def get(self, option: str, default: Optional[ConfigValue] = None, - section: str = DEFSECTION) -> Any: + section: str = USE_DEFAULT_SECTION) -> Any: """ get a value from the config but return a default value if it is not found The type of default defines the type returned """ + if section == USE_DEFAULT_SECTION: + section = self.default_section if default is None: default = self.default.get(section, {}).get(option, '') @@ -229,16 +233,16 @@ class Config: else: return '' - def sections(self, *args, **kwargs): + def sections(self, *args, **kwargs) -> List[str]: return self.configparser.sections(*args, **kwargs) def options(self, *args, **kwargs): return self.configparser.options(*args, **kwargs) - def has_option(self, *args, **kwargs): + def has_option(self, *args, **kwargs) -> bool: return self.configparser.has_option(*args, **kwargs) - def has_section(self, *args, **kwargs): + def has_section(self, *args, **kwargs) -> bool: return self.configparser.has_section(*args, **kwargs) def add_section(self, *args, **kwargs): @@ -260,7 +264,7 @@ class Config: True. And we return `default` as a fallback as a last resort. """ if self.default and (not default) and fallback: - default = self.default.get(DEFSECTION, {}).get(option, '') + default = self.default.get(self.default_section, {}).get(option, '') if tabname in self.sections(): if option in self.options(tabname): # We go the tab-specific option @@ -288,10 +292,12 @@ class Config: return self.get(option, default) return default - def __get(self, option, section=DEFSECTION, **kwargs): + def __get(self, option, section=USE_DEFAULT_SECTION, **kwargs): """ facility for RawConfigParser.get """ + if section == USE_DEFAULT_SECTION: + section = self.default_section return self.configparser.get(section, option, **kwargs) def _get(self, section, conv, option, **kwargs): @@ -300,44 +306,53 @@ class Config: """ return conv(self.__get(option, section, **kwargs)) - def getstr(self, option, section=DEFSECTION) -> str: + def getstr(self, option, section=USE_DEFAULT_SECTION) -> str: """ get a value and returns it as a string """ + if section == USE_DEFAULT_SECTION: + section = self.default_section try: return self.configparser.get(section, option) except (NoOptionError, NoSectionError, ValueError, AttributeError): return cast(str, self._get_default(option, section)) - def getint(self, option, section=DEFSECTION) -> int: + def getint(self, option, section=USE_DEFAULT_SECTION) -> int: """ get a value and returns it as an int """ + if section == USE_DEFAULT_SECTION: + section = self.default_section try: return self.configparser.getint(section, option) except (NoOptionError, NoSectionError, ValueError, AttributeError): return cast(int, self._get_default(option, section)) - def getfloat(self, option, section=DEFSECTION) -> float: + def getfloat(self, option, section=USE_DEFAULT_SECTION) -> float: """ get a value and returns it as a float """ + if section == USE_DEFAULT_SECTION: + section = self.default_section try: return self.configparser.getfloat(section, option) except (NoOptionError, NoSectionError, ValueError, AttributeError): return cast(float, self._get_default(option, section)) - - def getbool(self, option, section=DEFSECTION) -> bool: + def getbool(self, option, section=USE_DEFAULT_SECTION) -> bool: """ get a value and returns it as a boolean """ + if section == USE_DEFAULT_SECTION: + section = self.default_section try: return self.configparser.getboolean(section, option) except (NoOptionError, NoSectionError, ValueError, AttributeError): return cast(bool, self._get_default(option, section)) - def getlist(self, option, section=DEFSECTION) -> List[str]: + def getlist(self, option, section=USE_DEFAULT_SECTION) -> List[str]: + if section == USE_DEFAULT_SECTION: + section = self.default_section return self.getstr(option, section).split(':') def write_in_file(self, section: str, option: str, @@ -471,7 +486,7 @@ class Config: return (sections, lines_before) def set_and_save(self, option: str, value: ConfigValue, - section=DEFSECTION) -> Tuple[str, str]: + section=USE_DEFAULT_SECTION) -> Tuple[str, str]: """ set the value in the configuration then save it to the file @@ -479,6 +494,8 @@ class Config: # Special case for a 'toggle' value. We take the current value # and set the opposite. Warning if the no current value exists # or it is not a bool. + if section == USE_DEFAULT_SECTION: + section = self.default_section if isinstance(value, str) and value == "toggle": current = self.getbool(option, section) if isinstance(current, bool): @@ -506,20 +523,24 @@ class Config: return ("%s=%s" % (option, value), 'Info') def remove_and_save(self, option: str, - section=DEFSECTION) -> Tuple[str, str]: + section=USE_DEFAULT_SECTION) -> Tuple[str, str]: """ Remove an option and then save it the config file """ + if section == USE_DEFAULT_SECTION: + section = self.default_section if self.has_section(section): self.configparser.remove_option(section, option) if not self.remove_in_file(section, option): return ('Unable to save the config file', 'Error') return ('Option %s deleted' % option, 'Info') - def silent_set(self, option: str, value: ConfigValue, section=DEFSECTION): + def silent_set(self, option: str, value: ConfigValue, section=USE_DEFAULT_SECTION): """ Set a value, save, and return True on success and False on failure """ + if section == USE_DEFAULT_SECTION: + section = self.default_section if self.has_section(section): self.configparser.set(section, option, str(value)) else: @@ -527,10 +548,12 @@ class Config: self.configparser.set(section, option, str(value)) return self.write_in_file(section, option, str(value)) - def set(self, option: str, value: ConfigValue, section=DEFSECTION): + def set(self, option: str, value: ConfigValue, section=USE_DEFAULT_SECTION): """ Set the value of an option temporarily """ + if section == USE_DEFAULT_SECTION: + section = self.default_section try: self.configparser.set(section, option, str(value)) except NoSectionError: diff --git a/poezio/connection.py b/poezio/connection.py index c24dd913..503d9169 100644 --- a/poezio/connection.py +++ b/poezio/connection.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Defines the Connection class """ @@ -16,6 +16,7 @@ import subprocess import sys import base64 import random +from pathlib import Path import slixmpp from slixmpp import JID, InvalidJID @@ -117,7 +118,10 @@ class Connection(slixmpp.ClientXMPP): self.ciphers = config.getstr( 'ciphers', 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK' ':!SRP:!3DES:!aNULL') - self.ca_certs = config.getstr('ca_cert_path') or None + self.ca_certs = None + ca_certs = config.getlist('ca_cert_path') + if ca_certs and ca_certs != ['']: + self.ca_certs = list(map(Path, config.getlist('ca_cert_path'))) interval = config.getint('whitespace_interval') if int(interval) > 0: self.whitespace_keepalive_interval = int(interval) @@ -200,6 +204,11 @@ class Connection(slixmpp.ClientXMPP): log.error('Failed to load HTTP File Upload plugin, it can only be ' 'used with aiohttp installed') self.register_plugin('xep_0380') + try: + self.register_plugin('xep_0454') + except slixmpp.plugins.base.PluginNotFound: + log.error('Failed to load Media Sharing plugin, ' + 'it requires slixmpp 1.8.2.') self.init_plugins() def set_keepalive_values(self, option=None, value=None): diff --git a/poezio/contact.py b/poezio/contact.py index 8359e031..90f34c7e 100644 --- a/poezio/contact.py +++ b/poezio/contact.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Defines the Resource and Contact classes, which are used in the roster. @@ -21,6 +21,7 @@ from typing import ( ) from slixmpp import InvalidJID, JID +from slixmpp.roster import RosterItem log = logging.getLogger(__name__) @@ -74,7 +75,7 @@ class Contact: to get the resource with the highest priority, etc """ - def __init__(self, item): + def __init__(self, item: RosterItem): """ item: a slixmpp RosterItem pointing to that contact """ @@ -96,7 +97,7 @@ class Contact: return self.__item.jid @property - def name(self): + def name(self) -> str: """The name of the contact or an empty string.""" return self.__item['name'] or self._name or '' @@ -106,26 +107,27 @@ class Contact: self._name = value @property - def ask(self): + def ask(self) -> Optional[str]: if self.__item['pending_out']: return 'asked' + return None @property - def pending_in(self): + def pending_in(self) -> bool: """We received a subscribe stanza from this contact.""" return self.__item['pending_in'] @pending_in.setter - def pending_in(self, value): + def pending_in(self, value: bool): self.__item['pending_in'] = value @property - def pending_out(self): + def pending_out(self) -> bool: """We sent a subscribe stanza to this contact.""" return self.__item['pending_out'] @pending_out.setter - def pending_out(self, value): + def pending_out(self, value: bool): self.__item['pending_out'] = value @property diff --git a/poezio/core/commands.py b/poezio/core/commands.py index 915f9d90..fe91ca67 100644 --- a/poezio/core/commands.py +++ b/poezio/core/commands.py @@ -8,7 +8,7 @@ from xml.etree import ElementTree as ET from typing import List, Optional, Tuple import logging -from slixmpp import Iq, JID, InvalidJID +from slixmpp import JID, InvalidJID from slixmpp.exceptions import XMPPError, IqError, IqTimeout from slixmpp.xmlstream.xmlstream import NotConnectedError from slixmpp.xmlstream.stanzabase import StanzaBase @@ -298,7 +298,7 @@ class CommandCore: jid = self.core.tabs.current_tab.jid if jid is None or not jid.domain: return None - asyncio.ensure_future( + asyncio.create_task( self._list_async(jid) ) @@ -446,14 +446,19 @@ class CommandCore: """ /bookmark_local [room][/nick] [password] """ - if not args and not isinstance(self.core.tabs.current_tab, - tabs.MucTab): + tab = self.core.tabs.current_tab + if not args and not isinstance(tab, tabs.MucTab): return room, nick = self._parse_join_jid(args[0] if args else '') password = args[1] if len(args) > 1 else None - asyncio.ensure_future( + if not room: + room = tab.jid.bare + if password is None and tab.password is not None: + password = tab.password + + asyncio.create_task( self._add_bookmark( room=room, nick=nick, @@ -468,8 +473,8 @@ class CommandCore: """ /bookmark [room][/nick] [autojoin] [password] """ - if not args and not isinstance(self.core.tabs.current_tab, - tabs.MucTab): + tab = self.core.tabs.current_tab + if not args and not isinstance(tab, tabs.MucTab): return room, nick = self._parse_join_jid(args[0] if args else '') password = args[2] if len(args) > 2 else None @@ -478,13 +483,18 @@ class CommandCore: autojoin = (method == 'local' or (len(args) > 1 and args[1].lower() == 'true')) - asyncio.ensure_future( + if not room: + room = tab.jid.bare + if password is None and tab.password is not None: + password = tab.password + + asyncio.create_task( self._add_bookmark(room, nick, autojoin, password, method) ) async def _add_bookmark( self, - room: Optional[str], + room: str, nick: Optional[str], autojoin: bool, password: str, @@ -503,16 +513,8 @@ class CommandCore: method: 'local' or 'remote'. ''' - # No room Jid was specified. A nick may have been specified. Set the - # room Jid to be bookmarked to the current tab bare jid. - if not room: - tab = self.core.tabs.current_tab - if not isinstance(tab, tabs.MucTab): - return - room = tab.jid.bare - if password is None and tab.password is not None: - password = tab.password - elif room == '*': + + if room == '*': return await self._add_wildcard_bookmarks(method) # Once we found which room to bookmark, find corresponding tab if it @@ -524,13 +526,14 @@ class CommandCore: # Validate / Normalize try: - if nick is None: + if not nick: jid = JID(room) else: jid = JID('{}/{}'.format(room, nick)) room = jid.bare nick = jid.resource or None except InvalidJID: + self.core.information(f'Invalid address for bookmark: {room}/{nick}', 'Error') return bookmark = self.core.bookmarks[room] @@ -596,7 +599,7 @@ class CommandCore: else: jid = args[0] - asyncio.ensure_future( + asyncio.create_task( self._remove_bookmark_routine(jid) ) @@ -981,10 +984,9 @@ class CommandCore: bare = JID(jid).bare except InvalidJID: return self.core.information('Invalid JID for /impromptu: %s' % args[0], 'Error') - jids.add(bare) + jids.add(JID(bare)) - asyncio.ensure_future(self.core.impromptu(jids)) - self.core.information('Invited %s to a random room' % (', '.join(jids)), 'Info') + asyncio.create_task(self.core.impromptu(jids)) @command_args_parser.quoted(1, 1, ['']) def decline(self, args): @@ -1043,7 +1045,7 @@ class CommandCore: if jid is None: self.core.information('No specified JID to block', 'Error') else: - asyncio.ensure_future(self._block_async(jid)) + asyncio.create_task(self._block_async(jid)) async def _block_async(self, jid: JID): """Block a JID, asynchronously""" @@ -1096,7 +1098,7 @@ class CommandCore: jid = JID(current_tab.jid.bare) if jid is not None: - asyncio.ensure_future( + asyncio.create_task( self._unblock_async(jid) ) else: @@ -1153,7 +1155,7 @@ class CommandCore: """ /destroy_room [JID [reason [alternative room JID]]] """ - async def do_destroy(room: JID, reason: str, altroom: JID): + async def do_destroy(room: JID, reason: str, altroom: Optional[JID]): try: await self.core.xmpp['xep_0045'].destroy(room, reason, altroom) except (IqError, IqTimeout) as e: @@ -1178,6 +1180,7 @@ class CommandCore: return reason = args[1] + altroom = None if args[2]: try: altroom = JID(args[2]) @@ -1185,7 +1188,7 @@ class CommandCore: self.core.information('Invalid alternative room JID: "%s"' % args[2], 'Error') return - asyncio.ensure_future(do_destroy(room, reason, altroom)) + asyncio.create_task(do_destroy(room, reason, altroom)) @command_args_parser.quoted(1, 1, ['']) def bind(self, args): @@ -1280,7 +1283,7 @@ class CommandCore: list(self.core.plugin_manager.plugins.keys())), 'Info') @command_args_parser.quoted(1, 1) - def message(self, args): + async def message(self, args): """ /message <jid> [message] """ @@ -1310,7 +1313,7 @@ class CommandCore: else: self.core.focus_tab(tab) if len(args) == 2: - tab.command_say(args[1]) + await tab.command_say(args[1]) @command_args_parser.ignored def xml_tab(self): diff --git a/poezio/core/core.py b/poezio/core/core.py index 81ac6e8a..6582402d 100644 --- a/poezio/core/core.py +++ b/poezio/core/core.py @@ -15,7 +15,6 @@ import pipes import sys import shutil import time -import uuid from collections import defaultdict from typing import ( Any, @@ -30,9 +29,11 @@ from typing import ( TYPE_CHECKING, ) from xml.etree import ElementTree as ET +from pathlib import Path -from slixmpp import JID, InvalidJID +from slixmpp import Iq, JID, InvalidJID from slixmpp.util import FileSystemPerJidCache +from slixmpp.xmlstream.xmlstream import InvalidCABundle from slixmpp.xmlstream.handler import Callback from slixmpp.exceptions import IqError, IqTimeout, XMPPError @@ -42,6 +43,7 @@ from poezio import events from poezio import theming from poezio import timed_events from poezio import windows +from poezio import utils from poezio.bookmarks import ( BookmarkList, @@ -274,7 +276,7 @@ class Core: self.key_func.try_execute = self.try_execute # Add handlers - xmpp_event_handlers = [ + xmpp_event_handlers: List[Tuple[str, Callable[..., Any]]] = [ ('attention', self.handler.on_attention), ('carbon_received', self.handler.on_carbon_received), ('carbon_sent', self.handler.on_carbon_sent), @@ -444,7 +446,7 @@ class Core: if value not in ('pep', 'privatexml'): return self.bookmarks.preferred = value - asyncio.ensure_future( + asyncio.create_task( self.bookmarks.save(self.xmpp, core=self) ) @@ -674,6 +676,26 @@ class Core: self.do_command(''.join(char_list), True) self.doupdate() + def loop_exception_handler(self, loop, context) -> None: + """Do not log unhandled iq errors and timeouts""" + handled_exceptions = (IqError, IqTimeout, InvalidCABundle) + if not isinstance(context['exception'], handled_exceptions): + loop.default_exception_handler(context) + elif isinstance(context['exception'], InvalidCABundle): + paths = context['exception'].path + error = ( + 'Poezio could not find a valid CA bundle file automatically. ' + 'Ensure the ca_cert_path configuration is set to a valid ' + 'CA bundle path, generally provided by the \'ca-certificates\' ' + 'package in your distribution.' + ) + if isinstance(paths, (str, Path)): + # error += '\nFound the following value: {path}'.format(path=str(path)) + paths = [paths] + if paths is not None: + error += f"\nThe following values were tried: {str([str(s) for s in paths])}" + self.information(error, 'Error') + def save_config(self): """ Save config in the file just before exit @@ -906,7 +928,9 @@ class Core: """ if not isinstance(self.tabs.current_tab, ChatTab): return False - self.tabs.current_tab.command_say(msg) + asyncio.ensure_future( + self.tabs.current_tab.command_say(msg) + ) return True async def invite(self, jid: JID, room: JID, reason: Optional[str] = None, force_mediated: bool = False) -> bool: @@ -943,7 +967,7 @@ class Core: ) return True - def _impromptu_room_form(self, room): + def _impromptu_room_form(self, room) -> Iq: fields = [ ('hidden', 'FORM_TYPE', 'http://jabber.org/protocol/muc#roomconfig'), ('boolean', 'muc#roomconfig_changesubject', True), @@ -1004,31 +1028,65 @@ class Core: ) return - nick = self.own_nick - localpart = uuid.uuid4().hex - room_str = '{!s}@{!s}'.format(localpart, default_muc) - try: - room = JID(room_str) - except InvalidJID: + # Retries generating a name until we find a non-existing room. + # Abort otherwise. + retries = 3 + while retries > 0: + localpart = utils.pronounceable() + room_str = f'{localpart}@{default_muc}' + try: + room = JID(room_str) + except InvalidJID: + self.information( + f'The generated XMPP address is invalid: {room_str}', + 'Error' + ) + return None + + try: + iq = await self.xmpp['xep_0030'].get_info( + jid=room, + cached=False, + ) + except IqTimeout: + pass + except IqError as exn: + if exn.etype == 'cancel' and exn.condition == 'item-not-found': + log.debug('Found empty room for /impromptu') + break + + retries = retries - 1 + + if retries == 0: self.information( - 'The generated XMPP address is invalid: {!s}'.format(room_str), - 'Error' + 'Couldn\'t generate a room name that isn\'t already used.', + 'Error', ) return None - self.open_new_room(room, nick).join() - iq = self._impromptu_room_form(room) - try: - await iq.send() - except (IqError, IqTimeout): - self.information('Failed to configure impromptu room.', 'Info') - # TODO: destroy? leave room. - return None + self.open_new_room(room, self.own_nick).join() + + async def configure_and_invite(_presence): + iq = self._impromptu_room_form(room) + try: + await iq.send() + except (IqError, IqTimeout): + self.information('Failed to configure impromptu room.', 'Info') + # TODO: destroy? leave room. + return None + + self.information(f'Room {room} created', 'Info') - self.information('Room %s created' % room, 'Info') + for jid in jids: + await self.invite(jid, room, force_mediated=True) + jids_str = ', '.join(jids) + self.information(f'Invited {jids_str} to {room.bare}', 'Info') - for jid in jids: - await self.invite(jid, room, force_mediated=True) + self.xmpp.add_event_handler( + f'muc::{room.bare}::groupchat_subject', + configure_and_invite, + disposable=True, + ) ####################### Tab logic-related things ############################## diff --git a/poezio/core/handlers.py b/poezio/core/handlers.py index d4625b4b..e92e4aac 100644 --- a/poezio/core/handlers.py +++ b/poezio/core/handlers.py @@ -8,25 +8,20 @@ from typing import Optional import asyncio import curses -import functools import select import signal import ssl import sys import time -from datetime import datetime from hashlib import sha1, sha256, sha512 -from os import path import pyasn1.codec.der.decoder import pyasn1.codec.der.encoder import pyasn1_modules.rfc2459 -from slixmpp import InvalidJID, JID, Message +from slixmpp import InvalidJID, JID, Message, Iq, Presence from slixmpp.xmlstream.stanzabase import StanzaBase, ElementBase from xml.etree import ElementTree as ET -from poezio import common -from poezio import fixes from poezio import tabs from poezio import xhtml from poezio import multiuserchat as muc @@ -36,12 +31,10 @@ from poezio.core.structs import Status from poezio.contact import Resource from poezio.logger import logger from poezio.roster import roster -from poezio.text_buffer import CorrectionError, AckError +from poezio.text_buffer import AckError from poezio.theming import dump_tuple, get_theme from poezio.ui.types import ( XMLLog, - Message as PMessage, - BaseMessage, InfoMessage, PersistentInfoMessage, ) @@ -90,8 +83,6 @@ class HandlerCore: """ Enable carbons & blocking on session start if wanted and possible """ - - iq = await self.core.xmpp.plugin['xep_0030'].get_info( jid=self.core.xmpp.boundjid.domain ) @@ -108,7 +99,7 @@ class HandlerCore: await self.core.check_bookmark_storage(features) def find_identities(self, _): - asyncio.ensure_future( + asyncio.create_task( self.core.xmpp['xep_0030'].get_info_from_domain(), ) @@ -159,67 +150,57 @@ class HandlerCore: return None - def on_carbon_received(self, message): + async def on_carbon_received(self, message: Message): """ Carbon <received/> received """ - - def ignore_message(recv): - log.debug('%s has category conference, ignoring carbon', - recv['from'].server) - - def receive_message(recv): - recv['to'] = self.core.xmpp.boundjid.full - if recv['receipt']: - return self.on_receipt(recv) - self.on_normal_message(recv) - recv = message['carbon_received'] is_muc_pm = self.is_known_muc_pm(recv, recv['from']) if is_muc_pm: log.debug('%s sent a MUC-PM, ignoring carbon', recv['from']) - return - if is_muc_pm is None: - fixes.has_identity( - self.core.xmpp, + elif is_muc_pm is None: + is_muc = await self.core.xmpp.plugin['xep_0030'].has_identity( recv['from'].bare, - identity='conference', - on_true=functools.partial(ignore_message, recv), - on_false=functools.partial(receive_message, recv)) - return + node='conference', + ) + if is_muc: + log.debug('%s has category conference, ignoring carbon', + recv['from'].server) + else: + recv['to'] = self.core.xmpp.boundjid.full + if recv['receipt']: + await self.on_receipt(recv) + else: + await self.on_normal_message(recv) else: - receive_message(recv) + recv['to'] = self.core.xmpp.boundjid.full + await self.on_normal_message(recv) - def on_carbon_sent(self, message): + async def on_carbon_sent(self, message: Message): """ Carbon <sent/> received """ - - def groupchat_private_message(sent): - self.on_groupchat_private_message(sent, sent=True) - - def send_message(sent): - sent['from'] = self.core.xmpp.boundjid.full - self.on_normal_message(sent) - sent = message['carbon_sent'] is_muc_pm = self.is_known_muc_pm(sent, sent['to']) if is_muc_pm: - groupchat_private_message(sent) - return - if is_muc_pm is None: - fixes.has_identity( - self.core.xmpp, + await self.on_groupchat_private_message(sent, sent=True) + elif is_muc_pm is None: + is_muc = await self.core.xmpp.plugin['xep_0030'].has_identity( sent['to'].bare, - identity='conference', - on_true=functools.partial(groupchat_private_message, sent), - on_false=functools.partial(send_message, sent)) + node='conference', + ) + if is_muc: + await self.on_groupchat_private_message(sent, sent=True) + else: + sent['from'] = self.core.xmpp.boundjid.full + await self.on_normal_message(sent) else: - send_message(sent) + sent['from'] = self.core.xmpp.boundjid.full + await self.on_normal_message(sent) ### Invites ### - def on_groupchat_invitation(self, message): + async def on_groupchat_invitation(self, message: Message): """ Mediated invitation received """ @@ -243,11 +224,11 @@ class HandlerCore: logger.log_roster_change(inviter.full, 'invited you to %s' % jid.full) self.core.pending_invites[jid.bare] = inviter.full - def on_groupchat_decline(self, decline): + async def on_groupchat_decline(self, decline): "Mediated invitation declined; skip for now" pass - def on_groupchat_direct_invitation(self, message): + async def on_groupchat_direct_invitation(self, message: Message): """ Direct invitation received """ @@ -280,7 +261,7 @@ class HandlerCore: ### "classic" messages ### - def on_message(self, message): + async def on_message(self, message: Message): """ When receiving private message from a muc OR a normal message (from one of our contacts) @@ -291,19 +272,19 @@ class HandlerCore: return # Differentiate both type of messages, and call the appropriate handler. if self.is_known_muc_pm(message, message['from']): - self.on_groupchat_private_message(message, sent=False) - return - self.on_normal_message(message) + await self.on_groupchat_private_message(message, sent=False) + else: + await self.on_normal_message(message) - def on_encrypted_message(self, message): + async def on_encrypted_message(self, message: Message): """ When receiving an encrypted message """ if message["body"]: return # Already being handled by on_message. - self.on_message(message) + await self.on_message(message) - def on_error_message(self, message): + async def on_error_message(self, message: Message): """ When receiving any message with type="error" """ @@ -332,7 +313,7 @@ class HandlerCore: tab.add_message(InfoMessage(error)) self.core.refresh_window() - def on_normal_message(self, message): + async def on_normal_message(self, message: Message): """ When receiving "normal" messages (not a private message from a muc participant) @@ -373,7 +354,7 @@ class HandlerCore: ) self.core.tabs.append(conversation) else: - conversation.handle_message(message) + await conversation.handle_message(message) if not own and 'private' in config.getstr('beep_on').split(): if not config.get_by_tabname('disable_beep', conv_jid.bare): @@ -388,7 +369,7 @@ class HandlerCore: else: self.core.refresh_window() - async def on_0084_avatar(self, msg): + async def on_0084_avatar(self, msg: Message): jid = msg['from'].bare contact = roster[jid] if not contact: @@ -438,7 +419,7 @@ class HandlerCore: exc_info=True) return - async def on_vcard_avatar(self, pres): + async def on_vcard_avatar(self, pres: Presence): jid = pres['from'].bare contact = roster[jid] if not contact: @@ -474,7 +455,7 @@ class HandlerCore: log.debug( 'Failed writing %s’s avatar to cache:', jid, exc_info=True) - def on_nick_received(self, message): + async def on_nick_received(self, message: Message): """ Called when a pep notification for a user nickname is received @@ -488,7 +469,7 @@ class HandlerCore: else: contact.name = '' - def on_groupchat_message(self, message): + async def on_groupchat_message(self, message: Message) -> None: """ Triggered whenever a message is received from a multi-user chat room. """ @@ -505,116 +486,26 @@ class HandlerCore: muc.leave_groupchat( self.core.xmpp, room_from, self.core.own_nick, msg='') return - - nick_from = message['mucnick'] - user = tab.get_user_by_name(nick_from) - if user and user in tab.ignores: - return - - self.core.events.trigger('muc_msg', message, tab) - use_xhtml = config.get_by_tabname('enable_xhtml_im', room_from) - tmp_dir = get_image_cache() - body = xhtml.get_body_from_message_stanza( - message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) - - # TODO: #3314. Is this a MUC reflection? - # Is this an encrypted message? Is so ignore. - # It is not possible in the OMEMO case to decrypt these messages - # since we don't encrypt for our own device (something something - # forward secrecy), but even for non-FS encryption schemes anyway - # messages shouldn't have changed after a round-trip to the room. - # Otherwire replace the matching message we sent. - - if not body: - return - - old_state = tab.state - delayed, date = common.find_delayed_tag(message) - is_history = not tab.joined and delayed - - replaced = False - if message.xml.find('{urn:xmpp:message-correct:0}replace') is not None: - replaced_id = message['replace']['id'] - if replaced_id != '' and config.get_by_tabname( - 'group_corrections', message['from'].bare): - try: - delayed_date = date or datetime.now() - if tab.modify_message( - body, - replaced_id, - message['id'], - time=delayed_date, - delayed=delayed, - nickname=nick_from, - user=user): - self.core.events.trigger('highlight', message, tab) - replaced = True - except CorrectionError: - log.debug('Unable to correct a message', exc_info=True) - - if not replaced: - # Messages coming from MUC barejid (Server maintenance, IRC mode - # changes from biboumi, etc.) are displayed as info messages. - highlight = False - if message['from'].resource: - highlight = tab.message_is_highlight(body, nick_from, is_history) - ui_msg = PMessage( - txt=body, - time=date, - nickname=nick_from, - history=is_history, - delayed=delayed, - identifier=message['id'], - jid=message['from'], - user=user, - highlight=highlight, - ) - typ = 1 - else: - ui_msg = InfoMessage( - txt=body, - time=date, - identifier=message['id'], - ) - typ = 2 - tab.add_message(ui_msg) - if highlight: - self.core.events.trigger('highlight', message, tab) - - if message['from'].resource == tab.own_nick: - tab.set_last_sent_message(message, correct=replaced) - - if tab is self.core.tabs.current_tab: - tab.text_win.refresh() - tab.info_header.refresh(tab, tab.text_win, user=tab.own_user) - tab.input.refresh() - self.core.doupdate() - elif tab.state != old_state: - self.core.refresh_tab_win() - current = self.core.tabs.current_tab - if hasattr(current, 'input') and current.input: - current.input.refresh() - self.core.doupdate() - - if 'message' in config.getstr('beep_on').split(): + valid_message = await tab.handle_message(message) + if valid_message and 'message' in config.getstr('beep_on').split(): if (not config.get_by_tabname('disable_beep', room_from) and self.core.own_nick != message['from'].resource): curses.beep() - def on_muc_own_nickchange(self, muc): + def on_muc_own_nickchange(self, muc: tabs.MucTab): "We changed our nick in a MUC" for tab in self.core.get_tabs(tabs.PrivateTab): if tab.parent_muc == muc: tab.own_nick = muc.own_nick - def on_groupchat_private_message(self, message, sent): + async def on_groupchat_private_message(self, message: Message, sent: bool): """ We received a Private Message (from someone in a Muc) """ jid = message['to'] if sent else message['from'] with_nick = jid.resource if not with_nick: - self.on_groupchat_message(message) + await self.on_groupchat_message(message) return room_from = jid.bare @@ -630,7 +521,7 @@ class HandlerCore: tabs.PrivateTab) # get the tab with the private conversation ignore = config.get_by_tabname('ignore_private', room_from) if ignore and not sent: - self.core.events.trigger('ignored_private', message, tab) + await self.core.events.trigger_async('ignored_private', message, tab) msg = config.get_by_tabname('private_auto_response', room_from) if msg and body: self.core.xmpp.send_message( @@ -647,7 +538,7 @@ class HandlerCore: self.core.tabs.append(tab) tab.parent_muc.privates.append(tab) else: - tab.handle_message(message) + await tab.handle_message(message) if not sent and 'private' in config.getstr('beep_on').split(): if not config.get_by_tabname('disable_beep', jid.full): @@ -660,37 +551,37 @@ class HandlerCore: ### Chatstates ### - def on_chatstate_active(self, message): - self._on_chatstate(message, "active") + async def on_chatstate_active(self, message: Message): + await self._on_chatstate(message, "active") - def on_chatstate_inactive(self, message): - self._on_chatstate(message, "inactive") + async def on_chatstate_inactive(self, message: Message): + await self._on_chatstate(message, "inactive") - def on_chatstate_composing(self, message): - self._on_chatstate(message, "composing") + async def on_chatstate_composing(self, message: Message): + await self._on_chatstate(message, "composing") - def on_chatstate_paused(self, message): - self._on_chatstate(message, "paused") + async def on_chatstate_paused(self, message: Message): + await self._on_chatstate(message, "paused") - def on_chatstate_gone(self, message): - self._on_chatstate(message, "gone") + async def on_chatstate_gone(self, message: Message): + await self._on_chatstate(message, "gone") - def _on_chatstate(self, message, state): + async def _on_chatstate(self, message: Message, state: str): if message['type'] == 'chat': - if not self._on_chatstate_normal_conversation(message, state): + if not await self._on_chatstate_normal_conversation(message, state): tab = self.core.tabs.by_name_and_class(message['from'].full, tabs.PrivateTab) if not tab: return - self._on_chatstate_private_conversation(message, state) + await self._on_chatstate_private_conversation(message, state) elif message['type'] == 'groupchat': - self.on_chatstate_groupchat_conversation(message, state) + await self.on_chatstate_groupchat_conversation(message, state) - def _on_chatstate_normal_conversation(self, message, state): + async def _on_chatstate_normal_conversation(self, message: Message, state: str): tab = self.core.get_conversation_by_jid(message['from'], False) if not tab: return False - self.core.events.trigger('normal_chatstate', message, tab) + await self.core.events.trigger_async('normal_chatstate', message, tab) tab.chatstate = state if state == 'gone' and isinstance(tab, tabs.DynamicConversationTab): tab.unlock() @@ -702,7 +593,7 @@ class HandlerCore: self.core.refresh_tab_win() return True - def _on_chatstate_private_conversation(self, message, state): + async def _on_chatstate_private_conversation(self, message: Message, state: str): """ Chatstate received in a private conversation from a MUC """ @@ -710,7 +601,7 @@ class HandlerCore: tabs.PrivateTab) if not tab: return - self.core.events.trigger('private_chatstate', message, tab) + await self.core.events.trigger_async('private_chatstate', message, tab) tab.chatstate = state if tab == self.core.tabs.current_tab: tab.refresh_info_header() @@ -719,7 +610,7 @@ class HandlerCore: _composing_tab_state(tab, state) self.core.refresh_tab_win() - def on_chatstate_groupchat_conversation(self, message, state): + async def on_chatstate_groupchat_conversation(self, message: Message, state: str): """ Chatstate received in a MUC """ @@ -727,7 +618,7 @@ class HandlerCore: room_from = message.get_mucroom() tab = self.core.tabs.by_name_and_class(room_from, tabs.MucTab) if tab and tab.get_user_by_name(nick): - self.core.events.trigger('muc_chatstate', message, tab) + await self.core.events.trigger_async('muc_chatstate', message, tab) tab.get_user_by_name(nick).chatstate = state if tab == self.core.tabs.current_tab: if not self.core.size.tab_degrade_x: @@ -745,7 +636,7 @@ class HandlerCore: return '%s: %s' % (error_condition, error_text) if error_text else error_condition - def on_version_result(self, iq): + def on_version_result(self, iq: Iq): """ Handle the result of a /version command. """ @@ -762,7 +653,7 @@ class HandlerCore: 'an unknown platform')) self.core.information(version, 'Info') - def on_bookmark_result(self, iq): + def on_bookmark_result(self, iq: Iq): """ Handle the result of a /bookmark commands. """ @@ -774,7 +665,7 @@ class HandlerCore: ### subscription-related handlers ### - def on_roster_update(self, iq): + async def on_roster_update(self, iq: Iq): """ The roster was received. """ @@ -793,7 +684,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_subscription_request(self, presence): + async def on_subscription_request(self, presence: Presence): """subscribe received""" jid = presence['from'].bare contact = roster[jid] @@ -816,7 +707,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_subscription_authorized(self, presence): + async def on_subscription_authorized(self, presence: Presence): """subscribed received""" jid = presence['from'].bare contact = roster[jid] @@ -831,7 +722,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_subscription_remove(self, presence): + async def on_subscription_remove(self, presence: Presence): """unsubscribe received""" jid = presence['from'].bare contact = roster[jid] @@ -844,7 +735,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_subscription_removed(self, presence): + async def on_subscription_removed(self, presence: Presence): """unsubscribed received""" jid = presence['from'].bare contact = roster[jid] @@ -865,7 +756,7 @@ class HandlerCore: ### Presence-related handlers ### - def on_presence(self, presence): + async def on_presence(self, presence: Presence): if presence.match('presence/muc'): return jid = presence['from'] @@ -880,8 +771,8 @@ class HandlerCore: return roster.modified() contact.error = None - self.core.events.trigger('normal_presence', presence, - contact[jid.full]) + await self.core.events.trigger_async('normal_presence', presence, + contact[jid.full]) tab = self.core.get_conversation_by_jid(jid, create=False) if tab: tab.update_status( @@ -892,7 +783,7 @@ class HandlerCore: tab.refresh() self.core.doupdate() - def on_presence_error(self, presence): + async def on_presence_error(self, presence: Presence): jid = presence['from'] contact = roster[jid.bare] if not contact: @@ -901,7 +792,7 @@ class HandlerCore: contact.error = presence['error']['text'] or presence['error']['type'] + ': ' + presence['error']['condition'] # TODO: reset chat states status on presence error - def on_got_offline(self, presence): + async def on_got_offline(self, presence: Presence): """ A JID got offline """ @@ -933,7 +824,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_got_online(self, presence): + async def on_got_online(self, presence: Presence): """ A JID got online """ @@ -954,7 +845,7 @@ class HandlerCore: 'status': presence['status'], 'show': presence['show'], }) - self.core.events.trigger('normal_presence', presence, resource) + await self.core.events.trigger_async('normal_presence', presence, resource) name = contact.name if contact.name else jid.bare self.core.add_information_message_to_conversation_tab( jid.full, '\x195}%s is \x194}online' % name) @@ -972,7 +863,7 @@ class HandlerCore: if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab): self.core.refresh_window() - def on_groupchat_presence(self, presence): + async def on_groupchat_presence(self, presence: Presence): """ Triggered whenever a presence stanza is received from a user in a multi-user chat room. Display the presence on the room window and update the @@ -981,19 +872,19 @@ class HandlerCore: from_room = presence['from'].bare tab = self.core.tabs.by_name_and_class(from_room, tabs.MucTab) if tab: - self.core.events.trigger('muc_presence', presence, tab) + await self.core.events.trigger_async('muc_presence', presence, tab) tab.handle_presence(presence) ### Connection-related handlers ### - def on_failed_connection(self, error): + async def on_failed_connection(self, error: str): """ We cannot contact the remote server """ self.core.information( "Connection to remote server failed: %s" % (error, ), 'Error') - def on_session_end(self, event): + async def on_session_end(self, event): """ Called when a session is terminated (e.g. due to a manual disconnect or a 0198 resume fail) """ @@ -1002,7 +893,7 @@ class HandlerCore: for tab in self.core.get_tabs(tabs.MucTab): tab.disconnect() - def on_session_resumed(self, event): + async def on_session_resumed(self, event): """ Called when a session is successfully resumed by 0198 """ @@ -1037,7 +928,7 @@ class HandlerCore: """ self.core.information("Reconnecting in %d seconds..." % (event), 'Info') - def on_stream_error(self, event): + async def on_stream_error(self, event): """ When we receive a stream error """ @@ -1046,7 +937,7 @@ class HandlerCore: if event: self.core.last_stream_error = (time.time(), event) - def on_failed_all_auth(self, event): + async def on_failed_all_auth(self, event): """ Authentication failed """ @@ -1054,7 +945,7 @@ class HandlerCore: 'Error') self.core.legitimate_disconnect = True - def on_no_auth(self, event): + async def on_no_auth(self, event): """ Authentication failed (no mech) """ @@ -1062,14 +953,14 @@ class HandlerCore: "Authentication failed, no login method available.", 'Error') self.core.legitimate_disconnect = True - def on_connected(self, event): + async def on_connected(self, event): """ Remote host responded, but we are not yet authenticated """ self.core.information("Connected to server.", 'Info') self.core.legitimate_disconnect = False - def on_session_start(self, event): + async def on_session_start(self, event): """ Called when we are connected and authenticated """ @@ -1088,7 +979,7 @@ class HandlerCore: pres = self.core.xmpp.make_presence() pres['show'] = self.core.status.show pres['status'] = self.core.status.message - self.core.events.trigger('send_normal_presence', pres) + await self.core.events.trigger_async('send_normal_presence', pres) pres.send() self.core.bookmarks.get_local() # join all the available bookmarks. As of yet, this is just the local ones @@ -1097,13 +988,13 @@ class HandlerCore: if config.getbool('enable_user_nick'): self.core.xmpp.plugin['xep_0172'].publish_nick( nick=self.core.own_nick, callback=dumb_callback) - asyncio.ensure_future(self.core.xmpp.plugin['xep_0115'].update_caps()) + asyncio.create_task(self.core.xmpp.plugin['xep_0115'].update_caps()) # Start the ping's plugin regular event self.core.xmpp.set_keepalive_values() ### Other handlers ### - def on_status_codes(self, message): + async def on_status_codes(self, message: Message): """ Handle groupchat messages with status codes. Those are received when a room configuration change occurs. @@ -1182,7 +1073,7 @@ class HandlerCore: if modif: self.core.refresh_window() - def on_groupchat_subject(self, message): + async def on_groupchat_subject(self, message: Message): """ Triggered when the topic is changed. """ @@ -1239,7 +1130,7 @@ class HandlerCore: room_from, tabs.MucTab) is self.core.tabs.current_tab: self.core.refresh_window() - def on_receipt(self, message): + async def on_receipt(self, message): """ When a delivery receipt is received (XEP-0184) """ @@ -1261,13 +1152,13 @@ class HandlerCore: except AckError: log.debug('Error while receiving an ack', exc_info=True) - def on_data_form(self, message): + async def on_data_form(self, message: Message): """ When a data form is received """ self.core.information(str(message)) - def on_attention(self, message): + async def on_attention(self, message: Message): """ Attention probe received. """ @@ -1284,30 +1175,31 @@ class HandlerCore: tab.state = "attention" self.core.refresh_tab_win() - def outgoing_stanza(self, stanza): + def outgoing_stanza(self, stanza: StanzaBase): """ We are sending a new stanza, write it in the xml buffer if needed. """ if self.core.xml_tab: + stanza_str = str(stanza) if PYGMENTS: - xhtml_text = highlight(str(stanza), LEXER, FORMATTER) + xhtml_text = highlight(stanza_str, LEXER, FORMATTER) poezio_colored = xhtml.xhtml_to_poezio_colors( xhtml_text, force=True).rstrip('\x19o').strip() else: - poezio_colored = str(stanza) + poezio_colored = stanza_str self.core.xml_buffer.add_message( XMLLog(txt=poezio_colored, incoming=False), ) try: if self.core.xml_tab.match_stanza( - ElementBase(ET.fromstring(stanza))): + ElementBase(ET.fromstring(stanza_str))): self.core.xml_tab.filtered_buffer.add_message( XMLLog(txt=poezio_colored, incoming=False), ) except: # Most of the time what gets logged is whitespace pings. Skip. # And also skip tab updates. - if stanza.strip() == '': + if stanza_str.strip() == '': return None log.debug('', exc_info=True) @@ -1315,7 +1207,7 @@ class HandlerCore: self.core.tabs.current_tab.refresh() self.core.doupdate() - def incoming_stanza(self, stanza): + def incoming_stanza(self, stanza: StanzaBase): """ We are receiving a new stanza, write it in the xml buffer if needed. """ diff --git a/poezio/daemon.py b/poezio/daemon.py index c8225a07..7a67a12d 100755 --- a/poezio/daemon.py +++ b/poezio/daemon.py @@ -4,7 +4,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ This file is a standalone program that reads commands on stdin and executes them (each line should be a command). diff --git a/poezio/events.py b/poezio/events.py index 63782836..0ba97d56 100644 --- a/poezio/events.py +++ b/poezio/events.py @@ -2,16 +2,20 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Defines the EventHandler class. The list of available events is here: http://poezio.eu/doc/en/plugins.html#_poezio_events """ +import logging from collections import OrderedDict +from inspect import iscoroutinefunction from typing import Callable, Dict, List +log = logging.getLogger(__name__) + class EventHandler: """ @@ -75,6 +79,20 @@ class EventHandler: return True + async def trigger_async(self, name: str, *args, **kwargs): + """ + Call all the callbacks associated to the given event name. + """ + callbacks = self.events.get(name, None) + if callbacks is None: + return + for priority in callbacks.values(): + for callback in priority: + if iscoroutinefunction(callback): + await callback(*args, **kwargs) + else: + callback(*args, **kwargs) + def trigger(self, name: str, *args, **kwargs): """ Call all the callbacks associated to the given event name. @@ -84,7 +102,11 @@ class EventHandler: return for priority in callbacks.values(): for callback in priority: - callback(*args, **kwargs) + if not iscoroutinefunction(callback): + callback(*args, **kwargs) + else: + log.error(f'async event handler {callback} ' + 'called in sync trigger!') def del_event_handler(self, name: str, callback: Callable): """ diff --git a/poezio/fixes.py b/poezio/fixes.py index 62e67f43..c2db4332 100644 --- a/poezio/fixes.py +++ b/poezio/fixes.py @@ -5,31 +5,15 @@ upstream. TODO: Check that they are fixed and remove those hacks """ -import asyncio -from typing import Callable, Any -from slixmpp import Message, Iq, ClientXMPP -from slixmpp.xmlstream import ET +from slixmpp import Message +from slixmpp.plugins.xep_0184 import XEP_0184 import logging log = logging.getLogger(__name__) -def has_identity(xmpp, jid, identity, on_true=None, on_false=None): - def _cb(iq): - ident = lambda x: x[0] - res = identity in map(ident, iq['disco_info']['identities']) - if res and on_true is not None: - on_true() - if not res and on_false is not None: - on_false() - - asyncio.ensure_future( - xmpp.plugin['xep_0030'].get_info(jid=jid, callback=_cb) - ) - - -def _filter_add_receipt_request(self, stanza): +def _filter_add_receipt_request(self: XEP_0184, stanza): """ Auto add receipt requests to outgoing messages, if: diff --git a/poezio/keyboard.py b/poezio/keyboard.py index 23da2e37..1e75b2a2 100755 --- a/poezio/keyboard.py +++ b/poezio/keyboard.py @@ -4,7 +4,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Functions to interact with the keyboard Mainly, read keys entered and return a string (most diff --git a/poezio/log_loader.py b/poezio/log_loader.py index 146bc9b4..2e3b27c2 100644 --- a/poezio/log_loader.py +++ b/poezio/log_loader.py @@ -76,10 +76,17 @@ class LogLoader: mam_only: bool def __init__(self, logger: Logger, tab: tabs.ChatTab, - local_logs: bool = True): + local_logs: bool = True, + done_event: Optional[asyncio.Event] = None): self.mam_only = not local_logs self.logger = logger self.tab = tab + self.done_event = done_event + + def _done(self) -> None: + """Signal end if possible""" + if self.done_event is not None: + self.done_event.set() async def tab_open(self) -> None: """Called on a tab opening or a MUC join""" @@ -104,6 +111,7 @@ class LogLoader: if messages: self.tab._text_buffer.add_history_messages(messages) self.tab.core.refresh_window() + self._done() async def mam_tab_open(self, nb: int) -> List[BaseMessage]: """Fetch messages in MAM when opening a new tab. @@ -238,6 +246,7 @@ class LogLoader: if messages: tab._text_buffer.add_history_messages(messages) tab.core.refresh_window() + self._done() async def local_scroll_requested(self, nb: int) -> List[BaseMessage]: """Fetch messages locally on scroll up. @@ -321,7 +330,7 @@ class MAMFiller: self.tab = tab self.logger = logger logger.fd_busy(tab.jid) - self.future = asyncio.ensure_future(self.fetch_routine()) + self.future = asyncio.create_task(self.fetch_routine()) self.done = asyncio.Event() self.limit = limit self.result = 0 diff --git a/poezio/logger.py b/poezio/logger.py index 6e4a6ff0..29eaad32 100644 --- a/poezio/logger.py +++ b/poezio/logger.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ The logger module that handles logging of the poezio conversations and roster changes @@ -157,11 +157,11 @@ class Logger: def close(self, jid: str) -> None: """Close the log file for a JID.""" - jid = str(jid).replace('/', '\\') - if jid in self._fds: - self._fds[jid].close() + jidstr = str(jid).replace('/', '\\') + if jidstr in self._fds: + self._fds[jidstr].close() log.debug('Log file for %s closed.', jid) - del self._fds[jid] + del self._fds[jidstr] def reload_all(self) -> None: """Close and reload all the file handles (on SIGHUP)""" @@ -184,7 +184,7 @@ class Logger: self._check_and_create_log_dir(room) log.debug('Log handle for %s re-created', room) - def _check_and_create_log_dir(self, jid: str, + def _check_and_create_log_dir(self, jid: Union[str, JID], open_fd: bool = True) -> Optional[IO[str]]: """ Check that the directory where we want to log the messages @@ -196,6 +196,8 @@ class Logger: """ if not config.get_by_tabname('use_log', JID(jid)): return None + # POSIX filesystems don't support / in filename, so we replace it with a backslash + jid = str(jid).replace('/', '\\') try: self.log_dir.mkdir(parents=True, exist_ok=True) except OSError: @@ -205,7 +207,7 @@ class Logger: return None if not open_fd: return None - filename = self.log_dir / jid + filename = self.get_file_path(jid) try: fd = filename.open('a', encoding='utf-8') self._fds[jid] = fd @@ -251,18 +253,18 @@ class Logger: :param force: Bypass the buffered fd check :returns: True if no error was encountered """ - jid = str(jid).replace('/', '\\') - if jid in self._fds.keys(): - fd = self._fds[jid] + jidstr = str(jid).replace('/', '\\') + if jidstr in self._fds.keys(): + fd = self._fds[jidstr] else: option_fd = self._check_and_create_log_dir(jid) if option_fd is None: return True fd = option_fd - filename = self.log_dir / jid + filename = self.get_file_path(jid) try: - if not force and self._busy_fds.get(jid): - self._buffered_fds[jid].append(logged_msg) + if not force and self._busy_fds.get(jidstr): + self._buffered_fds[jidstr].append(logged_msg) return True fd.write(logged_msg) except OSError: @@ -398,26 +400,6 @@ def iterate_messages_reverse(filepath: Path) -> Generator[LogDict, None, None]: pass -def _get_lines_from_fd(fd: IO[Any], nb: int = 10) -> List[str]: - """ - Get the last log lines from a fileno with mmap - - :param fd: File descriptor on the log file - :param nb: number of messages to fetch - :returns: A list of message lines - """ - with mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ) as m: - # start of messages begin with MI or MR, after a \n - pos = m.rfind(b"\nM") + 1 - # number of message found so far - count = 0 - while pos != 0 and count < nb - 1: - count += 1 - pos = m.rfind(b"\nM", 0, pos) + 1 - lines = m[pos:].decode(errors='replace').splitlines() - return lines - - def parse_log_lines(lines: List[str], jid: str = '') -> List[LogDict]: """ Parse raw log lines into poezio log objects diff --git a/poezio/mam.py b/poezio/mam.py index 180f1b2e..7cb1d369 100644 --- a/poezio/mam.py +++ b/poezio/mam.py @@ -5,7 +5,6 @@ from __future__ import annotations -import asyncio import logging from datetime import datetime, timedelta, timezone from hashlib import md5 @@ -131,6 +130,20 @@ def _parse_message(msg: SMessage) -> Dict: } +def _ignore_private_message(stanza: SMessage, filter_jid: Optional[JID]) -> bool: + """Returns True if a MUC-PM should be ignored, as prosody returns + all PMs within the same room. + """ + if filter_jid is None: + return False + sent = stanza['from'].bare != filter_jid.bare + if sent and stanza['to'].full != filter_jid.full: + return True + elif not sent and stanza['from'].full != filter_jid.full: + return True + return False + + async def retrieve_messages(tab: tabs.ChatTab, results: AsyncIterable[SMessage], amount: int = 100) -> List[BaseMessage]: @@ -138,11 +151,17 @@ async def retrieve_messages(tab: tabs.ChatTab, msg_count = 0 msgs = [] to_add = [] + tab_is_private = isinstance(tab, tabs.PrivateTab) + filter_jid = None + if tab_is_private: + filter_jid = tab.jid try: async for rsm in results: for msg in rsm['mam']['results']: - if msg['mam_result']['forwarded']['stanza'] \ - .xml.find('{%s}%s' % ('jabber:client', 'body')) is not None: + stanza = msg['mam_result']['forwarded']['stanza'] + if stanza.xml.find('{%s}%s' % ('jabber:client', 'body')) is not None: + if _ignore_private_message(stanza, filter_jid): + continue args = _parse_message(msg) msgs.append(make_line(tab, **args)) for msg in reversed(msgs): diff --git a/poezio/multiuserchat.py b/poezio/multiuserchat.py index 366f6d78..3278e1bd 100644 --- a/poezio/multiuserchat.py +++ b/poezio/multiuserchat.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Implementation of the XEP-0045: Multi-User Chat. Add some facilities that are not available on the XEP_0045 @@ -109,7 +109,7 @@ def join_groupchat( xmpp.plugin['xep_0045'].rooms[jid] = {} xmpp.plugin['xep_0045'].our_nicks[jid] = to.resource - asyncio.ensure_future( + asyncio.create_task( xmpp.plugin['xep_0030'].get_info(jid=jid, callback=on_disco) ) diff --git a/poezio/plugin.py b/poezio/plugin.py index 4af27cbd..f38e47e2 100644 --- a/poezio/plugin.py +++ b/poezio/plugin.py @@ -26,6 +26,7 @@ class PluginConfig(config.Config): def __init__(self, filename, module_name, default=None): config.Config.__init__(self, filename, default=default) self.module_name = module_name + self.default_section = module_name self.read() def get(self, option, default=None, section=None): @@ -425,7 +426,7 @@ class BasePlugin(object, metaclass=SafetyMetaclass): self.init() @property - def name(self): + def name(self) -> str: """ Get the name (module name) of the plugin. """ diff --git a/poezio/plugin_e2ee.py b/poezio/plugin_e2ee.py index 3ac88eb9..49f7b067 100644 --- a/poezio/plugin_e2ee.py +++ b/poezio/plugin_e2ee.py @@ -4,7 +4,7 @@ # # Copyright © 2019 Maxime “pep” Buquet <pep@bouah.net> # -# Distributed under terms of the zlib license. See COPYING file. +# Distributed under terms of the GPL-3.0+ license. See COPYING file. """ Interface for E2EE (End-to-end Encryption) plugins. @@ -23,19 +23,23 @@ from typing import ( from slixmpp import InvalidJID, JID, Message from slixmpp.xmlstream import StanzaBase +from slixmpp.xmlstream.handler import CoroutineCallback +from slixmpp.xmlstream.matcher import MatchXPath from poezio.tabs import ( ChatTab, ConversationTab, DynamicConversationTab, MucTab, PrivateTab, + RosterInfoTab, StaticConversationTab, ) from poezio.plugin import BasePlugin -from poezio.theming import get_theme, dump_tuple +from poezio.theming import Theme, get_theme, dump_tuple from poezio.config import config from poezio.decorators import command_args_parser +import asyncio from asyncio import iscoroutinefunction import logging @@ -118,7 +122,9 @@ class E2EEPlugin(BasePlugin): #: Used to figure out what messages to attempt decryption for. Also used #: in combination with `tag_whitelist` to avoid removing encrypted tags - #: before sending. + #: before sending. If multiple tags are present, a handler will be + #: registered for each invididual tag/ns pair under <message/>, as opposed + #: to a single handler for all tags combined. encrypted_tags: Optional[List[Tuple[str, str]]] = None # Static map, to be able to limit to one encryption mechanism per tab at a @@ -126,7 +132,7 @@ class E2EEPlugin(BasePlugin): _enabled_tabs: Dict[JID, Callable] = {} # Tabs that support this encryption mechanism - supported_tab_types: Tuple[Type[ChatTabs], ...] = tuple() + supported_tab_types: Tuple[Type[ChatTab], ...] = tuple() # States for each remote entity trust_states: Dict[str, Set[str]] = {'accepted': set(), 'rejected': set()} @@ -146,11 +152,24 @@ class E2EEPlugin(BasePlugin): if self.encryption_short_name is None: self.encryption_short_name = self.encryption_name + if not self.supported_tab_types: + raise NotImplementedError + # Ensure decryption is done before everything, so that other handlers # don't have to know about the encryption mechanism. - self.api.add_event_handler('muc_msg', self._decrypt, priority=0) - self.api.add_event_handler('conversation_msg', self._decrypt, priority=0) - self.api.add_event_handler('private_msg', self._decrypt, priority=0) + self.api.add_event_handler('muc_msg', self._decrypt_wrapper, priority=0) + self.api.add_event_handler('conversation_msg', self._decrypt_wrapper, priority=0) + self.api.add_event_handler('private_msg', self._decrypt_wrapper, priority=0) + + # Register a handler for each invididual tag/ns pair in encrypted_tags + # as well. as _msg handlers only include messages with a <body/>. + if self.encrypted_tags is not None: + default_ns = self.core.xmpp.default_ns + for i, (namespace, tag) in enumerate(self.encrypted_tags): + self.core.xmpp.register_handler(CoroutineCallback(f'EncryptedTag{i}', + MatchXPath(f'{{{default_ns}}}message/{{{namespace}}}{tag}'), + self._decrypt_encryptedtag, + )) # Ensure encryption is done after everything, so that whatever can be # encrypted is encrypted, and no plain element slips in. @@ -191,8 +210,8 @@ class E2EEPlugin(BasePlugin): self.encryption_short_name + '_fingerprint', self._command_show_fingerprints, usage='[jid]', - short='Show %s fingerprint(s) for a JID.' % self.encryption_short_name, - help='Show %s fingerprint(s) for a JID.' % self.encryption_short_name, + short=f'Show {self.encryption_short_name} fingerprint(s) for a JID.', + help=f'Show {self.encryption_short_name} fingerprint(s) for a JID.', ) ConversationTab.add_information_element( @@ -215,7 +234,8 @@ class E2EEPlugin(BasePlugin): for section in config.sections(): value = config.getstr('encryption', section=section) if value and value == self.encryption_short_name: - self._enabled_tabs[section] = self.encrypt + section_jid = JID(section) + self._enabled_tabs[section_jid] = self.encrypt def cleanup(self): ConversationTab.remove_information_element(self.encryption_short_name) @@ -238,56 +258,73 @@ class E2EEPlugin(BasePlugin): return "" def _toggle_tab(self, _input: str) -> None: - jid: JID = self.api.current_tab().jid + tab = self.api.current_tab() + jid: JID = tab.jid if self._encryption_enabled(jid): del self._enabled_tabs[jid] + tab.e2e_encryption = None config.remove_and_save('encryption', section=jid) self.api.information( - '{} encryption disabled for {}'.format(self.encryption_name, jid), + f'{self.encryption_name} encryption disabled for {jid}', 'Info', ) elif self.encryption_short_name: self._enabled_tabs[jid] = self.encrypt + tab.e2e_encryption = self.encryption_name config.set_and_save('encryption', self.encryption_short_name, section=jid) self.api.information( - '{} encryption enabled for {}'.format(self.encryption_name, jid), + f'{self.encryption_name} encryption enabled for {jid}', 'Info', ) - def _show_fingerprints(self, jid: JID) -> None: + @staticmethod + def format_fingerprint(fingerprint: str, own: bool, theme: Theme) -> str: + return fingerprint + + async def _show_fingerprints(self, jid: JID) -> None: """Display encryption fingerprints for a JID.""" - fprs = self.get_fingerprints(jid) + theme = get_theme() + fprs = await self.get_fingerprints(jid) if len(fprs) == 1: + fp, own = fprs[0] + fingerprint = self.format_fingerprint(fp, own, theme) self.api.information( - 'Fingerprint for %s: %s' % (jid, fprs[0]), + f'Fingerprint for {jid}:\n{fingerprint}', 'Info', ) elif fprs: + fmt_fprs = map(lambda fp: self.format_fingerprint(fp[0], fp[1], theme), fprs) self.api.information( - 'Fingerprints for %s:\n\t%s' % (jid, '\n\t'.join(fprs)), + 'Fingerprints for %s:\n%s' % (jid, '\n\n'.join(fmt_fprs)), 'Info', ) else: self.api.information( - 'No fingerprints to display', + f'{jid}: No fingerprints to display', 'Info', ) @command_args_parser.quoted(0, 1) def _command_show_fingerprints(self, args: List[str]) -> None: - if not args and isinstance(self.api.current_tab(), self.supported_tab_types): - jid = self.api.current_tab().jid + tab = self.api.current_tab() + if not args and isinstance(tab, self.supported_tab_types): + jid = tab.jid + if isinstance(tab, MucTab): + jid = self.core.xmpp.boundjid.bare + elif not args and isinstance(tab, RosterInfoTab): + # Allow running the command without arguments in roster tab + jid = self.core.xmpp.boundjid.bare elif args: jid = args[0] else: + shortname = self.encryption_short_name self.api.information( - '%s_fingerprint: Couldn\'t deduce JID from context' % ( - self.encryption_short_name), + f'{shortname}_fingerprint: Couldn\'t deduce JID from context', 'Error', ) return None - self._show_fingerprints(JID(jid)) + asyncio.create_task(self._show_fingerprints(JID(jid))) @command_args_parser.quoted(2) def __command_set_state_global(self, args, state='') -> None: @@ -299,9 +336,9 @@ class E2EEPlugin(BasePlugin): return jid, fpr = args if state not in self._all_trust_states: + shortname = self.encryption_short_name self.api.information( - 'Unknown state for plugin %s: %s' % ( - self.encryption_short_name, state), + f'Unknown state for plugin {shortname}: {state}', 'Error' ) return @@ -324,9 +361,9 @@ class E2EEPlugin(BasePlugin): return fpr = args[0] if state not in self._all_trust_states: + shortname = self.encryption_short_name self.api.information( - 'Unknown state for plugin %s: %s' % ( - self.encryption_short_name, state), + f'Unknown state for plugin {shortname}: {state}', 'Error', ) return @@ -345,6 +382,28 @@ class E2EEPlugin(BasePlugin): except NothingToEncrypt: return stanza except Exception as exc: + jid = stanza['from'] + tab = self.core.tabs.by_name_and_class(jid, ChatTab) + msg = ' \n\x19%s}Could not decrypt message: %s' % ( + dump_tuple(get_theme().COLOR_CHAR_NACK), + exc, + ) + # XXX: check before commit. Do we not nack in MUCs? + if tab and not isinstance(tab, MucTab): + tab.nack_message(msg, stanza['id'], stanza['to']) + # TODO: display exceptions to the user properly + log.error('Exception in encrypt:', exc_info=True) + return None + return result + + async def _decrypt_wrapper(self, stanza: Message, tab: Optional[ChatTabs]) -> None: + """ + Wrapper around _decrypt() to handle errors and display the message after encryption. + """ + try: + # pylint: disable=unexpected-keyword-arg + await self._decrypt(stanza, tab, passthrough=True) + except Exception as exc: jid = stanza['to'] tab = self.core.tabs.by_name_and_class(jid, ChatTab) msg = ' \n\x19%s}Could not send message: %s' % ( @@ -352,27 +411,57 @@ class E2EEPlugin(BasePlugin): exc, ) # XXX: check before commit. Do we not nack in MUCs? - if not isinstance(tab, MucTab): + if tab and not isinstance(tab, MucTab): tab.nack_message(msg, stanza['id'], stanza['from']) # TODO: display exceptions to the user properly - log.error('Exception in encrypt:', exc_info=True) + log.error('Exception in decrypt:', exc_info=True) + return None + return None + + async def _decrypt_encryptedtag(self, stanza: Message) -> None: + """ + Handler to decrypt encrypted_tags elements that are matched separately + from other messages because the default 'message' handler that we use + only matches messages containing a <body/>. + """ + # If the message contains a body, it will already be handled by the + # other handler. If not, pass it to the handler. + if stanza.xml.find(f'{{{self.core.xmpp.default_ns}}}body') is not None: return None - return result - def _decrypt(self, message: Message, tab: ChatTabs) -> None: + mfrom = stanza['from'] - has_eme = False - if message.xml.find('{%s}%s' % (EME_NS, EME_TAG)) is not None and \ + # Find what tab this message corresponds to. + if stanza['type'] == 'groupchat': # MUC + tab = self.core.tabs.by_name_and_class( + name=mfrom.bare, cls=MucTab, + ) + elif self.core.handler.is_known_muc_pm(stanza, mfrom): # MUC-PM + tab = self.core.tabs.by_name_and_class( + name=mfrom.full, cls=PrivateTab, + ) + else: # 1:1 + tab = self.core.get_conversation_by_jid( + jid=JID(mfrom.bare), + create=False, + fallback_barejid=True, + ) + log.debug('Found tab %r for encrypted message', tab) + await self._decrypt_wrapper(stanza, tab) + + async def _decrypt(self, message: Message, tab: Optional[ChatTabs], passthrough: bool = True) -> None: + + has_eme: bool = False + if message.xml.find(f'{{{EME_NS}}}{EME_TAG}') is not None and \ message['eme']['namespace'] == self.eme_ns: has_eme = True - has_encrypted_tag = False + has_encrypted_tag: bool = False if not has_eme and self.encrypted_tags is not None: + tmp: bool = True for (namespace, tag) in self.encrypted_tags: - if message.xml.find('{%s}%s' % (namespace, tag)) is not None: - # TODO: count all encrypted tags. - has_encrypted_tag = True - break + tmp = tmp and message.xml.find(f'{{{namespace}}}{tag}') is not None + has_encrypted_tag = tmp if not has_eme and not has_encrypted_tag: return None @@ -397,26 +486,63 @@ class E2EEPlugin(BasePlugin): if user is not None: jid = user.jid or None - self.decrypt(message, jid, tab) + # Call the enabled encrypt method + func = self.decrypt + if iscoroutinefunction(func): + # pylint: disable=unexpected-keyword-arg + await func(message, jid, tab, passthrough=True) # type: ignore + else: + # pylint: disable=unexpected-keyword-arg + func(message, jid, tab) # type: ignore log.debug('Decrypted %s message: %r', self.encryption_name, message['body']) return None async def _encrypt(self, stanza: StanzaBase, passthrough: bool = True) -> Optional[StanzaBase]: + # TODO: Let through messages that contain elements that don't need to + # be encrypted even in an encrypted context, such as MUC mediated + # invites, etc. + # What to do when they're mixed with other elements? It probably + # depends on the element. Maybe they can be mixed with + # `self.tag_whitelist` that are already assumed to be sent as plain by + # the E2EE plugin. + # They might not be accompanied by a <body/> most of the time, nor by + # an encrypted tag. + if not isinstance(stanza, Message) or stanza['type'] not in ('normal', 'chat', 'groupchat'): raise NothingToEncrypt() message = stanza + + # Is this message already encrypted? Do we need to do all these + # checks? Such as an OMEMO heartbeat. + has_encrypted_tag: bool = False + if self.encrypted_tags is not None: + tmp: bool = True + for (namespace, tag) in self.encrypted_tags: + tmp = tmp and message.xml.find(f'{{{namespace}}}{tag}') is not None + has_encrypted_tag = tmp + + if has_encrypted_tag: + log.debug('Message already contains encrypted tags.') + raise NothingToEncrypt() + # Find who to encrypt to. If in a groupchat this can be multiple JIDs. # It is possible that we are not able to find a jid (e.g., semi-anon # MUCs). Let the plugin decide what to do with this information. jids: Optional[List[JID]] = [message['to']] tab = self.core.tabs.by_jid(message['to']) - if tab is None: # When does that ever happen? - log.debug('Attempting to encrypt a message to \'%s\' ' - 'that is not attached to a Tab. ?! Aborting ' - 'encryption.', message['to']) - return None + if tab is None and message['to'].resource: + # Redo the search with the bare JID + tab = self.core.tabs.by_jid(message['to'].bare) + + if tab is None: # Possible message sent directly by the e2ee lib? + log.debug( + 'A message we do not have a tab for ' + 'is being sent to \'%s\'. \n%r.', + message['to'], + message, + ) parent = None if isinstance(tab, PrivateTab): @@ -449,19 +575,22 @@ class E2EEPlugin(BasePlugin): if user.jid.bare: jids.append(user.jid) - if not self._encryption_enabled(tab.jid): + if tab and not self._encryption_enabled(tab.jid): raise NothingToEncrypt() log.debug('Sending %s message', self.encryption_name) has_body = message.xml.find('{%s}%s' % (JCLIENT_NS, 'body')) is not None + if not self._encryption_enabled(tab.jid): + raise NothingToEncrypt() + # Drop all messages that don't contain a body if the plugin doesn't do # Stanza Encryption if not self.stanza_encryption and not has_body: log.debug( '%s plugin: Dropping message as it contains no body, and ' - 'not doesn\'t do stanza encryption', + 'doesn\'t do stanza encryption', self.encryption_name, ) return None @@ -495,7 +624,7 @@ class E2EEPlugin(BasePlugin): if self.encrypted_tags is not None: whitelist += self.encrypted_tags - tag_whitelist = {'{%s}%s' % tag for tag in whitelist} + tag_whitelist = {f'{{{ns}}}{tag}' for (ns, tag) in whitelist} for elem in message.xml[:]: if elem.tag not in tag_whitelist: @@ -506,15 +635,15 @@ class E2EEPlugin(BasePlugin): def store_trust(self, jid: JID, state: str, fingerprint: str) -> None: """Store trust for a fingerprint and a jid.""" - option_name = '%s:%s' % (self.encryption_short_name, fingerprint) + option_name = f'{self.encryption_short_name}:{fingerprint}' config.silent_set(option=option_name, value=state, section=jid) def fetch_trust(self, jid: JID, fingerprint: str) -> str: """Fetch trust of a fingerprint and a jid.""" - option_name = '%s:%s' % (self.encryption_short_name, fingerprint) + option_name = f'{self.encryption_short_name}:{fingerprint}' return config.getstr(option=option_name, section=jid) - async def decrypt(self, message: Message, jid: Optional[JID], tab: ChatTab): + async def decrypt(self, message: Message, jid: Optional[JID], tab: Optional[ChatTab]): """Decryption method This is a method the plugin must implement. It is expected that this @@ -546,7 +675,7 @@ class E2EEPlugin(BasePlugin): raise NotImplementedError - def get_fingerprints(self, jid: JID) -> List[str]: + async def get_fingerprints(self, jid: JID) -> List[Tuple[str, bool]]: """Show fingerprint(s) for this encryption method and JID. To overload in plugins. diff --git a/poezio/poezio.py b/poezio/poezio.py index 694130f0..b149abd4 100644 --- a/poezio/poezio.py +++ b/poezio/poezio.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Starting point of poezio. Launches both the Connection and Gui """ @@ -115,17 +115,12 @@ def main(): from slixmpp.exceptions import IqError, IqTimeout - def swallow_iqerrors(loop, context): - """Do not log unhandled iq errors and timeouts""" - if not isinstance(context['exception'], (IqError, IqTimeout)): - loop.default_exception_handler(context) - # Warning: asyncio must always be imported after the config. Otherwise # the asyncio logger will not follow our configuration and won't write # the tracebacks in the correct file, etc import asyncio loop = asyncio.get_event_loop() - loop.set_exception_handler(swallow_iqerrors) + loop.set_exception_handler(cocore.loop_exception_handler) loop.add_reader(sys.stdin, cocore.on_input_readable) loop.add_signal_handler(signal.SIGWINCH, cocore.sigwinch_handler) diff --git a/poezio/poopt.py b/poezio/poopt.py deleted file mode 100644 index 57bd28c8..00000000 --- a/poezio/poopt.py +++ /dev/null @@ -1,185 +0,0 @@ -# Copyright 2017 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> -# -# This file is part of Poezio. -# -# Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. -'''This is a template module just for instruction. And poopt.''' - -from typing import List, Tuple - -# CFFI codepath. -from cffi import FFI - -ffi = FFI() -ffi.cdef(""" - typedef long wchar_t; - int wcwidth(wchar_t c); -""") -libc = ffi.dlopen(None) - -# Cython codepath. -#cdef extern from "wchar.h": -# ctypedef Py_UCS4 wchar_t -# int wcwidth(wchar_t c) - - -# Just checking if the return value is -1. In some (all?) implementations, -# wcwidth("😆") returns -1 while it should return 2. In these cases, we -# return 1 instead because this is by far the most probable real value. -# Since the string is received from python, and the unicode character is -# extracted with mbrtowc(), and supposing these two compononents are not -# bugged, and since poezio’s code should never pass '\t', '\n' or their -# friends, a return value of -1 from wcwidth() is considered to be a bug in -# wcwidth() (until proven otherwise). xwcwidth() is here to work around -# this bug. -def xwcwidth(c: str) -> int: - character = ord(c) - res = libc.wcwidth(character) - if res == -1 and c != '\x19': - return 1 - return res - - -# cut_text: takes a string and returns a tuple of int. -# -# Each two int tuple is a line, represented by the ending position it -# (where it should be cut). Not that this position is calculed using the -# position of the python string characters, not just the individual bytes. -# -# For example, -# poopt_cut_text("vivent les réfrigérateurs", 6); -# will return [(0, 6), (7, 10), (11, 17), (17, 22), (22, 24)], meaning that -# the lines are -# "vivent", "les", "réfrig", "érateu" and "rs" -def cut_text(string: str, width: int) -> List[Tuple[int, int]]: - '''cut_text(text, width) - - Return a list of two-tuple, the first int is the starting position of the line and the second is its end.''' - - # The list of tuples that we return - retlist = [] - - # The start position (in the python-string) of the next line - #: unsigned int - start_pos = 0 - - # The position of the last space seen in the current line. This is used - # to cut on spaces instead of cutting inside words, if possible (aka if - # there is a space) - #: int - last_space = -1 - # The number of columns taken by chars between start_pos and last_space - #: size_t - cols_until_space = 0 - - # Number of columns taken to display the current line so far - #: size_t - columns = 0 - - #: wchar_t - #wc = 0 - - # The position, considering unicode chars (aka, the position in the - # python string). This is used to determine the position in the python - # string at which we should cut */ - #: unsigned int - #spos = -1 - - in_special_character = False - for spos, wc in enumerate(string): - # Special case to skip poezio special characters that are contained - # in the python string, but should not be counted as chars because - # they will not be displayed. Those are the formatting chars (to - # insert colors or things like that in the string) - if in_special_character: - # Skip everything until the end of this format marker, but - # without increasing the number of columns of the current - # line. Because these chars are not printed. - if wc in ('u', 'a', 'i', 'b', 'o', '}'): - in_special_character = False - continue - if wc == '\x19': - in_special_character = True - continue - - # This is one condition to end the line: an explicit \n is found - if wc == '\n': - spos += 1 - retlist.append((start_pos, spos)) - - # And then initiate a new line - start_pos = spos - last_space = -1 - columns = 0 - continue - - # Get the number of columns needed to display this character. May be 0, 1 or 2 - cols = xwcwidth(wc) - - # This is the second condition to end the line: we have consumed - # enough columns to fill a whole line - if columns + cols > width: - # If possible, cut on a space - if last_space != -1: - retlist.append((start_pos, last_space)) - start_pos = last_space + 1 - last_space = -1 - columns -= (cols_until_space + 1) - else: - # Otherwise, cut in the middle of a word - retlist.append((start_pos, spos)) - start_pos = spos - columns = 0 - # We save the position of the last space seen in this line, and the - # number of columns we have until now. This helps us keep track of - # the columns to count when we will use that space as a cutting - # point, later - if wc == ' ': - last_space = spos - cols_until_space = columns - # We advanced from one char, increment spos by one and add the - # char's columns to the line's columns - columns += cols - # We are at the end of the string, append the last line, not finished - retlist.append((start_pos, spos + 1)) - return retlist - - -# wcswidth: An emulation of the POSIX wcswidth(3) function using xwcwidth. -def wcswidth(string: str) -> int: - '''wcswidth(s) - - The wcswidth() function returns the number of columns needed to represent the wide-character string pointed to by s. Raise UnicodeError if an invalid unicode value is passed''' - - columns = 0 - for wc in string: - columns += xwcwidth(wc) - return columns - - -# cut_by_columns: takes a python string and a number of columns, returns a -# python string truncated to take at most that many columns -# For example cut_by_columns(n, "エメルカ") will return: -# - n == 5 -> "エメ" (which takes only 4 columns since we can't cut the -# next character in half) -# - n == 2 -> "エ" -# - n == 1 -> "" -# - n == 42 -> "エメルカ" -# - etc -def cut_by_columns(string: str, limit: int) -> str: - '''cut_by_columns(string, limit) - - returns a string truncated to take at most limit columns''' - - spos = 0 - columns = 0 - for wc in string: - if columns == limit: - break - cols = xwcwidth(wc) - if columns + cols > limit: - break - spos += 1 - columns += cols - return string[:spos] diff --git a/poezio/poopt.pyi b/poezio/poopt.pyi new file mode 100644 index 00000000..3762c94a --- /dev/null +++ b/poezio/poopt.pyi @@ -0,0 +1,7 @@ + +from typing import List, Tuple + +def xwcwidth(c: str) -> int: ... +def cut_text(string: str, width: int) -> List[Tuple[int, int]]: ... +def wcswidth(string: str) -> int: ... +def cut_by_columns(string: str, limit: int) -> str: ... diff --git a/poezio/pooptmodule.c b/poezio/pooptmodule.c index 427ac883..8574b225 100644 --- a/poezio/pooptmodule.c +++ b/poezio/pooptmodule.c @@ -3,7 +3,7 @@ /* This file is part of Poezio. */ /* Poezio is free software: you can redistribute it and/or modify */ -/* it under the terms of the zlib license. See the COPYING file. */ +/* it under the terms of the GPL-3.0+ license. See the COPYING file. */ /** The poopt python3 module **/ diff --git a/poezio/py.typed b/poezio/py.typed new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/poezio/py.typed diff --git a/poezio/roster.py b/poezio/roster.py index 4534dd30..a52ea23e 100644 --- a/poezio/roster.py +++ b/poezio/roster.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Defines the Roster and RosterGroup classes """ @@ -71,7 +71,7 @@ class Roster: self.last_modified = datetime.now() @property - def needs_rebuild(self): + def needs_rebuild(self) -> bool: return self.last_modified >= self.last_built def __getitem__(self, key): @@ -133,7 +133,7 @@ class Roster: return False @property - def jid(self): + def jid(self) -> JID: """Our JID""" return self.__node.jid diff --git a/poezio/size_manager.py b/poezio/size_manager.py index 3e80c357..c5312c9f 100644 --- a/poezio/size_manager.py +++ b/poezio/size_manager.py @@ -18,21 +18,25 @@ class SizeManager: self._core = core @property - def tab_degrade_x(self): + def tab_degrade_x(self) -> bool: + if base_wins.TAB_WIN is None: + raise ValueError _, x = base_wins.TAB_WIN.getmaxyx() return x < THRESHOLD_WIDTH_DEGRADE @property - def tab_degrade_y(self): + def tab_degrade_y(self) -> bool: + if base_wins.TAB_WIN is None: + raise ValueError y, x = base_wins.TAB_WIN.getmaxyx() return y < THRESHOLD_HEIGHT_DEGRADE @property - def core_degrade_x(self): + def core_degrade_x(self) -> bool: y, x = self._core.stdscr.getmaxyx() return x < FULL_WIDTH_DEGRADE @property - def core_degrade_y(self): + def core_degrade_y(self) -> bool: y, x = self._core.stdscr.getmaxyx() return y < FULL_HEIGHT_DEGRADE diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py index 508465e3..793eae62 100644 --- a/poezio/tabs/basetabs.py +++ b/poezio/tabs/basetabs.py @@ -170,15 +170,15 @@ class Tab: return 1 @property - def info_win(self): + def info_win(self) -> windows.TextWin: return self.core.information_win @property - def color(self): + def color(self) -> Union[Tuple[int, int], Tuple[int, int, 'str']]: return STATE_COLORS[self._state]() @property - def vertical_color(self): + def vertical_color(self) -> Union[Tuple[int, int], Tuple[int, int, 'str']]: return VERTICAL_STATE_COLORS[self._state]() @property @@ -351,7 +351,7 @@ class Tab: if hasattr(self.input, "reset_completion"): self.input.reset_completion() if asyncio.iscoroutinefunction(func): - asyncio.ensure_future(func(arg)) + asyncio.create_task(func(arg)) else: func(arg) return True @@ -390,12 +390,6 @@ class Tab: """ return self.name - def get_text_window(self) -> Optional[windows.TextWin]: - """ - Returns the principal TextWin window, if there's one - """ - return None - def on_input(self, key: str, raw: bool): """ raw indicates if the key should activate the associated command or not. @@ -498,7 +492,7 @@ class GapTab(Tab): return 0 @property - def name(self): + def name(self) -> str: return '' def refresh(self): @@ -520,6 +514,7 @@ class ChatTab(Tab): timed_event_paused: Optional[DelayedEvent] timed_event_not_paused: Optional[DelayedEvent] mam_filler: Optional[MAMFiller] + e2e_encryption: Optional[str] = None def __init__(self, core, jid: Union[JID, str]): Tab.__init__(self, core) @@ -675,7 +670,9 @@ class ChatTab(Tab): if not self.execute_command(txt): if txt.startswith('//'): txt = txt[1:] - self.command_say(xhtml.convert_simple_to_full_colors(txt)) + asyncio.ensure_future( + self.command_say(xhtml.convert_simple_to_full_colors(txt)) + ) self.cancel_paused_delay() @command_args_parser.raw @@ -800,7 +797,7 @@ class ChatTab(Tab): self.last_sent_message = msg @command_args_parser.raw - def command_correct(self, line: str) -> None: + async def command_correct(self, line: str) -> None: """ /correct <fixed message> """ @@ -810,7 +807,7 @@ class ChatTab(Tab): if not self.last_sent_message: self.core.information('There is no message to correct.', 'Error') return - self.command_say(line, correct=True) + await self.command_say(line, correct=True) def completion_correct(self, the_input): if self.last_sent_message and the_input.get_argument_position() == 1: @@ -844,7 +841,7 @@ class ChatTab(Tab): self.state = 'scrolled' @command_args_parser.raw - def command_say(self, line: str, attention: bool = False, correct: bool = False): + async def command_say(self, line: str, attention: bool = False, correct: bool = False): pass def goto_build_lines(self, new_date): @@ -979,7 +976,7 @@ class ChatTab(Tab): def on_scroll_up(self): if not self.query_status: from poezio.log_loader import LogLoader - asyncio.ensure_future( + asyncio.create_task( LogLoader(logger, self, config.getbool('use_log')).scroll_requested() ) return self.text_win.scroll_up(self.text_win.height - 1) @@ -1004,6 +1001,7 @@ class OneToOneTab(ChatTab): self.__status = Status("", "") self.last_remote_message = datetime.now() + self._initial_log = asyncio.Event() # Set to true once the first disco is done self.__initial_disco = False @@ -1018,9 +1016,9 @@ class OneToOneTab(ChatTab): shortdesc='Request the attention.', desc='Attention: Request the attention of the contact. Can also ' 'send a message along with the attention.') - self.init_logs(initial=initial) + asyncio.create_task(self.init_logs(initial=initial)) - def init_logs(self, initial=None) -> None: + async def init_logs(self, initial: Optional[SMessage] = None) -> None: use_log = config.get_by_tabname('use_log', self.jid) mam_sync = config.get_by_tabname('mam_sync', self.jid) if use_log and mam_sync: @@ -1031,19 +1029,16 @@ class OneToOneTab(ChatTab): if initial is not None: # If there is an initial message, throw it back into the # text buffer if it cannot be fetched from mam - async def fallback_no_mam(): - await mam_filler.done.wait() - if mam_filler.result == 0: - self.handle_message(initial) - - asyncio.ensure_future(fallback_no_mam()) + await mam_filler.done.wait() + if mam_filler.result == 0: + await self.handle_message(initial) elif use_log and initial: - self.handle_message(initial, display=False) - asyncio.ensure_future( - LogLoader(logger, self, use_log).tab_open() - ) + await self.handle_message(initial, display=False) + elif initial: + await self.handle_message(initial) + await LogLoader(logger, self, use_log, self._initial_log).tab_open() - def handle_message(self, msg: SMessage, display: bool = True): + async def handle_message(self, msg: SMessage, display: bool = True): pass def remote_user_color(self): @@ -1116,10 +1111,10 @@ class OneToOneTab(ChatTab): self.refresh() @command_args_parser.raw - def command_attention(self, message): + async def command_attention(self, message): """/attention [message]""" if message != '': - self.command_say(message, attention=True) + await self.command_say(message, attention=True) else: msg = self.core.xmpp.make_message(self.get_dest_jid()) msg['type'] = 'chat' @@ -1127,7 +1122,7 @@ class OneToOneTab(ChatTab): msg.send() @command_args_parser.raw - def command_say(self, line: str, attention: bool = False, correct: bool = False): + async def command_say(self, line: str, attention: bool = False, correct: bool = False): pass @command_args_parser.ignored diff --git a/poezio/tabs/bookmarkstab.py b/poezio/tabs/bookmarkstab.py index 10c7c0ce..d21b5630 100644 --- a/poezio/tabs/bookmarkstab.py +++ b/poezio/tabs/bookmarkstab.py @@ -96,7 +96,7 @@ class BookmarksTab(Tab): if bm in self.bookmarks: self.bookmarks.remove(bm) - asyncio.ensure_future( + asyncio.create_task( self.save_routine() ) diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py index 9ddb6fc1..de1f988a 100644 --- a/poezio/tabs/conversationtab.py +++ b/poezio/tabs/conversationtab.py @@ -11,6 +11,7 @@ There are two different instances of a ConversationTab: the time. """ +import asyncio import curses import logging from datetime import datetime @@ -21,7 +22,6 @@ from slixmpp import JID, InvalidJID, Message as SMessage from poezio.tabs.basetabs import OneToOneTab, Tab from poezio import common -from poezio import tabs from poezio import windows from poezio import xhtml from poezio.config import config, get_image_cache @@ -83,8 +83,8 @@ class ConversationTab(OneToOneTab): self.update_keys() @property - def general_jid(self): - return self.jid.bare + def general_jid(self) -> JID: + return JID(self.jid.bare) def get_info_header(self): raise NotImplementedError @@ -105,16 +105,25 @@ class ConversationTab(OneToOneTab): def completion(self): self.complete_commands(self.input) - def handle_message(self, message: SMessage, display: bool = True): + async def handle_message(self, message: SMessage, display: bool = True): """Handle a received message. The message can come from us (carbon copy). """ + + # Prevent messages coming from our own devices (1:1) to be reflected + if message['to'].bare == self.core.xmpp.boundjid.bare and \ + message['from'].bare == self.core.xmpp.boundjid.bare: + _, index = self._text_buffer._find_message(message['id']) + if index != -1: + return + use_xhtml = config.get_by_tabname( 'enable_xhtml_im', message['from'].bare ) tmp_dir = get_image_cache() + # normal message, we are the recipient if message['to'].bare == self.core.xmpp.boundjid.bare: conv_jid = message['from'] @@ -132,7 +141,7 @@ class ConversationTab(OneToOneTab): else: return - self.core.events.trigger('conversation_msg', message, self) + await self.core.events.trigger_async('conversation_msg', message, self) if not message['body']: return @@ -172,7 +181,8 @@ class ConversationTab(OneToOneTab): @refresh_wrapper.always @command_args_parser.raw - def command_say(self, line: str, attention: bool = False, correct: bool = False): + async def command_say(self, line: str, attention: bool = False, correct: bool = False): + await self._initial_log.wait() msg: SMessage = self.core.xmpp.make_message( mto=self.get_dest_jid(), mfrom=self.core.xmpp.boundjid @@ -189,7 +199,6 @@ class ConversationTab(OneToOneTab): self.core.events.trigger('conversation_say', msg, self) if not msg['body']: return - replaced = False if correct or msg['replace']['id']: msg['replace']['id'] = self.last_sent_message['id'] # type: ignore else: @@ -209,10 +218,10 @@ class ConversationTab(OneToOneTab): if not msg['body']: return self.set_last_sent_message(msg, correct=correct) - self.core.handler.on_normal_message(msg) - # Our receipts slixmpp hack msg._add_receipt = True # type: ignore msg.send() + await self.core.handler.on_normal_message(msg) + # Our receipts slixmpp hack self.cancel_paused_delay() @command_args_parser.quoted(0, 1) @@ -277,16 +286,9 @@ class ConversationTab(OneToOneTab): else: resource = None if resource: - status = ( - 'Status: %s' % resource.status) if resource.status else '' - self.add_message( - InfoMessage( - "Show: %(show)s, %(status)s" % { - 'show': resource.presence or 'available', - 'status': status, - } - ), - ) + status = (f', Status: {resource.status}') if resource.status else '' + show = f"Show: {resource.presence or 'available'}" + self.add_message(InfoMessage(f'{show}{status}')) return True self.add_message( InfoMessage("No information available"), @@ -436,9 +438,6 @@ class ConversationTab(OneToOneTab): 1, self.width, self.height - 2 - self.core.information_win_size - Tab.tab_win_height(), 0) - def get_text_window(self): - return self.text_win - def on_close(self): Tab.on_close(self) if config.get_by_tabname('send_chat_states', self.general_jid): @@ -543,7 +542,7 @@ class StaticConversationTab(ConversationTab): self.update_commands() self.update_keys() - def init_logs(self, initial=None) -> None: + async def init_logs(self, initial=None) -> None: # Disable local logs because… pass diff --git a/poezio/tabs/muclisttab.py b/poezio/tabs/muclisttab.py index f6b3fc35..53fce727 100644 --- a/poezio/tabs/muclisttab.py +++ b/poezio/tabs/muclisttab.py @@ -4,6 +4,7 @@ A MucListTab is a tab listing the rooms on a conference server. It has no functionality except scrolling the list, and allowing the user to join the rooms. """ +import asyncio import logging from typing import Dict, Callable @@ -74,4 +75,4 @@ class MucListTab(ListTab): row = self.listview.get_selected_row() if not row: return - self.core.command.join(row[1]) + asyncio.ensure_future(self.core.command.join(row[1])) diff --git a/poezio/tabs/muctab.py b/poezio/tabs/muctab.py index acc145af..e2d546c9 100644 --- a/poezio/tabs/muctab.py +++ b/poezio/tabs/muctab.py @@ -18,6 +18,7 @@ import random import re import functools from copy import copy +from dataclasses import dataclass from datetime import datetime from typing import ( cast, @@ -44,12 +45,13 @@ from poezio import timed_events from poezio import windows from poezio import xhtml from poezio.common import to_utc -from poezio.config import config +from poezio.config import config, get_image_cache from poezio.core.structs import Command from poezio.decorators import refresh_wrapper, command_args_parser from poezio.logger import logger from poezio.log_loader import LogLoader, MAMFiller from poezio.roster import roster +from poezio.text_buffer import CorrectionError from poezio.theming import get_theme, dump_tuple from poezio.user import User from poezio.core.structs import Completion, Status @@ -73,6 +75,18 @@ NS_MUC_USER = 'http://jabber.org/protocol/muc#user' COMPARE_USERS_LAST_TALKED = lambda x: x.last_talked +@dataclass +class MessageData: + message: SMessage + delayed: bool + date: Optional[datetime] + nick: str + user: Optional[User] + room_from: str + body: str + is_history: bool + + class MucTab(ChatTab): """ The tab containing a multi-user-chat room. @@ -154,14 +168,14 @@ class MucTab(ChatTab): """ The user do not want to send their config, send an iq cancel """ - asyncio.ensure_future(self.core.xmpp['xep_0045'].cancel_config(self.jid)) + asyncio.create_task(self.core.xmpp['xep_0045'].cancel_config(self.jid)) self.core.close_tab() def send_config(self, form: Form) -> None: """ The user sends their config to the server """ - asyncio.ensure_future(self.core.xmpp['xep_0045'].set_room_config(self.jid, form)) + asyncio.create_task(self.core.xmpp['xep_0045'].set_room_config(self.jid, form)) self.core.close_tab() def join(self) -> None: @@ -233,6 +247,8 @@ class MucTab(ChatTab): message) self.core.disable_private_tabs(self.jid.bare, reason=msg) else: + self.presence_buffer = [] + self.users = [] muc.leave_groupchat(self.core.xmpp, self.jid, self.own_nick, message) @@ -450,9 +466,6 @@ class MucTab(ChatTab): # TODO: send the disco#info identity name here, if it exists. return self.jid.node - def get_text_window(self) -> windows.TextWin: - return self.text_win - def on_lose_focus(self) -> None: if self.joined: if self.input.text: @@ -480,6 +493,126 @@ class MucTab(ChatTab): self.general_jid) and not self.input.get_text(): self.send_chat_state('active') + async def handle_message(self, message: SMessage) -> bool: + """Parse an incoming message + + Returns False if the message was dropped silently. + """ + room_from = message['from'].bare + nick_from = message['mucnick'] + user = self.get_user_by_name(nick_from) + if user and user in self.ignores: + return False + + await self.core.events.trigger_async('muc_msg', message, self) + use_xhtml = config.get_by_tabname('enable_xhtml_im', room_from) + tmp_dir = get_image_cache() + body = xhtml.get_body_from_message_stanza( + message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) + + # TODO: #3314. Is this a MUC reflection? + # Is this an encrypted message? Is so ignore. + # It is not possible in the OMEMO case to decrypt these messages + # since we don't encrypt for our own device (something something + # forward secrecy), but even for non-FS encryption schemes anyway + # messages shouldn't have changed after a round-trip to the room. + # Otherwire replace the matching message we sent. + if not body: + return False + + old_state = self.state + delayed, date = common.find_delayed_tag(message) + is_history = not self.joined and delayed + + mdata = MessageData( + message, delayed, date, nick_from, user, room_from, body, + is_history + ) + + replaced = False + if message.xml.find('{urn:xmpp:message-correct:0}replace') is not None: + replaced = await self._handle_correction_message(mdata) + if not replaced: + await self._handle_normal_message(mdata) + if mdata.nick == self.own_nick: + self.set_last_sent_message(message, correct=replaced) + self._refresh_after_message(old_state) + return True + + def _refresh_after_message(self, old_state: str) -> None: + """Refresh the appropriate UI after a message is received""" + if self is self.core.tabs.current_tab: + self.refresh() + elif self.state != old_state: + self.core.refresh_tab_win() + current = self.core.tabs.current_tab + current.refresh_input() + self.core.doupdate() + + async def _handle_correction_message(self, message: MessageData) -> bool: + """Process a correction message. + + Returns true if a message was actually corrected. + """ + replaced_id = message.message['replace']['id'] + if replaced_id != '' and config.get_by_tabname( + 'group_corrections', JID(message.room_from)): + try: + delayed_date = message.date or datetime.now() + modify_hl = self.modify_message( + message.body, + replaced_id, + message.message['id'], + time=delayed_date, + delayed=message.delayed, + nickname=message.nick, + user=message.user + ) + if modify_hl: + await self.core.events.trigger_async( + 'highlight', + message.message, + self + ) + return True + except CorrectionError: + log.debug('Unable to correct a message', exc_info=True) + return False + + async def _handle_normal_message(self, message: MessageData) -> None: + """ + Process the non-correction groupchat message. + """ + ui_msg: Union[InfoMessage, Message] + # Messages coming from MUC barejid (Server maintenance, IRC mode + # changes from biboumi, etc.) have no nick/resource and are displayed + # as info messages. + highlight = False + if message.nick: + highlight = self.message_is_highlight( + message.body, message.nick, message.is_history + ) + ui_msg = Message( + txt=message.body, + time=message.date, + nickname=message.nick, + history=message.is_history, + delayed=message.delayed, + identifier=message.message['id'], + jid=message.message['from'], + user=message.user, + highlight=highlight, + ) + else: + ui_msg = InfoMessage( + txt=message.body, + time=message.date, + identifier=message.message['id'], + ) + self.add_message(ui_msg) + if highlight: + await self.core.events.trigger_async('highlight', message, self) + def handle_presence(self, presence: Presence) -> None: """Handle MUC presence""" self.reset_lag() @@ -610,7 +743,7 @@ class MucTab(ChatTab): }, ), ) - asyncio.ensure_future(LogLoader( + asyncio.create_task(LogLoader( logger, self, config.get_by_tabname('use_log', self.general_jid) ).tab_open()) @@ -662,6 +795,17 @@ class MucTab(ChatTab): elif typ == 'unavailable': self.on_user_leave_groupchat(user, jid, status, from_nick, JID(from_room), server_initiated) + ns = 'http://jabber.org/protocol/muc#user' + if presence.xml.find(f'{{{ns}}}x/{{{ns}}}destroy') is not None: + info = f'Room {self.jid} was destroyed.' + if presence['muc']['destroy']: + reason = presence['muc']['destroy']['reason'] + altroom = presence['muc']['destroy']['jid'] + if reason: + info += f' “{reason}”.' + if altroom: + info += f' The new address now is {altroom}.' + self.core.information(info, 'Info') # status change else: self.on_user_change_status(user, from_nick, from_room, affiliation, @@ -1513,7 +1657,7 @@ class MucTab(ChatTab): bookmark = self.core.bookmarks[self.jid] if bookmark: bookmark.autojoin = False - asyncio.ensure_future( + asyncio.create_task( self.core.bookmarks.save(self.core.xmpp) ) self.core.close_tab(self) @@ -1538,8 +1682,10 @@ class MucTab(ChatTab): r = self.core.open_private_window(self.jid.bare, user.nick) if r and len(args) == 2: msg = args[1] - r.command_say( - xhtml.convert_simple_to_full_colors(msg) + asyncio.ensure_future( + r.command_say( + xhtml.convert_simple_to_full_colors(msg) + ) ) if not r: self.core.information("Cannot find user: %s" % nick, 'Error') @@ -1712,7 +1858,7 @@ class MucTab(ChatTab): return None @command_args_parser.raw - def command_say(self, line: str, attention: bool = False, correct: bool = False): + async def command_say(self, line: str, attention: bool = False, correct: bool = False): """ /say <message> Or normal input + enter @@ -2198,7 +2344,7 @@ class MucTab(ChatTab): 'shortdesc': 'Fix a color for a nick.', 'completion': - self.completion_recolor + self.completion_color }, { 'name': 'cycle', diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py index fb89d8e6..1909e3c1 100644 --- a/poezio/tabs/privatetab.py +++ b/poezio/tabs/privatetab.py @@ -10,6 +10,7 @@ both participant’s nicks. It also has slightly different features than the ConversationTab (such as tab-completion on nicks from the room). """ +import asyncio import curses import logging from datetime import datetime @@ -47,7 +48,7 @@ class PrivateTab(OneToOneTab): additional_information: Dict[str, Callable[[str], str]] = {} def __init__(self, core, jid, nick, initial=None): - OneToOneTab.__init__(self, core, jid) + OneToOneTab.__init__(self, core, jid, initial) self.own_nick = nick self.info_header = windows.PrivateInfoWin() self.input = windows.MessageInput() @@ -84,14 +85,14 @@ class PrivateTab(OneToOneTab): return super().remote_user_color() @property - def general_jid(self): + def general_jid(self) -> JID: return self.jid - def get_dest_jid(self): + def get_dest_jid(self) -> JID: return self.jid @property - def nick(self): + def nick(self) -> str: return self.get_nick() def ack_message(self, msg_id: str, msg_jid: JID): @@ -141,7 +142,7 @@ class PrivateTab(OneToOneTab): and not self.input.get_text().startswith('//')) self.send_composing_chat_state(empty_after) - def handle_message(self, message: SMessage, display: bool = True): + async def handle_message(self, message: SMessage, display: bool = True): sent = message['from'].bare == self.core.xmpp.boundjid.bare jid = message['to'] if sent else message['from'] with_nick = jid.resource @@ -155,7 +156,7 @@ class PrivateTab(OneToOneTab): ) tmp_dir = get_image_cache() if not sent: - self.core.events.trigger('private_msg', message, self) + await self.core.events.trigger_async('private_msg', message, self) body = xhtml.get_body_from_message_stanza( message, use_xhtml=use_xhtml, extract_images_to=tmp_dir) if not body or not self: @@ -201,9 +202,10 @@ class PrivateTab(OneToOneTab): @refresh_wrapper.always @command_args_parser.raw - def command_say(self, line: str, attention: bool = False, correct: bool = False) -> None: + async def command_say(self, line: str, attention: bool = False, correct: bool = False) -> None: if not self.on: return + await self._initial_log.wait() our_jid = JID(self.jid.bare) our_jid.resource = self.own_nick msg: SMessage = self.core.xmpp.make_message( @@ -239,7 +241,7 @@ class PrivateTab(OneToOneTab): if not msg['body']: return self.set_last_sent_message(msg, correct=correct) - self.core.handler.on_groupchat_private_message(msg, sent=True) + await self.core.handler.on_groupchat_private_message(msg, sent=True) # Our receipts slixmpp hack msg._add_receipt = True # type: ignore msg.send() @@ -358,9 +360,6 @@ class PrivateTab(OneToOneTab): 1, self.width, self.height - 2 - self.core.information_win_size - Tab.tab_win_height(), 0) - def get_text_window(self): - return self.text_win - @refresh_wrapper.conditional def rename_user(self, old_nick, user): """ diff --git a/poezio/tabs/rostertab.py b/poezio/tabs/rostertab.py index 66aff2b1..18334c20 100644 --- a/poezio/tabs/rostertab.py +++ b/poezio/tabs/rostertab.py @@ -14,7 +14,7 @@ import ssl from functools import partial from os import getenv, path from pathlib import Path -from typing import Dict, Callable +from typing import Dict, Callable, Union from slixmpp import JID, InvalidJID from slixmpp.exceptions import IqError, IqTimeout @@ -199,7 +199,7 @@ class RosterInfoTab(Tab): completion=self.completion_cert_fetch) @property - def selected_row(self): + def selected_row(self) -> Union[Contact, Resource]: return self.roster_win.get_selected_row() @command_args_parser.ignored diff --git a/poezio/tabs/xmltab.py b/poezio/tabs/xmltab.py index 9501c6d3..939af67d 100644 --- a/poezio/tabs/xmltab.py +++ b/poezio/tabs/xmltab.py @@ -10,7 +10,6 @@ log = logging.getLogger(__name__) import curses import os -from typing import Union, Optional from slixmpp import JID, InvalidJID from slixmpp.xmlstream import matcher, StanzaBase from slixmpp.xmlstream.tostring import tostring diff --git a/poezio/theming.py b/poezio/theming.py index 7a13bb6d..187d07c5 100755 --- a/poezio/theming.py +++ b/poezio/theming.py @@ -1,9 +1,10 @@ +#!/usr/bin/env python3 # Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> # # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Define the variables (colors and some other stuff) that are used when drawing the interface. @@ -73,7 +74,6 @@ except ImportError: import curses import functools -import os from typing import Dict, List, Union, Tuple, Optional, cast from pathlib import Path from os import path @@ -234,6 +234,15 @@ class Theme: COLOR_TAB_ATTENTION = (7, 1) COLOR_TAB_DISCONNECTED = (7, 8) + # If autocolor_tab_names is set to true, the following modes are used to + # distinguish tabs with normal and important messages. + MODE_TAB_NORMAL = '' + MODE_TAB_IMPORTANT = 'r' # reverse video mode + + # This is the mode used for the tab name in the info bar of MUC and 1:1 + # chat tabs. + MODE_TAB_NAME = 'r' + COLOR_VERTICAL_TAB_NORMAL = (4, -1) COLOR_VERTICAL_TAB_NONEMPTY = (4, -1) COLOR_VERTICAL_TAB_JOINED = (82, -1) @@ -383,7 +392,7 @@ class Theme: } @property - def ccg_palette(self): + def ccg_palette(self) -> Optional[Dict[float, int]]: prepare_ccolor_palette(self) return self.CCG_PALETTE @@ -488,6 +497,8 @@ def to_curses_attr( curses, 'A_ITALIC') else curses.A_REVERSE) if 'a' in additional_val: curses_pair = curses_pair | curses.A_BLINK + if 'r' in additional_val: + curses_pair = curses_pair | curses.A_REVERSE return curses_pair diff --git a/poezio/timed_events.py b/poezio/timed_events.py index 3354443a..314ed76c 100644 --- a/poezio/timed_events.py +++ b/poezio/timed_events.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Timed events are the standard way to schedule events for later in poezio. diff --git a/poezio/ui/consts.py b/poezio/ui/consts.py index 51febf22..91f19a82 100644 --- a/poezio/ui/consts.py +++ b/poezio/ui/consts.py @@ -1,5 +1,3 @@ -from datetime import datetime - FORMAT_CHAR = '\x19' # These are non-printable chars, so they should never appear in the input, # I guess. But maybe we can find better chars that are even less risky. diff --git a/poezio/ui/render.py b/poezio/ui/render.py index 2256dc14..aad482b5 100644 --- a/poezio/ui/render.py +++ b/poezio/ui/render.py @@ -265,14 +265,14 @@ class PreMessageHelpers: """ Write the date on the yth line of the window """ - theme = get_theme() if time: + theme = get_theme() if history and time.date() != date.today(): format = theme.LONG_TIME_FORMAT else: format = theme.SHORT_TIME_FORMAT time_str = time.strftime(format) - color = get_theme().COLOR_TIME_STRING + color = theme.COLOR_TIME_STRING with buffer.colored_text(color=color): buffer.addstr(time_str) buffer.addstr(' ') diff --git a/poezio/user.py b/poezio/user.py index 858c6d0e..602ee2c8 100644 --- a/poezio/user.py +++ b/poezio/user.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Define the user class. A user is a MUC participant, not a roster contact (see contact.py) @@ -12,7 +12,6 @@ A user is a MUC participant, not a roster contact (see contact.py) import logging from datetime import timedelta, datetime from hashlib import md5 -from random import choice from typing import Optional, Tuple from poezio import xhtml, colors diff --git a/poezio/utils.py b/poezio/utils.py new file mode 100644 index 00000000..124d2002 --- /dev/null +++ b/poezio/utils.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" + Utilities +""" + +from random import choice + +VOWELS = 'aiueo' +CONSONANTS = 'bcdfghjklmnpqrstvwxz' + + +def pronounceable(length: int = 6) -> str: + """Generates a pronounceable name""" + out = '' + vowels = choice((True, False)) + for _ in range(0, length): + out += choice(VOWELS if vowels else CONSONANTS) + vowels = not vowels + return out diff --git a/poezio/version.py b/poezio/version.py index 6defd704..2397b102 100644 --- a/poezio/version.py +++ b/poezio/version.py @@ -1,2 +1,2 @@ -__version__ = '0.13.1' -__version_info__ = (0, 13, 1) +__version__ = '0.14' +__version_info__ = (0, 14, 0) diff --git a/poezio/windows/bookmark_forms.py b/poezio/windows/bookmark_forms.py index 4ec1259e..a0e57cc7 100644 --- a/poezio/windows/bookmark_forms.py +++ b/poezio/windows/bookmark_forms.py @@ -38,7 +38,7 @@ class BookmarkJIDInput(FieldInput, Input): jid = JID(field.jid) except InvalidJID: jid = JID('') - jid.resource = field.nick or '' + jid.resource = field.nick or None self.text = jid.full self.pos = 0 self.view_pos = 0 diff --git a/poezio/windows/data_forms.py b/poezio/windows/data_forms.py index 1b22aa2c..db174703 100644 --- a/poezio/windows/data_forms.py +++ b/poezio/windows/data_forms.py @@ -6,6 +6,7 @@ does not inherit from the Win base class), as it will create the others when needed. """ +from typing import Type from poezio.windows import base_wins from poezio.windows.base_wins import Win from poezio.windows.inputs import Input @@ -397,10 +398,10 @@ class FormWin: for (name, field) in self._form.getFields().items(): if field['type'] == 'hidden': continue - try: + if field['type'] not in self.input_classes: + input_class: Type[FieldInputMixin] = TextSingleWin + else: input_class = self.input_classes[field['type']] - except IndexError: - continue label = field['label'] desc = field['desc'] if field['type'] == 'fixed': diff --git a/poezio/windows/image.py b/poezio/windows/image.py index b721b859..2862d2d9 100644 --- a/poezio/windows/image.py +++ b/poezio/windows/image.py @@ -2,6 +2,8 @@ Defines a window which contains either an image or a border. """ +from __future__ import annotations + import curses from io import BytesIO @@ -9,9 +11,6 @@ try: from PIL import Image HAS_PIL = True except ImportError: - class Image: # type: ignore - class Image: - pass HAS_PIL = False try: diff --git a/poezio/windows/info_bar.py b/poezio/windows/info_bar.py index 67b68888..6e6c3bbd 100644 --- a/poezio/windows/info_bar.py +++ b/poezio/windows/info_bar.py @@ -15,6 +15,7 @@ from poezio.config import config from poezio.windows.base_wins import Win from poezio.theming import get_theme, to_curses_attr from poezio.common import unique_prefix_of +from poezio.colors import ccg_text_to_color log = logging.getLogger(__name__) @@ -38,6 +39,7 @@ class GlobalInfoBar(Win): use_nicks = config.getbool('use_tab_nicks') show_inactive = config.getbool('show_inactive_tabs') unique_prefix_tab_names = config.getbool('unique_prefix_tab_names') + autocolor_tab_names = config.getbool('autocolor_tab_names') if unique_prefix_tab_names: unique_prefixes: List[Optional[str]] = [None] * len(self.core.tabs) @@ -73,6 +75,24 @@ class GlobalInfoBar(Win): if not show_inactive and color is theme.COLOR_TAB_NORMAL and ( tab.priority < 0): continue + if autocolor_tab_names: + # TODO: in case of private MUC conversations, we should try to + # get hold of more information to make the colour the same as + # the nickname colour in the MUC. + fgcolor, bgcolor, *flags = color + # this is fugly, but I’m not sure how to improve it... since + # apparently the state is only kept in the color -.- + if (color == theme.COLOR_TAB_HIGHLIGHT or + color == theme.COLOR_TAB_PRIVATE): + fgcolor = ccg_text_to_color(theme.ccg_palette, tab.name) + bgcolor = -1 + flags = theme.MODE_TAB_IMPORTANT + elif color == theme.COLOR_TAB_NEW_MESSAGE: + fgcolor = ccg_text_to_color(theme.ccg_palette, tab.name) + bgcolor = -1 + flags = theme.MODE_TAB_NORMAL + + color = (fgcolor, bgcolor) + tuple(flags) try: if show_nums or not show_names: self.addstr("%s" % str(nb), to_curses_attr(color)) diff --git a/poezio/windows/info_wins.py b/poezio/windows/info_wins.py index 23d28cc1..227dc115 100644 --- a/poezio/windows/info_wins.py +++ b/poezio/windows/info_wins.py @@ -16,6 +16,7 @@ from poezio.config import config from poezio.windows.base_wins import Win from poezio.ui.funcs import truncate_nick from poezio.theming import get_theme, to_curses_attr +from poezio.colors import ccg_text_to_color if TYPE_CHECKING: from poezio.user import User @@ -103,6 +104,9 @@ class PrivateInfoWin(InfoWin): to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) def write_room_name(self, name): + # TODO: autocolour this too, but we need more info about the occupant + # (whether we know its real jid) and the room (whether it is + # anonymous) to provide correct colouring. try: jid = JID(name) except InvalidJID: @@ -223,13 +227,23 @@ class ConversationInfoWin(InfoWin): """ Write the information about the contact """ - color = to_curses_attr(get_theme().COLOR_INFORMATION_BAR) + theme = get_theme() + color = to_curses_attr(theme.COLOR_INFORMATION_BAR) + if config.get('autocolor_tab_names') and contact is not None: + name_color = ( + ccg_text_to_color(theme.ccg_palette, str(contact.bare_jid)), + -1, + theme.MODE_TAB_NAME, + ) + else: + name_color = color + if not contact: self.addstr("(contact not in roster)", color) return display_name = contact.name if display_name: - self.addstr('%s ' % (display_name), color) + self.addstr('%s ' % (display_name), name_color) def write_contact_jid(self, jid): """ @@ -237,9 +251,17 @@ class ConversationInfoWin(InfoWin): """ theme = get_theme() color = to_curses_attr(theme.COLOR_INFORMATION_BAR) + if config.get('autocolor_tab_names'): + name_color = ( + ccg_text_to_color(theme.ccg_palette, str(contact.jid)), + -1, + theme.MODE_TAB_NAME, + ) + else: + name_color = theme.COLOR_CONVERSATION_NAME + self.addstr('[', color) - self.addstr(jid.full, - to_curses_attr(theme.COLOR_CONVERSATION_NAME)) + self.addstr(jid.full, to_curses_attr(name_color)) self.addstr('] ', color) def write_chatstate(self, state): @@ -313,9 +335,17 @@ class MucInfoWin(InfoWin): def write_room_name(self, room): theme = get_theme() color = to_curses_attr(theme.COLOR_INFORMATION_BAR) + label_color = theme.COLOR_GROUPCHAT_NAME + + if config.get('autocolor_tab_names'): + label_color = ccg_text_to_color( + theme.ccg_palette, + room.jid.bare, + ), -1, theme.MODE_TAB_NAME + self.addstr('[', color) self.addstr(room.name, - to_curses_attr(theme.COLOR_GROUPCHAT_NAME)) + to_curses_attr(label_color)) self.addstr(']', color) def write_participants_number(self, room): diff --git a/poezio/windows/inputs.py b/poezio/windows/inputs.py index 7ee8aa45..01b94ac0 100644 --- a/poezio/windows/inputs.py +++ b/poezio/windows/inputs.py @@ -5,7 +5,7 @@ Text inputs. import curses import logging import string -from typing import List, Dict, Callable, Optional +from typing import List, Dict, Callable, Optional, ClassVar from poezio import keyboard from poezio import common @@ -592,9 +592,10 @@ class HistoryInput(Input): An input with colors and stuff, plus an history ^R allows to search inside the history (as in a shell) """ - __slots__ = ('help_message', 'histo_pos', 'current_completed', 'search') + __slots__ = ('help_message', 'histo_pos', 'current_completed', 'search', + 'history') - history: List[str] = [] + global_history: ClassVar[List[str]] = [] def __init__(self) -> None: Input.__init__(self) @@ -604,8 +605,9 @@ class HistoryInput(Input): self.key_func['^R'] = self.toggle_search self.search = False if config.getbool('separate_history'): - # pylint: disable=assigning-non-slot self.history: List[str] = [] + else: + self.history = self.__class__.global_history def toggle_search(self) -> None: if self.help_message: @@ -682,7 +684,7 @@ class MessageInput(HistoryInput): Also letting the user enter colors or other text markups """ # The history is common to all MessageInput - history: List[str] = [] + global_history: ClassVar[List[str]] = [] def __init__(self) -> None: HistoryInput.__init__(self) @@ -728,7 +730,7 @@ class CommandInput(HistoryInput): HelpMessage when a command is started The on_input callback """ - history: List[str] = [] + global_history: ClassVar[List[str]] = [] def __init__(self, help_message: str, on_abort, on_success, on_input=None) -> None: HistoryInput.__init__(self) diff --git a/poezio/windows/roster_win.py b/poezio/windows/roster_win.py index b112689e..dfdc9b9b 100644 --- a/poezio/windows/roster_win.py +++ b/poezio/windows/roster_win.py @@ -6,11 +6,10 @@ import logging log = logging.getLogger(__name__) from datetime import datetime -from typing import Optional, List, Union, Dict +from typing import Optional, List, Union from poezio.windows.base_wins import Win -from poezio import common from poezio.config import config from poezio.contact import Contact, Resource from poezio.roster import Roster, RosterGroup @@ -279,7 +278,7 @@ class RosterWin(Win): elif contact.name and contact.name != contact.bare_jid: display_name = '%s (%s)' % (contact.name, contact.bare_jid) else: - display_name = contact.bare_jid + display_name = str(contact.bare_jid) display_name = self.truncate_name(display_name, added) + nb diff --git a/poezio/xdg.py b/poezio/xdg.py index d4ce0538..d7ff9d73 100644 --- a/poezio/xdg.py +++ b/poezio/xdg.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Implements the XDG base directory specification. diff --git a/poezio/xhtml.py b/poezio/xhtml.py index 29df520d..2875f1a1 100644 --- a/poezio/xhtml.py +++ b/poezio/xhtml.py @@ -3,7 +3,7 @@ # This file is part of Poezio. # # Poezio is free software: you can redistribute it and/or modify -# it under the terms of the zlib license. See the COPYING file. +# it under the terms of the GPL-3.0+ license. See the COPYING file. """ Various methods to convert shell colors to poezio colors, @@ -431,7 +431,7 @@ class XHTMLHandler(ContentHandler): if 'href' in attrs and attrs['href'] != link_text: builder.append(' (%s)' % _trim(attrs['href'])) elif name == 'blockquote': - builder.append('”') + builder.append('”\n') elif name in ('cite', 'em', 'strong'): self.pop_formatting() elif name in ('ol', 'p', 'ul'): @@ -125,8 +125,8 @@ setup( long_description=LONG_DESCRIPTION, ext_modules=[module_poopt], url='https://poez.io/', - license='zlib', - download_url='https://poez.io/#download', + license='GPL-3.0-or-later', + download_url='https://dev.louiz.org/projects/poezio/files', author='Florent Le Coz', author_email='louiz@louiz.org', @@ -139,7 +139,7 @@ setup( 'Topic :: Internet :: XMPP', 'Environment :: Console :: Curses', 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: zlib/libpng License', + 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 'Natural Language :: English', 'Operating System :: Unix', 'Programming Language :: Python :: 3.9', @@ -152,7 +152,7 @@ setup( package_dir={'poezio': 'poezio', 'poezio_plugins': 'plugins', 'poezio_themes': 'data/themes'}, - package_data={'poezio': ['default_config.cfg']}, + package_data={'poezio': ['default_config.cfg', 'py.typed']}, scripts=['scripts/poezio_logs'], entry_points={'console_scripts': ['poezio = poezio.__main__:run']}, data_files=([ diff --git a/tools/sticker-picker/Cargo.toml b/tools/sticker-picker/Cargo.toml new file mode 100644 index 00000000..476ceea6 --- /dev/null +++ b/tools/sticker-picker/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "poezio-sticker-picker" +version = "0.1.0" +edition = "2021" +authors = ["Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>"] +license = "GPL-3.0-or-later" +description = "Helper tool for selecting a sticker inside a pack" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +gtk = { package = "gtk4", version = "0.4", features = ["v4_6"] } +gdk = { package = "gdk4", version = "0.4", features = ["v4_6"] } +glib = "0.15" +gio = "0.15" +once_cell = "1.9.0" diff --git a/tools/sticker-picker/src/main.rs b/tools/sticker-picker/src/main.rs new file mode 100644 index 00000000..429043e3 --- /dev/null +++ b/tools/sticker-picker/src/main.rs @@ -0,0 +1,93 @@ +// This file is part of Poezio. +// +// Poezio is free software: you can redistribute it and/or modify +// it under the terms of the GPL-3.0-or-later license. See the COPYING file. + +mod sticker; + +use gtk::prelude::*; +use sticker::StickerType as Sticker; + +fn main() { + let app = gtk::Application::builder() + .application_id("io.poez.StickerPicker") + .flags(gio::ApplicationFlags::HANDLES_OPEN) + .build(); + + let quit = gio::SimpleAction::new("quit", None); + app.set_accels_for_action("app.quit", &["<Control>q"]); + app.add_action(&quit); + quit.connect_activate(glib::clone!(@weak app => move |_, _| app.quit())); + + app.connect_open(move |app, directories, _| { + let path = match directories { + [directory] => directory.path().unwrap(), + _ => { + eprintln!("Only a single directory is allowed!"); + std::process::exit(1); + } + }; + + let window = gtk::ApplicationWindow::builder() + .application(app) + .default_width(1280) + .default_height(720) + .title("Poezio Sticker Picker") + .build(); + + let sw = gtk::ScrolledWindow::builder() + .has_frame(true) + .hscrollbar_policy(gtk::PolicyType::Always) + .vscrollbar_policy(gtk::PolicyType::Always) + .vexpand(true) + .build(); + window.set_child(Some(&sw)); + + let store = gio::ListStore::new(Sticker::static_type()); + + for dir_entry in std::fs::read_dir(path).unwrap() { + let dir_entry = dir_entry.unwrap(); + let file_name = dir_entry.file_name().into_string().unwrap(); + let sticker = Sticker::new(file_name, &dir_entry.path()); + store.append(&sticker); + } + + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let picture = gtk::Picture::builder() + .alternative_text("Sticker") + .can_shrink(false) + .build(); + item.set_child(Some(&picture)); + }); + factory.connect_bind(|_, list_item| { + if let Some(child) = list_item.child() { + if let Some(item) = list_item.item() { + let picture: gtk::Picture = child.downcast().unwrap(); + let sticker: Sticker = item.downcast().unwrap(); + picture.set_paintable(sticker.texture().as_ref()); + } + } + }); + + let selection = gtk::SingleSelection::new(Some(&store)); + let grid_view = gtk::GridView::builder() + .single_click_activate(true) + .model(&selection) + .factory(&factory) + .build(); + grid_view.connect_activate(move |_, position| { + let item = store.item(position).unwrap(); + let sticker: Sticker = item.downcast().unwrap(); + if let Some(filename) = sticker.filename() { + println!("{}", filename); + std::process::exit(0); + } + }); + sw.set_child(Some(&grid_view)); + + window.show(); + }); + + app.run(); +} diff --git a/tools/sticker-picker/src/sticker.rs b/tools/sticker-picker/src/sticker.rs new file mode 100644 index 00000000..309b21fa --- /dev/null +++ b/tools/sticker-picker/src/sticker.rs @@ -0,0 +1,106 @@ +// This file is part of Poezio. +// +// Poezio is free software: you can redistribute it and/or modify +// it under the terms of the GPL-3.0-or-later license. See the COPYING file. + +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use std::cell::RefCell; +use std::path::Path; + +#[derive(Debug, Default)] +pub struct Sticker { + filename: RefCell<Option<String>>, + texture: RefCell<Option<gdk::Texture>>, +} + +#[glib::object_subclass] +impl ObjectSubclass for Sticker { + const NAME: &'static str = "Sticker"; + type Type = StickerType; +} + +impl ObjectImpl for Sticker { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { + vec![ + glib::ParamSpecString::new( + "filename", + "Filename", + "Filename", + None, + glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, + ), + glib::ParamSpecObject::new( + "texture", + "Texture", + "Texture", + gdk::Texture::static_type(), + glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, + ), + ] + }); + PROPERTIES.as_ref() + } + + fn set_property( + &self, + _obj: &StickerType, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "filename" => { + let filename = value.get().unwrap(); + self.filename.replace(filename); + } + "texture" => { + let texture = value.get().unwrap(); + self.texture.replace(texture); + } + _ => unimplemented!(), + } + } + + fn property(&self, _obj: &StickerType, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "filename" => self.filename.borrow().to_value(), + "texture" => self.texture.borrow().to_value(), + _ => unimplemented!(), + } + } +} + +glib::wrapper! { + pub struct StickerType(ObjectSubclass<Sticker>); +} + +impl StickerType { + pub fn new(filename: String, path: &Path) -> StickerType { + let texture = gdk::Texture::from_filename(path).unwrap(); + glib::Object::new(&[("filename", &filename), ("texture", &texture)]) + .expect("Failed to create Sticker") + } + + pub fn filename(&self) -> Option<String> { + let imp = self.imp(); + let filename = imp.filename.borrow(); + if let Some(filename) = filename.as_ref() { + Some(filename.clone()) + } else { + None + } + } + + pub fn texture(&self) -> Option<gdk::Texture> { + let imp = self.imp(); + let texture = imp.texture.borrow(); + if let Some(texture) = texture.as_ref() { + Some(texture.clone()) + } else { + None + } + } +} |