summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab-ci.yml62
-rw-r--r--.travis.yml2
-rw-r--r--CHANGELOG251
-rw-r--r--COPYING684
-rw-r--r--MANIFEST.in1
-rw-r--r--README.rst92
-rw-r--r--data/default_config.cfg63
-rw-r--r--data/doap.xml115
-rw-r--r--data/io.poez.Poezio.appdata.xml55
-rw-r--r--data/poezio.144
-rw-r--r--data/poezio_logo.svg2
-rw-r--r--data/poezio_logs.12
-rw-r--r--data/scripts-manpages.xml2
-rw-r--r--data/themes/clean.py193
-rw-r--r--doc/source/commands.rst82
-rw-r--r--doc/source/conf.py16
-rw-r--r--doc/source/configuration.rst145
-rw-r--r--doc/source/dev/contributing.rst6
-rw-r--r--doc/source/dev/plugin.rst11
-rw-r--r--doc/source/install.rst17
-rw-r--r--doc/source/keys.rst9
-rw-r--r--doc/source/plugins/index.rst14
-rw-r--r--doc/source/plugins/sticker.rst6
-rw-r--r--doc/source/plugins/user_extras.rst6
-rw-r--r--doc/source/themes.rst12
-rw-r--r--doc/source/usage.rst26
-rwxr-xr-xlaunch.sh16
-rw-r--r--plugins/admin.py8
-rw-r--r--plugins/amsg.py4
-rw-r--r--plugins/b64.py25
-rw-r--r--plugins/bob.py4
-rw-r--r--plugins/code.py6
-rw-r--r--plugins/contact.py56
-rw-r--r--plugins/day_change.py9
-rw-r--r--plugins/dice.py37
-rw-r--r--plugins/disco.py55
-rw-r--r--plugins/display_corrections.py10
-rw-r--r--plugins/embed.py28
-rw-r--r--plugins/emoji_ascii.py6
-rw-r--r--plugins/exec.py2
-rw-r--r--plugins/irc.py244
-rw-r--r--plugins/lastlog.py20
-rw-r--r--plugins/link.py15
-rw-r--r--plugins/marquee.py11
-rw-r--r--plugins/otr.py131
-rw-r--r--plugins/ping.py41
-rwxr-xr-xplugins/qr.py14
-rw-r--r--plugins/quote.py16
-rw-r--r--plugins/rainbow.py2
-rw-r--r--plugins/remove_get_trackers.py2
-rw-r--r--plugins/reorder.py17
-rw-r--r--plugins/screen_detach.py4
-rw-r--r--plugins/send_delayed.py3
-rw-r--r--plugins/server_part.py12
-rw-r--r--plugins/simple_notify.py3
-rw-r--r--plugins/sticker.py97
-rw-r--r--plugins/tell.py3
-rw-r--r--plugins/time_marker.py4
-rw-r--r--plugins/untrackme.py140
-rw-r--r--plugins/upload.py40
-rw-r--r--plugins/uptime.py30
-rw-r--r--plugins/user_extras.py634
-rw-r--r--plugins/vcard.py38
-rw-r--r--poezio/args.py45
-rw-r--r--poezio/asyncio_fix.py (renamed from poezio/asyncio.py)0
-rw-r--r--poezio/bookmarks.py124
-rw-r--r--poezio/colors.py11
-rw-r--r--poezio/common.py141
-rw-r--r--poezio/config.py289
-rw-r--r--poezio/connection.py93
-rw-r--r--poezio/contact.py47
-rw-r--r--poezio/core/command_defs.py452
-rw-r--r--poezio/core/commands.py655
-rw-r--r--poezio/core/completions.py89
-rw-r--r--poezio/core/core.py1010
-rw-r--r--poezio/core/handlers.py845
-rw-r--r--poezio/core/structs.py81
-rw-r--r--poezio/core/tabs.py91
-rwxr-xr-xpoezio/daemon.py2
-rw-r--r--poezio/decorators.py161
-rw-r--r--poezio/events.py28
-rw-r--r--poezio/fixes.py35
-rwxr-xr-xpoezio/keyboard.py6
-rw-r--r--poezio/log_loader.py395
-rw-r--r--poezio/logger.py312
-rw-r--r--poezio/mam.py305
-rw-r--r--poezio/multiuserchat.py233
-rw-r--r--poezio/pep.py207
-rw-r--r--poezio/plugin.py17
-rw-r--r--poezio/plugin_e2ee.py287
-rw-r--r--poezio/plugin_manager.py52
-rw-r--r--poezio/poezio.py31
-rw-r--r--poezio/poezio_shlex.pyi45
-rw-r--r--poezio/poopt.py185
-rw-r--r--poezio/poopt.pyi7
-rw-r--r--poezio/pooptmodule.c2
-rw-r--r--poezio/py.typed0
-rw-r--r--poezio/roster.py60
-rw-r--r--poezio/size_manager.py12
-rw-r--r--poezio/tabs/adhoc_commands_list.py4
-rw-r--r--poezio/tabs/basetabs.py351
-rw-r--r--poezio/tabs/bookmarkstab.py46
-rw-r--r--poezio/tabs/confirmtab.py6
-rw-r--r--poezio/tabs/conversationtab.py205
-rw-r--r--poezio/tabs/data_forms.py4
-rw-r--r--poezio/tabs/listtab.py6
-rw-r--r--poezio/tabs/muclisttab.py7
-rw-r--r--poezio/tabs/muctab.py1294
-rw-r--r--poezio/tabs/privatetab.py244
-rw-r--r--poezio/tabs/rostertab.py459
-rw-r--r--poezio/tabs/xmltab.py67
-rw-r--r--poezio/text_buffer.py339
-rwxr-xr-xpoezio/theming.py63
-rw-r--r--poezio/timed_events.py10
-rw-r--r--poezio/types.py8
-rw-r--r--poezio/ui/__init__.py0
-rw-r--r--poezio/ui/consts.py4
-rw-r--r--poezio/ui/funcs.py (renamed from poezio/windows/funcs.py)14
-rw-r--r--poezio/ui/render.py280
-rw-r--r--poezio/ui/types.py260
-rw-r--r--poezio/user.py34
-rw-r--r--poezio/utils.py21
-rw-r--r--poezio/version.py3
-rw-r--r--poezio/windows/__init__.py4
-rw-r--r--poezio/windows/base_wins.py60
-rw-r--r--poezio/windows/bookmark_forms.py49
-rw-r--r--poezio/windows/data_forms.py12
-rw-r--r--poezio/windows/image.py11
-rw-r--r--poezio/windows/info_bar.py86
-rw-r--r--poezio/windows/info_wins.py84
-rw-r--r--poezio/windows/input_placeholders.py2
-rw-r--r--poezio/windows/inputs.py51
-rw-r--r--poezio/windows/list.py8
-rw-r--r--poezio/windows/misc.py8
-rw-r--r--poezio/windows/muc.py6
-rw-r--r--poezio/windows/roster_win.py71
-rw-r--r--poezio/windows/text_win.py599
-rw-r--r--poezio/xdg.py6
-rw-r--r--poezio/xhtml.py31
-rw-r--r--requirements-plugins.txt4
-rw-r--r--requirements.txt1
-rwxr-xr-xscripts/poezio_logs53
-rwxr-xr-xsetup.py147
-rw-r--r--test/test_common.py23
-rw-r--r--test/test_completion.py10
-rw-r--r--test/test_config.py16
-rw-r--r--test/test_logger.py90
-rw-r--r--test/test_tabs.py50
-rw-r--r--test/test_text_buffer.py200
-rw-r--r--test/test_ui/test_funcs.py46
-rw-r--r--test/test_ui/test_render.py144
-rw-r--r--test/test_ui/test_types.py126
-rw-r--r--test/test_user.py44
-rw-r--r--test/test_windows.py20
-rw-r--r--test/test_xhtml.py6
-rw-r--r--tools/sticker-picker/Cargo.toml16
-rw-r--r--tools/sticker-picker/src/main.rs93
-rw-r--r--tools/sticker-picker/src/sticker.rs106
-rwxr-xr-xupdate.sh8
159 files changed, 10334 insertions, 5746 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e029c83d..e8bd5415 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -6,10 +6,10 @@ stages:
build-doc:
stage: build
only:
- - branches@poezio/poezio
+ - main@poezio/poezio
tags:
- www
- image: docker.louiz.org/poezio/poezio/doc-builder
+ image: python:3
script:
- ./update.sh
- . ./poezio-venv/bin/activate
@@ -29,7 +29,7 @@ build-ubuntu:
paths:
- dist/
only:
- - master
+ - main
security-check:
stage: lint
@@ -38,13 +38,12 @@ security-check:
- pip3 install safety
- safety check -r requirements.txt
-pytest-3.5:
+pytest-3.7:
stage: test
- image: python:3.5
+ image: python:3.7
script:
- apt-get update && apt-get install -y libidn11-dev
- - pip install 'aiohttp<4.0'
- - git clone git://git.louiz.org/slixmpp
+ - git clone https://lab.louiz.org/poezio/slixmpp.git
- pip3 install pytest pyasn1-modules cffi --upgrade
- cd slixmpp
- python3 setup.py install
@@ -52,12 +51,12 @@ pytest-3.5:
- python3 setup.py install
- py.test -v test/
-pytest-3.6:
+pytest-3.8:
stage: test
- image: python:3.6
+ image: python:3.8
script:
- apt-get update && apt-get install -y libidn11-dev
- - git clone git://git.louiz.org/slixmpp
+ - git clone https://lab.louiz.org/poezio/slixmpp.git
- pip3 install pytest pyasn1-modules cffi --upgrade
- cd slixmpp
- python3 setup.py install
@@ -65,12 +64,12 @@ pytest-3.6:
- python3 setup.py install
- py.test -v test/
-pytest-3.7:
+pytest-3.9:
stage: test
- image: python:3.7
+ image: python:3.9
script:
- apt-get update && apt-get install -y libidn11-dev
- - git clone git://git.louiz.org/slixmpp
+ - git clone https://lab.louiz.org/poezio/slixmpp.git
- pip3 install pytest pyasn1-modules cffi --upgrade
- cd slixmpp
- python3 setup.py install
@@ -78,13 +77,12 @@ pytest-3.7:
- python3 setup.py install
- py.test -v test/
-
-pytest-latest:
+pytest-3.10:
stage: test
- image: python:3
+ image: python:3.10
script:
- apt-get update && apt-get install -y libidn11-dev
- - git clone git://git.louiz.org/slixmpp
+ - git clone https://lab.louiz.org/poezio/slixmpp.git
- pip3 install pytest pyasn1-modules cffi --upgrade
- cd slixmpp
- python3 setup.py install
@@ -92,18 +90,19 @@ pytest-latest:
- 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 git://git.louiz.org/slixmpp
- - pip3 install pylint pyasn1-modules cffi --upgrade
+ - git clone https://lab.louiz.org/poezio/slixmpp.git
+ - 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
@@ -112,15 +111,24 @@ pylint-plugins:
script:
- apt-get update && apt-get install -y libidn11-dev
- pip3 install pylint pyasn1-modules cffi --upgrade
- - pip3 install -e git+git://git.louiz.org/slixmpp#egg=slixmpp
+ - pip3 install -e git+https://lab.louiz.org/poezio/slixmpp.git#egg=slixmpp
- pip3 install -r requirements-plugins.txt
- python3 setup.py install
- pylint -E plugins
-mypyc:
+mypy-fixed:
+ stage: lint
+ image: python:3
+ script:
+ - 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
- - mypyc --ignore-missing-imports ./poezio
+ - pip3 install mypy types-setuptools
+ - mypy --ignore-missing-imports ./poezio
+ - mypy --ignore-missing-imports ./plugins
diff --git a/.travis.yml b/.travis.yml
index 923df969..3877ad41 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,7 +1,5 @@
language: python
python:
- - "3.5"
- - "3.6"
- "3.7"
- "3.8-dev"
install:
diff --git a/CHANGELOG b/CHANGELOG
index 1923306f..9480db1b 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,8 +1,253 @@
This file describes the new features in each poezio release.
-For more detailed changelog, see the roadmap:
-https://dev.louiz.org/projects/poezio/roadmap
-* Poezio 0.13 - dev
+* 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.
+- 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.
+
+# Under the hood
+
+- 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
+
+# Bug fixes
+
+- Contacts won’t be seen playing games or music when they actually stop doing
+ so.
+- /leave now toggles off the autojoin flag instead of removing the bookmark.
+- Only add auotjoin on new bookmarks for synchronise_open_rooms on /join
+- /affiliation displays things in the info win instead of directly in the room,
+ and additionally displays which room it refers to.
+- List the correct required versions for package maintainers.
+- Fix the AppStream manifest to get Flathub to accept it.
+- Add a warning when the terminal doesn’t support 256color mode.
+- Display our own nick properly in messages received from MAM.
+- Only send an unavailable presence on closing a room if we are joined.
+- Don’t display the current date for history messages received today.
+- Fix marquee and dice plugin to use newer Last Message Correction format.
+- Bookmarks tab properly displays bookmarks method (local/remote) and allows
+ to switch between them again.
+- Updated manpages and added manpages built from the doc in the setup process.
+ Packagers need to run setup.py build_man to have them built.
+
+# Forgotten additions
+
+- Add a clean theme for light terminals, thanks Armael!
+
+* Poezio 0.13
+
+# Thanks
+
+- madhur for the MAM code and various other fixes (GSoC 2019)
+- Ge0rG for fixes in the reconnect and carbons code
+- fiaxh for the nice SVG logo
+
+Zash, PS Le Stang, Karthikeyan Singaravelan, Jonas Schäfer, Célestin Matte,
+Andrey Utkin, root.
+
+# Meta changes
+
+- Packages: poezio is now packaged for Gentoo and Debian buster
+ (poezio v0.12.1)
+- Source: the repository is now hosted at https://lab.louiz.org/poezio/poezio
+
+# Breaking changes
+
+- BREAKING: requires python3.7 (previously python 3.5)
+- DEPRECATION: `load_log` configuration was removed
+- DEPRECATION: `bookmark_on_join` configuration option was renamed
+ `synchronise_open_rooms`, and now defaults to true. This makes /join
+ automatically create a bookmark, use /leave to remove it
+
+# New features and plugins
+
+- `/impromptu <jid> [jid ..]` command to create a new chat with these persons
+- `/scrollback` to scroll back to a specific line/message in the current window
+- `/invite <jid> [jid ..]` command in single user chat does like `/impromptu`
+ creating a new MUC
+- `/affiliation` command now returns the list of privileged users on the
+ current MUC when no argument is supplied
+- `default_muc_service` configuration for replacing the server's default MUC
+- `unique_prefix_tab_names` display option to show the shorter tab name
+ prefixes in the tab list
+- `/list` defaults to `default_muc_service` when no argument is specified
+- XEP-0392 support
+- New Plugin: OMEMO (experimental) - lives as an external plugin at
+ https://lab.louiz.org/poezio/poezio-omemo
+- New Plugin: Contact - queries an entity for contact addresses (XEP-0157)
+- New Plugin: Upload - adds a `/upload <filename>` command in chats for HTTP
+ upload
+- New Plugin: remove_get_trackers - Remove GET trackers from URLs in sent
+ messages.
+- New Plugin: QR
+
+
+# Under the hood
+
+- Plugin API: `E2EEPlugin` is a new experimental API for message encryption
+ (used by the OMEMO plugin)
+- Plugin API: plugins can now declare a string list of dependencies
+- Plugin API: plugins can now be setup using a well-known setuptools entrypoint
+- Reworked Tab handling
+- Split Message rendering
+- Typing improvements
+
+# Minor changes (bugfixes)
+
+- Properly advertize gaming status
+- Improve error reporting to users
+- Add SVG support for avatars
+- Plugin: Disco - allow node to be specified
+- Don't always treat carbons from biboumi as MUC-PMs (#3705)
+- Read newer Last Message Correction rules while still reading older ones
+ (#3462)
+- Allow /block and /unblock in ConversationTab (#3346)
+- Experimental: Fetch archives (MAM) on scroll up in MUC (#3052)
+- Allow /add in ConversationTab (#3395)
+- Prevent Chat State Notifications from being stored in the archive (#3518)
+- Rework MUC-PM Carbons handling (#3294)
+- /scrollback (#3481)
+- Read <delay/> in <subject/> (#3451)
+- Only use MUC <subject/> in specific cases (#3452)
+- Allow /reconnect in all tabs (#3471)
+- Allow /embed in chat tabs (#3449)
+- Allow /upload in chat tabs
+- Properly identify MUC-PMs for normal messages and chatstates (#3491)
+- /server_cycle: stricly match specified domain (#3412)
+- Breaking: -v/--version previously for internal purposes now returns version
+ as expected (#3429)
+- Improve highlighting regex (#3433)
+- Generate static resource at first launch (#3400)
* Poezio 0.12
diff --git a/COPYING b/COPYING
index 589e4764..20d40b6b 100644
--- a/COPYING
+++ b/COPYING
@@ -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
diff --git a/README.rst b/README.rst
index 34c01b1f..d377c82a 100644
--- a/README.rst
+++ b/README.rst
@@ -1,35 +1,52 @@
poezio
======
+.. image:: https://lab.louiz.org/poezio/poezio/-/raw/main/data/poezio_logo.svg
+ :alt: Poezio logo
+ :width: 200
+
+|pipeline| |python versions| |license|
+
+|discuss|
+
Homepage: https://poez.io
Forge Page: https://lab.louiz.org/poezio/poezio
-Poezio is a console Jabber/XMPP client. Its goal is to use anonymous
-connections to simply let the user join MultiUserChats. This way, the user
-doesn't have to create a Jabber account, exactly like people are using
-IRC. Poezio's commands are designed to be (if possible) like commonly
-used IRC clients (weechat, irssi, etc).
+Poezio is a console Jabber/XMPP client. The initial goal was to provide a
+way of connecting easily to XMPP without the need for an account, exactly like
+IRC clients. Poezio's commands are also designed to be close, if possible,
+to the ones commonly used in IRC clients (weechat, irssi, etc).
-Since version 0.7, poezio can handle real Jabber accounts along with
-roster and one-to-one conversations, making it a full-featured console
-Jabber client, but still MultiUserChats-centered.
-In the future, poezio should implement at a 100% level all XEP related to
-MUCs, especially XEP 0045.
+For this reason, the experience is still centered around chatrooms, despite
+poezio being a full-featured XMPP client for a very long while.
Install
-=======
+-------
+
+Packages
+~~~~~~~~
+
+The stable version of poezio is packaged in
+`a number of GNU/Linux (and OpenBSD) distributions <https://doc.poez.io/install.html#poezio-in-the-gnu-linux-distributions>`_.
+
+
+If it is not packaged in your distribution, you can run the
+`flatpak <https://flathub.org/apps/details/io.poez.Poezio>`_ or use pip
+to install the package from `Pypi <https://pypi.org/project/slixmpp/>`_.
+
+
+From git
+~~~~~~~~
+
+`Documentation <https://doc.poez.io/install.html#install-from-source>`_
-You need python 3.5 or higher (preferably the latest) and the associated devel
+
+You need python 3.7 or higher (preferably the latest) and the associated devel
package, to build C modules, and the slixmpp python library.
You also need aiodns if you want SRV record support.
-Additionally, you’ll need sphinx to build the documentation pages.
-To read the documentation without these dependancies just read the rst
-files in the doc/source/ directory or the generated documentation on the
-website.
-
-The simplest way to have up-to-date dependencies and to be able to test
+The easiest way to have up-to-date dependencies and to be able to test
this developement version is to use the ``update.sh`` script that downloads
them, places them in the right directory, and builds the C module.
@@ -39,7 +56,6 @@ You can then launch poezio with
$ ./launch.sh
-you can now simply launch ``poezio``
You can edit the configuration file which is located in
``~/.config/poezio/poezio.cfg`` by default, and you will have to copy
@@ -58,48 +74,50 @@ Please DO report any bug you encounter and ask for any feature you want
(we may implement it or not, but it’s always better to ask).
Authors
-=======
+-------
- Florent Le Coz (louiz’) <louiz@louiz.org> (developer)
- Mathieu Pasquet (mathieui) <mathieui@mathieui.net> (developer)
- Emmanuel Gil Peyrot (Link Mauve) <linkmauve@linkmauve.fr> (developer)
+- Maxime Buquet (pep.) <pep@bouah.net> (developer)
Contact/support
-===============
+---------------
-Jabber ChatRoom: `poezio@muc.poez.io <xmpp:poezio@muc.poez.io?join>`_
+Jabber chat room: `poezio@muc.poez.io <xmpp:poezio@muc.poez.io?join>`_
+(`web chat`_)
Report a bug: https://lab.louiz.org/poezio/poezio/issues/new
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
-the Creative Commons BY license (http://creativecommons.org/licenses/by/2.0/)
+the `Creative Commons BY license <http://creativecommons.org/licenses/by/2.0/>`_.
Hacking
-=======
+-------
If you want to contribute, you will be welcome on
-`poezio@muc.poez.io <xmpp:poezio@muc.poez.io?join>`_ to announce your
-ideas, what you are going to do, or to seek help if you have trouble
-understanding some of the code.
+`poezio@muc.poez.io <xmpp:poezio@muc.poez.io?join>`_ (`web chat`_)
+to announce your ideas, what you are going to do, or to seek help if you have
+trouble understanding some of the code.
The preferred way to submit changes is through a merge request on gitlab,
at https://lab.louiz.org/poezio/poezio, but we also accept contributions
on github, or with a simple “please fetch my code on my personal git
-repository hosted somewhere”
+repository hosted somewhere”.
Thanks
-======
+------
- People:
- Todd Eisenberger - Plugin system and OTR support
@@ -124,3 +142,15 @@ Thanks
- FlashCode (weechat dev) - Useful advices on how to use ncurses efficiently
- And all the people using and testing poezio, and especially the ones present
on the jabber chatroom doing bug reports and/or feature requests.
+
+
+.. |pipeline| image:: https://lab.louiz.org/poezio/poezio/badges/main/pipeline.svg
+
+.. |python versions| image:: https://img.shields.io/pypi/pyversions/poezio.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
+
+.. _web chat: https://chat.jabberfr.org/converse.js/poezio@muc.poez.io
diff --git a/data/default_config.cfg b/data/default_config.cfg
index 908a3d70..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
@@ -132,17 +134,13 @@ use_bookmarks_method =
# possible values are: anything/false
#use_remote_bookmarks = true
-# Force the retrieval of the remote bookmarks even when the server
-# doesn't advertise support for your method
-#force_remote_bookmarks = false
-
# Whether you want all bookmarks, even those without
# autojoin, to be open on startup
#open_all_bookmarks = false
# Will create a bookmark on manual /join, using your preferred
-# storage method
-#bookmark_on_join = false
+# storage method. Use /leave to remove the bookmark.
+#synchronise_open_rooms = true
# What will be put after the name, when using autocompletion at the
# beginning of the input. A space will always be added after that
@@ -253,6 +251,9 @@ use_bookmarks_method =
# in files.
#use_log = true
+# set to 'false' to not sync the local lgos with the MAM server history
+#mam_sync = true
+
# The number of lines to preload in a chat buffer when it opens
# (the lines are preloaded from the log files)
# 0 or a negative value disable that option
@@ -408,9 +409,6 @@ use_bookmarks_method =
# possible values: desc, asc
#user_list_sort = desc
-# If the chatroom nicks should receive a fixed color based on their text or not
-#deterministic_nick_colors = true
-
# If _nick, nick_, _nick_, nick__ etc. should have the same color as nick
#nick_color_aliases = true
@@ -418,9 +416,6 @@ use_bookmarks_method =
# will be displayed using their nick color if true.
#display_user_color_in_join_part = true
-# Display user tune notifications as information messages or not
-#display_tune_notifications = false
-
# Change the tab state when receiving chatroom messages.
# useful if you are connected to a high-volume chatroom and do not
# want it to appear in your tab bar as active. Highlights are still
@@ -453,39 +448,6 @@ use_bookmarks_method =
# defaults to $XDG_CACHE_HOME/poezio/images.
#tmp_image_dir =
-# Receive the tune notifications or not (in order to display information
-# in the contact list).
-# If this is set to false, then the display_tune_notifications
-# option will be ignored.
-#enable_user_tune = true
-
-# Display user gaming notifications as information messages or not
-#display_gaming_notifications = false
-
-# Receive the gaming notifications or not (in order to display information
-# in the contact list).
-# If this is set to false, then the display_gaming_notifications
-# option will be ignored.
-#enable_user_gaming = true
-
-# Display user mood notifications as information messages or not
-#display_mood_notifications = false
-
-# Receive the mood notifications or not (in order to display information
-# in the contact list).
-# If this is set to false, then the display_mood_notifications
-# option will be ignored.
-#enable_user_mood = true
-
-# Display user activity notifications as information messages or not
-#display_activity_notifications = false
-
-# Receive the activity notifications or not (in order to display information
-# in the contact list).
-# If this is set to false, then the display_activity_notifications
-# option will be ignored.
-#enable_user_activity = true
-
# If set to true, use the nickname broadcasted by the user if none has been
# set manually.
#enable_user_nick = true
@@ -548,6 +510,13 @@ use_bookmarks_method =
# “true” should be the most comfortable value
#lazy_resize = true
+# If set to true and if show_tab_names is set, the info bar will only show
+# the unique prefix of each tab name instead of the full name. This saves a
+# lot of space if many tabs exist or are active.
+# Best used with the `/wup` command or the `_go_to_room_name` action to select
+# a tab based on the prefix.
+#unique_prefix_tab_names = false
+
[bindings]
# Bindings are keyboard shortcut aliases. You can use them
# to define your own keys and bind them with some functions
diff --git a/data/doap.xml b/data/doap.xml
index f41c9fea..6a1330b7 100644
--- a/data/doap.xml
+++ b/data/doap.xml
@@ -1,6 +1,6 @@
<?xml version="1.0"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
-<Project xmlns="http://usefulinc.com/ns/doap#" xmlns:foaf="http://xmlns.com/foaf/0.1/" xmlns:xmpp="https://linkmauve.fr/ns/xmpp-doap#">
+<Project xmlns="http://usefulinc.com/ns/doap#" xmlns:foaf="http://xmlns.com/foaf/0.1/" xmlns:xmpp="https://linkmauve.fr/ns/xmpp-doap#" xmlns:schema="https://schema.org/">
<name>poezio</name>
<created>2010-01-10</created>
@@ -12,23 +12,23 @@
<description xml:lang="fr">Client console XMPP libre et moderne, écrit en Python avec la bibliothèque ncurses</description>
<homepage rdf:resource="https://poez.io/"/>
- <!-- TODO: https://github.com/ewilderj/doap/issues/51 -->
- <!--<doc rdf:resource="https://doc.poez.io/"/>-->
- <download-page rdf:resource="https://dev.louiz.org/projects/poezio/files"/>
- <bug-database rdf:resource="https://dev.louiz.org/projects/poezio/issues"/>
+ <!-- Until https://github.com/ewilderj/doap/issues/51 is done, we’ll use schema.org instead. -->
+ <schema:documentation rdf:resource="https://doc.poez.io/"/>
+ <download-page rdf:resource="https://poez.io/#download"/>
+ <bug-database rdf:resource="https://lab.louiz.org/poezio/poezio/-/issues"/>
<!-- See https://github.com/ewilderj/doap/issues/53 -->
<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>
- <!-- TODO: https://github.com/ewilderj/doap/issues/40 -->
- <!--<logo rdf:resource="https://poez.io/img/logo.png"/>-->
- <!-- TODO: https://github.com/ewilderj/doap/issues/50 -->
- <!--<screenshot rdf:resource="https://poez.io/img/screenshot.png"/>-->
+ <!-- Until https://github.com/ewilderj/doap/pull/68 is merged, we’ll use schema.org instead. -->
+ <schema:logo rdf:resource="https://poez.io/img/logo.svg"/>
+ <!-- Same here, see https://github.com/ewilderj/doap/issues/50 -->
+ <schema:screenshot rdf:resource="https://poez.io/img/screenshot.png"/>
<programming-language>Python</programming-language>
@@ -64,11 +64,18 @@
<foaf:mbox_sha1sum>c14292b375a7cec3f39872aa8524c66a1d9106cf</foaf:mbox_sha1sum>
</foaf:Person>
</maintainer>
+ <maintainer>
+ <foaf:Person>
+ <foaf:name>pep.</foaf:name>
+ <foaf:homepage rdf:resource="https://bouah.net/"/>
+ <foaf:mbox_sha1sum>29ed31759e39e0da3f3634e91b667275ba5e4ac6</foaf:mbox_sha1sum>
+ </foaf:Person>
+ </maintainer>
<repository>
<GitRepository>
- <browse rdf:resource="https://git.poez.io/poezio/"/>
- <location rdf:resource="https://git.poez.io/poezio.git"/>
+ <browse rdf:resource="https://lab.louiz.org/poezio/poezio"/>
+ <location rdf:resource="https://lab.louiz.org/poezio/poezio.git"/>
</GitRepository>
</repository>
@@ -266,6 +273,15 @@
</implements>
<implements>
<xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0157.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.0.1</xmpp:version>
+ <xmpp:since>0.13</xmpp:since>
+ <xmpp:note>contact plugin</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0163.html"/>
<xmpp:status>complete</xmpp:status>
<xmpp:version>1.2</xmpp:version>
@@ -423,7 +439,7 @@
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0308.html"/>
<xmpp:status>complete</xmpp:status>
- <xmpp:version>1.0</xmpp:version>
+ <xmpp:version>1.1.0</xmpp:version>
<xmpp:since>0.8</xmpp:since>
</xmpp:SupportedXep>
</implements>
@@ -432,15 +448,15 @@
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0313.html"/>
<xmpp:status>complete</xmpp:status>
<xmpp:version>0.6.3</xmpp:version>
- <xmpp:since>NEXT</xmpp:since>
+ <xmpp:since>0.13</xmpp:since>
</xmpp:SupportedXep>
</implements>
<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>
@@ -461,6 +477,15 @@
</implements>
<implements>
<xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0363.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.0.0</xmpp:version>
+ <xmpp:since>0.13</xmpp:since>
+ <xmpp:note>upload plugin</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0364.html"/>
<xmpp:status>complete</xmpp:status>
<xmpp:version>0.3</xmpp:version>
@@ -484,15 +509,61 @@
<xmpp:since>0.11</xmpp:since>
</xmpp:SupportedXep>
</implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0384.html"/>
+ <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. UI largely missing, trust management missing</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0392.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>0.5</xmpp:version>
+ <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"/>
+ </Version>
+ </release>
+ <!-- TODO: https://github.com/ewilderj/doap/issues/52 -->
+ <release>
+ <Version>
+ <revision>0.13</revision>
+ <created>2020-05-24</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.13/poezio-v0.13.tar.gz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
<revision>0.12.1</revision>
<created>2018-09-12</created>
<file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.12.1/poezio-v0.12.1.tar.gz"/>
</Version>
</release>
- <!-- TODO: https://github.com/ewilderj/doap/issues/52 -->
<release>
<Version>
<revision>0.12</revision>
@@ -504,35 +575,35 @@
<Version>
<revision>0.11</revision>
<created>2017-01-31</created>
- <file-release rdf:resource="https://dev.louiz.org/attachments/118/poezio-0.11.tar.gz"/>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.11/poezio-v0.11.tar.gz"/>
</Version>
</release>
<release>
<Version>
<revision>0.10</revision>
<created>2016-10-09</created>
- <file-release rdf:resource="https://dev.louiz.org/attachments/102/poezio-0.10.tar.gz"/>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.10/poezio-v0.10.tar.gz"/>
</Version>
</release>
<release>
<Version>
<revision>0.9</revision>
<created>2015-07-31</created>
- <file-release rdf:resource="https://dev.louiz.org/attachments/91/poezio-0.9.tar.xz"/>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.9/poezio-v0.9.tar.gz"/>
</Version>
</release>
<release>
<Version>
<revision>0.8.1</revision>
<created>2014-03-20</created>
- <file-release rdf:resource="https://dev.louiz.org/attachments/52/poezio-0.8.1.tar.xz"/>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.8.1/poezio-v0.8.1.tar.gz"/>
</Version>
</release>
<release>
<Version>
<revision>0.8</revision>
<created>2014-02-22</created>
- <file-release rdf:resource="https://dev.louiz.org/attachments/43/poezio-0.8.tar.xz"/>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.8/poezio-v0.8.tar.gz"/>
</Version>
</release>
<release>
diff --git a/data/io.poez.Poezio.appdata.xml b/data/io.poez.Poezio.appdata.xml
index ce0d1a86..d6f479a3 100644
--- a/data/io.poez.Poezio.appdata.xml
+++ b/data/io.poez.Poezio.appdata.xml
@@ -1,17 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
-<!-- Copyright 2018 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> -->
+<!-- Copyright 2018-2020 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> -->
<component type="console-application">
<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>
<url type="homepage">https://poez.io</url>
- <url type="bugtracker">https://dev.louiz.org/projects/poezio/issues</url>
- <url type="help">https://doc.poez.io/</url>
+ <url type="bugtracker">https://lab.louiz.org/poezio/poezio/-/issues</url>
+ <url type="help">https://doc.poez.io</url>
+
+ <description>
+ <p>
+ Poezio is a free console XMPP client (the protocol on which the Jabber IM
+ network is built).
+ </p>
+ <p>
+ Its goal is to let you connect very easily (no account creation needed)
+ to the network and join various chatrooms, immediately. It tries to look
+ like the most famous IRC clients (weechat, irssi, etc). Many commands are
+ identical and you won’t be lost if you already know these clients.
+ Configuration can be made in a configuration file or directly from the
+ client.
+ </p>
+ <p>
+ You’ll find the light, fast, geeky and anonymous spirit of IRC while
+ using a powerful, standard and open protocol.
+ </p>
+
+ <p>Features</p>
+
+ <ul>
+ <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>
+
+ <li>Corrections: Fix your last sent messages.</li>
+ <li>Rich text: Send and receive messages with colors and formatting.</li>
+ <li>Chat state notifications: See the writing status of your contacts.</li>
+ <li>Anonymous: Use XMPP without an account.</li>
+ </ul>
+ </description>
- <icon width="80" height="80">data/poezio_80.png</icon>
<screenshots>
<screenshot type="default">
<image>https://poez.io/img/screenshot.png</image>
@@ -24,11 +58,20 @@
<category>InstantMessaging</category>
<category>Network</category>
</categories>
+
+ <content_rating type="oars-1.1">
+ <content_attribute id="social-chat">intense</content_attribute>
+ </content_rating>
+
<provides>
<binary>poezio</binary>
</provides>
- <developer_name>Poezio Team</developer_name>
+
<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"/>
<release version="0.12" date="2018-08-13"/>
<release version="0.11" date="2017-01-31"/>
<release version="0.10" date="2016-10-09"/>
diff --git a/data/poezio.1 b/data/poezio.1
index d54e991c..4e9fbf12 100644
--- a/data/poezio.1
+++ b/data/poezio.1
@@ -1,45 +1,48 @@
.\" Copyright 2010 Le Coz Florent
.\" This man page is distributed under the GPLv3 license.
.\" See COPYING file
-.TH "Poezio" "1" "September 26, 2011" "Poezio dev team" ""
+.TH "Poezio" "1" "May 31, 2020" "Poezio dev team" ""
.SH "NAME"
-Poezio \- a ncurses jabber client written in python3
+Poezio \- a ncurses jabber client written in python
.SH "SYNOPSIS"
.B poezio [\-f \fICONFIG_FILE\fR] [\-d \fIDEBUG_FILE\fR] [\-h]
.SH "DESCRIPTION"
.B Poezio
-is a console jabber (XMPP) client written in Python and using ncurses to draw its interface. It aims at being similar to the most famous IRC clients, like weechat or irssi. The keyboard shortcuts are inspired from emacs. For more information on XMPP see http://xmpp.org and on Poezio see https://poez.io
+is a console jabber (XMPP) client written in Python and using ncurses to draw its interface. It aims at being similar to the most famous IRC clients, like weechat or irssi. Some keyboard shortcuts are inspired from emacs. For more information on XMPP see http://xmpp.org and on Poezio see https://poez.io
.PP
.SH "OPTIONS"
.TP
\fB\-f\fR, \fB\-\-file \fICONFIG_FILE\fR
-Run poezio using \fICONFIG_FILE\fR as the config file instead of ~/.config/poezio/poezio.cfg
+Run poezio using \fICONFIG_FILE\fR as the config file instead of ~/.config/poezio/poezio.cfg.
.TP
\fB\-d\fR, \fB\-\-debug \fIDEBUG_FILE\fR
-Log debug from both poezio and SleekXMPP in \fIDEBUG_FILE\fR. Debug contains incoming and outgoing stanzas in addition to various message helping poezio's debugging.
+Log debug from both poezio and slixmpp in \fIDEBUG_FILE\fR. Debug contains incoming and outgoing stanzas in addition to various message helping poezio's debugging.
.TP
-\fB\-h\fR
-Display an help message
+\fB\-c\fR, \fB\-\-check\-config\fR
+Display the list of modified/unmodified config options, with their changes from the default.
+.TP
+\fB\-h\fR, \fB\-\-help\fR
+Display the poezio help message.
+
+.SH "BASICS"
+
+The following sections will give you a short overview on how to use poezio. Poezio has many more options, commands and key bindings, please refer to \fIpoezio.cfg(7)\fR, \fIpoezio.commands(7)\fR, \fIpoezio.keys(7)\fR or the full documentation which should have been provided alongside the source code, or check it online at https://doc.poez.io/.
-.SH "TABS"
-A \fItab\fR, in Poezio, is the base structure of the interface. A tab may contains one or more \fIwindows\fR, and can be of different types:
+A \fItab\fR, in Poezio, is the base structure of the interface. A tab may contains one or more \fIwindows\fR, the main types are:
.RS
.TP 6
.I Roster \fRtab
-It contains a list of your contacts on the left, as well as an info window on the right.
+It contains a browsable list of your contacts on the left, as well as an info window on the right.
.TP
-.I MUC \fRtab
-MUC stands for "Multi-User Chat".
+.I Chatroom \fRtab
+This tab displays the contents of a multi-user chat.
.TP
.I Conversation \fRtab
It is used for one-to-one communication, usually when using a real Jabber account.
-.TP
-.I Private \fRtab
-It is used to privately communicate with someone in a MUC.
.SH "KEY BINDINGS"
While most of the keyboard shortcuts are common to all types of tabs, some of them are tab-specific.
-.SS Text edition
+.SS Text edition
These shortcuts work in any kind of tab; most of them are identical to emacs' ones.
.RS
.TP 8
@@ -146,9 +149,6 @@ The opposite of \fI/accept\fR.
.SS MUC-specific commands
.RS
.TP 5
-.B /recolor
-Change the color of the nicknames in the conversation. Useful when a few people are talking and their random color happen to be the same: using this command will let you differentiate them more easily.
-.TP
.B /kick <user>
Kick the specified user from the room.
.TP
@@ -165,7 +165,7 @@ View or change the topic of the room.
Talk privately with the specified participant.
.TP
.B /part
-Leave the current room.
+Leave the current room.
.SH "BUGS"
Sure.
@@ -174,8 +174,8 @@ Sure.
If you're using a terminal multiplexer such as \fIscreen\fR or \fItmux\fR, it may be setting $TERM to "screen", which breaks 256-color support. Consider setting your $TERM to something like "screen-256color".
.SH "FEEDBACK"
-You are encouraged to report bugs or feature requests on https://dev.louiz.org/projects/poezio.
-You can also find us on the Jabber chatroom poezio@muc.poez.io
+You are encouraged to report bugs or feature requests on https://lab.louiz.org/poezio/poezio.
+You can also find us on the Jabber chatroom xmpp:poezio@muc.poez.io?join
.SH "AUTHORS"
Written by Florent Le Coz <louiz@louiz.org>
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/data/themes/clean.py b/data/themes/clean.py
new file mode 100644
index 00000000..66a18a6c
--- /dev/null
+++ b/data/themes/clean.py
@@ -0,0 +1,193 @@
+import poezio.theming
+
+class CleanTheme(poezio.theming.Theme):
+ # Message text color
+ COLOR_NORMAL_TEXT = (-1, -1)
+ COLOR_INFORMATION_TEXT = (12, -1) # TODO
+ COLOR_WARNING_TEXT = (1, -1)
+
+ # Color of the commands in the help message
+ COLOR_HELP_COMMANDS = (208, -1)
+
+ # "reverse" is a special value, available only for this option. It just
+ # takes the nick colors and reverses it. A theme can still specify a
+ # fixed color if need be.
+ COLOR_HIGHLIGHT_NICK = "reverse"
+
+ # Color of the participant JID in a MUC
+ COLOR_MUC_JID = (4, -1)
+
+ # User list color
+ COLOR_USER_VISITOR = (239, -1)
+ COLOR_USER_PARTICIPANT = (4, -1)
+ COLOR_USER_NONE = (0, -1)
+ COLOR_USER_MODERATOR = (1, -1)
+
+ # nickname colors
+ COLOR_REMOTE_USER = (13, -1)
+
+ # The character printed in color (COLOR_STATUS_*) before the nickname
+ # in the user list
+ CHAR_STATUS = '┃'
+ #CHAR_STATUS = '●'
+ #CHAR_STATUS = '◆'
+
+ # The characters used for the chatstates in the user list
+ # in a MUC
+ CHAR_CHATSTATE_ACTIVE = 'A'
+ CHAR_CHATSTATE_COMPOSING = 'X'
+ CHAR_CHATSTATE_PAUSED = 'p'
+
+ # These characters are used for the affiliation in the user list
+ # in a MUC
+ CHAR_AFFILIATION_OWNER = '~'
+ CHAR_AFFILIATION_ADMIN = '&'
+ CHAR_AFFILIATION_MEMBER = '+'
+ CHAR_AFFILIATION_NONE = '-'
+
+
+ # XML Tab
+ CHAR_XML_IN = 'IN '
+ CHAR_XML_OUT = 'OUT'
+ COLOR_XML_IN = (1, -1)
+ COLOR_XML_OUT = (2, -1)
+
+ # Color for the /me message
+ COLOR_ME_MESSAGE = (6, -1)
+
+ # Color for the number of revisions of a message
+ COLOR_REVISIONS_MESSAGE = (3, -1, 'b')
+
+ # Color for various important text. For example the "?" before JIDs in
+ # the roster that require an user action.
+ COLOR_IMPORTANT_TEXT = (3, 5, 'b')
+
+ # Separators
+ COLOR_VERTICAL_SEPARATOR = (4, -1)
+ COLOR_NEW_TEXT_SEPARATOR = (2, -1)
+ COLOR_MORE_INDICATOR = (6, 4)
+
+ # Time
+ CHAR_TIME_LEFT = ''
+ CHAR_TIME_RIGHT = ''
+ COLOR_TIME_STRING = (-1, -1)
+
+ # Tabs
+ COLOR_TAB_NORMAL = (-1, 0)
+ COLOR_TAB_NONEMPTY = (7, 4)
+ COLOR_TAB_SCROLLED = (5, 4)
+ COLOR_TAB_JOINED = (82, 4)
+ COLOR_TAB_CURRENT = (0, 13)
+ COLOR_TAB_COMPOSING = (7, 5)
+ COLOR_TAB_NEW_MESSAGE = (7, 5)
+ COLOR_TAB_HIGHLIGHT = (7, 3)
+ COLOR_TAB_PRIVATE = (7, 2)
+ COLOR_TAB_ATTENTION = (7, 1)
+ COLOR_TAB_DISCONNECTED = (7, 8)
+
+ COLOR_VERTICAL_TAB_NORMAL = (4, -1)
+ COLOR_VERTICAL_TAB_NONEMPTY = (4, -1)
+ COLOR_VERTICAL_TAB_JOINED = (82, -1)
+ COLOR_VERTICAL_TAB_SCROLLED = (66, -1)
+ COLOR_VERTICAL_TAB_CURRENT = (7, 4)
+ COLOR_VERTICAL_TAB_NEW_MESSAGE = (5, -1)
+ COLOR_VERTICAL_TAB_COMPOSING = (5, -1)
+ COLOR_VERTICAL_TAB_HIGHLIGHT = (3, -1)
+ COLOR_VERTICAL_TAB_PRIVATE = (2, -1)
+ COLOR_VERTICAL_TAB_ATTENTION = (1, -1)
+ COLOR_VERTICAL_TAB_DISCONNECTED = (8, -1)
+
+ # Nickname colors
+ # A list of colors randomly attributed to nicks in MUCs
+ # Setting more colors makes it harder to have two nicks with the same color,
+ # avoiding confusions.
+ LIST_COLOR_NICKNAMES = [
+ (1, -1), (2, -1), (3, -1), (4, -1), (5, -1), (6, -1), (7, -1),
+ (8, -1), (9, -1), (10, -1), (11, -1), (12, -1), (13, -1), (14, -1)
+ ]
+
+ # This is your own nickname
+ COLOR_OWN_NICK = (-1, -1)
+
+ COLOR_LOG_MSG = (8, -1)
+ # This is for in-tab error messages
+ COLOR_ERROR_MSG = (9, -1, 'b')
+ # Status color
+ COLOR_STATUS_XA = (90, 0)
+ COLOR_STATUS_NONE = (4, 0)
+ COLOR_STATUS_DND = (1, 0)
+ COLOR_STATUS_AWAY = (3, 0)
+ COLOR_STATUS_CHAT = (2, 0)
+ COLOR_STATUS_UNAVAILABLE = (8, 0)
+ COLOR_STATUS_ONLINE = (4, 0)
+
+ # Bars
+ COLOR_WARNING_PROMPT = (16, 1, 'b')
+ COLOR_INFORMATION_BAR = (7, 0)
+ COLOR_TOPIC_BAR = (7, 0)
+ COLOR_SCROLLABLE_NUMBER = (220, 4, 'b')
+ COLOR_SELECTED_ROW = (0, 13)
+ COLOR_PRIVATE_NAME = (-1, 4)
+ COLOR_CONVERSATION_NAME = (2, 0)
+ COLOR_CONVERSATION_RESOURCE = (121, 0)
+ COLOR_GROUPCHAT_NAME = (10, 0)
+ COLOR_COLUMN_HEADER = (36, 4)
+ COLOR_COLUMN_HEADER_SEL = (4, 36)
+
+ # Strings for special messages (like join, quit, nick change, etc)
+ # Special messages
+ CHAR_JOIN = '--->'
+ CHAR_QUIT = '<---'
+ CHAR_KICK = '-!-'
+ CHAR_NEW_TEXT_SEPARATOR = ' ─'
+ CHAR_OK = '✔'
+ CHAR_ERROR = '✖'
+ CHAR_EMPTY = ' '
+ CHAR_ACK_RECEIVED = CHAR_OK
+ CHAR_NACK = CHAR_ERROR
+ CHAR_COLUMN_ASC = ' ▲'
+ CHAR_COLUMN_DESC = ' ▼'
+ CHAR_ROSTER_ERROR = CHAR_ERROR
+ CHAR_ROSTER_TUNE = '♪'
+ CHAR_ROSTER_ASKED = '?'
+ CHAR_ROSTER_ACTIVITY = 'A'
+ CHAR_ROSTER_MOOD = 'M'
+ CHAR_ROSTER_GAMING = 'G'
+ CHAR_ROSTER_FROM = '←'
+ CHAR_ROSTER_BOTH = '↔'
+ CHAR_ROSTER_TO = '→'
+ CHAR_ROSTER_NONE = '⇹'
+
+ COLOR_CHAR_ACK = (2, -1)
+ COLOR_CHAR_NACK = (1, -1)
+
+ COLOR_ROSTER_GAMING = (6, -1)
+ COLOR_ROSTER_MOOD = (2, -1)
+ COLOR_ROSTER_ACTIVITY = (3, -1)
+ COLOR_ROSTER_TUNE = (6, -1)
+ COLOR_ROSTER_ERROR = (1, -1)
+ COLOR_ROSTER_SUBSCRIPTION = (-1, -1)
+
+ COLOR_JOIN_CHAR = (4, -1)
+ COLOR_QUIT_CHAR = (1, -1)
+ COLOR_KICK_CHAR = (1, -1)
+
+ # Vertical tab list color
+ COLOR_VERTICAL_TAB_NUMBER = (34, -1)
+
+ # Info messages color (the part before the ">")
+ INFO_COLORS = {
+ 'info': (2, -1),
+ 'error': (1, -1, 'b'),
+ 'warning': (1, -1),
+ 'roster': (2, -1),
+ 'help': (10, -1),
+ 'headline': (11, -1, 'b'),
+ 'tune': (6, -1),
+ 'gaming': (6, -1),
+ 'mood': (5, -1),
+ 'activity': (3, -1),
+ 'default': (-1, -1),
+ }
+
+theme = CleanTheme()
diff --git a/doc/source/commands.rst b/doc/source/commands.rst
index bea44fe0..d1763084 100644
--- a/doc/source/commands.rst
+++ b/doc/source/commands.rst
@@ -34,6 +34,12 @@ These commands work in *any* tab.
available commands. If it has a valid command as an argument, this command
will show the usage and the help for the given command.
+ /debug
+ **Usage:** ``/debug [filename]
+
+ Reset logging and enable debugging to ``[filename]``. If the filename
+ is empty, debug logging will be disabled.
+
/join
**Usage:** ``/join [room_name][@server][/nick] [password]``
@@ -56,7 +62,7 @@ These commands work in *any* tab.
- ``/join / password``
/destroy_room
- **Usage:** ``/destroy_room [room JID]``
+ **Usage:** ``/destroy_room [room JID [reason [alternative venue]]]``
Try to destroy the room given as a parameter, or the current room
is not parameter is given and the current tab is a chatroom.
@@ -93,6 +99,15 @@ These commands work in *any* tab.
Go to the matching tab. If the argument is a number, it goes to the tab with that number.
Otherwise, it goes to the next tab whose name contains the given string.
+ /wup
+
+ **Usage:** ``/wup <prefix>``
+
+ Go to the tab whose name starts with `prefix`. If multiple tabs start
+ with that prefix, no action is taken.
+
+ (Mnemonic: Window by Unique Prefix)
+
/status
**Usage:** ``/status <availability> [status message]``
@@ -209,6 +224,11 @@ These commands work in *any* tab.
Get the software version of the given JID (usually its XMPP
client and Operating System).
+ /ad-hoc
+ **Usage:** ``/ad-hoc <jid>``
+
+ List available ad-hoc commands on the given jid.
+
/invite
**Usage:** ``/invite <jid> <room> [reason]``
@@ -225,28 +245,6 @@ These commands work in *any* tab.
.. versionadded:: 0.13
- /activity
- **Usage:** ``/activity [<general> [specific] [comment]]``
-
- Send your current activity to your contacts (use the completion to cycle
- through all the general and specific possible activities).
-
- Nothing means "stop broadcasting an activity".
-
- /mood
- **Usage:** ``/mood [<mood> [comment]]``
- Send your current mood to your contacts (use the completion to cycle
- through all the possible moods).
-
- Nothing means "stop broadcasting a mood".
-
- /gaming
- **Usage:** ``/gaming [<game name> [server address]]``
-
- Send your current gaming activity to your contacts.
-
- Nothing means "stop broadcasting a gaming activity".
-
/last_activity
**Usage:** ``/activity <jid>``
@@ -331,11 +329,12 @@ MultiUserChat tab commands
:sorted:
/affiliation
- **Usage:** ``/affiliation <nick> <affiliation>``
+ **Usage:** ``/affiliation [<nick or jid> <affiliation>]``
- Sets the affiliation of the participant designated by **nick** to the
- given **affiliation** (can be one of owner, admin, member, outcast
- and none).
+ Sets the affiliation of the participant designated by **nick** or
+ **jid** to the given **affiliation** (can be one of owner, admin,
+ member, outcast and none). If not argument is provided, lists
+ room affiliations.
/role
**Usage:** ``/affiliation <nick> <role>``
@@ -350,8 +349,7 @@ MultiUserChat tab commands
are considered identical if they only differ by the presence of one
ore more **_** character at the beginning or the end. For example
_Foo and Foo___ are considered aliases of the nick Foo) will then
- always have the specified color, in all MultiUserChat tabs. This is
- true whatever the value of **deterministic_nick_colors** is.
+ always have the specified color, in all MultiUserChat tabs.
Use the completion to get a list of all the available color values.
Use the special color **unset** to remove the attributed color on
@@ -411,18 +409,34 @@ MultiUserChat tab commands
Disconnect you from a room. You can specify an optional
message.
+ This is similar to :term:`/leave`, but keeps the tab open and doesn’t
+ remove the bookmark, so restarting poezio or another client will reopen
+ this room.
+
+ /leave
+ **Usage:** ``/leave [message]``
+
+ Disconnect you from a room, on all of your clients. You can specify an
+ optional message.
+
+ This is similar to :term:`/part`, but closes the tab and removes its
+ bookmark, to make sure we don’t come back to this room the next time we
+ open poezio or another client.
+
+ This is similar to :term:`/close`, but also removes the bookmark to
+ make sure we don’t come back to this room the next time we open poezio
+ or another client.
+
/nick
**Usage:** ``/nick <nickname>``
Change your nickname in the current room.
/recolor
- **Usage:** ``/recolor [random]``
+ **Usage:** ``/recolor``
- Re-assign a color to all the participants in the current
- room, based on the last time they talked. Use this if the participants
- currently talking have too many identical colors. If a random argument is
- given, the participants will be shuffled before they are assigned a color.
+ Re-assign a color to all the participants in the current room,
+ if the theme has changed.
/cycle
**Usage:** ``/cycle [message]``
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 0396e88f..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-dev'
+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 9619022a..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]``
@@ -324,37 +324,12 @@ to understand what is :ref:`carbons <carbons-details>` or
as mobile networks). It can however increase bandwidth usage.
It also requires server support.
- enable_user_activity
-
- **Default value:** ``true``
-
- Set this to ``false`` if you don’t want to receive the activity of your contacts.
-
- enable_user_gaming
-
- **Default value:** ``true``
-
- Set this to ``false`` if you don’t want to receive the gaming activity of your contacts.
-
- enable_user_mood
-
- **Default value:** ``true``
-
- Set this to ``false`` if you don’t want to receive the mood of your contacts.
-
enable_user_nick
**Default value:** ``true``
Set to ``false`` if you don’t want your contacts to hint you their identity.
- enable_user_tune
-
- **Default value:** ``true``
-
- If this is set to ``false``, you will no longer be subscribed to tune events,
- and the :term:`display_tune_notifications` option will be ignored.
-
go_to_previous_tab_on_alt_number
**Default value:** ``false``
@@ -371,19 +346,18 @@ to understand what is :ref:`carbons <carbons-details>` or
sender intended it as such. See :ref:`Message Correction <correct-feature>` for
more information.
- bookmark_on_join
-
- **Default value:** ``false``
+ synchronise_open_rooms
- If ``true``, poezio will bookmark automatically every room that is joined with
- a manual ``/join`` command.
+ **Default value:** ``true``
- force_remote_bookmarks
+ If ``false``, poezio will not store the state of currently open rooms,
+ so that if you leave a room and restart poezio (or start another
+ client) it will reopen it.
- **Default value:** ``false``
+ If ``true``, ``/join`` will create a bookmark with ``autojoin=true``,
+ and ``/leave`` will remove said bookmark.
- Try to retrieve your remote bookmarks, even when your server doesn’t advertise
- support.
+ This was previously named ``bookmark_on_join``.
use_bookmark_method
@@ -407,34 +381,6 @@ to understand what is :ref:`carbons <carbons-details>` or
Use this option to force the use of local bookmarks if needed.
Anything but "false" will be counted as true.
- display_gaming_notifications
-
- **Default value:** ``false``
-
- If set to true, notifications about the games your are playing
- will be displayed in the info buffer as 'Gaming' messages.
-
- display_tune_notifications
-
- **Default value:** ``false``
-
- If set to true, notifications about the music your contacts listen to
- will be displayed in the info buffer as 'Tune' messages.
-
- display_mood_notifications
-
- **Default value:** ``false``
-
- If set to true, notifications about the mood of your contacts
- will be displayed in the info buffer as 'Mood' messages.
-
- display_activity_notifications
-
- **Default value:** ``false``
-
- If set to true, notifications about the current activity of your contacts
- will be displayed in the info buffer as 'Activity' messages.
-
enable_xhtml_im
**Default value:** ``true``
@@ -566,23 +512,14 @@ or the way messages are displayed.
bottom in the list, if set to ``asc``, they will be displayed from
bottom to top.
- deterministic_nick_colors
-
- **Default value:** ``true``
-
- Use a deterministic algorithm to choose the user colors in chatrooms if
- set to ``true``. Otherwise the colors will be picked randomly.
-
- The value of this option affects the behavior of :term:`/recolor`.
-
nick_color_aliases
**Default value:** ``true``
- Automatically search for color of nick aliases. For example, if nick is
- set to red, _nick, nick\_, _nick_, nick\__ etc. will have the same color.
- Aliases colors are checked first, so that it is still possible to have
- different colors for nick\_ and nick.
+ Automatically search for color of nick aliases. For example, if nick is
+ set to red, _nick, nick\_, _nick_, nick\__ etc. will have the same color.
+ Aliases colors are checked first, so that it is still possible to have
+ different colors for nick\_ and nick.
vertical_tab_list_size
@@ -680,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``
@@ -793,6 +738,17 @@ or the way messages are displayed.
If you want to show the tab name in the bottom Tab bar, set this to ``true``.
+ unique_prefix_tab_names
+
+ **Default value:** ``false``
+
+ If this and :term:`show_tab_names` is set to true, only the shortest
+ unique prefix of each tab name is shown instead of the full name. This
+ can declutter the interface in an instance with many tabs shown in the
+ interface, while not having to use numbers (which may change completely due to reordering).
+
+ Takes precedence over `use_tab_nicks`.
+
show_tab_numbers
**Default value:** ``true``
@@ -913,6 +869,21 @@ Options related to logging.
Set to ``false`` if you don’t want to write any message to the disk.
+ mam_sync
+
+ **Default value:** ``true``
+
+ If ``true``, will try to fill local logs with missing MAM history
+ when opening a tab or joining a room.
+
+ mam_sync_limit
+
+ **Default value:** ``2000``
+
+ Maximum number of messages to fetch on a MAM sync. Will affect
+ performance when joining rooms with a huge backlog for the first time
+ or after a long period.
+
Plugins
~~~~~~~
@@ -1116,7 +1087,7 @@ found.
display_user_color_in_join_part
- **Default value:** ``false``
+ **Default value:** ``true``
If set to ``true``, the color of the nick will be used in chatroom
information messages, instead of the default color from the theme.
diff --git a/doc/source/dev/contributing.rst b/doc/source/dev/contributing.rst
index 8d386c87..4c2d8161 100644
--- a/doc/source/dev/contributing.rst
+++ b/doc/source/dev/contributing.rst
@@ -38,8 +38,8 @@ useless merges (and polluting the commit history) when none is needed.
.. code-block:: bash
git fetch origin
- git rebase origin/master
- git push origin master
+ git rebase origin/main
+ git push origin main
If your commit is related to an issue on our tracker_ (or fixes such an
issue), you can use ``Fix #BUGID`` or ``References #BUGID`` to help with the
@@ -64,4 +64,4 @@ account and submit it again.
If you have already submitted some code and plan to do more, you can ask us
direct commit access on the main repo.
-.. _tracker: https://dev.louiz.org/project/poezio/bugs
+.. _tracker: https://lab.louiz.org/poezio/poezio/-/issues
diff --git a/doc/source/dev/plugin.rst b/doc/source/dev/plugin.rst
index 6a7605b2..4614c761 100644
--- a/doc/source/dev/plugin.rst
+++ b/doc/source/dev/plugin.rst
@@ -27,7 +27,6 @@ BasePlugin
.. module:: poezio.plugin
.. autoclass:: BasePlugin
- :members: init, cleanup, api, core
.. method:: init(self)
@@ -49,6 +48,16 @@ BasePlugin
The :py:class:`~PluginAPI` instance for this plugin.
+ .. attribute:: dependencies
+
+ Dependencies on other plugins, as a set of strings. A reference
+ to each dependency will be added in ``refs``.
+
+ .. attribute:: refs
+
+ This attribute is not to be edited by the user. It will be
+ populated when the plugin is initialized with references on each
+ plugin specified in the ``dependencies`` attribute.
Each plugin inheriting :py:class:`~BasePlugin` has an ``api`` member variable, which refers
to a :py:class:`~PluginAPI` object.
diff --git a/doc/source/install.rst b/doc/source/install.rst
index 3425542c..31dc332d 100644
--- a/doc/source/install.rst
+++ b/doc/source/install.rst
@@ -3,7 +3,7 @@
Installing poezio
=================
-.. warning:: Python 3.5 or above is **required**.
+.. warning:: Python 3.7 or above is **required**.
To install it on a distribution that doesn't provide it, see :ref:`pyenv <pyenv-install>`.
poezio in the GNU/Linux distributions
@@ -16,14 +16,17 @@ using one of these.
- **Archlinux**: poezio_ and poezio-git_ packages are in the AUR
(use your favourite AUR wrapper to install them)
- **Gentoo**: `net-im/poezio`_
-- **Fedora**: The stable poezio package was out of date for a long time in
- Fedora, but now thanks to Casper, there is an `up-to-date package`_ in
- the repos since F19.
+- **Fedora**: There is an `up-to-date package`_ in the repos since F19.
+- **CentOS**: Poezio is available in EPEL repositories since CentOS 8.
+- **Flatpak**: A stable package is provided on flathub_.
- **Debian**: A stable package is provided since buster_ thanks to debacle.
- **Nix** (and **NixOS**): The last stable version of poezio is availalble in
the unstable branch of `nixpkgs`. Use ``nix-env -f "<nixpkgs>" -iA poezio``
to install poezio for the current user.
- **OpenBSD**: a poezio port_ is available
+- **Guix**: Poezio can be obtained with Guix on any GNU/Linux distribution.
+ To install poezio in default user-profile: ``guix install poezio``.
+ To try poezio without installation: ``guix environment --pure --ad-hoc poezio``.
(If another distribution provides a poezio package, please tell us and we will
add it to the list)
@@ -60,7 +63,7 @@ support. Therefore, you might want to use the git version.
General
"""""""
-Poezio is a python3.5 (and above)-only application, so you will first need that.
+Poezio is a python3.7 (and above)-only application, so you will first need that.
Packages required for building poezio and deps:
@@ -241,8 +244,7 @@ that should be created beforehand:
If you don’t trust images distributed on the docker hub, you can rebuild the
image from the Dockerfile at the root of the git repository.
-.. _stable sources: https://dev.louiz.org/project/poezio/download
-.. _slixmpp: https://dev.louiz.org/projects/slixmpp
+.. _slixmpp: https://lab.louiz.org/poezio/slixmpp
.. _aiodns: https://github.com/saghul/aiodns
.. _poezio: https://aur.archlinux.org/packages/poezio/
.. _poezio-git: https://aur.archlinux.org/packages/poezio-git/
@@ -254,3 +256,4 @@ image from the Dockerfile at the root of the git repository.
.. _poezio/poezio: https://hub.docker.com/r/poezio/poezio/
.. _buster: https://packages.debian.org/buster/poezio
.. _net-im/poezio: https://packages.gentoo.org/packages/net-im/poezio
+.. _flathub: https://flathub.org/apps/details/io.poez.Poezio
diff --git a/doc/source/keys.rst b/doc/source/keys.rst
index dc5fa35b..03ab2071 100644
--- a/doc/source/keys.rst
+++ b/doc/source/keys.rst
@@ -374,6 +374,15 @@ Actions list
Similar to F4.
+**_go_to_room_name**: Jump to a tab by unique prefix.
+
+ Similar to :term:`/wup` and the default *Alt-j*. This action will take
+ input as long as there is at least one tab name starting with the input
+ given so far. If there is exactly one tab matching, the action completes
+ and the current tab is switched over to the tab matching the input. If
+ no tab matches, the action completes without any change. This means that
+ you can typically abort the action with Escape.
+
Status actions
~~~~~~~~~~~~~~
diff --git a/doc/source/plugins/index.rst b/doc/source/plugins/index.rst
index 59801784..c1222c84 100644
--- a/doc/source/plugins/index.rst
+++ b/doc/source/plugins/index.rst
@@ -146,7 +146,7 @@ Plugin index
the current (chat) tab.
OMEMO
- *Not distributed with Poezio.* See https://lab.louiz.org/poezio/poezio-omemo.
+ **Not distributed with Poezio.** See https://lab.louiz.org/poezio/poezio-omemo.
`Documentation <https://lab.louiz.org/poezio/poezio-omemo>`_
@@ -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>`
@@ -312,6 +317,11 @@ Plugin index
Add an ``/upload`` command to upload a file.
+ User Extras
+ :ref:`Documentation <userextras-plugin>`
+
+ Add /mood, /gaming, /activity
+
.. toctree::
:hidden:
@@ -337,6 +347,7 @@ Plugin index
simple_notify
spam
status
+ sticker
tell
time_marker
uptime
@@ -361,3 +372,4 @@ Plugin index
vcard
upload
contact
+ userextras
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
diff --git a/doc/source/plugins/user_extras.rst b/doc/source/plugins/user_extras.rst
new file mode 100644
index 00000000..8c63092e
--- /dev/null
+++ b/doc/source/plugins/user_extras.rst
@@ -0,0 +1,6 @@
+.. _userextras-plugin:
+
+User Extras
+===========
+
+.. automodule:: user_extras
diff --git a/doc/source/themes.rst b/doc/source/themes.rst
index f6da6af5..ba4be33b 100644
--- a/doc/source/themes.rst
+++ b/doc/source/themes.rst
@@ -19,7 +19,7 @@ the text impossible to read).
.. note:: The default theme should work properly in any case. If not, that’s a bug.
A theme file is a python file (with the .py extension) containing a
-class, inheriting the *theming.Theme* class defined into the *theming*
+class, inheriting the *poezio.theming.Theme* class defined into the *theming*
poezio module.
To check how many colors your current terminal/$TERM supports, do:
@@ -38,18 +38,18 @@ add:
.. code-block:: python
- import theming
+ from poezio.theming import Theme
- class FooTheme(theming.Theme):
- # Define here colors for that theme
+ class FooTheme(Theme):
+ # Define here colors for that theme
theme = FooTheme()
To define a *color pair* and assign it to the *COLOR_NAME* option, just do
.. code-block:: python
- class FooTheme(theming.Theme):
- COLOR_NAME = (fg_color, bg_color, opt_attr)
+ class FooTheme(Theme):
+ COLOR_NAME = (fg_color, bg_color, opt_attr)
You do not have to define all the :ref:`available-options`,
you can decide that your theme will only change some options, the other
diff --git a/doc/source/usage.rst b/doc/source/usage.rst
index 86936d03..6caf2728 100644
--- a/doc/source/usage.rst
+++ b/doc/source/usage.rst
@@ -15,20 +15,11 @@ Tab list
There are two ways of showing the open tabs:
-Horizontal list
-^^^^^^^^^^^^^^^
-
-This is the default method.
-
-On all tabs, you get a line showing the the list of all opened tabs. Each tab
-has a number, each time you open a new tab, it gets the next available number.
-
-.. figure:: ./images/tab_bar.png
- :alt: Example of 5 opened tabs
-
Vertical list
^^^^^^^^^^^^^
+This is the default method.
+
On all tabs, you get a pane on the left side of the screen that shows a list
of the opened tabs. As stated above, each tab has a number, and each time you
open a new tab, it gets the next available number.
@@ -36,10 +27,17 @@ open a new tab, it gets the next available number.
.. figure:: ./images/vert_tabs.png
:alt: Example of the vertical tab bar
+Horizontal list
+^^^^^^^^^^^^^^^
+
+On all tabs, you get a line showing the the list of all opened tabs. Each tab
+has a number, each time you open a new tab, it gets the next available number.
+
+.. figure:: ./images/tab_bar.png
+ :alt: Example of 5 opened tabs
-This mode is enabled by setting the
-:term:`enable_vertical_tab_list` option to ``true`` in the
-configuration file.
+This mode is enabled by setting the :term:`enable_vertical_tab_list`
+option to ``false`` in the configuration file.
Options for the tab list
^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/launch.sh b/launch.sh
index 44b8074c..b9d59cb5 100755
--- a/launch.sh
+++ b/launch.sh
@@ -1,20 +1,20 @@
#!/bin/sh
-cd $(dirname "$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.12-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"
@@ -24,6 +24,6 @@ else
PYTHON3=python3
fi
-$PYTHON3 -c 'import sys;(print("Python 3.5 or newer is required") and exit(1)) if sys.version_info < (3, 5) else exit(0)' || exit 1
-exec "$PYTHON3" -m poezio --custom-version "$args" "$@"
+$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
+PYTHONPATH="$proj_dir:$PYTHONPATH" exec "$PYTHON3" -m poezio --custom-version "$args" "$@"
diff --git a/plugins/admin.py b/plugins/admin.py
index 7bbc01d6..c2901844 100644
--- a/plugins/admin.py
+++ b/plugins/admin.py
@@ -122,10 +122,14 @@ class Plugin(BasePlugin):
completion=self.complete_nick)
def role(self, role):
- return lambda args: self.api.current_tab().command_role(args + ' ' + role)
+ async def inner(args):
+ await self.api.current_tab().command_role(args + ' ' + role)
+ return inner
def affiliation(self, affiliation):
- return lambda args: self.api.current_tab().command_affiliation(args + ' ' + affiliation)
+ async def inner(args):
+ await self.api.current_tab().command_affiliation(args + ' ' + affiliation)
+ return inner
def complete_nick(self, the_input):
tab = self.api.current_tab()
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 d56ac5b3..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,8 +23,17 @@ This plugin also respects security guidelines listed in XEP-0419.
"""
from base64 import b64decode, b64encode
+from typing import List, Optional
+from slixmpp import Message, JID
+
from poezio.plugin_e2ee import E2EEPlugin
-from slixmpp import Message
+from poezio.tabs import (
+ ChatTab,
+ MucTab,
+ PrivateTab,
+ DynamicConversationTab,
+ StaticConversationTab,
+)
class Plugin(E2EEPlugin):
@@ -37,14 +46,22 @@ class Plugin(E2EEPlugin):
# This encryption mechanism is using <body/> as a container
replace_body_with_eme = False
- def decrypt(self, message: Message, _tab) -> 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()
- def encrypt(self, message: Message, _tab) -> None:
+ async def encrypt(self, message: Message, _jid: Optional[List[JID]], _tab: ChatTab) -> None:
"""
Encrypt to base64
"""
diff --git a/plugins/bob.py b/plugins/bob.py
index 2d733e25..98c62901 100644
--- a/plugins/bob.py
+++ b/plugins/bob.py
@@ -47,7 +47,7 @@ class Plugin(BasePlugin):
short='Send a short image',
completion=self.completion_bob)
- def command_bob(self, filename):
+ async def command_bob(self, filename):
path = Path(expanduser(filename))
try:
size = path.stat().st_size
@@ -67,7 +67,7 @@ class Plugin(BasePlugin):
with open(path.as_posix(), 'rb') as file:
data = file.read()
max_age = self.config.get('max_age')
- cid = self.core.xmpp.plugin['xep_0231'].set_bob(
+ cid = await self.core.xmpp.plugin['xep_0231'].set_bob(
data, mime_type, max_age=max_age)
self.api.run_command(
'/xhtml <img src="cid:%s" alt="%s"/>' % (cid, path.name))
diff --git a/plugins/code.py b/plugins/code.py
index 1c6dfab0..8d9c57a3 100644
--- a/plugins/code.py
+++ b/plugins/code.py
@@ -41,7 +41,11 @@ class Plugin(BasePlugin):
help='Sends syntax-highlighted code in the current tab')
def command_code(self, args):
- language, code = args.split(None, 1)
+ split = args.split(None, 1)
+ if len(split) != 2:
+ self.api.information('Usage: /code <language> <code>', 'Error')
+ return None
+ language, code = split
lexer = get_lexer_by_name(language)
tab = self.api.current_tab()
code = highlight(code, lexer, FORMATTER)
diff --git a/plugins/contact.py b/plugins/contact.py
index ebe4dcc4..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,38 +26,35 @@ 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']
- title = 'Contact Info'
contacts = []
- for field in info['form']:
- var = field['var']
- if field['type'] == 'hidden' and var == 'FORM_TYPE':
- form_type = field['value'][0]
- if form_type != 'http://jabber.org/network/serverinfo':
- self.api.information('Not a server: “%s”: %s' % (iq['from'], form_type), 'Error')
- return
- continue
- if not var.endswith('-addresses'):
- continue
- var = var[:-10] # strip '-addresses'
- sep = '\n ' + len(var) * ' '
- field_value = field.get_value(convert=False)
- value = sep.join(field_value) if isinstance(field_value, list) else field_value
- contacts.append('%s: %s' % (var, value))
+ # iterate all data forms, in case there are multiple
+ for form in iq['disco_info']:
+ values = form.get_values()
+ if values['FORM_TYPE'][0] == 'http://jabber.org/network/serverinfo':
+ for var in values:
+ if not var.endswith('-addresses'):
+ continue
+ title = var[:-10] # strip '-addresses'
+ sep = '\n ' + len(title) * ' '
+ field_value = values[var]
+ if field_value:
+ value = sep.join(field_value) if isinstance(field_value, list) else field_value
+ contacts.append(f'{title}: {value}')
if contacts:
- self.api.information('\n'.join(contacts), title)
+ 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')
- def command_disco(self, jid):
+ async def command_disco(self, jid):
try:
- self.core.xmpp.plugin['xep_0030'].get_info(jid=jid, cached=False,
- callback=self.on_disco)
- except InvalidJID as e:
- self.api.information('Invalid JID “%s”: %s' % (jid, e), 'Error')
+ iq = await self.core.xmpp.plugin['xep_0030'].get_info(jid=jid, cached=False)
+ self.on_disco(iq)
+ 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/day_change.py b/plugins/day_change.py
index 051b447b..5d3ab37c 100644
--- a/plugins/day_change.py
+++ b/plugins/day_change.py
@@ -4,11 +4,12 @@ date has changed.
"""
+import datetime
from gettext import gettext as _
+
+from poezio import timed_events, tabs
from poezio.plugin import BasePlugin
-import datetime
-from poezio import tabs
-from poezio import timed_events
+from poezio.ui.types import InfoMessage
class Plugin(BasePlugin):
@@ -30,7 +31,7 @@ class Plugin(BasePlugin):
for tab in self.core.tabs:
if isinstance(tab, tabs.ChatTab):
- tab.add_message(msg)
+ tab.add_message(InfoMessage(msg))
self.core.refresh_window()
self.schedule_event()
diff --git a/plugins/dice.py b/plugins/dice.py
index f92604e3..3b540cbd 100644
--- a/plugins/dice.py
+++ b/plugins/dice.py
@@ -29,6 +29,7 @@ Configuration
"""
import random
+from typing import Optional
from poezio import tabs
from poezio.decorators import command_args_parser
@@ -40,17 +41,16 @@ DICE = '\u2680\u2681\u2682\u2683\u2684\u2685'
class DiceRoll:
__slots__ = [
'duration', 'total_duration', 'dice_number', 'msgtype', 'jid',
- 'last_msgid', 'increments'
+ 'msgid', 'increments'
]
- def __init__(self, total_duration, dice_number, is_muc, jid, msgid,
- increments):
+ def __init__(self, total_duration, dice_number, msgtype, jid, msgid, increments):
self.duration = 0
self.total_duration = total_duration
self.dice_number = dice_number
- self.msgtype = "groupchat" if is_muc else "chat"
+ self.msgtype = msgtype
self.jid = jid
- self.last_msgid = msgid
+ self.msgid = msgid
self.increments = increments
def reroll(self):
@@ -60,8 +60,11 @@ class DiceRoll:
return self.duration >= self.total_duration
+def roll_dice(num_dice: int) -> str:
+ return ''.join(random.choice(DICE) for _ in range(num_dice))
+
class Plugin(BasePlugin):
- default_config = {"dice": {"refresh": 0.5, "default_duration": 5}}
+ default_config = {"dice": {"refresh": 0.75, "default_duration": 7.5}}
def init(self):
for tab_t in [tabs.MucTab, tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab]:
@@ -90,13 +93,17 @@ class Plugin(BasePlugin):
self.core.command.help("roll")
return
- firstroll = ''.join(random.choice(DICE) for _ in range(num_dice))
- tab.command_say(firstroll)
- is_muctab = isinstance(tab, tabs.MucTab)
- msg_id = tab.last_sent_message["id"]
+ msgtype = 'groupchat' if isinstance(tab, tabs.MucTab) else 'chat'
+
+ message = self.core.xmpp.make_message(tab.jid)
+ message['type'] = msgtype
+ message['body'] = roll_dice(num_dice)
+ message.send()
+
increment = self.config.get('refresh')
- roll = DiceRoll(duration, num_dice, is_muctab, tab.jid, msg_id,
- increment)
+ msgid = message['id']
+
+ roll = DiceRoll(duration, num_dice, msgtype, tab.jid, msgid, increment)
event = self.api.create_delayed_event(increment, self.delayed_event,
roll)
self.api.add_timed_event(event)
@@ -107,11 +114,9 @@ class Plugin(BasePlugin):
roll.reroll()
message = self.core.xmpp.make_message(roll.jid)
message["type"] = roll.msgtype
- message["body"] = ''.join(
- random.choice(DICE) for _ in range(roll.dice_number))
- message["replace"]["id"] = roll.last_msgid
+ message["body"] = roll_dice(roll.dice_number)
+ message["replace"]["id"] = roll.msgid
message.send()
- roll.last_msgid = message['id']
event = self.api.create_delayed_event(roll.increments,
self.delayed_event, roll)
self.api.add_timed_event(event)
diff --git a/plugins/disco.py b/plugins/disco.py
index ec0a04cd..d15235f6 100644
--- a/plugins/disco.py
+++ b/plugins/disco.py
@@ -16,7 +16,9 @@ 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):
@@ -24,11 +26,11 @@ class Plugin(BasePlugin):
self.api.add_command(
'disco',
self.command_disco,
- usage='<JID>',
+ usage='<JID> [node] [info|items]',
short='Get the disco#info of a JID',
help='Get the disco#info of a JID')
- def on_disco(self, iq):
+ def on_info(self, iq):
if iq['type'] == 'error':
self.api.information(iq['error']['text'] or iq['error']['condition'], 'Error')
return
@@ -53,9 +55,52 @@ class Plugin(BasePlugin):
if server_info:
self.api.information('\n'.join(server_info), title)
- def command_disco(self, jid):
+ def on_items(self, iq):
+ if iq['type'] == 'error':
+ self.api.information(iq['error']['text'] or iq['error']['condition'], 'Error')
+ return
+
+ def describe(item):
+ text = item[0]
+ node = item[1]
+ name = item[2]
+ if node is not None:
+ text += ', node=' + node
+ if name is not None:
+ text += ', name=' + name
+ return text
+
+ items = iq['disco_items']
+ self.api.information('\n'.join(describe(item) for item in items['items']), 'Items')
+
+ @command_args_parser.quoted(1, 3)
+ async def command_disco(self, args):
+ if args is None:
+ self.core.command.help('disco')
+ return
+ if len(args) == 1:
+ jid, = args
+ node = None
+ type_ = 'info'
+ elif len(args) == 2:
+ jid, node = args
+ type_ = 'info'
+ else:
+ jid, node, type_ = args
try:
- self.core.xmpp.plugin['xep_0030'].get_info(
- jid=jid, cached=False, callback=self.on_disco)
+ if type_ == 'info':
+ iq = await self.core.xmpp.plugin['xep_0030'].get_info(
+ jid=jid, node=node, cached=False
+ )
+ self.on_info(iq)
+ elif type_ == 'items':
+ iq = await self.core.xmpp.plugin['xep_0030'].get_items(
+ 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/display_corrections.py b/plugins/display_corrections.py
index e9e8a2e4..cf8107ce 100644
--- a/plugins/display_corrections.py
+++ b/plugins/display_corrections.py
@@ -25,6 +25,8 @@ Usage
from poezio.plugin import BasePlugin
from poezio.common import shell_split
from poezio import tabs
+from poezio.ui.types import Message
+from poezio.theming import get_theme
class Plugin(BasePlugin):
@@ -43,7 +45,9 @@ class Plugin(BasePlugin):
messages = self.api.get_conversation_messages()
if not messages:
return None
- for message in messages[::-1]:
+ for message in reversed(messages):
+ if not isinstance(message, Message):
+ continue
if message.old_message:
if nb == 1:
return message
@@ -52,6 +56,7 @@ class Plugin(BasePlugin):
return None
def command_display_corrections(self, args):
+ theme = get_theme()
args = shell_split(args)
if len(args) == 1:
try:
@@ -64,8 +69,9 @@ class Plugin(BasePlugin):
if message:
display = []
while message:
+ str_time = message.time.strftime(theme.SHORT_TIME_FORMAT)
display.append('%s %s%s%s %s' %
- (message.str_time, '* '
+ (str_time, '* '
if message.me else '', message.nickname, ''
if message.me else '>', message.txt))
message = message.old_message
diff --git a/plugins/embed.py b/plugins/embed.py
index 9895a927..4a68f035 100644
--- a/plugins/embed.py
+++ b/plugins/embed.py
@@ -16,6 +16,7 @@ Usage
from poezio import tabs
from poezio.plugin import BasePlugin
from poezio.theming import get_theme
+from poezio.ui.types import Message
class Plugin(BasePlugin):
@@ -28,21 +29,22 @@ class Plugin(BasePlugin):
help='Embed an image url into the contact\'s client',
usage='<image_url>')
- def embed_image_url(self, args):
- 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'] = args
- message['oob']['url'] = args
- if isinstance(tab, tabs.MucTab):
- message['type'] = 'groupchat'
- else:
+ message['body'] = url
+ message['oob']['url'] = url
+ message['type'] = 'groupchat'
+ if not isinstance(tab, tabs.MucTab):
message['type'] = 'chat'
tab.add_message(
- message['body'],
- nickname=tab.core.own_nick,
- nick_color=get_theme().COLOR_OWN_NICK,
- identifier=message['id'],
- jid=tab.core.xmpp.boundjid,
- typ=1,
+ Message(
+ message['body'],
+ nickname=tab.core.own_nick,
+ nick_color=get_theme().COLOR_OWN_NICK,
+ identifier=message['id'],
+ jid=tab.core.xmpp.boundjid,
+ ),
)
message.send()
+ self.core.refresh_window()
diff --git a/plugins/emoji_ascii.py b/plugins/emoji_ascii.py
index 6629c50e..4beec3b1 100644
--- a/plugins/emoji_ascii.py
+++ b/plugins/emoji_ascii.py
@@ -21,10 +21,12 @@ import os
import re
from poezio.plugin import BasePlugin
+from typing import Dict
+
class Plugin(BasePlugin):
- emoji_to_ascii = {}
- ascii_to_emoji = {}
+ emoji_to_ascii: Dict[str, str] = {}
+ ascii_to_emoji: Dict[str, str] = {}
emoji_pattern = None
alias_pattern = None
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 9d981c91..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
@@ -129,30 +110,30 @@ 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):
- 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)
-
+ 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_join',
self.command_irc_join,
@@ -179,22 +160,38 @@ class Plugin(BasePlugin):
'example.com "hi there"`'),
short='Open a private conversation with an IRC user')
- 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:
room = '{}%{}@{}/{}'.format(room, server, gateway, nick)
- self.core.command.join(room)
+ joins.append(self.core.command.join(room))
- def initial_connect(self):
- gateway = self.config.get('gateway', 'irc.poez.io')
- sections = self.config.sections()
+ await asyncio.gather(*joins)
- for section in (s for s in sections if s != 'irc'):
+ async def initial_connect(self) -> None:
+ gateway: str = self.config.getstr('gateway')
+ sections: List[str] = self.config.sections()
+ 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
@@ -203,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:
- 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)
+ if not already_opened:
+ await self.join(gateway, section)
@command_args_parser.quoted(1, 1)
- 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]):
- 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:
- self.join_room(args[0])
+ 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
@@ -336,14 +248,14 @@ class Plugin(BasePlugin):
else:
self.core.command.message('{}'.format(jid))
- 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:
@@ -351,9 +263,9 @@ class Plugin(BasePlugin):
suffix = '%{}@{}{}'.format(section, gateway, nick)
for room in rooms:
- self.core.command.join(room + suffix)
+ await self.core.command.join(room + suffix)
- def join_room(self, name):
+ async def join_room(self, name: str) -> None:
"""
Join a room with only its name and the current tab
"""
@@ -361,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)
- self.core.command.join(room)
+ 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:
@@ -397,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 104399b4..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
@@ -17,7 +17,8 @@ from datetime import datetime
from poezio.plugin import BasePlugin
from poezio import tabs
-from poezio.text_buffer import Message, TextBuffer
+from poezio.text_buffer import TextBuffer
+from poezio.ui.types import Message as PMessage, InfoMessage
def add_line(
@@ -26,18 +27,7 @@ def add_line(
datetime: Optional[datetime] = None,
) -> None:
"""Adds a textual entry in the TextBuffer"""
- text_buffer.add_message(
- text,
- datetime, # Time
- None, # Nickname
- None, # Nick Color
- False, # History
- None, # User
- False, # Highlight
- None, # Identifier
- None, # str_time
- None, # Jid
- )
+ text_buffer.add_message(InfoMessage(text, time=datetime))
class Plugin(BasePlugin):
@@ -62,7 +52,7 @@ class Plugin(BasePlugin):
res = []
add_line(text_buffer, "Lastlog:")
for message in text_buffer.messages:
- if message.nickname is not None and \
+ if isinstance(message, PMessage) and \
search_re.search(message.txt) is not None:
res.append(message)
add_line(text_buffer, "%s> %s" % (message.nickname, message.txt), message.time)
diff --git a/plugins/link.py b/plugins/link.py
index d4929d1a..699215ea 100644
--- a/plugins/link.py
+++ b/plugins/link.py
@@ -76,7 +76,7 @@ Options
Set the default browser started by the plugin
.. _Unix FIFO: https://en.wikipedia.org/wiki/Named_pipe
-.. _daemon.py: https://lab.louiz.org/poezio/poezio/raw/master/poezio/daemon.py
+.. _daemon.py: https://lab.louiz.org/poezio/poezio/raw/main/poezio/daemon.py
"""
import platform
@@ -87,8 +87,17 @@ from poezio.xhtml import clean_text
from poezio import common
from poezio import tabs
-url_pattern = re.compile(r'\b(?:http[s]?://(?:\S+))|(?:magnet:\?(?:\S+))\b',
- re.I | re.U)
+url_pattern = re.compile(
+ r'\b'
+ '(?:http[s]?://(?:\S+))|'
+ '(?:magnet:\?(?:\S+))|'
+ '(?:aesgcm://(?:\S+))|'
+ '(?:gopher://(?:\S+))|'
+ '(?:gemini://(?:\S+))'
+ '\b',
+ re.I | re.U
+)
+
app_mapping = {
'Linux': 'xdg-open',
'Darwin': 'open',
diff --git a/plugins/marquee.py b/plugins/marquee.py
index 80bfbfeb..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
@@ -41,7 +42,7 @@ from poezio.decorators import command_args_parser
def move(text, step, spacing):
- new_text = text + (" " * spacing)
+ new_text = text + ("\u00A0" * spacing)
return new_text[-(step % len(new_text)):] + new_text[:-(
step % len(new_text))]
@@ -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
@@ -85,6 +88,6 @@ class Plugin(BasePlugin):
message.send()
event = self.api.create_delayed_event(
self.config.get("refresh"), self.delayed_event, jid, body,
- message["id"], step + 1, duration + self.config.get("refresh"),
+ msg_id, step + 1, duration + self.config.get("refresh"),
is_muctab)
self.api.add_timed_event(event)
diff --git a/plugins/otr.py b/plugins/otr.py
index 2ddc332b..6c15f3d2 100644
--- a/plugins/otr.py
+++ b/plugins/otr.py
@@ -184,7 +184,6 @@ and :term:`log` configuration parameters are tab-specific.
from gettext import gettext as _
import logging
-log = logging.getLogger(__name__)
import os
import html
import curses
@@ -194,10 +193,11 @@ import potr
from potr.context import NotEncryptedError, UnencryptedMessage, ErrorReceived, NotOTRMessage,\
STATE_ENCRYPTED, STATE_PLAINTEXT, STATE_FINISHED, Context, Account, crypt
+from slixmpp import JID, InvalidJID
+
from poezio import common
from poezio import xdg
from poezio import xhtml
-from poezio.common import safeJID
from poezio.config import config
from poezio.plugin import BasePlugin
from poezio.roster import roster
@@ -205,6 +205,9 @@ from poezio.tabs import StaticConversationTab, PrivateTab
from poezio.theming import get_theme, dump_tuple
from poezio.decorators import command_args_parser
from poezio.core.structs import Completion
+from poezio.ui.types import InfoMessage, Message
+
+log = logging.getLogger(__name__)
POLICY_FLAGS = {
'ALLOW_V1': False,
@@ -344,7 +347,7 @@ class PoezioContext(Context):
self.xmpp = xmpp
self.core = core
self.flags = {}
- self.trustName = safeJID(peer).bare
+ self.trustName = JID(peer).bare
self.in_smp = False
self.smp_own = False
self.log = 0
@@ -374,7 +377,7 @@ class PoezioContext(Context):
'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
'normal': '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT),
'jid': self.peer,
- 'bare_jid': safeJID(self.peer).bare
+ 'bare_jid': JID(self.peer).bare
}
tab = self.core.tabs.by_name(self.peer)
@@ -385,25 +388,28 @@ class PoezioContext(Context):
log.debug('OTR conversation with %s refreshed', self.peer)
if self.getCurrentTrust():
msg = OTR_REFRESH_TRUSTED % format_dict
- tab.add_message(msg, typ=self.log)
+ tab.add_message(InfoMessage(msg))
else:
msg = OTR_REFRESH_UNTRUSTED % format_dict
- tab.add_message(msg, typ=self.log)
+ tab.add_message(InfoMessage(msg))
hl(tab)
elif newstate == STATE_FINISHED or newstate == STATE_PLAINTEXT:
log.debug('OTR conversation with %s finished', self.peer)
if tab:
- tab.add_message(OTR_END % format_dict, typ=self.log)
+ tab.add_message(InfoMessage(OTR_END % format_dict))
hl(tab)
elif newstate == STATE_ENCRYPTED and tab:
if self.getCurrentTrust():
- tab.add_message(OTR_START_TRUSTED % format_dict, typ=self.log)
+ tab.add_message(InfoMessage(OTR_START_TRUSTED % format_dict))
else:
format_dict['our_fpr'] = self.user.getPrivkey()
format_dict['remote_fpr'] = self.getCurrentKey()
- tab.add_message(OTR_TUTORIAL % format_dict, typ=0)
tab.add_message(
- OTR_START_UNTRUSTED % format_dict, typ=self.log)
+ InfoMessage(OTR_TUTORIAL % format_dict),
+ )
+ tab.add_message(
+ InfoMessage(OTR_START_UNTRUSTED % format_dict),
+ )
hl(tab)
log.debug('Set encryption state of %s to %s', self.peer,
@@ -455,8 +461,9 @@ class PoezioAccount(Account):
if acc != self.name or proto != 'xmpp':
continue
- jid = safeJID(ctx).bare
- if not jid:
+ try:
+ jid = JID(ctx).bare
+ except InvalidJID:
continue
self.setTrust(jid, fpr, trust)
except:
@@ -589,7 +596,7 @@ class Plugin(BasePlugin):
"""
Retrieve or create an OTR context
"""
- jid = safeJID(jid)
+ jid = JID(jid)
if jid.full not in self.contexts:
flags = POLICY_FLAGS.copy()
require = self.config.get_by_tabname(
@@ -607,6 +614,8 @@ class Plugin(BasePlugin):
"""
Message received
"""
+ if msg['from'].bare == self.core.xmpp.boundjid.bare:
+ return
format_dict = {
'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
@@ -639,7 +648,7 @@ class Plugin(BasePlugin):
# Received an OTR error
proto_error = err.args[0].error # pylint: disable=no-member
format_dict['err'] = proto_error.decode('utf-8', errors='replace')
- tab.add_message(OTR_ERROR % format_dict, typ=0)
+ tab.add_message(InfoMessage(OTR_ERROR % format_dict))
del msg['body']
del msg['html']
hl(tab)
@@ -649,7 +658,7 @@ class Plugin(BasePlugin):
# Encrypted message received, but unreadable as we do not have
# an OTR session in place.
text = MESSAGE_UNREADABLE % format_dict
- tab.add_message(text, jid=msg['from'], typ=0)
+ tab.add_message(InfoMessage(text))
hl(tab)
del msg['body']
del msg['html']
@@ -658,7 +667,7 @@ class Plugin(BasePlugin):
except crypt.InvalidParameterError:
# Malformed OTR payload and stuff
text = MESSAGE_INVALID % format_dict
- tab.add_message(text, jid=msg['from'], typ=0)
+ tab.add_message(InfoMessage(text))
hl(tab)
del msg['body']
del msg['html']
@@ -669,7 +678,7 @@ class Plugin(BasePlugin):
import traceback
exc = traceback.format_exc()
format_dict['exc'] = exc
- tab.add_message(POTR_ERROR % format_dict, typ=0)
+ tab.add_message(InfoMessage(POTR_ERROR % format_dict))
log.error('Unspecified error in the OTR plugin', exc_info=True)
return
# No error, proceed with the message
@@ -688,10 +697,10 @@ class Plugin(BasePlugin):
abort = get_tlv(tlvs, potr.proto.SMPABORTTLV)
if abort:
ctx.reset_smp()
- tab.add_message(SMP_ABORTED_PEER % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_ABORTED_PEER % format_dict))
elif ctx.in_smp and not ctx.smpIsValid():
ctx.reset_smp()
- tab.add_message(SMP_ABORTED % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_ABORTED % format_dict))
elif smp1 or smp1q:
# Received an SMP request (with a question or not)
if smp1q:
@@ -709,22 +718,22 @@ class Plugin(BasePlugin):
# we did not initiate it
ctx.smp_own = False
format_dict['q'] = question
- tab.add_message(SMP_REQUESTED % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_REQUESTED % format_dict))
elif smp2:
# SMP reply received
if not ctx.in_smp:
ctx.reset_smp()
else:
- tab.add_message(SMP_PROGRESS % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_PROGRESS % format_dict))
elif smp3 or smp4:
# Type 4 (SMP message 3) or 5 (SMP message 4) TLVs received
# in both cases it is the final message of the SMP exchange
if ctx.smpIsSuccess():
- tab.add_message(SMP_SUCCESS % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_SUCCESS % format_dict))
if not ctx.getCurrentTrust():
- tab.add_message(SMP_RECIPROCATE % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_RECIPROCATE % format_dict))
else:
- tab.add_message(SMP_FAIL % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_FAIL % format_dict))
ctx.reset_smp()
hl(tab)
self.core.refresh_window()
@@ -736,7 +745,13 @@ class Plugin(BasePlugin):
"""
format_dict['msg'] = err.args[0].decode('utf-8')
text = MESSAGE_UNENCRYPTED % format_dict
- tab.add_message(text, jid=msg['from'], typ=ctx.log)
+ tab.add_message(
+ Message(
+ text,
+ nickname=tab.nick,
+ jid=msg['from'],
+ ),
+ )
del msg['body']
del msg['html']
hl(tab)
@@ -780,12 +795,14 @@ class Plugin(BasePlugin):
if decode_newlines:
body = body.replace('<br/>', '\n').replace('<br>', '\n')
tab.add_message(
- body,
- nickname=tab.nick,
- jid=msg['from'],
- forced_user=user,
- typ=ctx.log,
- nick_color=nick_color)
+ Message(
+ body,
+ nickname=tab.nick,
+ jid=msg['from'],
+ user=user,
+ nick_color=nick_color
+ ),
+ )
hl(tab)
self.core.refresh_window()
del msg['body']
@@ -795,9 +812,11 @@ class Plugin(BasePlugin):
Find an OTR session from a bare JID.
"""
for ctx in self.contexts:
- if safeJID(
- ctx
- ).bare == bare_jid and self.contexts[ctx].state == STATE_ENCRYPTED:
+ try:
+ jid = JID(ctx).bare
+ except InvalidJID:
+ continue
+ if jid == bare_jid and self.contexts[ctx].state == STATE_ENCRYPTED:
return self.contexts[ctx]
return None
@@ -826,19 +845,21 @@ class Plugin(BasePlugin):
tab.send_chat_state('inactive', always_send=True)
tab.add_message(
- msg['body'],
- nickname=self.core.own_nick or tab.own_nick,
- nick_color=get_theme().COLOR_OWN_NICK,
- identifier=msg['id'],
- jid=self.core.xmpp.boundjid,
- typ=ctx.log)
+ Message(
+ msg['body'],
+ nickname=self.core.own_nick or tab.own_nick,
+ nick_color=get_theme().COLOR_OWN_NICK,
+ identifier=msg['id'],
+ jid=self.core.xmpp.boundjid,
+ ),
+ )
# remove everything from the message so that it doesn’t get sent
del msg['body']
del msg['replace']
del msg['html']
elif is_relevant(tab) and ctx and ctx.getPolicy('REQUIRE_ENCRYPTION'):
warning_msg = MESSAGE_NOT_SENT % format_dict
- tab.add_message(warning_msg, typ=0)
+ tab.add_message(InfoMessage(warning_msg))
del msg['body']
del msg['replace']
del msg['html']
@@ -856,7 +877,7 @@ class Plugin(BasePlugin):
('\n - /message %s' % jid) for jid in res)
format_dict['help'] = help_msg
warning_msg = INCOMPATIBLE_TAB % format_dict
- tab.add_message(warning_msg, typ=0)
+ tab.add_message(InfoMessage(warning_msg))
del msg['body']
del msg['replace']
del msg['html']
@@ -866,7 +887,11 @@ class Plugin(BasePlugin):
Returns the text to display in the infobar (the OTR status)
"""
context = self.get_context(jid)
- if safeJID(jid).bare == jid and context.state != STATE_ENCRYPTED:
+ try:
+ bare_jid = JID(jid).bare
+ except InvalidJID:
+ bare_jid = ''
+ if bare_jid == jid and context.state != STATE_ENCRYPTED:
ctx = self.find_encrypted_context_with_matching(jid)
if ctx:
context = ctx
@@ -900,22 +925,22 @@ class Plugin(BasePlugin):
self.otr_start(tab, name, format_dict)
elif action == 'ourfpr':
format_dict['fpr'] = self.account.getPrivkey()
- tab.add_message(OTR_OWN_FPR % format_dict, typ=0)
+ tab.add_message(InfoMessage(OTR_OWN_FPR % format_dict))
elif action == 'fpr':
if name in self.contexts:
ctx = self.contexts[name]
if ctx.getCurrentKey() is not None:
format_dict['fpr'] = ctx.getCurrentKey()
- tab.add_message(OTR_REMOTE_FPR % format_dict, typ=0)
+ tab.add_message(InfoMessage(OTR_REMOTE_FPR % format_dict))
else:
- tab.add_message(OTR_NO_FPR % format_dict, typ=0)
+ tab.add_message(InfoMessage(OTR_NO_FPR % format_dict))
elif action == 'drop':
# drop the privkey (and obviously, end the current conversations before that)
for context in self.contexts.values():
if context.state not in (STATE_FINISHED, STATE_PLAINTEXT):
context.disconnect()
self.account.drop_privkey()
- tab.add_message(KEY_DROPPED % format_dict, typ=0)
+ tab.add_message(InfoMessage(KEY_DROPPED % format_dict))
elif action == 'trust':
ctx = self.get_context(name)
key = ctx.getCurrentKey()
@@ -927,7 +952,7 @@ class Plugin(BasePlugin):
format_dict['key'] = key
ctx.setTrust(fpr, 'verified')
self.account.saveTrusts()
- tab.add_message(TRUST_ADDED % format_dict, typ=0)
+ tab.add_message(InfoMessage(TRUST_ADDED % format_dict))
elif action == 'untrust':
ctx = self.get_context(name)
key = ctx.getCurrentKey()
@@ -939,7 +964,7 @@ class Plugin(BasePlugin):
format_dict['key'] = key
ctx.setTrust(fpr, '')
self.account.saveTrusts()
- tab.add_message(TRUST_REMOVED % format_dict, typ=0)
+ tab.add_message(InfoMessage(TRUST_REMOVED % format_dict))
self.core.refresh_window()
def otr_start(self, tab, name, format_dict):
@@ -954,7 +979,7 @@ class Plugin(BasePlugin):
if otr.state != STATE_ENCRYPTED:
format_dict['secs'] = secs
text = OTR_NOT_ENABLED % format_dict
- tab.add_message(text, typ=0)
+ tab.add_message(InfoMessage(text))
self.core.refresh_window()
if secs > 0:
@@ -962,7 +987,7 @@ class Plugin(BasePlugin):
self.api.add_timed_event(event)
body = self.get_context(name).sendMessage(0, b'?OTRv?').decode()
self.core.xmpp.send_message(mto=name, mtype='chat', mbody=body)
- tab.add_message(OTR_REQUEST % format_dict, typ=0)
+ tab.add_message(InfoMessage(OTR_REQUEST % format_dict))
@staticmethod
def completion_otr(the_input):
@@ -1012,13 +1037,13 @@ class Plugin(BasePlugin):
ctx.smpInit(secret, question)
else:
ctx.smpInit(secret)
- tab.add_message(SMP_INITIATED % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_INITIATED % format_dict))
elif action == 'answer':
ctx.smpGotSecret(secret)
elif action == 'abort':
if ctx.in_smp:
ctx.smpAbort()
- tab.add_message(SMP_ABORTED % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_ABORTED % format_dict))
self.core.refresh_window()
@staticmethod
diff --git a/plugins/ping.py b/plugins/ping.py
index 4a3ba8ef..cc987bf0 100644
--- a/plugins/ping.py
+++ b/plugins/ping.py
@@ -21,8 +21,10 @@ Command
In a private or a direct conversation, you can do ``/ping`` to ping
the current interlocutor.
"""
+import asyncio
from slixmpp import InvalidJID, JID
+from slixmpp.exceptions import IqTimeout
from poezio.decorators import command_args_parser
from poezio.plugin import BasePlugin
from poezio.roster import roster
@@ -69,7 +71,7 @@ class Plugin(BasePlugin):
completion=self.completion_ping)
@command_args_parser.raw
- def command_ping(self, arg):
+ async def command_ping(self, arg):
if not arg:
return self.core.command.help('ping')
try:
@@ -78,7 +80,10 @@ class Plugin(BasePlugin):
return self.api.information('Invalid JID: %s' % arg, 'Error')
start = time.time()
- def callback(iq):
+ try:
+ iq = await self.core.xmpp.plugin['xep_0199'].send_ping(
+ jid=jid, timeout=10
+ )
delay = time.time() - start
error = False
reply = ''
@@ -101,13 +106,11 @@ class Plugin(BasePlugin):
message = '%s responded to ping after %ss%s' % (
jid, round(delay, 4), reply)
self.api.information(message, 'Info')
-
- def timeout(iq):
+ except IqTimeout:
self.api.information(
- '%s did not respond to ping after 10s: timeout' % jid, 'Info')
-
- self.core.xmpp.plugin['xep_0199'].send_ping(
- jid=jid, callback=callback, timeout=10, timeout_callback=timeout)
+ '%s did not respond to ping after 10s: timeout' % jid,
+ 'Info'
+ )
def completion_muc_ping(self, the_input):
users = [user.nick for user in self.api.current_tab().users]
@@ -117,9 +120,12 @@ class Plugin(BasePlugin):
@command_args_parser.raw
def command_private_ping(self, arg):
- if arg:
- return self.command_ping(arg)
- self.command_ping(self.api.current_tab().jid)
+ jid = arg
+ if not arg:
+ jid = self.api.current_tab().jid
+ asyncio.create_task(
+ self.command_ping(jid)
+ )
@command_args_parser.raw
def command_muc_ping(self, arg):
@@ -134,20 +140,25 @@ class Plugin(BasePlugin):
jid = JID(arg)
except InvalidJID:
return self.api.information('Invalid JID: %s' % arg, 'Error')
- self.command_ping(jid.full)
+ asyncio.create_task(
+ self.command_ping(jid.full)
+ )
@command_args_parser.raw
def command_roster_ping(self, arg):
if arg:
- self.command_ping(arg)
+ jid = arg
else:
current = self.api.current_tab().selected_row
if isinstance(current, Resource):
- self.command_ping(current.jid)
+ jid = current.jid
elif isinstance(current, Contact):
res = current.get_highest_priority_resource()
if res is not None:
- self.command_ping(res.jid)
+ jid =res.jid
+ asyncio.create_task(
+ self.command_ping(jid)
+ )
def resources(self):
l = []
diff --git a/plugins/qr.py b/plugins/qr.py
index 25530248..735c3002 100755
--- a/plugins/qr.py
+++ b/plugins/qr.py
@@ -3,11 +3,13 @@
import io
import logging
import qrcode
-import sys
+
+from typing import Dict, Callable
+
+from slixmpp import JID, InvalidJID
from poezio import windows
from poezio.tabs import Tab
-from poezio.common import safeJID
from poezio.core.structs import Command
from poezio.decorators import command_args_parser
from poezio.plugin import BasePlugin
@@ -72,7 +74,7 @@ class QrTab(Tab):
Tab.__init__(self, core)
self.state = 'highlight'
self.text = qr
- self.name = qr
+ self._name = qr
self.topic_win = windows.Topic()
self.topic_win.set_message(qr)
self.qr_win = QrWindow(qr)
@@ -169,7 +171,11 @@ class Plugin(BasePlugin):
def command_invite(self, args):
server = self.core.xmpp.boundjid.domain
if len(args) > 0:
- server = safeJID(args[0])
+ try:
+ server = JID(args[0])
+ except InvalidJID:
+ self.api.information(f'Invalid JID: {args[0]}', 'Error')
+ return
session = {
'next' : self.on_next,
'error': self.core.handler.adhoc_error
diff --git a/plugins/quote.py b/plugins/quote.py
index 20bd9133..d7bc1e2a 100644
--- a/plugins/quote.py
+++ b/plugins/quote.py
@@ -45,8 +45,10 @@ Options
"""
from poezio.core.structs import Completion
+from poezio.ui.types import Message
from poezio.plugin import BasePlugin
from poezio.xhtml import clean_text
+from poezio.theming import get_theme
from poezio import common
from poezio import tabs
@@ -74,13 +76,14 @@ class Plugin(BasePlugin):
return self.api.run_command('/help quote')
message = self.find_message(message)
if message:
+ str_time = message.time.strftime(get_theme().SHORT_TIME_FORMAT)
before = self.config.get('before_quote', '') % {
'nick': message.nickname or '',
- 'time': message.str_time
+ 'time': str_time,
}
after = self.config.get('after_quote', '') % {
'nick': message.nickname or '',
- 'time': message.str_time
+ 'time': str_time,
}
self.core.insert_input_text(
'%(before)s%(quote)s%(after)s' % {
@@ -96,7 +99,7 @@ class Plugin(BasePlugin):
if not messages:
return None
for message in messages[::-1]:
- if clean_text(message.txt) == txt:
+ if isinstance(message, Message) and clean_text(message.txt) == txt:
return message
return None
@@ -114,5 +117,8 @@ class Plugin(BasePlugin):
messages = list(filter(message_match, messages))
elif len(args) > 1:
return False
- return Completion(the_input.auto_completion,
- [clean_text(msg.txt) for msg in messages[::-1]], '')
+ return Completion(
+ the_input.auto_completion,
+ [clean_text(msg.txt) for msg in messages[::-1] if isinstance(msg, Message)],
+ ''
+ )
diff --git a/plugins/rainbow.py b/plugins/rainbow.py
index 4ab0b9ac..e5987089 100644
--- a/plugins/rainbow.py
+++ b/plugins/rainbow.py
@@ -14,7 +14,7 @@ Usage
.. note:: Can create fun things when used with :ref:`The figlet plugin <figlet-plugin>`.
-.. _#3273: https://dev.louiz.org/issues/3273
+.. _#3273: https://lab.louiz.org/poezio/poezio/-/issues/3273
"""
from poezio.plugin import BasePlugin
from poezio import xhtml
diff --git a/plugins/remove_get_trackers.py b/plugins/remove_get_trackers.py
index 423e9b4e..db1e87f3 100644
--- a/plugins/remove_get_trackers.py
+++ b/plugins/remove_get_trackers.py
@@ -6,6 +6,8 @@ import re
class Plugin(BasePlugin):
def init(self):
+ self.api.information('This plugin is deprecated and will be replaced by \'untrackme\'.', 'Warning')
+
self.api.add_event_handler('muc_say', self.remove_get_trackers)
self.api.add_event_handler('conversation_say', self.remove_get_trackers)
self.api.add_event_handler('private_say', self.remove_get_trackers)
diff --git a/plugins/reorder.py b/plugins/reorder.py
index 7be0b350..158b89bb 100644
--- a/plugins/reorder.py
+++ b/plugins/reorder.py
@@ -92,7 +92,11 @@ def parse_config(tab_config):
if pos in result or pos <= 0:
return None
- typ, name = tab_config.get(option, default=':').split(':', maxsplit=1)
+ spec = tab_config.get(option, default=':').split(':', maxsplit=1)
+ # Gap tabs are recreated automatically if there's a gap in indices.
+ if spec == 'empty':
+ return None
+ typ, name = spec
if typ not in TEXT_TO_TAB:
return None
result[pos] = (TEXT_TO_TAB[typ], name)
@@ -113,7 +117,8 @@ def parse_runtime_tablist(tablist):
for tab in tablist[1:]:
i += 1
result = check_tab(tab)
- if result:
+ # Don't serialize gap tabs as they're recreated automatically
+ if result != 'empty' and isinstance(tab, tuple(TEXT_TO_TAB.values())):
props.append((i, '%s:%s' % (result, tab.jid.full)))
return props
@@ -162,7 +167,7 @@ class Plugin(BasePlugin):
for pos in sorted(tabs_spec):
if create_gaps and pos > last + 1:
new_tabs += [
- tabs.GapTab(self.core) for i in range(pos - last - 1)
+ tabs.GapTab() for i in range(pos - last - 1)
]
cls, jid = tabs_spec[pos]
try:
@@ -172,15 +177,17 @@ class Plugin(BasePlugin):
new_tabs.append(tab)
old_tabs.remove(tab)
else:
- self.api.information('Tab %s not found. Creating it' % jid, 'Warning')
# TODO: Add support for MucTab. Requires nickname.
if cls in (tabs.DynamicConversationTab, tabs.StaticConversationTab):
+ self.api.information('Tab %s not found. Creating it' % jid, 'Warning')
new_tab = cls(self.core, jid)
new_tabs.append(new_tab)
+ else:
+ new_tabs.append(tabs.GapTab())
except:
self.api.information('Failed to create tab \'%s\'.' % jid, 'Error')
if create_gaps:
- new_tabs.append(tabs.GapTab(self.core))
+ new_tabs.append(tabs.GapTab())
finally:
last = pos
diff --git a/plugins/screen_detach.py b/plugins/screen_detach.py
index 0a2514c4..1f908513 100644
--- a/plugins/screen_detach.py
+++ b/plugins/screen_detach.py
@@ -43,10 +43,10 @@ DEFAULT_CONFIG = {
# overload if this is not how your stuff
# is configured
try:
- LOGIN = os.getlogin()
+ LOGIN = os.getlogin() or ''
LOGIN_TMUX = os.getuid()
except Exception:
- LOGIN = os.getenv('USER')
+ LOGIN = os.getenv('USER') or ''
LOGIN_TMUX = os.getuid()
SCREEN_DIR = '/var/run/screens/S-%s' % LOGIN
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/server_part.py b/plugins/server_part.py
index f29b4099..cae2248e 100644
--- a/plugins/server_part.py
+++ b/plugins/server_part.py
@@ -16,10 +16,10 @@ Command
"""
+from slixmpp import JID, InvalidJID
from poezio.plugin import BasePlugin
from poezio.tabs import MucTab
from poezio.decorators import command_args_parser
-from poezio.common import safeJID
from poezio.core.structs import Completion
@@ -42,13 +42,15 @@ class Plugin(BasePlugin):
jid = current_tab.jid.bare
message = None
elif len(args) == 1:
- jid = safeJID(args[0]).domain
- if not jid:
+ try:
+ jid = JID(args[0]).domain
+ except InvalidJID:
return self.core.command_help('server_part')
message = None
else:
- jid = safeJID(args[0]).domain
- if not jid:
+ try:
+ jid = JID(args[0]).domain
+ except InvalidJID:
return self.core.command_help('server_part')
message = args[1]
diff --git a/plugins/simple_notify.py b/plugins/simple_notify.py
index f4dfd2d2..29418f40 100644
--- a/plugins/simple_notify.py
+++ b/plugins/simple_notify.py
@@ -114,7 +114,8 @@ class Plugin(BasePlugin):
def on_conversation_msg(self, message, tab):
fro = message['from'].bare
- self.do_notify(message, fro)
+ if fro.bare != self.core.xmpp.boundjid.bare:
+ self.do_notify(message, fro)
def on_muc_msg(self, message, tab):
# Don't notify if message is from yourself
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/time_marker.py b/plugins/time_marker.py
index 76f7e589..6ce511a0 100644
--- a/plugins/time_marker.py
+++ b/plugins/time_marker.py
@@ -31,6 +31,7 @@ Messages like “2 hours, 25 minutes passed…” are automatically displayed in
from poezio.plugin import BasePlugin
from datetime import datetime, timedelta
+from poezio.ui.types import InfoMessage
class Plugin(BasePlugin):
@@ -72,4 +73,5 @@ class Plugin(BasePlugin):
delta = datetime.now() - last_message_date
if delta >= timedelta(0, self.config.get('delay', 900)):
tab.add_message(
- "%s passed…" % (format_timedelta(delta), ), str_time='')
+ InfoMessage("%s passed…" % (format_timedelta(delta), ))
+ )
diff --git a/plugins/untrackme.py b/plugins/untrackme.py
new file mode 100644
index 00000000..ceddc5c5
--- /dev/null
+++ b/plugins/untrackme.py
@@ -0,0 +1,140 @@
+"""
+ UntrackMe wannabe plugin
+"""
+
+from typing import Callable, Dict, List, Tuple, Union
+
+import re
+import logging
+from slixmpp import Message
+from poezio import tabs
+from poezio.plugin import BasePlugin
+from urllib.parse import quote as urlquote
+
+
+log = logging.getLogger(__name__)
+
+ChatTabs = Union[
+ tabs.MucTab,
+ tabs.DynamicConversationTab,
+ tabs.StaticConversationTab,
+ tabs.PrivateTab,
+]
+
+RE_URL: re.Pattern = re.compile('https?://(?P<host>[^/]+)(?P<rest>[^ ]*)')
+
+SERVICES: Dict[str, Tuple[str, bool]] = { # host: (service, proxy)
+ 'm.youtube.com': ('invidious', False),
+ 'www.youtube.com': ('invidious', False),
+ 'youtube.com': ('invidious', False),
+ 'youtu.be': ('invidious', False),
+ 'youtube-nocookie.com': ('invidious', False),
+ 'mobile.twitter.com': ('nitter', False),
+ 'www.twitter.com': ('nitter', False),
+ 'twitter.com': ('nitter', False),
+ 'pic.twitter.com': ('nitter_img', True),
+ 'pbs.twimg.com': ('nitter_img', True),
+ 'instagram.com': ('bibliogram', False),
+ 'www.instagram.com': ('bibliogram', False),
+ 'm.instagram.com': ('bibliogram', False),
+}
+
+def proxy(service: str) -> Callable[[str], str]:
+ """Some services require the original url"""
+ def inner(origin: str) -> str:
+ return service + urlquote(origin)
+ return inner
+
+
+class Plugin(BasePlugin):
+ """UntrackMe"""
+
+ default_config: Dict[str, Dict[str, Union[str, bool]]] = {
+ 'default': {
+ 'cleanup': True,
+ 'redirect': True,
+ 'display_corrections': False,
+ },
+ 'services': {
+ 'invidious': 'https://invidious.snopyta.org',
+ 'nitter': 'https://nitter.net',
+ 'bibliogram': 'https://bibliogram.art',
+ },
+ }
+
+ def init(self):
+ nitter_img = self.config.get('nitter', section='services') + '/pic/'
+ self.config.set('nitter_img', nitter_img, section='services')
+
+ self.api.add_event_handler('muc_say', self.handle_msg)
+ self.api.add_event_handler('conversation_say', self.handle_msg)
+ self.api.add_event_handler('private_say', self.handle_msg)
+
+ self.api.add_event_handler('muc_msg', self.handle_msg)
+ self.api.add_event_handler('conversation_msg', self.handle_msg)
+ self.api.add_event_handler('private_msg', self.handle_msg)
+
+ def map_services(self, match: re.Match) -> str:
+ """
+ If it matches a host that we know about, change the domain for the
+ alternative service. Some hosts needs to be proxied instead (such
+ as twitter pictures), so they're url encoded and appended to the
+ proxy service.
+ """
+
+ host = match.group('host')
+
+ dest = SERVICES.get(host)
+ if dest is None:
+ return match.group(0)
+
+ destname, proxy = dest
+ replaced = self.config.get(destname, section='services')
+ result = replaced + match.group('rest')
+
+ if proxy:
+ url = urlquote(match.group(0))
+ result = replaced + url
+
+ # TODO: count parenthesis?
+ # Removes comma at the end of a link.
+ if result[-3] == '%2C':
+ result = result[:-3] + ','
+
+ return result
+
+ def handle_msg(self, msg: Message, tab: ChatTabs) -> None:
+ orig = msg['body']
+
+ if self.config.get('cleanup', section='default'):
+ msg['body'] = self.cleanup_url(msg['body'])
+ if self.config.get('redirect', section='default'):
+ msg['body'] = self.redirect_url(msg['body'])
+
+ if self.config.get('display_corrections', section='default') and \
+ msg['body'] != orig:
+ log.debug(
+ 'UntrackMe in tab \'%s\':\nOriginal: %s\nModified: %s',
+ tab.name, orig, msg['body'],
+ )
+
+ self.api.information(
+ 'UntrackMe in tab \'{}\':\nOriginal: {}\nModified: {}'.format(
+ tab.name, orig, msg['body']
+ ),
+ 'Info',
+ )
+
+ def cleanup_url(self, txt: str) -> str:
+ # fbclid: used globally (Facebook)
+ # utm_*: used globally https://en.wikipedia.org/wiki/UTM_parameters
+ # ncid: DoubleClick (Google)
+ # ref_src, ref_url: twitter
+ # Others exist but are excluded because they are not common.
+ # See https://en.wikipedia.org/wiki/UTM_parameters
+ return re.sub('(https?://[^ ]+)&?(fbclid|dclid|ncid|utm_source|utm_medium|utm_campaign|utm_term|utm_content|ref_src|ref_url)=[^ &#]*',
+ r'\1',
+ txt)
+
+ def redirect_url(self, txt: str) -> str:
+ return RE_URL.sub(self.map_services, txt)
diff --git a/plugins/upload.py b/plugins/upload.py
index 7e25070e..6926c075 100644
--- a/plugins/upload.py
+++ b/plugins/upload.py
@@ -16,12 +16,15 @@ This plugin adds a command to the chat tabs.
"""
+
+from typing import Optional
+
import asyncio
import traceback
from os.path import expanduser
from glob import glob
-from slixmpp.plugins.xep_0363.http_upload import UploadServiceNotFound
+from slixmpp.plugins.xep_0363.http_upload import FileTooBig, HTTPError, UploadServiceNotFound
from poezio.plugin import BasePlugin
from poezio.core.structs import Completion
@@ -30,9 +33,19 @@ from poezio import tabs
class Plugin(BasePlugin):
+ dependencies = {'embed'}
+
def init(self):
+ self.embed = self.refs['embed']
+
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,
@@ -43,18 +56,29 @@ class Plugin(BasePlugin):
short='Upload a file',
completion=self.completion_filename)
- async def async_upload(self, filename):
+ 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
+ return None
+ except (FileTooBig, HTTPError) as exn:
+ self.api.information(str(exn), 'Error')
+ return None
except Exception:
exception = traceback.format_exc()
self.api.information('Failed to upload file: %s' % exception,
'Error')
- return
- self.core.insert_input_text(url)
+ return None
+ return url
+
+ 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, tab)
@command_args_parser.quoted(1)
def command_upload(self, args):
@@ -63,7 +87,9 @@ class Plugin(BasePlugin):
return
filename, = args
filename = expanduser(filename)
- asyncio.ensure_future(self.async_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/uptime.py b/plugins/uptime.py
index d5a07b7b..a55af970 100644
--- a/plugins/uptime.py
+++ b/plugins/uptime.py
@@ -12,8 +12,10 @@ Command
Retrieve the uptime of the server of ``jid``.
"""
from poezio.plugin import BasePlugin
-from poezio.common import parse_secs_to_str, safeJID
+from poezio.common import parse_secs_to_str
from slixmpp.xmlstream import ET
+from slixmpp import JID, InvalidJID
+from slixmpp.exceptions import IqError, IqTimeout
class Plugin(BasePlugin):
@@ -25,19 +27,23 @@ class Plugin(BasePlugin):
help='Ask for the uptime of a server or component (see XEP-0012).',
short='Get the uptime')
- def command_uptime(self, arg):
- def callback(iq):
- for query in iq.xml.getiterator('{jabber:iq:last}query'):
+ async def command_uptime(self, arg):
+ try:
+ jid = JID(arg)
+ except InvalidJID:
+ return
+ iq = self.core.xmpp.make_iq_get(ito=jid.server)
+ iq.append(ET.Element('{jabber:iq:last}query'))
+ try:
+ iq = await iq.send()
+ result = iq.xml.find('{jabber:iq:last}query')
+ if result is not None:
self.api.information(
'Server %s online since %s' %
(iq['from'], parse_secs_to_str(
- int(query.attrib['seconds']))), 'Info')
+ int(result.attrib['seconds']))), 'Info')
return
- self.api.information('Could not retrieve uptime', 'Error')
+ except (IqError, IqTimeout):
+ pass
+ self.api.information('Could not retrieve uptime', 'Error')
- jid = safeJID(arg)
- if not jid.server:
- return
- iq = self.core.xmpp.make_iq_get(ito=jid.server)
- iq.append(ET.Element('{jabber:iq:last}query'))
- iq.send(callback=callback)
diff --git a/plugins/user_extras.py b/plugins/user_extras.py
new file mode 100644
index 00000000..96559111
--- /dev/null
+++ b/plugins/user_extras.py
@@ -0,0 +1,634 @@
+"""
+This plugin enables rich presence events, such as mood, activity, gaming or tune.
+
+.. versionadded:: 0.14
+ This plugin was previously provided in the poezio core features.
+
+Command
+-------
+.. glossary::
+
+ /activity
+ **Usage:** ``/activity [<general> [specific] [comment]]``
+
+ Send your current activity to your contacts (use the completion to cycle
+ through all the general and specific possible activities).
+
+ Nothing means "stop broadcasting an activity".
+
+ /mood
+ **Usage:** ``/mood [<mood> [comment]]``
+ Send your current mood to your contacts (use the completion to cycle
+ through all the possible moods).
+
+ Nothing means "stop broadcasting a mood".
+
+ /gaming
+ **Usage:** ``/gaming [<game name> [server address]]``
+
+ Send your current gaming activity to your contacts.
+
+ Nothing means "stop broadcasting a gaming activity".
+
+
+Configuration
+-------------
+
+.. glossary::
+
+ display_gaming_notifications
+
+ **Default value:** ``true``
+
+ If set to true, notifications about the games your contacts are playing
+ will be displayed in the info buffer as 'Gaming' messages.
+
+ display_tune_notifications
+
+ **Default value:** ``true``
+
+ If set to true, notifications about the music your contacts listen to
+ will be displayed in the info buffer as 'Tune' messages.
+
+ display_mood_notifications
+
+ **Default value:** ``true``
+
+ If set to true, notifications about the mood of your contacts
+ will be displayed in the info buffer as 'Mood' messages.
+
+ display_activity_notifications
+
+ **Default value:** ``true``
+
+ If set to true, notifications about the current activity of your contacts
+ will be displayed in the info buffer as 'Activity' messages.
+
+ enable_user_activity
+
+ **Default value:** ``true``
+
+ Set this to ``false`` if you don’t want to receive the activity of your contacts.
+
+ enable_user_gaming
+
+ **Default value:** ``true``
+
+ Set this to ``false`` if you don’t want to receive the gaming activity of your contacts.
+
+ enable_user_mood
+
+ **Default value:** ``true``
+
+ Set this to ``false`` if you don’t want to receive the mood of your contacts.
+
+ enable_user_tune
+
+ **Default value:** ``true``
+
+ If this is set to ``false``, you will no longer be subscribed to tune events,
+ and the :term:`display_tune_notifications` option will be ignored.
+
+
+"""
+import asyncio
+from functools import reduce
+from typing import Dict
+
+from slixmpp import InvalidJID, JID, Message
+from poezio.decorators import command_args_parser
+from poezio.plugin import BasePlugin
+from poezio.roster import roster
+from poezio.contact import Contact, Resource
+from poezio.core.structs import Completion
+from poezio import common
+from poezio import tabs
+
+
+class Plugin(BasePlugin):
+
+ default_config = {
+ 'user_extras': {
+ 'display_gaming_notifications': True,
+ 'display_mood_notifications': True,
+ 'display_activity_notifications': True,
+ 'display_tune_notifications': True,
+ 'enable_user_activity': True,
+ 'enable_user_gaming': True,
+ 'enable_user_mood': True,
+ 'enable_user_tune': True,
+ }
+ }
+
+ def init(self):
+ for plugin in {'xep_0196', 'xep_0108', 'xep_0107', 'xep_0118'}:
+ self.core.xmpp.register_plugin(plugin)
+ self.api.add_command(
+ 'activity',
+ self.command_activity,
+ usage='[<general> [specific] [text]]',
+ help='Send your current activity to your contacts '
+ '(use the completion). Nothing means '
+ '"stop broadcasting an activity".',
+ short='Send your activity.',
+ completion=self.comp_activity
+ )
+ self.api.add_command(
+ 'mood',
+ self.command_mood,
+ usage='[<mood> [text]]',
+ help='Send your current mood to your contacts '
+ '(use the completion). Nothing means '
+ '"stop broadcasting a mood".',
+ short='Send your mood.',
+ completion=self.comp_mood,
+ )
+ self.api.add_command(
+ 'gaming',
+ self.command_gaming,
+ usage='[<game name> [server address]]',
+ help='Send your current gaming activity to '
+ 'your contacts. Nothing means "stop '
+ 'broadcasting a gaming activity".',
+ short='Send your gaming activity.',
+ completion=None
+ )
+ handlers = [
+ ('user_mood_publish', self.on_mood_event),
+ ('user_tune_publish', self.on_tune_event),
+ ('user_gaming_publish', self.on_gaming_event),
+ ('user_activity_publish', self.on_activity_event),
+ ]
+ for name, handler in handlers:
+ self.core.xmpp.add_event_handler(name, handler)
+
+ def cleanup(self):
+ handlers = [
+ ('user_mood_publish', self.on_mood_event),
+ ('user_tune_publish', self.on_tune_event),
+ ('user_gaming_publish', self.on_gaming_event),
+ ('user_activity_publish', self.on_activity_event),
+ ]
+ for name, handler in handlers:
+ self.core.xmpp.del_event_handler(name, handler)
+ asyncio.create_task(self._stop())
+
+ async def _stop(self):
+ await asyncio.gather(
+ self.core.xmpp.plugin['xep_0108'].stop(),
+ self.core.xmpp.plugin['xep_0107'].stop(),
+ self.core.xmpp.plugin['xep_0196'].stop(),
+ )
+
+
+ @command_args_parser.quoted(0, 2)
+ async def command_mood(self, args):
+ """
+ /mood [<mood> [text]]
+ """
+ if not args:
+ return await self.core.xmpp.plugin['xep_0107'].stop()
+ mood = args[0]
+ if mood not in MOODS:
+ return self.core.information(
+ '%s is not a correct value for a mood.' % mood, 'Error')
+ if len(args) == 2:
+ text = args[1]
+ else:
+ text = None
+ await self.core.xmpp.plugin['xep_0107'].publish_mood(
+ mood, text
+ )
+
+ @command_args_parser.quoted(0, 3)
+ async def command_activity(self, args):
+ """
+ /activity [<general> [specific] [text]]
+ """
+ length = len(args)
+ if not length:
+ return await self.core.xmpp.plugin['xep_0108'].stop()
+
+ general = args[0]
+ if general not in ACTIVITIES:
+ return self.api.information(
+ '%s is not a correct value for an activity' % general, 'Error')
+ specific = None
+ text = None
+ if length == 2:
+ if args[1] in ACTIVITIES[general]:
+ specific = args[1]
+ else:
+ text = args[1]
+ elif length == 3:
+ specific = args[1]
+ text = args[2]
+ if specific and specific not in ACTIVITIES[general]:
+ return self.core.information(
+ '%s is not a correct value '
+ 'for an activity' % specific, 'Error')
+ await self.core.xmpp.plugin['xep_0108'].publish_activity(
+ general, specific, text
+ )
+
+ @command_args_parser.quoted(0, 2)
+ async def command_gaming(self, args):
+ """
+ /gaming [<game name> [server address]]
+ """
+ if not args:
+ return await self.core.xmpp.plugin['xep_0196'].stop()
+
+ name = args[0]
+ if len(args) > 1:
+ address = args[1]
+ else:
+ address = None
+ return await self.core.xmpp.plugin['xep_0196'].publish_gaming(
+ name=name, server_address=address
+ )
+
+ def comp_activity(self, the_input):
+ """Completion for /activity"""
+ n = the_input.get_argument_position(quoted=True)
+ args = common.shell_split(the_input.text)
+ if n == 1:
+ return Completion(
+ the_input.new_completion,
+ sorted(ACTIVITIES.keys()),
+ n,
+ quotify=True)
+ elif n == 2:
+ if args[1] in ACTIVITIES:
+ l = list(ACTIVITIES[args[1]])
+ l.remove('category')
+ l.sort()
+ return Completion(the_input.new_completion, l, n, quotify=True)
+
+ def comp_mood(self, the_input):
+ """Completion for /mood"""
+ n = the_input.get_argument_position(quoted=True)
+ if n == 1:
+ return Completion(
+ the_input.new_completion,
+ sorted(MOODS.keys()),
+ 1,
+ quotify=True)
+
+ def on_gaming_event(self, message: Message):
+ """
+ Called when a pep notification for user gaming
+ is received
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ item = message['pubsub_event']['items']['item']
+ old_gaming = contact.rich_presence['gaming']
+ xml_node = item.xml.find('{urn:xmpp:gaming:0}game')
+ # list(xml_node) checks whether there are children or not.
+ if xml_node is not None and list(xml_node):
+ item = item['gaming']
+ # only name and server_address are used for now
+ contact.rich_presence['gaming'] = {
+ 'character_name': item['character_name'],
+ 'character_profile': item['character_profile'],
+ 'name': item['name'],
+ 'level': item['level'],
+ 'uri': item['uri'],
+ 'server_name': item['server_name'],
+ 'server_address': item['server_address'],
+ }
+ else:
+ contact.rich_presence['gaming'] = {}
+
+ if old_gaming != contact.rich_presence['gaming'] and self.config.get(
+ 'display_gaming_notifications'):
+ if contact.rich_presence['gaming']:
+ self.core.information(
+ '%s is playing %s' % (contact.bare_jid,
+ common.format_gaming_string(
+ contact.rich_presence['gaming'])), 'Gaming')
+ else:
+ self.core.information(contact.bare_jid + ' stopped playing.',
+ 'Gaming')
+
+ def on_mood_event(self, message: Message):
+ """
+ Called when a pep notification for a user mood
+ is received.
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ item = message['pubsub_event']['items']['item']
+ old_mood = contact.rich_presence.get('mood')
+ plugin = item.get_plugin('mood', check=True)
+ if plugin:
+ mood = item['mood']['value']
+ else:
+ mood = ''
+ if mood:
+ mood = MOODS.get(mood, mood)
+ text = item['mood']['text']
+ if text:
+ mood = '%s (%s)' % (mood, text)
+ contact.rich_presence['mood'] = mood
+ else:
+ contact.rich_presence['mood'] = ''
+
+ if old_mood != contact.rich_presence['mood'] and self.config.get(
+ 'display_mood_notifications'):
+ if contact.rich_presence['mood']:
+ self.core.information(
+ 'Mood from ' + contact.bare_jid + ': ' + contact.rich_presence['mood'],
+ 'Mood')
+ else:
+ self.core.information(
+ contact.bare_jid + ' stopped having their mood.', 'Mood')
+
+ def on_activity_event(self, message: Message):
+ """
+ Called when a pep notification for a user activity
+ is received.
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ item = message['pubsub_event']['items']['item']
+ old_activity = contact.rich_presence['activity']
+ xml_node = item.xml.find('{http://jabber.org/protocol/activity}activity')
+ # list(xml_node) checks whether there are children or not.
+ if xml_node is not None and list(xml_node):
+ try:
+ activity = item['activity']['value']
+ except ValueError:
+ return
+ if activity[0]:
+ general = ACTIVITIES.get(activity[0])
+ if general is None:
+ return
+ s = general['category']
+ if activity[1]:
+ s = s + '/' + general.get(activity[1], 'other')
+ text = item['activity']['text']
+ if text:
+ s = '%s (%s)' % (s, text)
+ contact.rich_presence['activity'] = s
+ else:
+ contact.rich_presence['activity'] = ''
+ else:
+ contact.rich_presence['activity'] = ''
+
+ if old_activity != contact.rich_presence['activity'] and self.config.get(
+ 'display_activity_notifications'):
+ if contact.rich_presence['activity']:
+ self.core.information(
+ 'Activity from ' + contact.bare_jid + ': ' +
+ contact.rich_presence['activity'], 'Activity')
+ else:
+ self.core.information(
+ contact.bare_jid + ' stopped doing their activity.',
+ 'Activity')
+
+ def on_tune_event(self, message: Message):
+ """
+ Called when a pep notification for a user tune
+ is received
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ roster.modified()
+ item = message['pubsub_event']['items']['item']
+ old_tune = contact.rich_presence['tune']
+ xml_node = item.xml.find('{http://jabber.org/protocol/tune}tune')
+ # list(xml_node) checks whether there are children or not.
+ if xml_node is not None and list(xml_node):
+ item = item['tune']
+ contact.rich_presence['tune'] = {
+ 'artist': item['artist'],
+ 'length': item['length'],
+ 'rating': item['rating'],
+ 'source': item['source'],
+ 'title': item['title'],
+ 'track': item['track'],
+ 'uri': item['uri']
+ }
+ else:
+ contact.rich_presence['tune'] = {}
+
+ if old_tune != contact.rich_presence['tune'] and self.config.get(
+ 'display_tune_notifications'):
+ if contact.rich_presence['tune']:
+ self.core.information(
+ 'Tune from ' + message['from'].bare + ': ' +
+ common.format_tune_string(contact.rich_presence['tune']), 'Tune')
+ else:
+ self.core.information(
+ contact.bare_jid + ' stopped listening to music.', 'Tune')
+
+
+# Collection of mappings for PEP moods/activities
+# extracted directly from the XEP
+
+MOODS: Dict[str, str] = {
+ 'afraid': 'Afraid',
+ 'amazed': 'Amazed',
+ 'angry': 'Angry',
+ 'amorous': 'Amorous',
+ 'annoyed': 'Annoyed',
+ 'anxious': 'Anxious',
+ 'aroused': 'Aroused',
+ 'ashamed': 'Ashamed',
+ 'bored': 'Bored',
+ 'brave': 'Brave',
+ 'calm': 'Calm',
+ 'cautious': 'Cautious',
+ 'cold': 'Cold',
+ 'confident': 'Confident',
+ 'confused': 'Confused',
+ 'contemplative': 'Contemplative',
+ 'contented': 'Contented',
+ 'cranky': 'Cranky',
+ 'crazy': 'Crazy',
+ 'creative': 'Creative',
+ 'curious': 'Curious',
+ 'dejected': 'Dejected',
+ 'depressed': 'Depressed',
+ 'disappointed': 'Disappointed',
+ 'disgusted': 'Disgusted',
+ 'dismayed': 'Dismayed',
+ 'distracted': 'Distracted',
+ 'embarrassed': 'Embarrassed',
+ 'envious': 'Envious',
+ 'excited': 'Excited',
+ 'flirtatious': 'Flirtatious',
+ 'frustrated': 'Frustrated',
+ 'grumpy': 'Grumpy',
+ 'guilty': 'Guilty',
+ 'happy': 'Happy',
+ 'hopeful': 'Hopeful',
+ 'hot': 'Hot',
+ 'humbled': 'Humbled',
+ 'humiliated': 'Humiliated',
+ 'hungry': 'Hungry',
+ 'hurt': 'Hurt',
+ 'impressed': 'Impressed',
+ 'in_awe': 'In awe',
+ 'in_love': 'In love',
+ 'indignant': 'Indignant',
+ 'interested': 'Interested',
+ 'intoxicated': 'Intoxicated',
+ 'invincible': 'Invincible',
+ 'jealous': 'Jealous',
+ 'lonely': 'Lonely',
+ 'lucky': 'Lucky',
+ 'mean': 'Mean',
+ 'moody': 'Moody',
+ 'nervous': 'Nervous',
+ 'neutral': 'Neutral',
+ 'offended': 'Offended',
+ 'outraged': 'Outraged',
+ 'playful': 'Playful',
+ 'proud': 'Proud',
+ 'relaxed': 'Relaxed',
+ 'relieved': 'Relieved',
+ 'remorseful': 'Remorseful',
+ 'restless': 'Restless',
+ 'sad': 'Sad',
+ 'sarcastic': 'Sarcastic',
+ 'serious': 'Serious',
+ 'shocked': 'Shocked',
+ 'shy': 'Shy',
+ 'sick': 'Sick',
+ 'sleepy': 'Sleepy',
+ 'spontaneous': 'Spontaneous',
+ 'stressed': 'Stressed',
+ 'strong': 'Strong',
+ 'surprised': 'Surprised',
+ 'thankful': 'Thankful',
+ 'thirsty': 'Thirsty',
+ 'tired': 'Tired',
+ 'undefined': 'Undefined',
+ 'weak': 'Weak',
+ 'worried': 'Worried'
+}
+
+ACTIVITIES: Dict[str, Dict[str, str]] = {
+ 'doing_chores': {
+ 'category': 'Doing_chores',
+ 'buying_groceries': 'Buying groceries',
+ 'cleaning': 'Cleaning',
+ 'cooking': 'Cooking',
+ 'doing_maintenance': 'Doing maintenance',
+ 'doing_the_dishes': 'Doing the dishes',
+ 'doing_the_laundry': 'Doing the laundry',
+ 'gardening': 'Gardening',
+ 'running_an_errand': 'Running an errand',
+ 'walking_the_dog': 'Walking the dog',
+ 'other': 'Other',
+ },
+ 'drinking': {
+ 'category': 'Drinking',
+ 'having_a_beer': 'Having a beer',
+ 'having_coffee': 'Having coffee',
+ 'having_tea': 'Having tea',
+ 'other': 'Other',
+ },
+ 'eating': {
+ 'category': 'Eating',
+ 'having_breakfast': 'Having breakfast',
+ 'having_a_snack': 'Having a snack',
+ 'having_dinner': 'Having dinner',
+ 'having_lunch': 'Having lunch',
+ 'other': 'Other',
+ },
+ 'exercising': {
+ 'category': 'Exercising',
+ 'cycling': 'Cycling',
+ 'dancing': 'Dancing',
+ 'hiking': 'Hiking',
+ 'jogging': 'Jogging',
+ 'playing_sports': 'Playing sports',
+ 'running': 'Running',
+ 'skiing': 'Skiing',
+ 'swimming': 'Swimming',
+ 'working_out': 'Working out',
+ 'other': 'Other',
+ },
+ 'grooming': {
+ 'category': 'Grooming',
+ 'at_the_spa': 'At the spa',
+ 'brushing_teeth': 'Brushing teeth',
+ 'getting_a_haircut': 'Getting a haircut',
+ 'shaving': 'Shaving',
+ 'taking_a_bath': 'Taking a bath',
+ 'taking_a_shower': 'Taking a shower',
+ 'other': 'Other',
+ },
+ 'having_appointment': {
+ 'category': 'Having appointment',
+ 'other': 'Other',
+ },
+ 'inactive': {
+ 'category': 'Inactive',
+ 'day_off': 'Day_off',
+ 'hanging_out': 'Hanging out',
+ 'hiding': 'Hiding',
+ 'on_vacation': 'On vacation',
+ 'praying': 'Praying',
+ 'scheduled_holiday': 'Scheduled holiday',
+ 'sleeping': 'Sleeping',
+ 'thinking': 'Thinking',
+ 'other': 'Other',
+ },
+ 'relaxing': {
+ 'category': 'Relaxing',
+ 'fishing': 'Fishing',
+ 'gaming': 'Gaming',
+ 'going_out': 'Going out',
+ 'partying': 'Partying',
+ 'reading': 'Reading',
+ 'rehearsing': 'Rehearsing',
+ 'shopping': 'Shopping',
+ 'smoking': 'Smoking',
+ 'socializing': 'Socializing',
+ 'sunbathing': 'Sunbathing',
+ 'watching_a_movie': 'Watching a movie',
+ 'watching_tv': 'Watching tv',
+ 'other': 'Other',
+ },
+ 'talking': {
+ 'category': 'Talking',
+ 'in_real_life': 'In real life',
+ 'on_the_phone': 'On the phone',
+ 'on_video_phone': 'On video phone',
+ 'other': 'Other',
+ },
+ 'traveling': {
+ 'category': 'Traveling',
+ 'commuting': 'Commuting',
+ 'driving': 'Driving',
+ 'in_a_car': 'In a car',
+ 'on_a_bus': 'On a bus',
+ 'on_a_plane': 'On a plane',
+ 'on_a_train': 'On a train',
+ 'on_a_trip': 'On a trip',
+ 'walking': 'Walking',
+ 'cycling': 'Cycling',
+ 'other': 'Other',
+ },
+ 'undefined': {
+ 'category': 'Undefined',
+ 'other': 'Other',
+ },
+ 'working': {
+ 'category': 'Working',
+ 'coding': 'Coding',
+ 'in_a_meeting': 'In a meeting',
+ 'writing': 'Writing',
+ 'studying': 'Studying',
+ 'other': 'Other',
+ }
+}
diff --git a/plugins/vcard.py b/plugins/vcard.py
index e3a776e3..b0c8e396 100644
--- a/plugins/vcard.py
+++ b/plugins/vcard.py
@@ -25,15 +25,16 @@ Command
vcard from the current interlocutor, and in the contact list to do it
on the currently selected contact.
"""
+import asyncio
from poezio.decorators import command_args_parser
from poezio.plugin import BasePlugin
from poezio.roster import roster
-from poezio.common import safeJID
from poezio.contact import Contact, Resource
from poezio.core.structs import Completion
from poezio import tabs
from slixmpp.jid import JID, InvalidJID
+from slixmpp.exceptions import IqTimeout
class Plugin(BasePlugin):
@@ -240,19 +241,18 @@ class Plugin(BasePlugin):
on_cancel = lambda form: self.core.close_tab()
self.core.open_new_form(form, on_cancel, on_validate)
- def _get_vcard(self, jid):
+ async def _get_vcard(self, jid):
'''Send an iq to ask the vCard.'''
-
- def timeout_cb(iq):
+ try:
+ vcard = await self.core.xmpp.plugin['xep_0054'].get_vcard(
+ jid=jid,
+ timeout=30,
+ )
+ self._handle_vcard(vcard)
+ except IqTimeout:
self.api.information('Timeout while retrieving vCard for %s' % jid,
'Error')
- return
- self.core.xmpp.plugin['xep_0054'].get_vcard(
- jid=jid,
- timeout=30,
- callback=self._handle_vcard,
- timeout_callback=timeout_cb)
@command_args_parser.raw
def command_vcard(self, arg):
@@ -266,7 +266,9 @@ class Plugin(BasePlugin):
self.api.information('Invalid JID: %s' % arg, 'Error')
return
- self._get_vcard(jid)
+ asyncio.create_task(
+ self._get_vcard(jid)
+ )
@command_args_parser.raw
def command_private_vcard(self, arg):
@@ -285,10 +287,12 @@ class Plugin(BasePlugin):
jid = self.api.current_tab().jid.bare + '/' + user.nick
else:
try:
- jid = safeJID(arg)
+ jid = JID(arg)
except InvalidJID:
return self.api.information('Invalid JID: %s' % arg, 'Error')
- self._get_vcard(jid)
+ asyncio.create_task(
+ self._get_vcard(jid)
+ )
@command_args_parser.raw
def command_roster_vcard(self, arg):
@@ -297,9 +301,13 @@ class Plugin(BasePlugin):
return
current = self.api.current_tab().selected_row
if isinstance(current, Resource):
- self._get_vcard(JID(current.jid).bare)
+ asyncio.create_task(
+ self._get_vcard(JID(current.jid).bare)
+ )
elif isinstance(current, Contact):
- self._get_vcard(current.bare_jid)
+ asyncio.create_task(
+ self._get_vcard(current.bare_jid)
+ )
def completion_vcard(self, the_input):
contacts = [contact.bare_jid for contact in roster.get_contacts()]
diff --git a/poezio/args.py b/poezio/args.py
index e1ebe5e0..3907fc88 100644
--- a/poezio/args.py
+++ b/poezio/args.py
@@ -1,12 +1,16 @@
"""
Module related to the argument parsing
-
-There is a fallback to the deprecated optparse if argparse is not found
"""
+import pkg_resources
+import stat
+import sys
+from argparse import ArgumentParser, SUPPRESS, Namespace
from pathlib import Path
-from argparse import ArgumentParser, SUPPRESS
+from shutil import copy2
+from typing import Tuple
from poezio.version import __version__
+from poezio import xdg
def parse_args(CONFIG_PATH: Path):
@@ -47,5 +51,36 @@ def parse_args(CONFIG_PATH: Path):
metavar="VERSION",
default=__version__
)
- options = parser.parse_args()
- return options
+ return parser.parse_args()
+
+
+def run_cmdline_args() -> Tuple[Namespace, bool]:
+ "Parse the command line arguments"
+ options = parse_args(xdg.CONFIG_HOME)
+ firstrun = False
+
+ # Copy a default file if none exists
+ if not options.filename.is_file():
+ try:
+ options.filename.parent.mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ sys.stderr.write(
+ 'Poezio was unable to create the config directory: %s\n' % e)
+ sys.exit(1)
+ default = Path(__file__).parent / '..' / 'data' / 'default_config.cfg'
+ other = Path(
+ pkg_resources.resource_filename('poezio', 'default_config.cfg'))
+ if default.is_file():
+ copy2(str(default), str(options.filename))
+ elif other.is_file():
+ copy2(str(other), str(options.filename))
+
+ # Inside the nixstore and possibly other distributions, the reference
+ # file is readonly, so is the copy.
+ # Make it writable by the user who just created it.
+ if options.filename.exists():
+ options.filename.chmod(options.filename.stat().st_mode
+ | stat.S_IWUSR)
+ firstrun = True
+
+ return (options, firstrun)
diff --git a/poezio/asyncio.py b/poezio/asyncio_fix.py
index d333ffa6..d333ffa6 100644
--- a/poezio/asyncio.py
+++ b/poezio/asyncio_fix.py
diff --git a/poezio/bookmarks.py b/poezio/bookmarks.py
index ccda2b93..64d7a437 100644
--- a/poezio/bookmarks.py
+++ b/poezio/bookmarks.py
@@ -30,10 +30,20 @@ Adding a remote bookmark:
import functools
import logging
-from typing import Optional, List, Union
-
-from slixmpp import InvalidJID, JID
+from typing import (
+ Callable,
+ List,
+ Optional,
+ Union,
+)
+
+from slixmpp import (
+ InvalidJID,
+ JID,
+)
+from slixmpp.exceptions import IqError, IqTimeout
from slixmpp.plugins.xep_0048 import Bookmarks, Conference, URL
+from poezio.connection import Connection
from poezio.config import config
log = logging.getLogger(__name__)
@@ -152,8 +162,8 @@ class Bookmark:
class BookmarkList:
def __init__(self):
- self.bookmarks = [] # type: List[Bookmark]
- preferred = config.get('use_bookmarks_method').lower()
+ self.bookmarks: List[Bookmark] = []
+ preferred = config.getstr('use_bookmarks_method').lower()
if preferred not in ('pep', 'privatexml'):
preferred = 'privatexml'
self.preferred = preferred
@@ -171,7 +181,7 @@ class BookmarkList:
return self.bookmarks[key]
return None
- def __in__(self, key) -> bool:
+ def __contains__(self, key) -> bool:
if isinstance(key, (str, JID)):
for bookmark in self.bookmarks:
if bookmark.jid == key:
@@ -213,17 +223,17 @@ class BookmarkList:
self.preferred = value
config.set_and_save('use_bookmarks_method', value)
- def save_remote(self, xmpp, callback):
+ async def save_remote(self, xmpp: Connection):
"""Save the remote bookmarks."""
if not any(self.available_storage.values()):
return
method = 'xep_0049' if self.preferred == 'privatexml' else 'xep_0223'
if method:
- xmpp.plugin['xep_0048'].set_bookmarks(
+ return await xmpp.plugin['xep_0048'].set_bookmarks(
stanza_storage(self.bookmarks),
method=method,
- callback=callback)
+ )
def save_local(self):
"""Save the local bookmarks."""
@@ -231,84 +241,60 @@ class BookmarkList:
if bookmark.method == 'local')
config.set_and_save('rooms', local)
- def save(self, xmpp, core=None, callback=None):
+ async def save(self, xmpp: Connection, core=None):
"""Save all the bookmarks."""
self.save_local()
-
- def _cb(iq):
- if callback:
- callback(iq)
- if iq["type"] == "error" and core:
- core.information('Could not save remote bookmarks.', 'Error')
- elif core:
- core.information('Bookmarks saved', 'Info')
-
- if config.get('use_remote_bookmarks'):
- self.save_remote(xmpp, _cb)
-
- def get_pep(self, xmpp, callback):
+ if config.getbool('use_remote_bookmarks'):
+ try:
+ result = await self.save_remote(xmpp)
+ if core is not None:
+ core.information('Bookmarks saved', 'Info')
+ return result
+ except (IqError, IqTimeout):
+ if core is not None:
+ core.information(
+ 'Could not save remote bookmarks.',
+ 'Error'
+ )
+ raise
+
+ async def get_pep(self, xmpp: Connection):
"""Add the remotely stored bookmarks via pep to the list."""
+ iq = await xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223')
+ for conf in iq['pubsub']['items']['item']['bookmarks'][
+ 'conferences']:
+ if isinstance(conf, URL):
+ continue
+ bookm = Bookmark.parse(conf)
+ self.append(bookm)
+ return iq
- def _cb(iq):
- if iq['type'] == 'result':
- for conf in iq['pubsub']['items']['item']['bookmarks'][
- 'conferences']:
- if isinstance(conf, URL):
- continue
- b = Bookmark.parse(conf)
- self.append(b)
- if callback:
- callback(iq)
-
- xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223', callback=_cb)
-
- def get_privatexml(self, xmpp, callback):
+ async def get_privatexml(self, xmpp: Connection):
"""
Fetch the remote bookmarks stored via privatexml.
"""
- def _cb(iq):
- if iq['type'] == 'result':
- for conf in iq['private']['bookmarks']['conferences']:
- b = Bookmark.parse(conf)
- self.append(b)
- if callback:
- callback(iq)
-
- xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049', callback=_cb)
+ iq = await xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049')
+ for conf in iq['private']['bookmarks']['conferences']:
+ bookm = Bookmark.parse(conf)
+ self.append(bookm)
+ return iq
- def get_remote(self, xmpp, information, callback):
+ async def get_remote(self, xmpp: Connection, information: Callable):
"""Add the remotely stored bookmarks to the list."""
- force = config.get('force_remote_bookmarks')
- if xmpp.anon or not (any(self.available_storage.values()) or force):
+ if xmpp.anon or not any(self.available_storage.values()):
information('No remote bookmark storage available', 'Warning')
return
-
- if force and not any(self.available_storage.values()):
- old_callback = callback
- method = 'pep' if self.preferred == 'pep' else 'privatexml'
-
- def new_callback(result):
- if result['type'] != 'error':
- self.available_storage[method] = True
- old_callback(result)
- else:
- information('No remote bookmark storage available',
- 'Warning')
-
- callback = new_callback
-
if self.preferred == 'pep':
- self.get_pep(xmpp, callback=callback)
+ return await self.get_pep(xmpp)
else:
- self.get_privatexml(xmpp, callback=callback)
+ return await self.get_privatexml(xmpp)
def get_local(self):
"""Add the locally stored bookmarks to the list."""
- rooms = config.get('rooms')
+ rooms = config.getlist('rooms')
if not rooms:
return
- rooms = rooms.split(':')
for room in rooms:
try:
jid = JID(room)
@@ -332,7 +318,7 @@ class BookmarkList:
self.append(b)
-def stanza_storage(bookmarks: BookmarkList) -> Bookmarks:
+def stanza_storage(bookmarks: Union[BookmarkList, List[Bookmark]]) -> Bookmarks:
"""Generate a <storage/> stanza with the conference elements."""
storage = Bookmarks()
for b in (b for b in bookmarks if b.method == 'remote'):
diff --git a/poezio/colors.py b/poezio/colors.py
index c1019145..62566c77 100644
--- a/poezio/colors.py
+++ b/poezio/colors.py
@@ -1,7 +1,6 @@
-from typing import Tuple, Dict, List
+from typing import Tuple, Dict, List, Union
import curses
import hashlib
-import math
from . import hsluv
@@ -15,6 +14,9 @@ K_B = 1 - K_R - K_G
def ncurses_color_to_rgb(color: int) -> Tuple[float, float, float]:
if color <= 15:
+ r: Union[int, float]
+ g: Union[int, float]
+ b: Union[int, float]
try:
(r, g, b) = curses.color_content(color)
except: # fallback in faulty terminals (e.g. xterm)
@@ -37,7 +39,7 @@ def ncurses_color_to_rgb(color: int) -> Tuple[float, float, float]:
def generate_ccg_palette(curses_palette: List[int],
reference_y: float) -> Palette:
- cbcr_palette = {} # type: Dict[float, Tuple[float, int]]
+ cbcr_palette: Dict[float, Tuple[float, int]] = {}
for curses_color in curses_palette:
r, g, b = ncurses_color_to_rgb(curses_color)
# drop grayscale
@@ -83,6 +85,9 @@ def ccg_palette_lookup(palette: Palette, angle: float) -> int:
best_metric = metric
best = color
+ if best is None:
+ raise ValueError("No color in palette")
+
return best
diff --git a/poezio/common.py b/poezio/common.py
index ba179310..6b7d2bfe 100644
--- a/poezio/common.py
+++ b/poezio/common.py
@@ -3,12 +3,16 @@
# 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.
"""
-from datetime import datetime, timedelta
+from datetime import (
+ datetime,
+ timedelta,
+ timezone,
+)
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
@@ -17,8 +21,9 @@ import subprocess
import time
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__)
@@ -39,7 +44,7 @@ def _get_output_of_command(command: str) -> Optional[List[str]]:
return None
-def _is_in_path(command: str, return_abs_path=False) -> Union[bool, str]:
+def _is_in_path(command: str, return_abs_path: bool = False) -> Union[bool, str]:
"""
Check if *command* is in the $PATH or not.
@@ -106,10 +111,12 @@ def get_os_info() -> str:
stdout=subprocess.PIPE,
close_fds=True)
process.wait()
- output = process.stdout.readline().decode('utf-8').strip()
- # some distros put n/a in places, so remove those
- output = output.replace('n/a', '').replace('N/A', '')
- return output
+ if process.stdout is not None:
+ out = process.stdout.readline().decode('utf-8').strip()
+ # some distros put n/a in places, so remove those
+ out = out.replace('n/a', '').replace('N/A', '')
+ return out
+ return ''
# lsb_release executable not available, so parse files
for distro_name in DISTRO_INFO:
@@ -243,7 +250,7 @@ def find_delayed_tag(message: Message) -> Tuple[bool, Optional[datetime]]:
find_delay = message.xml.find
delay_tag = find_delay('{urn:xmpp:delay}delay')
- date = None # type: Optional[datetime]
+ date: Optional[datetime] = None
if delay_tag is not None:
delayed = True
date = _datetime_tuple(delay_tag.attrib['stamp'])
@@ -282,7 +289,7 @@ def shell_split(st: str) -> List[str]:
return ret
-def find_argument(pos: int, text: str, quoted=True) -> int:
+def find_argument(pos: int, text: str, quoted: bool = True) -> int:
"""
Split an input into a list of arguments, return the number of the
argument selected by pos.
@@ -337,7 +344,7 @@ def _find_argument_unquoted(pos: int, text: str) -> int:
return argnum + 1
-def parse_str_to_secs(duration='') -> int:
+def parse_str_to_secs(duration: str = '') -> int:
"""
Parse a string of with a number of d, h, m, s.
@@ -365,7 +372,7 @@ def parse_str_to_secs(duration='') -> int:
return result
-def parse_secs_to_str(duration=0) -> str:
+def parse_secs_to_str(duration: int = 0) -> str:
"""
Do the reverse operation of :py:func:`parse_str_to_secs`.
@@ -452,19 +459,103 @@ def format_gaming_string(infos: Dict[str, str]) -> str:
return name
-def safeJID(*args, **kwargs) -> JID:
+def unique_prefix_of(a: str, b: str) -> str:
"""
- Construct a :py:class:`slixmpp.JID` object from a string.
+ Return the unique prefix of `a` with `b`.
- Used to avoid tracebacks during is stringprep fails
- (fall back to a JID with an empty string).
+ Corner cases:
+
+ * If `a` and `b` share no prefix, the first letter of `a` is returned.
+ * If `a` and `b` are equal, `a` is returned.
+ * If `a` is a prefix of `b`, `a` is returned.
+ * If `b` is a prefix of `a`, `b` plus the first letter of `a` after the
+ common prefix is returned.
"""
- try:
- return JID(*args, **kwargs)
- except InvalidJID:
- log.debug(
- 'safeJID caught an invalidJID exception: %r, %r',
- args, kwargs,
- exc_info=True,
- )
- return JID('')
+ for i, (ca, cb) in enumerate(itertools.zip_longest(a, b)):
+ if ca != cb:
+ return a[:i+1]
+ # both are equal, return a
+ return a
+
+
+def to_utc(time_: datetime) -> datetime:
+ """Convert a datetime-aware time zone into raw UTC"""
+ if time_.tzinfo is not None: # Convert to UTC
+ time_ = time_.astimezone(tz=timezone.utc)
+ else: # Assume local tz, convert to UTC
+ tzone = datetime.now().astimezone().tzinfo
+ time_ = time_.replace(tzinfo=tzone).astimezone(tz=timezone.utc)
+ # Return an offset-naive datetime
+ return time_.replace(tzinfo=None)
+
+
+# http://xmpp.org/extensions/xep-0045.html#errorstatus
+ERROR_AND_STATUS_CODES = {
+ '401': 'A password is required',
+ '403': 'Permission denied',
+ '404': 'The room doesn’t exist',
+ '405': 'Your are not allowed to create a new room',
+ '406': 'A reserved nick must be used',
+ '407': 'You are not in the member list',
+ '409': 'This nickname is already in use or has been reserved',
+ '503': 'The maximum number of users has been reached',
+}
+
+
+# http://xmpp.org/extensions/xep-0086.html
+DEPRECATED_ERRORS = {
+ '302': 'Redirect',
+ '400': 'Bad request',
+ '401': 'Not authorized',
+ '402': 'Payment required',
+ '403': 'Forbidden',
+ '404': 'Not found',
+ '405': 'Not allowed',
+ '406': 'Not acceptable',
+ '407': 'Registration required',
+ '408': 'Request timeout',
+ '409': 'Conflict',
+ '500': 'Internal server error',
+ '501': 'Feature not implemented',
+ '502': 'Remote server error',
+ '503': 'Service unavailable',
+ '504': 'Remote server timeout',
+ '510': 'Disconnected',
+}
+
+
+def get_error_message(stanza: Message, deprecated: bool = False) -> str:
+ """
+ Takes a stanza of the form <message type='error'><error/></message>
+ and return a well formed string containing error information
+ """
+ sender = stanza['from']
+ msg = stanza['error']['type']
+ condition = stanza['error']['condition']
+ code = stanza['error']['code']
+ body = stanza['error']['text']
+ if not body:
+ if deprecated:
+ if code in DEPRECATED_ERRORS:
+ body = DEPRECATED_ERRORS[code]
+ else:
+ body = condition or 'Unknown error'
+ else:
+ if code in ERROR_AND_STATUS_CODES:
+ body = ERROR_AND_STATUS_CODES[code]
+ else:
+ body = condition or 'Unknown error'
+ if code:
+ message = '%(from)s: %(code)s - %(msg)s: %(body)s' % {
+ 'from': sender,
+ 'msg': msg,
+ 'body': body,
+ 'code': code
+ }
+ else:
+ message = '%(from)s: %(msg)s: %(body)s' % {
+ 'from': sender,
+ 'msg': msg,
+ 'body': body
+ }
+ return message
diff --git a/poezio/config.py b/poezio/config.py
index 8da71071..4eb43cad 100644
--- a/poezio/config.py
+++ b/poezio/config.py
@@ -10,35 +10,37 @@ TODO: get http://bugs.python.org/issue1410680 fixed, one day, in order
to remove our ugly custom I/O methods.
"""
+import logging
import logging.config
import os
-import stat
import sys
-import pkg_resources
from configparser import RawConfigParser, NoOptionError, NoSectionError
from pathlib import Path
-from shutil import copy2
-from typing import Callable, Dict, List, Optional, Union, Tuple
+from typing import Dict, List, Optional, Union, Tuple, cast, Any
-from poezio.args import parse_args
from poezio import xdg
+from slixmpp import JID, InvalidJID
+
+log = logging.getLogger(__name__) # type: logging.Logger
ConfigValue = Union[str, int, float, bool]
-DEFSECTION = "Poezio"
+ConfigDict = Dict[str, Dict[str, ConfigValue]]
+
+USE_DEFAULT_SECTION = '__DEFAULT SECTION PLACEHOLDER__'
-DEFAULT_CONFIG = {
+DEFAULT_CONFIG: ConfigDict = {
'Poezio': {
'ack_message_receipts': True,
'add_space_after_completion': True,
'after_completion': ',',
'alternative_nickname': '',
'auto_reconnect': True,
+ 'autocolor_tab_names': False,
'autorejoin_delay': '5',
'autorejoin': False,
'beep_on': 'highlight private invite disconnect',
- 'bookmark_on_join': False,
'ca_cert_path': '',
'certificate': '',
'certfile': '',
@@ -50,7 +52,6 @@ DEFAULT_CONFIG = {
'custom_port': '',
'default_nick': '',
'default_muc_service': '',
- 'deterministic_nick_colors': True,
'device_id': '',
'nick_color_aliases': True,
'display_activity_notifications': False,
@@ -74,7 +75,6 @@ DEFAULT_CONFIG = {
'extract_inline_images': True,
'filter_info_messages': '',
'force_encryption': True,
- 'force_remote_bookmarks': False,
'go_to_previous_tab_on_alt_number': False,
'group_corrections': True,
'hide_exit_join': -1,
@@ -92,6 +92,8 @@ DEFAULT_CONFIG = {
'lazy_resize': True,
'log_dir': '',
'log_errors': True,
+ 'mam_sync': True,
+ 'mam_sync_limit': 2000,
'max_lines_in_memory': 2048,
'max_messages_in_memory': 2048,
'max_nick_length': 25,
@@ -133,9 +135,11 @@ DEFAULT_CONFIG = {
'show_useless_separator': True,
'status': '',
'status_message': '',
+ 'synchronise_open_rooms': True,
'theme': 'default',
'themes_dir': '',
'tmp_image_dir': '',
+ 'unique_prefix_tab_names': False,
'use_bookmarks_method': '',
'use_log': True,
'use_remote_bookmarks': True,
@@ -157,21 +161,33 @@ DEFAULT_CONFIG = {
}
-class Config(RawConfigParser):
+class PoezioConfigParser(RawConfigParser):
+ def optionxform(self, value) -> str:
+ return str(value)
+
+
+class Config:
"""
load/save the config to a file
"""
- def __init__(self, file_name: Path, default=None) -> None:
- RawConfigParser.__init__(self, None)
+ 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()
# make the options case sensitive
- self.optionxform = lambda param: str(param)
self.file_name = file_name
self.read_file()
- self.default = default
+ self.default = default or {}
+
+ def optionxform(self, value):
+ return str(value)
def read_file(self):
- RawConfigParser.read(self, str(self.file_name), encoding='utf-8')
+ self.configparser.read(str(self.file_name), encoding='utf-8')
# Check config integrity and fix it if it’s wrong
# only when the object is the main config
if self.__class__ is Config:
@@ -182,38 +198,62 @@ class Config(RawConfigParser):
def get(self,
option: str,
default: Optional[ConfigValue] = None,
- section=DEFSECTION) -> ConfigValue:
+ 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:
- if self.default:
- default = self.default.get(section, {}).get(option)
- else:
- default = ''
+ default = self.default.get(section, {}).get(option, '')
+ res: Optional[ConfigValue]
try:
if isinstance(default, bool):
- res = self.getboolean(option, section)
+ res = self.configparser.getboolean(section, option)
elif isinstance(default, int):
- res = self.getint(option, section)
+ res = self.configparser.getint(section, option)
elif isinstance(default, float):
- res = self.getfloat(option, section)
+ res = self.configparser.getfloat(section, option)
else:
- res = self.getstr(option, section)
+ res = self.configparser.get(section, option)
except (NoOptionError, NoSectionError, ValueError, AttributeError):
- return default if default is not None else ''
+ return default
if res is None:
return default
return res
+ def _get_default(self, option, section):
+ if self.default:
+ return self.default.get(section, {}).get(option)
+ else:
+ return ''
+
+ 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) -> bool:
+ return self.configparser.has_option(*args, **kwargs)
+
+ def has_section(self, *args, **kwargs) -> bool:
+ return self.configparser.has_section(*args, **kwargs)
+
+ def add_section(self, *args, **kwargs):
+ return self.configparser.add_section(*args, **kwargs)
+
+ def remove_section(self, *args, **kwargs):
+ return self.configparser.remove_section(*args, **kwargs)
+
def get_by_tabname(self,
option,
- tabname: str,
+ tabname: JID,
fallback=True,
fallback_server=True,
default=''):
@@ -223,15 +263,12 @@ class Config(RawConfigParser):
in the section, we search for the global option if fallback is
True. And we return `default` as a fallback as a last resort.
"""
- from slixmpp import JID
- if isinstance(tabname, JID):
- tabname = tabname.full
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
- return self.get(option, default, tabname)
+ return self.get(option, default, tabname.full)
if fallback_server:
return self.get_by_servname(tabname, option, default, fallback)
if fallback:
@@ -243,7 +280,10 @@ class Config(RawConfigParser):
"""
Try to get the value of an option for a server
"""
- server = safeJID(jid).server
+ try:
+ server = JID(jid).server
+ except InvalidJID:
+ server = ''
if server:
server = '@' + server
if server in self.sections() and option in self.options(server):
@@ -252,11 +292,13 @@ class Config(RawConfigParser):
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
"""
- return RawConfigParser.get(self, section, option, **kwargs)
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
+ return self.configparser.get(section, option, **kwargs)
def _get(self, section, conv, option, **kwargs):
"""
@@ -264,29 +306,54 @@ class Config(RawConfigParser):
"""
return conv(self.__get(option, section, **kwargs))
- def getstr(self, option, section=DEFSECTION):
+ def getstr(self, option, section=USE_DEFAULT_SECTION) -> str:
"""
get a value and returns it as a string
"""
- return self.__get(option, section)
+ 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):
+ def getint(self, option, section=USE_DEFAULT_SECTION) -> int:
"""
get a value and returns it as an int
"""
- return RawConfigParser.getint(self, section, option)
+ 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):
+ def getfloat(self, option, section=USE_DEFAULT_SECTION) -> float:
"""
get a value and returns it as a float
"""
- return RawConfigParser.getfloat(self, section, option)
+ 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 getboolean(self, option, section=DEFSECTION):
+ def getbool(self, option, section=USE_DEFAULT_SECTION) -> bool:
"""
get a value and returns it as a boolean
"""
- return RawConfigParser.getboolean(self, section, option)
+ 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=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,
value: ConfigValue) -> bool:
@@ -382,8 +449,7 @@ class Config(RawConfigParser):
if file_ok(self.file_name):
try:
with self.file_name.open('r', encoding='utf-8') as df:
- lines_before = [line.strip()
- for line in df] # type: List[str]
+ lines_before: List[str] = [line.strip() for line in df]
except OSError:
log.error(
'Unable to read the config file %s',
@@ -393,7 +459,7 @@ class Config(RawConfigParser):
else:
lines_before = []
- sections = {} # type: Dict[str, List[int]]
+ sections: Dict[str, List[int]] = {}
duplicate_section = False
current_section = ''
current_line = 0
@@ -420,7 +486,7 @@ class Config(RawConfigParser):
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
@@ -428,10 +494,12 @@ class Config(RawConfigParser):
# 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 value == "toggle":
- current = self.get(option, "", section)
+ 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):
- value = str(not current)
+ value = str(not current).lower()
else:
if current.lower() == "false":
value = "true"
@@ -442,11 +510,12 @@ class Config(RawConfigParser):
'Could not toggle option: %s.'
' Current value is %s.' % (option, current or "empty"),
'Warning')
+ value = str(value)
if self.has_section(section):
- RawConfigParser.set(self, section, option, value)
+ self.configparser.set(section, option, value)
else:
self.add_section(section)
- RawConfigParser.set(self, section, option, value)
+ self.configparser.set(section, option, value)
if not self.write_in_file(section, option, value):
return ('Unable to write in the config file', 'Error')
if isinstance(option, str) and 'password' in option and 'eval_password' not in option:
@@ -454,41 +523,47 @@ class Config(RawConfigParser):
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):
- RawConfigParser.remove_option(self, section, option)
+ 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):
- RawConfigParser.set(self, section, option, value)
+ self.configparser.set(section, option, str(value))
else:
self.add_section(section)
- RawConfigParser.set(self, section, option, value)
- return self.write_in_file(section, option, value)
+ 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:
- RawConfigParser.set(self, section, option, value)
+ self.configparser.set(section, option, str(value))
except NoSectionError:
pass
- def to_dict(self) -> Dict[str, Dict[str, ConfigValue]]:
+ def to_dict(self) -> Dict[str, Dict[str, Optional[ConfigValue]]]:
"""
Returns a dict of the form {section: {option: value, option: value}, …}
"""
- res = {} # Dict[str, Dict[str, ConfigValue]]
+ res: Dict[str, Dict[str, Optional[ConfigValue]]] = {}
for section in self.sections():
res[section] = {}
for option in self.options(section):
@@ -522,10 +597,10 @@ def file_ok(filepath: Path) -> bool:
return bool(val)
-def get_image_cache() -> Path:
+def get_image_cache() -> Optional[Path]:
if not config.get('extract_inline_images'):
return None
- tmp_dir = config.get('tmp_image_dir')
+ tmp_dir = config.getstr('tmp_image_dir')
if tmp_dir:
return Path(tmp_dir)
return xdg.CACHE_HOME / 'images'
@@ -564,43 +639,11 @@ def check_config():
print(' \033[31m%s\033[0m' % option)
-def run_cmdline_args():
- "Parse the command line arguments"
- global options
- options = parse_args(xdg.CONFIG_HOME)
-
- # Copy a default file if none exists
- if not options.filename.is_file():
- try:
- options.filename.parent.mkdir(parents=True, exist_ok=True)
- except OSError as e:
- sys.stderr.write(
- 'Poezio was unable to create the config directory: %s\n' % e)
- sys.exit(1)
- default = Path(__file__).parent / '..' / 'data' / 'default_config.cfg'
- other = Path(
- pkg_resources.resource_filename('poezio', 'default_config.cfg'))
- if default.is_file():
- copy2(str(default), str(options.filename))
- elif other.is_file():
- copy2(str(other), str(options.filename))
-
- # Inside the nixstore and possibly other distributions, the reference
- # file is readonly, so is the copy.
- # Make it writable by the user who just created it.
- if options.filename.exists():
- options.filename.chmod(options.filename.stat().st_mode
- | stat.S_IWUSR)
-
- global firstrun
- firstrun = True
-
-
-def create_global_config():
+def create_global_config(filename):
"Create the global config object, or crash"
try:
global config
- config = Config(options.filename, DEFAULT_CONFIG)
+ config = Config(filename, DEFAULT_CONFIG)
except:
import traceback
sys.stderr.write('Poezio was unable to read or'
@@ -609,11 +652,13 @@ def create_global_config():
sys.exit(1)
-def setup_logging():
+def setup_logging(debug_file=''):
"Change the logging config according to the cmdline options and config"
global LOG_DIR
LOG_DIR = config.get('log_dir')
LOG_DIR = Path(LOG_DIR).expanduser() if LOG_DIR else xdg.DATA_HOME / 'logs'
+ from copy import deepcopy
+ logging_config = deepcopy(LOGGING_CONFIG)
if config.get('log_errors'):
try:
LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -621,8 +666,8 @@ def setup_logging():
# We can’t really log any error here, because logging isn’t setup yet.
pass
else:
- LOGGING_CONFIG['root']['handlers'].append('error')
- LOGGING_CONFIG['handlers']['error'] = {
+ logging_config['root']['handlers'].append('error')
+ logging_config['handlers']['error'] = {
'level': 'ERROR',
'class': 'logging.FileHandler',
'filename': str(LOG_DIR / 'errors.log'),
@@ -630,37 +675,26 @@ def setup_logging():
}
logging.disable(logging.WARNING)
- if options.debug:
- LOGGING_CONFIG['root']['handlers'].append('debug')
- LOGGING_CONFIG['handlers']['debug'] = {
+ if debug_file:
+ logging_config['root']['handlers'].append('debug')
+ logging_config['handlers']['debug'] = {
'level': 'DEBUG',
'class': 'logging.FileHandler',
- 'filename': options.debug,
+ 'filename': debug_file,
'formatter': 'simple',
}
logging.disable(logging.NOTSET)
- if LOGGING_CONFIG['root']['handlers']:
- logging.config.dictConfig(LOGGING_CONFIG)
+ if logging_config['root']['handlers']:
+ logging.config.dictConfig(logging_config)
else:
logging.disable(logging.ERROR)
logging.basicConfig(level=logging.CRITICAL)
- global log
- log = logging.getLogger(__name__)
-
-
-def post_logging_setup():
- # common imports slixmpp, which creates then its loggers, so
- # it needs to be after logger configuration
- from poezio.common import safeJID as JID
- global safeJID
- safeJID = JID
-
LOGGING_CONFIG = {
'version': 1,
- 'disable_existing_loggers': True,
+ 'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '%(asctime)s %(levelname)s:%(module)s:%(message)s'
@@ -674,21 +708,8 @@ LOGGING_CONFIG = {
}
}
-# True if this is the first run, in this case we will display
-# some help in the info buffer
-firstrun = False
-
-# Global config object. Is setup in poezio.py
-config = None # type: Config
-
-# The logger object for this module
-log = None # type: Optional[logging.Logger]
-
-# The command-line options
-options = None
-
-# delayed import from common.py
-safeJID = None # type: Optional[Callable]
+# Global config object. Is setup for real in poezio.py
+config = Config(Path('/dev/null'))
# the global log dir
LOG_DIR = Path()
diff --git a/poezio/connection.py b/poezio/connection.py
index b04bf9fd..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,8 +16,10 @@ import subprocess
import sys
import base64
import random
+from pathlib import Path
import slixmpp
+from slixmpp import JID, InvalidJID
from slixmpp.xmlstream import ET
from slixmpp.plugins.xep_0184 import XEP_0184
from slixmpp.plugins.xep_0030 import DiscoInfo
@@ -26,8 +28,7 @@ from slixmpp.util import FileSystemCache
from poezio import common
from poezio import fixes
from poezio import xdg
-from poezio.common import safeJID
-from poezio.config import config, options
+from poezio.config import config
class Connection(slixmpp.ClientXMPP):
@@ -37,25 +38,25 @@ class Connection(slixmpp.ClientXMPP):
"""
__init = False
- def __init__(self):
- keyfile = config.get('keyfile')
- certfile = config.get('certfile')
+ def __init__(self, custom_version=''):
+ keyfile = config.getstr('keyfile')
+ certfile = config.getstr('certfile')
- device_id = config.get('device_id')
+ device_id = config.getstr('device_id')
if not device_id:
rng = random.SystemRandom()
device_id = base64.urlsafe_b64encode(
rng.getrandbits(24).to_bytes(3, 'little')).decode('ascii')
config.set_and_save('device_id', device_id)
- if config.get('jid'):
+ if config.getstr('jid'):
# Field used to know if we are anonymous or not.
# many features will be handled differently
# depending on this setting
self.anon = False
- jid = config.get('jid')
- password = config.get('password')
- eval_password = config.get('eval_password')
+ jid = config.getstr('jid')
+ password = config.getstr('password')
+ eval_password = config.getstr('eval_password')
if not password and not eval_password and not (keyfile
and certfile):
password = getpass.getpass()
@@ -79,25 +80,29 @@ class Connection(slixmpp.ClientXMPP):
'\n')
else: # anonymous auth
self.anon = True
- jid = config.get('server')
+ jid = config.getstr('server')
password = None
- jid = safeJID(jid)
+ try:
+ jid = JID(jid)
+ except InvalidJID:
+ sys.stderr.write('Invalid jid option: "%s" is not a valid JID\n' % jid)
+ sys.exit(1)
jid.resource = '%s-%s' % (
jid.resource,
device_id) if jid.resource else 'poezio-%s' % device_id
# TODO: use the system language
slixmpp.ClientXMPP.__init__(
- self, jid, password, lang=config.get('lang'))
+ self, jid, password, lang=config.getstr('lang'))
- force_encryption = config.get('force_encryption')
+ force_encryption = config.getbool('force_encryption')
if force_encryption:
self['feature_mechanisms'].unencrypted_plain = False
self['feature_mechanisms'].unencrypted_digest = False
self['feature_mechanisms'].unencrypted_cram = False
self['feature_mechanisms'].unencrypted_scram = False
- self.keyfile = config.get('keyfile')
- self.certfile = config.get('certfile')
+ self.keyfile = keyfile
+ self.certfile = certfile
if keyfile and not certfile:
log.error(
'keyfile is present in configuration file without certfile')
@@ -106,15 +111,18 @@ class Connection(slixmpp.ClientXMPP):
'certfile is present in configuration file without keyfile')
self.core = None
- self.auto_reconnect = config.get('auto_reconnect')
+ self.auto_reconnect = config.getbool('auto_reconnect')
self.auto_authorize = None
# prosody defaults, lowest is AES128-SHA, it should be a minimum
# for anything that came out after 2002
- self.ciphers = config.get(
+ self.ciphers = config.getstr(
'ciphers', 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK'
':!SRP:!3DES:!aNULL')
- self.ca_certs = config.get('ca_cert_path') or None
- interval = config.get('whitespace_interval')
+ 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)
else:
@@ -152,33 +160,21 @@ class Connection(slixmpp.ClientXMPP):
# without a body
XEP_0184._filter_add_receipt_request = fixes._filter_add_receipt_request
self.register_plugin('xep_0184')
- self.plugin['xep_0184'].auto_ack = config.get('ack_message_receipts')
- self.plugin['xep_0184'].auto_request = config.get(
+ self.plugin['xep_0184'].auto_ack = config.getbool('ack_message_receipts')
+ self.plugin['xep_0184'].auto_request = config.getbool(
'request_message_receipts')
self.register_plugin('xep_0191')
- if config.get('enable_smacks'):
+ if config.getbool('enable_smacks'):
self.register_plugin('xep_0198')
self.register_plugin('xep_0199')
- if config.get('enable_user_tune'):
- self.register_plugin('xep_0118')
-
- if config.get('enable_user_nick'):
+ if config.getbool('enable_user_nick'):
self.register_plugin('xep_0172')
- if config.get('enable_user_mood'):
- self.register_plugin('xep_0107')
-
- if config.get('enable_user_activity'):
- self.register_plugin('xep_0108')
-
- if config.get('enable_user_gaming'):
- self.register_plugin('xep_0196')
-
- if config.get('send_poezio_info'):
- info = {'name': 'poezio', 'version': options.custom_version}
- if config.get('send_os_info'):
+ if config.getbool('send_poezio_info'):
+ info = {'name': 'poezio', 'version': custom_version}
+ if config.getbool('send_os_info'):
info['os'] = common.get_os_info()
self.plugin['xep_0030'].set_identities(identities={('client',
'console',
@@ -190,7 +186,7 @@ class Connection(slixmpp.ClientXMPP):
'console',
None, '')})
self.register_plugin('xep_0092', pconfig=info)
- if config.get('send_time'):
+ if config.getbool('send_time'):
self.register_plugin('xep_0202')
self.register_plugin('xep_0224')
self.register_plugin('xep_0231')
@@ -200,18 +196,19 @@ class Connection(slixmpp.ClientXMPP):
self.register_plugin('xep_0297')
self.register_plugin('xep_0308')
self.register_plugin('xep_0313')
- self.register_plugin('xep_0319')
self.register_plugin('xep_0334')
self.register_plugin('xep_0352')
try:
self.register_plugin('xep_0363')
- except SyntaxError:
- log.error('Failed to load HTTP File Upload plugin, it can only be '
- 'used on Python 3.5+')
except slixmpp.plugins.base.PluginNotFound:
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):
@@ -224,8 +221,8 @@ class Connection(slixmpp.ClientXMPP):
# Happens when we change the value with /set while we are not
# connected. Do nothing in that case
return
- ping_interval = config.get('connection_check_interval')
- timeout_delay = config.get('connection_timeout_delay')
+ ping_interval = config.getint('connection_check_interval')
+ timeout_delay = config.getint('connection_timeout_delay')
if timeout_delay <= 0:
# We help the stupid user (with a delay of 0, poezio will try to
# reconnect immediately because the timeout is immediately
@@ -242,7 +239,7 @@ class Connection(slixmpp.ClientXMPP):
"""
Connect and process events.
"""
- custom_host = config.get('custom_host')
+ custom_host = config.getstr('custom_host')
custom_port = config.get('custom_port', 5222)
if custom_port == -1:
custom_port = 5222
diff --git a/poezio/contact.py b/poezio/contact.py
index 50ccab1f..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.
@@ -11,9 +11,17 @@ the roster.
from collections import defaultdict
import logging
-from typing import Dict, Iterator, List, Optional, Union
+from typing import (
+ Any,
+ Dict,
+ Iterator,
+ List,
+ Optional,
+ Union,
+)
from slixmpp import InvalidJID, JID
+from slixmpp.roster import RosterItem
log = logging.getLogger(__name__)
@@ -29,8 +37,8 @@ class Resource:
data: the dict to use as a source
"""
# Full JID
- self._jid = jid # type: str
- self._data = data # type: Dict[str, Union[str, int]]
+ self._jid: str = jid
+ self._data: Dict[str, Union[str, int]] = data
@property
def jid(self) -> str:
@@ -38,15 +46,18 @@ class Resource:
@property
def priority(self) -> int:
- return self._data.get('priority') or 0
+ try:
+ return int(self._data.get('priority', 0))
+ except Exception:
+ return 0
@property
def presence(self) -> str:
- return self._data.get('show') or ''
+ return str(self._data.get('show')) or ''
@property
def status(self) -> str:
- return self._data.get('status') or ''
+ return str(self._data.get('status')) or ''
def __repr__(self) -> str:
return '<%s>' % self._jid
@@ -64,19 +75,16 @@ 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
"""
self.__item = item
- self.folded_states = defaultdict(lambda: True) # type: Dict[str, bool]
+ self.folded_states: Dict[str, bool] = defaultdict(lambda: True)
self._name = ''
self.avatar = None
self.error = None
- self.tune = {} # type: Dict[str, str]
- self.gaming = {} # type: Dict[str, str]
- self.mood = ''
- self.activity = ''
+ self.rich_presence: Dict[str, Any] = defaultdict(lambda: None)
@property
def groups(self) -> List[str]:
@@ -89,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 ''
@@ -99,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/command_defs.py b/poezio/core/command_defs.py
new file mode 100644
index 00000000..770b3492
--- /dev/null
+++ b/poezio/core/command_defs.py
@@ -0,0 +1,452 @@
+from typing import Callable, List, Optional
+
+from poezio.core.commands import CommandCore
+from poezio.core.completions import CompletionCore
+from poezio.plugin_manager import PluginManager
+from poezio.types import TypedDict
+
+
+CommandDict = TypedDict(
+ "CommandDict",
+ {
+ "name": str,
+ "func": Callable,
+ "shortdesc": str,
+ "desc": str,
+ "usage": str,
+ "completion": Optional[Callable],
+ },
+ total=False,
+)
+
+
+def get_commands(commands: CommandCore, completions: CompletionCore, plugin_manager: PluginManager) -> List[CommandDict]:
+ """
+ Get the set of default poezio commands.
+ """
+ return [
+ {
+ "name": "help",
+ "func": commands.help,
+ "usage": "[command]",
+ "shortdesc": "\\_o< KOIN KOIN KOIN",
+ "completion": completions.help,
+ },
+ {
+ "name": "join",
+ "func": commands.join,
+ "usage": "[room_name][@server][/nick] [password]",
+ "desc": (
+ "Join the specified room. You can specify a nickname "
+ "after a slash (/). If no nickname is specified, you will"
+ " use the default_nick in the configuration file. You can"
+ " omit the room name: you will then join the room you're"
+ " looking at (useful if you were kicked). You can also "
+ "provide a room_name without specifying a server, the "
+ "server of the room you're currently in will be used. You"
+ " can also provide a password to join the room.\nExamples"
+ ":\n/join room@server.tld\n/join room@server.tld/John\n"
+ "/join room2\n/join /me_again\n/join\n/join room@server"
+ ".tld/my_nick password\n/join / password"
+ ),
+ "shortdesc": "Join a room",
+ "completion": completions.join,
+ },
+ {
+ "name": "exit",
+ "func": commands.quit,
+ "desc": "Just disconnect from the server and exit poezio.",
+ "shortdesc": "Exit poezio.",
+ },
+ {
+ "name": "quit",
+ "func": commands.quit,
+ "desc": "Just disconnect from the server and exit poezio.",
+ "shortdesc": "Exit poezio.",
+ },
+ {
+ "name": "next",
+ "func": commands.rotate_rooms_right,
+ "shortdesc": "Go to the next room.",
+ },
+ {
+ "name": "prev",
+ "func": commands.rotate_rooms_left,
+ "shortdesc": "Go to the previous room.",
+ },
+ {
+ "name": "win",
+ "func": commands.win,
+ "usage": "<number or name>",
+ "shortdesc": "Go to the specified room",
+ "completion": completions.win,
+ },
+ {
+ "name": "w",
+ "func": commands.win,
+ "usage": "<number or name>",
+ "shortdesc": "Go to the specified room",
+ "completion": completions.win,
+ },
+ {
+ "name": "wup",
+ "func": commands.wup,
+ "usage": "<prefix>",
+ "shortdesc": "Go to the tab whose name uniquely starts with prefix",
+ "completion": completions.win,
+ },
+ {
+ "name": "move_tab",
+ "func": commands.move_tab,
+ "usage": "<source> <destination>",
+ "desc": (
+ "Insert the <source> tab at the position of "
+ "<destination>. This will make the following tabs shift in"
+ " some cases (refer to the documentation). A tab can be "
+ "designated by its number or by the beginning of its "
+ 'address. You can use "." as a shortcut for the current '
+ "tab."
+ ),
+ "shortdesc": "Move a tab.",
+ "completion": completions.move_tab,
+ },
+ {
+ "name": "destroy_room",
+ "func": commands.destroy_room,
+ "usage": "[room JID]",
+ "desc": (
+ "Try to destroy the room [room JID], or the current"
+ " tab if it is a multi-user chat and [room JID] is "
+ "not given."
+ ),
+ "shortdesc": "Destroy a room.",
+ "completion": None,
+ },
+ {
+ "name": "status",
+ "func": commands.status,
+ "usage": "<availability> [status message]",
+ "desc": (
+ "Sets your availability and (optionally) your status "
+ 'message. The <availability> argument is one of "available'
+ ', chat, away, afk, dnd, busy, xa" and the optional '
+ "[status message] argument will be your status message."
+ ),
+ "shortdesc": "Change your availability.",
+ "completion": completions.status,
+ },
+ {
+ "name": "show",
+ "func": commands.status,
+ "usage": "<availability> [status message]",
+ "desc": (
+ "Sets your availability and (optionally) your status "
+ 'message. The <availability> argument is one of "available'
+ ', chat, away, afk, dnd, busy, xa" and the optional '
+ "[status message] argument will be your status message."
+ ),
+ "shortdesc": "Change your availability.",
+ "completion": completions.status,
+ },
+ {
+ "name": "bookmark_local",
+ "func": commands.bookmark_local,
+ "usage": "[roomname][/nick] [password]",
+ "desc": (
+ "Bookmark Local: Bookmark locally the specified room "
+ "(you will then auto-join it on each poezio start). This"
+ " commands uses almost the same syntaxe as /join. Type "
+ "/help join for syntax examples. Note that when typing "
+ '"/bookmark" on its own, the room will be bookmarked '
+ "with the nickname you're currently using in this room "
+ "(instead of default_nick)"
+ ),
+ "shortdesc": "Bookmark a room locally.",
+ "completion": completions.bookmark_local,
+ },
+ {
+ "name": "bookmark",
+ "func": commands.bookmark,
+ "usage": "[roomname][/nick] [autojoin] [password]",
+ "desc": (
+ "Bookmark: Bookmark online the specified room (you "
+ "will then auto-join it on each poezio start if autojoin"
+ " is specified and is 'true'). This commands uses almost"
+ " the same syntax as /join. Type /help join for syntax "
+ 'examples. Note that when typing "/bookmark" alone, the'
+ " room will be bookmarked with the nickname you're "
+ "currently using in this room (instead of default_nick)."
+ ),
+ "shortdesc": "Bookmark a room online.",
+ "completion": completions.bookmark,
+ },
+ {
+ "name": "accept",
+ "func": commands.accept,
+ "usage": "[jid]",
+ "desc": (
+ "Allow the provided JID (or the selected contact "
+ "in your roster), to see your presence."
+ ),
+ "shortdesc": "Allow a user your presence.",
+ "completion": completions.roster_barejids,
+ },
+ {
+ "name": "add",
+ "func": commands.add,
+ "usage": "<jid>",
+ "desc": (
+ "Add the specified JID to your roster, ask them to"
+ " allow you to see his presence, and allow them to"
+ " see your presence."
+ ),
+ "shortdesc": "Add a user to your roster.",
+ },
+ {
+ "name": "deny",
+ "func": commands.deny,
+ "usage": "[jid]",
+ "desc": (
+ "Deny your presence to the provided JID (or the "
+ "selected contact in your roster), who is asking"
+ "you to be in their roster."
+ ),
+ "shortdesc": "Deny a user your presence.",
+ "completion": completions.roster_barejids,
+ },
+ {
+ "name": "remove",
+ "func": commands.remove,
+ "usage": "[jid]",
+ "desc": (
+ "Remove the specified JID from your roster. This "
+ "will unsubscribe you from its presence, cancel "
+ "its subscription to yours, and remove the item "
+ "from your roster."
+ ),
+ "shortdesc": "Remove a user from your roster.",
+ "completion": completions.remove,
+ },
+ {
+ "name": "reconnect",
+ "func": commands.command_reconnect,
+ "usage": "[reconnect]",
+ "desc": (
+ "Disconnect from the remote server if you are "
+ "currently connected and then connect to it again."
+ ),
+ "shortdesc": "Disconnect and reconnect to the server.",
+ },
+ {
+ "name": "set",
+ "func": commands.set,
+ "usage": "[plugin|][section] <option> [value]",
+ "desc": (
+ "Set the value of an option in your configuration file."
+ " You can, for example, change your default nickname by "
+ "doing `/set default_nick toto` or your resource with `/set"
+ " resource blabla`. You can also set options in specific "
+ "sections with `/set bindings M-i ^i` or in specific plugin"
+ " with `/set mpd_client| host 127.0.0.1`. `toggle` can be "
+ "used as a special value to toggle a boolean option."
+ ),
+ "shortdesc": "Set the value of an option",
+ "completion": completions.set,
+ },
+ {
+ "name": "set_default",
+ "func": commands.set_default,
+ "usage": "[section] <option>",
+ "desc": (
+ "Set the default value of an option. For example, "
+ "`/set_default resource` will reset the resource "
+ "option. You can also reset options in specific "
+ "sections by doing `/set_default section option`."
+ ),
+ "shortdesc": "Set the default value of an option",
+ "completion": completions.set_default,
+ },
+ {
+ "name": "toggle",
+ "func": commands.toggle,
+ "usage": "<option>",
+ "desc": "Shortcut for /set <option> toggle",
+ "shortdesc": "Toggle an option",
+ "completion": completions.toggle,
+ },
+ {
+ "name": "theme",
+ "func": commands.theme,
+ "usage": "[theme name]",
+ "desc": (
+ "Reload the theme defined in the config file. If theme"
+ "_name is provided, set that theme before reloading it."
+ ),
+ "shortdesc": "Load a theme",
+ "completion": completions.theme,
+ },
+ {
+ "name": "list",
+ "func": commands.list,
+ "usage": "[server]",
+ "desc": "Get the list of public rooms" " on the specified server.",
+ "shortdesc": "List the rooms.",
+ "completion": completions.list,
+ },
+ {
+ "name": "message",
+ "func": commands.message,
+ "usage": "<jid> [optional message]",
+ "desc": (
+ "Open a conversation with the specified JID (even if it"
+ " is not in our roster), and send a message to it, if the "
+ "message is specified."
+ ),
+ "shortdesc": "Send a message",
+ "completion": completions.message,
+ },
+ {
+ "name": "version",
+ "func": commands.version,
+ "usage": "<jid>",
+ "desc": (
+ "Get the software version of the given JID (usually its"
+ " XMPP client and Operating System)."
+ ),
+ "shortdesc": "Get the software version of a JID.",
+ "completion": completions.version,
+ },
+ {
+ "name": "server_cycle",
+ "func": commands.server_cycle,
+ "usage": "[domain] [message]",
+ "desc": "Disconnect and reconnect in all the rooms in domain.",
+ "shortdesc": "Cycle a range of rooms",
+ "completion": completions.server_cycle,
+ },
+ {
+ "name": "bind",
+ "func": commands.bind,
+ "usage": "<key> <equ>",
+ "desc": (
+ "Bind a key to another key or to a “command”. For "
+ 'example "/bind ^H KEY_UP" makes Control + h do the'
+ " same same as the Up key."
+ ),
+ "completion": completions.bind,
+ "shortdesc": "Bind a key to another key.",
+ },
+ {
+ "name": "load",
+ "func": commands.load,
+ "usage": "<plugin> [<otherplugin> …]",
+ "shortdesc": "Load the specified plugin(s)",
+ "completion": plugin_manager.completion_load,
+ },
+ {
+ "name": "unload",
+ "func": commands.unload,
+ "usage": "<plugin> [<otherplugin> …]",
+ "shortdesc": "Unload the specified plugin(s)",
+ "completion": plugin_manager.completion_unload,
+ },
+ {
+ "name": "plugins",
+ "func": commands.plugins,
+ "shortdesc": "Show the plugins in use.",
+ },
+ {
+ "name": "presence",
+ "func": commands.presence,
+ "usage": "<JID> [type] [status]",
+ "desc": "Send a directed presence to <JID> and using"
+ " [type] and [status] if provided.",
+ "shortdesc": "Send a directed presence.",
+ "completion": completions.presence,
+ },
+ {
+ "name": "rawxml",
+ "func": commands.rawxml,
+ "usage": "<xml>",
+ "shortdesc": "Send a custom xml stanza.",
+ },
+ {
+ "name": "invite",
+ "func": commands.invite,
+ "usage": "<jid> <room> [reason]",
+ "desc": "Invite jid in room with reason.",
+ "shortdesc": "Invite someone in a room.",
+ "completion": completions.invite,
+ },
+ {
+ "name": "impromptu",
+ "func": commands.impromptu,
+ "usage": "<jid> [jid ...]",
+ "desc": "Invite specified JIDs into a newly created room.",
+ "shortdesc": "Invite specified JIDs into newly created room.",
+ "completion": completions.impromptu,
+ },
+ {
+ "name": "invitations",
+ "func": commands.invitations,
+ "shortdesc": "Show the pending invitations.",
+ },
+ {
+ "name": "bookmarks",
+ "func": commands.bookmarks,
+ "shortdesc": "Show the current bookmarks.",
+ },
+ {
+ "name": "remove_bookmark",
+ "func": commands.remove_bookmark,
+ "usage": "[jid]",
+ "desc": "Remove the specified bookmark, or the "
+ "bookmark on the current tab, if any.",
+ "shortdesc": "Remove a bookmark",
+ "completion": completions.remove_bookmark,
+ },
+ {
+ "name": "xml_tab",
+ "func": commands.xml_tab,
+ "shortdesc": "Open an XML tab.",
+ },
+ {
+ "name": "runkey",
+ "func": commands.runkey,
+ "usage": "<key>",
+ "shortdesc": "Execute the action defined for <key>.",
+ "completion": completions.runkey,
+ },
+ {
+ "name": "self",
+ "func": commands.self_,
+ "shortdesc": "Remind you of who you are.",
+ },
+ {
+ "name": "last_activity",
+ "func": commands.last_activity,
+ "usage": "<jid>",
+ "desc": "Informs you of the last activity of a JID.",
+ "shortdesc": "Get the activity of someone.",
+ "completion": completions.last_activity,
+ },
+ {
+ "name": "ad-hoc",
+ "func": commands.adhoc,
+ "usage": "<jid>",
+ "shortdesc": "List available ad-hoc commands on the given jid",
+ },
+ {
+ "name": "reload",
+ "func": commands.reload,
+ "shortdesc": "Reload the config. You can achieve the same by "
+ "sending SIGUSR1 to poezio.",
+ },
+ {
+ "name": "debug",
+ "func": commands.debug,
+ "usage": "[debug_filename]",
+ "shortdesc": "Enable or disable debug logging according to the "
+ "presence of [debug_filename].",
+ },
+ ]
diff --git a/poezio/core/commands.py b/poezio/core/commands.py
index b00cf24a..fe91ca67 100644
--- a/poezio/core/commands.py
+++ b/poezio/core/commands.py
@@ -3,25 +3,23 @@ Global commands which are to be linked to the Core class
"""
import asyncio
+from urllib.parse import unquote
from xml.etree import ElementTree as ET
from typing import List, Optional, Tuple
import logging
-from slixmpp import Iq, JID, InvalidJID
-from slixmpp.exceptions import XMPPError
+from slixmpp import JID, InvalidJID
+from slixmpp.exceptions import XMPPError, IqError, IqTimeout
from slixmpp.xmlstream.xmlstream import NotConnectedError
from slixmpp.xmlstream.stanzabase import StanzaBase
from slixmpp.xmlstream.handler import Callback
from slixmpp.xmlstream.matcher import StanzaPath
-from poezio import common
-from poezio import pep
-from poezio import tabs
+from poezio import common, config as config_module, tabs, multiuserchat as muc
from poezio.bookmarks import Bookmark
-from poezio.common import safeJID
-from poezio.config import config, DEFAULT_CONFIG, options as config_opts
-from poezio import multiuserchat as muc
+from poezio.config import config, DEFAULT_CONFIG
from poezio.contact import Contact, Resource
+from poezio.decorators import deny_anonymous
from poezio.plugin import PluginConfig
from poezio.roster import roster
from poezio.theming import dump_tuple, get_theme
@@ -36,6 +34,14 @@ class CommandCore:
def __init__(self, core):
self.core = core
+ @command_args_parser.ignored
+ def rotate_rooms_left(self, args=None):
+ self.core.rotate_rooms_left()
+
+ @command_args_parser.ignored
+ def rotate_rooms_right(self, args=None):
+ self.core.rotate_rooms_right()
+
@command_args_parser.quoted(0, 1)
def help(self, args):
"""
@@ -218,6 +224,20 @@ class CommandCore:
return
self.core.tabs.set_current_tab(match)
+ @command_args_parser.quoted(1)
+ def wup(self, args):
+ """
+ /wup <prefix of name>
+ """
+ if args is None:
+ return self.help('wup')
+
+ prefix = args[0]
+ _, match = self.core.tabs.find_by_unique_prefix(prefix)
+ if match is None:
+ return
+ self.core.tabs.set_current_tab(match)
+
@command_args_parser.quoted(2)
def move_tab(self, args):
"""
@@ -278,27 +298,40 @@ class CommandCore:
jid = self.core.tabs.current_tab.jid
if jid is None or not jid.domain:
return None
+ asyncio.create_task(
+ self._list_async(jid)
+ )
+
+ async def _list_async(self, jid: JID):
jid = JID(jid.domain)
list_tab = tabs.MucListTab(self.core, jid)
self.core.add_tab(list_tab, True)
- cb = list_tab.on_muc_list_item_received
- self.core.xmpp.plugin['xep_0030'].get_items(jid=jid, callback=cb)
+ iq = await self.core.xmpp.plugin['xep_0030'].get_items(jid=jid)
+ list_tab.on_muc_list_item_received(iq)
@command_args_parser.quoted(1)
- def version(self, args):
+ async def version(self, args):
"""
/version <jid>
"""
if args is None:
return self.help('version')
- jid = safeJID(args[0])
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information(
+ 'Invalid JID for /version: %s' % args[0],
+ 'Error'
+ )
if jid.resource or jid not in roster or not roster[jid].resources:
- self.core.xmpp.plugin['xep_0092'].get_version(
- jid, callback=self.core.handler.on_version_result)
+ iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid)
+ self.core.handler.on_version_result(iq)
elif jid in roster:
for resource in roster[jid].resources:
- self.core.xmpp.plugin['xep_0092'].get_version(
- resource.jid, callback=self.core.handler.on_version_result)
+ iq = await self.core.xmpp.plugin['xep_0092'].get_version(
+ resource.jid
+ )
+ self.core.handler.on_version_result(iq)
def _empty_join(self):
tab = self.core.tabs.current_tab
@@ -310,6 +343,9 @@ class CommandCore:
def _parse_join_jid(self, jid_string: str) -> Tuple[Optional[str], Optional[str]]:
# we try to join a server directly
+ server_root = False
+ if jid_string.startswith('xmpp:') and jid_string.endswith('?join'):
+ jid_string = unquote(jid_string[5:-5])
try:
if jid_string.startswith('@'):
server_root = True
@@ -318,9 +354,9 @@ class CommandCore:
info = JID(jid_string)
server_root = False
except InvalidJID:
- return (None, None)
+ info = JID('')
- set_nick = '' # type: Optional[str]
+ set_nick: Optional[str] = ''
if len(jid_string) > 1 and jid_string.startswith('/'):
set_nick = jid_string[1:]
elif info.resource:
@@ -347,7 +383,7 @@ class CommandCore:
return (room, set_nick)
@command_args_parser.quoted(0, 2)
- def join(self, args):
+ async def join(self, args):
"""
/join [room][/nick] [password]
"""
@@ -359,7 +395,11 @@ class CommandCore:
return # nothing was parsed
room = room.lower()
+
+ # Has the nick been specified explicitely when joining
+ config_nick = False
if nick == '':
+ config_nick = True
nick = self.core.own_nick
# a password is provided
@@ -386,10 +426,16 @@ class CommandCore:
tab.password = password
tab.join()
- if config.get('bookmark_on_join'):
- method = 'remote' if config.get(
+ if config.getbool('synchronise_open_rooms') and room not in self.core.bookmarks:
+ method = 'remote' if config.getbool(
'use_remote_bookmarks') else 'local'
- self._add_bookmark('%s/%s' % (room, nick), True, password, method)
+ await self._add_bookmark(
+ room=room,
+ nick=nick if not config_nick else None,
+ autojoin=True,
+ password=password,
+ method=method,
+ )
if tab == self.core.tabs.current_tab:
tab.refresh()
@@ -400,57 +446,99 @@ 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
- jid = args[0] if args else None
- self._add_bookmark(jid, True, password, 'local')
+ 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,
+ autojoin=True,
+ password=password,
+ method='local',
+ )
+ )
@command_args_parser.quoted(0, 3)
def bookmark(self, args):
"""
/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
- jid = args[0] if args else ''
+ room, nick = self._parse_join_jid(args[0] if args else '')
password = args[2] if len(args) > 2 else None
- if not config.get('use_remote_bookmarks'):
- return self._add_bookmark(jid, True, password, 'local')
-
- if len(args) > 1:
- autojoin = False if args[1].lower() != 'true' else True
- else:
- autojoin = True
+ method = 'remote' if config.getbool('use_remote_bookmarks') else 'local'
+ autojoin = (method == 'local' or
+ (len(args) > 1 and args[1].lower() == 'true'))
+
+ 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: str,
+ nick: Optional[str],
+ autojoin: bool,
+ password: str,
+ method: str,
+ ) -> None:
+ '''
+ Adds a bookmark.
+
+ Args:
+ room: room Jid.
+ nick: optional nick. Will always be added to the bookmark if
+ specified. This takes precedence over tab.own_nick which takes
+ precedence over core.own_nick (global config).
+ autojoin: set the bookmark to join automatically.
+ password: room password.
+ method: 'local' or 'remote'.
+ '''
+
+
+ if room == '*':
+ return await self._add_wildcard_bookmarks(method)
+
+ # Once we found which room to bookmark, find corresponding tab if it
+ # exists and fill nickname if none was specified and not default.
+ tab = self.core.tabs.by_name_and_class(room, tabs.MucTab)
+ if tab and isinstance(tab, tabs.MucTab) and \
+ tab.joined and tab.own_nick != self.core.own_nick:
+ nick = nick or tab.own_nick
- self._add_bookmark(jid, autojoin, password, 'remote')
+ # Validate / Normalize
+ try:
+ 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
- def _add_bookmark(self, jid, autojoin, password, method):
- nick = None
- if not jid:
- tab = self.core.tabs.current_tab
- roomname = tab.jid.bare
- if tab.joined and tab.own_nick != self.core.own_nick:
- nick = tab.own_nick
- if password is None and tab.password is not None:
- password = tab.password
- elif jid == '*':
- return self._add_wildcard_bookmarks(method)
- else:
- info = safeJID(jid)
- roomname, nick = info.bare, info.resource
- if roomname == '':
- tab = self.core.tabs.current_tab
- if not isinstance(tab, tabs.MucTab):
- return
- roomname = tab.jid.bare
- bookmark = self.core.bookmarks[roomname]
+ bookmark = self.core.bookmarks[room]
if bookmark is None:
- bookmark = Bookmark(roomname)
+ bookmark = Bookmark(room)
self.core.bookmarks.append(bookmark)
bookmark.method = method
bookmark.autojoin = autojoin
@@ -460,10 +548,15 @@ class CommandCore:
bookmark.password = password
self.core.bookmarks.save_local()
- self.core.bookmarks.save_remote(self.core.xmpp,
- self.core.handler.on_bookmark_result)
+ try:
+ result = await self.core.bookmarks.save_remote(
+ self.core.xmpp,
+ )
+ self.core.handler.on_bookmark_result(result)
+ except (IqError, IqTimeout) as iq:
+ self.core.handler.on_bookmark_result(iq)
- def _add_wildcard_bookmarks(self, method):
+ async def _add_wildcard_bookmarks(self, method):
new_bookmarks = []
for tab in self.core.get_tabs(tabs.MucTab):
bookmark = self.core.bookmarks[tab.jid.bare]
@@ -477,8 +570,11 @@ class CommandCore:
new_bookmarks.extend(self.core.bookmarks.bookmarks)
self.core.bookmarks.set(new_bookmarks)
self.core.bookmarks.save_local()
- self.core.bookmarks.save_remote(self.core.xmpp,
- self.core.handler.on_bookmark_result)
+ try:
+ iq = await self.core.bookmarks.save_remote(self.core.xmpp)
+ self.core.handler.on_bookmark_result(iq)
+ except IqError as iq:
+ self.core.handler.on_bookmark_result(iq)
@command_args_parser.ignored
def bookmarks(self):
@@ -495,30 +591,34 @@ class CommandCore:
@command_args_parser.quoted(0, 1)
def remove_bookmark(self, args):
"""/remove_bookmark [jid]"""
+ jid = None
+ if not args:
+ tab = self.core.tabs.current_tab
+ if isinstance(tab, tabs.MucTab):
+ jid = tab.jid.bare
+ else:
+ jid = args[0]
- def cb(success):
- if success:
+ asyncio.create_task(
+ self._remove_bookmark_routine(jid)
+ )
+
+ async def _remove_bookmark_routine(self, jid: str):
+ """Asynchronously remove a bookmark"""
+ if self.core.bookmarks[jid]:
+ self.core.bookmarks.remove(jid)
+ try:
+ await self.core.bookmarks.save(self.core.xmpp)
self.core.information('Bookmark deleted', 'Info')
- else:
+ except (IqError, IqTimeout):
self.core.information('Error while deleting the bookmark',
'Error')
-
- if not args:
- tab = self.core.tabs.current_tab
- if isinstance(tab, tabs.MucTab) and self.core.bookmarks[tab.jid.bare]:
- self.core.bookmarks.remove(tab.jid.bare)
- self.core.bookmarks.save(self.core.xmpp, callback=cb)
- else:
- self.core.information('No bookmark to remove', 'Info')
else:
- if self.core.bookmarks[args[0]]:
- self.core.bookmarks.remove(args[0])
- self.core.bookmarks.save(self.core.xmpp, callback=cb)
- else:
- self.core.information('No bookmark to remove', 'Info')
+ self.core.information('No bookmark to remove', 'Info')
+ @deny_anonymous
@command_args_parser.quoted(0, 1)
- def command_accept(self, args):
+ def accept(self, args):
"""
Accept a JID. Authorize it AND subscribe to it
"""
@@ -534,9 +634,12 @@ class CommandCore:
else:
return self.core.information('No subscription to accept', 'Warning')
else:
- jid = safeJID(args[0]).bare
- nodepart = safeJID(jid).user
- jid = safeJID(jid)
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ return self.core.information('Invalid JID for /accept: %s' % args[0], 'Error')
+ jid = JID(jid)
+ nodepart = jid.user
# crappy transports putting resources inside the node part
if '\\2f' in nodepart:
jid.user = nodepart.split('\\2f')[0]
@@ -553,8 +656,9 @@ class CommandCore:
pto=jid, ptype='subscribe', pnick=self.core.own_nick)
self.core.information('%s is now authorized' % jid, 'Roster')
+ @deny_anonymous
@command_args_parser.quoted(1)
- def command_add(self, args):
+ def add(self, args):
"""
Add the specified JID to the roster, and automatically
accept the reverse subscription
@@ -571,17 +675,72 @@ class CommandCore:
return self.core.information('%s was added to the roster' % jid, 'Roster')
else:
return self.core.information('No JID specified', 'Error')
- jid = safeJID(safeJID(args[0]).bare)
- if not str(jid):
- self.core.information(
- 'The provided JID (%s) is not valid' % (args[0], ), 'Error')
- return
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ return self.core.information('Invalid JID for /add: %s' % args[0], 'Error')
if jid in roster and roster[jid].subscription in ('to', 'both'):
return self.core.information('Already subscribed.', 'Roster')
roster.add(jid)
roster.modified()
self.core.information('%s was added to the roster' % jid, 'Roster')
+ @deny_anonymous
+ @command_args_parser.quoted(0, 1)
+ def deny(self, args):
+ """
+ /deny [jid]
+ Denies a JID from our roster
+ """
+ jid = None
+ if not args:
+ tab = self.core.tabs.current_tab
+ if isinstance(tab, tabs.RosterInfoTab):
+ item = tab.roster_win.selected_row
+ if isinstance(item, Contact):
+ jid = item.bare_jid
+ else:
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ return self.core.information('Invalid JID for /deny: %s' % args[0], 'Error')
+ if jid not in [jid for jid in roster.jids()]:
+ jid = None
+ if jid is None:
+ self.core.information('No subscription to deny', 'Warning')
+ return
+
+ contact = roster[jid]
+ if contact:
+ contact.unauthorize()
+ self.core.information('Subscription to %s was revoked' % jid,
+ 'Roster')
+
+ @deny_anonymous
+ @command_args_parser.quoted(0, 1)
+ def remove(self, args):
+ """
+ Remove the specified JID from the roster. i.e.: unsubscribe
+ from its presence, and cancel its subscription to our.
+ """
+ jid = None
+ if args:
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ return self.core.information('Invalid JID for /remove: %s' % args[0], 'Error')
+ else:
+ tab = self.core.tabs.current_tab
+ if isinstance(tab, tabs.RosterInfoTab):
+ item = tab.roster_win.selected_row
+ if isinstance(item, Contact):
+ jid = item.bare_jid
+ if jid is None:
+ self.core.information('No roster item to remove', 'Error')
+ return
+ roster.remove(jid)
+ del roster[jid]
+
@command_args_parser.ignored
def command_reconnect(self):
"""
@@ -597,6 +756,8 @@ class CommandCore:
"""
/set [module|][section] <option> [value]
"""
+ if len(args) == 3 and args[1] == '=':
+ args = [args[0], args[2]]
if args is None or len(args) == 0:
config_dict = config.to_dict()
lines = []
@@ -643,7 +804,8 @@ class CommandCore:
info = ('%s=%s' % (option, value), 'Info')
else:
possible_section = args[0]
- if config.has_section(possible_section):
+ if (not config.has_option(section='Poezio', option=possible_section)
+ and config.has_section(possible_section)):
section = possible_section
option = args[1]
value = config.get(option, section=section)
@@ -747,108 +909,44 @@ class CommandCore:
tab.join()
@command_args_parser.quoted(1)
- def last_activity(self, args):
+ async def last_activity(self, args):
"""
/last_activity <jid>
"""
- def callback(iq):
- "Callback for the last activity"
- if iq['type'] != 'result':
- if iq['error']['type'] == 'auth':
- self.core.information(
- 'You are not allowed to see the '
- 'activity of this contact.', 'Error')
- else:
- self.core.information('Error retrieving the activity',
- 'Error')
- return
- seconds = iq['last_activity']['seconds']
- status = iq['last_activity']['status']
- from_ = iq['from']
- if not safeJID(from_).user:
- msg = 'The uptime of %s is %s.' % (
- from_, common.parse_secs_to_str(seconds))
- else:
- msg = 'The last activity of %s was %s ago%s' % (
- from_, common.parse_secs_to_str(seconds),
- (' and their last status was %s' % status)
- if status else '')
- self.core.information(msg, 'Info')
-
if args is None:
return self.help('last_activity')
- jid = safeJID(args[0])
- self.core.xmpp.plugin['xep_0012'].get_last_activity(
- jid, callback=callback)
-
- @command_args_parser.quoted(0, 2)
- def mood(self, args):
- """
- /mood [<mood> [text]]
- """
- if not args:
- return self.core.xmpp.plugin['xep_0107'].stop()
-
- mood = args[0]
- if mood not in pep.MOODS:
- return self.core.information(
- '%s is not a correct value for a mood.' % mood, 'Error')
- if len(args) == 2:
- text = args[1]
- else:
- text = None
- self.core.xmpp.plugin['xep_0107'].publish_mood(
- mood, text, callback=dumb_callback)
-
- @command_args_parser.quoted(0, 3)
- def activity(self, args):
- """
- /activity [<general> [specific] [text]]
- """
- length = len(args)
- if not length:
- return self.core.xmpp.plugin['xep_0108'].stop()
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information('Invalid JID for /last_activity: %s' % args[0], 'Error')
- general = args[0]
- if general not in pep.ACTIVITIES:
- return self.core.information(
- '%s is not a correct value for an activity' % general, 'Error')
- specific = None
- text = None
- if length == 2:
- if args[1] in pep.ACTIVITIES[general]:
- specific = args[1]
+ try:
+ iq = await self.core.xmpp.plugin['xep_0012'].get_last_activity(jid)
+ except IqError as error:
+ if error.etype == 'auth':
+ msg = 'You are not allowed to see the activity of %s' % jid
else:
- text = args[1]
- elif length == 3:
- specific = args[1]
- text = args[2]
- if specific and specific not in pep.ACTIVITIES[general]:
- return self.core.information(
- '%s is not a correct value '
- 'for an activity' % specific, 'Error')
- self.core.xmpp.plugin['xep_0108'].publish_activity(
- general, specific, text, callback=dumb_callback)
-
- @command_args_parser.quoted(0, 2)
- def gaming(self, args):
- """
- /gaming [<game name> [server address]]
- """
- if not args:
- return self.core.xmpp.plugin['xep_0196'].stop()
-
- name = args[0]
- if len(args) > 1:
- address = args[1]
+ msg = 'Error retrieving the activity of %s: %s' % (jid, error)
+ return self.core.information(msg, 'Error')
+ except IqTimeout:
+ return self.core.information('Timeout while retrieving the last activity of %s' % jid, 'Error')
+
+ seconds = iq['last_activity']['seconds']
+ status = iq['last_activity']['status']
+ from_ = iq['from']
+ if not from_.user:
+ msg = 'The uptime of %s is %s.' % (
+ from_, common.parse_secs_to_str(seconds))
else:
- address = None
- return self.core.xmpp.plugin['xep_0196'].publish_gaming(
- name=name, server_address=address, callback=dumb_callback)
+ msg = 'The last activity of %s was %s ago%s' % (
+ from_, common.parse_secs_to_str(seconds),
+ (' and their last status was %s' % status)
+ if status else '')
+ self.core.information(msg, 'Info')
@command_args_parser.quoted(2, 1, [None])
- def invite(self, args):
+ async def invite(self, args):
"""/invite <to> <room> [reason]"""
if args is None:
@@ -865,8 +963,9 @@ class CommandCore:
except InvalidJID:
self.core.information('Invalid room JID specified to invite: %s' % args[1], 'Error')
return None
- self.core.invite(to.full, room, reason=reason)
- self.core.information('Invited %s to %s' % (to.bare, room), 'Info')
+ result = await self.core.invite(to.full, room, reason=reason)
+ if result:
+ self.core.information('Invited %s to %s' % (to.bare, room), 'Info')
@command_args_parser.quoted(1, 0)
def impromptu(self, args: str) -> None:
@@ -881,17 +980,23 @@ class CommandCore:
jids.add(current_tab.general_jid)
for jid in common.shell_split(' '.join(args)):
- jids.add(safeJID(jid).bare)
+ try:
+ bare = JID(jid).bare
+ except InvalidJID:
+ return self.core.information('Invalid JID for /impromptu: %s' % args[0], 'Error')
+ 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):
"""/decline <room@server.tld> [reason]"""
if args is None:
return self.help('decline')
- jid = safeJID(args[0])
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information('Invalid JID for /decline: %s' % args[0], 'Error')
if jid.bare not in self.core.pending_invites:
return
reason = args[1]
@@ -911,9 +1016,10 @@ class CommandCore:
jid = None
if args:
try:
- jid = JID(args[0]).full
+ jid = JID(args[0])
except InvalidJID:
self.core.information('Invalid JID %s' % args, 'Error')
+ return
current_tab = self.core.tabs.current_tab
if jid is None:
@@ -926,7 +1032,7 @@ class CommandCore:
if isinstance(item, Contact):
jid = item.bare_jid
elif isinstance(item, Resource):
- jid = item.jid
+ jid = JID(item.jid)
chattabs = (
tabs.ConversationTab,
@@ -934,22 +1040,22 @@ class CommandCore:
tabs.DynamicConversationTab,
)
if isinstance(current_tab, chattabs):
- jid = current_tab.jid.bare
-
- def callback(iq: Iq) -> None:
- if iq['type'] == 'error':
- return self.core.information(
- 'Could not block %s.' % jid, 'Error',
- )
- if iq['type'] == 'result':
- return self.core.information('Blocked %s.' % jid, 'Info')
- return None
-
+ jid = JID(current_tab.jid.bare)
- if jid is not None:
- self.core.xmpp.plugin['xep_0191'].block(jid, callback=callback)
- else:
+ if jid is None:
self.core.information('No specified JID to block', 'Error')
+ else:
+ asyncio.create_task(self._block_async(jid))
+
+ async def _block_async(self, jid: JID):
+ """Block a JID, asynchronously"""
+ try:
+ await self.core.xmpp.plugin['xep_0191'].block(jid)
+ return self.core.information('Blocked %s.' % jid, 'Info')
+ except (IqError, IqTimeout):
+ return self.core.information(
+ 'Could not block %s.' % jid, 'Error',
+ )
@command_args_parser.quoted(0, 1)
def unblock(self, args: List[str]) -> None:
@@ -965,9 +1071,10 @@ class CommandCore:
jid = None
if args:
try:
- jid = JID(args[0]).full
+ jid = JID(args[0])
except InvalidJID:
self.core.information('Invalid JID %s' % args, 'Error')
+ return
current_tab = self.core.tabs.current_tab
if jid is None:
@@ -980,7 +1087,7 @@ class CommandCore:
if isinstance(item, Contact):
jid = item.bare_jid
elif isinstance(item, Resource):
- jid = item.jid
+ jid = JID(item.jid)
chattabs = (
tabs.ConversationTab,
@@ -988,34 +1095,44 @@ class CommandCore:
tabs.DynamicConversationTab,
)
if isinstance(current_tab, chattabs):
- jid = current_tab.jid.bare
+ jid = JID(current_tab.jid.bare)
if jid is not None:
- def callback(iq: Iq):
- if iq['type'] == 'error':
- return self.core.information('Could not unblock the contact.',
- 'Error')
- elif iq['type'] == 'result':
- return self.core.information('Unblocked %s.' % jid, 'Info')
-
- self.core.xmpp.plugin['xep_0191'].unblock(jid, callback=callback)
+ asyncio.create_task(
+ self._unblock_async(jid)
+ )
else:
self.core.information('No specified JID to unblock', 'Error')
+ async def _unblock_async(self, jid: JID):
+ """Unblock a JID, asynchrously"""
+ try:
+ await self.core.xmpp.plugin['xep_0191'].unblock(jid)
+ return self.core.information('Unblocked %s.' % jid, 'Info')
+ except (IqError, IqTimeout):
+ return self.core.information('Could not unblock the contact.',
+ 'Error')
+
### Commands without a completion in this class ###
@command_args_parser.ignored
def invitations(self):
"""/invitations"""
- build = ""
- for invite in self.core.pending_invites:
- build += "%s by %s" % (
- invite, safeJID(self.core.pending_invites[invite]).bare)
- if self.core.pending_invites:
- build = "You are invited to the following rooms:\n" + build
+ build = []
+ for room, inviter in self.core.pending_invites.items():
+ try:
+ bare = JID(inviter).bare
+ except InvalidJID:
+ self.core.information(
+ f'Invalid JID found in /invitations: {inviter}',
+ 'Error'
+ )
+ build.append(f'{room} by {bare}')
+ if build:
+ message = 'You are invited to the following rooms:\n' + ','.join(build)
else:
- build = "You do not have any pending invitations."
- self.core.information(build, 'Info')
+ message = 'You do not have any pending invitations.'
+ self.core.information(message, 'Info')
@command_args_parser.quoted(0, 1, [None])
def quit(self, args):
@@ -1027,38 +1144,51 @@ class CommandCore:
return
msg = args[0]
- if config.get('enable_user_mood'):
- self.core.xmpp.plugin['xep_0107'].stop()
- if config.get('enable_user_activity'):
- self.core.xmpp.plugin['xep_0108'].stop()
- if config.get('enable_user_gaming'):
- self.core.xmpp.plugin['xep_0196'].stop()
self.core.save_config()
self.core.plugin_manager.disable_plugins()
self.core.xmpp.add_event_handler(
"disconnected", self.core.exit, disposable=True)
self.core.disconnect(msg)
- @command_args_parser.quoted(0, 1, [''])
- def destroy_room(self, args: List[str]) -> None:
+ @command_args_parser.quoted(0, 3, ['', '', ''])
+ def destroy_room(self, args: List[str]):
"""
- /destroy_room [JID]
+ /destroy_room [JID [reason [alternative room 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:
+ self.core.information('Unable to destroy room %s: %s' % (room, e), 'Info')
+ else:
+ self.core.information('Room %s destroyed' % room, 'Info')
+
+ room: Optional[JID]
if not args[0] and isinstance(self.core.tabs.current_tab, tabs.MucTab):
- muc.destroy_room(self.core.xmpp,
- self.core.tabs.current_tab.general_jid)
- return None
+ room = self.core.tabs.current_tab.general_jid
+ else:
+ try:
+ room = JID(args[0])
+ except InvalidJID:
+ room = None
+ else:
+ if room.resource:
+ room = None
- try:
- room = JID(args[0]).bare
- if room:
- muc.destroy_room(self.core.xmpp, room)
- return None
- except InvalidJID:
- pass
+ if room is None:
+ self.core.information('Invalid room JID: "%s"' % args[0], 'Error')
+ return
+
+ reason = args[1]
+ altroom = None
+ if args[2]:
+ try:
+ altroom = JID(args[2])
+ except InvalidJID:
+ self.core.information('Invalid alternative room JID: "%s"' % args[2], 'Error')
+ return
- self.core.information('Invalid JID: "%s"' % args[0], 'Error')
- return None
+ asyncio.create_task(do_destroy(room, reason, altroom))
@command_args_parser.quoted(1, 1, [''])
def bind(self, args):
@@ -1153,13 +1283,16 @@ 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]
"""
if args is None:
return self.help('message')
- jid = safeJID(args[0])
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information('Invalid JID for /message: %s' % args[0], 'Error')
if not jid.user and not jid.domain and not jid.resource:
return self.core.information('Invalid JID.', 'Error')
tab = self.core.get_conversation_by_jid(
@@ -1180,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):
@@ -1192,15 +1325,23 @@ class CommandCore:
self.core.xml_tab = tab
@command_args_parser.quoted(1)
- def adhoc(self, args):
+ async def adhoc(self, args):
if not args:
return self.help('ad-hoc')
- jid = safeJID(args[0])
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information(
+ 'Invalid JID for ad-hoc command: %s' % args[0],
+ 'Error',
+ )
list_tab = tabs.AdhocCommandsListTab(self.core, jid)
self.core.add_tab(list_tab, True)
- cb = list_tab.on_list_received
- self.core.xmpp.plugin['xep_0050'].get_commands(
- jid=jid, local=False, callback=cb)
+ iq = await self.core.xmpp.plugin['xep_0050'].get_commands(
+ jid=jid,
+ local=False
+ )
+ list_tab.on_list_received(iq)
@command_args_parser.ignored
def self_(self):
@@ -1214,7 +1355,7 @@ class CommandCore:
info = ('Your JID is %s\nYour current status is "%s" (%s)'
'\nYour default nickname is %s\nYou are running poezio %s' %
(jid, message if message else '', show
- if show else 'available', nick, config_opts.custom_version))
+ if show else 'available', nick, self.core.custom_version))
self.core.information(info, 'Info')
@command_args_parser.ignored
@@ -1224,6 +1365,16 @@ class CommandCore:
"""
self.core.reload_config()
+ @command_args_parser.raw
+ def debug(self, args):
+ """/debug [filename]"""
+ if not args.strip():
+ config_module.setup_logging('')
+ self.core.information('Debug logging disabled!', 'Info')
+ elif args:
+ config_module.setup_logging(args)
+ self.core.information(f'Debug logging to {args} enabled!', 'Info')
+
def dumb_callback(*args, **kwargs):
"mock callback"
diff --git a/poezio/core/completions.py b/poezio/core/completions.py
index ee3e95bf..084910a2 100644
--- a/poezio/core/completions.py
+++ b/poezio/core/completions.py
@@ -2,26 +2,23 @@
Completions for the global commands
"""
import logging
-from typing import List, Optional
-
-log = logging.getLogger(__name__)
-
import os
-from pathlib import Path
from functools import reduce
+from pathlib import Path
+from typing import List, Optional
-from slixmpp import JID
+from slixmpp import JID, InvalidJID
from poezio import common
-from poezio import pep
from poezio import tabs
from poezio import xdg
-from poezio.common import safeJID
from poezio.config import config
from poezio.roster import roster
from poezio.core.structs import POSSIBLE_SHOW, Completion
+log = logging.getLogger(__name__)
+
class CompletionCore:
def __init__(self, core):
@@ -44,6 +41,19 @@ class CompletionCore:
' ',
quotify=False)
+ def roster_barejids(self, the_input):
+ """Complete roster bare jids"""
+ jids = sorted(
+ str(contact.bare_jid) for contact in roster.contacts.values()
+ if contact.pending_in
+ )
+ return Completion(the_input.new_completion, jids, 1, '', quotify=False)
+
+ def remove(self, the_input):
+ """Completion for /remove"""
+ jids = [jid for jid in roster.jids()]
+ return Completion(the_input.auto_completion, jids, '', quotify=False)
+
def presence(self, the_input):
"""
Completion of /presence
@@ -70,7 +80,7 @@ class CompletionCore:
def theme(self, the_input):
""" Completion for /theme"""
- themes_dir = config.get('themes_dir')
+ themes_dir = config.getstr('themes_dir')
themes_dir = Path(themes_dir).expanduser(
) if themes_dir else xdg.DATA_HOME / 'themes'
try:
@@ -112,9 +122,12 @@ class CompletionCore:
return False
if len(args) == 1:
args.append('')
- jid = safeJID(args[1])
- if args[1].endswith('@') and not jid.user and not jid.server:
- jid.user = args[1][:-1]
+ try:
+ jid = JID(args[1])
+ except InvalidJID:
+ jid = JID('')
+ if args[1].endswith('@'):
+ jid.user = args[1][:-1]
relevant_rooms = []
relevant_rooms.extend(sorted(self.core.pending_invites.keys()))
@@ -137,7 +150,8 @@ class CompletionCore:
for tab in self.core.get_tabs(tabs.MucTab):
if tab.joined:
serv_list.append(
- '%s@%s' % (jid.user, safeJID(tab.name).host))
+ '%s@%s' % (jid.user, tab.general_jid.server)
+ )
serv_list.extend(relevant_rooms)
return Completion(
the_input.new_completion, serv_list, 1, quotify=True)
@@ -201,14 +215,13 @@ class CompletionCore:
if len(args) == 1:
args.append('')
- jid = safeJID(args[1])
-
- if jid.server and (jid.resource or jid.full.endswith('/')):
+ try:
+ jid = JID(args[1])
tab = self.core.tabs.by_name_and_class(jid.bare, tabs.MucTab)
nicks = [tab.own_nick] if tab else []
default = os.environ.get('USER') if os.environ.get(
'USER') else 'poezio'
- nick = config.get('default_nick')
+ nick = config.getstr('default_nick')
if not nick:
if default not in nicks:
nicks.append(default)
@@ -218,6 +231,8 @@ class CompletionCore:
jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks]
return Completion(
the_input.new_completion, jids_list, 1, quotify=True)
+ except InvalidJID:
+ pass
muc_list = [tab.name for tab in self.core.get_tabs(tabs.MucTab)]
muc_list.sort()
muc_list.append('*')
@@ -305,33 +320,6 @@ class CompletionCore:
comp = sorted(onlines) + sorted(offlines)
return Completion(the_input.new_completion, comp, n, quotify=True)
- def activity(self, the_input):
- """Completion for /activity"""
- n = the_input.get_argument_position(quoted=True)
- args = common.shell_split(the_input.text)
- if n == 1:
- return Completion(
- the_input.new_completion,
- sorted(pep.ACTIVITIES.keys()),
- n,
- quotify=True)
- elif n == 2:
- if args[1] in pep.ACTIVITIES:
- l = list(pep.ACTIVITIES[args[1]])
- l.remove('category')
- l.sort()
- return Completion(the_input.new_completion, l, n, quotify=True)
-
- def mood(self, the_input):
- """Completion for /mood"""
- n = the_input.get_argument_position(quoted=True)
- if n == 1:
- return Completion(
- the_input.new_completion,
- sorted(pep.MOODS.keys()),
- 1,
- quotify=True)
-
def last_activity(self, the_input):
"""
Completion for /last_activity <jid>
@@ -444,14 +432,13 @@ class CompletionCore:
return False
if len(args) == 1:
args.append('')
- jid = safeJID(args[1])
-
- if jid.server and (jid.resource or jid.full.endswith('/')):
+ try:
+ jid = JID(args[1])
tab = self.core.tabs.by_name_and_class(jid.bare, tabs.MucTab)
nicks = [tab.own_nick] if tab else []
default = os.environ.get('USER') if os.environ.get(
'USER') else 'poezio'
- nick = config.get('default_nick')
+ nick = config.getstr('default_nick')
if not nick:
if default not in nicks:
nicks.append(default)
@@ -461,6 +448,8 @@ class CompletionCore:
jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks]
return Completion(
the_input.new_completion, jids_list, 1, quotify=True)
+ except InvalidJID:
+ pass
muc_list = [tab.name for tab in self.core.get_tabs(tabs.MucTab)]
muc_list.append('*')
return Completion(the_input.new_completion, muc_list, 1, quotify=True)
@@ -477,11 +466,11 @@ class CompletionCore:
tabs.StaticConversationTab,
tabs.DynamicConversationTab,
)
- tabjid = [] # type: List[JID]
+ tabjid: List[str] = []
if isinstance(current_tab, chattabs):
tabjid = [current_tab.jid.bare]
- jids = roster.jids()
+ jids = [str(i) for i in roster.jids()]
jids += tabjid
return Completion(
the_input.new_completion, jids, 1, '', quotify=False)
diff --git a/poezio/core/core.py b/poezio/core/core.py
index 525d02a6..6582402d 100644
--- a/poezio/core/core.py
+++ b/poezio/core/core.py
@@ -5,6 +5,8 @@ of everything; it also contains global commands, completions and event
handlers but those are defined in submodules in order to avoir cluttering
this file.
"""
+from __future__ import annotations
+
import logging
import asyncio
import curses
@@ -13,29 +15,47 @@ import pipes
import sys
import shutil
import time
-import uuid
from collections import defaultdict
-from typing import Callable, Dict, List, Optional, Set, Tuple, Type
+from typing import (
+ Any,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ Set,
+ Tuple,
+ Type,
+ TypeVar,
+ TYPE_CHECKING,
+)
from xml.etree import ElementTree as ET
-from functools import partial
+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
+from slixmpp.exceptions import IqError, IqTimeout, XMPPError
from poezio import connection
from poezio import decorators
from poezio import events
-from poezio import multiuserchat as muc
-from poezio import tabs
-from poezio import mam
from poezio import theming
from poezio import timed_events
from poezio import windows
-
-from poezio.bookmarks import BookmarkList
-from poezio.config import config, firstrun
+from poezio import utils
+
+from poezio.bookmarks import (
+ BookmarkList,
+ Bookmark,
+)
+from poezio.tabs import (
+ Tab, XMLTab, ChatTab, ConversationTab, PrivateTab, MucTab, OneToOneTab,
+ GapTab, RosterInfoTab, StaticConversationTab, DataFormsTab,
+ DynamicConversationTab, STATE_PRIORITY
+)
+from poezio.common import get_error_message
+from poezio.config import config
from poezio.contact import Contact, Resource
from poezio.daemon import Executor
from poezio.fifo import Fifo
@@ -46,45 +66,92 @@ from poezio.size_manager import SizeManager
from poezio.user import User
from poezio.text_buffer import TextBuffer
from poezio.timed_events import DelayedEvent
-from poezio.theming import get_theme
from poezio import keyboard, xdg
from poezio.core.completions import CompletionCore
from poezio.core.tabs import Tabs
from poezio.core.commands import CommandCore
+from poezio.core.command_defs import get_commands
from poezio.core.handlers import HandlerCore
-from poezio.core.structs import POSSIBLE_SHOW, DEPRECATED_ERRORS, \
- ERROR_AND_STATUS_CODES, Command, Status
+from poezio.core.structs import (
+ Command,
+ Status,
+ POSSIBLE_SHOW,
+)
+
+from poezio.ui.types import (
+ PersistentInfoMessage,
+ UIMessage,
+)
+
+if TYPE_CHECKING:
+ from _curses import _CursesWindow # pylint: disable=no-name-in-module
log = logging.getLogger(__name__)
+T = TypeVar('T', bound=Tab)
+
class Core:
"""
“Main” class of poezion
"""
- def __init__(self):
+ custom_version: str
+ firstrun: bool
+ completion: CompletionCore
+ command: CommandCore
+ handler: HandlerCore
+ bookmarks: BookmarkList
+ status: Status
+ commands: Dict[str, Command]
+ room_number_jump: List[str]
+ initial_joins: List[JID]
+ pending_invites: Dict[str, str]
+ configuration_change_handlers: Dict[str, List[Callable[..., None]]]
+ own_nick: str
+ connection_time: float
+ xmpp: connection.Connection
+ avatar_cache: FileSystemPerJidCache
+ plugins_autoloaded: bool
+ previous_tab_nb: int
+ tabs: Tabs
+ size: SizeManager
+ plugin_manager: PluginManager
+ events: events.EventHandler
+ legitimate_disconnect: bool
+ information_buffer: TextBuffer
+ information_win_size: int
+ stdscr: Optional[_CursesWindow]
+ xml_buffer: TextBuffer
+ xml_tab: Optional[XMLTab]
+ last_stream_error: Optional[Tuple[float, XMPPError]]
+ remote_fifo: Optional[Fifo]
+ key_func: KeyDict
+ tab_win: windows.GlobalInfoBar
+ left_tab_win: Optional[windows.VerticalGlobalInfoBar]
+
+ def __init__(self, custom_version: str, firstrun: bool):
self.completion = CompletionCore(self)
self.command = CommandCore(self)
self.handler = HandlerCore(self)
+ self.firstrun = firstrun
# All uncaught exception are given to this callback, instead
# of being displayed on the screen and exiting the program.
sys.excepthook = self.on_exception
self.connection_time = time.time()
self.last_stream_error = None
self.stdscr = None
- status = config.get('status')
- status = POSSIBLE_SHOW.get(status, None)
- self.status = Status(show=status, message=config.get('status_message'))
- self.running = True
- self.xmpp = connection.Connection()
+ status = config.getstr('status')
+ status = POSSIBLE_SHOW.get(status) or ''
+ self.status = Status(show=status, message=config.getstr('status_message'))
+ self.custom_version = custom_version
+ self.xmpp = connection.Connection(custom_version)
self.xmpp.core = self
self.keyboard = keyboard.Keyboard()
roster.set_node(self.xmpp.client_roster)
decorators.refresh_wrapper.core = self
self.bookmarks = BookmarkList()
- self.debug = False
self.remote_fifo = None
self.avatar_cache = FileSystemPerJidCache(
str(xdg.CACHE_HOME), 'avatars', binary=True)
@@ -92,13 +159,8 @@ class Core:
# that are displayed in almost all tabs, in an
# information window.
self.information_buffer = TextBuffer()
- self.information_win_size = config.get(
- 'info_win_height', section='var')
- self.information_win = windows.TextWin(300)
- self.information_buffer.add_window(self.information_win)
- self.left_tab_win = None
+ self.information_win_size = config.getint('info_win_height', section='var')
- self.tab_win = windows.GlobalInfoBar(self)
# Whether the XML tab is opened
self.xml_tab = None
self.xml_buffer = TextBuffer()
@@ -108,14 +170,13 @@ class Core:
self.events = events.EventHandler()
self.events.add_event_handler('tab_change', self.on_tab_change)
- self.tabs = Tabs(self.events)
+ self.tabs = Tabs(self.events, GapTab())
self.previous_tab_nb = 0
- own_nick = config.get('default_nick')
- own_nick = own_nick or self.xmpp.boundjid.user
- own_nick = own_nick or os.environ.get('USER')
- own_nick = own_nick or 'poezio'
- self.own_nick = own_nick
+ self.own_nick: str = (
+ config.getstr('default_nick') or self.xmpp.boundjid.user or
+ os.environ.get('USER') or 'poezio_user'
+ )
self.size = SizeManager(self)
@@ -202,6 +263,7 @@ class Core:
'_show_plugins': self.command.plugins,
'_show_xmltab': self.command.xml_tab,
'_toggle_pane': self.toggle_left_pane,
+ "_go_to_room_name": self.go_to_room_name,
###### status actions ######
'_available': lambda: self.command.status('available'),
'_away': lambda: self.command.status('away'),
@@ -209,12 +271,12 @@ class Core:
'_dnd': lambda: self.command.status('dnd'),
'_xa': lambda: self.command.status('xa'),
##### Custom actions ########
- '_exc_': self.try_execute,
}
self.key_func.update(key_func)
+ 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),
@@ -268,35 +330,20 @@ class Core:
for name, handler in xmpp_event_handlers:
self.xmpp.add_event_handler(name, handler)
- if config.get('enable_avatars'):
+ if config.getbool('enable_avatars'):
self.xmpp.add_event_handler("vcard_avatar_update",
self.handler.on_vcard_avatar)
self.xmpp.add_event_handler("avatar_metadata_publish",
self.handler.on_0084_avatar)
- if config.get('enable_user_tune'):
- self.xmpp.add_event_handler("user_tune_publish",
- self.handler.on_tune_event)
- if config.get('enable_user_nick'):
+ if config.getbool('enable_user_nick'):
self.xmpp.add_event_handler("user_nick_publish",
self.handler.on_nick_received)
- if config.get('enable_user_mood'):
- self.xmpp.add_event_handler("user_mood_publish",
- self.handler.on_mood_event)
- if config.get('enable_user_activity'):
- self.xmpp.add_event_handler("user_activity_publish",
- self.handler.on_activity_event)
- if config.get('enable_user_gaming'):
- self.xmpp.add_event_handler("user_gaming_publish",
- self.handler.on_gaming_event)
-
all_stanzas = Callback('custom matcher', connection.MatchAll(None),
self.handler.incoming_stanza)
self.xmpp.register_handler(all_stanzas)
self.initial_joins = []
- self.connected_events = {}
-
self.pending_invites = {}
# a dict of the form {'config_option': [list, of, callbacks]}
@@ -312,13 +359,12 @@ class Core:
# The callback takes two argument: the config option, and the new
# value
self.configuration_change_handlers = defaultdict(list)
- config_handlers = [
+ config_handlers: List[Tuple[str, Callable[..., Any]]] = [
('', self.on_any_config_change),
('ack_message_receipts', self.on_ack_receipts_config_change),
('connection_check_interval', self.xmpp.set_keepalive_values),
('connection_timeout_delay', self.xmpp.set_keepalive_values),
('create_gaps', self.on_gaps_config_change),
- ('deterministic_nick_colors', self.on_nick_determinism_changed),
('enable_carbons', self.on_carbons_switch),
('enable_vertical_tab_list',
self.on_vertical_tab_list_config_change),
@@ -329,6 +375,7 @@ class Core:
('plugins_dir', self.plugin_manager.on_plugins_dir_change),
('request_message_receipts',
self.on_request_receipts_config_change),
+ ('show_timestamps', self.on_show_timestamps_changed),
('theme', self.on_theme_config_change),
('themes_dir', theming.update_themes_dir),
('use_bookmarks_method', self.on_bookmarks_method_config_change),
@@ -338,7 +385,14 @@ class Core:
for option, handler in config_handlers:
self.add_configuration_handler(option, handler)
- def on_tab_change(self, old_tab: tabs.Tab, new_tab: tabs.Tab):
+ def _create_windows(self):
+ """Create the windows (delayed after curses init)"""
+ self.information_win = windows.TextWin(300)
+ self.information_buffer.add_window(self.information_win)
+ self.left_tab_win = None
+ self.tab_win = windows.GlobalInfoBar(self)
+
+ def on_tab_change(self, old_tab: Tab, new_tab: Tab):
"""Whenever the current tab changes, change focus and refresh"""
old_tab.on_lose_focus()
new_tab.on_gain_focus()
@@ -379,6 +433,12 @@ class Core:
"""
self.call_for_resize()
+ def on_show_timestamps_changed(self, option, value):
+ """
+ Called when the show_timestamps option changes
+ """
+ self.call_for_resize(ui_config_changed=True)
+
def on_bookmarks_method_config_change(self, option, value):
"""
Called when the use_bookmarks_method option changes
@@ -386,7 +446,9 @@ class Core:
if value not in ('pep', 'privatexml'):
return
self.bookmarks.preferred = value
- self.bookmarks.save(self.xmpp, core=self)
+ asyncio.create_task(
+ self.bookmarks.save(self.xmpp, core=self)
+ )
def on_gaps_config_change(self, option, value):
"""
@@ -430,14 +492,6 @@ class Core:
"""
self.xmpp.password = value
- def on_nick_determinism_changed(self, option, value):
- """If we change the value to true, we call /recolor on all the MucTabs, to
- make the current nick colors reflect their deterministic value.
- """
- if value.lower() == "true":
- for tab in self.get_tabs(tabs.MucTab):
- tab.command_recolor('')
-
def on_carbons_switch(self, option, value):
"""Whenever the user enables or disables carbons using /set, we should
inform the server immediately, this way we do not require a restart
@@ -501,12 +555,6 @@ class Core:
}
log.error("%s received. Exiting…", signals[sig])
- if config.get('enable_user_mood'):
- self.xmpp.plugin['xep_0107'].stop()
- if config.get('enable_user_activity'):
- self.xmpp.plugin['xep_0108'].stop()
- if config.get('enable_user_gaming'):
- self.xmpp.plugin['xep_0196'].stop()
self.plugin_manager.disable_plugins()
self.disconnect('%s received' % signals.get(sig))
self.xmpp.add_event_handler("disconnected", self.exit, disposable=True)
@@ -515,13 +563,13 @@ class Core:
"""
Load the plugins on startup.
"""
- plugins = config.get('plugins_autoload')
+ plugins = config.getstr('plugins_autoload')
if ':' in plugins:
for plugin in plugins.split(':'):
- self.plugin_manager.load(plugin)
+ self.plugin_manager.load(plugin, unload_first=False)
else:
for plugin in plugins.split():
- self.plugin_manager.load(plugin)
+ self.plugin_manager.load(plugin, unload_first=False)
self.plugins_autoloaded = True
def start(self):
@@ -530,12 +578,20 @@ class Core:
"""
self.stdscr = curses.initscr()
self._init_curses(self.stdscr)
+ windows.base_wins.TAB_WIN = self.stdscr
+ self._create_windows()
self.call_for_resize()
- default_tab = tabs.RosterInfoTab(self)
+ default_tab = RosterInfoTab(self)
default_tab.on_gain_focus()
self.tabs.append(default_tab)
self.information('Welcome to poezio!', 'Info')
- if firstrun:
+ if curses.COLORS < 256:
+ self.information(
+ 'Your terminal does not appear to support 256 colors, the UI'
+ ' colors will probably be ugly',
+ 'Error',
+ )
+ if self.firstrun:
self.information(
'It seems that it is the first time you start poezio.\n'
'The online help is here https://doc.poez.io/\n\n'
@@ -563,7 +619,7 @@ class Core:
pass
sys.__excepthook__(typ, value, trace)
- def sigwinch_handler(self):
+ def sigwinch_handler(self, *args):
"""A work-around for ncurses resize stuff, which sucks. Normally, ncurses
catches SIGWINCH itself. In its signal handler, it updates the
windows structures (for example the size, etc) and it
@@ -605,7 +661,7 @@ class Core:
except ValueError:
pass
else:
- if self.tabs.current_tab.nb == nb and config.get(
+ if self.tabs.current_tab.nb == nb and config.getbool(
'go_to_previous_tab_on_alt_number'):
self.go_to_previous_tab()
else:
@@ -618,10 +674,28 @@ class Core:
self.do_command(replace_line_breaks(char), False)
else:
self.do_command(''.join(char_list), True)
- if self.status.show not in ('xa', 'away'):
- self.xmpp.plugin['xep_0319'].idle()
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
@@ -659,7 +733,7 @@ class Core:
Messages are namedtuples of the form
('txt nick_color time str_time nickname user')
"""
- if not isinstance(self.tabs.current_tab, tabs.ChatTab):
+ if not isinstance(self.tabs.current_tab, ChatTab):
return None
return self.tabs.current_tab.get_conversation_messages()
@@ -716,9 +790,9 @@ class Core:
work. If you try to do anything else, your |, [, <<, etc will be
interpreted as normal command arguments, not shell special tokens.
"""
- if config.get('exec_remote'):
+ if config.getbool('exec_remote'):
# We just write the command in the fifo
- fifo_path = config.get('remote_fifo_path')
+ fifo_path = config.getstr('remote_fifo_path')
filename = os.path.join(fifo_path, 'poezio.fifo')
if not self.remote_fifo:
try:
@@ -790,16 +864,18 @@ class Core:
def remove_timed_event(self, event: DelayedEvent) -> None:
"""Remove an existing timed event"""
- event.handler.cancel()
+ if event.handler is not None:
+ event.handler.cancel()
def add_timed_event(self, event: DelayedEvent) -> None:
"""Add a new timed event"""
event.handler = asyncio.get_event_loop().call_later(
- event.delay, event.callback, *event.args)
+ event.delay, event.callback, *event.args
+ )
####################### XMPP-related actions ##################################
- def get_status(self) -> str:
+ def get_status(self) -> Status:
"""
Get the last status that was previously set
"""
@@ -812,7 +888,7 @@ class Core:
or to use it when joining a new muc)
"""
self.status = Status(show=pres, message=msg)
- if config.get('save_status'):
+ if config.getbool('save_status'):
ok = config.silent_set('status', pres if pres else '')
msg = msg.replace('\n', '|') if msg else ''
ok = ok and config.silent_set('status_message', msg)
@@ -827,7 +903,7 @@ class Core:
or the default nickname
"""
bm = self.bookmarks[room_name]
- if bm:
+ if bm and bm.nick:
return bm.nick
return self.own_nick
@@ -840,7 +916,7 @@ class Core:
if reconnect:
self.xmpp.reconnect(wait=0.0, reason=msg)
else:
- for tab in self.get_tabs(tabs.MucTab):
+ for tab in self.get_tabs(MucTab):
tab.leave_room(msg)
self.xmpp.disconnect(reason=msg)
@@ -850,32 +926,48 @@ class Core:
conversation.
Returns False if the current tab is not a conversation tab
"""
- if not isinstance(self.tabs.current_tab, tabs.ChatTab):
+ 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
- def invite(self, jid: JID, room: JID, reason: Optional[str] = None) -> None:
+ async def invite(self, jid: JID, room: JID, reason: Optional[str] = None, force_mediated: bool = False) -> bool:
"""
Checks if the sender supports XEP-0249, then send an invitation,
or a mediated one if it does not.
TODO: allow passwords
"""
+ features = set()
- def callback(iq):
- if not iq:
- return
- if 'jabber:x:conference' in iq['disco_info'].get_features():
- self.xmpp.plugin['xep_0249'].send_invitation(
- jid, room, reason=reason)
- else: # fallback
- self.xmpp.plugin['xep_0045'].invite(
- room, jid, reason=reason or '')
-
- self.xmpp.plugin['xep_0030'].get_info(
- jid=jid, timeout=5, callback=callback)
+ # force mediated: act as if the other entity does not
+ # support direct invites
+ if not force_mediated:
+ try:
+ iq = await self.xmpp.plugin['xep_0030'].get_info(
+ jid=jid,
+ timeout=5,
+ )
+ features = iq['disco_info'].get_features()
+ except (IqError, IqTimeout):
+ pass
+ supports_direct = 'jabber:x:conference' in features
+ if supports_direct:
+ self.xmpp.plugin['xep_0249'].send_invitation(
+ jid=jid,
+ roomjid=room,
+ reason=reason
+ )
+ else: # fallback
+ self.xmpp.plugin['xep_0045'].invite(
+ jid=jid,
+ room=room,
+ reason=reason or '',
+ )
+ 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),
@@ -936,82 +1028,78 @@ 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()
- self.information('Room %s created' % room, 'Info')
+ 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
- for jid in jids:
- self.invite(jid, room)
+ self.information(f'Room {room} created', 'Info')
- def get_error_message(self, stanza, deprecated: bool = False):
- """
- Takes a stanza of the form <message type='error'><error/></message>
- and return a well formed string containing error information
- """
- sender = stanza['from']
- msg = stanza['error']['type']
- condition = stanza['error']['condition']
- code = stanza['error']['code']
- body = stanza['error']['text']
- if not body:
- if deprecated:
- if code in DEPRECATED_ERRORS:
- body = DEPRECATED_ERRORS[code]
- else:
- body = condition or 'Unknown error'
- else:
- if code in ERROR_AND_STATUS_CODES:
- body = ERROR_AND_STATUS_CODES[code]
- else:
- body = condition or 'Unknown error'
- if code:
- message = '%(from)s: %(code)s - %(msg)s: %(body)s' % {
- 'from': sender,
- 'msg': msg,
- 'body': body,
- 'code': code
- }
- else:
- message = '%(from)s: %(msg)s: %(body)s' % {
- 'from': sender,
- 'msg': msg,
- 'body': body
- }
- return message
+ 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')
+
+ self.xmpp.add_event_handler(
+ f'muc::{room.bare}::groupchat_subject',
+ configure_and_invite,
+ disposable=True,
+ )
####################### Tab logic-related things ##############################
### Tab getters ###
- def get_tabs(self, cls: Type[tabs.Tab] = None) -> List[tabs.Tab]:
+ def get_tabs(self, cls: Type[T]) -> List[T]:
"Get all the tabs of a type"
- if cls is None:
- return self.tabs.get_tabs()
return self.tabs.by_class(cls)
def get_conversation_by_jid(self,
jid: JID,
create: bool = True,
- fallback_barejid: bool = True) -> Optional[tabs.ChatTab]:
+ fallback_barejid: bool = True) -> Optional[ChatTab]:
"""
From a JID, get the tab containing the conversation with it.
If none already exist, and create is "True", we create it
@@ -1023,16 +1111,17 @@ class Core:
jid = JID(jid)
# We first check if we have a static conversation opened
# with this precise resource
+ conversation: Optional[ConversationTab]
conversation = self.tabs.by_name_and_class(jid.full,
- tabs.StaticConversationTab)
+ StaticConversationTab)
if jid.bare == jid.full and not conversation:
conversation = self.tabs.by_name_and_class(
- jid.full, tabs.DynamicConversationTab)
+ jid.full, DynamicConversationTab)
if not conversation and fallback_barejid:
# If not, we search for a conversation with the bare jid
conversation = self.tabs.by_name_and_class(
- jid.bare, tabs.DynamicConversationTab)
+ jid.bare, DynamicConversationTab)
if not conversation:
if create:
# We create a dynamic conversation with the bare Jid if
@@ -1044,7 +1133,7 @@ class Core:
conversation = None
return conversation
- def add_tab(self, new_tab: tabs.Tab, focus: bool = False) -> None:
+ def add_tab(self, new_tab: Tab, focus: bool = False) -> None:
"""
Appends the new_tab in the tab list and
focus it if focus==True
@@ -1059,21 +1148,21 @@ class Core:
returns False if it could not move the tab, True otherwise
"""
return self.tabs.insert_tab(old_pos, new_pos,
- config.get('create_gaps'))
+ config.getbool('create_gaps'))
### Move actions (e.g. go to next room) ###
- def rotate_rooms_right(self, args=None) -> None:
+ def rotate_rooms_right(self) -> None:
"""
rotate the rooms list to the right
"""
- self.tabs.next()
+ self.tabs.next() # pylint: disable=not-callable
- def rotate_rooms_left(self, args=None) -> None:
+ def rotate_rooms_left(self) -> None:
"""
rotate the rooms list to the right
"""
- self.tabs.prev()
+ self.tabs.prev() # pylint: disable=not-callable
def go_to_room_number(self) -> None:
"""
@@ -1101,6 +1190,34 @@ class Core:
keyboard.continuation_keys_callback = read_next_digit
+ def go_to_room_name(self) -> None:
+ room_name_jump = []
+
+ def read_next_letter(s) -> None:
+ nonlocal room_name_jump
+ room_name_jump.append(s)
+ any_matched, unique_tab = self.tabs.find_by_unique_prefix(
+ "".join(room_name_jump)
+ )
+
+ if not any_matched:
+ return
+
+ if unique_tab is not None:
+ self.tabs.set_current_tab(unique_tab)
+ # NOTE: returning here means that as soon as the tab is
+ # matched, normal input resumes. If we do *not* return here,
+ # any further characters matching the prefix of the tab will
+ # be swallowed (and a lot of tab switching will happen...),
+ # until a non-matching character or escape or something is
+ # pressed.
+ # This behaviour *may* be desirable.
+ return
+
+ keyboard.continuation_keys_callback = read_next_letter
+
+ keyboard.continuation_keys_callback = read_next_letter
+
def go_to_roster(self) -> None:
"Select the roster as the current tab"
self.tabs.set_current_tab(self.tabs.first())
@@ -1112,11 +1229,11 @@ class Core:
def go_to_important_room(self) -> None:
"""
Go to the next room with activity, in the order defined in the
- dict tabs.STATE_PRIORITY
+ dict STATE_PRIORITY
"""
# shortcut
- priority = tabs.STATE_PRIORITY
- tab_refs = {} # type: Dict[str, List[tabs.Tab]]
+ priority = STATE_PRIORITY
+ tab_refs: Dict[str, List[Tab]] = {}
# put all the active tabs in a dict of lists by state
for tab in self.tabs.get_tabs():
if not tab:
@@ -1141,7 +1258,7 @@ class Core:
def focus_tab_named(self,
tab_name: str,
- type_: Type[tabs.Tab] = None) -> bool:
+ type_: Type[Tab] = None) -> bool:
"""Returns True if it found a tab to focus on"""
if type_ is None:
tab = self.tabs.by_name(tab_name)
@@ -1152,23 +1269,24 @@ class Core:
return True
return False
- def focus_tab(self, tab: tabs.Tab) -> bool:
+ def focus_tab(self, tab: Tab) -> bool:
"""Focus a tab"""
return self.tabs.set_current_tab(tab)
### Opening actions ###
def open_conversation_window(self, jid: JID,
- focus=True) -> tabs.ConversationTab:
+ focus=True) -> ConversationTab:
"""
Open a new conversation tab and focus it if needed. If a resource is
provided, we open a StaticConversationTab, else a
DynamicConversationTab
"""
+ new_tab: ConversationTab
if jid.resource:
- new_tab = tabs.StaticConversationTab(self, jid)
+ new_tab = StaticConversationTab(self, jid)
else:
- new_tab = tabs.DynamicConversationTab(self, jid)
+ new_tab = DynamicConversationTab(self, jid)
if not focus:
new_tab.state = "private"
self.add_tab(new_tab, focus)
@@ -1176,41 +1294,41 @@ class Core:
return new_tab
def open_private_window(self, room_name: str, user_nick: str,
- focus=True) -> Optional[tabs.PrivateTab]:
+ focus=True) -> Optional[PrivateTab]:
"""
Open a Private conversation in a MUC and focus if needed.
"""
complete_jid = room_name + '/' + user_nick
# if the room exists, focus it and return
- for tab in self.get_tabs(tabs.PrivateTab):
+ for tab in self.get_tabs(PrivateTab):
if tab.name == complete_jid:
self.tabs.set_current_tab(tab)
return tab
# create the new tab
- tab = self.tabs.by_name_and_class(room_name, tabs.MucTab)
- if not tab:
+ muc_tab = self.tabs.by_name_and_class(room_name, MucTab)
+ if not muc_tab:
return None
- new_tab = tabs.PrivateTab(self, complete_jid, tab.own_nick)
+ tab = PrivateTab(self, complete_jid, muc_tab.own_nick)
if hasattr(tab, 'directed_presence'):
- new_tab.directed_presence = tab.directed_presence
+ tab.directed_presence = tab.directed_presence
if not focus:
- new_tab.state = "private"
+ tab.state = "private"
# insert it in the tabs
- self.add_tab(new_tab, focus)
+ self.add_tab(tab, focus)
self.refresh_window()
- tab.privates.append(new_tab)
- return new_tab
+ muc_tab.privates.append(tab)
+ return tab
def open_new_room(self,
- room: str,
+ room: JID,
nick: str,
*,
password: Optional[str] = None,
- focus=True) -> tabs.MucTab:
+ focus=True) -> MucTab:
"""
Open a new tab.MucTab containing a muc Room, using the specified nick
"""
- new_tab = tabs.MucTab(self, room, nick, password=password)
+ new_tab = MucTab(self, room, nick, password=password)
self.add_tab(new_tab, focus)
self.refresh_window()
return new_tab
@@ -1222,7 +1340,7 @@ class Core:
The callback are called with the completed form as parameter in
addition with kwargs
"""
- form_tab = tabs.DataFormsTab(self, form, on_cancel, on_send, kwargs)
+ form_tab = DataFormsTab(self, form, on_cancel, on_send, kwargs)
self.add_tab(form_tab, True)
### Modifying actions ###
@@ -1234,7 +1352,7 @@ class Core:
with him/her
"""
tab = self.tabs.by_name_and_class('%s/%s' % (room_name, old_nick),
- tabs.PrivateTab)
+ PrivateTab)
if tab:
tab.rename_user(old_nick, user)
@@ -1245,7 +1363,7 @@ class Core:
private conversation
"""
tab = self.tabs.by_name_and_class('%s/%s' % (room_name, user.nick),
- tabs.PrivateTab)
+ PrivateTab)
if tab:
tab.user_left(status_message, user)
@@ -1255,7 +1373,7 @@ class Core:
private conversation
"""
tab = self.tabs.by_name_and_class('%s/%s' % (room_name, nick),
- tabs.PrivateTab)
+ PrivateTab)
if tab:
tab.user_rejoined(nick)
@@ -1267,7 +1385,7 @@ class Core:
"""
if reason is None:
reason = '\x195}You left the room\x193}'
- for tab in self.get_tabs(tabs.PrivateTab):
+ for tab in self.get_tabs(PrivateTab):
if tab.name.startswith(room_name):
tab.deactivate(reason=reason)
@@ -1278,28 +1396,28 @@ class Core:
"""
if reason is None:
reason = '\x195}You joined the room\x193}'
- for tab in self.get_tabs(tabs.PrivateTab):
+ for tab in self.get_tabs(PrivateTab):
if tab.name.startswith(room_name):
tab.activate(reason=reason)
- def on_user_changed_status_in_private(self, jid: JID, status: str) -> None:
- tab = self.tabs.by_name_and_class(jid, tabs.ChatTab)
+ def on_user_changed_status_in_private(self, jid: JID, status: Status) -> None:
+ tab = self.tabs.by_name_and_class(jid, OneToOneTab)
if tab is not None: # display the message in private
tab.update_status(status)
- def close_tab(self, to_close: tabs.Tab = None) -> None:
+ def close_tab(self, to_close: Tab = None) -> None:
"""
Close the given tab. If None, close the current one
"""
was_current = to_close is None
tab = to_close or self.tabs.current_tab
- if isinstance(tab, tabs.RosterInfoTab):
+ if isinstance(tab, RosterInfoTab):
return # The tab 0 should NEVER be closed
tab.on_close()
del tab.key_func # Remove self references
del tab.commands # and make the object collectable
- self.tabs.delete(tab, gap=config.get('create_gaps'))
+ self.tabs.delete(tab, gap=config.getbool('create_gaps'))
logger.close(tab.name)
if was_current:
self.tabs.current_tab.on_gain_focus()
@@ -1315,9 +1433,9 @@ class Core:
Search for a ConversationTab with the given jid (full or bare),
if yes, add the given message to it
"""
- tab = self.tabs.by_name_and_class(jid, tabs.ConversationTab)
+ tab = self.tabs.by_name_and_class(jid, ConversationTab)
if tab is not None:
- tab.add_message(msg, typ=2)
+ tab.add_message(PersistentInfoMessage(msg))
if self.tabs.current_tab is tab:
self.refresh_window()
@@ -1325,36 +1443,36 @@ class Core:
def doupdate(self) -> None:
"Do a curses update"
- if not self.running:
- return
curses.doupdate()
def information(self, msg: str, typ: str = '') -> bool:
"""
Displays an informational message in the "Info" buffer
"""
- filter_types = config.get('information_buffer_type_filter').split(':')
+ filter_types = config.getlist('information_buffer_type_filter')
if typ.lower() in filter_types:
log.debug(
'Did not show the message:\n\t%s> %s \n\tdue to '
'information_buffer_type_filter configuration', typ, msg)
return False
- filter_messages = config.get('filter_info_messages').split(':')
+ filter_messages = config.getlist('filter_info_messages')
for words in filter_messages:
if words and words in msg:
log.debug(
'Did not show the message:\n\t%s> %s \n\tdue to filter_info_messages configuration',
typ, msg)
return False
- colors = get_theme().INFO_COLORS
- color = colors.get(typ.lower(), colors.get('default', None))
nb_lines = self.information_buffer.add_message(
- msg, nickname=typ, nick_color=color)
- popup_on = config.get('information_buffer_popup_on').split()
- if isinstance(self.tabs.current_tab, tabs.RosterInfoTab):
+ UIMessage(
+ txt=msg,
+ level=typ,
+ )
+ )
+ popup_on = config.getlist('information_buffer_popup_on')
+ if isinstance(self.tabs.current_tab, RosterInfoTab):
self.refresh_window()
elif typ != '' and typ.lower() in popup_on:
- popup_time = config.get('popup_time') + (nb_lines - 1) * 2
+ popup_time = config.getint('popup_time') + (nb_lines - 1) * 2
self._pop_information_win_up(nb_lines, popup_time)
else:
if self.information_win_size != 0:
@@ -1502,7 +1620,7 @@ class Core:
Scroll the information buffer up
"""
self.information_win.scroll_up(self.information_win.height)
- if not isinstance(self.tabs.current_tab, tabs.RosterInfoTab):
+ if not isinstance(self.tabs.current_tab, RosterInfoTab):
self.information_win.refresh()
else:
info = self.tabs.current_tab.information_win
@@ -1514,7 +1632,7 @@ class Core:
Scroll the information buffer down
"""
self.information_win.scroll_down(self.information_win.height)
- if not isinstance(self.tabs.current_tab, tabs.RosterInfoTab):
+ if not isinstance(self.tabs.current_tab, RosterInfoTab):
self.information_win.refresh()
else:
info = self.tabs.current_tab.information_win
@@ -1539,57 +1657,47 @@ class Core:
"""
Enable/disable the left panel.
"""
- enabled = config.get('enable_vertical_tab_list')
+ enabled = config.getbool('enable_vertical_tab_list')
if not config.silent_set('enable_vertical_tab_list', str(not enabled)):
self.information('Unable to write in the config file', 'Error')
self.call_for_resize()
- def resize_global_information_win(self):
+ def resize_global_information_win(self, ui_config_changed: bool = False):
"""
Resize the global_information_win only once at each resize.
"""
- if self.information_win_size > tabs.Tab.height - 6:
- self.information_win_size = tabs.Tab.height - 6
- if tabs.Tab.height < 6:
+ if self.information_win_size > Tab.height - 6:
+ self.information_win_size = Tab.height - 6
+ if Tab.height < 6:
self.information_win_size = 0
- height = (tabs.Tab.height - 1 - self.information_win_size -
- tabs.Tab.tab_win_height())
- self.information_win.resize(self.information_win_size, tabs.Tab.width,
- height, 0)
+ height = (Tab.height - 1 - self.information_win_size -
+ Tab.tab_win_height())
+ self.information_win.resize(self.information_win_size, Tab.width,
+ height, 0, self.information_buffer,
+ force=ui_config_changed)
def resize_global_info_bar(self):
"""
Resize the GlobalInfoBar only once at each resize
"""
height, width = self.stdscr.getmaxyx()
- if config.get('enable_vertical_tab_list'):
+ if config.getbool('enable_vertical_tab_list'):
if self.size.core_degrade_x:
return
try:
height, _ = self.stdscr.getmaxyx()
truncated_win = self.stdscr.subwin(
- height, config.get('vertical_tab_list_size'), 0, 0)
+ height, config.getint('vertical_tab_list_size'), 0, 0)
except:
log.error('Curses error on infobar resize', exc_info=True)
return
self.left_tab_win = windows.VerticalGlobalInfoBar(
self, truncated_win)
elif not self.size.core_degrade_y:
- self.tab_win.resize(1, tabs.Tab.width, tabs.Tab.height - 2, 0)
+ self.tab_win.resize(1, Tab.width, Tab.height - 2, 0)
self.left_tab_win = None
- def add_message_to_text_buffer(self, buff, txt, nickname=None):
- """
- Add the message to the room if possible, else, add it to the Info window
- (in the Info tab of the info window in the RosterTab)
- """
- if not buff:
- self.information('Trying to add a message in no room: %s' % txt,
- 'Error')
- return
- buff.add_message(txt, nickname=nickname)
-
def full_screen_redraw(self):
"""
Completely erase and redraw the screen
@@ -1597,7 +1705,7 @@ class Core:
self.stdscr.clear()
self.refresh_window()
- def call_for_resize(self):
+ def call_for_resize(self, ui_config_changed: bool = False):
"""
Called when we want to resize the screen
"""
@@ -1605,22 +1713,27 @@ class Core:
# window to each Tab class, so they draw themself in the portion of
# the screen that they can occupy, and we draw the tab list on the
# remaining space, on the left
+ if self.stdscr is None:
+ raise ValueError('No output available')
height, width = self.stdscr.getmaxyx()
- if (config.get('enable_vertical_tab_list')
+ if (config.getbool('enable_vertical_tab_list')
and not self.size.core_degrade_x):
try:
- scr = self.stdscr.subwin(0,
- config.get('vertical_tab_list_size'))
+ scr = self.stdscr.subwin(
+ 0,
+ config.getint('vertical_tab_list_size')
+ )
except:
log.error('Curses error on resize', exc_info=True)
return
else:
scr = self.stdscr
- tabs.Tab.resize(scr)
+ Tab.initial_resize(scr)
self.resize_global_info_bar()
- self.resize_global_information_win()
+ self.resize_global_information_win(ui_config_changed)
for tab in self.tabs:
- if config.get('lazy_resize'):
+ tab.ui_config_changed = True
+ if config.getbool('lazy_resize'):
tab.need_resize = True
else:
tab.resize()
@@ -1663,330 +1776,10 @@ class Core:
"""
Register the commands when poezio starts
"""
- self.register_command(
- 'help',
- self.command.help,
- usage='[command]',
- shortdesc='\\_o< KOIN KOIN KOIN',
- completion=self.completion.help)
- self.register_command(
- 'join',
- self.command.join,
- usage="[room_name][@server][/nick] [password]",
- desc="Join the specified room. You can specify a nickname "
- "after a slash (/). If no nickname is specified, you will"
- " use the default_nick in the configuration file. You can"
- " omit the room name: you will then join the room you\'re"
- " looking at (useful if you were kicked). You can also "
- "provide a room_name without specifying a server, the "
- "server of the room you're currently in will be used. You"
- " can also provide a password to join the room.\nExamples"
- ":\n/join room@server.tld\n/join room@server.tld/John\n"
- "/join room2\n/join /me_again\n/join\n/join room@server"
- ".tld/my_nick password\n/join / password",
- shortdesc='Join a room',
- completion=self.completion.join)
- self.register_command(
- 'exit',
- self.command.quit,
- desc='Just disconnect from the server and exit poezio.',
- shortdesc='Exit poezio.')
- self.register_command(
- 'quit',
- self.command.quit,
- desc='Just disconnect from the server and exit poezio.',
- shortdesc='Exit poezio.')
- self.register_command(
- 'next', self.rotate_rooms_right, shortdesc='Go to the next room.')
- self.register_command(
- 'prev',
- self.rotate_rooms_left,
- shortdesc='Go to the previous room.')
- self.register_command(
- 'win',
- self.command.win,
- usage='<number or name>',
- shortdesc='Go to the specified room',
- completion=self.completion.win)
- self.commands['w'] = self.commands['win']
- self.register_command(
- 'move_tab',
- self.command.move_tab,
- usage='<source> <destination>',
- desc="Insert the <source> tab at the position of "
- "<destination>. This will make the following tabs shift in"
- " some cases (refer to the documentation). A tab can be "
- "designated by its number or by the beginning of its "
- "address. You can use \".\" as a shortcut for the current "
- "tab.",
- shortdesc='Move a tab.',
- completion=self.completion.move_tab)
- self.register_command(
- 'destroy_room',
- self.command.destroy_room,
- usage='[room JID]',
- desc='Try to destroy the room [room JID], or the current'
- ' tab if it is a multi-user chat and [room JID] is '
- 'not given.',
- shortdesc='Destroy a room.',
- completion=None)
- self.register_command(
- 'show',
- self.command.status,
- usage='<availability> [status message]',
- desc="Sets your availability and (optionally) your status "
- "message. The <availability> argument is one of \"available"
- ", chat, away, afk, dnd, busy, xa\" and the optional "
- "[status message] argument will be your status message.",
- shortdesc='Change your availability.',
- completion=self.completion.status)
- self.commands['status'] = self.commands['show']
- self.register_command(
- 'bookmark_local',
- self.command.bookmark_local,
- usage="[roomname][/nick] [password]",
- desc="Bookmark Local: Bookmark locally the specified room "
- "(you will then auto-join it on each poezio start). This"
- " commands uses almost the same syntaxe as /join. Type "
- "/help join for syntax examples. Note that when typing "
- "\"/bookmark\" on its own, the room will be bookmarked "
- "with the nickname you\'re currently using in this room "
- "(instead of default_nick)",
- shortdesc='Bookmark a room locally.',
- completion=self.completion.bookmark_local)
- self.register_command(
- 'bookmark',
- self.command.bookmark,
- usage="[roomname][/nick] [autojoin] [password]",
- desc="Bookmark: Bookmark online the specified room (you "
- "will then auto-join it on each poezio start if autojoin"
- " is specified and is 'true'). This commands uses almost"
- " the same syntax as /join. Type /help join for syntax "
- "examples. Note that when typing \"/bookmark\" alone, the"
- " room will be bookmarked with the nickname you\'re "
- "currently using in this room (instead of default_nick).",
- shortdesc="Bookmark a room online.",
- completion=self.completion.bookmark)
- self.register_command(
- 'accept',
- self.command.command_accept,
- usage='[jid]',
- desc='Allow the provided JID (or the selected contact '
- 'in your roster), to see your presence.',
- shortdesc='Allow a user your presence.',)
- self.register_command(
- 'add',
- self.command.command_add,
- usage='<jid>',
- desc='Add the specified JID to your roster, ask them to'
- ' allow you to see his presence, and allow them to'
- ' see your presence.',
- shortdesc='Add a user to your roster.')
- self.register_command(
- 'reconnect',
- self.command.command_reconnect,
- usage="[reconnect]",
- desc='Disconnect from the remote server if you are '
- 'currently connected and then connect to it again.',
- shortdesc='Disconnect and reconnect to the server.')
- self.register_command(
- 'set',
- self.command.set,
- usage="[plugin|][section] <option> [value]",
- desc="Set the value of an option in your configuration file."
- " You can, for example, change your default nickname by "
- "doing `/set default_nick toto` or your resource with `/set"
- " resource blabla`. You can also set options in specific "
- "sections with `/set bindings M-i ^i` or in specific plugin"
- " with `/set mpd_client| host 127.0.0.1`. `toggle` can be "
- "used as a special value to toggle a boolean option.",
- shortdesc="Set the value of an option",
- completion=self.completion.set)
- self.register_command(
- 'set_default',
- self.command.set_default,
- usage="[section] <option>",
- desc="Set the default value of an option. For example, "
- "`/set_default resource` will reset the resource "
- "option. You can also reset options in specific "
- "sections by doing `/set_default section option`.",
- shortdesc="Set the default value of an option",
- completion=self.completion.set_default)
- self.register_command(
- 'toggle',
- self.command.toggle,
- usage='<option>',
- desc='Shortcut for /set <option> toggle',
- shortdesc='Toggle an option',
- completion=self.completion.toggle)
- self.register_command(
- 'theme',
- self.command.theme,
- usage='[theme name]',
- desc="Reload the theme defined in the config file. If theme"
- "_name is provided, set that theme before reloading it.",
- shortdesc='Load a theme',
- completion=self.completion.theme)
- self.register_command(
- 'list',
- self.command.list,
- usage='[server]',
- desc="Get the list of public rooms"
- " on the specified server.",
- shortdesc='List the rooms.',
- completion=self.completion.list)
- self.register_command(
- 'message',
- self.command.message,
- usage='<jid> [optional message]',
- desc="Open a conversation with the specified JID (even if it"
- " is not in our roster), and send a message to it, if the "
- "message is specified.",
- shortdesc='Send a message',
- completion=self.completion.message)
- self.register_command(
- 'version',
- self.command.version,
- usage='<jid>',
- desc="Get the software version of the given JID (usually its"
- " XMPP client and Operating System).",
- shortdesc='Get the software version of a JID.',
- completion=self.completion.version)
- self.register_command(
- 'server_cycle',
- self.command.server_cycle,
- usage='[domain] [message]',
- desc='Disconnect and reconnect in all the rooms in domain.',
- shortdesc='Cycle a range of rooms',
- completion=self.completion.server_cycle)
- self.register_command(
- 'bind',
- self.command.bind,
- usage='<key> <equ>',
- desc="Bind a key to another key or to a “command”. For "
- "example \"/bind ^H KEY_UP\" makes Control + h do the"
- " same same as the Up key.",
- completion=self.completion.bind,
- shortdesc='Bind a key to another key.')
- self.register_command(
- 'load',
- self.command.load,
- usage='<plugin> [<otherplugin> …]',
- shortdesc='Load the specified plugin(s)',
- completion=self.plugin_manager.completion_load)
- self.register_command(
- 'unload',
- self.command.unload,
- usage='<plugin> [<otherplugin> …]',
- shortdesc='Unload the specified plugin(s)',
- completion=self.plugin_manager.completion_unload)
- self.register_command(
- 'plugins',
- self.command.plugins,
- shortdesc='Show the plugins in use.')
- self.register_command(
- 'presence',
- self.command.presence,
- usage='<JID> [type] [status]',
- desc="Send a directed presence to <JID> and using"
- " [type] and [status] if provided.",
- shortdesc='Send a directed presence.',
- completion=self.completion.presence)
- self.register_command(
- 'rawxml',
- self.command.rawxml,
- usage='<xml>',
- shortdesc='Send a custom xml stanza.')
- self.register_command(
- 'invite',
- self.command.invite,
- usage='<jid> <room> [reason]',
- desc='Invite jid in room with reason.',
- shortdesc='Invite someone in a room.',
- completion=self.completion.invite)
- self.register_command(
- 'impromptu',
- self.command.impromptu,
- usage='<jid> [jid ...]',
- desc='Invite specified JIDs into a newly created room.',
- shortdesc='Invite specified JIDs into newly created room.',
- completion=self.completion.impromptu)
- self.register_command(
- 'invitations',
- self.command.invitations,
- shortdesc='Show the pending invitations.')
- self.register_command(
- 'bookmarks',
- self.command.bookmarks,
- shortdesc='Show the current bookmarks.')
- self.register_command(
- 'remove_bookmark',
- self.command.remove_bookmark,
- usage='[jid]',
- desc="Remove the specified bookmark, or the "
- "bookmark on the current tab, if any.",
- shortdesc='Remove a bookmark',
- completion=self.completion.remove_bookmark)
- self.register_command(
- 'xml_tab', self.command.xml_tab, shortdesc='Open an XML tab.')
- self.register_command(
- 'runkey',
- self.command.runkey,
- usage='<key>',
- shortdesc='Execute the action defined for <key>.',
- completion=self.completion.runkey)
- self.register_command(
- 'self', self.command.self_, shortdesc='Remind you of who you are.')
- self.register_command(
- 'last_activity',
- self.command.last_activity,
- usage='<jid>',
- desc='Informs you of the last activity of a JID.',
- shortdesc='Get the activity of someone.',
- completion=self.completion.last_activity)
- self.register_command(
- 'ad-hoc',
- self.command.adhoc,
- usage='<jid>',
- shortdesc='List available ad-hoc commands on the given jid')
- self.register_command(
- 'reload',
- self.command.reload,
- shortdesc='Reload the config. You can achieve the same by '
- 'sending SIGUSR1 to poezio.')
-
- if config.get('enable_user_activity'):
- self.register_command(
- 'activity',
- self.command.activity,
- usage='[<general> [specific] [text]]',
- desc='Send your current activity to your contacts '
- '(use the completion). Nothing means '
- '"stop broadcasting an activity".',
- shortdesc='Send your activity.',
- completion=self.completion.activity)
- if config.get('enable_user_mood'):
- self.register_command(
- 'mood',
- self.command.mood,
- usage='[<mood> [text]]',
- desc='Send your current mood to your contacts '
- '(use the completion). Nothing means '
- '"stop broadcasting a mood".',
- shortdesc='Send your mood.',
- completion=self.completion.mood)
- if config.get('enable_user_gaming'):
- self.register_command(
- 'gaming',
- self.command.gaming,
- usage='[<game name> [server address]]',
- desc='Send your current gaming activity to '
- 'your contacts. Nothing means "stop '
- 'broadcasting a gaming activity".',
- shortdesc='Send your gaming activity.',
- completion=None)
-
- def check_blocking(self, features):
+ for command in get_commands(self.command, self.completion, self.plugin_manager):
+ self.register_command(**command)
+
+ def check_blocking(self, features: List[str]):
if 'urn:xmpp:blocking' in features and not self.xmpp.anon:
self.register_command(
'block',
@@ -2005,12 +1798,12 @@ class Core:
####################### Random things to move #################################
- def join_initial_rooms(self, bookmarks):
+ def join_initial_rooms(self, bookmarks: List[Bookmark]):
"""Join all rooms given in the iterator `bookmarks`"""
for bm in bookmarks:
- if not (bm.autojoin or config.get('open_all_bookmarks')):
+ if not (bm.autojoin or config.getbool('open_all_bookmarks')):
continue
- tab = self.tabs.by_name_and_class(bm.jid, tabs.MucTab)
+ tab = self.tabs.by_name_and_class(bm.jid, MucTab)
nick = bm.nick if bm.nick else self.own_nick
if not tab:
tab = self.open_new_room(
@@ -2018,28 +1811,21 @@ class Core:
self.initial_joins.append(bm.jid)
# do not join rooms that do not have autojoin
# but display them anyway
- if bm.autojoin:
- muc.join_groupchat(
- self,
- bm.jid,
- nick,
- passwd=bm.password,
- status=self.status.message,
- show=self.status.show,
- tab=tab)
- if tab._text_buffer.last_message is None:
- asyncio.ensure_future(mam.on_tab_open(tab))
-
- def check_bookmark_storage(self, features):
+ if bm.autojoin and tab:
+ tab.join()
+
+ async def check_bookmark_storage(self, features: List[str]):
private = 'jabber:iq:private' in features
pep_ = 'http://jabber.org/protocol/pubsub#publish' in features
self.bookmarks.available_storage['private'] = private
self.bookmarks.available_storage['pep'] = pep_
- def _join_remote_only(iq):
- if iq['type'] == 'error':
- type_ = iq['error']['type']
- condition = iq['error']['condition']
+ if not self.xmpp.anon and config.getbool('use_remote_bookmarks'):
+ try:
+ await self.bookmarks.get_remote(self.xmpp, self.information)
+ except IqError as error:
+ type_ = error.iq['error']['type']
+ condition = error.iq['error']['condition']
if not (type_ == 'cancel' and condition == 'item-not-found'):
self.information(
'Unable to fetch the remote'
@@ -2048,38 +1834,37 @@ class Core:
remote_bookmarks = self.bookmarks.remote()
self.join_initial_rooms(remote_bookmarks)
- if not self.xmpp.anon and config.get('use_remote_bookmarks'):
- self.bookmarks.get_remote(self.xmpp, self.information,
- _join_remote_only)
-
- def room_error(self, error, room_name):
+ def room_error(self, error, room_name: str) -> None:
"""
Display the error in the tab
"""
- tab = self.tabs.by_name_and_class(room_name, tabs.MucTab)
+ tab = self.tabs.by_name_and_class(room_name, MucTab)
if not tab:
return
- error_message = self.get_error_message(error)
+ error_message = get_error_message(error)
tab.add_message(
- error_message,
- highlight=True,
- nickname='Error',
- nick_color=get_theme().COLOR_ERROR_MSG,
- typ=2)
+ UIMessage(
+ error_message,
+ level='Error',
+ ),
+ )
code = error['error']['code']
if code == '401':
msg = 'To provide a password in order to join the room, type "/join / password" (replace "password" by the real password)'
- tab.add_message(msg, typ=2)
+ tab.add_message(PersistentInfoMessage(msg))
if code == '409':
- if config.get('alternative_nickname') != '':
+ if config.getstr('alternative_nickname') != '':
if not tab.joined:
- tab.own_nick += config.get('alternative_nickname')
+ tab.own_nick += config.getstr('alternative_nickname')
tab.join()
else:
if not tab.joined:
tab.add_message(
- 'You can join the room with an other nick, by typing "/join /other_nick"',
- typ=2)
+ PersistentInfoMessage(
+ 'You can join the room with another nick, '
+ 'by typing "/join /other_nick"'
+ )
+ )
self.refresh_window()
@@ -2088,13 +1873,18 @@ class KeyDict(dict):
A dict, with a wrapper for get() that will return a custom value
if the key starts with _exc_
"""
+ try_execute: Optional[Callable[[str], Any]]
- def get(self, key: str, default: Optional[Callable] = None) -> Callable:
+ def get(self, key: str, default=None) -> Callable:
if isinstance(key, str) and key.startswith('_exc_') and len(key) > 5:
- return lambda: dict.get(self, '_exc_')(key[5:])
+ if self.try_execute is not None:
+ try_execute = self.try_execute
+ return lambda: try_execute(key[5:])
+ raise ValueError("KeyDict not initialized")
return dict.get(self, key, default)
+
def replace_key_with_bound(key: str) -> str:
"""
Replace an inputted key with the one defined as its replacement
diff --git a/poezio/core/handlers.py b/poezio/core/handlers.py
index 620d854c..e92e4aac 100644
--- a/poezio/core/handlers.py
+++ b/poezio/core/handlers.py
@@ -3,42 +3,41 @@ XMPP-related handlers for the Core class
"""
import logging
-log = logging.getLogger(__name__)
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 pep
from poezio import tabs
from poezio import xhtml
from poezio import multiuserchat as muc
-from poezio.common import safeJID
+from poezio.common import get_error_message
from poezio.config import config, get_image_cache
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,
+ InfoMessage,
+ PersistentInfoMessage,
+)
from poezio.core.commands import dumb_callback
@@ -52,6 +51,8 @@ try:
except ImportError:
PYGMENTS = False
+log = logging.getLogger(__name__)
+
CERT_WARNING_TEXT = """
WARNING: CERTIFICATE FOR %s CHANGED
@@ -78,30 +79,27 @@ class HandlerCore:
def __init__(self, core):
self.core = core
- def on_session_start_features(self, _):
+ async def on_session_start_features(self, _):
"""
Enable carbons & blocking on session start if wanted and possible
"""
-
- def callback(iq):
- if not iq:
- return
- features = iq['disco_info']['features']
- rostertab = self.core.tabs.by_name_and_class(
- 'Roster', tabs.RosterInfoTab)
- rostertab.check_blocking(features)
- rostertab.check_saslexternal(features)
- self.core.check_blocking(features)
- if (config.get('enable_carbons')
- and 'urn:xmpp:carbons:2' in features):
- self.core.xmpp.plugin['xep_0280'].enable()
- self.core.check_bookmark_storage(features)
-
- self.core.xmpp.plugin['xep_0030'].get_info(
- jid=self.core.xmpp.boundjid.domain, callback=callback)
+ iq = await self.core.xmpp.plugin['xep_0030'].get_info(
+ jid=self.core.xmpp.boundjid.domain
+ )
+ features = iq['disco_info']['features']
+
+ rostertab = self.core.tabs.by_name_and_class(
+ 'Roster', tabs.RosterInfoTab)
+ rostertab.check_saslexternal(features)
+ rostertab.check_blocking(features)
+ self.core.check_blocking(features)
+ if (config.getbool('enable_carbons')
+ and 'urn:xmpp:carbons:2' in features):
+ self.core.xmpp.plugin['xep_0280'].enable()
+ 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(),
)
@@ -111,7 +109,7 @@ class HandlerCore:
"""
# first, look for the x (XEP-0045 version 1.28)
- if message.xml.find('{http://jabber.org/protocol/muc#user}x') is not None:
+ if message.match('message/muc'):
log.debug('MUC-PM from %s with <x>', with_jid)
return True
@@ -152,79 +150,64 @@ 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,
- sent['to'].server,
- identity='conference',
- on_true=functools.partial(groupchat_private_message, sent),
- on_false=functools.partial(send_message, sent))
+ 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,
+ 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
"""
jid = message['from']
if jid.bare in self.core.pending_invites:
return
- # there are 2 'x' tags in the messages, making message['x'] useless
- invite = StanzaBase(
- self.core.xmpp,
- xml=message.xml.find(
- '{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}invite'
- ))
+ invite = message['muc']['invite']
# TODO: find out why pylint thinks "inviter" is a list
#pylint: disable=no-member
inviter = invite['from']
@@ -236,20 +219,23 @@ class HandlerCore:
if password:
msg += ". The password is \"%s\"." % password
self.core.information(msg, 'Info')
- if 'invite' in config.get('beep_on').split():
+ if 'invite' in config.getstr('beep_on').split():
curses.beep()
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
"""
- room = safeJID(message['groupchat_invite']['jid'])
+ try:
+ room = JID(message['groupchat_invite']['jid'])
+ except InvalidJID:
+ return
if room.bare in self.core.pending_invites:
return
@@ -267,7 +253,7 @@ class HandlerCore:
msg += "\nreason: %s" % reason
self.core.information(msg, 'Info')
- if 'invite' in config.get('beep_on').split():
+ if 'invite' in config.getstr('beep_on').split():
curses.beep()
self.core.pending_invites[room.bare] = inviter.full
@@ -275,32 +261,30 @@ 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)
"""
- if message.xml.find(
- '{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}invite'
- ) is not None:
+ if message.match('message/muc/invite'):
return
if message['type'] == 'groupchat':
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"
"""
@@ -310,7 +294,7 @@ class HandlerCore:
if jid_from.full == jid_from.bare:
self.core.room_error(message, jid_from.bare)
else:
- text = self.core.get_error_message(message)
+ text = get_error_message(message)
p_tab = self.core.tabs.by_name_and_class(
jid_from.full, tabs.PrivateTab)
if p_tab:
@@ -319,17 +303,17 @@ class HandlerCore:
self.core.information(text, 'Error')
return
tab = self.core.get_conversation_by_jid(message['from'], create=False)
- error_msg = self.core.get_error_message(message, deprecated=True)
+ error_msg = get_error_message(message, deprecated=True)
if not tab:
self.core.information(error_msg, 'Error')
return
error = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_CHAR_NACK),
error_msg)
if not tab.nack_message('\n' + error, message['id'], message['to']):
- tab.add_message(error, typ=0)
+ 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)
@@ -343,94 +327,36 @@ class HandlerCore:
use_xhtml = config.get_by_tabname('enable_xhtml_im',
message['from'].bare)
tmp_dir = get_image_cache()
- body = xhtml.get_body_from_message_stanza(
- message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
- if not body:
+ if not xhtml.get_body_from_message_stanza(
+ message, use_xhtml=use_xhtml, extract_images_to=tmp_dir):
if not self.core.xmpp.plugin['xep_0380'].has_eme(message):
return
self.core.xmpp.plugin['xep_0380'].replace_body_with_eme(message)
- body = message['body']
- remote_nick = ''
# normal message, we are the recipient
if message['to'].bare == self.core.xmpp.boundjid.bare:
conv_jid = message['from']
- jid = conv_jid
- color = get_theme().COLOR_REMOTE_USER
- # check for a name
- if conv_jid.bare in roster:
- remote_nick = roster[conv_jid.bare].name
- # check for a received nick
- if not remote_nick and config.get('enable_user_nick'):
- if message.xml.find(
- '{http://jabber.org/protocol/nick}nick') is not None:
- remote_nick = message['nick']['nick']
- if not remote_nick:
- remote_nick = conv_jid.user
- if not remote_nick:
- remote_nick = conv_jid.full
own = False
# we wrote the message (happens with carbons)
elif message['from'].bare == self.core.xmpp.boundjid.bare:
conv_jid = message['to']
- jid = self.core.xmpp.boundjid
- color = get_theme().COLOR_OWN_NICK
- remote_nick = self.core.own_nick
own = True
# we are not part of that message, drop it
else:
return
- conversation = self.core.get_conversation_by_jid(conv_jid, create=True)
- if isinstance(conversation,
- tabs.DynamicConversationTab) and conv_jid.resource:
- conversation.lock(conv_jid.resource)
-
- if not own and not conversation.nick:
- conversation.nick = remote_nick
- elif not own:
- remote_nick = conversation.get_nick()
-
- if not own:
- conversation.last_remote_message = datetime.now()
-
- self.core.events.trigger('conversation_msg', message, conversation)
- if not message['body']:
- return
- body = xhtml.get_body_from_message_stanza(
- message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
- delayed, date = common.find_delayed_tag(message)
-
- def try_modify():
- if message.xml.find('{urn:xmpp:message-correct:0}replace') is None:
- return False
- replaced_id = message['replace']['id']
- if replaced_id and config.get_by_tabname('group_corrections',
- conv_jid.bare):
- try:
- conversation.modify_message(
- body,
- replaced_id,
- message['id'],
- jid=jid,
- nickname=remote_nick)
- return True
- except CorrectionError:
- log.debug('Unable to correct a message', exc_info=True)
- return False
+ conversation = self.core.get_conversation_by_jid(conv_jid, create=False)
+ if conversation is None:
+ conversation = tabs.DynamicConversationTab(
+ self.core,
+ JID(conv_jid.bare),
+ initial=message,
+ )
+ self.core.tabs.append(conversation)
+ else:
+ await conversation.handle_message(message)
- if not try_modify():
- conversation.add_message(
- body,
- date,
- nickname=remote_nick,
- nick_color=color,
- history=delayed,
- identifier=message['id'],
- jid=jid,
- typ=1)
-
- if not own and 'private' in config.get('beep_on').split():
+ if not own and 'private' in config.getstr('beep_on').split():
if not config.get_by_tabname('disable_beep', conv_jid.bare):
curses.beep()
if self.core.tabs.current_tab is not conversation:
@@ -443,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:
@@ -493,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:
@@ -529,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
@@ -543,172 +469,7 @@ class HandlerCore:
else:
contact.name = ''
- def on_gaming_event(self, message):
- """
- Called when a pep notification for user gaming
- is received
- """
- contact = roster[message['from'].bare]
- if not contact:
- return
- item = message['pubsub_event']['items']['item']
- old_gaming = contact.gaming
- if item.xml.find('{urn:xmpp:gaming:0}gaming') is not None:
- item = item['gaming']
- # only name and server_address are used for now
- contact.gaming = {
- 'character_name': item['character_name'],
- 'character_profile': item['character_profile'],
- 'name': item['name'],
- 'level': item['level'],
- 'uri': item['uri'],
- 'server_name': item['server_name'],
- 'server_address': item['server_address'],
- }
- else:
- contact.gaming = {}
-
- if contact.gaming:
- logger.log_roster_change(
- contact.bare_jid, 'is playing %s' %
- (common.format_gaming_string(contact.gaming)))
-
- if old_gaming != contact.gaming and config.get_by_tabname(
- 'display_gaming_notifications', contact.bare_jid):
- if contact.gaming:
- self.core.information(
- '%s is playing %s' % (contact.bare_jid,
- common.format_gaming_string(
- contact.gaming)), 'Gaming')
- else:
- self.core.information(contact.bare_jid + ' stopped playing.',
- 'Gaming')
-
- def on_mood_event(self, message):
- """
- Called when a pep notification for a user mood
- is received.
- """
- contact = roster[message['from'].bare]
- if not contact:
- return
- roster.modified()
- item = message['pubsub_event']['items']['item']
- old_mood = contact.mood
- if item.xml.find('{http://jabber.org/protocol/mood}mood') is not None:
- mood = item['mood']['value']
- if mood:
- mood = pep.MOODS.get(mood, mood)
- text = item['mood']['text']
- if text:
- mood = '%s (%s)' % (mood, text)
- contact.mood = mood
- else:
- contact.mood = ''
- else:
- contact.mood = ''
-
- if contact.mood:
- logger.log_roster_change(contact.bare_jid,
- 'has now the mood: %s' % contact.mood)
-
- if old_mood != contact.mood and config.get_by_tabname(
- 'display_mood_notifications', contact.bare_jid):
- if contact.mood:
- self.core.information(
- 'Mood from ' + contact.bare_jid + ': ' + contact.mood,
- 'Mood')
- else:
- self.core.information(
- contact.bare_jid + ' stopped having their mood.', 'Mood')
-
- def on_activity_event(self, message):
- """
- Called when a pep notification for a user activity
- is received.
- """
- contact = roster[message['from'].bare]
- if not contact:
- return
- roster.modified()
- item = message['pubsub_event']['items']['item']
- old_activity = contact.activity
- if item.xml.find(
- '{http://jabber.org/protocol/activity}activity') is not None:
- try:
- activity = item['activity']['value']
- except ValueError:
- return
- if activity[0]:
- general = pep.ACTIVITIES.get(activity[0])
- s = general['category']
- if activity[1]:
- s = s + '/' + general.get(activity[1], 'other')
- text = item['activity']['text']
- if text:
- s = '%s (%s)' % (s, text)
- contact.activity = s
- else:
- contact.activity = ''
- else:
- contact.activity = ''
-
- if contact.activity:
- logger.log_roster_change(
- contact.bare_jid, 'has now the activity %s' % contact.activity)
-
- if old_activity != contact.activity and config.get_by_tabname(
- 'display_activity_notifications', contact.bare_jid):
- if contact.activity:
- self.core.information(
- 'Activity from ' + contact.bare_jid + ': ' +
- contact.activity, 'Activity')
- else:
- self.core.information(
- contact.bare_jid + ' stopped doing their activity.',
- 'Activity')
-
- def on_tune_event(self, message):
- """
- Called when a pep notification for a user tune
- is received
- """
- contact = roster[message['from'].bare]
- if not contact:
- return
- roster.modified()
- item = message['pubsub_event']['items']['item']
- old_tune = contact.tune
- if item.xml.find('{http://jabber.org/protocol/tune}tune') is not None:
- item = item['tune']
- contact.tune = {
- 'artist': item['artist'],
- 'length': item['length'],
- 'rating': item['rating'],
- 'source': item['source'],
- 'title': item['title'],
- 'track': item['track'],
- 'uri': item['uri']
- }
- else:
- contact.tune = {}
-
- if contact.tune:
- logger.log_roster_change(
- message['from'].bare, 'is now listening to %s' %
- common.format_tune_string(contact.tune))
-
- if old_tune != contact.tune and config.get_by_tabname(
- 'display_tune_notifications', contact.bare_jid):
- if contact.tune:
- self.core.information(
- 'Tune from ' + message['from'].bare + ': ' +
- common.format_tune_string(contact.tune), 'Tune')
- else:
- self.core.information(
- contact.bare_jid + ' stopped listening to music.', 'Tune')
-
- 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.
"""
@@ -725,88 +486,33 @@ 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)
- if not body:
- return
-
- old_state = tab.state
- delayed, date = common.find_delayed_tag(message)
- 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,
- 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 and tab.add_message(
- body,
- date,
- nick_from,
- history=delayed,
- identifier=message['id'],
- jid=message['from'],
- typ=1):
- self.core.events.trigger('highlight', message, tab)
-
- if message['from'].resource == tab.own_nick:
- tab.last_sent_message = message
-
- 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.get('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
- use_xhtml = config.get_by_tabname('enable_xhtml_im', jid.bare)
+ use_xhtml = config.get_by_tabname(
+ 'enable_xhtml_im',
+ jid.bare
+ )
tmp_dir = get_image_cache()
body = xhtml.get_body_from_message_stanza(
message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
@@ -814,59 +520,27 @@ class HandlerCore:
jid.full,
tabs.PrivateTab) # get the tab with the private conversation
ignore = config.get_by_tabname('ignore_private', room_from)
- if not tab: # It's the first message we receive: create the tab
- if body and not ignore:
- tab = self.core.open_private_window(room_from, with_nick,
- False)
- # Tab can still be None here, when receiving carbons of a MUC-PM for
- # example
- sender_nick = (tab and tab.own_nick
- or self.core.own_nick) if sent else with_nick
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(
mto=jid.full, mbody=msg, mtype='chat')
return
- self.core.events.trigger('private_msg', message, tab)
- body = xhtml.get_body_from_message_stanza(
- message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
- if not body or not tab:
- return
- replaced = False
- user = tab.parent_muc.get_user_by_name(with_nick)
- 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', room_from):
- try:
- tab.modify_message(
- body,
- replaced_id,
- message['id'],
- user=user,
- jid=message['from'],
- nickname=sender_nick)
- replaced = True
- except CorrectionError:
- log.debug('Unable to correct a message', exc_info=True)
- if not replaced:
- tab.add_message(
- body,
- time=None,
- nickname=sender_nick,
- nick_color=get_theme().COLOR_OWN_NICK if sent else None,
- forced_user=user,
- identifier=message['id'],
- jid=message['from'],
- typ=1)
- if sent:
- tab.last_sent_message = message
+ if tab is None: # It's the first message we receive: create the tab
+ if body and not ignore:
+ tab = tabs.PrivateTab(
+ self.core,
+ jid,
+ self.core.own_nick,
+ initial=message,
+ )
+ self.core.tabs.append(tab)
+ tab.parent_muc.privates.append(tab)
else:
- tab.last_remote_message = datetime.now()
+ await tab.handle_message(message)
- if not sent and 'private' in config.get('beep_on').split():
+ if not sent and 'private' in config.getstr('beep_on').split():
if not config.get_by_tabname('disable_beep', jid.full):
curses.beep()
if tab is self.core.tabs.current_tab:
@@ -877,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()
@@ -919,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
"""
@@ -927,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()
@@ -936,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
"""
@@ -944,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:
@@ -962,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.
"""
@@ -979,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.
"""
@@ -991,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.
"""
@@ -1010,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]
@@ -1033,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]
@@ -1048,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]
@@ -1061,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]
@@ -1082,9 +756,8 @@ class HandlerCore:
### Presence-related handlers ###
- def on_presence(self, presence):
- if presence.match('presence/muc') or presence.xml.find(
- '{http://jabber.org/protocol/muc#user}x') is not None:
+ async def on_presence(self, presence: Presence):
+ if presence.match('presence/muc'):
return
jid = presence['from']
contact = roster[jid.bare]
@@ -1098,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(
@@ -1110,21 +783,20 @@ 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:
return
roster.modified()
- contact.error = presence['error']['type'] + ': ' + presence['error']['condition']
+ 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
"""
- if presence.match('presence/muc') or presence.xml.find(
- '{http://jabber.org/protocol/muc#user}x') is not None:
+ if presence.match('presence/muc'):
return
jid = presence['from']
status = presence['status']
@@ -1152,12 +824,11 @@ 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
"""
- if presence.match('presence/muc') or presence.xml.find(
- '{http://jabber.org/protocol/muc#user}x') is not None:
+ if presence.match('presence/muc'):
return
jid = presence['from']
contact = roster[jid.bare]
@@ -1174,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)
@@ -1192,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
@@ -1201,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)
"""
@@ -1222,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
"""
@@ -1233,22 +904,23 @@ class HandlerCore:
"""
When we are disconnected from remote server
"""
- if 'disconnect' in config.get('beep_on').split():
+ if 'disconnect' in config.getstr('beep_on').split():
curses.beep()
# Stop the ping plugin. It would try to send stanza on regular basis
self.core.xmpp.plugin['xep_0199'].disable_keepalive()
msg_typ = 'Error' if not self.core.legitimate_disconnect else 'Info'
self.core.information("Disconnected from server%s." % (event and ": %s" % event or ""), msg_typ)
- if self.core.legitimate_disconnect or not config.get(
- 'auto_reconnect', True):
+ if self.core.legitimate_disconnect or not config.getbool(
+ 'auto_reconnect'):
return
if (self.core.last_stream_error
and self.core.last_stream_error[1]['condition'] in (
'conflict', 'host-unknown')):
return
await asyncio.sleep(1)
- self.core.information("Auto-reconnecting.", 'Info')
- self.core.xmpp.start()
+ if not self.core.xmpp.is_connecting() and not self.core.xmpp.is_connected():
+ self.core.information("Auto-reconnecting.", 'Info')
+ self.core.xmpp.start()
async def on_reconnect_delay(self, event):
"""
@@ -1256,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
"""
@@ -1265,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
"""
@@ -1273,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)
"""
@@ -1281,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
"""
@@ -1303,26 +975,26 @@ class HandlerCore:
self.core.xmpp.get_roster()
roster.update_contact_groups(self.core.xmpp.boundjid.bare)
# send initial presence
- if config.get('send_initial_presence'):
+ if config.getbool('send_initial_presence'):
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
- self.core.join_initial_rooms(self.core.bookmarks)
+ self.core.join_initial_rooms(self.core.bookmarks.local())
- if config.get('enable_user_nick'):
+ 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.
@@ -1351,41 +1023,57 @@ class HandlerCore:
if show_unavailable or hide_unavailable or non_priv or logging_off\
or non_anon or semi_anon or full_anon:
tab.add_message(
- '\x19%(info_col)s}Info: A configuration change not privacy-related occurred.' % info_col,
- typ=2)
+ PersistentInfoMessage(
+ 'Info: A configuration change not privacy-related occurred.'
+ ),
+ )
modif = True
if show_unavailable:
tab.add_message(
- '\x19%(info_col)s}Info: The unavailable members are now shown.' % info_col,
- typ=2)
+ PersistentInfoMessage(
+ 'Info: The unavailable members are now shown.'
+ ),
+ )
elif hide_unavailable:
tab.add_message(
- '\x19%(info_col)s}Info: The unavailable members are now hidden.' % info_col,
- typ=2)
+ PersistentInfoMessage(
+ 'Info: The unavailable members are now hidden.',
+ ),
+ )
if non_anon:
tab.add_message(
- '\x191}Warning:\x19%(info_col)s} The room is now not anonymous. (public JID)' % info_col,
- typ=2)
+ PersistentInfoMessage(
+ '\x191}Warning:\x19%(info_col)s} The room is now not anonymous. (public JID)' % info_col
+ ),
+ )
elif semi_anon:
tab.add_message(
- '\x19%(info_col)s}Info: The room is now semi-anonymous. (moderators-only JID)' % info_col,
- typ=2)
+ PersistentInfoMessage(
+ 'Info: The room is now semi-anonymous. (moderators-only JID)',
+ ),
+ )
elif full_anon:
tab.add_message(
- '\x19%(info_col)s}Info: The room is now fully anonymous.' % info_col,
- typ=2)
+ PersistentInfoMessage(
+ 'Info: The room is now fully anonymous.',
+ ),
+ )
if logging_on:
tab.add_message(
- '\x191}Warning: \x19%(info_col)s}This room is publicly logged' % info_col,
- typ=2)
+ PersistentInfoMessage(
+ '\x191}Warning: \x19%(info_col)s}This room is publicly logged' % info_col
+ ),
+ )
elif logging_off:
tab.add_message(
- '\x19%(info_col)s}Info: This room is not logged anymore.' % info_col,
- typ=2)
+ PersistentInfoMessage(
+ 'Info: This room is not logged anymore.',
+ ),
+ )
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.
"""
@@ -1424,23 +1112,25 @@ class HandlerCore:
if nick_from:
tab.add_message(
- "%(user)s set the subject to: \x19%(text_col)s}%(subject)s"
- % fmt,
- str_time=time,
- typ=2)
+ PersistentInfoMessage(
+ "%(user)s set the subject to: \x19%(text_col)s}%(subject)s" % fmt,
+ time=time,
+ ),
+ )
else:
tab.add_message(
- "\x19%(info_col)s}The subject is: \x19%(text_col)s}%(subject)s"
- % fmt,
- str_time=time,
- typ=2)
+ PersistentInfoMessage(
+ "The subject is: \x19%(text_col)s}%(subject)s" % fmt,
+ time=time,
+ ),
+ )
tab.topic = subject
tab.topic_from = nick_from
if self.core.tabs.by_name_and_class(
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)
"""
@@ -1462,57 +1152,54 @@ 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.
"""
jid_from = message['from']
self.core.information('%s requests your attention!' % jid_from, 'Info')
- for tab in self.core.tabs:
- if tab.jid == jid_from:
- tab.state = 'attention'
- self.core.refresh_tab_win()
- return
- for tab in self.core.tabs:
- if tab.jid.bare == jid_from.bare:
- tab.state = 'attention'
- self.core.refresh_tab_win()
- return
- self.core.information('%s tab not found.' % jid_from, 'Error')
+ tab = (
+ self.core.tabs.by_name_and_class(
+ jid_from.full, tabs.ChatTab
+ ) or self.core.tabs.by_name_and_class(
+ jid_from.bare, tabs.ChatTab
+ )
+ )
+ if tab and tab is not self.core.tabs.current_tab:
+ 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)
- char = get_theme().CHAR_XML_OUT
- self.core.add_message_to_text_buffer(
- self.core.xml_buffer,
- poezio_colored,
- nickname=char)
+ 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))):
- self.core.add_message_to_text_buffer(
- self.core.xml_tab.filtered_buffer,
- poezio_colored,
- nickname=char)
+ 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)
@@ -1520,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.
"""
@@ -1531,17 +1218,14 @@ class HandlerCore:
xhtml_text, force=True).rstrip('\x19o').strip()
else:
poezio_colored = str(stanza)
- char = get_theme().CHAR_XML_IN
- self.core.add_message_to_text_buffer(
- self.core.xml_buffer,
- poezio_colored,
- nickname=char)
+ self.core.xml_buffer.add_message(
+ XMLLog(txt=poezio_colored, incoming=True),
+ )
try:
if self.core.xml_tab.match_stanza(stanza):
- self.core.add_message_to_text_buffer(
- self.core.xml_tab.filtered_buffer,
- poezio_colored,
- nickname=char)
+ self.core.xml_tab.filtered_buffer.add_message(
+ XMLLog(txt=poezio_colored, incoming=True),
+ )
except:
log.debug('', exc_info=True)
if isinstance(self.core.tabs.current_tab, tabs.XMLTab):
@@ -1580,19 +1264,24 @@ class HandlerCore:
self.core.add_tab(confirm_tab, True)
self.core.doupdate()
+ # handle resize
+ prev_value = signal.signal(signal.SIGWINCH, self.core.sigwinch_handler)
while not confirm_tab.done:
- sel = select.select([sys.stdin], [], [], 5)[0]
-
- if sel:
- self.core.on_input_readable()
+ try:
+ sel = select.select([sys.stdin], [], [], 0.5)[0]
+ if sel:
+ self.core.on_input_readable()
+ except:
+ continue
+ signal.signal(signal.SIGWINCH, prev_value)
def validate_ssl(self, pem):
"""
Check the server certificate using the slixmpp ssl_cert event
"""
- if config.get('ignore_certificate'):
+ if config.getbool('ignore_certificate'):
return
- cert = config.get('certificate')
+ cert = config.getstr('certificate')
# update the cert representation when it uses the old one
if cert and ':' not in cert:
cert = ':'.join(
@@ -1701,7 +1390,7 @@ class HandlerCore:
def adhoc_error(self, iq, adhoc_session):
self.core.xmpp.plugin['xep_0050'].terminate_command(adhoc_session)
- error_message = self.core.get_error_message(iq)
+ error_message = get_error_message(iq)
self.core.information(
"An error occurred while executing the command: %s" %
(error_message), 'Error')
@@ -1734,7 +1423,7 @@ def _composing_tab_state(tab, state):
else:
return # should not happen
- show = config.get('show_composing_tabs')
+ show = config.getstr('show_composing_tabs').lower()
show = show in values
if tab.state != 'composing' and state == 'composing':
diff --git a/poezio/core/structs.py b/poezio/core/structs.py
index 72c9628a..31d31339 100644
--- a/poezio/core/structs.py
+++ b/poezio/core/structs.py
@@ -1,45 +1,20 @@
"""
Module defining structures useful to the core class and related methods
"""
+from __future__ import annotations
+from dataclasses import dataclass
+from typing import Any, Callable, List, TYPE_CHECKING, Optional
+
+if TYPE_CHECKING:
+ from poezio import windows
__all__ = [
- 'ERROR_AND_STATUS_CODES', 'DEPRECATED_ERRORS', 'POSSIBLE_SHOW', 'Status',
- 'Command', 'Completion'
+ 'Command',
+ 'Completion',
+ 'POSSIBLE_SHOW',
+ 'Status',
]
-# http://xmpp.org/extensions/xep-0045.html#errorstatus
-ERROR_AND_STATUS_CODES = {
- '401': 'A password is required',
- '403': 'Permission denied',
- '404': 'The room doesn’t exist',
- '405': 'Your are not allowed to create a new room',
- '406': 'A reserved nick must be used',
- '407': 'You are not in the member list',
- '409': 'This nickname is already in use or has been reserved',
- '503': 'The maximum number of users has been reached',
-}
-
-# http://xmpp.org/extensions/xep-0086.html
-DEPRECATED_ERRORS = {
- '302': 'Redirect',
- '400': 'Bad request',
- '401': 'Not authorized',
- '402': 'Payment required',
- '403': 'Forbidden',
- '404': 'Not found',
- '405': 'Not allowed',
- '406': 'Not acceptable',
- '407': 'Registration required',
- '408': 'Request timeout',
- '409': 'Conflict',
- '500': 'Internal server error',
- '501': 'Feature not implemented',
- '502': 'Remote server error',
- '503': 'Service unavailable',
- '504': 'Remote server timeout',
- '510': 'Disconnected',
-}
-
POSSIBLE_SHOW = {
'available': None,
'chat': 'chat',
@@ -51,23 +26,11 @@ POSSIBLE_SHOW = {
}
+@dataclass
class Status:
__slots__ = ('show', 'message')
-
- def __init__(self, show, message):
- self.show = show
- self.message = message
-
-
-class Command:
- __slots__ = ('func', 'desc', 'comp', 'short_desc', 'usage')
-
- def __init__(self, func, desc, comp, short_desc, usage):
- self.func = func
- self.desc = desc
- self.comp = comp
- self.short_desc = short_desc
- self.usage = usage
+ show: str
+ message: str
class Completion:
@@ -76,7 +39,13 @@ class Completion:
"""
__slots__ = ['func', 'args', 'kwargs', 'comp_list']
- def __init__(self, func, comp_list, *args, **kwargs):
+ def __init__(
+ self,
+ func: Callable[..., Any],
+ comp_list: List[str],
+ *args: Any,
+ **kwargs: Any
+ ) -> None:
self.func = func
self.comp_list = comp_list
self.args = args
@@ -84,3 +53,13 @@ class Completion:
def run(self):
return self.func(self.comp_list, *self.args, **self.kwargs)
+
+
+@dataclass
+class Command:
+ __slots__ = ('func', 'desc', 'comp', 'short_desc', 'usage')
+ func: Callable[..., Any]
+ desc: str
+ comp: Optional[Callable[['windows.Input'], Completion]]
+ short_desc: str
+ usage: str
diff --git a/poezio/core/tabs.py b/poezio/core/tabs.py
index abea7313..6d0589ba 100644
--- a/poezio/core/tabs.py
+++ b/poezio/core/tabs.py
@@ -24,12 +24,14 @@ have become [0|1|2|3], with the tab "4" renumbered to "3" if gap tabs are
disabled.
"""
-from typing import List, Dict, Type, Optional, Union
+from typing import List, Dict, Type, Optional, Union, Tuple, TypeVar, cast
from collections import defaultdict
from slixmpp import JID
from poezio import tabs
from poezio.events import EventHandler
+T = TypeVar('T', bound=tabs.Tab)
+
class Tabs:
"""
@@ -46,23 +48,22 @@ class Tabs:
'_events',
]
- def __init__(self, events: EventHandler) -> None:
+ def __init__(self, events: EventHandler, initial_tab: tabs.Tab) -> None:
"""
Initialize the Tab List. Even though the list is initially
empty, all methods are only valid once append() has been called
once. Otherwise, mayhem is expected.
"""
# cursor
- self._current_index = 0 # type: int
- self._current_tab = None # type: Optional[tabs.Tab]
+ self._current_index: int = 0
+ self._current_tab: tabs.Tab = initial_tab
- self._previous_tab = None # type: Optional[tabs.Tab]
- self._tabs = [] # type: List[tabs.Tab]
- self._tab_jids = dict() # type: Dict[JID, tabs.Tab]
- self._tab_types = defaultdict(
- list) # type: Dict[Type[tabs.Tab], List[tabs.Tab]]
- self._tab_names = dict() # type: Dict[str, tabs.Tab]
- self._events = events # type: EventHandler
+ self._previous_tab: Optional[tabs.Tab] = None
+ self._tabs: List[tabs.Tab] = []
+ self._tab_jids: Dict[JID, tabs.Tab] = dict()
+ self._tab_types: Dict[Type[tabs.Tab], List[tabs.Tab]] = defaultdict(list)
+ self._tab_names: Dict[str, tabs.Tab] = dict()
+ self._events: EventHandler = events
def __len__(self):
return len(self._tabs)
@@ -92,7 +93,7 @@ class Tabs:
return False
@property
- def current_tab(self) -> Optional[tabs.Tab]:
+ def current_tab(self) -> tabs.Tab:
"""Current tab"""
return self._current_tab
@@ -122,9 +123,9 @@ class Tabs:
"""Get a tab with a specific name"""
return self._tab_names.get(name)
- def by_class(self, cls: Type[tabs.Tab]) -> List[tabs.Tab]:
+ def by_class(self, cls: Type[T]) -> List[T]:
"""Get all the tabs of a class"""
- return self._tab_types.get(cls, [])
+ return cast(List[T], self._tab_types.get(cls, []))
def find_match(self, name: str) -> Optional[tabs.Tab]:
"""Get a tab using extended matching (tab.matching_name())"""
@@ -139,13 +140,49 @@ class Tabs:
return self._tabs[i]
return None
- def by_name_and_class(self, name: str,
- cls: Type[tabs.Tab]) -> Optional[tabs.Tab]:
+ def find_by_unique_prefix(self, prefix: str) -> Tuple[bool, Optional[tabs.Tab]]:
+ """
+ Get a tab by its unique name prefix, ignoring case.
+
+ :return: A tuple indicating the presence of any match, as well as the
+ uniquely matched tab (if any).
+
+ The first element, a boolean, in the returned tuple indicates whether
+ at least one tab matched.
+
+ The second element (a Tab) in the returned tuple is the uniquely
+ matched tab, if any. If multiple or no tabs match the prefix, the
+ second element in the tuple is :data:`None`.
+ """
+
+ # TODO: should this maybe use something smarter than .lower()?
+ # something something stringprep?
+ prefix = prefix.lower()
+ candidate = None
+ any_matched = False
+ for tab in self._tabs:
+ if not tab.name.lower().startswith(prefix):
+ continue
+ any_matched = True
+ if candidate is not None:
+ # multiple tabs match -> return None
+ return True, None
+ candidate = tab
+
+ return any_matched, candidate
+
+ def by_name_and_class(self, name: Union[str, JID],
+ cls: Type[T]) -> Optional[T]:
"""Get a tab with its name and class"""
+ if isinstance(name, JID):
+ str_name = name.full
+ else:
+ str_name = name
+ str
cls_tabs = self._tab_types.get(cls, [])
for tab in cls_tabs:
- if tab.name == name:
- return tab
+ if tab.name == str_name:
+ return cast(T, tab)
return None
def _rebuild(self):
@@ -156,7 +193,7 @@ class Tabs:
for cls in _get_tab_types(tab):
self._tab_types[cls].append(tab)
if hasattr(tab, 'jid'):
- self._tab_jids[tab.jid] = tab
+ self._tab_jids[tab.jid] = tab # type: ignore
self._tab_names[tab.name] = tab
self._update_numbers()
@@ -217,7 +254,7 @@ class Tabs:
for cls in _get_tab_types(tab):
self._tab_types[cls].append(tab)
if hasattr(tab, 'jid'):
- self._tab_jids[tab.jid] = tab
+ self._tab_jids[tab.jid] = tab # type: ignore
self._tab_names[tab.name] = tab
def delete(self, tab: tabs.Tab, gap=False):
@@ -226,7 +263,7 @@ class Tabs:
return
if gap:
- self._tabs[tab.nb] = tabs.GapTab(None)
+ self._tabs[tab.nb] = tabs.GapTab()
else:
self._tabs.remove(tab)
@@ -235,7 +272,7 @@ class Tabs:
for cls in _get_tab_types(tab):
self._tab_types[cls].remove(tab)
if hasattr(tab, 'jid'):
- del self._tab_jids[tab.jid]
+ del self._tab_jids[tab.jid] # type: ignore
del self._tab_names[tab.name]
if gap:
@@ -262,7 +299,7 @@ class Tabs:
def _validate_current_index(self):
if not 0 <= self._current_index < len(
self._tabs) or not self.current_tab:
- self.prev()
+ self.prev() # pylint: disable=not-callable
def _collect_trailing_gaptabs(self):
"""Remove trailing gap tabs if any"""
@@ -315,16 +352,16 @@ class Tabs:
if new_pos < len(self._tabs):
old_tab = self._tabs[old_pos]
self._tabs[new_pos], self._tabs[
- old_pos] = old_tab, tabs.GapTab(self)
+ old_pos] = old_tab, tabs.GapTab()
else:
self._tabs.append(self._tabs[old_pos])
- self._tabs[old_pos] = tabs.GapTab(self)
+ self._tabs[old_pos] = tabs.GapTab()
else:
if new_pos > old_pos:
self._tabs.insert(new_pos, tab)
- self._tabs[old_pos] = tabs.GapTab(self)
+ self._tabs[old_pos] = tabs.GapTab()
elif new_pos < old_pos:
- self._tabs[old_pos] = tabs.GapTab(self)
+ self._tabs[old_pos] = tabs.GapTab()
self._tabs.insert(new_pos, tab)
else:
return False
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/decorators.py b/poezio/decorators.py
index 4b5d0320..9342161f 100644
--- a/poezio/decorators.py
+++ b/poezio/decorators.py
@@ -1,54 +1,106 @@
"""
Module containing various decorators
"""
-from typing import Any, Callable, List, Optional
+
+from __future__ import annotations
+from asyncio import iscoroutinefunction
+
+from typing import (
+ cast,
+ Any,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ TypeVar,
+ TYPE_CHECKING,
+)
from poezio import common
+if TYPE_CHECKING:
+ from poezio.core.core import Core
+
+
+T = TypeVar('T', bound=Callable[..., Any])
+
+
+BeforeFunc = Optional[Callable[[List[Any], Dict[str, Any]], Any]]
+AfterFunc = Optional[Callable[[Any, List[Any], Dict[str, Any]], Any]]
+
+
+def wrap_generic(func: Callable, before: BeforeFunc = None, after: AfterFunc = None):
+ """
+ Generic wrapper which can both wrap coroutines and normal functions.
+ """
+ def wrap(*args, **kwargs):
+ args = list(args)
+ if before is not None:
+ result = before(args, kwargs)
+ if result is not None:
+ return result
+ result = func(*args, **kwargs)
+ if after is not None:
+ result = after(result, args, kwargs)
+ return result
+
+ async def awrap(*args, **kwargs):
+ args = list(args)
+ if before is not None:
+ result = before(args, kwargs)
+ if result is not None:
+ return result
+ result = await func(*args, **kwargs)
+ if after is not None:
+ result = after(result, args, kwargs)
+ return result
+ if iscoroutinefunction(func):
+ return awrap
+ return wrap
class RefreshWrapper:
- def __init__(self):
+ core: Optional[Core]
+
+ def __init__(self) -> None:
self.core = None
- def conditional(self, func: Callable) -> Callable:
+ def conditional(self, func: T) -> T:
"""
Decorator to refresh the UI if the wrapped function
returns True
"""
+ def after(result: Any, args, kwargs) -> Any:
+ if self.core is not None and result:
+ self.core.refresh_window() # pylint: disable=no-member
+ return result
- def wrap(*args, **kwargs):
- ret = func(*args, **kwargs)
- if self.core and ret:
- self.core.refresh_window()
- return ret
+ wrap = wrap_generic(func, after=after)
- return wrap
+ return cast(T, wrap)
- def always(self, func: Callable) -> Callable:
+ def always(self, func: T) -> T:
"""
Decorator that refreshs the UI no matter what after the function
"""
+ def after(result: Any, args, kwargs) -> Any:
+ if self.core is not None:
+ self.core.refresh_window() # pylint: disable=no-member
+ return result
- def wrap(*args, **kwargs):
- ret = func(*args, **kwargs)
- if self.core:
- self.core.refresh_window()
- return ret
-
- return wrap
+ wrap = wrap_generic(func, after=after)
+ return cast(T, wrap)
- def update(self, func: Callable) -> Callable:
+ def update(self, func: T) -> T:
"""
Decorator that only updates the screen
"""
- def wrap(*args, **kwargs):
- ret = func(*args, **kwargs)
- if self.core:
- self.core.doupdate()
- return ret
-
- return wrap
+ def after(result: Any, args, kwargs) -> Any:
+ if self.core is not None:
+ self.core.doupdate() # pylint: disable=no-member
+ return result
+ wrap = wrap_generic(func, after=after)
+ return cast(T, wrap)
refresh_wrapper = RefreshWrapper()
@@ -61,32 +113,29 @@ class CommandArgParser:
"""
@staticmethod
- def raw(func: Callable) -> Callable:
+ def raw(func: T) -> T:
"""Just call the function with a single string, which is the original string
untouched
"""
-
- def wrap(self, args, *a, **kw):
- return func(self, args, *a, **kw)
-
- return wrap
+ return func
@staticmethod
- def ignored(func: Callable) -> Callable:
+ def ignored(func: T) -> T:
"""
- Call the function without any argument
+ Call the function without textual arguments
"""
+ def before(args: List[Any], kwargs: Dict[Any, Any]) -> None:
+ if len(args) >= 2:
+ del args[1]
- def wrap(self, args=None, *a, **kw):
- return func(self, *a, **kw)
-
- return wrap
+ wrap = wrap_generic(func, before=before)
+ return cast(T, wrap)
@staticmethod
def quoted(mandatory: int,
- optional=0,
+ optional: int = 0,
defaults: Optional[List[Any]] = None,
- ignore_trailing_arguments=False):
+ ignore_trailing_arguments: bool = False) -> Callable[[T], T]:
"""The function receives a list with a number of arguments that is between
the numbers `mandatory` and `optional`.
@@ -131,15 +180,17 @@ class CommandArgParser:
"""
default_args_outer = defaults or []
- def first(func: Callable):
- def second(self, args: str, *a, **kw):
+ def first(func: T) -> T:
+ def before(args: List, kwargs: Dict[str, Any]) -> Any:
default_args = default_args_outer
- if args and args.strip():
- split_args = common.shell_split(args)
+ cmdargs = args[1]
+ if cmdargs and cmdargs.strip():
+ split_args = common.shell_split(cmdargs)
else:
split_args = []
if len(split_args) < mandatory:
- return func(self, None, *a, **kw)
+ args[1] = None
+ return
res, split_args = split_args[:mandatory], split_args[
mandatory:]
if optional == -1:
@@ -154,11 +205,25 @@ class CommandArgParser:
res += default_args
if split_args and res and not ignore_trailing_arguments:
res[-1] += " " + " ".join(split_args)
- return func(self, res, *a, **kw)
+ args[1] = res
+ return
+ wrap = wrap_generic(func, before=before)
+ return cast(T, wrap)
+ return first
- return second
+command_args_parser = CommandArgParser()
- return first
+def deny_anonymous(func: T) -> T:
+ """Decorator to disable commands when using an anonymous account."""
-command_args_parser = CommandArgParser()
+ def before(args: Any, kwargs: Any) -> Any:
+ core = args[0].core
+ if core.xmpp.anon:
+ core.information(
+ 'This command is not available for anonymous accounts.',
+ 'Info'
+ )
+ return False
+ wrap = wrap_generic(func, before=before)
+ return cast(T, wrap)
diff --git a/poezio/events.py b/poezio/events.py
index 5213f663..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:
"""
@@ -48,7 +52,7 @@ class EventHandler:
'ignored_private',
'tab_change',
]
- self.events = {} # type: Dict[str, OrderedDict[int, List[Callable]]]
+ self.events: Dict[str, OrderedDict[int, List[Callable]]] = {}
for event in events:
self.events[event] = OrderedDict()
@@ -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 f8de7b14..c2db4332 100644
--- a/poezio/fixes.py
+++ b/poezio/fixes.py
@@ -5,44 +5,15 @@ upstream.
TODO: Check that they are fixed and remove those hacks
"""
-from slixmpp.stanza import Message
-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()
-
- xmpp.plugin['xep_0030'].get_info(jid=jid, callback=_cb)
-
-
-def get_room_form(xmpp, room, callback):
- def _cb(result):
- if result["type"] == "error":
- return callback(None)
- xform = result.xml.find(
- '{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x')
- if xform is None:
- return callback(None)
- form = xmpp.plugin['xep_0004'].build_form(xform)
- return callback(form)
-
- iq = xmpp.make_iq_get(ito=room)
- query = ET.Element('{http://jabber.org/protocol/muc#owner}query')
- iq.append(query)
- iq.send(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 3d8e8d5c..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
@@ -26,7 +26,7 @@ log = logging.getLogger(__name__)
# shortcuts or inserting text in the current output. The callback
# is always reset to None afterwards (to resume the normal
# processing of keys)
-continuation_keys_callback = None # type: Optional[Callable]
+continuation_keys_callback: Optional[Callable] = None
def get_next_byte(s) -> Tuple[Optional[int], Optional[bytes]]:
@@ -46,7 +46,7 @@ def get_next_byte(s) -> Tuple[Optional[int], Optional[bytes]]:
def get_char_list(s) -> List[str]:
- ret_list = [] # type: List[str]
+ ret_list: List[str] = []
while True:
try:
key = s.get_wch()
diff --git a/poezio/log_loader.py b/poezio/log_loader.py
new file mode 100644
index 00000000..2e3b27c2
--- /dev/null
+++ b/poezio/log_loader.py
@@ -0,0 +1,395 @@
+"""
+This modules contains a class that loads messages into a ChatTab, either from
+MAM or the local logs, and a class that loads MUC history into the local
+logs.
+
+
+How the log loading works will depend on the poezio configuration:
+
+- if use_log is True, no logs will be fetched dynamically
+- if use_log is False, all logs will be fetched from MAM (if available)
+- if mam_sync and use_log are True, most chat tabs (all of them except the
+ static conversation tab) will try to sync the local
+ logs with the MAM history when opening them, or when joining a room.
+- all log loading/writing workflows are paused until the MAM sync is complete
+ (so that the local log loading can be up-to-date with the MAM history)
+- when use_log is False, mam_sync has no effect
+"""
+from __future__ import annotations
+import asyncio
+import logging
+from datetime import datetime, timedelta, timezone
+from typing import List, Optional
+from poezio import tabs
+from poezio.logger import (
+ build_log_message,
+ iterate_messages_reverse,
+ last_message_in_archive,
+ Logger,
+ LogDict,
+)
+from poezio.mam import (
+ fetch_history,
+ NoMAMSupportException,
+ MAMQueryException,
+ DiscoInfoException,
+ make_line,
+)
+from poezio.common import to_utc
+from poezio.ui.types import EndOfArchive, Message, BaseMessage
+from poezio.text_buffer import HistoryGap
+from slixmpp import JID
+
+
+# Max number of messages to insert when filling a gap
+HARD_LIMIT = 999
+
+
+log = logging.getLogger(__name__)
+
+
+def make_line_local(tab: tabs.ChatTab, msg: LogDict) -> Message:
+ """Create a UI message from a local log read.
+
+ :param tab: Tab in which that message will be displayed
+ :param msg: Log data
+ :returns: The UI message
+ """
+ if isinstance(tab, tabs.MucTab):
+ jid = JID(tab.jid)
+ jid.resource = msg.get('nickname') or ''
+ else:
+ jid = JID(tab.jid)
+ msg['time'] = msg['time'].astimezone(tz=timezone.utc)
+ return make_line(tab, msg['txt'], msg['time'], jid, '', msg['nickname'])
+
+
+class LogLoader:
+ """
+ An ephemeral class that loads history in a tab.
+
+ Loading from local logs is blocked until history has been fetched from
+ MAM to fill the local archive.
+ """
+ logger: Logger
+ tab: tabs.ChatTab
+ mam_only: bool
+
+ def __init__(self, logger: Logger, tab: tabs.ChatTab,
+ 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"""
+ amount = 2 * self.tab.text_win.height
+ gap = self.tab._text_buffer.find_last_gap_muc()
+ messages = []
+ if gap is not None:
+ if self.mam_only:
+ messages = await self.mam_fill_gap(gap, amount)
+ else:
+ messages = await self.local_fill_gap(gap, amount)
+ else:
+ if self.mam_only:
+ messages = await self.mam_tab_open(amount)
+ else:
+ messages = await self.local_tab_open(amount)
+
+ log.debug(
+ 'Fetched %s messages for %s',
+ len(messages), self.tab.jid
+ )
+ 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.
+
+ :param nb: number of max messages to fetch.
+ :returns: list of ui messages to add
+ """
+ tab = self.tab
+ end = datetime.now()
+ for message in tab._text_buffer.messages:
+ time_ok = to_utc(message.time) < to_utc(end)
+ if isinstance(message, Message) and time_ok:
+ end = message.time
+ break
+ end = end - timedelta(microseconds=1)
+ try:
+ return await fetch_history(tab, end=end, amount=nb)
+ except (NoMAMSupportException, MAMQueryException, DiscoInfoException):
+ return []
+ finally:
+ tab.query_status = False
+
+ def _get_time_limit(self) -> datetime:
+ """Get the date 10 weeks ago from now."""
+ return datetime.now() - timedelta(weeks=10)
+
+ async def local_tab_open(self, nb: int) -> List[BaseMessage]:
+ """Fetch messages locally when opening a new tab.
+
+ :param nb: number of max messages to fetch.
+ :returns: list of ui messages to add
+ """
+ await self.wait_mam()
+ limit = self._get_time_limit()
+ results: List[BaseMessage] = []
+ filepath = self.logger.get_file_path(self.tab.jid)
+ count = 0
+ for msg in iterate_messages_reverse(filepath):
+ typ_ = msg.pop('type')
+ if typ_ == 'message':
+ results.append(make_line_local(self.tab, msg))
+ elif msg['time'] < limit and 'set the subject' not in msg['txt']:
+ break
+ if len(results) >= nb:
+ break
+ count += 1
+ if count % 20 == 0:
+ await asyncio.sleep(0)
+ return results[::-1]
+
+ async def mam_fill_gap(self, gap: HistoryGap, amount: Optional[int] = None) -> List[BaseMessage]:
+ """Fill a message gap in an existing tab using MAM.
+
+ :param gap: Object describing the history gap
+ :returns: list of ui messages to add
+ """
+ tab = self.tab
+ if amount is None:
+ amount = HARD_LIMIT
+
+ start = gap.last_timestamp_before_leave
+ end = gap.first_timestamp_after_join
+ if start:
+ start = start + timedelta(seconds=1)
+ if end:
+ end = end - timedelta(seconds=1)
+ try:
+ return await fetch_history(
+ tab,
+ start=start,
+ end=end,
+ amount=amount,
+ )
+ except (NoMAMSupportException, MAMQueryException, DiscoInfoException):
+ return []
+ finally:
+ tab.query_status = False
+
+ async def local_fill_gap(self, gap: HistoryGap, amount: Optional[int] = None) -> List[BaseMessage]:
+ """Fill a message gap in an existing tab using the local logs.
+ Mostly useless when not used with the MAMFiller.
+
+ :param gap: Object describing the history gap
+ :returns: list of ui messages to add
+ """
+ if amount is None:
+ amount = HARD_LIMIT
+ await self.wait_mam()
+ limit = self._get_time_limit()
+ start = gap.last_timestamp_before_leave
+ end = gap.first_timestamp_after_join
+ count = 0
+
+ results: List[BaseMessage] = []
+ filepath = self.logger.get_file_path(self.tab.jid)
+ for msg in iterate_messages_reverse(filepath):
+ typ_ = msg.pop('type')
+ if start and msg['time'] < start:
+ break
+ if typ_ == 'message' and (not end or msg['time'] < end):
+ results.append(make_line_local(self.tab, msg))
+ elif msg['time'] < limit and 'set the subject' not in msg['txt']:
+ break
+ if len(results) >= amount:
+ break
+ count += 1
+ if count % 20 == 0:
+ await asyncio.sleep(0)
+ return results[::-1]
+
+ async def scroll_requested(self):
+ """When a scroll up is requested in a chat tab.
+
+ Try to load more history if there are no more messages in the buffer.
+ """
+ tab = self.tab
+ tw = tab.text_win
+
+ # If position in the tab is < two screen pages, then fetch MAM, so that
+ # wa keep some prefetched margin. A first page should also be
+ # prefetched on join if not already available.
+ total, pos, height = len(tw.built_lines), tw.pos, tw.height
+ rest = (total - pos) // height
+
+ if rest > 1:
+ return None
+
+ if self.mam_only:
+ messages = await self.mam_scroll_requested(height)
+ else:
+ messages = await self.local_scroll_requested(height)
+ 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.
+
+ :param nb: Number of messages to fetch
+ :returns: list of ui messages to add
+ """
+ await self.wait_mam()
+ tab = self.tab
+ count = 0
+
+ first_message = tab._text_buffer.find_first_message()
+ first_message_time = None
+ if first_message:
+ first_message_time = first_message.time - timedelta(microseconds=1)
+
+ results: List[BaseMessage] = []
+ filepath = self.logger.get_file_path(self.tab.jid)
+ for msg in iterate_messages_reverse(filepath):
+ typ_ = msg.pop('type')
+ if first_message_time is None or msg['time'] < first_message_time:
+ if typ_ == 'message':
+ results.append(make_line_local(self.tab, msg))
+ if len(results) >= nb:
+ break
+ count += 1
+ if count % 20 == 0:
+ await asyncio.sleep(0)
+ return results[::-1]
+
+ async def mam_scroll_requested(self, nb: int) -> List[BaseMessage]:
+ """Fetch messages from MAM on scroll up.
+
+ :param nb: Number of messages to fetch
+ :returns: list of ui messages to add
+ """
+ tab = self.tab
+ try:
+ messages = await fetch_history(tab, amount=nb)
+ last_message_exists = False
+ if tab._text_buffer.messages:
+ last_message = tab._text_buffer.messages[0]
+ last_message_exists = True
+ if (not messages and
+ last_message_exists
+ and not isinstance(last_message, EndOfArchive)):
+ time = tab._text_buffer.messages[0].time
+ messages = [EndOfArchive('End of archive reached', time=time)]
+ return messages
+ except NoMAMSupportException:
+ return []
+ except (MAMQueryException, DiscoInfoException):
+ tab.core.information(
+ f'An error occured when fetching MAM for {tab.jid}',
+ 'Error'
+ )
+ return []
+ finally:
+ tab.query_status = False
+
+ async def wait_mam(self) -> None:
+ """Wait for the MAM history sync before reading the local logs.
+
+ Does nothing apart from blocking.
+ """
+ if self.tab.mam_filler is None:
+ return
+ await self.tab.mam_filler.done.wait()
+
+
+class MAMFiller:
+ """Class that loads messages from MAM history into the local logs.
+ """
+ tab: tabs.ChatTab
+ logger: Logger
+ future: asyncio.Future
+ done: asyncio.Event
+ limit: int
+
+ def __init__(self, logger: Logger, tab: tabs.ChatTab, limit: int = 2000):
+ self.tab = tab
+ self.logger = logger
+ logger.fd_busy(tab.jid)
+ self.future = asyncio.create_task(self.fetch_routine())
+ self.done = asyncio.Event()
+ self.limit = limit
+ self.result = 0
+
+ def cancel(self) -> None:
+ """Cancel the routine and signal the end."""
+ self.future.cancel()
+ self.end()
+
+ async def fetch_routine(self) -> None:
+ """Load logs into the local archive, if possible."""
+ filepath = self.logger.get_file_path(self.tab.jid)
+ log.debug('Fetching logs for %s', self.tab.jid)
+ try:
+ last_msg = last_message_in_archive(filepath)
+ last_msg_time = None
+ if last_msg:
+ last_msg_time = last_msg['time'] + timedelta(seconds=1)
+ try:
+ messages = await fetch_history(
+ self.tab,
+ start=last_msg_time,
+ amount=self.limit,
+ )
+ log.debug(
+ 'Fetched %s messages to fill local logs for %s',
+ len(messages), self.tab.jid,
+ )
+ self.result = len(messages)
+ except NoMAMSupportException:
+ log.debug('The entity %s does not support MAM', self.tab.jid)
+ return
+ except (DiscoInfoException, MAMQueryException):
+ log.debug(
+ 'Failed fetching logs for %s',
+ self.tab.jid, exc_info=True
+ )
+ return
+
+ def build_message(msg) -> str:
+ return build_log_message(
+ msg.nickname,
+ msg.txt,
+ msg.time,
+ prefix='MR',
+ )
+
+ logs = ''.join(map(build_message, messages))
+ self.logger.log_raw(self.tab.jid, logs, force=True)
+ finally:
+ self.end()
+
+ def end(self) -> None:
+ """End a MAM fill (error or sucess). Remove references and signal on
+ the Event().
+ """
+ try:
+ self.logger.fd_available(self.tab.jid)
+ except Exception:
+ log.error('Error when restoring log fd:', exc_info=True)
+ self.tab.mam_filler = None
+ self.done.set()
diff --git a/poezio/logger.py b/poezio/logger.py
index 14882f00..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
@@ -11,20 +11,21 @@ conversations and roster changes
import mmap
import re
-from typing import List, Dict, Optional, IO, Any
+from typing import List, Dict, Optional, IO, Any, Union, Generator
from datetime import datetime
+from pathlib import Path
from poezio import common
from poezio.config import config
from poezio.xhtml import clean_text
-from poezio.theming import dump_tuple, get_theme
+from poezio.ui.types import Message, BaseMessage, LoggableTrait
+from slixmpp import JID
+from poezio.types import TypedDict
import logging
log = logging.getLogger(__name__)
-from poezio.config import LOG_DIR as log_dir
-
MESSAGE_LOG_RE = re.compile(r'^MR (\d{4})(\d{2})(\d{2})T'
r'(\d{2}):(\d{2}):(\d{2})Z '
r'(\d+) <([^ ]+)>  (.*)$')
@@ -34,8 +35,13 @@ INFO_LOG_RE = re.compile(r'^MI (\d{4})(\d{2})(\d{2})T'
class LogItem:
- def __init__(self, year, month, day, hour, minute, second, nb_lines,
- message):
+ time: datetime
+ nb_lines: int
+ text: str
+
+ def __init__(self, year: str, month: str, day: str, hour: str, minute: str,
+ second: str, nb_lines: str,
+ message: str):
self.time = datetime(
int(year), int(month), int(day), int(hour), int(minute),
int(second))
@@ -49,18 +55,37 @@ class LogInfo(LogItem):
class LogMessage(LogItem):
- def __init__(self, year, month, day, hour, minute, seconds, nb_lines, nick,
- message):
+ nick: str
+
+ def __init__(self, year: str, month: str, day: str, hour: str, minute: str,
+ seconds: str, nb_lines: str, nick: str,
+ message: str):
LogItem.__init__(self, year, month, day, hour, minute, seconds,
nb_lines, message)
self.nick = nick
-def parse_log_line(msg: str, jid: str) -> Optional[LogItem]:
- match = re.match(MESSAGE_LOG_RE, msg)
+LogDict = TypedDict(
+ 'LogDict',
+ {
+ 'type': str, 'txt': str, 'time': datetime,
+ 'history': bool, 'nickname': str
+ },
+ total=False,
+)
+
+
+def parse_log_line(msg: str, jid: str = '') -> Optional[LogItem]:
+ """Parse a log line.
+
+ :param msg: The message ligne
+ :param jid: jid (for error logging)
+ :returns: The LogItem or None on error
+ """
+ match = MESSAGE_LOG_RE.match(msg)
if match:
return LogMessage(*match.groups())
- match = re.match(INFO_LOG_RE, msg)
+ match = INFO_LOG_RE.match(msg)
if match:
return LogInfo(*match.groups())
log.debug('Error while parsing %s’s logs: “%s”', jid, msg)
@@ -72,60 +97,120 @@ class Logger:
Appends things to files. Error/information/warning logs
and also log the conversations to logfiles
"""
+ _roster_logfile: Optional[IO[str]]
+ log_dir: Path
+ _fds: Dict[str, IO[str]]
+ _busy_fds: Dict[str, bool]
def __init__(self):
- self._roster_logfile = None # Optional[IO[Any]]
+ self.log_dir = Path()
+ self._roster_logfile = None
# a dict of 'groupchatname': file-object (opened)
- self._fds = {} # type: Dict[str, IO[Any]]
+ self._fds = {}
+ self._busy_fds = {}
+ self._buffered_fds = {}
def __del__(self):
+ """Close all fds on exit"""
for opened_file in self._fds.values():
if opened_file:
try:
opened_file.close()
- except: # Can't close? too bad
+ except Exception: # Can't close? too bad
pass
+ try:
+ self._roster_logfile.close()
+ except Exception:
+ pass
- def close(self, jid) -> None:
- jid = str(jid).replace('/', '\\')
- if jid in self._fds:
- self._fds[jid].close()
+ def get_file_path(self, jid: Union[str, JID]) -> Path:
+ """Return the log path for a specific jid"""
+ jidstr = str(jid).replace('/', '\\')
+ return self.log_dir / jidstr
+
+ def fd_busy(self, jid: Union[str, JID]) -> None:
+ """Signal to the logger that this logfile is busy elsewhere.
+ And that the messages should be queued to be logged later.
+
+ :param jid: file name
+ """
+ jidstr = str(jid).replace('/', '\\')
+ self._busy_fds[jidstr] = True
+ if jidstr not in self._buffered_fds:
+ self._buffered_fds[jidstr] = []
+
+ def fd_available(self, jid: Union[str, JID]) -> None:
+ """Signal to the logger that this logfile is no longer busy.
+ And write messages to the end.
+
+ :param jid: file name
+ """
+ jidstr = str(jid).replace('/', '\\')
+ if jidstr in self._busy_fds:
+ del self._busy_fds[jidstr]
+ if jidstr in self._buffered_fds:
+ msgs = ''.join(self._buffered_fds.pop(jidstr))
+ if jidstr in self._fds:
+ self._fds[jidstr].close()
+ del self._fds[jidstr]
+ self.log_raw(jid, msgs)
+
+ def close(self, jid: str) -> None:
+ """Close the log file for a JID."""
+ 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]
- return None
+ del self._fds[jidstr]
def reload_all(self) -> None:
"""Close and reload all the file handles (on SIGHUP)"""
- for opened_file in self._fds.values():
+ not_closed = set()
+ for key, opened_file in self._fds.items():
if opened_file:
- opened_file.close()
+ try:
+ opened_file.close()
+ except Exception:
+ not_closed.add(key)
+ if self._roster_logfile:
+ try:
+ self._roster_logfile.close()
+ except Exception:
+ not_closed.add('roster')
log.debug('All log file handles closed')
+ if not_closed:
+ log.error('Unable to close log files for: %s', not_closed)
for room in self._fds:
self._check_and_create_log_dir(room)
log.debug('Log handle for %s re-created', room)
- return None
- def _check_and_create_log_dir(self, room: str,
- open_fd: bool = True) -> Optional[IO[Any]]:
+ 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
exists. if not, create it
+
+ :param jid: JID of the file to open after creating the dir
+ :param open_fd: if the file should be opened after creating the dir
+ :returns: the opened fd or None
"""
- if not config.get_by_tabname('use_log', room):
+ 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:
- log_dir.mkdir(parents=True, exist_ok=True)
- except OSError as e:
+ self.log_dir.mkdir(parents=True, exist_ok=True)
+ except OSError:
log.error('Unable to create the log dir', exc_info=True)
- except:
+ except Exception:
log.error('Unable to create the log dir', exc_info=True)
return None
if not open_fd:
return None
- filename = log_dir / room
+ filename = self.get_file_path(jid)
try:
fd = filename.open('a', encoding='utf-8')
- self._fds[room] = fd
+ self._fds[jid] = fd
return fd
except IOError:
log.error(
@@ -134,32 +219,53 @@ class Logger:
def log_message(self,
jid: str,
- nick: str,
- msg: str,
- date: Optional[datetime] = None,
- typ: int = 1) -> bool:
+ msg: Union[BaseMessage, Message]) -> bool:
"""
- log the message in the appropriate jid's file
- type:
- 0 = Don’t log
- 1 = Message
- 2 = Status/whatever
+ Log the message in the appropriate file
+
+ :param jid: JID of the entity for which to log the message
+ :param msg: Message to log
+ :returns: True if no error was encountered
"""
- if not config.get_by_tabname('use_log', jid):
+ if not config.get_by_tabname('use_log', JID(jid)):
+ return True
+ if not isinstance(msg, LoggableTrait):
return True
- logged_msg = build_log_message(nick, msg, date=date, typ=typ)
+ date = msg.time
+ txt = msg.txt
+ nick = ''
+ typ = 'MI'
+ if isinstance(msg, Message):
+ nick = msg.nickname or ''
+ if msg.me:
+ txt = f'/me {txt}'
+ typ = 'MR'
+ logged_msg = build_log_message(nick, txt, date=date, prefix=typ)
if not logged_msg:
return True
- jid = str(jid).replace('/', '\\')
- if jid in self._fds.keys():
- fd = self._fds[jid]
+ return self.log_raw(jid, logged_msg)
+
+ def log_raw(self, jid: Union[str, JID], logged_msg: str, force: bool = False) -> bool:
+ """Log a raw string.
+
+ :param jid: filename
+ :param logged_msg: string to log
+ :param force: Bypass the buffered fd check
+ :returns: True if no error was encountered
+ """
+ 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 = log_dir / jid
+ filename = self.get_file_path(jid)
try:
+ if not force and self._busy_fds.get(jidstr):
+ self._buffered_fds[jidstr].append(logged_msg)
+ return True
fd.write(logged_msg)
except OSError:
log.error(
@@ -181,11 +287,15 @@ class Logger:
def log_roster_change(self, jid: str, message: str) -> bool:
"""
Log a roster change
+
+ :param jid: jid to log the change for
+ :param message: message to log
+ :returns: True if no error happened
"""
- if not config.get_by_tabname('use_log', jid):
+ if not config.get_by_tabname('use_log', JID(jid)):
return True
self._check_and_create_log_dir('', open_fd=False)
- filename = log_dir / 'roster.log'
+ filename = self.log_dir / 'roster.log'
if not self._roster_logfile:
try:
self._roster_logfile = filename.open('a', encoding='utf-8')
@@ -206,7 +316,7 @@ class Logger:
for line in lines:
self._roster_logfile.write(' %s\n' % line)
self._roster_logfile.flush()
- except:
+ except Exception:
log.error(
'Unable to write in the log file (%s)',
filename,
@@ -218,21 +328,19 @@ class Logger:
def build_log_message(nick: str,
msg: str,
date: Optional[datetime] = None,
- typ: int = 1) -> str:
+ prefix: str = 'MI') -> str:
"""
Create a log message from a nick, a message, optionally a date and type
- message types:
- 0 = Don’t log
- 1 = Message
- 2 = Status/whatever
- """
- if not typ:
- return ''
+ :param nick: nickname to log
+ :param msg: text of the message
+ :param date: date of the message
+ :param prefix: MI (info) or MR (message)
+ :returns: The log line(s)
+ """
msg = clean_text(msg)
time = common.get_utc_time() if date is None else common.get_utc_time(date)
str_time = time.strftime('%Y%m%dT%H:%M:%SZ')
- prefix = 'MR' if typ == 1 else 'MI'
lines = msg.split('\n')
first_line = lines.pop(0)
nb_lines = str(len(lines)).zfill(3)
@@ -245,28 +353,62 @@ def build_log_message(nick: str,
return logged_msg + ''.join(' %s\n' % line for line in lines)
-def _get_lines_from_fd(fd: IO[Any], nb: int = 10) -> List[str]:
+def last_message_in_archive(filepath: Path) -> Optional[LogDict]:
+ """Get the last message from the local archive.
+
+ :param filepath: the log file path
"""
- Get the last log lines from a fileno
+ last_msg = None
+ for msg in iterate_messages_reverse(filepath):
+ if msg['type'] == 'message':
+ last_msg = msg
+ break
+ return last_msg
+
+
+def iterate_messages_reverse(filepath: Path) -> Generator[LogDict, None, None]:
+ """Get the latest messages from the log file, one at a time.
+
+ :param fd: the file descriptor
"""
- 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[Dict[str, Any]]:
+ try:
+ with open(filepath, 'rb') as fd:
+ 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
+ if pos != -1:
+ lines = parse_log_lines(
+ m[pos:-1].decode(errors='replace').splitlines()
+ )
+ elif m[0:1] == b'M':
+ # Handle the case of a single message present in the log
+ # file, hence no newline.
+ lines = parse_log_lines(
+ m[:].decode(errors='replace').splitlines()
+ )
+ if lines:
+ yield lines[0]
+ while pos > 0:
+ old_pos = pos
+ pos = m.rfind(b"\nM", 0, pos) + 1
+ lines = parse_log_lines(
+ m[pos:old_pos].decode(errors='replace').splitlines()
+ )
+ if lines:
+ yield lines[0]
+ except (OSError, ValueError):
+ pass
+
+
+def parse_log_lines(lines: List[str], jid: str = '') -> List[LogDict]:
"""
Parse raw log lines into poezio log objects
+
+ :param lines: Message lines
+ :param jid: jid (for error logging)
+ :return: a list of dicts containing message info
"""
messages = []
- color = '\x19%s}' % dump_tuple(get_theme().COLOR_LOG_MSG)
# now convert that data into actual Message objects
idx = 0
@@ -281,16 +423,18 @@ def parse_log_lines(lines: List[str], jid: str) -> List[Dict[str, Any]]:
log.debug('wrong log format? %s', log_item)
continue
message_lines = []
- message = {
+ message = LogDict({
'history': True,
- 'time': common.get_local_time(log_item.time)
- }
+ 'time': common.get_local_time(log_item.time),
+ 'type': 'message',
+ })
size = log_item.nb_lines
if isinstance(log_item, LogInfo):
- message_lines.append(color + log_item.text)
+ message_lines.append(log_item.text)
+ message['type'] = 'info'
elif isinstance(log_item, LogMessage):
message['nickname'] = log_item.nick
- message_lines.append(color + log_item.text)
+ message_lines.append(log_item.text)
while size != 0 and idx < len(lines):
message_lines.append(lines[idx][1:])
size -= 1
@@ -300,10 +444,4 @@ def parse_log_lines(lines: List[str], jid: str) -> List[Dict[str, Any]]:
return messages
-def create_logger() -> None:
- "Create the global logger object"
- global logger
- logger = Logger()
-
-
-logger = None # type: Logger
+logger = Logger()
diff --git a/poezio/mam.py b/poezio/mam.py
index 0f745f30..7cb1d369 100644
--- a/poezio/mam.py
+++ b/poezio/mam.py
@@ -1,102 +1,107 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-
"""
Query and control an archive of messages stored on a server using
XEP-0313: Message Archive Management(MAM).
"""
-import random
+from __future__ import annotations
+
+import logging
from datetime import datetime, timedelta, timezone
from hashlib import md5
-from typing import Optional, Callable
-
-from slixmpp import JID
+from typing import (
+ Any,
+ AsyncIterable,
+ Dict,
+ List,
+ Optional,
+)
+
+from slixmpp import JID, Message as SMessage
from slixmpp.exceptions import IqError, IqTimeout
from poezio.theming import get_theme
from poezio import tabs
-from poezio import xhtml, colors
-from poezio.config import config
-from poezio.text_buffer import TextBuffer
+from poezio import colors
+from poezio.common import to_utc
+from poezio.ui.types import (
+ BaseMessage,
+ Message,
+)
+
+log = logging.getLogger(__name__)
class DiscoInfoException(Exception): pass
class MAMQueryException(Exception): pass
class NoMAMSupportException(Exception): pass
-def add_line(
- tab,
- text_buffer: TextBuffer,
+def make_line(
+ tab: tabs.ChatTab,
text: str,
time: datetime,
- nick: str,
- top: bool,
- ) -> None:
+ jid: JID,
+ identifier: str = '',
+ nick: str = ''
+ ) -> Message:
"""Adds a textual entry in the TextBuffer"""
# Convert to local timezone
time = time.replace(tzinfo=timezone.utc).astimezone(tz=None)
time = time.replace(tzinfo=None)
- deterministic = config.get_by_tabname('deterministic_nick_colors', tab.jid.bare)
if isinstance(tab, tabs.MucTab):
- nick = nick.split('/')[1]
+ nick = jid.resource
user = tab.get_user_by_name(nick)
- if deterministic:
- if user:
- color = user.color
- else:
- theme = get_theme()
- if theme.ccg_palette:
- fg_color = colors.ccg_text_to_color(theme.ccg_palette, nick)
- color = fg_color, -1
- else:
- mod = len(theme.LIST_COLOR_NICKNAMES)
- nick_pos = int(md5(nick.encode('utf-8')).hexdigest(), 16) % mod
- color = theme.LIST_COLOR_NICKNAMES[nick_pos]
+ if user:
+ color = user.color
else:
- color = random.choice(list(xhtml.colors))
- color = xhtml.colors.get(color)
- color = (color, -1)
+ theme = get_theme()
+ if theme.ccg_palette:
+ fg_color = colors.ccg_text_to_color(theme.ccg_palette, nick)
+ color = fg_color, -1
+ else:
+ mod = len(theme.LIST_COLOR_NICKNAMES)
+ nick_pos = int(md5(nick.encode('utf-8')).hexdigest(), 16) % mod
+ color = theme.LIST_COLOR_NICKNAMES[nick_pos]
else:
- nick = nick.split('/')[0]
- color = get_theme().COLOR_OWN_NICK
- text_buffer.add_message(
+ if jid.bare == tab.core.xmpp.boundjid.bare:
+ if not nick:
+ nick = tab.core.own_nick
+ color = get_theme().COLOR_OWN_NICK
+ else:
+ color = get_theme().COLOR_REMOTE_USER
+ if not nick:
+ nick = tab.get_nick()
+ return Message(
txt=text,
+ identifier=identifier,
time=time,
nickname=nick,
nick_color=color,
history=True,
user=None,
- highlight=False,
- top=top,
- identifier=None,
- str_time=None,
- jid=None,
)
-
-async def query(
+async def get_mam_iterator(
core,
groupchat: bool,
remote_jid: JID,
amount: int,
- reverse: bool,
- start: Optional[datetime] = None,
- end: Optional[datetime] = None,
+ reverse: bool = True,
+ start: Optional[str] = None,
+ end: Optional[str] = None,
before: Optional[str] = None,
- callback: Optional[Callable] = None,
- ) -> None:
+ ) -> AsyncIterable[SMessage]:
+ """Get an async iterator for this mam query"""
try:
query_jid = remote_jid if groupchat else JID(core.xmpp.boundjid.bare)
iq = await core.xmpp.plugin['xep_0030'].get_info(jid=query_jid)
except (IqError, IqTimeout):
- raise DiscoInfoException
+ raise DiscoInfoException()
if 'urn:xmpp:mam:2' not in iq['disco_info'].get_features():
- raise NoMAMSupportException
+ raise NoMAMSupportException()
- args = {
+ args: Dict[str, Any] = {
'iterator': True,
'reverse': reverse,
}
@@ -106,129 +111,101 @@ async def query(
else:
args['with_jid'] = remote_jid
- args['rsm'] = {'max': amount}
- if reverse:
- if before is not None:
- args['rsm']['before'] = before
- else:
- args['end'] = end
- else:
- args['rsm']['start'] = start
- if before is not None:
- args['rsm']['end'] = end
- try:
- results = core.xmpp['xep_0313'].retrieve(**args)
- except (IqError, IqTimeout):
- raise MAMQueryException
- if callback is not None:
- callback(results)
+ if amount > 0:
+ args['rsm'] = {'max': amount}
+ args['start'] = start
+ args['end'] = end
+ return core.xmpp['xep_0313'].retrieve(**args)
+
+
+def _parse_message(msg: SMessage) -> Dict:
+ """Parse info inside a MAM forwarded message"""
+ forwarded = msg['mam_result']['forwarded']
+ message = forwarded['stanza']
+ return {
+ 'time': forwarded['delay']['stamp'],
+ 'jid': message['from'],
+ 'text': message['body'],
+ 'identifier': message['origin-id']
+ }
- return results
+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 add_messages_to_buffer(tab, top: bool, results, amount: int) -> bool:
- """Prepends or appends messages to the tab text_buffer"""
- text_buffer = tab._text_buffer
+async def retrieve_messages(tab: tabs.ChatTab,
+ results: AsyncIterable[SMessage],
+ amount: int = 100) -> List[BaseMessage]:
+ """Run the MAM query and put messages in order"""
msg_count = 0
msgs = []
- async for rsm in results:
- if top:
+ 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:
- msgs.append(msg)
- if msg_count == amount:
- tab.core.refresh_window()
- return False
+ 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):
+ to_add.append(msg)
msg_count += 1
- msgs.reverse()
- for msg in msgs:
- forwarded = msg['mam_result']['forwarded']
- timestamp = forwarded['delay']['stamp']
- message = forwarded['stanza']
- tab.last_stanza_id = msg['mam_result']['id']
- nick = str(message['from'])
- add_line(tab, text_buffer, message['body'], timestamp, nick, top)
- else:
- for msg in rsm['mam']['results']:
- forwarded = msg['mam_result']['forwarded']
- timestamp = forwarded['delay']['stamp']
- message = forwarded['stanza']
- nick = str(message['from'])
- add_line(tab, text_buffer, message['body'], timestamp, nick, top)
- tab.core.refresh_window()
- return False
-
-
-async def fetch_history(tab, end: Optional[datetime] = None, amount: Optional[int] = None):
+ if msg_count == amount:
+ to_add.reverse()
+ return to_add
+ msgs = []
+ to_add.reverse()
+ return to_add
+ except (IqError, IqTimeout) as exc:
+ log.debug('Unable to complete MAM query: %s', exc, exc_info=True)
+ raise MAMQueryException('Query interrupted')
+
+
+async def fetch_history(tab: tabs.ChatTab,
+ start: Optional[datetime] = None,
+ end: Optional[datetime] = None,
+ amount: int = 100) -> List[BaseMessage]:
remote_jid = tab.jid
- before = tab.last_stanza_id
+ if not end:
+ for msg in tab._text_buffer.messages:
+ if isinstance(msg, Message):
+ end = msg.time
+ end -= timedelta(microseconds=1)
+ break
if end is None:
end = datetime.now()
- tzone = datetime.now().astimezone().tzinfo
- end = end.replace(tzinfo=tzone).astimezone(tz=timezone.utc)
- end = end.replace(tzinfo=None)
- end = datetime.strftime(end, '%Y-%m-%dT%H:%M:%SZ')
-
- if amount >= 100:
- amount = 99
-
- groupchat = isinstance(tab, tabs.MucTab)
-
- results = await query(
- tab.core,
- groupchat,
- remote_jid,
- amount,
+ end = to_utc(end)
+ end_str = datetime.strftime(end, '%Y-%m-%dT%H:%M:%SZ')
+
+ start_str = None
+ if start is not None:
+ start = to_utc(start)
+ start_str = datetime.strftime(start, '%Y-%m-%dT%H:%M:%SZ')
+
+ mam_iterator = await get_mam_iterator(
+ core=tab.core,
+ groupchat=isinstance(tab, tabs.MucTab),
+ remote_jid=remote_jid,
+ amount=amount,
+ end=end_str,
+ start=start_str,
reverse=True,
- end=end,
- before=before,
)
- query_status = await add_messages_to_buffer(tab, True, results, amount)
- tab.query_status = query_status
-
-
-async def on_tab_open(tab) -> None:
- amount = 2 * tab.text_win.height
- end = datetime.now()
- tab.query_status = True
- for message in tab._text_buffer.messages:
- time = message.time
- if time < end:
- end = time
- end = end + timedelta(seconds=-1)
- try:
- await fetch_history(tab, end=end, amount=amount)
- except (NoMAMSupportException, MAMQueryException, DiscoInfoException):
- tab.query_status = False
- return None
-
-
-async def on_scroll_up(tab) -> None:
- tw = tab.text_win
-
- # If position in the tab is < two screen pages, then fetch MAM, so that we
- # keep some prefetched margin. A first page should also be prefetched on
- # join if not already available.
- total, pos, height = len(tw.built_lines), tw.pos, tw.height
- rest = (total - pos) // height
- # Not resetting the state of query_status here, it is changed only after the
- # query is complete (in fetch_history)
- # This is done to stop message repetition, eg: if the user presses PageUp continuously.
- tab.query_status = True
-
- if rest > 1:
- return None
-
- try:
- # XXX: Do we want to fetch a possibly variable number of messages?
- # (InfoTab changes height depending on the type of messages, see
- # `information_buffer_popup_on`).
- await fetch_history(tab, amount=height)
- except NoMAMSupportException:
- tab.core.information('MAM not supported for %r' % tab.jid, 'Info')
- return None
- except (MAMQueryException, DiscoInfoException):
- tab.core.information('An error occured when fetching MAM for %r' % tab.jid, 'Error')
- return None
+ return await retrieve_messages(tab, mam_iterator, amount)
diff --git a/poezio/multiuserchat.py b/poezio/multiuserchat.py
index 30c36a77..3278e1bd 100644
--- a/poezio/multiuserchat.py
+++ b/poezio/multiuserchat.py
@@ -3,77 +3,51 @@
# 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
slix plugin
"""
+from __future__ import annotations
+
+import asyncio
from xml.etree import ElementTree as ET
+from typing import (
+ Optional,
+ Union,
+ TYPE_CHECKING,
+)
+
+from slixmpp import (
+ JID,
+ ClientXMPP,
+ Iq,
+ Presence,
+)
-from poezio.common import safeJID
-from slixmpp import JID
-from slixmpp.exceptions import IqError, IqTimeout
import logging
log = logging.getLogger(__name__)
-NS_MUC_ADMIN = 'http://jabber.org/protocol/muc#admin'
-NS_MUC_OWNER = 'http://jabber.org/protocol/muc#owner'
-
-
-def destroy_room(xmpp, room, reason='', altroom=''):
- """
- destroy a room
- """
- room = safeJID(room)
- if not room:
- return False
- iq = xmpp.make_iq_set()
- iq['to'] = room
- query = ET.Element('{%s}query' % NS_MUC_OWNER)
- destroy = ET.Element('{%s}destroy' % NS_MUC_OWNER)
- if altroom:
- destroy.attrib['jid'] = altroom
- if reason:
- xreason = ET.Element('{%s}reason' % NS_MUC_OWNER)
- xreason.text = reason
- destroy.append(xreason)
- query.append(destroy)
- iq.append(query)
-
- def callback(iq):
- if not iq or iq['type'] == 'error':
- xmpp.core.information('Unable to destroy room %s' % room, 'Info')
- else:
- xmpp.core.information('Room %s destroyed' % room, 'Info')
-
- iq.send(callback=callback)
- return True
-
-def send_private_message(xmpp, jid, line):
- """
- Send a private message
- """
- jid = safeJID(jid)
- xmpp.send_message(mto=jid, mbody=line, mtype='chat')
-
-
-def send_groupchat_message(xmpp, jid, line):
- """
- Send a message to the groupchat
- """
- jid = safeJID(jid)
- xmpp.send_message(mto=jid, mbody=line, mtype='groupchat')
+if TYPE_CHECKING:
+ from poezio.core.core import Core
+ from poezio.tabs import MucTab
-def change_show(xmpp, jid: JID, own_nick: str, show, status):
+def change_show(
+ xmpp: ClientXMPP,
+ jid: JID,
+ own_nick: str,
+ show: str,
+ status: Optional[str]
+) -> None:
"""
Change our 'Show'
"""
- jid = safeJID(jid)
- pres = xmpp.make_presence(pto='%s/%s' % (jid, own_nick))
+ jid = JID(jid)
+ pres: Presence = xmpp.make_presence(pto='%s/%s' % (jid, own_nick))
if show: # if show is None, don't put a <show /> tag. It means "available"
pres['type'] = show
if status:
@@ -81,46 +55,45 @@ def change_show(xmpp, jid: JID, own_nick: str, show, status):
pres.send()
-def change_subject(xmpp, jid, subject):
- """
- Change the room subject
- """
- jid = safeJID(jid)
- msg = xmpp.make_message(jid)
- msg['type'] = 'groupchat'
- msg['subject'] = subject
- msg.send()
-
-
-def change_nick(core, jid, nick, status=None, show=None):
+def change_nick(
+ core: Core,
+ jid: Union[JID, str],
+ nick: str,
+ status: Optional[str] = None,
+ show: Optional[str] = None
+) -> None:
"""
Change our own nick in a room
"""
xmpp = core.xmpp
- presence = xmpp.make_presence(
- pshow=show, pstatus=status, pto=safeJID('%s/%s' % (jid, nick)))
+ presence: Presence = xmpp.make_presence(
+ pshow=show, pstatus=status, pto=JID('%s/%s' % (jid, nick)))
core.events.trigger('changing_nick', presence)
presence.send()
-def join_groupchat(core,
- jid,
- nick,
- passwd='',
- status=None,
- show=None,
- seconds=None,
- tab=None):
+def join_groupchat(
+ core: Core,
+ jid: JID,
+ nick: str,
+ passwd: str = '',
+ status: Optional[str] = None,
+ show: Optional[str] = None,
+ seconds: Optional[int] = None,
+ tab: Optional['MucTab'] = None
+) -> None:
xmpp = core.xmpp
- stanza = xmpp.make_presence(
+ stanza: Presence = xmpp.make_presence(
pto='%s/%s' % (jid, nick), pstatus=status, pshow=show)
x = ET.Element('{http://jabber.org/protocol/muc}x')
if passwd:
passelement = ET.Element('password')
passelement.text = passwd
x.append(passelement)
- def on_disco(iq):
- if 'urn:xmpp:mam:2' in iq['disco_info'].get_features() or (tab and tab._text_buffer.last_message):
+
+ def on_disco(iq: Iq) -> None:
+ if ('urn:xmpp:mam:2' in iq['disco_info'].get_features()
+ or (tab and tab._text_buffer.last_message)):
history = ET.Element('{http://jabber.org/protocol/muc}history')
history.attrib['seconds'] = str(0)
x.append(history)
@@ -136,17 +109,21 @@ def join_groupchat(core,
xmpp.plugin['xep_0045'].rooms[jid] = {}
xmpp.plugin['xep_0045'].our_nicks[jid] = to.resource
- try:
+ asyncio.create_task(
xmpp.plugin['xep_0030'].get_info(jid=jid, callback=on_disco)
- except (IqError, IqTimeout):
- return core.information('Failed to retrieve messages', 'Error')
+ )
-def leave_groupchat(xmpp, jid, own_nick, msg):
+def leave_groupchat(
+ xmpp: ClientXMPP,
+ jid: JID,
+ own_nick: str,
+ msg: str
+) -> None:
"""
Leave the groupchat
"""
- jid = safeJID(jid)
+ jid = JID(jid)
try:
xmpp.plugin['xep_0045'].leave_muc(jid, own_nick, msg)
except KeyError:
@@ -154,91 +131,3 @@ def leave_groupchat(xmpp, jid, own_nick, msg):
"muc.leave_groupchat: could not leave the room %s",
jid,
exc_info=True)
-
-
-def set_user_role(xmpp, jid, nick, reason, role, callback=None):
- """
- (try to) Set the role of a MUC user
- (role = 'none': eject user)
- """
- jid = safeJID(jid)
- iq = xmpp.make_iq_set()
- query = ET.Element('{%s}query' % NS_MUC_ADMIN)
- item = ET.Element('{%s}item' % NS_MUC_ADMIN, {'nick': nick, 'role': role})
- if reason:
- reason_el = ET.Element('{%s}reason' % NS_MUC_ADMIN)
- reason_el.text = reason
- item.append(reason_el)
- query.append(item)
- iq.append(query)
- iq['to'] = jid
- if callback:
- return iq.send(callback=callback)
- try:
- return iq.send()
- except (IqError, IqTimeout) as e:
- return e.iq
-
-
-def set_user_affiliation(xmpp,
- muc_jid,
- affiliation,
- nick=None,
- jid=None,
- reason=None,
- callback=None):
- """
- (try to) Set the affiliation of a MUC user
- """
- muc_jid = safeJID(muc_jid)
- query = ET.Element('{http://jabber.org/protocol/muc#admin}query')
- if nick:
- item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {
- 'affiliation': affiliation,
- 'nick': nick
- })
- else:
- item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {
- 'affiliation': affiliation,
- 'jid': str(jid)
- })
-
- if reason:
- reason_item = ET.Element(
- '{http://jabber.org/protocol/muc#admin}reason')
- reason_item.text = reason
- item.append(reason_item)
-
- query.append(item)
- iq = xmpp.make_iq_set(query)
- iq['to'] = muc_jid
- if callback:
- return iq.send(callback=callback)
- try:
- return xmpp.plugin['xep_0045'].set_affiliation(
- str(muc_jid),
- str(jid) if jid else None, nick, affiliation)
- except:
- log.debug('Error setting the affiliation: %s', exc_info=True)
- return False
-
-
-def cancel_config(xmpp, room):
- query = ET.Element('{http://jabber.org/protocol/muc#owner}query')
- x = ET.Element('{jabber:x:data}x', type='cancel')
- query.append(x)
- iq = xmpp.make_iq_set(query)
- iq['to'] = room
- iq.send()
-
-
-def configure_room(xmpp, room, form):
- if form is None:
- return
- iq = xmpp.make_iq_set()
- iq['to'] = room
- query = ET.Element('{http://jabber.org/protocol/muc#owner}query')
- form['type'] = 'submit'
- query.append(form.xml)
- iq.append(query)
- iq.send()
diff --git a/poezio/pep.py b/poezio/pep.py
deleted file mode 100644
index 52cc4cd5..00000000
--- a/poezio/pep.py
+++ /dev/null
@@ -1,207 +0,0 @@
-"""
-Collection of mappings for PEP moods/activities
-extracted directly from the XEP
-"""
-
-from typing import Dict
-
-MOODS = {
- 'afraid': 'Afraid',
- 'amazed': 'Amazed',
- 'angry': 'Angry',
- 'amorous': 'Amorous',
- 'annoyed': 'Annoyed',
- 'anxious': 'Anxious',
- 'aroused': 'Aroused',
- 'ashamed': 'Ashamed',
- 'bored': 'Bored',
- 'brave': 'Brave',
- 'calm': 'Calm',
- 'cautious': 'Cautious',
- 'cold': 'Cold',
- 'confident': 'Confident',
- 'confused': 'Confused',
- 'contemplative': 'Contemplative',
- 'contented': 'Contented',
- 'cranky': 'Cranky',
- 'crazy': 'Crazy',
- 'creative': 'Creative',
- 'curious': 'Curious',
- 'dejected': 'Dejected',
- 'depressed': 'Depressed',
- 'disappointed': 'Disappointed',
- 'disgusted': 'Disgusted',
- 'dismayed': 'Dismayed',
- 'distracted': 'Distracted',
- 'embarrassed': 'Embarrassed',
- 'envious': 'Envious',
- 'excited': 'Excited',
- 'flirtatious': 'Flirtatious',
- 'frustrated': 'Frustrated',
- 'grumpy': 'Grumpy',
- 'guilty': 'Guilty',
- 'happy': 'Happy',
- 'hopeful': 'Hopeful',
- 'hot': 'Hot',
- 'humbled': 'Humbled',
- 'humiliated': 'Humiliated',
- 'hungry': 'Hungry',
- 'hurt': 'Hurt',
- 'impressed': 'Impressed',
- 'in_awe': 'In awe',
- 'in_love': 'In love',
- 'indignant': 'Indignant',
- 'interested': 'Interested',
- 'intoxicated': 'Intoxicated',
- 'invincible': 'Invincible',
- 'jealous': 'Jealous',
- 'lonely': 'Lonely',
- 'lucky': 'Lucky',
- 'mean': 'Mean',
- 'moody': 'Moody',
- 'nervous': 'Nervous',
- 'neutral': 'Neutral',
- 'offended': 'Offended',
- 'outraged': 'Outraged',
- 'playful': 'Playful',
- 'proud': 'Proud',
- 'relaxed': 'Relaxed',
- 'relieved': 'Relieved',
- 'remorseful': 'Remorseful',
- 'restless': 'Restless',
- 'sad': 'Sad',
- 'sarcastic': 'Sarcastic',
- 'serious': 'Serious',
- 'shocked': 'Shocked',
- 'shy': 'Shy',
- 'sick': 'Sick',
- 'sleepy': 'Sleepy',
- 'spontaneous': 'Spontaneous',
- 'stressed': 'Stressed',
- 'strong': 'Strong',
- 'surprised': 'Surprised',
- 'thankful': 'Thankful',
- 'thirsty': 'Thirsty',
- 'tired': 'Tired',
- 'undefined': 'Undefined',
- 'weak': 'Weak',
- 'worried': 'Worried'
-} # type: Dict[str, str]
-
-ACTIVITIES = {
- 'doing_chores': {
- 'category': 'Doing_chores',
- 'buying_groceries': 'Buying groceries',
- 'cleaning': 'Cleaning',
- 'cooking': 'Cooking',
- 'doing_maintenance': 'Doing maintenance',
- 'doing_the_dishes': 'Doing the dishes',
- 'doing_the_laundry': 'Doing the laundry',
- 'gardening': 'Gardening',
- 'running_an_errand': 'Running an errand',
- 'walking_the_dog': 'Walking the dog',
- 'other': 'Other',
- },
- 'drinking': {
- 'category': 'Drinking',
- 'having_a_beer': 'Having a beer',
- 'having_coffee': 'Having coffee',
- 'having_tea': 'Having tea',
- 'other': 'Other',
- },
- 'eating': {
- 'category': 'Eating',
- 'having_breakfast': 'Having breakfast',
- 'having_a_snack': 'Having a snack',
- 'having_dinner': 'Having dinner',
- 'having_lunch': 'Having lunch',
- 'other': 'Other',
- },
- 'exercising': {
- 'category': 'Exercising',
- 'cycling': 'Cycling',
- 'dancing': 'Dancing',
- 'hiking': 'Hiking',
- 'jogging': 'Jogging',
- 'playing_sports': 'Playing sports',
- 'running': 'Running',
- 'skiing': 'Skiing',
- 'swimming': 'Swimming',
- 'working_out': 'Working out',
- 'other': 'Other',
- },
- 'grooming': {
- 'category': 'Grooming',
- 'at_the_spa': 'At the spa',
- 'brushing_teeth': 'Brushing teeth',
- 'getting_a_haircut': 'Getting a haircut',
- 'shaving': 'Shaving',
- 'taking_a_bath': 'Taking a bath',
- 'taking_a_shower': 'Taking a shower',
- 'other': 'Other',
- },
- 'having_appointment': {
- 'category': 'Having appointment',
- 'other': 'Other',
- },
- 'inactive': {
- 'category': 'Inactive',
- 'day_off': 'Day_off',
- 'hanging_out': 'Hanging out',
- 'hiding': 'Hiding',
- 'on_vacation': 'On vacation',
- 'praying': 'Praying',
- 'scheduled_holiday': 'Scheduled holiday',
- 'sleeping': 'Sleeping',
- 'thinking': 'Thinking',
- 'other': 'Other',
- },
- 'relaxing': {
- 'category': 'Relaxing',
- 'fishing': 'Fishing',
- 'gaming': 'Gaming',
- 'going_out': 'Going out',
- 'partying': 'Partying',
- 'reading': 'Reading',
- 'rehearsing': 'Rehearsing',
- 'shopping': 'Shopping',
- 'smoking': 'Smoking',
- 'socializing': 'Socializing',
- 'sunbathing': 'Sunbathing',
- 'watching_a_movie': 'Watching a movie',
- 'watching_tv': 'Watching tv',
- 'other': 'Other',
- },
- 'talking': {
- 'category': 'Talking',
- 'in_real_life': 'In real life',
- 'on_the_phone': 'On the phone',
- 'on_video_phone': 'On video phone',
- 'other': 'Other',
- },
- 'traveling': {
- 'category': 'Traveling',
- 'commuting': 'Commuting',
- 'driving': 'Driving',
- 'in_a_car': 'In a car',
- 'on_a_bus': 'On a bus',
- 'on_a_plane': 'On a plane',
- 'on_a_train': 'On a train',
- 'on_a_trip': 'On a trip',
- 'walking': 'Walking',
- 'cycling': 'Cycling',
- 'other': 'Other',
- },
- 'undefined': {
- 'category': 'Undefined',
- 'other': 'Other',
- },
- 'working': {
- 'category': 'Working',
- 'coding': 'Coding',
- 'in_a_meeting': 'In a meeting',
- 'writing': 'Writing',
- 'studying': 'Studying',
- 'other': 'Other',
- }
-} # type: Dict[str, Dict[str, str]]
diff --git a/poezio/plugin.py b/poezio/plugin.py
index 61e0ea87..f38e47e2 100644
--- a/poezio/plugin.py
+++ b/poezio/plugin.py
@@ -3,6 +3,8 @@ Define the PluginConfig and Plugin classes, plus the SafetyMetaclass.
These are used in the plugin system added in poezio 0.7.5
(see plugin_manager.py)
"""
+
+from typing import Any, Dict, Set, Optional
from asyncio import iscoroutinefunction
from functools import partial
from configparser import RawConfigParser
@@ -24,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):
@@ -43,7 +46,7 @@ class PluginConfig(config.Config):
def read(self):
"""Read the config file"""
- RawConfigParser.read(self, str(self.file_name))
+ RawConfigParser.read(self.configparser, str(self.file_name))
if not self.has_section(self.module_name):
self.add_section(self.module_name)
@@ -62,7 +65,7 @@ class PluginConfig(config.Config):
"""Write the config to the disk"""
try:
with self.file_name.open('w') as fp:
- RawConfigParser.write(self, fp)
+ RawConfigParser.write(self.configparser, fp)
return True
except IOError:
return False
@@ -399,7 +402,13 @@ class BasePlugin(object, metaclass=SafetyMetaclass):
Class that all plugins derive from.
"""
- default_config = None
+ # Internal use only
+ _unloading = False
+
+ default_config: Optional[Dict[Any, Any]] = None
+ dependencies: Set[str] = set()
+ # This dict will get populated when the plugin is initialized
+ refs: Dict[str, Any] = {}
def __init__(self, name, plugin_api, core, plugins_conf_dir):
self.__name = name
@@ -417,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 9d1d4903..49f7b067 100644
--- a/poezio/plugin_e2ee.py
+++ b/poezio/plugin_e2ee.py
@@ -4,29 +4,42 @@
#
# 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.
"""
-from typing import Callable, Dict, List, Optional, Union, Tuple, Set
+from typing import (
+ Callable,
+ Dict,
+ List,
+ Optional,
+ Union,
+ Tuple,
+ Set,
+ Type,
+)
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
@@ -97,30 +110,32 @@ class E2EEPlugin(BasePlugin):
#: Encryption name, used in command descriptions, and logs. At least one
#: of `encryption_name` and `encryption_short_name` must be set.
- encryption_name = None # type: Optional[str]
+ encryption_name: Optional[str] = None
#: Encryption short name, used as command name, and also to display
#: encryption status in a tab. At least one of `encryption_name` and
#: `encryption_short_name` must be set.
- encryption_short_name = None # type: Optional[str]
+ encryption_short_name: Optional[str] = None
#: Required. https://xmpp.org/extensions/xep-0380.html.
- eme_ns = None # type: Optional[str]
+ eme_ns: Optional[str] = None
#: Used to figure out what messages to attempt decryption for. Also used
#: in combination with `tag_whitelist` to avoid removing encrypted tags
- #: before sending.
- encrypted_tags = None # type: Optional[List[Tuple[str, str]]]
+ #: 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
# time
- _enabled_tabs = {} # type: Dict[JID, Callable]
+ _enabled_tabs: Dict[JID, Callable] = {}
# Tabs that support this encryption mechanism
- supported_tab_types = tuple() # type: Tuple[ChatTabs]
+ supported_tab_types: Tuple[Type[ChatTab], ...] = tuple()
# States for each remote entity
- trust_states = {'accepted': set(), 'rejected': set()} # type: Dict[str, Set[str]]
+ trust_states: Dict[str, Set[str]] = {'accepted': set(), 'rejected': set()}
def init(self):
self._all_trust_states = self.trust_states['accepted'].union(
@@ -137,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.
@@ -182,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(
@@ -204,9 +232,10 @@ class E2EEPlugin(BasePlugin):
def __load_encrypted_states(self) -> None:
"""Load previously stored encryption states for jids."""
for section in config.sections():
- value = config.get('encryption', section=section)
+ 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)
@@ -224,69 +253,92 @@ class E2EEPlugin(BasePlugin):
except InvalidJID:
return ""
- if self._encryption_enabled(jid):
+ if self._encryption_enabled(jid) and self.encryption_short_name:
return " " + self.encryption_short_name
return ""
def _toggle_tab(self, _input: str) -> None:
- jid = self.api.current_tab().jid # type: 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',
)
- else:
+ 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:
+ if not args:
+ self.api.information(
+ 'No fingerprint provided to the command..',
+ 'Error',
+ )
+ 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
@@ -309,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
@@ -330,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' % (
@@ -337,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 result
+ return None
- def _decrypt(self, message: Message, tab: ChatTabs) -> 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
+
+ mfrom = stanza['from']
+
+ # 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 = False
- if message.xml.find('{%s}%s' % (EME_NS, EME_TAG)) is not None and \
+ 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
@@ -368,39 +472,77 @@ class E2EEPlugin(BasePlugin):
# comes from a semi-anonymous MUC for example. Some plugins might be
# fine with this so let them handle it.
jid = message['from']
- muctab = tab
- if isinstance(muctab, PrivateTab):
+ muctab: Optional[MucTab] = None
+ if isinstance(tab, PrivateTab):
muctab = tab.parent_muc
jid = None
- if isinstance(muctab, MucTab):
+ if muctab is not None or isinstance(tab, MucTab):
+ if muctab is None:
+ muctab = tab # type: ignore
nick = message['from'].resource
- for user in muctab.users:
- if user.nick == nick:
- jid = user.jid or None
- break
+ user = muctab.get_user_by_name(nick) # type: ignore
+ 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) -> Optional[StanzaBase]:
+ 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 = [message['to']] # type: Optional[List[JID]]
+ 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):
@@ -433,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
@@ -479,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:
@@ -490,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)
- return config.get(option=option_name, section=jid)
+ 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
@@ -530,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/plugin_manager.py b/poezio/plugin_manager.py
index 75a6b4a3..17673a9e 100644
--- a/poezio/plugin_manager.py
+++ b/poezio/plugin_manager.py
@@ -7,6 +7,7 @@ plugin env.
import logging
import os
+from typing import Dict, Set
from importlib import import_module, machinery
from pathlib import Path
from os import path
@@ -27,6 +28,8 @@ class PluginManager:
And keeps track of everything the plugin has done through the API.
"""
+ rdeps: Dict[str, Set[str]] = {}
+
def __init__(self, core):
self.core = core
# module name -> module object
@@ -58,10 +61,25 @@ class PluginManager:
for plugin in set(self.plugins.keys()):
self.unload(plugin, notify=False)
- def load(self, name: str, notify=True):
+ def set_rdeps(self, name):
+ """
+ Runs through plugin dependencies to build the reverse dependencies table.
+ """
+
+ if name not in self.rdeps:
+ self.rdeps[name] = set()
+ for dep in self.plugins[name].dependencies:
+ if dep not in self.rdeps:
+ self.rdeps[dep] = {name}
+ else:
+ self.rdeps[dep].add(name)
+
+ def load(self, name: str, notify=True, unload_first=True):
"""
Load a plugin.
"""
+ if not unload_first and name in self.plugins:
+ return None
if name in self.plugins:
self.unload(name)
@@ -109,8 +127,22 @@ class PluginManager:
self.event_handlers[name] = []
try:
self.plugins[name] = None
+
+ for dep in module.Plugin.dependencies:
+ self.load(dep, unload_first=False)
+ if dep not in self.plugins:
+ log.debug(
+ 'Plugin %s couldn\'t load because of dependency %s',
+ name, dep
+ )
+ return None
+ # Add reference of the dep to the plugin's usage
+ module.Plugin.refs[dep] = self.plugins[dep]
+
self.plugins[name] = module.Plugin(name, self.plugin_api, self.core,
self.plugins_conf_dir)
+ self.set_rdeps(name)
+
except Exception as e:
log.error('Error while loading the plugin %s', name, exc_info=True)
if notify:
@@ -122,8 +154,21 @@ class PluginManager:
self.core.information('Plugin %s loaded' % name, 'Info')
def unload(self, name: str, notify=True):
+ """
+ Unloads plugin as well as plugins depending on it.
+ """
+
if name in self.plugins:
try:
+ if self.plugins[name] is not None:
+ self.plugins[name]._unloading = True # Prevents loops
+ for rdep in self.rdeps[name].copy():
+ if rdep in self.plugins and not self.plugins[rdep]._unloading:
+ self.unload(rdep)
+ if rdep in self.plugins:
+ log.debug('Failed to unload reverse dependency %s first.', rdep)
+ return None
+
for command in self.commands[name].keys():
del self.core.commands[command]
for key in self.keys[name].keys():
@@ -143,6 +188,7 @@ class PluginManager:
if self.plugins[name] is not None:
self.plugins[name].unload()
del self.plugins[name]
+ del self.rdeps[name]
del self.commands[name]
del self.keys[name]
del self.tab_commands[name]
@@ -347,7 +393,7 @@ class PluginManager:
"""
Create the plugins_conf_dir
"""
- plugins_conf_dir = config.get('plugins_conf_dir')
+ plugins_conf_dir = config.getstr('plugins_conf_dir')
self.plugins_conf_dir = Path(plugins_conf_dir).expanduser(
) if plugins_conf_dir else xdg.CONFIG_HOME / 'plugins'
self.check_create_plugins_conf_dir()
@@ -372,7 +418,7 @@ class PluginManager:
"""
Set the plugins_dir on start
"""
- plugins_dir = config.get('plugins_dir')
+ plugins_dir = config.getstr('plugins_dir')
self.plugins_dir = Path(plugins_dir).expanduser(
) if plugins_dir else xdg.DATA_HOME / 'plugins'
self.check_create_plugins_dir()
diff --git a/poezio/poezio.py b/poezio/poezio.py
index e38871c6..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
"""
@@ -79,57 +79,48 @@ def main():
sys.stdout.write("\x1b]0;poezio\x07")
sys.stdout.flush()
+ from poezio.args import run_cmdline_args
+ options, firstrun = run_cmdline_args()
from poezio import config
- config.run_cmdline_args()
- config.create_global_config()
- config.setup_logging()
- config.post_logging_setup()
+ config.create_global_config(options.filename)
+ config.setup_logging(options.debug)
import logging
logging.raiseExceptions = False
- from poezio.config import options
-
if options.check_config:
config.check_config()
sys.exit(0)
- from poezio.asyncio import monkey_patch_asyncio_slixmpp
+ from poezio.asyncio_fix import monkey_patch_asyncio_slixmpp
monkey_patch_asyncio_slixmpp()
from poezio import theming
theming.update_themes_dir()
- from poezio import logger
- logger.create_logger()
+ from poezio.logger import logger
+ logger.log_dir = config.LOG_DIR
from poezio import roster
- roster.create_roster()
+ roster.roster.reset()
from poezio.core.core import Core
signal.signal(signal.SIGINT, signal.SIG_IGN) # ignore ctrl-c
- cocore = Core()
+ cocore = Core(options.custom_version, firstrun)
signal.signal(signal.SIGUSR1, cocore.sigusr_handler) # reload the config
signal.signal(signal.SIGHUP, cocore.exit_from_signal)
signal.signal(signal.SIGTERM, cocore.exit_from_signal)
- if options.debug:
- cocore.debug = True
cocore.start()
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/poezio_shlex.pyi b/poezio/poezio_shlex.pyi
new file mode 100644
index 00000000..affbe12b
--- /dev/null
+++ b/poezio/poezio_shlex.pyi
@@ -0,0 +1,45 @@
+from typing import List, Tuple, Any, TextIO, Union, Optional, Iterable, TypeVar
+import sys
+
+def split(s: str, comments: bool = ..., posix: bool = ...) -> List[str]: ...
+if sys.version_info >= (3, 8):
+ def join(split_command: Iterable[str]) -> str: ...
+def quote(s: str) -> str: ...
+
+_SLT = TypeVar('_SLT', bound=shlex)
+
+class shlex(Iterable[str]):
+ commenters: str
+ wordchars: str
+ whitespace: str
+ escape: str
+ quotes: str
+ escapedquotes: str
+ whitespace_split: bool
+ infile: str
+ instream: TextIO
+ source: str
+ debug: int
+ lineno: int
+ token: str
+ eof: str
+ if sys.version_info >= (3, 6):
+ punctuation_chars: str
+
+ if sys.version_info >= (3, 6):
+ def __init__(self, instream: Union[str, TextIO] = ..., infile: Optional[str] = ...,
+ posix: bool = ..., punctuation_chars: Union[bool, str] = ...) -> None: ...
+ else:
+ def __init__(self, instream: Union[str, TextIO] = ..., infile: Optional[str] = ...,
+ posix: bool = ...) -> None: ...
+ def get_token(self) -> Tuple[int, int, str]: ...
+ def push_token(self, tok: str) -> None: ...
+ def read_token(self) -> str: ...
+ def sourcehook(self, filename: str) -> Tuple[str, TextIO]: ...
+ # TODO argument types
+ def push_source(self, newstream: Any, newfile: Any = ...) -> None: ...
+ def pop_source(self) -> None: ...
+ def error_leader(self, infile: str = ...,
+ lineno: int = ...) -> None: ...
+ def __iter__(self: _SLT) -> _SLT: ...
+ def __next__(self) -> str: ...
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 bedf477b..a52ea23e 100644
--- a/poezio/roster.py
+++ b/poezio/roster.py
@@ -3,12 +3,13 @@
# 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
"""
import logging
-log = logging.getLogger(__name__)
+
+from typing import List
from poezio.config import config
from poezio.contact import Contact
@@ -16,9 +17,10 @@ from poezio.roster_sorting import SORTING_METHODS, GROUP_SORTING_METHODS
from os import path as p
from datetime import datetime
-from poezio.common import safeJID
from slixmpp.exceptions import IqError, IqTimeout
+from slixmpp import JID, InvalidJID
+log = logging.getLogger(__name__)
class Roster:
"""
@@ -29,6 +31,22 @@ class Roster:
DEFAULT_FILTER = (lambda x, y: None, None)
def __init__(self):
+ self.__node = None
+
+ # A tuple(function, *args) function to filter contacts
+ # on search, for example
+ self.contact_filter = self.DEFAULT_FILTER
+ self.groups = {}
+ self.contacts = {}
+ self.length = 0
+ self.connected = 0
+ self.folded_groups = []
+
+ # Used for caching roster infos
+ self.last_built = datetime.now()
+ self.last_modified = datetime.now()
+
+ def reset(self):
"""
node: the RosterSingle from slixmpp
"""
@@ -38,7 +56,8 @@ class Roster:
# on search, for example
self.contact_filter = self.DEFAULT_FILTER
self.folded_groups = set(
- config.get('folded_roster_groups', section='var').split(':'))
+ config.getlist('folded_roster_groups', section='var')
+ )
self.groups = {}
self.contacts = {}
self.length = 0
@@ -52,12 +71,15 @@ 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):
"""Get a Contact from his bare JID"""
- key = safeJID(key).bare
+ try:
+ key = JID(key).bare
+ except InvalidJID:
+ return None
if key in self.contacts and self.contacts[key] is not None:
return self.contacts[key]
if key in self.jids():
@@ -71,7 +93,10 @@ class Roster:
def remove(self, jid):
"""Send a removal iq to the server"""
- jid = safeJID(jid).bare
+ try:
+ jid = JID(jid).bare
+ except InvalidJID:
+ return
if self.__node[jid]:
try:
self.__node[jid].send_presence(ptype='unavailable')
@@ -81,7 +106,10 @@ class Roster:
def __delitem__(self, jid):
"""Remove a contact from the roster view"""
- jid = safeJID(jid).bare
+ try:
+ jid = JID(jid).bare
+ except InvalidJID:
+ return
contact = self[jid]
if not contact:
return
@@ -99,10 +127,13 @@ class Roster:
def __contains__(self, key):
"""True if the bare jid is in the roster, false otherwise"""
- return safeJID(key).bare in self.jids()
+ try:
+ return JID(key).bare in self.jids()
+ except InvalidJID:
+ return False
@property
- def jid(self):
+ def jid(self) -> JID:
"""Our JID"""
return self.__node.jid
@@ -143,7 +174,7 @@ class Roster:
"""Subscribe to a jid"""
self.__node.subscribe(jid)
- def jids(self):
+ def jids(self) -> List[JID]:
"""List of the contact JIDS"""
l = []
for key in self.__node.keys():
@@ -335,11 +366,6 @@ class RosterGroup:
return len([1 for contact in self.contacts if len(contact)])
-def create_roster():
- "Create the global roster object"
- global roster
- roster = Roster()
-
# Shared roster object
-roster = None
+roster = Roster()
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/adhoc_commands_list.py b/poezio/tabs/adhoc_commands_list.py
index b62166b0..3b6bc1db 100644
--- a/poezio/tabs/adhoc_commands_list.py
+++ b/poezio/tabs/adhoc_commands_list.py
@@ -16,8 +16,8 @@ log = logging.getLogger(__name__)
class AdhocCommandsListTab(ListTab):
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core, jid):
ListTab.__init__(
diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py
index 73db87f2..793eae62 100644
--- a/poezio/tabs/basetabs.py
+++ b/poezio/tabs/basetabs.py
@@ -13,38 +13,57 @@ This module also defines ChatTabs, the parent class for all tabs
revolving around chats.
"""
-import copy
+from __future__ import annotations
+
import logging
import string
import asyncio
-import time
+from copy import copy
from math import ceil, log10
from datetime import datetime
from xml.etree import ElementTree as ET
+from xml.sax import SAXParseException
from typing import (
Any,
Callable,
+ cast,
Dict,
List,
Optional,
Union,
+ Tuple,
TYPE_CHECKING,
)
-from poezio import mam, poopt, timed_events, xhtml, windows
+from poezio import (
+ poopt,
+ timed_events,
+ xhtml,
+ windows
+)
from poezio.core.structs import Command, Completion, Status
-from poezio.common import safeJID
from poezio.config import config
from poezio.decorators import command_args_parser, refresh_wrapper
from poezio.logger import logger
+from poezio.log_loader import MAMFiller, LogLoader
from poezio.text_buffer import TextBuffer
from poezio.theming import get_theme, dump_tuple
-from poezio.windows.funcs import truncate_nick
+from poezio.user import User
+from poezio.ui.funcs import truncate_nick
+from poezio.timed_events import DelayedEvent
+from poezio.ui.types import (
+ BaseMessage,
+ Message,
+ PersistentInfoMessage,
+ LoggableTrait,
+)
-from slixmpp import JID, InvalidJID, Message
+from slixmpp import JID, InvalidJID, Message as SMessage
if TYPE_CHECKING:
from _curses import _CursesWindow # pylint: disable=E0611
+ from poezio.size_manager import SizeManager
+ from poezio.core.core import Core
log = logging.getLogger(__name__)
@@ -102,29 +121,42 @@ SHOW_NAME = {
class Tab:
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
# Placeholder values, set on resize
- height = 1
- width = 1
-
- def __init__(self, core):
+ height: int = 1
+ width: int = 1
+ core: Core
+ input: Optional[windows.Input]
+ key_func: Dict[str, Callable[[], Any]]
+ commands: Dict[str, Command]
+ need_resize: bool
+ ui_config_changed: bool
+
+ def __init__(self, core: Core):
self.core = core
self.nb = 0
- if not hasattr(self, 'name'):
- self.name = self.__class__.__name__
+ if not hasattr(self, '_name'):
+ self._name = self.__class__.__name__
self.input = None
self.closed = False
self._state = 'normal'
self._prev_state = None
self.need_resize = False
+ self.ui_config_changed = False
self.key_func = {} # each tab should add their keys in there
# and use them in on_input
self.commands = {} # and their own commands
@property
- def size(self) -> int:
+ def name(self) -> str:
+ if hasattr(self, '_name'):
+ return self._name
+ return ''
+
+ @property
+ def size(self) -> SizeManager:
return self.core.size
@staticmethod
@@ -133,23 +165,27 @@ class Tab:
Returns 1 or 0, depending on if we are using the vertical tab list
or not.
"""
- if config.get('enable_vertical_tab_list'):
+ if config.getbool('enable_vertical_tab_list'):
return 0
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
+ def priority(self) -> Union[int, float]:
+ return STATE_PRIORITY.get(self._state, -1)
+
+ @property
def state(self) -> str:
return self._state
@@ -187,7 +223,7 @@ class Tab:
self._state = 'normal'
@staticmethod
- def resize(scr: '_CursesWindow'):
+ def initial_resize(scr: _CursesWindow):
Tab.height, Tab.width = scr.getmaxyx()
windows.base_wins.TAB_WIN = scr
@@ -224,7 +260,7 @@ class Tab:
*,
desc='',
shortdesc='',
- completion: Optional[Callable] = None,
+ completion: Optional[Callable[[windows.Input], Completion]] = None,
usage=''):
"""
Add a command
@@ -276,7 +312,6 @@ class Tab:
comp = command.comp(the_input)
if comp:
return comp.run()
- return comp
return False
def execute_command(self, provided_text: str) -> bool:
@@ -284,8 +319,10 @@ class Tab:
Execute the command in the input and return False if
the input didn't contain a command
"""
+ if self.input is None:
+ raise NotImplementedError
txt = provided_text or self.input.key_enter()
- if txt.startswith('/') and not txt.startswith('//') and\
+ if txt and txt.startswith('/') and not txt.startswith('//') and\
not txt.startswith('/me '):
command = txt.strip().split()[0][1:]
arg = txt[2 + len(command):] # jump the '/' and the ' '
@@ -313,13 +350,16 @@ class Tab:
if func:
if hasattr(self.input, "reset_completion"):
self.input.reset_completion()
- func(arg)
+ if asyncio.iscoroutinefunction(func):
+ asyncio.create_task(func(arg))
+ else:
+ func(arg)
return True
else:
return False
- def refresh_tab_win(self):
- if config.get('enable_vertical_tab_list'):
+ def refresh_tab_win(self) -> None:
+ if config.getbool('enable_vertical_tab_list'):
left_tab_win = self.core.left_tab_win
if left_tab_win and not self.size.core_degrade_x:
left_tab_win.refresh()
@@ -350,24 +390,18 @@ 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.
"""
pass
- def update_commands(self):
+ def update_commands(self) -> None:
for c in self.plugin_commands:
if c not in self.commands:
self.commands[c] = self.plugin_commands[c]
- def update_keys(self):
+ def update_keys(self) -> None:
for k in self.plugin_keys:
if k not in self.key_func:
self.key_func[k] = self.plugin_keys[k]
@@ -426,7 +460,7 @@ class Tab:
"""
pass
- def on_close(self):
+ def on_close(self) -> None:
"""
Called when the tab is to be closed
"""
@@ -434,7 +468,7 @@ class Tab:
self.input.on_delete()
self.closed = True
- def matching_names(self) -> List[str]:
+ def matching_names(self) -> List[Tuple[int, str]]:
"""
Returns a list of strings that are used to name a tab with the /win
command. For example you could switch to a tab that returns
@@ -448,6 +482,9 @@ class Tab:
class GapTab(Tab):
+ def __init__(self):
+ return
+
def __bool__(self):
return False
@@ -455,7 +492,7 @@ class GapTab(Tab):
return 0
@property
- def name(self):
+ def name(self) -> str:
return ''
def refresh(self):
@@ -470,9 +507,14 @@ class ChatTab(Tab):
Also, ^M is already bound to on_enter
And also, add the /say command
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
+ last_sent_message: Optional[SMessage]
message_type = 'chat'
+ 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)
@@ -483,19 +525,19 @@ class ChatTab(Tab):
self._jid = jid
#: Is the tab currently requesting MAM data?
self.query_status = False
- self.last_stanza_id = None
-
- self._name = jid.full # type: Optional[str]
- self.text_win = None
+ self._name = jid.full
+ self.text_win = windows.TextWin()
self.directed_presence = None
self._text_buffer = TextBuffer()
+ self._text_buffer.add_window(self.text_win)
+ self.mam_filler = None
self.chatstate = None # can be "active", "composing", "paused", "gone", "inactive"
# We keep a reference of the event that will set our chatstate to "paused", so that
# we can delete it or change it if we need to
self.timed_event_paused = None
self.timed_event_not_paused = None
# Keeps the last sent message to complete it easily in completion_correct, and to replace it.
- self.last_sent_message = {}
+ self.last_sent_message = None
self.key_func['M-v'] = self.move_separator
self.key_func['M-h'] = self.scroll_separator
self.key_func['M-/'] = self.last_words_completion
@@ -524,7 +566,7 @@ class ChatTab(Tab):
desc='Fix the last message with whatever you want.',
shortdesc='Correct the last message.',
completion=self.completion_correct)
- self.chat_state = None
+ self.chat_state: Optional[str] = None
self.update_commands()
self.update_keys()
@@ -544,13 +586,18 @@ class ChatTab(Tab):
if value.domain:
self._jid = value
except InvalidJID:
- self._name = value
+ self._name = str(value)
else:
raise TypeError("Name %r must be of type JID or str." % value)
@property
+ def log_name(self) -> str:
+ """Name used for the log filename"""
+ return self.jid.bare
+
+ @property
def jid(self) -> JID:
- return copy.copy(self._jid)
+ return copy(self._jid)
@jid.setter
def jid(self, value: JID) -> None:
@@ -563,53 +610,35 @@ class ChatTab(Tab):
def general_jid(self) -> JID:
raise NotImplementedError
- def log_message(self,
- txt: str,
- nickname: str,
- time: Optional[datetime] = None,
- typ=1):
+ def log_message(self, message: BaseMessage):
"""
Log the messages in the archives.
"""
- name = self.jid.bare
- if not logger.log_message(name, nickname, txt, date=time, typ=typ):
+ if not isinstance(message, LoggableTrait):
+ return
+ if not logger.log_message(self.log_name, message):
self.core.information('Unable to write in the log file', 'Error')
- def add_message(self,
- txt,
- time=None,
- nickname=None,
- forced_user=None,
- nick_color=None,
- identifier=None,
- jid=None,
- history=None,
- typ=1,
- highlight=False):
- self.log_message(txt, nickname, time=time, typ=typ)
- self._text_buffer.add_message(
- txt,
- time=time,
- nickname=nickname,
- highlight=highlight,
- nick_color=nick_color,
- history=history,
- user=forced_user,
- identifier=identifier,
- jid=jid)
+ def add_message(self, message: BaseMessage):
+ self.log_message(message)
+ self._text_buffer.add_message(message)
def modify_message(self,
- txt,
- old_id,
- new_id,
- user=None,
- jid=None,
- nickname=None):
- self.log_message(txt, nickname, typ=1)
+ txt: str,
+ old_id: str,
+ new_id: str,
+ time: Optional[datetime],
+ delayed: bool = False,
+ nickname: Optional[str] = None,
+ user: Optional[User] = None,
+ jid: Optional[JID] = None,
+ ) -> bool:
message = self._text_buffer.modify_message(
- txt, old_id, new_id, time=time, user=user, jid=jid)
+ txt, old_id, new_id, user=user, jid=jid, time=time
+ )
if message:
- self.text_win.modify_message(old_id, message)
+ self.log_message(message)
+ self.text_win.modify_message(message.identifier, message)
self.core.refresh_window()
return True
return False
@@ -630,16 +659,20 @@ class ChatTab(Tab):
for word in txt.split():
if len(word) >= 4 and word not in words:
words.append(word)
- words.extend([word for word in config.get('words').split(':') if word])
+ words.extend([word for word in config.getlist('words') if word])
self.input.auto_completion(words, ' ', quotify=False)
def on_enter(self):
+ if self.input is None:
+ raise NotImplementedError
txt = self.input.key_enter()
if txt:
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
@@ -651,19 +684,19 @@ class ChatTab(Tab):
if message:
message.send()
- def generate_xhtml_message(self, arg: str) -> Message:
+ def generate_xhtml_message(self, arg: str) -> Optional[SMessage]:
if not arg:
- return
+ return None
try:
body = xhtml.clean_text(
xhtml.xhtml_to_poezio_colors(arg, force=True))
ET.fromstring(arg)
- except:
+ except SAXParseException:
self.core.information('Could not send custom xhtml', 'Error')
- log.error('/xhtml: Unable to send custom xhtml', exc_info=True)
- return
+ log.error('/xhtml: Unable to send custom xhtml')
+ return None
- msg = self.core.xmpp.make_message(self.get_dest_jid())
+ msg: SMessage = self.core.xmpp.make_message(self.get_dest_jid())
msg['body'] = body
msg.enable('html')
msg['html']['body'] = arg
@@ -680,27 +713,31 @@ class ChatTab(Tab):
self._text_buffer.messages = []
self.text_win.rebuild_everything(self._text_buffer)
- def check_send_chat_state(self):
+ def check_send_chat_state(self) -> bool:
"If we should send a chat state"
return True
- def send_chat_state(self, state, always_send=False):
+ def send_chat_state(self, state: str, always_send: bool = False) -> None:
"""
Send an empty chatstate message
"""
+ from poezio.tabs import PrivateTab
+
if self.check_send_chat_state():
if state in ('active', 'inactive',
'gone') and self.inactive and not always_send:
return
if config.get_by_tabname('send_chat_states', self.general_jid):
- msg = self.core.xmpp.make_message(self.get_dest_jid())
+ msg: SMessage = self.core.xmpp.make_message(self.get_dest_jid())
msg['type'] = self.message_type
msg['chat_state'] = state
self.chat_state = state
+ msg['no-store'] = True
+ if isinstance(self, PrivateTab):
+ msg.enable('muc')
msg.send()
- return True
- def send_composing_chat_state(self, empty_after):
+ def send_composing_chat_state(self, empty_after: bool) -> None:
"""
Send the "active" or "composing" chatstate, depending
on the the current status of the input
@@ -736,7 +773,7 @@ class ChatTab(Tab):
self.core.add_timed_event(new_event)
self.timed_event_not_paused = new_event
- def cancel_paused_delay(self):
+ def cancel_paused_delay(self) -> None:
"""
Remove that event from the list and set it to None.
Called for example when the input is emptied, or when the message
@@ -745,11 +782,22 @@ class ChatTab(Tab):
if self.timed_event_paused is not None:
self.core.remove_timed_event(self.timed_event_paused)
self.timed_event_paused = None
- self.core.remove_timed_event(self.timed_event_not_paused)
- self.timed_event_not_paused = None
+ if self.timed_event_not_paused is not None:
+ self.core.remove_timed_event(self.timed_event_not_paused)
+ self.timed_event_not_paused = None
+
+ def set_last_sent_message(self, msg: SMessage, correct: bool = False) -> None:
+ """Ensure last_sent_message is set with the correct attributes"""
+ if correct:
+ # XXX: Is the copy needed. Is the object passed here reused
+ # afterwards? Who knows.
+ msg = cast(SMessage, copy(msg))
+ if self.last_sent_message is not None:
+ msg['id'] = self.last_sent_message['id']
+ self.last_sent_message = msg
@command_args_parser.raw
- def command_correct(self, line):
+ async def command_correct(self, line: str) -> None:
"""
/correct <fixed message>
"""
@@ -759,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:
@@ -772,32 +820,37 @@ class ChatTab(Tab):
@property
def inactive(self) -> bool:
"""Whether we should send inactive or active as a chatstate"""
- return self.core.status.show in ('xa', 'away') or\
- (hasattr(self, 'directed_presence') and not self.directed_presence)
+ return self.core.status.show in ('xa', 'away') or (
+ hasattr(self, 'directed_presence')
+ and self.directed_presence is not None
+ and self.directed_presence
+ )
- def move_separator(self):
+ def move_separator(self) -> None:
self.text_win.remove_line_separator()
self.text_win.add_line_separator(self._text_buffer)
self.text_win.refresh()
- self.input.refresh()
+ if self.input:
+ self.input.refresh()
def get_conversation_messages(self):
return self._text_buffer.messages
- def check_scrolled(self):
+ def check_scrolled(self) -> None:
if self.text_win.pos != 0:
self.state = 'scrolled'
@command_args_parser.raw
- def command_say(self, line, correct=False):
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False):
pass
def goto_build_lines(self, new_date):
text_buffer = self._text_buffer
built_lines = []
message_count = 0
- timestamp = config.get('show_timestamps')
- nick_size = config.get('max_nick_length')
+ timestamp = config.getbool('show_timestamps')
+ nick_size = config.getint('max_nick_length')
+ theme = get_theme()
for message in text_buffer.messages:
# Build lines of a message
txt = message.txt
@@ -816,12 +869,8 @@ class ChatTab(Tab):
if message.me:
offset += 1
if timestamp:
- if message.str_time:
- offset += 1 + len(message.str_time)
- if theme.CHAR_TIME_LEFT and message.str_time:
- offset += 1
- if theme.CHAR_TIME_RIGHT and message.str_time:
- offset += 1
+ if message.history:
+ offset += 1 + theme.LONG_TIME_FORMAT_LENGTH
lines = poopt.cut_text(txt, self.text_win.width - offset - 1)
for line in lines:
built_lines.append(line)
@@ -926,7 +975,10 @@ class ChatTab(Tab):
def on_scroll_up(self):
if not self.query_status:
- asyncio.ensure_future(mam.on_scroll_up(tab=self))
+ from poezio.log_loader import LogLoader
+ asyncio.create_task(
+ LogLoader(logger, self, config.getbool('use_log')).scroll_requested()
+ )
return self.text_win.scroll_up(self.text_win.height - 1)
def on_scroll_down(self):
@@ -944,15 +996,15 @@ class ChatTab(Tab):
class OneToOneTab(ChatTab):
- def __init__(self, core, jid):
+ def __init__(self, core, jid, initial=None):
ChatTab.__init__(self, core, jid)
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
- self.check_features()
self.register_command(
'unquery', self.command_unquery, shortdesc='Close the tab.')
self.register_command(
@@ -964,6 +1016,30 @@ class OneToOneTab(ChatTab):
shortdesc='Request the attention.',
desc='Attention: Request the attention of the contact. Can also '
'send a message along with the attention.')
+ asyncio.create_task(self.init_logs(initial=initial))
+
+ 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:
+ limit = config.get_by_tabname('mam_sync_limit', self.jid)
+ mam_filler = MAMFiller(logger, self, limit)
+ self.mam_filler = mam_filler
+
+ 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
+ await mam_filler.done.wait()
+ if mam_filler.result == 0:
+ await self.handle_message(initial)
+ elif use_log and initial:
+ 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()
+
+ async def handle_message(self, msg: SMessage, display: bool = True):
+ pass
def remote_user_color(self):
return dump_tuple(get_theme().COLOR_REMOTE_USER)
@@ -990,7 +1066,9 @@ class OneToOneTab(ChatTab):
msg += 'status: %s, ' % status.message
if status.show in SHOW_NAME:
msg += 'show: %s, ' % SHOW_NAME[status.show]
- self.add_message(msg[:-2], typ=2)
+ self.add_message(
+ PersistentInfoMessage(txt=msg[:-2])
+ )
def ack_message(self, msg_id: str, msg_jid: JID):
"""
@@ -1022,26 +1100,21 @@ class OneToOneTab(ChatTab):
message.send()
body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True)
self._text_buffer.add_message(
- body,
- nickname=self.core.own_nick,
- nick_color=get_theme().COLOR_OWN_NICK,
- identifier=message['id'],
- jid=self.core.xmpp.boundjid)
+ Message(
+ body,
+ nickname=self.core.own_nick,
+ nick_color=get_theme().COLOR_OWN_NICK,
+ identifier=message['id'],
+ jid=self.core.xmpp.boundjid,
+ )
+ )
self.refresh()
- def check_features(self):
- "check the features supported by the other party"
- if safeJID(self.get_dest_jid()).resource:
- self.core.xmpp.plugin['xep_0030'].get_info(
- jid=self.get_dest_jid(),
- timeout=5,
- callback=self.features_checked)
-
@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'
@@ -1049,7 +1122,7 @@ class OneToOneTab(ChatTab):
msg.send()
@command_args_parser.raw
- def command_say(self, line, correct=False, attention=False):
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False):
pass
@command_args_parser.ignored
@@ -1073,7 +1146,3 @@ class OneToOneTab(ChatTab):
msg = msg % (self.name, feature, command_name)
self.core.information(msg, 'Info')
return True
-
- def features_checked(self, iq):
- "Features check callback"
- features = iq['disco_info'].get_features() or []
diff --git a/poezio/tabs/bookmarkstab.py b/poezio/tabs/bookmarkstab.py
index cb3a4d0c..d21b5630 100644
--- a/poezio/tabs/bookmarkstab.py
+++ b/poezio/tabs/bookmarkstab.py
@@ -2,16 +2,18 @@
Defines the data-forms Tab
"""
+import asyncio
import logging
from typing import Dict, Callable, List
+from slixmpp.exceptions import IqError, IqTimeout
+
from poezio import windows
from poezio.bookmarks import Bookmark, BookmarkList
from poezio.core.structs import Command
from poezio.tabs import Tab
-from poezio.common import safeJID
-from slixmpp import JID
+from slixmpp import JID, InvalidJID
log = logging.getLogger(__name__)
@@ -21,20 +23,19 @@ class BookmarksTab(Tab):
A tab displaying lines of bookmarks, each bookmark having
a 4 widgets to set the jid/password/autojoin/storage method
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core, bookmarks: BookmarkList):
Tab.__init__(self, core)
- self.name = "Bookmarks"
+ self._name = "Bookmarks"
self.bookmarks = bookmarks
- self.new_bookmarks = [] # type: List[Bookmark]
- self.removed_bookmarks = [] # type: List[Bookmark]
+ self.new_bookmarks: List[Bookmark] = []
+ self.removed_bookmarks: List[Bookmark] = []
self.header_win = windows.ColumnHeaderWin(
- ('name', 'room@server/nickname', 'password', 'autojoin',
- 'storage'))
- self.bookmarks_win = windows.BookmarksWin(
- self.bookmarks, self.height - 4, self.width, 1, 0)
+ ['name', 'room@server/nickname', 'password', 'autojoin',
+ 'storage'])
+ self.bookmarks_win = windows.BookmarksWin(self.bookmarks)
self.help_win = windows.HelpText('Ctrl+Y: save, Ctrl+G: cancel, '
'↑↓: change lines, tab: change '
'column, M-a: add bookmark, C-k'
@@ -80,26 +81,31 @@ class BookmarksTab(Tab):
'Duplicate bookmarks in list (saving aborted)', 'Error')
return
for bm in self.new_bookmarks:
- if safeJID(bm.jid):
+ try:
+ JID(bm.jid)
if not self.bookmarks[bm.jid]:
self.bookmarks.append(bm)
- else:
+ except InvalidJID:
self.core.information(
'Invalid JID for bookmark: %s/%s' % (bm.jid, bm.nick),
'Error')
return
+
for bm in self.removed_bookmarks:
if bm in self.bookmarks:
self.bookmarks.remove(bm)
- def send_cb(success):
- if success:
- self.core.information('Bookmarks saved.', 'Info')
- else:
- self.core.information('Remote bookmarks not saved.', 'Error')
+ asyncio.create_task(
+ self.save_routine()
+ )
- self.bookmarks.save(self.core.xmpp, callback=send_cb)
+ async def save_routine(self):
+ try:
+ await self.bookmarks.save(self.core.xmpp)
+ self.core.information('Bookmarks saved', 'Info')
+ except (IqError, IqTimeout):
+ self.core.information('Remote bookmarks not saved.', 'Error')
self.core.close_tab(self)
return True
@@ -110,7 +116,7 @@ class BookmarksTab(Tab):
return res
self.bookmarks_win.refresh_current_input()
else:
- self.bookmarks_win.on_input(key)
+ self.bookmarks_win.on_input(key, raw=raw)
def resize(self):
self.need_resize = False
diff --git a/poezio/tabs/confirmtab.py b/poezio/tabs/confirmtab.py
index c13de4a6..d7488de7 100644
--- a/poezio/tabs/confirmtab.py
+++ b/poezio/tabs/confirmtab.py
@@ -13,8 +13,8 @@ log = logging.getLogger(__name__)
class ConfirmTab(Tab):
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self,
core,
@@ -34,7 +34,7 @@ class ConfirmTab(Tab):
"""
Tab.__init__(self, core)
self.state = 'highlight'
- self.name = name
+ self._name = name
self.default_help_message = windows.HelpText(
"Choose with arrow keys and press enter")
self.input = self.default_help_message
diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py
index 39411872..de1f988a 100644
--- a/poezio/tabs/conversationtab.py
+++ b/poezio/tabs/conversationtab.py
@@ -11,23 +11,27 @@ There are two different instances of a ConversationTab:
the time.
"""
+import asyncio
import curses
import logging
+from datetime import datetime
from typing import Dict, Callable
+from slixmpp import JID, InvalidJID, Message as SMessage
+
from poezio.tabs.basetabs import OneToOneTab, Tab
from poezio import common
from poezio import windows
from poezio import xhtml
-from poezio.common import safeJID
-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
from poezio.roster import roster
-from poezio.text_buffer import CorrectionError
from poezio.theming import get_theme, dump_tuple
from poezio.decorators import command_args_parser
+from poezio.ui.types import InfoMessage, Message
+from poezio.text_buffer import CorrectionError
log = logging.getLogger(__name__)
@@ -37,18 +41,16 @@ class ConversationTab(OneToOneTab):
The tab containing a normal conversation (not from a MUC)
Must not be instantiated, use Static or Dynamic version only.
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
- additional_information = {} # type: Dict[str, Callable[[str], str]]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
+ additional_information: Dict[str, Callable[[str], str]] = {}
message_type = 'chat'
- def __init__(self, core, jid):
- OneToOneTab.__init__(self, core, jid)
+ def __init__(self, core, jid, initial=None):
+ OneToOneTab.__init__(self, core, jid, initial=initial)
self.nick = None
self.nick_sent = False
self.state = 'normal'
- self.text_win = windows.TextWin()
- self._text_buffer.add_window(self.text_win)
self.upper_bar = windows.ConversationStatusMessageWin()
self.input = windows.MessageInput()
# keys
@@ -81,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
@@ -103,10 +105,85 @@ class ConversationTab(OneToOneTab):
def completion(self):
self.complete_commands(self.input)
+ 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']
+ jid = conv_jid
+ color = get_theme().COLOR_REMOTE_USER
+ self.last_remote_message = datetime.now()
+ remote_nick = self.get_nick()
+ # we wrote the message (happens with carbons)
+ elif message['from'].bare == self.core.xmpp.boundjid.bare:
+ conv_jid = message['to']
+ jid = self.core.xmpp.boundjid
+ color = get_theme().COLOR_OWN_NICK
+ remote_nick = self.core.own_nick
+ # we are not part of that message, drop it
+ else:
+ return
+
+ await self.core.events.trigger_async('conversation_msg', message, self)
+
+ if not message['body']:
+ return
+ body = xhtml.get_body_from_message_stanza(
+ message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
+ delayed, date = common.find_delayed_tag(message)
+
+ replaced = False
+ if message.get_plugin('replace', check=True):
+ replaced_id = message['replace']['id']
+ if replaced_id and config.get_by_tabname('group_corrections',
+ conv_jid.bare):
+ try:
+ replaced = self.modify_message(
+ body,
+ replaced_id,
+ message['id'],
+ time=date,
+ jid=jid,
+ nickname=remote_nick)
+ except CorrectionError:
+ log.debug('Unable to correct the message: %s', message)
+ if not replaced:
+ msg = Message(
+ txt=body,
+ time=date,
+ nickname=remote_nick,
+ nick_color=color,
+ history=delayed,
+ identifier=message['id'],
+ jid=jid,
+ )
+ if display:
+ self.add_message(msg)
+ else:
+ self.log_message(msg)
+
@refresh_wrapper.always
@command_args_parser.raw
- def command_say(self, line, attention=False, correct=False):
- msg = self.core.xmpp.make_message(
+ 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
)
@@ -122,9 +199,8 @@ 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']
+ msg['replace']['id'] = self.last_sent_message['id'] # type: ignore
else:
del msg['replace']
if msg['body'].find('\x19') != -1:
@@ -132,17 +208,20 @@ class ConversationTab(OneToOneTab):
msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body'])
msg['body'] = xhtml.clean_text(msg['body'])
if config.get_by_tabname('send_chat_states', self.general_jid):
- needed = 'inactive' if self.inactive else 'active'
- msg['chat_state'] = needed
+ if self.inactive:
+ self.send_chat_state('inactive', always_send=True)
+ else:
+ msg['chat_state'] = 'active'
if attention:
msg['attention'] = True
self.core.events.trigger('conversation_say_after', msg, self)
if not msg['body']:
return
- self.last_sent_message = msg
- self.core.handler.on_normal_message(msg)
- msg._add_receipt = True
+ self.set_last_sent_message(msg, correct=correct)
+ 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)
@@ -167,7 +246,13 @@ class ConversationTab(OneToOneTab):
status = iq['last_activity']['status']
from_ = iq['from']
msg = '\x19%s}The last activity of %s was %s ago%s'
- if not safeJID(from_).user:
+ user = ''
+ try:
+ user = JID(from_).user
+ except InvalidJID:
+ pass
+
+ if not user:
msg = '\x19%s}The uptime of %s is %s.' % (
dump_tuple(get_theme().COLOR_INFORMATION_TEXT), from_,
common.parse_secs_to_str(seconds))
@@ -179,7 +264,7 @@ class ConversationTab(OneToOneTab):
(' and their last status was %s' % status)
if status else '',
)
- self.add_message(msg)
+ self.add_message(InfoMessage(msg))
self.core.refresh_window()
self.core.xmpp.plugin['xep_0012'].get_last_activity(
@@ -189,7 +274,10 @@ class ConversationTab(OneToOneTab):
@command_args_parser.ignored
def command_info(self):
contact = roster[self.get_dest_jid()]
- jid = safeJID(self.get_dest_jid())
+ try:
+ jid = JID(self.get_dest_jid())
+ except InvalidJID:
+ jid = JID('')
if contact:
if jid.resource:
resource = contact[jid.full]
@@ -198,35 +286,29 @@ class ConversationTab(OneToOneTab):
else:
resource = None
if resource:
- status = (
- 'Status: %s' % resource.status) if resource.status else ''
- self._text_buffer.add_message(
- "\x19%(info_col)s}Show: %(show)s, %(status)s\x19o" % {
- 'show': resource.presence or 'available',
- 'status': status,
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- })
- return True
- else:
- self._text_buffer.add_message(
- "\x19%(info_col)s}No information available\x19o" %
- {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)})
+ 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"),
+ )
+ return True
@command_args_parser.quoted(0, 1)
- def command_version(self, args):
+ async def command_version(self, args):
"""
/version [jid]
"""
if args:
- return self.core.command.version(args[0])
+ return await self.core.command.version(args[0])
jid = self.jid
if not jid.resource:
if jid in roster:
resource = roster[jid].get_highest_priority_resource()
jid = resource.jid if resource else jid
- self.core.xmpp.plugin['xep_0092'].get_version(
- jid, callback=self.core.handler.on_version_result)
+ iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid)
+ self.core.handler.on_version_result(iq)
def resize(self):
self.need_resize = False
@@ -243,8 +325,10 @@ class ConversationTab(OneToOneTab):
self.text_win.resize(
self.height - 2 - bar_height - info_win_height - tab_win_height,
- self.width, bar_height, 0)
- self.text_win.rebuild_everything(self._text_buffer)
+ self.width, bar_height, 0, self._text_buffer,
+ force=self.ui_config_changed
+ )
+ self.ui_config_changed = False
if display_bar:
self.upper_bar.resize(1, self.width, 0, 0)
self.get_info_header().resize(
@@ -285,7 +369,7 @@ class ConversationTab(OneToOneTab):
else:
if self.nick:
return self.nick
- return self.jid.user
+ return self.jid.user or self.jid.domain
def on_input(self, key, raw):
if not raw and key in self.key_func:
@@ -300,7 +384,10 @@ class ConversationTab(OneToOneTab):
def on_lose_focus(self):
contact = roster[self.get_dest_jid()]
- jid = safeJID(self.get_dest_jid())
+ try:
+ jid = JID(self.get_dest_jid())
+ except InvalidJID:
+ jid = JID('')
if contact:
if jid.resource:
resource = contact[jid.full]
@@ -321,7 +408,10 @@ class ConversationTab(OneToOneTab):
def on_gain_focus(self):
contact = roster[self.get_dest_jid()]
- jid = safeJID(self.get_dest_jid())
+ try:
+ jid = JID(self.get_dest_jid())
+ except InvalidJID:
+ jid = JID('')
if contact:
if jid.resource:
resource = contact[jid.full]
@@ -348,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):
@@ -374,12 +461,12 @@ class DynamicConversationTab(ConversationTab):
bad idea so it has been removed.
Only one DynamicConversationTab can be opened for a given jid.
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
- def __init__(self, core, jid, resource=None):
+ def __init__(self, core, jid, initial=None):
self.locked_resource = None
- ConversationTab.__init__(self, core, jid)
+ ConversationTab.__init__(self, core, jid, initial=initial)
self.jid.resource = None
self.info_header = windows.DynamicConversationInfoWin()
self.register_command(
@@ -444,16 +531,20 @@ class StaticConversationTab(ConversationTab):
A conversation tab associated with one Full JID. It cannot be locked to
an different resource or unlocked.
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
- def __init__(self, core, jid):
- ConversationTab.__init__(self, core, jid)
+ def __init__(self, core, jid, initial=None):
+ ConversationTab.__init__(self, core, jid, initial=initial)
assert jid.resource
self.info_header = windows.ConversationInfoWin()
self.resize()
self.update_commands()
self.update_keys()
+ async def init_logs(self, initial=None) -> None:
+ # Disable local logs because…
+ pass
+
def get_info_header(self):
return self.info_header
diff --git a/poezio/tabs/data_forms.py b/poezio/tabs/data_forms.py
index f4ed63e5..8e13a84c 100644
--- a/poezio/tabs/data_forms.py
+++ b/poezio/tabs/data_forms.py
@@ -17,8 +17,8 @@ class DataFormsTab(Tab):
A tab containing various window type, displaying
a form that the user needs to fill.
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core, form, on_cancel, on_send, kwargs):
Tab.__init__(self, core)
diff --git a/poezio/tabs/listtab.py b/poezio/tabs/listtab.py
index 87e7d9f4..049f7076 100644
--- a/poezio/tabs/listtab.py
+++ b/poezio/tabs/listtab.py
@@ -18,8 +18,8 @@ log = logging.getLogger(__name__)
class ListTab(Tab):
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core, name, help_message, header_text, cols):
"""Parameters:
@@ -34,7 +34,7 @@ class ListTab(Tab):
Tab.__init__(self, core)
self.state = 'normal'
self._error_message = ''
- self.name = name
+ self._name = name
columns = collections.OrderedDict()
for col, num in cols:
columns[col] = num
diff --git a/poezio/tabs/muclisttab.py b/poezio/tabs/muclisttab.py
index 4c1e492f..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
@@ -20,8 +21,8 @@ class MucListTab(ListTab):
A tab listing rooms from a specific server, displaying various information,
scrollable, and letting the user join them, etc
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core, server):
ListTab.__init__(self, core, server.full, "“j”: join room.",
@@ -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 3985a6c7..e2d546c9 100644
--- a/poezio/tabs/muctab.py
+++ b/poezio/tabs/muctab.py
@@ -7,88 +7,124 @@ It keeps track of many things such as part/joins, maintains an
user list, and updates private tabs when necessary.
"""
+from __future__ import annotations
+
import asyncio
import bisect
import curses
import logging
-import asyncio
import os
import random
import re
import functools
from copy import copy
+from dataclasses import dataclass
from datetime import datetime
-from typing import Dict, Callable, List, Optional, Union, Set
-
-from slixmpp import InvalidJID, JID
+from typing import (
+ cast,
+ Any,
+ Dict,
+ Callable,
+ List,
+ Optional,
+ Tuple,
+ Union,
+ Set,
+ Type,
+ Pattern,
+ TYPE_CHECKING,
+)
+
+from slixmpp import InvalidJID, JID, Presence, Iq, Message as SMessage
from slixmpp.exceptions import IqError, IqTimeout
from poezio.tabs import ChatTab, Tab, SHOW_NAME
from poezio import common
-from poezio import fixes
-from poezio import mam
from poezio import multiuserchat as muc
from poezio import timed_events
from poezio import windows
from poezio import xhtml
-from poezio.common import safeJID
-from poezio.config import config
+from poezio.common import to_utc
+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
+from poezio.ui.types import (
+ BaseMessage,
+ InfoMessage,
+ Message,
+ MucOwnJoinMessage,
+ MucOwnLeaveMessage,
+ PersistentInfoMessage,
+)
+
+if TYPE_CHECKING:
+ from poezio.core.core import Core
+ from slixmpp.plugins.xep_0004 import Form
log = logging.getLogger(__name__)
NS_MUC_USER = 'http://jabber.org/protocol/muc#user'
-STATUS_XPATH = '{%s}x/{%s}status' % (NS_MUC_USER, NS_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.
It contains a userlist, an input, a topic, an information and a chat zone
"""
message_type = 'groupchat'
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
- additional_information = {} # type: Dict[str, Callable[[str], str]]
- lagged = False
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable[..., Any]] = {}
+ additional_information: Dict[str, Callable[[str], str]] = {}
+ lagged: bool = False
- def __init__(self, core, jid, nick, password=None):
+ def __init__(self, core: Core, jid: JID, nick: str, password: Optional[str] = None) -> None:
ChatTab.__init__(self, core, jid)
self.joined = False
self._state = 'disconnected'
# our nick in the MUC
self.own_nick = nick
# self User object
- self.own_user = None # type: Optional[User]
+ self.own_user: Optional[User] = None
self.password = password
# buffered presences
- self.presence_buffer = []
+ self.presence_buffer: List[Presence] = []
# userlist
- self.users = [] # type: List[User]
+ self.users: List[User] = []
# private conversations
- self.privates = [] # type: List[Tab]
+ self.privates: List[Tab] = []
self.topic = ''
self.topic_from = ''
# Self ping event, so we can cancel it when we leave the room
- self.self_ping_event = None
+ self.self_ping_event: Optional[timed_events.DelayedEvent] = None
# UI stuff
self.topic_win = windows.Topic()
- self.text_win = windows.TextWin()
- self._text_buffer.add_window(self.text_win)
self.v_separator = windows.VerticalSeparator()
self.user_win = windows.UserList()
self.info_header = windows.MucInfoWin()
- self.input = windows.MessageInput()
+ self.input: windows.MessageInput = windows.MessageInput()
# List of ignored users
- self.ignores = [] # type: List[User]
+ self.ignores: List[User] = []
# keys
self.register_keys()
self.update_keys()
@@ -98,7 +134,7 @@ class MucTab(ChatTab):
self.resize()
@property
- def general_jid(self):
+ def general_jid(self) -> JID:
return self.jid
def check_send_chat_state(self) -> bool:
@@ -128,41 +164,49 @@ class MucTab(ChatTab):
"""
del MucTab.additional_information[plugin_name]
- def cancel_config(self, form):
+ def cancel_config(self, form: Form) -> None:
"""
The user do not want to send their config, send an iq cancel
"""
- muc.cancel_config(self.core.xmpp, self.jid.bare)
+ asyncio.create_task(self.core.xmpp['xep_0045'].cancel_config(self.jid))
self.core.close_tab()
- def send_config(self, form):
+ def send_config(self, form: Form) -> None:
"""
The user sends their config to the server
"""
- muc.configure_room(self.core.xmpp, self.jid.bare, form)
+ asyncio.create_task(self.core.xmpp['xep_0045'].set_room_config(self.jid, form))
self.core.close_tab()
- def join(self):
+ def join(self) -> None:
"""
Join the room
"""
+ seconds: Optional[int]
status = self.core.get_status()
if self.last_connection:
- delta = datetime.now() - self.last_connection
+ delta = to_utc(datetime.now()) - to_utc(self.last_connection)
seconds = delta.seconds + delta.days * 24 * 3600
else:
+ last_message = self._text_buffer.find_last_message()
seconds = None
+ if last_message is not None:
+ seconds = (datetime.now() - last_message.time).seconds
+ use_log = config.get_by_tabname('mam_sync', self.general_jid)
+ mam_sync = config.get_by_tabname('mam_sync', self.general_jid)
+ if self.mam_filler is None and use_log and mam_sync:
+ limit = config.get_by_tabname('mam_sync_limit', self.jid)
+ self.mam_filler = MAMFiller(logger, self, limit)
muc.join_groupchat(
self.core,
- self.jid.bare,
+ self.jid,
self.own_nick,
- self.password,
+ self.password or '',
status=status.message,
show=status.show,
seconds=seconds)
- asyncio.ensure_future(mam.on_tab_open(self))
- def leave_room(self, message: str):
+ def leave_room(self, message: str) -> None:
if self.joined:
theme = get_theme()
info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
@@ -197,80 +241,103 @@ class MucTab(ChatTab):
'color_spec': spec_col,
'nick': self.own_nick,
}
-
- self.add_message(msg, typ=2)
+ self.add_message(MucOwnLeaveMessage(msg))
self.disconnect()
- muc.leave_groupchat(self.core.xmpp, self.jid.bare, self.own_nick,
+ muc.leave_groupchat(self.core.xmpp, self.jid, self.own_nick,
message)
self.core.disable_private_tabs(self.jid.bare, reason=msg)
else:
- muc.leave_groupchat(self.core.xmpp, self.jid.bare, self.own_nick,
+ self.presence_buffer = []
+ self.users = []
+ muc.leave_groupchat(self.core.xmpp, self.jid, self.own_nick,
message)
- def change_affiliation(self,
- nick_or_jid: Union[str, JID],
- affiliation: str,
- reason=''):
+ async def change_affiliation(
+ self,
+ nick_or_jid: Union[str, JID],
+ affiliation: str,
+ reason: str = ''
+ ) -> None:
"""
Change the affiliation of a nick or JID
"""
-
- def callback(iq):
- if iq['type'] == 'error':
- self.core.information(
- "Could not set affiliation '%s' for '%s'." %
- (affiliation, nick_or_jid), "Warning")
-
if not self.joined:
return
valid_affiliations = ('outcast', 'none', 'member', 'admin', 'owner')
if affiliation not in valid_affiliations:
- return self.core.information(
+ self.core.information(
'The affiliation must be one of ' +
', '.join(valid_affiliations), 'Error')
- if nick_or_jid in [user.nick for user in self.users]:
- muc.set_user_affiliation(
- self.core.xmpp,
- self.jid.bare,
- affiliation,
- nick=nick_or_jid,
- callback=callback,
- reason=reason)
- else:
- muc.set_user_affiliation(
- self.core.xmpp,
- self.jid.bare,
- affiliation,
- jid=safeJID(nick_or_jid),
- callback=callback,
- reason=reason)
+ return
+ jid = None
+ nick = None
+ for user in self.users:
+ if user.nick == nick_or_jid:
+ jid = user.jid
+ nick = user.nick
+ break
+ if jid is None:
+ try:
+ jid = JID(nick_or_jid)
+ except InvalidJID:
+ self.core.information(
+ f'Invalid JID or missing occupant: {nick_or_jid}',
+ 'Error'
+ )
+ return
- def change_role(self, nick: str, role: str, reason=''):
+ try:
+ if affiliation != 'member':
+ nick = None
+ await self.core.xmpp['xep_0045'].set_affiliation(
+ self.jid,
+ jid=jid,
+ nick=nick,
+ affiliation=affiliation,
+ reason=reason
+ )
+ self.core.information(
+ f"Affiliation of {jid} set to {affiliation} successfully",
+ "Info"
+ )
+ except (IqError, IqTimeout) as exc:
+ self.core.information(
+ f"Could not set affiliation '{affiliation}' for '{jid}': {exc}",
+ "Warning",
+ )
+
+ async def change_role(self, nick: str, role: str, reason: str = '') -> None:
"""
Change the role of a nick
"""
- def callback(iq):
- if iq['type'] == 'error':
- self.core.information(
- "Could not set role '%s' for '%s'." % (role, nick),
- "Warning")
-
valid_roles = ('none', 'visitor', 'participant', 'moderator')
if not self.joined or role not in valid_roles:
- return self.core.information(
+ self.core.information(
'The role must be one of ' + ', '.join(valid_roles), 'Error')
+ return
try:
target_jid = copy(self.jid)
target_jid.resource = nick
except InvalidJID:
- return self.core.information('Invalid nick', 'Info')
+ self.core.information('Invalid nick', 'Info')
+ return
- muc.set_user_role(
- self.core.xmpp, self.jid.bare, nick, reason, role, callback=callback)
+ try:
+ await self.core.xmpp['xep_0045'].set_role(
+ self.jid, nick, role=role, reason=reason
+ )
+ self.core.information(
+ f'Role of {nick} changed to {role} successfully.'
+ 'Info'
+ )
+ except (IqError, IqTimeout) as e:
+ self.core.information(
+ "Could not set role '%s' for '%s': %s" % (role, nick, e),
+ "Warning")
@refresh_wrapper.conditional
def print_info(self, nick: str) -> bool:
@@ -301,15 +368,15 @@ class MucTab(ChatTab):
'role': user.role or 'None',
'status': '\n%s' % user.status if user.status else ''
}
- self.add_message(info, typ=0)
+ self.add_message(InfoMessage(info))
return True
- def change_topic(self, topic: str):
+ def change_topic(self, topic: str) -> None:
"""Change the current topic"""
- muc.change_subject(self.core.xmpp, self.jid.bare, topic)
+ self.core.xmpp.plugin['xep_0045'].set_subject(self.jid, topic)
@refresh_wrapper.always
- def show_topic(self):
+ def show_topic(self) -> None:
"""
Print the current topic
"""
@@ -327,42 +394,23 @@ class MucTab(ChatTab):
else:
user_string = ''
- self._text_buffer.add_message(
- "\x19%s}The subject of the room is: \x19%s}%s %s" %
- (info_text, norm_text, self.topic, user_string))
+ self.add_message(
+ InfoMessage(
+ "The subject of the room is: \x19%s}%s %s" %
+ (norm_text, self.topic, user_string),
+ ),
+ )
@refresh_wrapper.always
- def recolor(self, random_colors=False):
+ def recolor(self) -> None:
"""Recolor the current MUC users"""
- deterministic = config.get_by_tabname('deterministic_nick_colors',
- self.jid.bare)
- if deterministic:
- for user in self.users:
- if user is self.own_user:
- continue
- color = self.search_for_color(user.nick)
- if color != '':
- continue
- user.set_deterministic_color()
- return
- # Sort the user list by last talked, to avoid color conflicts
- # on active participants
- sorted_users = sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True)
- full_sorted_users = sorted_users[:]
- # search our own user, to remove it from the list
- # Also remove users whose color is fixed
- for user in full_sorted_users:
- color = self.search_for_color(user.nick)
+ for user in self.users:
if user is self.own_user:
- sorted_users.remove(user)
- elif color != '':
- sorted_users.remove(user)
- user.change_color(color, deterministic)
- colors = list(get_theme().LIST_COLOR_NICKNAMES)
- if random_colors:
- random.shuffle(colors)
- for i, user in enumerate(sorted_users):
- user.color = colors[i % len(colors)]
+ continue
+ color = self.search_for_color(user.nick)
+ if color != '':
+ continue
+ user.set_deterministic_color()
self.text_win.rebuild_everything(self._text_buffer)
@refresh_wrapper.conditional
@@ -384,7 +432,7 @@ class MucTab(ChatTab):
user.change_color(color)
config.set_and_save(nick, color, 'muc_colors')
nick_color_aliases = config.get_by_tabname('nick_color_aliases',
- self.jid.bare)
+ self.jid)
if nick_color_aliases:
# if any user in the room has a nick which is an alias of the
# nick, update its color
@@ -397,7 +445,7 @@ class MucTab(ChatTab):
self.text_win.rebuild_everything(self._text_buffer)
return True
- def on_input(self, key, raw):
+ def on_input(self, key: str, raw: bool) -> bool:
if not raw and key in self.key_func:
self.key_func[key]()
return False
@@ -410,18 +458,15 @@ class MucTab(ChatTab):
return False
def get_nick(self) -> str:
- if config.get('show_muc_jid'):
- return self.jid.bare
- bookmark = self.core.bookmarks[self.jid.bare]
+ if config.getbool('show_muc_jid'):
+ return cast(str, self.jid)
+ bookmark = self.core.bookmarks[self.jid]
if bookmark is not None and bookmark.name:
return bookmark.name
# TODO: send the disco#info identity name here, if it exists.
- return self.jid.user
-
- def get_text_window(self):
- return self.text_win
+ return self.jid.node
- def on_lose_focus(self):
+ def on_lose_focus(self) -> None:
if self.joined:
if self.input.text:
self.state = 'nonempty'
@@ -437,10 +482,10 @@ class MucTab(ChatTab):
self.send_chat_state('inactive')
self.check_scrolled()
- def on_gain_focus(self):
+ def on_gain_focus(self) -> None:
self.state = 'current'
if (self.text_win.built_lines and self.text_win.built_lines[-1] is None
- and not config.get('show_useless_separator')):
+ and not config.getbool('show_useless_separator')):
self.text_win.remove_line_separator()
curses.curs_set(1)
if self.joined and config.get_by_tabname(
@@ -448,18 +493,134 @@ class MucTab(ChatTab):
self.general_jid) and not self.input.get_text():
self.send_chat_state('active')
- def handle_presence(self, presence):
+ 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:
"""
- Handle MUC presence
+ 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()
- status_codes = set()
- for status_code in presence.xml.findall(STATUS_XPATH):
- status_codes.add(status_code.attrib['code'])
+ status_codes = presence['muc']['status_codes']
if presence['type'] == 'error':
self.core.room_error(presence, self.jid.bare)
elif not self.joined:
- own = '110' in status_codes
+ own = 110 in status_codes
if own or len(self.presence_buffer) >= 10:
self.process_presence_buffer(presence, own)
else:
@@ -479,20 +640,17 @@ class MucTab(ChatTab):
self.input.refresh()
self.core.doupdate()
- def process_presence_buffer(self, last_presence, own):
+ def process_presence_buffer(self, last_presence: Presence, own: bool) -> None:
"""
Batch-process all the initial presences
"""
- deterministic = config.get_by_tabname('deterministic_nick_colors',
- self.jid.bare)
-
for stanza in self.presence_buffer:
try:
- self.handle_presence_unjoined(stanza, deterministic)
+ self.handle_presence_unjoined(stanza)
except PresenceError:
self.core.room_error(stanza, stanza['from'].bare)
self.presence_buffer = []
- self.handle_presence_unjoined(last_presence, deterministic, own)
+ self.handle_presence_unjoined(last_presence, own)
self.users.sort()
# Enable the self ping event, to regularly check if we
# are still in the room.
@@ -503,34 +661,35 @@ class MucTab(ChatTab):
self.core.tabs.current_tab.refresh_input()
self.core.doupdate()
- def handle_presence_unjoined(self, presence, deterministic, own=False):
+ def handle_presence_unjoined(self, presence: Presence, own: bool = False) -> None:
"""
Presence received while we are not in the room (before code=110)
"""
- from_nick, _, affiliation, show, status, role, jid, typ = dissect_presence(
- presence)
+ # If presence is coming from MUC barejid, ignore.
+ if not presence['from'].resource:
+ return None
+ dissected_presence = dissect_presence(presence)
+ from_nick, _, affiliation, show, status, role, jid, typ = dissected_presence
if typ == 'unavailable':
return
user_color = self.search_for_color(from_nick)
new_user = User(from_nick, affiliation, show, status, role, jid,
- deterministic, user_color)
+ user_color)
self.users.append(new_user)
self.core.events.trigger('muc_join', presence, self)
if own:
- status_codes = set()
- for status_code in presence.xml.findall(STATUS_XPATH):
- status_codes.add(status_code.attrib['code'])
+ status_codes = presence['muc']['status_codes']
self.own_join(from_nick, new_user, status_codes)
- def own_join(self, from_nick: str, new_user: User, status_codes: Set[str]):
+ def own_join(self, from_nick: str, new_user: User, status_codes: Set[int]) -> None:
"""
Handle the last presence we received, entering the room
"""
self.own_nick = from_nick
self.own_user = new_user
self.joined = True
- if self.jid.bare in self.core.initial_joins:
- self.core.initial_joins.remove(self.jid.bare)
+ if self.jid in self.core.initial_joins:
+ self.core.initial_joins.remove(self.jid)
self._state = 'normal'
elif self != self.core.tabs.current_tab:
self._state = 'joined'
@@ -558,42 +717,51 @@ class MucTab(ChatTab):
'nick_col': color,
'info_col': info_col,
}
- self.add_message(enable_message, typ=2)
+ self.add_message(MucOwnJoinMessage(enable_message))
self.core.enable_private_tabs(self.jid.bare, enable_message)
- if '201' in status_codes:
+ if 201 in status_codes:
self.add_message(
- '\x19%(info_col)s}Info: The room '
- 'has been created' % {'info_col': info_col},
- typ=0)
- if '170' in status_codes:
+ PersistentInfoMessage('Info: The room has been created'),
+ )
+ if 170 in status_codes:
self.add_message(
- '\x19%(warn_col)s}Warning:\x19%(info_col)s}'
- ' This room is publicly logged' % {
- 'info_col': info_col,
- 'warn_col': warn_col
- },
- typ=0)
- if '100' in status_codes:
+ InfoMessage(
+ '\x19%(warn_col)s}Warning:\x19%(info_col)s}'
+ ' This room is publicly logged' % {
+ 'info_col': info_col,
+ 'warn_col': warn_col
+ }
+ ),
+ )
+ if 100 in status_codes:
self.add_message(
- '\x19%(warn_col)s}Warning:\x19%(info_col)s}'
- ' This room is not anonymous.' % {
- 'info_col': info_col,
- 'warn_col': warn_col
- },
- typ=0)
+ InfoMessage(
+ '\x19%(warn_col)s}Warning:\x19%(info_col)s}'
+ ' This room is not anonymous.' % {
+ 'info_col': info_col,
+ 'warn_col': warn_col
+ },
+ ),
+ )
+ asyncio.create_task(LogLoader(
+ logger, self, config.get_by_tabname('use_log', self.general_jid)
+ ).tab_open())
- def handle_presence_joined(self, presence, status_codes):
+ def handle_presence_joined(self, presence: Presence, status_codes: Set[int]) -> None:
"""
Handle new presences when we are already in the room
"""
- from_nick, from_room, affiliation, show, status, role, jid, typ = dissect_presence(
- presence)
- change_nick = '303' in status_codes
- kick = '307' in status_codes and typ == 'unavailable'
- ban = '301' in status_codes and typ == 'unavailable'
- shutdown = '332' in status_codes and typ == 'unavailable'
- server_initiated = '333' in status_codes and typ == 'unavailable'
- non_member = '322' in status_codes and typ == 'unavailable'
+ # If presence is coming from MUC barejid, ignore.
+ if not presence['from'].resource:
+ return None
+ dissected_presence = dissect_presence(presence)
+ from_nick, from_room, affiliation, show, status, role, jid, typ = dissected_presence
+ change_nick = 303 in status_codes
+ kick = 307 in status_codes and typ == 'unavailable'
+ ban = 301 in status_codes and typ == 'unavailable'
+ shutdown = 332 in status_codes and typ == 'unavailable'
+ server_initiated = 333 in status_codes and typ == 'unavailable'
+ non_member = 322 in status_codes and typ == 'unavailable'
user = self.get_user_by_name(from_nick)
# New user
if not user and typ != "unavailable":
@@ -602,11 +770,11 @@ class MucTab(ChatTab):
self.on_user_join(from_nick, affiliation, show, status, role, jid,
user_color)
elif user is None:
- log.error('BUG: User %s in %s is None', from_nick, self.jid.bare)
+ log.error('BUG: User %s in %s is None', from_nick, self.jid)
return
elif change_nick:
self.core.events.trigger('muc_nickchange', presence, self)
- self.on_user_nick_change(presence, user, from_nick, from_room)
+ self.on_user_nick_change(presence, user, from_nick)
elif ban:
self.core.events.trigger('muc_ban', presence, self)
self.core.on_user_left_private_conversation(
@@ -626,39 +794,50 @@ class MucTab(ChatTab):
# user quit
elif typ == 'unavailable':
self.on_user_leave_groupchat(user, jid, status, from_nick,
- from_room, server_initiated)
+ 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,
role, show, status)
- def on_non_member_kicked(self):
+ def on_non_member_kicked(self) -> None:
"""We have been kicked because the MUC is members-only"""
self.add_message(
- '\x19%(info_col)s}You have been kicked because you '
- 'are not a member and the room is now members-only.' %
- {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
- typ=2)
+ MucOwnLeaveMessage(
+ 'You have been kicked because you '
+ 'are not a member and the room is now members-only.'
+ )
+ )
self.disconnect()
- def on_muc_shutdown(self):
+ def on_muc_shutdown(self) -> None:
"""We have been kicked because the MUC service is shutting down"""
self.add_message(
- '\x19%(info_col)s}You have been kicked because the'
- ' MUC service is shutting down.' %
- {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
- typ=2)
+ MucOwnLeaveMessage(
+ 'You have been kicked because the'
+ ' MUC service is shutting down.'
+ )
+ )
self.disconnect()
- def on_user_join(self, from_nick, affiliation, show, status, role, jid,
- color):
+ def on_user_join(self, from_nick: str, affiliation: str, show: str, status: str, role: str, jid: JID,
+ color: str) -> None:
"""
When a new user joins the groupchat
"""
- deterministic = config.get_by_tabname('deterministic_nick_colors',
- self.jid.bare)
user = User(from_nick, affiliation, show, status, role, jid,
- deterministic, color)
+ color)
bisect.insort_left(self.users, user)
hide_exit_join = config.get_by_tabname('hide_exit_join',
self.general_jid)
@@ -667,7 +846,7 @@ class MucTab(ChatTab):
self.general_jid):
color = dump_tuple(user.color)
else:
- color = 3
+ color = "3"
theme = get_theme()
info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
spec_col = dump_tuple(theme.COLOR_JOIN_CHAR)
@@ -693,13 +872,14 @@ class MucTab(ChatTab):
'jid_color': dump_tuple(theme.COLOR_MUC_JID),
'color_spec': spec_col,
}
- self.add_message(msg, typ=2)
+ self.add_message(PersistentInfoMessage(msg))
self.core.on_user_rejoined_private_conversation(self.jid.bare, from_nick)
- def on_user_nick_change(self, presence, user, from_nick, from_room):
- new_nick = presence.xml.find(
- '{%s}x/{%s}item' % (NS_MUC_USER, NS_MUC_USER)).attrib['nick']
- old_color = user.color
+ def on_user_nick_change(self, presence: Presence, user: User, from_nick: str) -> None:
+ new_nick = presence['muc']['item']['nick']
+ if not new_nick:
+ return # should not happen
+ old_color_tuple = user.color
if user.nick == self.own_nick:
self.own_nick = new_nick
# also change our nick in all private discussions of this room
@@ -707,58 +887,56 @@ class MucTab(ChatTab):
user.change_nick(new_nick)
else:
user.change_nick(new_nick)
- deterministic = config.get_by_tabname('deterministic_nick_colors',
- self.jid.bare)
- color = config.get_by_tabname(new_nick, 'muc_colors') or None
- if color or deterministic:
- user.change_color(color, deterministic)
+ color = config.getstr(new_nick, section='muc_colors') or None
+ user.change_color(color)
self.users.remove(user)
bisect.insort_left(self.users, user)
if config.get_by_tabname('display_user_color_in_join_part',
self.general_jid):
color = dump_tuple(user.color)
- old_color = dump_tuple(old_color)
+ old_color = dump_tuple(old_color_tuple)
else:
- old_color = color = 3
+ old_color = color = "3"
info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
self.add_message(
- '\x19%(old_color)s}%(old)s\x19%(info_col)s} is'
- ' now known as \x19%(color)s}%(new)s' % {
- 'old': from_nick,
- 'new': new_nick,
- 'color': color,
- 'old_color': old_color,
- 'info_col': info_col
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x19%(old_color)s}%(old)s\x19%(info_col)s} is'
+ ' now known as \x19%(color)s}%(new)s' % {
+ 'old': from_nick,
+ 'new': new_nick,
+ 'color': color,
+ 'old_color': old_color,
+ 'info_col': info_col
+ },
+ )
+ )
# rename the private tabs if needed
self.core.rename_private_tabs(self.jid.bare, from_nick, user)
- def on_user_banned(self, presence, user, from_nick):
+ def on_user_banned(self, presence: Presence, user: User, from_nick: str) -> None:
"""
When someone is banned from a muc
"""
+ cls: Type[InfoMessage] = PersistentInfoMessage
self.users.remove(user)
- by = presence.xml.find('{%s}x/{%s}item/{%s}actor' %
- (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
- reason = presence.xml.find('{%s}x/{%s}item/{%s}reason' %
- (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
- if by:
- by = by.get('jid') or by.get('nick') or None
- else:
- by = None
+ by = presence['muc']['item'].get_plugin('actor', check=True)
+ reason = presence['muc']['item']['reason']
+ by_repr: Union[JID, str, None] = None
+ if by is not None:
+ by_repr = by['jid'] or by['nick'] or None
theme = get_theme()
info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
char_kick = theme.CHAR_KICK
if from_nick == self.own_nick: # we are banned
+ cls = MucOwnLeaveMessage
if by:
kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}'
' have been banned by \x194}%(by)s') % {
'spec': char_kick,
- 'by': by,
+ 'by': by_repr,
'info_col': info_col
}
else:
@@ -776,11 +954,11 @@ class MucTab(ChatTab):
self.general_jid)
delay = common.parse_str_to_secs(delay)
if delay <= 0:
- muc.join_groupchat(self.core, self.jid.bare, self.own_nick)
+ muc.join_groupchat(self.core, self.jid, self.own_nick)
else:
self.core.add_timed_event(
timed_events.DelayedEvent(delay, muc.join_groupchat,
- self.core, self.jid.bare,
+ self.core, self.jid,
self.own_nick))
else:
@@ -788,16 +966,16 @@ class MucTab(ChatTab):
self.general_jid):
color = dump_tuple(user.color)
else:
- color = 3
+ color = "3"
- if by:
+ if by_repr:
kick_msg = ('\x191}%(spec)s \x19%(color)s}'
'%(nick)s\x19%(info_col)s} '
'has been banned by \x194}%(by)s') % {
'spec': char_kick,
'nick': from_nick,
'color': color,
- 'by': by,
+ 'by': by_repr,
'info_col': info_col
}
else:
@@ -808,30 +986,30 @@ class MucTab(ChatTab):
'color': color,
'info_col': info_col
}
- if reason is not None and reason.text:
+ if reason:
kick_msg += ('\x19%(info_col)s} Reason: \x196}'
'%(reason)s\x19%(info_col)s}') % {
- 'reason': reason.text,
+ 'reason': reason,
'info_col': info_col
}
- self.add_message(kick_msg, typ=2)
+ self.add_message(cls(kick_msg))
- def on_user_kicked(self, presence, user, from_nick):
+ def on_user_kicked(self, presence: Presence, user: User, from_nick: str) -> None:
"""
When someone is kicked from a muc
"""
+ cls: Type[InfoMessage] = PersistentInfoMessage
self.users.remove(user)
- actor_elem = presence.xml.find('{%s}x/{%s}item/{%s}actor' %
- (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
- reason = presence.xml.find('{%s}x/{%s}item/{%s}reason' %
- (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
+ actor_elem = presence['muc']['item'].get_plugin('actor', check=True)
+ reason = presence['muc']['item']['reason']
by = None
theme = get_theme()
info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
char_kick = theme.CHAR_KICK
if actor_elem is not None:
- by = actor_elem.get('nick') or actor_elem.get('jid')
+ by = actor_elem['nick'] or actor_elem.get['jid'] or None
if from_nick == self.own_nick: # we are kicked
+ cls = MucOwnLeaveMessage
if by:
kick_msg = ('\x191}%(spec)s \x193}You\x19'
'%(info_col)s} have been kicked'
@@ -856,18 +1034,18 @@ class MucTab(ChatTab):
self.general_jid)
delay = common.parse_str_to_secs(delay)
if delay <= 0:
- muc.join_groupchat(self.core, self.jid.bare, self.own_nick)
+ muc.join_groupchat(self.core, self.jid, self.own_nick)
else:
self.core.add_timed_event(
timed_events.DelayedEvent(delay, muc.join_groupchat,
- self.core, self.jid.bare,
+ self.core, self.jid,
self.own_nick))
else:
if config.get_by_tabname('display_user_color_in_join_part',
self.general_jid):
color = dump_tuple(user.color)
else:
- color = 3
+ color = "3"
if by:
kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s'
'\x19%(info_col)s} has been kicked by '
@@ -886,13 +1064,13 @@ class MucTab(ChatTab):
'color': color,
'info_col': info_col
}
- if reason is not None and reason.text:
+ if reason:
kick_msg += ('\x19%(info_col)s} Reason: \x196}'
'%(reason)s') % {
- 'reason': reason.text,
+ 'reason': reason,
'info_col': info_col
}
- self.add_message(kick_msg, typ=2)
+ self.add_message(cls(kick_msg))
def on_user_leave_groupchat(self,
user: User,
@@ -900,7 +1078,7 @@ class MucTab(ChatTab):
status: str,
from_nick: str,
from_room: JID,
- server_initiated=False):
+ server_initiated: bool = False) -> None:
"""
When a user leaves a groupchat
"""
@@ -909,7 +1087,7 @@ class MucTab(ChatTab):
# We are now out of the room.
# Happens with some buggy (? not sure) servers
self.disconnect()
- self.core.disable_private_tabs(from_room)
+ self.core.disable_private_tabs(from_room.bare)
self.refresh_tab_win()
hide_exit_join = config.get_by_tabname('hide_exit_join',
@@ -920,7 +1098,7 @@ class MucTab(ChatTab):
self.general_jid):
color = dump_tuple(user.color)
else:
- color = 3
+ color = "3"
theme = get_theme()
info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
spec_col = dump_tuple(theme.COLOR_QUIT_CHAR)
@@ -957,11 +1135,11 @@ class MucTab(ChatTab):
}
if status:
leave_msg += ' (\x19o%s\x19%s})' % (status, info_col)
- self.add_message(leave_msg, typ=2)
- self.core.on_user_left_private_conversation(from_room, user, status)
+ self.add_message(PersistentInfoMessage(leave_msg))
+ self.core.on_user_left_private_conversation(from_room.bare, user, status)
- def on_user_change_status(self, user, from_nick, from_room, affiliation,
- role, show, status):
+ def on_user_change_status(self, user: User, from_nick: str, from_room: str, affiliation: str,
+ role: str, show: str, status: str) -> None:
"""
When a user changes her status
"""
@@ -972,7 +1150,7 @@ class MucTab(ChatTab):
self.general_jid):
color = dump_tuple(user.color)
else:
- color = 3
+ color = "3"
info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
if from_nick == self.own_nick:
msg = '\x19%(color)s}You\x19%(info_col)s} changed: ' % {
@@ -1016,15 +1194,16 @@ class MucTab(ChatTab):
or show != user.show or status != user.status)) or (
affiliation != user.affiliation or role != user.role):
# display the message in the room
- self._text_buffer.add_message(msg)
+ self.add_message(InfoMessage(msg))
self.core.on_user_changed_status_in_private(
- '%s/%s' % (from_room, from_nick), Status(show, status))
+ JID('%s/%s' % (from_room, from_nick)), Status(show, status)
+ )
self.users.remove(user)
# finally, effectively change the user status
user.update(affiliation, show, status, role)
bisect.insort_left(self.users, user)
- def disconnect(self):
+ def disconnect(self) -> None:
"""
Set the state of the room as not joined, so
we can know if we can join it, send messages to it, etc
@@ -1036,23 +1215,13 @@ class MucTab(ChatTab):
self.joined = False
self.disable_self_ping_event()
- def get_single_line_topic(self):
+ def get_single_line_topic(self) -> str:
"""
Return the topic as a single-line string (for the window header)
"""
return self.topic.replace('\n', '|')
- def log_message(self, txt, nickname, time=None, typ=1):
- """
- Log the messages in the archives, if it needs
- to be
- """
- if time is None and self.joined: # don't log the history messages
- if not logger.log_message(self.jid.bare, nickname, txt, typ=typ):
- self.core.information('Unable to write in the log file',
- 'Error')
-
- def get_user_by_name(self, nick):
+ def get_user_by_name(self, nick: str) -> Optional[User]:
"""
Gets the user associated with the given nick, or None if not found
"""
@@ -1061,65 +1230,34 @@ class MucTab(ChatTab):
return user
return None
- def add_message(self, txt, time=None, nickname=None, **kwargs):
- """
- Note that user can be None even if nickname is not None. It happens
- when we receive an history message said by someone who is not
- in the room anymore
- Return True if the message highlighted us. False otherwise.
- """
-
+ def add_message(self, msg: BaseMessage) -> None:
+ """Add a message to the text buffer and set various tab status"""
# reset self-ping interval
if self.self_ping_event:
self.enable_self_ping_event()
-
- self.log_message(txt, nickname, time=time, typ=kwargs.get('typ', 1))
- args = dict()
- for key, value in kwargs.items():
- if key not in ('typ', 'forced_user'):
- args[key] = value
- if nickname is not None:
- user = self.get_user_by_name(nickname)
- else:
- user = None
-
- if user:
- user.set_last_talked(datetime.now())
- args['user'] = user
- if not user and kwargs.get('forced_user'):
- args['user'] = kwargs['forced_user']
-
- if (not time and nickname and nickname != self.own_nick
- and self.state != 'current'):
- if (self.state != 'highlight'
- and config.get_by_tabname('notify_messages', self.jid.bare)):
+ super().add_message(msg)
+ if not isinstance(msg, Message):
+ return
+ if msg.user:
+ msg.user.set_last_talked(msg.time)
+ if config.get_by_tabname('notify_messages', self.jid) and self.state != 'current':
+ if msg.nickname != self.own_nick and not msg.history:
self.state = 'message'
- if time and not txt.startswith('/me'):
- txt = '\x19%(info_col)s}%(txt)s' % {
- 'txt': txt,
- 'info_col': dump_tuple(get_theme().COLOR_LOG_MSG)
- }
- elif not nickname:
- txt = '\x19%(info_col)s}%(txt)s' % {
- 'txt': txt,
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- }
- elif not kwargs.get('highlight'): # TODO
- args['highlight'] = self.do_highlight(txt, time, nickname)
- time = time or datetime.now()
- self._text_buffer.add_message(txt, time, nickname, **args)
- return args.get('highlight', False)
+ if msg.txt and msg.nickname:
+ self.do_highlight(msg.txt, msg.nickname, msg.history)
def modify_message(self,
- txt,
- old_id,
- new_id,
- time=None,
- nickname=None,
- user=None,
- jid=None):
- self.log_message(txt, nickname, time=time, typ=1)
- highlight = self.do_highlight(txt, time, nickname, corrected=True)
+ txt: str,
+ old_id: str,
+ new_id: str,
+ time: Optional[datetime] = None,
+ delayed: bool = False,
+ nickname: Optional[str] = None,
+ user: Optional[User] = None,
+ jid: Optional[JID] = None) -> bool:
+ highlight = self.message_is_highlight(
+ txt, nickname, delayed, corrected=True
+ )
message = self._text_buffer.modify_message(
txt,
old_id,
@@ -1129,14 +1267,15 @@ class MucTab(ChatTab):
user=user,
jid=jid)
if message:
- self.text_win.modify_message(old_id, message)
+ self.log_message(message)
+ self.text_win.modify_message(message.identifier, message)
return highlight
return False
- def matching_names(self):
- return [(1, self.jid.user), (3, self.jid.full)]
+ def matching_names(self) -> List[Tuple[int, str]]:
+ return [(1, self.jid.node), (3, self.jid.full)]
- def enable_self_ping_event(self):
+ def enable_self_ping_event(self) -> None:
delay = config.get_by_tabname(
"self_ping_delay", self.general_jid, default=0)
interval = int(
@@ -1149,22 +1288,25 @@ class MucTab(ChatTab):
interval, self.send_self_ping)
self.core.add_timed_event(self.self_ping_event)
- def disable_self_ping_event(self):
+ def disable_self_ping_event(self) -> None:
if self.self_ping_event is not None:
self.core.remove_timed_event(self.self_ping_event)
self.self_ping_event = None
- def send_self_ping(self):
- timeout = config.get_by_tabname(
- "self_ping_timeout", self.general_jid, default=60)
- to = self.jid.bare + "/" + self.own_nick
- self.core.xmpp.plugin['xep_0199'].send_ping(
- jid=to,
- callback=self.on_self_ping_result,
- timeout_callback=self.on_self_ping_failed,
- timeout=timeout)
-
- def on_self_ping_result(self, iq):
+ def send_self_ping(self) -> None:
+ if self.core.xmpp.is_connected():
+ timeout = config.get_by_tabname(
+ "self_ping_timeout", self.general_jid, default=60)
+ to = self.jid.bare + "/" + self.own_nick
+ self.core.xmpp.plugin['xep_0199'].send_ping(
+ jid=JID(to),
+ callback=self.on_self_ping_result,
+ timeout_callback=self.on_self_ping_failed,
+ timeout=timeout)
+ else:
+ self.enable_self_ping_event()
+
+ def on_self_ping_result(self, iq: Iq) -> None:
if iq["type"] == "error" and iq["error"]["condition"] not in \
("feature-not-implemented", "service-unavailable", "item-not-found"):
self.command_cycle(iq["error"]["text"] or "not in this room")
@@ -1173,38 +1315,40 @@ class MucTab(ChatTab):
self.reset_lag()
self.enable_self_ping_event()
- def search_for_color(self, nick):
+ def search_for_color(self, nick: str) -> str:
"""
Search for the color of a nick in the config file.
Also, look at the colors of its possible aliases if nick_color_aliases
is set.
"""
- color = config.get_by_tabname(nick, 'muc_colors')
+ color = config.getstr(nick, section='muc_colors')
if color != '':
return color
nick_color_aliases = config.get_by_tabname('nick_color_aliases',
- self.jid.bare)
+ self.jid)
if nick_color_aliases:
nick_alias = re.sub('^_*(.*?)_*$', '\\1', nick)
- color = config.get_by_tabname(nick_alias, 'muc_colors')
+ color = config.getstr(nick_alias, section='muc_colors')
return color
- def on_self_ping_failed(self, iq):
+ def on_self_ping_failed(self, iq: Any = None) -> None:
if not self.lagged:
self.lagged = True
- info_text = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
self._text_buffer.add_message(
- "\x19%s}MUC service not responding." % info_text)
+ InfoMessage(
+ "MUC service not responding."
+ ),
+ )
self._state = 'disconnected'
self.core.refresh_window()
self.enable_self_ping_event()
- def reset_lag(self):
+ def reset_lag(self) -> None:
if self.lagged:
self.lagged = False
- info_text = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- self._text_buffer.add_message(
- "\x19%s}MUC service is responding again." % info_text)
+ self.add_message(
+ InfoMessage("MUC service is responding again.")
+ )
if self != self.core.tabs.current_tab:
self._state = 'joined'
else:
@@ -1214,35 +1358,35 @@ class MucTab(ChatTab):
########################## UI ONLY #####################################
@refresh_wrapper.always
- def go_to_next_hl(self):
+ def go_to_next_hl(self) -> None:
"""
Go to the next HL in the room, or the last
"""
self.text_win.next_highlight()
@refresh_wrapper.always
- def go_to_prev_hl(self):
+ def go_to_prev_hl(self) -> None:
"""
Go to the previous HL in the room, or the first
"""
self.text_win.previous_highlight()
@refresh_wrapper.always
- def scroll_user_list_up(self):
+ def scroll_user_list_up(self) -> None:
"Scroll up in the userlist"
self.user_win.scroll_up()
@refresh_wrapper.always
- def scroll_user_list_down(self):
+ def scroll_user_list_down(self) -> None:
"Scroll down in the userlist"
self.user_win.scroll_down()
- def resize(self):
+ def resize(self) -> None:
"""
Resize the whole window. i.e. all its sub-windows
"""
self.need_resize = False
- if config.get('hide_user_list') or self.size.tab_degrade_x:
+ if config.getbool('hide_user_list') or self.size.tab_degrade_x:
text_width = self.width
else:
text_width = (self.width // 10) * 9
@@ -1266,18 +1410,18 @@ class MucTab(ChatTab):
self.text_win.resize(
self.height - 3 - info_win_height - tab_win_height, text_width, 1,
- 0)
- self.text_win.rebuild_everything(self._text_buffer)
+ 0, self._text_buffer, force=self.ui_config_changed)
+ self.ui_config_changed = False
self.info_header.resize(
1, self.width, self.height - 2 - info_win_height - tab_win_height,
0)
self.input.resize(1, self.width, self.height - 1, 0)
- def refresh(self):
+ def refresh(self) -> None:
if self.need_resize:
self.resize()
log.debug(' TAB Refresh: %s', self.__class__.__name__)
- if config.get('hide_user_list') or self.size.tab_degrade_x:
+ if config.getbool('hide_user_list') or self.size.tab_degrade_x:
display_user_list = False
else:
display_user_list = True
@@ -1296,10 +1440,10 @@ class MucTab(ChatTab):
self.info_win.refresh()
self.input.refresh()
- def on_info_win_size_changed(self):
+ def on_info_win_size_changed(self) -> None:
if self.core.information_win_size >= self.height - 3:
return
- if config.get("hide_user_list"):
+ if config.getbool("hide_user_list"):
text_width = self.width
else:
text_width = (self.width // 10) * 9
@@ -1312,7 +1456,7 @@ class MucTab(ChatTab):
Tab.tab_win_height(), 1, 1, 9 * (self.width // 10))
self.text_win.resize(
self.height - 3 - self.core.information_win_size -
- Tab.tab_win_height(), text_width, 1, 0)
+ Tab.tab_win_height(), text_width, 1, 0, self._text_buffer)
self.info_header.resize(
1, self.width, self.height - 2 - self.core.information_win_size -
Tab.tab_win_height(), 0)
@@ -1320,37 +1464,42 @@ class MucTab(ChatTab):
# This maxsize is kinda arbitrary, but most users won’t have that many
# nicknames anyway.
@functools.lru_cache(maxsize=8)
- def build_highlight_regex(self, nickname):
+ def build_highlight_regex(self, nickname: str) -> Pattern:
return re.compile(r"(^|\W)" + re.escape(nickname) + r"(\W|$)", re.I)
- def is_highlight(self, txt, time, nickname, own_nick, highlight_on,
- corrected=False):
+ def message_is_highlight(self, txt: str, nickname: Optional[str], history: bool,
+ corrected: bool = False) -> bool:
+ """Highlight algorithm for MUC tabs"""
+ # Don't highlight on info message or our own messages
+ if not nickname or nickname == self.own_nick:
+ return False
+ highlight_on = config.get_by_tabname(
+ 'highlight_on',
+ self.general_jid,
+ ).split(':')
highlighted = False
- if (not time or corrected) and nickname and nickname != own_nick:
- if self.build_highlight_regex(own_nick).search(txt):
+ if not history:
+ if self.build_highlight_regex(self.own_nick).search(txt):
highlighted = True
else:
- highlight_words = highlight_on.split(':')
- for word in highlight_words:
+ for word in highlight_on:
if word and word.lower() in txt.lower():
highlighted = True
break
return highlighted
- def do_highlight(self, txt, time, nickname, corrected=False):
- """
- Set the tab color and returns the nick color
- """
- own_nick = self.own_nick
- highlight_on = config.get_by_tabname('highlight_on', self.general_jid)
- highlighted = self.is_highlight(txt, time, nickname, own_nick,
- highlight_on, corrected)
- if highlighted and self.joined:
+ def do_highlight(self, txt: str, nickname: str, history: bool,
+ corrected: bool = False) -> bool:
+ """Set the tab color and returns the highlight state"""
+ highlighted = self.message_is_highlight(
+ txt, nickname, history, corrected
+ )
+ if highlighted and self.joined and not corrected:
if self.state != 'current':
self.state = 'highlight'
- beep_on = config.get('beep_on').split()
+ beep_on = config.getstr('beep_on').split()
if 'highlight' in beep_on and 'message' not in beep_on:
- if not config.get_by_tabname('disable_beep', self.jid.bare):
+ if not config.get_by_tabname('disable_beep', self.jid):
curses.beep()
return True
return False
@@ -1358,56 +1507,57 @@ class MucTab(ChatTab):
########################## COMMANDS ####################################
@command_args_parser.quoted(1, 1, [''])
- def command_invite(self, args):
+ async def command_invite(self, args: List[str]) -> None:
"""/invite <jid> [reason]"""
if args is None:
- return self.core.command.help('invite')
+ self.core.command.help('invite')
+ return
jid, reason = args
- self.core.command.invite('%s %s "%s"' % (jid, self.jid.bare, reason))
+ await self.core.command.invite('%s %s "%s"' % (jid, self.jid, reason))
@command_args_parser.quoted(1)
- def command_info(self, args):
+ def command_info(self, args: List[str]) -> None:
"""
/info <nick>
"""
if args is None:
- return self.core.command.help('info')
+ self.core.command.help('info')
+ return
nick = args[0]
if not self.print_info(nick):
self.core.information("Unknown user: %s" % nick, "Error")
@command_args_parser.quoted(0)
- def command_configure(self, ignored):
+ async def command_configure(self, ignored: Any) -> None:
"""
/configure
"""
- def on_form_received(form):
- if not form:
- self.core.information(
- 'Could not retrieve the configuration form', 'Error')
- return
+ try:
+ form = await self.core.xmpp.plugin['xep_0045'].get_room_config(
+ self.jid
+ )
self.core.open_new_form(form, self.cancel_config, self.send_config)
-
- fixes.get_room_form(self.core.xmpp, self.jid.bare, on_form_received)
+ except (IqError, IqTimeout, ValueError):
+ self.core.information(
+ 'Could not retrieve the configuration form', 'Error')
@command_args_parser.raw
- def command_cycle(self, msg):
+ def command_cycle(self, msg: str) -> None:
"""/cycle [reason]"""
self.leave_room(msg)
self.join()
- @command_args_parser.quoted(0, 1, [''])
- def command_recolor(self, args):
+ @command_args_parser.ignored
+ def command_recolor(self) -> None:
"""
/recolor [random]
Re-assigns color to the participants of the room
"""
- random_colors = args[0] == 'random'
- self.recolor(random_colors)
+ self.recolor()
@command_args_parser.quoted(2, 2, [''])
- def command_color(self, args):
+ def command_color(self, args: List[str]) -> None:
"""
/color <nick> <color>
Fix a color for a nick.
@@ -1415,58 +1565,71 @@ class MucTab(ChatTab):
User "random" to attribute a random color.
"""
if args is None:
- return self.core.command.help('color')
+ self.core.command.help('color')
+ return
nick = args[0]
color = args[1].lower()
if nick == self.own_nick:
- return self.core.information(
+ self.core.information(
"You cannot change the color of your"
- " own nick.", 'Error')
+ " own nick.", 'Error'
+ )
elif color not in xhtml.colors and color not in ('unset', 'random'):
- return self.core.information("Unknown color: %s" % color, 'Error')
- self.set_nick_color(nick, color)
+ self.core.information("Unknown color: %s" % color, 'Error')
+ else:
+ self.set_nick_color(nick, color)
@command_args_parser.quoted(1)
- def command_version(self, args):
+ async def command_version(self, args: List[str]) -> None:
"""
/version <jid or nick>
"""
if args is None:
- return self.core.command.help('version')
+ self.core.command.help('version')
+ return
nick = args[0]
try:
- if nick in [user.nick for user in self.users]:
+ if nick in {user.nick for user in self.users}:
jid = copy(self.jid)
jid.resource = nick
else:
jid = JID(nick)
except InvalidJID:
- return self.core.information('Invalid jid or nick %r' % nick, 'Error')
- self.core.xmpp.plugin['xep_0092'].get_version(
- jid, callback=self.core.handler.on_version_result)
+ self.core.information('Invalid jid or nick %r' % nick, 'Error')
+ return
+ iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid)
+ self.core.handler.on_version_result(iq)
@command_args_parser.quoted(1)
- def command_nick(self, args):
+ def command_nick(self, args: List[str]) -> None:
"""
/nick <nickname>
"""
if args is None:
- return self.core.command.help('nick')
+ self.core.command.help('nick')
+ return
nick = args[0]
if not self.joined:
- return self.core.information('/nick only works in joined rooms',
+ self.core.information('/nick only works in joined rooms',
'Info')
+ return
current_status = self.core.get_status()
try:
target_jid = copy(self.jid)
target_jid.resource = nick
except InvalidJID:
- return self.core.information('Invalid nick', 'Info')
- muc.change_nick(self.core, self.jid.bare, nick, current_status.message,
- current_status.show)
+ self.core.information('Invalid nick', 'Info')
+ return
+ muc.change_nick(
+ self.core,
+ self.jid,
+ nick,
+ current_status.message,
+ current_status.show,
+ )
@command_args_parser.quoted(0, 1, [''])
- def command_part(self, args):
+ def command_part(self, args: List[str]) -> None:
"""
/part [msg]
"""
@@ -1477,24 +1640,41 @@ class MucTab(ChatTab):
self.core.doupdate()
@command_args_parser.raw
- def command_close(self, msg):
+ def command_leave(self, msg: str) -> None:
+ """
+ /leave [msg]
+ """
+ self.command_close(msg)
+
+ @command_args_parser.raw
+ def command_close(self, msg: str) -> None:
"""
/close [msg]
"""
self.leave_room(msg)
+ if config.getbool('synchronise_open_rooms'):
+ if self.jid in self.core.bookmarks:
+ bookmark = self.core.bookmarks[self.jid]
+ if bookmark:
+ bookmark.autojoin = False
+ asyncio.create_task(
+ self.core.bookmarks.save(self.core.xmpp)
+ )
self.core.close_tab(self)
- def on_close(self):
+ def on_close(self) -> None:
super().on_close()
- self.leave_room('')
+ if self.joined:
+ self.leave_room('')
@command_args_parser.quoted(1, 1)
- def command_query(self, args):
+ def command_query(self, args: List[str]) -> None:
"""
/query <nick> [message]
"""
if args is None:
- return self.core.command.help('query')
+ self.core.command.help('query')
+ return
nick = args[0]
r = None
for user in self.users:
@@ -1502,13 +1682,16 @@ class MucTab(ChatTab):
r = self.core.open_private_window(self.jid.bare, user.nick)
if r and len(args) == 2:
msg = args[1]
- self.core.tabs.current_tab.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')
@command_args_parser.raw
- def command_topic(self, subject):
+ def command_topic(self, subject: str) -> None:
"""
/topic [new topic]
"""
@@ -1518,7 +1701,7 @@ class MucTab(ChatTab):
self.change_topic(subject)
@command_args_parser.quoted(0)
- def command_names(self, args):
+ def command_names(self, args: Any) -> None:
"""
/names
"""
@@ -1551,57 +1734,58 @@ class MucTab(ChatTab):
buff.append('\n')
message = ' '.join(buff)
- self._text_buffer.add_message(message)
+ self.add_message(InfoMessage(message))
self.text_win.refresh()
self.input.refresh()
@command_args_parser.quoted(1, 1)
- def command_kick(self, args):
+ async def command_kick(self, args: List[str]) -> None:
"""
/kick <nick> [reason]
"""
if args is None:
- return self.core.command.help('kick')
+ self.core.command.help('kick')
+ return
if len(args) == 2:
reason = args[1]
else:
reason = ''
nick = args[0]
- self.change_role(nick, 'none', reason)
+ await self.change_role(nick, 'none', reason)
@command_args_parser.quoted(1, 1)
- def command_ban(self, args):
+ async def command_ban(self, args: List[str]) -> None:
"""
/ban <nick> [reason]
"""
if args is None:
- return self.core.command.help('ban')
+ self.core.command.help('ban')
+ return
nick = args[0]
msg = args[1] if len(args) == 2 else ''
- self.change_affiliation(nick, 'outcast', msg)
+ await self.change_affiliation(nick, 'outcast', msg)
@command_args_parser.quoted(2, 1, [''])
- def command_role(self, args):
+ async def command_role(self, args: List[str]) -> None:
"""
/role <nick> <role> [reason]
Changes the role of a user
roles can be: none, visitor, participant, moderator
"""
-
- def callback(iq):
- if iq['type'] == 'error':
- self.core.room_error(iq, self.jid.bare)
-
if args is None:
- return self.core.command.help('role')
+ self.core.command.help('role')
+ return
nick, role, reason = args[0], args[1].lower(), args[2]
- self.change_role(nick, role, reason)
+ try:
+ await self.change_role(nick, role, reason)
+ except IqError as iq:
+ self.core.room_error(iq, self.jid.bare)
@command_args_parser.quoted(0, 2)
- def command_affiliation(self, args) -> None:
+ async def command_affiliation(self, args: List[str]) -> None:
"""
- /affiliation [<nick or jid> [<affiliation>]]
+ /affiliation [<nick or jid> <affiliation>]
Changes the affiliation of a user
affiliations can be: outcast, none, member, admin, owner
"""
@@ -1613,48 +1797,74 @@ class MucTab(ChatTab):
# List affiliations
if not args:
- asyncio.ensure_future(self.get_users_affiliations(room))
+ await self.get_users_affiliations(room)
return None
if len(args) != 2:
- return self.core.command.help('affiliation')
+ self.core.command.help('affiliation')
+ return
nick, affiliation = args[0], args[1].lower()
# Set affiliation
- self.change_affiliation(nick, affiliation)
+ await self.change_affiliation(nick, affiliation)
async def get_users_affiliations(self, jid: JID) -> None:
- MUC_ADMIN_NS = 'http://jabber.org/protocol/muc#admin'
-
- try:
- iqs = await asyncio.gather(
- self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'owner'),
- self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'admin'),
- self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'member'),
- self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'outcast'),
+ owners, admins, members, outcasts = await asyncio.gather(
+ self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'owner'),
+ self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'admin'),
+ self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'member'),
+ self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'outcast'),
+ return_exceptions=True,
+ )
+
+ all_errors = functools.reduce(
+ lambda acc, iq: acc and isinstance(iq, (IqError, IqTimeout)),
+ (owners, admins, members, outcasts),
+ True,
+ )
+ if all_errors:
+ self.core.information(
+ 'Can’t access affiliations for %s' % jid.bare,
+ 'Error',
)
- except (IqError, IqTimeout) as exn:
- self.core.room_error(exn.iq, jid)
return None
- self._text_buffer.add_message('Affiliations:')
- for iq in iqs:
- query = iq.xml.find('{%s}query' % MUC_ADMIN_NS)
- for item in query.findall('{%s}item' % MUC_ADMIN_NS):
- self._text_buffer.add_message(
- '%s: %s' % (item.get('jid'), item.get('affiliation'))
- )
- self.core.refresh_window()
+ theme = get_theme()
+ aff_colors = {
+ 'owner': theme.CHAR_AFFILIATION_OWNER,
+ 'admin': theme.CHAR_AFFILIATION_ADMIN,
+ 'member': theme.CHAR_AFFILIATION_MEMBER,
+ 'outcast': theme.CHAR_AFFILIATION_OUTCAST,
+ }
+
+
+
+ lines = ['Affiliations for %s' % jid.bare]
+ affiliation_dict = {
+ 'owner': owners,
+ 'admin': admins,
+ 'member': members,
+ 'outcast': outcasts,
+ }
+ for affiliation, items in affiliation_dict.items():
+ if isinstance(items, BaseException) or not items:
+ continue
+ aff_char = aff_colors[affiliation]
+ lines.append(' %s%s' % (aff_char, affiliation.capitalize()))
+ for ajid in sorted(items):
+ lines.append(' %s' % ajid)
+
+ self.core.information('\n'.join(lines), 'Info')
return None
@command_args_parser.raw
- def command_say(self, line, correct=False):
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False):
"""
/say <message>
Or normal input + enter
"""
- needed = 'inactive' if self.inactive else 'active'
- msg = self.core.xmpp.make_message(self.jid.bare)
+ chatstate = 'inactive' if self.inactive else 'active'
+ msg: SMessage = self.core.xmpp.make_message(self.jid)
msg['type'] = 'groupchat'
msg['body'] = line
# trigger the event BEFORE looking for colors.
@@ -1671,9 +1881,12 @@ class MucTab(ChatTab):
msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body'])
msg['body'] = xhtml.clean_text(msg['body'])
if config.get_by_tabname('send_chat_states', self.general_jid):
- msg['chat_state'] = needed
+ if chatstate == 'inactive':
+ self.send_chat_state(chatstate, always_send=True)
+ else:
+ msg['chat_state'] = chatstate
if correct:
- msg['replace']['id'] = self.last_sent_message['id']
+ msg['replace']['id'] = self.last_sent_message['id'] # type: ignore
self.cancel_paused_delay()
self.core.events.trigger('muc_say_after', msg, self)
if not msg['body']:
@@ -1681,24 +1894,26 @@ class MucTab(ChatTab):
self.text_win.refresh()
self.input.refresh()
return
- self.last_sent_message = msg
+ # TODO: #3314. Display outgoing MUC message.
+ self.set_last_sent_message(msg, correct=correct)
msg.send()
- self.chat_state = needed
+ self.chat_state = chatstate
@command_args_parser.raw
- def command_xhtml(self, msg):
+ def command_xhtml(self, msg: str) -> None:
message = self.generate_xhtml_message(msg)
if message:
message['type'] = 'groupchat'
message.send()
@command_args_parser.quoted(1)
- def command_ignore(self, args):
+ def command_ignore(self, args: List[str]) -> None:
"""
/ignore <nick>
"""
if args is None:
- return self.core.command.help('ignore')
+ self.core.command.help('ignore')
+ return
nick = args[0]
user = self.get_user_by_name(nick)
@@ -1711,12 +1926,13 @@ class MucTab(ChatTab):
self.core.information("%s is now ignored" % nick, 'info')
@command_args_parser.quoted(1)
- def command_unignore(self, args):
+ def command_unignore(self, args: List[str]) -> None:
"""
/unignore <nick>
"""
if args is None:
- return self.core.command.help('unignore')
+ self.core.command.help('unignore')
+ return
nick = args[0]
user = self.get_user_by_name(nick)
@@ -1728,9 +1944,33 @@ class MucTab(ChatTab):
self.ignores.remove(user)
self.core.information('%s is now unignored' % nick)
+ @command_args_parser.quoted(0, 1)
+ def command_request_voice(self, args: List[str]) -> None:
+ """
+ /request_voice [role]
+ Request voice in a moderated room
+ role can be: participant, moderator
+ """
+
+ room = JID(self.name)
+ if not room:
+ self.core.information('request_voice: requires a valid chat address', 'Error')
+ return
+
+ if len(args) > 1:
+ self.core.command.help('request_voice')
+ return
+
+ if args:
+ role = args[0]
+ else:
+ role = 'participant'
+
+ self.core.xmpp['xep_0045'].request_voice(room, role)
+
########################## COMPLETIONS #################################
- def completion(self):
+ def completion(self) -> None:
"""
Called when Tab is pressed, complete the nickname in the input
"""
@@ -1743,14 +1983,15 @@ class MucTab(ChatTab):
for user in sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True):
if user.nick != self.own_nick:
word_list.append(user.nick)
- after = config.get('after_completion') + ' '
+ after = config.getstr('after_completion') + ' '
input_pos = self.input.pos
- if ' ' not in self.input.get_text()[:input_pos] or (
+ text_before = self.input.get_text()[:input_pos]
+ if (' ' not in text_before and '\n' not in text_before) or (
self.input.last_completion and self.input.get_text()
[:input_pos] == self.input.last_completion + after):
add_after = after
else:
- if not config.get('add_space_after_completion'):
+ if not config.getbool('add_space_after_completion'):
add_after = ''
else:
add_after = ' '
@@ -1761,7 +2002,7 @@ class MucTab(ChatTab):
and not self.input.get_text().startswith('//'))
self.send_composing_chat_state(empty_after)
- def completion_version(self, the_input):
+ def completion_version(self, the_input: windows.MessageInput) -> Completion:
"""Completion for /version"""
userlist = []
for user in sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True):
@@ -1776,30 +2017,30 @@ class MucTab(ChatTab):
return Completion(the_input.auto_completion, userlist, quotify=False)
- def completion_info(self, the_input):
+ def completion_info(self, the_input: windows.MessageInput) -> Completion:
"""Completion for /info"""
userlist = []
for user in sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True):
userlist.append(user.nick)
return Completion(the_input.auto_completion, userlist, quotify=False)
- def completion_nick(self, the_input):
+ def completion_nick(self, the_input: windows.MessageInput) -> Completion:
"""Completion for /nick"""
- nicks = [
+ nicks_list = [
os.environ.get('USER'),
- config.get('default_nick'),
+ config.getstr('default_nick'),
self.core.get_bookmark_nickname(self.jid.bare)
]
- nicks = [i for i in nicks if i]
+ nicks = [i for i in nicks_list if i]
return Completion(the_input.auto_completion, nicks, '', quotify=False)
- def completion_recolor(self, the_input):
+ def completion_recolor(self, the_input: windows.MessageInput) -> Optional[Completion]:
if the_input.get_argument_position() == 1:
return Completion(
the_input.new_completion, ['random'], 1, '', quotify=False)
- return True
+ return None
- def completion_color(self, the_input):
+ def completion_color(self, the_input: windows.MessageInput) -> Optional[Completion]:
"""Completion for /color"""
n = the_input.get_argument_position(quoted=True)
if n == 1:
@@ -1815,8 +2056,9 @@ class MucTab(ChatTab):
colors.append('random')
return Completion(
the_input.new_completion, colors, 2, '', quotify=False)
+ return None
- def completion_ignore(self, the_input):
+ def completion_ignore(self, the_input: windows.MessageInput) -> Completion:
"""Completion for /ignore"""
userlist = [user.nick for user in self.users]
if self.own_nick in userlist:
@@ -1824,7 +2066,7 @@ class MucTab(ChatTab):
userlist.sort()
return Completion(the_input.auto_completion, userlist, quotify=False)
- def completion_role(self, the_input):
+ def completion_role(self, the_input: windows.MessageInput) -> Optional[Completion]:
"""Completion for /role"""
n = the_input.get_argument_position(quoted=True)
if n == 1:
@@ -1837,8 +2079,9 @@ class MucTab(ChatTab):
possible_roles = ['none', 'visitor', 'participant', 'moderator']
return Completion(
the_input.new_completion, possible_roles, 2, '', quotify=True)
+ return None
- def completion_affiliation(self, the_input):
+ def completion_affiliation(self, the_input: windows.MessageInput) -> Optional[Completion]:
"""Completion for /affiliation"""
n = the_input.get_argument_position(quoted=True)
if n == 1:
@@ -1861,20 +2104,26 @@ class MucTab(ChatTab):
2,
'',
quotify=True)
+ return None
- def completion_invite(self, the_input):
+ def completion_invite(self, the_input: windows.MessageInput) -> Optional[Completion]:
"""Completion for /invite"""
n = the_input.get_argument_position(quoted=True)
if n == 1:
return Completion(
- the_input.new_completion, roster.jids(), 1, quotify=True)
+ the_input.new_completion,
+ [str(i) for i in roster.jids()],
+ argument_position=1,
+ quotify=True)
+ return None
- def completion_topic(self, the_input):
+ def completion_topic(self, the_input: windows.MessageInput) -> Optional[Completion]:
if the_input.get_argument_position() == 1:
return Completion(
the_input.auto_completion, [self.topic], '', quotify=False)
+ return None
- def completion_quoted(self, the_input):
+ def completion_quoted(self, the_input: windows.MessageInput) -> Optional[Completion]:
"""Nick completion, but with quotes"""
if the_input.get_argument_position(quoted=True) == 1:
word_list = []
@@ -1884,16 +2133,23 @@ class MucTab(ChatTab):
return Completion(
the_input.new_completion, word_list, 1, quotify=True)
+ return None
- def completion_unignore(self, the_input):
+ def completion_unignore(self, the_input: windows.MessageInput) -> Optional[Completion]:
if the_input.get_argument_position() == 1:
users = [user.nick for user in self.ignores]
return Completion(the_input.auto_completion, users, quotify=False)
+ return None
+
+ def completion_request_voice(self, the_input: windows.MessageInput) -> Optional[Completion]:
+ """Completion for /request_voice"""
+ allowed = ['participant', 'moderator']
+ return Completion(the_input.auto_completion, allowed, quotify=False)
########################## REGISTER STUFF ##############################
- def register_keys(self):
+ def register_keys(self) -> None:
"Register tab-specific keys"
self.key_func['^I'] = self.completion
self.key_func['M-u'] = self.scroll_user_list_down
@@ -1901,7 +2157,7 @@ class MucTab(ChatTab):
self.key_func['M-n'] = self.go_to_next_hl
self.key_func['M-p'] = self.go_to_prev_hl
- def register_commands(self):
+ def register_commands(self) -> None:
"Register tab-specific commands"
self.register_commands_batch([{
'name': 'ignore',
@@ -2029,15 +2285,23 @@ class MucTab(ChatTab):
'shortdesc':
'Leave the room.'
}, {
+ 'name': 'leave',
+ 'func': self.command_leave,
+ 'usage': '[message]',
+ 'desc': 'Deprecated alias for /close',
+ 'shortdesc': 'Leave the room.'
+ }, {
'name':
'close',
'func':
self.command_close,
'usage':
'[message]',
- 'desc': ('Disconnect from a room and close the tab.'
- ' You can specify an optional message if '
- 'you are still connected.'),
+ 'desc': ('Disconnect from a room and close the tab. '
+ 'You can specify an optional message if '
+ 'you are still connected. If synchronise_open_tabs '
+ 'is true, also disconnect you from your other '
+ 'clients.'),
'shortdesc':
'Close the tab.'
}, {
@@ -2059,12 +2323,11 @@ class MucTab(ChatTab):
'func':
self.command_recolor,
'usage':
- '[random]',
- 'desc': ('Re-assign a color to all participants of the'
- ' current room, based on the last time they talked.'
- ' Use this if the participants currently talking '
- 'have too many identical colors. Use /recolor random'
- ' for a non-deterministic result.'),
+ '',
+ 'desc': (
+ 'Re-assign a color to all participants of the room '
+ 'if the theme has changed.'
+ ),
'shortdesc':
'Change the nicks colors.',
'completion':
@@ -2081,7 +2344,7 @@ class MucTab(ChatTab):
'shortdesc':
'Fix a color for a nick.',
'completion':
- self.completion_recolor
+ self.completion_color
}, {
'name':
'cycle',
@@ -2152,6 +2415,19 @@ class MucTab(ChatTab):
'Invite a contact to this room',
'completion':
self.completion_invite
+ }, {
+ 'name':
+ 'request_voice',
+ 'func':
+ self.command_request_voice,
+ 'desc':
+ 'Request voice when we are a visitor in a moderated room',
+ 'usage':
+ '[role]',
+ 'shortdesc':
+ 'Request voice in a moderated room',
+ 'completion':
+ self.completion_request_voice
}])
@@ -2159,7 +2435,7 @@ class PresenceError(Exception):
pass
-def dissect_presence(presence):
+def dissect_presence(presence: Presence) -> Tuple[str, str, str, str, str, str, JID, str]:
"""
Extract relevant information from a presence
"""
diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py
index 8d2c1b11..1909e3c1 100644
--- a/poezio/tabs/privatetab.py
+++ b/poezio/tabs/privatetab.py
@@ -10,22 +10,30 @@ 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
from typing import Dict, Callable
from slixmpp import JID
+from slixmpp.stanza import Message as SMessage
from poezio.tabs import OneToOneTab, MucTab, Tab
+from poezio import common
from poezio import windows
from poezio import xhtml
-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
-from poezio.logger import logger
from poezio.theming import get_theme, dump_tuple
from poezio.decorators import command_args_parser
+from poezio.text_buffer import CorrectionError
+from poezio.ui.types import (
+ Message,
+ PersistentInfoMessage,
+)
log = logging.getLogger(__name__)
@@ -34,16 +42,14 @@ class PrivateTab(OneToOneTab):
"""
The tab containing a private conversation (someone from a MUC)
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
message_type = 'chat'
- additional_information = {} # type: Dict[str, Callable[[str], str]]
+ additional_information: Dict[str, Callable[[str], str]] = {}
- def __init__(self, core, jid, nick):
- OneToOneTab.__init__(self, core, jid)
+ def __init__(self, core, jid, nick, initial=None):
+ OneToOneTab.__init__(self, core, jid, initial)
self.own_nick = nick
- self.text_win = windows.TextWin()
- self._text_buffer.add_window(self.text_win)
self.info_header = windows.PrivateInfoWin()
self.input = windows.MessageInput()
# keys
@@ -67,6 +73,11 @@ class PrivateTab(OneToOneTab):
self.update_commands()
self.update_keys()
+ @property
+ def log_name(self) -> str:
+ """Overriden from ChatTab because this is a case where we want the full JID"""
+ return self.jid.full
+
def remote_user_color(self):
user = self.parent_muc.get_user_by_name(self.jid.resource)
if user:
@@ -74,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):
@@ -103,14 +114,6 @@ class PrivateTab(OneToOneTab):
def remove_information_element(plugin_name):
del PrivateTab.additional_information[plugin_name]
- def log_message(self, txt, nickname, time=None, typ=1):
- """
- Log the messages in the archives.
- """
- if not logger.log_message(
- self.jid.full, nickname, txt, date=time, typ=typ):
- self.core.information('Unable to write in the log file', 'Error')
-
def on_close(self):
super().on_close()
self.parent_muc.privates.remove(self)
@@ -126,7 +129,7 @@ class PrivateTab(OneToOneTab):
compare_users = lambda x: x.last_talked
word_list = [user.nick for user in sorted(self.parent_muc.users, key=compare_users, reverse=True)\
if user.nick != self.own_nick]
- after = config.get('after_completion') + ' '
+ after = config.getstr('after_completion') + ' '
input_pos = self.input.pos
if ' ' not in self.input.get_text()[:input_pos] or (self.input.last_completion and\
self.input.get_text()[:input_pos] == self.input.last_completion + after):
@@ -139,29 +142,87 @@ class PrivateTab(OneToOneTab):
and not self.input.get_text().startswith('//'))
self.send_composing_chat_state(empty_after)
+ 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
+ sender_nick = with_nick
+ if sent:
+ sender_nick = (self.own_nick or self.core.own_nick)
+ room_from = jid.bare
+ use_xhtml = config.get_by_tabname(
+ 'enable_xhtml_im',
+ jid.bare
+ )
+ tmp_dir = get_image_cache()
+ if not sent:
+ 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:
+ return
+ delayed, date = common.find_delayed_tag(message)
+ replaced = False
+ user = self.parent_muc.get_user_by_name(with_nick)
+ if message.get_plugin('replace', check=True):
+ replaced_id = message['replace']['id']
+ if replaced_id != '' and config.get_by_tabname(
+ 'group_corrections', room_from):
+ try:
+ self.modify_message(
+ body,
+ replaced_id,
+ message['id'],
+ user=user,
+ time=date,
+ jid=message['from'],
+ nickname=sender_nick)
+ replaced = True
+ except CorrectionError:
+ log.debug('Unable to correct a message', exc_info=True)
+ if not replaced:
+ msg = Message(
+ txt=body,
+ time=date,
+ history=delayed,
+ nickname=sender_nick,
+ nick_color=get_theme().COLOR_OWN_NICK if sent else None,
+ user=user,
+ identifier=message['id'],
+ jid=message['from'],
+ )
+ if display:
+ self.add_message(msg)
+ else:
+ self.log_message(msg)
+ if sent:
+ self.set_last_sent_message(message, correct=replaced)
+ else:
+ self.last_remote_message = datetime.now()
+
@refresh_wrapper.always
@command_args_parser.raw
- def command_say(self, line, attention=False, correct=False):
+ 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 = self.core.xmpp.make_message(
+ msg: SMessage = self.core.xmpp.make_message(
mto=self.jid.full,
mfrom=our_jid,
)
msg['type'] = 'chat'
msg['body'] = line
+ msg.enable('muc')
# trigger the event BEFORE looking for colors.
# This lets a plugin insert \x19xxx} colors, that will
# be converted in xhtml.
self.core.events.trigger('private_say', msg, self)
if not msg['body']:
return
- user = self.parent_muc.get_user_by_name(self.own_nick)
- replaced = False
- if correct or msg['replace']['id']:
- msg['replace']['id'] = self.last_sent_message['id']
+ if correct or msg['replace']['id'] and self.last_sent_message:
+ msg['replace']['id'] = self.last_sent_message['id'] # type: ignore
else:
del msg['replace']
@@ -170,29 +231,32 @@ class PrivateTab(OneToOneTab):
msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body'])
msg['body'] = xhtml.clean_text(msg['body'])
if config.get_by_tabname('send_chat_states', self.general_jid):
- needed = 'inactive' if self.inactive else 'active'
- msg['chat_state'] = needed
+ if self.inactive:
+ self.send_chat_state('inactive', always_send=True)
+ else:
+ msg['chat_state'] = 'active'
if attention:
msg['attention'] = True
self.core.events.trigger('private_say_after', msg, self)
if not msg['body']:
return
- self.last_sent_message = msg
- self.core.handler.on_groupchat_private_message(msg, sent=True)
- msg._add_receipt = True
+ self.set_last_sent_message(msg, correct=correct)
+ await self.core.handler.on_groupchat_private_message(msg, sent=True)
+ # Our receipts slixmpp hack
+ msg._add_receipt = True # type: ignore
msg.send()
self.cancel_paused_delay()
@command_args_parser.quoted(0, 1)
- def command_version(self, args):
+ async def command_version(self, args):
"""
/version
"""
if args:
- return self.core.command.version(args[0])
+ return await self.core.command.version(args[0])
jid = self.jid.full
- self.core.xmpp.plugin['xep_0092'].get_version(
- jid, callback=self.core.handler.on_version_result)
+ iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid)
+ self.core.handler.on_version_result(iq)
@command_args_parser.quoted(0, 1)
def command_info(self, arg):
@@ -217,8 +281,8 @@ class PrivateTab(OneToOneTab):
self.text_win.resize(
self.height - 2 - info_win_height - tab_win_height, self.width, 0,
- 0)
- self.text_win.rebuild_everything(self._text_buffer)
+ 0, self._text_buffer, force=self.ui_config_changed)
+ self.ui_config_changed = False
self.info_header.resize(
1, self.width, self.height - 2 - info_win_height - tab_win_height,
0)
@@ -296,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):
"""
@@ -306,16 +367,18 @@ class PrivateTab(OneToOneTab):
display a message.
"""
self.add_message(
- '\x19%(nick_col)s}%(old)s\x19%(info_col)s} is now '
- 'known as \x19%(nick_col)s}%(new)s' % {
- 'old': old_nick,
- 'new': user.nick,
- 'nick_col': dump_tuple(user.color),
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x19%(nick_col)s}%(old)s\x19%(info_col)s} is now '
+ 'known as \x19%(nick_col)s}%(new)s' % {
+ 'old': old_nick,
+ 'new': user.nick,
+ 'nick_col': dump_tuple(user.color),
+ 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ },
+ ),
+ )
new_jid = self.jid.bare + '/' + user.nick
- self.name = new_jid
+ self._name = new_jid
return self.core.tabs.current_tab is self
@refresh_wrapper.conditional
@@ -333,28 +396,32 @@ class PrivateTab(OneToOneTab):
if not status_message:
self.add_message(
- '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}'
- '%(nick)s\x19%(info_col)s} has left the room' % {
- 'nick': user.nick,
- 'spec': theme.CHAR_QUIT,
- 'nick_col': color,
- 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR),
- 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}'
+ '%(nick)s\x19%(info_col)s} has left the room' % {
+ 'nick': user.nick,
+ 'spec': theme.CHAR_QUIT,
+ 'nick_col': color,
+ 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR),
+ 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ },
+ ),
+ )
else:
self.add_message(
- '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}'
- '%(nick)s\x19%(info_col)s} has left the room'
- ' (%(status)s)' % {
- 'status': status_message,
- 'nick': user.nick,
- 'spec': theme.CHAR_QUIT,
- 'nick_col': color,
- 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR),
- 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}'
+ '%(nick)s\x19%(info_col)s} has left the room'
+ ' (%(status)s)' % {
+ 'status': status_message,
+ 'nick': user.nick,
+ 'spec': theme.CHAR_QUIT,
+ 'nick_col': color,
+ 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR),
+ 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ },
+ ),
+ )
return self.core.tabs.current_tab is self
@refresh_wrapper.conditional
@@ -363,7 +430,6 @@ class PrivateTab(OneToOneTab):
The user (or at least someone with the same nick) came back in the MUC
"""
self.activate()
- self.check_features()
tab = self.parent_muc
theme = get_theme()
color = dump_tuple(theme.COLOR_REMOTE_USER)
@@ -373,26 +439,28 @@ class PrivateTab(OneToOneTab):
if user:
color = dump_tuple(user.color)
self.add_message(
- '\x19%(join_col)s}%(spec)s \x19%(color)s}%(nick)s\x19'
- '%(info_col)s} joined the room' % {
- 'nick': nick,
- 'color': color,
- 'spec': theme.CHAR_JOIN,
- 'join_col': dump_tuple(theme.COLOR_JOIN_CHAR),
- 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x19%(join_col)s}%(spec)s \x19%(color)s}%(nick)s\x19'
+ '%(info_col)s} joined the room' % {
+ 'nick': nick,
+ 'color': color,
+ 'spec': theme.CHAR_JOIN,
+ 'join_col': dump_tuple(theme.COLOR_JOIN_CHAR),
+ 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ },
+ ),
+ )
return self.core.tabs.current_tab is self
def activate(self, reason=None):
self.on = True
if reason:
- self.add_message(txt=reason, typ=2)
+ self.add_message(PersistentInfoMessage(reason))
def deactivate(self, reason=None):
self.on = False
if reason:
- self.add_message(txt=reason, typ=2)
+ self.add_message(PersistentInfoMessage(reason))
def matching_names(self):
return [(3, self.jid.resource), (4, self.name)]
@@ -402,9 +470,11 @@ class PrivateTab(OneToOneTab):
error = '\x19%s}%s\x19o' % (dump_tuple(theme.COLOR_CHAR_NACK),
error_message)
self.add_message(
- error,
- highlight=True,
- nickname='Error',
- nick_color=theme.COLOR_ERROR_MSG,
- typ=2)
+ Message(
+ error,
+ highlight=True,
+ nickname='Error',
+ nick_color=theme.COLOR_ERROR_MSG,
+ ),
+ )
self.core.refresh_window()
diff --git a/poezio/tabs/rostertab.py b/poezio/tabs/rostertab.py
index 6f43cca1..18334c20 100644
--- a/poezio/tabs/rostertab.py
+++ b/poezio/tabs/rostertab.py
@@ -14,44 +14,36 @@ 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
-from poezio import common
from poezio import windows
-from poezio.common import safeJID, shell_split
+from poezio.common import shell_split
from poezio.config import config
from poezio.contact import Contact, Resource
from poezio.decorators import refresh_wrapper
from poezio.roster import RosterGroup, roster
from poezio.theming import get_theme, dump_tuple
-from poezio.decorators import command_args_parser
+from poezio.decorators import command_args_parser, deny_anonymous
from poezio.core.structs import Command, Completion
from poezio.tabs import Tab
+from poezio.ui.types import InfoMessage
log = logging.getLogger(__name__)
-def deny_anonymous(func: Callable) -> Callable:
- def wrap(self: 'RosterInfoTab', *args, **kwargs):
- if self.core.xmpp.anon:
- return self.core.information(
- 'This command is not available for anonymous accounts.',
- 'Info'
- )
- return func(self, *args, **kwargs)
- return wrap
-
-
class RosterInfoTab(Tab):
"""
A tab, split in two, containing the roster and infos
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core):
Tab.__init__(self, core)
- self.name = "Roster"
+ self._name = "Roster"
self.v_separator = windows.VerticalSeparator()
self.information_win = windows.TextWin()
self.core.information_buffer.add_window(self.information_win)
@@ -83,15 +75,6 @@ class RosterInfoTab(Tab):
self.key_func["S"] = self.start_search_slow
self.key_func["n"] = self.change_contact_name
self.register_command(
- 'deny',
- self.command_deny,
- usage='[jid]',
- desc='Deny your presence to the provided JID (or the '
- 'selected contact in your roster), who is asking'
- 'you to be in their roster.',
- shortdesc='Deny a user your presence.',
- completion=self.completion_deny)
- self.register_command(
'name',
self.command_name,
usage='<jid> [name]',
@@ -119,16 +102,6 @@ class RosterInfoTab(Tab):
shortdesc='Remove a user from a group.',
completion=self.completion_groupremove)
self.register_command(
- 'remove',
- self.command_remove,
- usage='[jid]',
- desc='Remove the specified JID from your roster. This '
- 'will unsubscribe you from its presence, cancel '
- 'its subscription to yours, and remove the item '
- 'from your roster.',
- shortdesc='Remove a user from your roster.',
- completion=self.completion_remove)
- self.register_command(
'export',
self.command_export,
usage='[/path/to/file]',
@@ -226,50 +199,40 @@ 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
- def command_certs(self):
+ async def command_certs(self):
"""
/certs
"""
-
- def cb(iq):
- if iq['type'] == 'error':
- self.core.information(
- 'Unable to retrieve the certificate list.', 'Error')
- return
- certs = []
- for item in iq['sasl_certs']['items']:
- users = '\n'.join(item['users'])
- certs.append((item['name'], users))
-
- if not certs:
- return self.core.information('No certificates found', 'Info')
- msg = 'Certificates:\n'
- msg += '\n'.join(
- ((' %s%s' % (item[0] + (': ' if item[1] else ''), item[1]))
- for item in certs))
- self.core.information(msg, 'Info')
-
- self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb, timeout=3)
+ try:
+ iq = await self.core.xmpp.plugin['xep_0257'].get_certs(timeout=3)
+ except (IqError, IqTimeout):
+ self.core.information(
+ 'Unable to retrieve the certificate list.', 'Error')
+ return
+ certs = []
+ for item in iq['sasl_certs']['items']:
+ users = '\n'.join(item['users'])
+ certs.append((item['name'], users))
+
+ if not certs:
+ return self.core.information('No certificates found', 'Info')
+ msg = 'Certificates:\n'
+ msg += '\n'.join(
+ ((' %s%s' % (item[0] + (': ' if item[1] else ''), item[1]))
+ for item in certs))
+ self.core.information(msg, 'Info')
@command_args_parser.quoted(2, 1)
- def command_cert_add(self, args):
+ async def command_cert_add(self, args):
"""
/cert_add <name> <certfile> [cert-management]
"""
if not args or len(args) < 2:
return self.core.command.help('cert_add')
-
- def cb(iq):
- if iq['type'] == 'error':
- self.core.information('Unable to add the certificate.',
- 'Error')
- else:
- self.core.information('Certificate added.', 'Info')
-
name = args[0]
try:
@@ -295,8 +258,17 @@ class RosterInfoTab(Tab):
else:
management = True
- self.core.xmpp.plugin['xep_0257'].add_cert(
- name, crt, callback=cb, allow_management=management)
+ try:
+ await self.core.xmpp.plugin['xep_0257'].add_cert(
+ name,
+ crt,
+ allow_management=management
+ )
+ self.core.information('Certificate added.', 'Info')
+ except (IqError, IqTimeout):
+ self.core.information('Unable to add the certificate.',
+ 'Error')
+
def completion_cert_add(self, the_input):
"""
@@ -312,76 +284,62 @@ class RosterInfoTab(Tab):
return Completion(the_input.new_completion, ['true', 'false'], n)
@command_args_parser.quoted(1)
- def command_cert_disable(self, args):
+ async def command_cert_disable(self, args):
"""
/cert_disable <name>
"""
if not args:
return self.core.command.help('cert_disable')
-
- def cb(iq):
- if iq['type'] == 'error':
- self.core.information('Unable to disable the certificate.',
- 'Error')
- else:
- self.core.information('Certificate disabled.', 'Info')
-
name = args[0]
-
- self.core.xmpp.plugin['xep_0257'].disable_cert(name, callback=cb)
+ try:
+ await self.core.xmpp.plugin['xep_0257'].disable_cert(name)
+ self.core.information('Certificate disabled.', 'Info')
+ except (IqError, IqTimeout):
+ self.core.information('Unable to disable the certificate.',
+ 'Error')
@command_args_parser.quoted(1)
- def command_cert_revoke(self, args):
+ async def command_cert_revoke(self, args):
"""
/cert_revoke <name>
"""
if not args:
return self.core.command.help('cert_revoke')
-
- def cb(iq):
- if iq['type'] == 'error':
- self.core.information('Unable to revoke the certificate.',
- 'Error')
- else:
- self.core.information('Certificate revoked.', 'Info')
-
name = args[0]
-
- self.core.xmpp.plugin['xep_0257'].revoke_cert(name, callback=cb)
+ try:
+ await self.core.xmpp.plugin['xep_0257'].revoke_cert(name)
+ self.core.information('Certificate revoked.', 'Info')
+ except (IqError, IqTimeout):
+ self.core.information('Unable to revoke the certificate.',
+ 'Error')
@command_args_parser.quoted(2)
- def command_cert_fetch(self, args):
+ async def command_cert_fetch(self, args):
"""
/cert_fetch <name> <path>
"""
if not args or len(args) < 2:
return self.core.command.help('cert_fetch')
-
- def cb(iq):
- if iq['type'] == 'error':
- self.core.information('Unable to fetch the certificate.',
- 'Error')
- return
-
- cert = None
- for item in iq['sasl_certs']['items']:
- if item['name'] == name:
- cert = base64.b64decode(item['x509cert'])
- break
-
- if not cert:
- return self.core.information('Certificate not found.', 'Info')
-
- cert = ssl.DER_cert_to_PEM_cert(cert)
- with open(path, 'w') as fd:
- fd.write(cert)
-
- self.core.information('File stored at %s' % path, 'Info')
-
name = args[0]
path = args[1]
- self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb)
+ try:
+ iq = await self.core.xmpp.plugin['xep_0257'].get_certs()
+ except (IqError, IqTimeout):
+ self.core.information('Unable to fetch the certificate.',
+ 'Error')
+ return
+ cert = None
+ for item in iq['sasl_certs']['items']:
+ if item['name'] == name:
+ cert = base64.b64decode(item['x509cert'])
+ break
+ if not cert:
+ return self.core.information('Certificate not found.', 'Info')
+ cert = ssl.DER_cert_to_PEM_cert(cert)
+ with open(path, 'w') as fd:
+ fd.write(cert)
+ self.core.information('File stored at %s' % path, 'Info')
def completion_cert_fetch(self, the_input):
"""
@@ -402,32 +360,30 @@ class RosterInfoTab(Tab):
if not tab:
log.debug('Received message from nonexistent tab: %s',
message['from'])
- message = '\x19%(info_col)s}Cannot send message to %(jid)s: contact blocked' % {
+ message = 'Cannot send message to %(jid)s: contact blocked' % {
'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
'jid': message['from'],
}
- tab.add_message(message)
+ tab.add_message(InfoMessage(message))
@command_args_parser.ignored
- def command_list_blocks(self):
+ async def command_list_blocks(self):
"""
/list_blocks
"""
-
- def callback(iq):
- if iq['type'] == 'error':
- return self.core.information(
- 'Could not retrieve the blocklist.', 'Error')
- s = 'List of blocked JIDs:\n'
- items = (str(item) for item in iq['blocklist']['items'])
- jids = '\n'.join(items)
- if jids:
- s += jids
- else:
- s = 'No blocked JIDs.'
- self.core.information(s, 'Info')
-
- self.core.xmpp.plugin['xep_0191'].get_blocked(callback=callback)
+ try:
+ iq = await self.core.xmpp.plugin['xep_0191'].get_blocked()
+ except (IqError, IqTimeout) as iq:
+ return self.core.information(
+ 'Could not retrieve the blocklist.', 'Error')
+ s = 'List of blocked JIDs:\n'
+ items = (str(item) for item in iq['blocklist']['items'])
+ jids = '\n'.join(items)
+ if jids:
+ s += jids
+ else:
+ s = 'No blocked JIDs.'
+ self.core.information(s, 'Info')
@command_args_parser.ignored
def command_disconnect(self):
@@ -478,7 +434,9 @@ class RosterInfoTab(Tab):
roster_width)
self.information_win.resize(
self.height - 1 - tab_win_height - contact_win_h, info_width,
- 0, roster_width + 1, self.core.information_buffer)
+ 0, roster_width + 1, self.core.information_buffer,
+ force=self.ui_config_changed)
+ self.ui_config_changed = False
if display_contact_win:
y = self.height - tab_win_height - contact_win_h - 1
avatar_width = contact_win_h * 2
@@ -552,64 +510,34 @@ class RosterInfoTab(Tab):
@deny_anonymous
@command_args_parser.quoted(1)
- def command_password(self, args):
+ async def command_password(self, args):
"""
/password <password>
"""
-
- def callback(iq):
- if iq['type'] == 'result':
- self.core.information('Password updated', 'Account')
- if config.get('password'):
- config.silent_set('password', args[0])
- else:
- self.core.information('Unable to change the password',
- 'Account')
-
- self.core.xmpp.plugin['xep_0077'].change_password(
- args[0], callback=callback)
-
- @deny_anonymous
- @command_args_parser.quoted(0, 1)
- def command_deny(self, args):
- """
- /deny [jid]
- Denies a JID from our roster
- """
- if not args:
- item = self.roster_win.selected_row
- if isinstance(item, Contact):
- jid = item.bare_jid
- else:
- self.core.information('No subscription to deny', 'Warning')
- return
- else:
- jid = safeJID(args[0]).bare
- if jid not in [jid for jid in roster.jids()]:
- self.core.information('No subscription to deny', 'Warning')
- return
-
- contact = roster[jid]
- if contact:
- contact.unauthorize()
- self.core.information('Subscription to %s was revoked' % jid,
- 'Roster')
+ try:
+ await self.core.xmpp.plugin['xep_0077'].change_password(
+ args[0]
+ )
+ self.core.information('Password updated', 'Account')
+ if config.getstr('password'):
+ config.silent_set('password', args[0])
+ except (IqError, IqTimeout):
+ self.core.information('Unable to change the password',
+ 'Account')
@deny_anonymous
@command_args_parser.quoted(1, 1)
- def command_name(self, args):
+ async def command_name(self, args):
"""
Set a name for the specified JID in your roster
"""
-
- def callback(iq):
- if not iq:
- self.core.information('The name could not be set.', 'Error')
- log.debug('Error in /name:\n%s', iq)
-
if args is None:
return self.core.command.help('name')
- jid = safeJID(args[0]).bare
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ self.core.information(f'Invalid JID: {args[0]}', 'Error')
+ return
name = args[1] if len(args) == 2 else ''
contact = roster[jid]
@@ -621,16 +549,19 @@ class RosterInfoTab(Tab):
if 'none' in groups:
groups.remove('none')
subscription = contact.subscription
- self.core.xmpp.update_roster(
- jid,
- name=name,
- groups=groups,
- subscription=subscription,
- callback=callback)
+ try:
+ await self.core.xmpp.update_roster(
+ jid,
+ name=name,
+ groups=groups,
+ subscription=subscription
+ )
+ except (IqError, IqTimeout):
+ self.core.information('The name could not be set.', 'Error')
@deny_anonymous
@command_args_parser.quoted(1, 1)
- def command_groupadd(self, args):
+ async def command_groupadd(self, args):
"""
Add the specified JID to the specified group
"""
@@ -646,7 +577,11 @@ class RosterInfoTab(Tab):
else:
return self.core.command.help('groupadd')
else:
- jid = safeJID(args[0]).bare
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ self.core.information(f'Invalid JID: {args[0]}', 'Error')
+ return
group = args[1]
contact = roster[jid]
@@ -669,29 +604,31 @@ class RosterInfoTab(Tab):
name = contact.name
subscription = contact.subscription
- def callback(iq):
- if iq:
- roster.update_contact_groups(jid)
- else:
- self.core.information('The group could not be set.', 'Error')
- log.debug('Error in groupadd:\n%s', iq)
- self.core.xmpp.update_roster(
- jid,
- name=name,
- groups=new_groups,
- subscription=subscription,
- callback=callback)
+ try:
+ await self.core.xmpp.update_roster(
+ jid,
+ name=name,
+ groups=new_groups,
+ subscription=subscription,
+ )
+ roster.update_contact_groups(jid)
+ except (IqError, IqTimeout):
+ self.core.information('The group could not be set.', 'Error')
@deny_anonymous
@command_args_parser.quoted(3)
- def command_groupmove(self, args):
+ async def command_groupmove(self, args):
"""
Remove the specified JID from the first specified group and add it to the second one
"""
if args is None:
return self.core.command.help('groupmove')
- jid = safeJID(args[0]).bare
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ self.core.information(f'Invalid JID: {args[0]}', 'Error')
+ return
group_from = args[1]
group_to = args[2]
@@ -728,31 +665,31 @@ class RosterInfoTab(Tab):
new_groups.remove(group_from)
name = contact.name
subscription = contact.subscription
-
- def callback(iq):
- if iq:
- roster.update_contact_groups(contact)
- else:
- self.core.information('The group could not be set', 'Error')
- log.debug('Error in groupmove:\n%s', iq)
-
- self.core.xmpp.update_roster(
- jid,
- name=name,
- groups=new_groups,
- subscription=subscription,
- callback=callback)
+ try:
+ await self.core.xmpp.update_roster(
+ jid,
+ name=name,
+ groups=new_groups,
+ subscription=subscription,
+ )
+ roster.update_contact_groups(contact)
+ except (IqError, IqTimeout):
+ self.core.information('The group could not be set', 'Error')
@deny_anonymous
@command_args_parser.quoted(2)
- def command_groupremove(self, args):
+ async def command_groupremove(self, args):
"""
Remove the specified JID from the specified group
"""
if args is None:
return self.core.command.help('groupremove')
- jid = safeJID(args[0]).bare
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ self.core.information(f'Invalid JID: {args[0]}', 'Error')
+ return
group = args[1]
contact = roster[jid]
@@ -774,39 +711,16 @@ class RosterInfoTab(Tab):
new_groups.remove(group)
name = contact.name
subscription = contact.subscription
-
- def callback(iq):
- if iq:
- roster.update_contact_groups(jid)
- else:
- self.core.information('The group could not be set')
- log.debug('Error in groupremove:\n%s', iq)
-
- self.core.xmpp.update_roster(
- jid,
- name=name,
- groups=new_groups,
- subscription=subscription,
- callback=callback)
-
- @deny_anonymous
- @command_args_parser.quoted(0, 1)
- def command_remove(self, args):
- """
- Remove the specified JID from the roster. i.e.: unsubscribe
- from its presence, and cancel its subscription to our.
- """
- if args:
- jid = safeJID(args[0]).bare
- else:
- item = self.roster_win.selected_row
- if isinstance(item, Contact):
- jid = item.bare_jid
- else:
- self.core.information('No roster item to remove', 'Error')
- return
- roster.remove(jid)
- del roster[jid]
+ try:
+ self.core.xmpp.update_roster(
+ jid,
+ name=name,
+ groups=new_groups,
+ subscription=subscription,
+ )
+ roster.update_contact_groups(jid)
+ except (IqError, IqTimeout):
+ self.core.information('The group could not be set')
@deny_anonymous
@command_args_parser.quoted(0, 1)
@@ -932,16 +846,6 @@ class RosterInfoTab(Tab):
the_input.new_completion, groups, n, '', quotify=True)
return False
- def completion_deny(self, the_input):
- """
- Complete the first argument from the list of the
- contact with ask=='subscribe'
- """
- jids = sorted(
- str(contact.bare_jid) for contact in roster.contacts.values()
- if contact.pending_in)
- return Completion(the_input.new_completion, jids, 1, '', quotify=False)
-
def refresh(self):
if self.need_resize:
self.resize()
@@ -982,7 +886,7 @@ class RosterInfoTab(Tab):
Show or hide offline contacts
"""
option = 'roster_show_offline'
- value = config.get(option)
+ value = config.getbool(option)
success = config.silent_set(option, str(not value))
roster.modified()
if not success:
@@ -1126,15 +1030,6 @@ class RosterInfoTab(Tab):
'%s connected resource%s' % (len(cont), ''
if len(cont) == 1 else 's'))
acc.append('Current status: %s' % res.status)
- if cont.tune:
- acc.append('Tune: %s' % common.format_tune_string(cont.tune))
- if cont.mood:
- acc.append('Mood: %s' % cont.mood)
- if cont.activity:
- acc.append('Activity: %s' % cont.activity)
- if cont.gaming:
- acc.append(
- 'Game: %s' % (common.format_gaming_string(cont.gaming)))
msg = '\n'.join(acc)
elif isinstance(selected_row, Resource):
res = selected_row
@@ -1160,7 +1055,7 @@ class RosterInfoTab(Tab):
if isinstance(selected_row, Contact):
jid = selected_row.bare_jid
elif isinstance(selected_row, Resource):
- jid = safeJID(selected_row.jid).bare
+ jid = JID(selected_row.jid).bare
else:
return
self.on_slash()
@@ -1242,8 +1137,11 @@ def jid_and_name_match(contact, txt):
if not txt:
return True
txt = txt.lower()
- if txt in safeJID(contact.bare_jid).bare.lower():
- return True
+ try:
+ if txt in JID(contact.bare_jid).bare.lower():
+ return True
+ except InvalidJID:
+ pass
if txt in contact.name.lower():
return True
return False
@@ -1256,9 +1154,12 @@ def jid_and_name_match_slow(contact, txt):
"""
if not txt:
return True # Everything matches when search is empty
- user = safeJID(contact.bare_jid).bare
- if diffmatch(txt, user):
- return True
+ try:
+ user = JID(contact.bare_jid).bare
+ if diffmatch(txt, user):
+ return True
+ except InvalidJID:
+ pass
if contact.name and diffmatch(txt, contact.name):
return True
return False
diff --git a/poezio/tabs/xmltab.py b/poezio/tabs/xmltab.py
index c4a50df8..939af67d 100644
--- a/poezio/tabs/xmltab.py
+++ b/poezio/tabs/xmltab.py
@@ -10,7 +10,8 @@ log = logging.getLogger(__name__)
import curses
import os
-from slixmpp.xmlstream import matcher
+from slixmpp import JID, InvalidJID
+from slixmpp.xmlstream import matcher, StanzaBase
from slixmpp.xmlstream.tostring import tostring
from slixmpp.xmlstream.stanzabase import ElementBase
from xml.etree import ElementTree as ET
@@ -21,17 +22,16 @@ from poezio import text_buffer
from poezio import windows
from poezio.xhtml import clean_text
from poezio.decorators import command_args_parser, refresh_wrapper
-from poezio.common import safeJID
class MatchJID:
- def __init__(self, jid, dest=''):
+ def __init__(self, jid: JID, dest: str = ''):
self.jid = jid
self.dest = dest
- def match(self, xml):
- from_ = safeJID(xml['from'])
- to_ = safeJID(xml['to'])
+ def match(self, xml: StanzaBase):
+ from_ = xml['from']
+ to_ = xml['to']
if self.jid.full == self.jid.bare:
from_ = from_.bare
to_ = to_.bare
@@ -58,14 +58,14 @@ class XMLTab(Tab):
def __init__(self, core):
Tab.__init__(self, core)
self.state = 'normal'
- self.name = 'XMLTab'
+ self._name = 'XMLTab'
self.filters = []
self.core_buffer = self.core.xml_buffer
self.filtered_buffer = text_buffer.TextBuffer()
self.info_header = windows.XMLInfoWin()
- self.text_win = windows.XMLTextWin()
+ self.text_win = windows.TextWin()
self.core_buffer.add_window(self.text_win)
self.default_help_message = windows.HelpText("/ to enter a command")
@@ -120,7 +120,7 @@ class XMLTab(Tab):
usage='<filename>',
desc='Writes the content of the XML buffer into a file.',
shortdesc='Write in a file.')
- self.input = self.default_help_message
+ self.input = self.default_help_message # type: ignore
self.key_func['^T'] = self.close
self.key_func['^I'] = self.completion
self.key_func["KEY_DOWN"] = self.on_scroll_down
@@ -173,7 +173,7 @@ class XMLTab(Tab):
self.text_win.toggle_lock()
self.refresh()
- def match_stanza(self, stanza):
+ def match_stanza(self, stanza) -> bool:
for matcher_ in self.filters:
if not matcher_.match(stanza):
return False
@@ -190,33 +190,36 @@ class XMLTab(Tab):
self.command_filter_reset()
@command_args_parser.raw
- def command_filter_to(self, jid):
+ def command_filter_to(self, jid_str: str):
"""/filter_jid_to <jid>"""
- jid_obj = safeJID(jid)
- if not jid_obj:
+ try:
+ jid = JID(jid_str)
+ except InvalidJID:
return self.core.information('Invalid JID: %s' % jid, 'Error')
- self.update_filters(MatchJID(jid_obj, dest='to'))
+ self.update_filters(MatchJID(jid, dest='to'))
self.refresh()
@command_args_parser.raw
- def command_filter_from(self, jid):
+ def command_filter_from(self, jid_str: str):
"""/filter_jid_from <jid>"""
- jid_obj = safeJID(jid)
- if not jid_obj:
+ try:
+ jid = JID(jid_str)
+ except InvalidJID:
return self.core.information('Invalid JID: %s' % jid, 'Error')
- self.update_filters(MatchJID(jid_obj, dest='from'))
+ self.update_filters(MatchJID(jid, dest='from'))
self.refresh()
@command_args_parser.raw
- def command_filter_jid(self, jid):
+ def command_filter_jid(self, jid_str: str):
"""/filter_jid <jid>"""
- jid_obj = safeJID(jid)
- if not jid_obj:
+ try:
+ jid = JID(jid_str)
+ except InvalidJID:
return self.core.information('Invalid JID: %s' % jid, 'Error')
- self.update_filters(MatchJID(jid_obj))
+ self.update_filters(MatchJID(jid))
self.refresh()
@command_args_parser.quoted(1)
@@ -229,7 +232,7 @@ class XMLTab(Tab):
self.refresh()
@command_args_parser.raw
- def command_filter_xpath(self, xpath):
+ def command_filter_xpath(self, xpath: str):
"""/filter_xpath <xpath>"""
try:
self.update_filters(
@@ -262,7 +265,10 @@ class XMLTab(Tab):
else:
xml = self.core_buffer.messages[:]
text = '\n'.join(
- ('%s %s %s' % (msg.str_time, msg.nickname, clean_text(msg.txt))
+ ('%s %s %s' % (
+ msg.time.strftime('%H:%M:%S'),
+ 'IN' if msg.incoming else 'OUT',
+ clean_text(msg.txt))
for msg in xml))
filename = os.path.expandvars(os.path.expanduser(args[0]))
try:
@@ -283,7 +289,7 @@ class XMLTab(Tab):
self.input.do_command("/") # we add the slash
@refresh_wrapper.always
- def reset_help_message(self, _=None):
+ def reset_help_message(self, _=None) -> bool:
if self.closed:
return True
if self.core.tabs.current_tab is self:
@@ -291,10 +297,10 @@ class XMLTab(Tab):
self.input = self.default_help_message
return True
- def on_scroll_up(self):
+ def on_scroll_up(self) -> bool:
return self.text_win.scroll_up(self.text_win.height - 1)
- def on_scroll_down(self):
+ def on_scroll_down(self) -> bool:
return self.text_win.scroll_down(self.text_win.height - 1)
@command_args_parser.ignored
@@ -308,10 +314,11 @@ class XMLTab(Tab):
self.refresh()
self.core.doupdate()
- def execute_slash_command(self, txt):
+ def execute_slash_command(self, txt: str) -> bool:
if txt.startswith('/'):
- self.input.key_enter()
- self.execute_command(txt)
+ if isinstance(self.input, windows.CommandInput):
+ self.input.key_enter()
+ self.execute_command(txt)
return self.reset_help_message()
def completion(self):
diff --git a/poezio/text_buffer.py b/poezio/text_buffer.py
index d9347527..bcee5989 100644
--- a/poezio/text_buffer.py
+++ b/poezio/text_buffer.py
@@ -8,96 +8,35 @@ Each text buffer can be linked to multiple windows, that will be rendered
independently by their TextWins.
"""
+from __future__ import annotations
+
import logging
-log = logging.getLogger(__name__)
-from typing import Union, Optional, List, Tuple
+from typing import (
+ Dict,
+ List,
+ Optional,
+ TYPE_CHECKING,
+ Tuple,
+ Union,
+)
+from dataclasses import dataclass
from datetime import datetime
from poezio.config import config
-from poezio.theming import get_theme, dump_tuple
-
-
-class Message:
- __slots__ = ('txt', 'nick_color', 'time', 'str_time', 'nickname', 'user',
- 'identifier', 'top', 'highlight', 'me', 'old_message', 'revisions',
- 'jid', 'ack')
-
- def __init__(self,
- txt: str,
- time: Optional[datetime],
- nickname: Optional[str],
- nick_color: Optional[Tuple],
- history: bool,
- user: Optional[str],
- identifier: Optional[str],
- top: Optional[bool] = False,
- str_time: Optional[str] = None,
- highlight: bool = False,
- old_message: Optional['Message'] = None,
- revisions: int = 0,
- jid: Optional[str] = None,
- ack: int = 0) -> None:
- """
- Create a new Message object with parameters, check for /me messages,
- and delayed messages
- """
- time = time if time is not None else datetime.now()
- if txt.startswith('/me '):
- me = True
- txt = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_ME_MESSAGE),
- txt[4:])
- else:
- me = False
- str_time = time.strftime("%H:%M:%S")
- if history:
- txt = txt.replace(
- '\x19o',
- '\x19o\x19%s}' % dump_tuple(get_theme().COLOR_LOG_MSG))
- str_time = time.strftime("%Y-%m-%d %H:%M:%S")
-
- self.txt = txt.replace('\t', ' ') + '\x19o'
- self.nick_color = nick_color
- self.time = time
- self.str_time = str_time
- self.nickname = nickname
- self.user = user
- self.identifier = identifier
- self.top = top
- self.highlight = highlight
- self.me = me
- self.old_message = old_message
- self.revisions = revisions
- self.jid = jid
- self.ack = ack
-
- def _other_elems(self) -> str:
- "Helper for the repr_message function"
- acc = []
- fields = list(self.__slots__)
- fields.remove('old_message')
- for field in fields:
- acc.append('%s=%s' % (field, repr(getattr(self, field))))
- return 'Message(%s, %s' % (', '.join(acc), 'old_message=')
-
- def __repr__(self) -> str:
- """
- repr() for the Message class, for debug purposes, since the default
- repr() is recursive, so it can stack overflow given too many revisions
- of a message
- """
- init = self._other_elems()
- acc = [init]
- next_message = self.old_message
- rev = 1
- while next_message is not None:
- acc.append(next_message._other_elems())
- next_message = next_message.old_message
- rev += 1
- acc.append('None')
- while rev:
- acc.append(')')
- rev -= 1
- return ''.join(acc)
+from poezio.ui.types import (
+ BaseMessage,
+ Message,
+ MucOwnJoinMessage,
+ MucOwnLeaveMessage,
+)
+
+if TYPE_CHECKING:
+ from poezio.windows.text_win import TextWin
+ from poezio.user import User
+ from slixmpp import JID
+
+
+log = logging.getLogger(__name__)
class CorrectionError(Exception):
@@ -108,6 +47,15 @@ class AckError(Exception):
pass
+@dataclass
+class HistoryGap:
+ """Class representing a period of non-presence inside a MUC"""
+ leave_message: Optional[BaseMessage]
+ join_message: Optional[BaseMessage]
+ last_timestamp_before_leave: Optional[datetime]
+ first_timestamp_after_join: Optional[datetime]
+
+
class TextBuffer:
"""
This class just keep trace of messages, in a list with various
@@ -117,104 +65,178 @@ class TextBuffer:
def __init__(self, messages_nb_limit: Optional[int] = None) -> None:
if messages_nb_limit is None:
- messages_nb_limit = config.get('max_messages_in_memory')
- self._messages_nb_limit = messages_nb_limit # type: int
+ messages_nb_limit = config.getint('max_messages_in_memory')
+ self._messages_nb_limit: int = messages_nb_limit
# Message objects
- self.messages = [] # type: List[Message]
+ self.messages: List[BaseMessage] = []
+ # COMPAT: Correction id -> Original message id.
+ self.correction_ids: Dict[str, str] = {}
# we keep track of one or more windows
# so we can pass the new messages to them, as they are added, so
# they (the windows) can build the lines from the new message
- self._windows = []
+ self._windows: List[TextWin] = []
def add_window(self, win) -> None:
self._windows.append(win)
+ def find_last_gap_muc(self) -> Optional[HistoryGap]:
+ """Find the last known history gap contained in buffer"""
+ leave: Optional[Tuple[int, BaseMessage]] = None
+ join: Optional[Tuple[int, BaseMessage]] = None
+ for i, item in enumerate(reversed(self.messages)):
+ if isinstance(item, MucOwnLeaveMessage):
+ leave = (len(self.messages) - i - 1, item)
+ break
+ elif join and isinstance(item, MucOwnJoinMessage):
+ leave = (len(self.messages) - i - 1, item)
+ break
+ if isinstance(item, MucOwnJoinMessage):
+ join = (len(self.messages) - i - 1, item)
+
+ last_timestamp = None
+ first_timestamp = datetime.now()
+
+ # Identify the special case when we got disconnected from a chatroom
+ # without receiving or sending the relevant presence, therefore only
+ # having two joins with no leave, and messages in the middle.
+ if leave and join and isinstance(leave[1], MucOwnJoinMessage):
+ for i in range(join[0] - 1, leave[0], - 1):
+ if isinstance(self.messages[i], Message):
+ leave = (
+ i,
+ self.messages[i]
+ )
+ last_timestamp = self.messages[i].time
+ break
+ # If we have a normal gap but messages inbetween, it probably
+ # already has history, so abort there without returning it.
+ if join and leave:
+ for i in range(leave[0] + 1, join[0], 1):
+ if isinstance(self.messages[i], Message):
+ return None
+ elif not (join or leave):
+ return None
+
+ # If a leave message is found, get the last Message timestamp
+ # before it.
+ if leave is None:
+ leave_msg = None
+ elif last_timestamp is None:
+ leave_msg = leave[1]
+ for i in range(leave[0], 0, -1):
+ if isinstance(self.messages[i], Message):
+ last_timestamp = self.messages[i].time
+ break
+ else:
+ leave_msg = leave[1]
+ # If a join message is found, get the first Message timestamp
+ # after it, or the current time.
+ if join is None:
+ join_msg = None
+ else:
+ join_msg = join[1]
+ for i in range(join[0], len(self.messages)):
+ msg = self.messages[i]
+ if isinstance(msg, Message) and msg.time < first_timestamp:
+ first_timestamp = msg.time
+ break
+ return HistoryGap(
+ leave_message=leave_msg,
+ join_message=join_msg,
+ last_timestamp_before_leave=last_timestamp,
+ first_timestamp_after_join=first_timestamp,
+ )
+
+ def get_gap_index(self, gap: HistoryGap) -> Optional[int]:
+ """Find the first index to insert into inside a gap"""
+ if gap.leave_message is None:
+ return 0
+ for i, msg in enumerate(self.messages):
+ if msg is gap.leave_message:
+ return i + 1
+ return None
+
+ def add_history_messages(self, messages: List[BaseMessage], gap: Optional[HistoryGap] = None) -> None:
+ """Insert history messages at their correct place """
+ index = 0
+ new_index = None
+ if gap is not None:
+ new_index = self.get_gap_index(gap)
+ if new_index is None: # Not sure what happened, abort
+ return
+ index = new_index
+ for message in messages:
+ self.messages.insert(index, message)
+ index += 1
+ log.debug('inserted message: %s', message)
+ for window in self._windows: # make the associated windows
+ window.rebuild_everything(self)
+
@property
- def last_message(self) -> Optional[Message]:
+ def last_message(self) -> Optional[BaseMessage]:
return self.messages[-1] if self.messages else None
- def add_message(self,
- txt: str,
- time: Optional[datetime] = None,
- nickname: Optional[str] = None,
- nick_color: Optional[Tuple] = None,
- history: bool = False,
- user: Optional[str] = None,
- highlight: bool = False,
- top: Optional[bool] = False,
- identifier: Optional[str] = None,
- str_time: Optional[str] = None,
- jid: Optional[str] = None,
- ack: int = 0) -> int:
+ def add_message(self, msg: BaseMessage):
"""
Create a message and add it to the text buffer
"""
- msg = Message(
- txt,
- time,
- nickname,
- nick_color,
- history,
- user,
- identifier,
- top,
- str_time=str_time,
- highlight=highlight,
- jid=jid,
- ack=ack)
self.messages.append(msg)
while len(self.messages) > self._messages_nb_limit:
self.messages.pop(0)
ret_val = 0
- show_timestamps = config.get('show_timestamps')
- nick_size = config.get('max_nick_length')
+ show_timestamps = config.getbool('show_timestamps')
+ nick_size = config.getbool('max_nick_length')
for window in self._windows: # make the associated windows
# build the lines from the new message
nb = window.build_new_message(
msg,
- history=history,
- highlight=highlight,
timestamp=show_timestamps,
- top=top,
nick_size=nick_size)
if ret_val == 0:
ret_val = nb
- if window.pos != 0 and top is False:
+ if window.pos != 0:
window.scroll_up(nb)
return min(ret_val, 1)
- def _find_message(self, old_id: str) -> int:
+ def _find_message(self, orig_id: str) -> Tuple[str, int]:
"""
Find a message in the text buffer from its message id
"""
+ # When looking for a message, ensure the id doesn't appear in a
+ # message we've removed from our message list. If so return the index
+ # of the corresponding id for the original message instead.
+ orig_id = self.correction_ids.get(orig_id, orig_id)
+
for i in range(len(self.messages) - 1, -1, -1):
msg = self.messages[i]
- if msg.identifier == old_id:
- return i
- return -1
+ if msg.identifier == orig_id:
+ return (orig_id, i)
+ return (orig_id, -1)
- def ack_message(self, old_id: str, jid: str) -> Union[None, bool, Message]:
+ def ack_message(self, old_id: str, jid: JID) -> Union[None, bool, Message]:
"""Mark a message as acked"""
return self._edit_ack(1, old_id, jid)
def nack_message(self, error: str, old_id: str,
- jid: str) -> Union[None, bool, Message]:
+ jid: JID) -> Union[None, bool, Message]:
"""Mark a message as errored"""
return self._edit_ack(-1, old_id, jid, append=error)
- def _edit_ack(self, value: int, old_id: str, jid: str,
+ def _edit_ack(self, value: int, old_id: str, jid: JID,
append: str = '') -> Union[None, bool, Message]:
"""
Edit the ack status of a message, and optionally
append some text.
"""
- i = self._find_message(old_id)
+ _, i = self._find_message(old_id)
if i == -1:
return None
msg = self.messages[i]
+ if not isinstance(msg, Message):
+ return None
if msg.ack == 1: # Message was already acked
return False
if msg.jid != jid:
@@ -228,29 +250,35 @@ class TextBuffer:
def modify_message(self,
txt: str,
- old_id: str,
+ orig_id: str,
new_id: str,
highlight: bool = False,
time: Optional[datetime] = None,
- user: Optional[str] = None,
- jid: Optional[str] = None):
+ user: Optional[User] = None,
+ jid: Optional[JID] = None) -> Message:
"""
Correct a message in a text buffer.
+
+ Version 1.1.0 of Last Message Correction (0308) added clarifications
+ that break the way poezio handles corrections. Instead of linking
+ corrections to the previous correction/message as we were doing, we
+ are now required to link all corrections to the original messages.
"""
- i = self._find_message(old_id)
+ orig_id, i = self._find_message(orig_id)
if i == -1:
log.debug(
'Message %s not found in text_buffer, abort replacement.',
- old_id)
+ orig_id)
raise CorrectionError("nothing to replace")
msg = self.messages[i]
-
+ if not isinstance(msg, Message):
+ raise CorrectionError('Wrong message type')
if msg.user and msg.user is not user:
raise CorrectionError("Different users")
- elif len(msg.str_time) > 8: # ugly
+ elif msg.delayed:
raise CorrectionError("Delayed message")
elif not msg.user and (msg.jid is None or jid is None):
raise CorrectionError('Could not check the '
@@ -258,29 +286,44 @@ class TextBuffer:
elif not msg.user and msg.jid != jid:
raise CorrectionError(
'Messages %s and %s have not been '
- 'sent by the same fullJID' % (old_id, new_id))
+ 'sent by the same fullJID' % (orig_id, new_id))
if not time:
- time = msg.time
+ time = datetime.now()
+
+ self.correction_ids[new_id] = orig_id
message = Message(
- txt,
- time,
- msg.nickname,
- msg.nick_color,
- False,
- msg.user,
- new_id,
+ txt=txt,
+ time=time,
+ nickname=msg.nickname,
+ nick_color=msg.nick_color,
+ user=msg.user,
+ identifier=orig_id,
highlight=highlight,
old_message=msg,
revisions=msg.revisions + 1,
jid=jid)
self.messages[i] = message
- log.debug('Replacing message %s with %s.', old_id, new_id)
+ log.debug('Replacing message %s with %s.', orig_id, new_id)
return message
def del_window(self, win) -> None:
self._windows.remove(win)
+ def find_last_message(self) -> Optional[Message]:
+ """Find the last real message received in this buffer"""
+ for message in reversed(self.messages):
+ if isinstance(message, Message):
+ return message
+ return None
+
+ def find_first_message(self) -> Optional[Message]:
+ """Find the first real message received in this buffer"""
+ for message in self.messages:
+ if isinstance(message, Message):
+ return message
+ return None
+
def __del__(self):
size = len(self.messages)
log.debug('** Deleting %s messages from textbuffer', size)
diff --git a/poezio/theming.py b/poezio/theming.py
index bbf2fb64..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,11 +74,11 @@ except ImportError:
import curses
import functools
-import os
-from typing import Dict, List, Union, Tuple, Optional
+from typing import Dict, List, Union, Tuple, Optional, cast
from pathlib import Path
from os import path
from poezio import colors, xdg
+from datetime import datetime
from importlib import machinery
finder = machinery.PathFinder()
@@ -143,6 +144,14 @@ class Theme:
return sub_mapping[sub] if sub == keep else ''
return sub_mapping.get(sub, '')
+ # Short date format (only show time)
+ SHORT_TIME_FORMAT = '%H:%M:%S'
+ SHORT_TIME_FORMAT_LENGTH = len(datetime.now().strftime(SHORT_TIME_FORMAT))
+
+ # Long date format (show date and time)
+ LONG_TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
+ LONG_TIME_FORMAT_LENGTH = len(datetime.now().strftime(LONG_TIME_FORMAT))
+
# Message text color
COLOR_NORMAL_TEXT = (-1, -1)
COLOR_INFORMATION_TEXT = (5, -1) # TODO
@@ -178,12 +187,13 @@ class Theme:
CHAR_CHATSTATE_COMPOSING = 'X'
CHAR_CHATSTATE_PAUSED = 'p'
- # These characters are used for the affiliation in the user list
- # in a MUC
+ # These characters are used for the affiliation wherever needed, e.g., in
+ # the user list in a MUC, or when displaying affiliation lists.
CHAR_AFFILIATION_OWNER = '~'
CHAR_AFFILIATION_ADMIN = '&'
CHAR_AFFILIATION_MEMBER = '+'
CHAR_AFFILIATION_NONE = '-'
+ CHAR_AFFILIATION_OUTCAST = '!'
# XML Tab
CHAR_XML_IN = 'IN '
@@ -224,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)
@@ -281,7 +300,7 @@ class Theme:
(224, -1), (225, -1), (226, -1), (227, -1)]
# XEP-0392 consistent color generation palette placeholder
# it’s generated on first use when accessing the ccg_palette property
- CCG_PALETTE = None # type: Optional[Dict[float, int]]
+ CCG_PALETTE: Optional[Dict[float, int]] = None
CCG_Y = 0.5**0.45
# yapf: enable
@@ -315,7 +334,9 @@ class Theme:
COLOR_COLUMN_HEADER_SEL = (4, 36)
# Strings for special messages (like join, quit, nick change, etc)
- # Special messages
+ CHAR_BEFORE_NICK_ME = '* '
+ CHAR_AFTER_NICK_ME = ' '
+ CHAR_AFTER_NICK = '> '
CHAR_JOIN = '--->'
CHAR_QUIT = '<---'
CHAR_KICK = '-!-'
@@ -358,7 +379,7 @@ class Theme:
# Info messages color (the part before the ">")
INFO_COLORS = {
'info': (5, -1),
- 'error': (16, 1),
+ 'error': (9, 7, 'b'),
'warning': (1, -1),
'roster': (2, -1),
'help': (10, -1),
@@ -371,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
@@ -383,8 +404,7 @@ theme = Theme()
# Each time we use a color tuple, we check if it has already been used.
# If not we create a new color_pair and keep it in that dict, to use it
# the next time.
-curses_colors_dict = {
-} # type: Dict[Union[Tuple[int, int], Tuple[int, int, str]], int]
+curses_colors_dict: Dict[Union[Tuple[int, int], Tuple[int, int, str]], int] = {}
# yapf: disable
@@ -408,7 +428,7 @@ table_256_to_16 = [
]
# yapf: enable
-load_path = [] # type: List[str]
+load_path: List[str] = []
def color_256_to_16(color):
@@ -441,13 +461,14 @@ def to_curses_attr(
returns a valid curses attr that can be passed directly to attron() or attroff()
"""
# extract the color from that tuple
+ colors: Union[Tuple[int, int], Tuple[int, int, str]]
if len(color_tuple) == 3:
colors = (color_tuple[0], color_tuple[1])
else:
colors = color_tuple
bold = False
- if curses.COLORS != 256:
+ if curses.COLORS < 256:
# We are not in a term supporting 256 colors, so we convert
# colors to numbers between -1 and 8
colors = (color_256_to_16(colors[0]), color_256_to_16(colors[1]))
@@ -466,7 +487,7 @@ def to_curses_attr(
curses_colors_dict[colors] = pair
curses_pair = curses.color_pair(pair)
if len(color_tuple) == 3:
- additional_val = color_tuple[2]
+ _, _, additional_val = cast(Tuple[int, int, str], color_tuple)
if 'b' in additional_val or bold is True:
curses_pair = curses_pair | curses.A_BOLD
if 'u' in additional_val:
@@ -476,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
@@ -498,7 +521,7 @@ def update_themes_dir(option: Optional[str] = None,
load_path.append(default_dir)
# import from the user-defined prefs
- themes_dir_str = config.get('themes_dir')
+ themes_dir_str = config.getstr('themes_dir')
themes_dir = Path(themes_dir_str).expanduser(
) if themes_dir_str else xdg.DATA_HOME / 'themes'
try:
@@ -544,7 +567,7 @@ def prepare_ccolor_palette(theme: Theme) -> None:
def reload_theme() -> Optional[str]:
- theme_name = config.get('theme')
+ theme_name = config.getstr('theme')
global theme
if theme_name == 'default' or not theme_name.strip():
theme = Theme()
@@ -552,10 +575,10 @@ def reload_theme() -> Optional[str]:
new_theme = None
exc = None
try:
- loader = finder.find_module(theme_name, load_path)
- if not loader:
+ spec = finder.find_spec(theme_name, path=load_path)
+ if not spec or not spec.loader:
return 'Failed to load the theme %s' % theme_name
- new_theme = loader.load_module()
+ new_theme = spec.loader.load_module(theme_name)
except Exception as e:
log.error('Failed to load the theme %s', theme_name, exc_info=True)
exc = e
@@ -564,7 +587,7 @@ def reload_theme() -> Optional[str]:
return 'Failed to load theme: %s' % exc
if hasattr(new_theme, 'theme'):
- theme = new_theme.theme
+ theme = new_theme.theme # type: ignore
prepare_ccolor_palette(theme)
return None
return 'No theme present in the theme file'
diff --git a/poezio/timed_events.py b/poezio/timed_events.py
index cd7659e2..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.
@@ -32,11 +32,11 @@ class DelayedEvent:
:param function callback: The handler that will be executed.
:param args: Optional arguments passed to the handler.
"""
- self.callback = callback # type: Callable
- self.args = args # type: Tuple[Any, ...]
- self.delay = delay # type: Union[int, float]
+ self.callback: Callable = callback
+ self.args: Tuple[Any, ...] = args
+ self.delay: Union[int, float] = delay
# An asyncio handler, as returned by call_later() or call_at()
- self.handler = None # type: Optional[Handle]
+ self.handler: Optional[Handle] = None
class TimedEvent(DelayedEvent):
diff --git a/poezio/types.py b/poezio/types.py
new file mode 100644
index 00000000..8d727f46
--- /dev/null
+++ b/poezio/types.py
@@ -0,0 +1,8 @@
+"""Poezio type stuff"""
+
+try:
+ from typing import TypedDict
+except ImportError:
+ from typing_extensions import TypedDict
+
+__all__ = ['TypedDict']
diff --git a/poezio/ui/__init__.py b/poezio/ui/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/poezio/ui/__init__.py
diff --git a/poezio/ui/consts.py b/poezio/ui/consts.py
new file mode 100644
index 00000000..91f19a82
--- /dev/null
+++ b/poezio/ui/consts.py
@@ -0,0 +1,4 @@
+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.
+FORMAT_CHARS = '\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x1A'
diff --git a/poezio/windows/funcs.py b/poezio/ui/funcs.py
index 22977374..023432ee 100644
--- a/poezio/windows/funcs.py
+++ b/poezio/ui/funcs.py
@@ -4,14 +4,14 @@ Standalone functions used by the modules
import string
from typing import Optional, List
-from poezio.windows.base_wins import FORMAT_CHAR, format_chars
+from poezio.ui.consts import FORMAT_CHAR, FORMAT_CHARS
DIGITS = string.digits + '-'
def find_first_format_char(text: str,
chars: str = None) -> int:
- to_find = chars or format_chars
+ to_find = chars or FORMAT_CHARS
pos = -1
for char in to_find:
p = text.find(char)
@@ -22,12 +22,14 @@ def find_first_format_char(text: str,
return pos
-def truncate_nick(nick: Optional[str], size=10) -> Optional[str]:
+def truncate_nick(nick: Optional[str], size=10) -> str:
if size < 1:
size = 1
- if nick and len(nick) > size:
- return nick[:size] + '…'
- return nick
+ if nick:
+ if len(nick) > size:
+ return nick[:size] + '…'
+ return nick
+ return ''
def parse_attrs(text: str, previous: Optional[List[str]] = None) -> List[str]:
diff --git a/poezio/ui/render.py b/poezio/ui/render.py
new file mode 100644
index 00000000..aad482b5
--- /dev/null
+++ b/poezio/ui/render.py
@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+import curses
+
+from datetime import (
+ datetime,
+ date,
+)
+from functools import singledispatch
+from math import ceil, log10
+from typing import (
+ List,
+ Optional,
+ Tuple,
+ TYPE_CHECKING,
+)
+
+from poezio import poopt
+from poezio.theming import (
+ get_theme,
+)
+from poezio.ui.consts import (
+ FORMAT_CHAR,
+)
+from poezio.ui.funcs import (
+ truncate_nick,
+ parse_attrs,
+)
+from poezio.ui.types import (
+ BaseMessage,
+ Message,
+ StatusMessage,
+ UIMessage,
+ XMLLog,
+)
+
+if TYPE_CHECKING:
+ from poezio.windows import Win
+
+# msg is a reference to the corresponding Message object. text_start and
+# text_end are the position delimiting the text in this line.
+class Line:
+ __slots__ = ('msg', 'start_pos', 'end_pos', 'prepend')
+
+ def __init__(self, msg: BaseMessage, start_pos: int, end_pos: int, prepend: str) -> None:
+ self.msg = msg
+ self.start_pos = start_pos
+ self.end_pos = end_pos
+ self.prepend = prepend
+
+ def __repr__(self):
+ return '(%s, %s)' % (self.start_pos, self.end_pos)
+
+
+LinePos = Tuple[int, int]
+
+
+def generate_lines(lines: List[LinePos], msg: BaseMessage, default_color: str = '') -> List[Line]:
+ line_objects = []
+ attrs: List[str] = []
+ prepend = default_color if default_color else ''
+ for line in lines:
+ saved = Line(
+ msg=msg,
+ start_pos=line[0],
+ end_pos=line[1],
+ prepend=prepend)
+ attrs = parse_attrs(msg.txt[line[0]:line[1]], attrs)
+ if attrs:
+ prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs)
+ else:
+ if default_color:
+ prepend = default_color
+ else:
+ prepend = ''
+ line_objects.append(saved)
+ return line_objects
+
+
+@singledispatch
+def build_lines(msg: BaseMessage, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]:
+ offset = msg.compute_offset(timestamp, nick_size)
+ lines = poopt.cut_text(msg.txt, width - offset - 1)
+ return generate_lines(lines, msg, default_color='')
+
+
+@build_lines.register(type(None))
+def build_separator(*args, **kwargs):
+ return [None]
+
+
+@build_lines.register(Message)
+def build_message(msg: Message, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]:
+ """
+ Build a list of lines from this message.
+ """
+ txt = msg.txt
+ if not txt:
+ return []
+ offset = msg.compute_offset(timestamp, nick_size)
+ lines = poopt.cut_text(txt, width - offset - 1)
+ generated_lines = generate_lines(lines, msg, default_color='')
+ return generated_lines
+
+
+@build_lines.register(StatusMessage)
+def build_status(msg: StatusMessage, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]:
+ msg.rebuild()
+ offset = msg.compute_offset(timestamp, nick_size)
+ lines = poopt.cut_text(msg.txt, width - offset - 1)
+ return generate_lines(lines, msg, default_color='')
+
+
+@build_lines.register(XMLLog)
+def build_xmllog(msg: XMLLog, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]:
+ offset = msg.compute_offset(timestamp, nick_size)
+ lines = poopt.cut_text(msg.txt, width - offset - 1)
+ return generate_lines(lines, msg, default_color='')
+
+
+@singledispatch
+def write_pre(msg: BaseMessage, win: Win, with_timestamps: bool, nick_size: int) -> int:
+ """Write the part before text (only the timestamp)"""
+ if with_timestamps:
+ return PreMessageHelpers.write_time(win, False, msg.time)
+ return 0
+
+
+@write_pre.register(UIMessage)
+def write_pre_uimessage(msg: UIMessage, win: Win, with_timestamps: bool, nick_size: int) -> int:
+ """ Write the prefix of a ui message log
+ - timestamp (short or long)
+ - level
+ """
+ color: Optional[Tuple]
+ offset = 0
+ if with_timestamps:
+ offset += PreMessageHelpers.write_time(win, False, msg.time)
+
+ if not msg.level: # not a message, nothing to do afterwards
+ return offset
+
+ level = truncate_nick(msg.level, nick_size)
+ offset += poopt.wcswidth(level)
+ color = msg.color
+ PreMessageHelpers.write_nickname(win, level, color, False)
+ win.addstr('> ')
+ offset += 2
+ return offset
+
+
+@write_pre.register(Message)
+def write_pre_message(msg: Message, win: Win, with_timestamps: bool, nick_size: int) -> int:
+ """Write the part before the body:
+ - timestamp (short or long)
+ - ack/nack
+ - nick (with a "* " for /me)
+ - LMC number if present
+ """
+ color: Optional[Tuple]
+ offset = 0
+ if with_timestamps:
+ offset += PreMessageHelpers.write_time(win, msg.history, msg.time)
+
+ if not msg.nickname: # not a message, nothing to do afterwards
+ return offset
+
+ nick = truncate_nick(msg.nickname, nick_size)
+ offset += poopt.wcswidth(nick)
+ if msg.nick_color:
+ color = msg.nick_color
+ elif msg.user:
+ color = msg.user.color
+ else:
+ color = None
+ if msg.ack:
+ if msg.ack > 0:
+ offset += PreMessageHelpers.write_ack(win)
+ else:
+ offset += PreMessageHelpers.write_nack(win)
+ theme = get_theme()
+ if msg.me:
+ with win.colored_text(color=theme.COLOR_ME_MESSAGE):
+ win.addstr(theme.CHAR_BEFORE_NICK_ME)
+ PreMessageHelpers.write_nickname(win, nick, color, msg.highlight)
+ offset += PreMessageHelpers.write_revisions(win, msg)
+ win.addstr(theme.CHAR_AFTER_NICK_ME)
+ offset += len(theme.CHAR_BEFORE_NICK_ME) + len(theme.CHAR_AFTER_NICK_ME)
+ else:
+ PreMessageHelpers.write_nickname(win, nick, color, msg.highlight)
+ offset += PreMessageHelpers.write_revisions(win, msg)
+ win.addstr(theme.CHAR_AFTER_NICK)
+ offset += len(theme.CHAR_AFTER_NICK)
+ return offset
+
+
+@write_pre.register(XMLLog)
+def write_pre_xmllog(msg: XMLLog, win: Win, with_timestamps: bool, nick_size: int) -> int:
+ """Write the part before the stanza (timestamp + IN/OUT)"""
+ offset = 0
+ if with_timestamps:
+ offset += 1 + PreMessageHelpers.write_time(win, False, msg.time)
+ theme = get_theme()
+ if msg.incoming:
+ char = theme.CHAR_XML_IN
+ color = theme.COLOR_XML_IN
+ else:
+ char = theme.CHAR_XML_OUT
+ color = theme.COLOR_XML_OUT
+ nick = truncate_nick(char, nick_size)
+ offset += poopt.wcswidth(nick)
+ PreMessageHelpers.write_nickname(win, char, color)
+ win.addstr(' ')
+ return offset
+
+class PreMessageHelpers:
+
+ @staticmethod
+ def write_revisions(buffer: Win, msg: Message) -> int:
+ if msg.revisions:
+ color = get_theme().COLOR_REVISIONS_MESSAGE
+ with buffer.colored_text(color=color):
+ buffer.addstr('%d' % msg.revisions)
+ return ceil(log10(msg.revisions + 1))
+ return 0
+
+ @staticmethod
+ def write_ack(buffer: Win) -> int:
+ theme = get_theme()
+ color = theme.COLOR_CHAR_ACK
+ with buffer.colored_text(color=color):
+ buffer.addstr(theme.CHAR_ACK_RECEIVED)
+ buffer.addstr(' ')
+ return poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
+
+ @staticmethod
+ def write_nack(buffer: Win) -> int:
+ theme = get_theme()
+ color = theme.COLOR_CHAR_NACK
+ with buffer.colored_text(color=color):
+ buffer.addstr(theme.CHAR_NACK)
+ buffer.addstr(' ')
+ return poopt.wcswidth(theme.CHAR_NACK) + 1
+
+ @staticmethod
+ def write_nickname(buffer: Win, nickname: str, color, highlight=False) -> None:
+ """
+ Write the nickname, using the user's color
+ and return the number of written characters
+ """
+ if not nickname:
+ return
+ attr = None
+ if highlight:
+ hl_color = get_theme().COLOR_HIGHLIGHT_NICK
+ if hl_color == "reverse":
+ attr = curses.A_REVERSE
+ else:
+ color = hl_color
+ with buffer.colored_text(color=color, attr=attr):
+ buffer.addstr(nickname)
+
+ @staticmethod
+ def write_time(buffer: Win, history: bool, time: datetime) -> int:
+ """
+ Write the date on the yth line of the window
+ """
+ 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 = theme.COLOR_TIME_STRING
+ with buffer.colored_text(color=color):
+ buffer.addstr(time_str)
+ buffer.addstr(' ')
+ return poopt.wcswidth(time_str) + 1
+ return 0
diff --git a/poezio/ui/types.py b/poezio/ui/types.py
new file mode 100644
index 00000000..27ccbd62
--- /dev/null
+++ b/poezio/ui/types.py
@@ -0,0 +1,260 @@
+from __future__ import annotations
+
+from datetime import datetime
+from math import ceil, log10
+from typing import Optional, Tuple, Dict, Any, Callable
+
+from slixmpp import JID
+
+from poezio import poopt
+from poezio.theming import dump_tuple, get_theme
+from poezio.ui.funcs import truncate_nick
+from poezio.user import User
+
+
+class BaseMessage:
+ """Base class for all ui-related messages"""
+ __slots__ = ('txt', 'time', 'identifier')
+
+ txt: str
+ identifier: str
+ time: datetime
+
+ def __init__(self, txt: str, identifier: str = '', time: Optional[datetime] = None):
+ self.txt = txt
+ self.identifier = identifier
+ if time is not None:
+ self.time = time
+ else:
+ self.time = datetime.now()
+
+ def compute_offset(self, with_timestamps: bool, nick_size: int) -> int:
+ """Compute the offset of the message"""
+ theme = get_theme()
+ return theme.SHORT_TIME_FORMAT_LENGTH + 1
+
+
+class EndOfArchive(BaseMessage):
+ """Marker added to a buffer when we reach the end of a MAM archive"""
+
+
+class InfoMessage(BaseMessage):
+ """Information message"""
+ def __init__(self, txt: str, identifier: str = '', time: Optional[datetime] = None):
+ txt = ('\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)) + txt
+ super().__init__(txt=txt, identifier=identifier, time=time)
+
+
+class UIMessage(BaseMessage):
+ """Message displayed through poezio UI"""
+ __slots__ = ('level', 'color')
+ level: str
+ color: Optional[Tuple]
+
+ def __init__(self, txt: str, level: str):
+ BaseMessage.__init__(self, txt=txt)
+ self.level = level.capitalize()
+ colors = get_theme().INFO_COLORS
+ self.color = colors.get(level.lower(), colors.get('default', None))
+
+ def compute_offset(self, with_timestamps: bool, nick_size: int) -> int:
+ """Compute the x-position at which the message should be printed"""
+ offset = 0
+ theme = get_theme()
+ if with_timestamps:
+ offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH
+ level = self.level
+ if not level: # not a message, nothing to do afterwards
+ return offset
+ level = truncate_nick(level, nick_size) or ''
+ offset += poopt.wcswidth(level)
+ offset += 2
+ return offset
+
+
+class LoggableTrait:
+ """Trait for classes of messages that should go through the logger"""
+ pass
+
+
+class PersistentInfoMessage(InfoMessage, LoggableTrait):
+ """Information message thatt will be logged"""
+ pass
+
+
+class MucOwnLeaveMessage(InfoMessage, LoggableTrait):
+ """Status message displayed on our room leave/kick/ban"""
+
+
+class MucOwnJoinMessage(InfoMessage, LoggableTrait):
+ """Status message displayed on our room join"""
+
+
+class XMLLog(BaseMessage):
+ """XML Log message"""
+ __slots__ = ('incoming')
+ incoming: bool
+
+ def __init__(
+ self,
+ txt: str,
+ incoming: bool,
+ ):
+ BaseMessage.__init__(
+ self,
+ txt=txt,
+ )
+ self.incoming = incoming
+
+ def compute_offset(self, with_timestamps: bool, nick_size: int) -> int:
+ offset = 0
+ theme = get_theme()
+ if with_timestamps:
+ offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH
+ if self.incoming:
+ nick = theme.CHAR_XML_IN
+ else:
+ nick = theme.CHAR_XML_OUT
+ nick = truncate_nick(nick, nick_size) or ''
+ offset += 1 + len(nick)
+ return offset
+
+
+class StatusMessage(BaseMessage):
+ """A dynamically formatted status message"""
+ __slots__ = ('format_string', 'format_args')
+ format_string: str
+ format_args: Dict[str, Callable[[], Any]]
+
+ def __init__(self, format_string: str, format_args: dict):
+ BaseMessage.__init__(
+ self,
+ txt='',
+ )
+ self.format_string = format_string
+ self.format_args = format_args
+ self.rebuild()
+
+ def rebuild(self):
+ real_args = {}
+ for key, func in self.format_args.items():
+ real_args[key] = func()
+ self.txt = self.format_string.format(**real_args)
+
+
+class Message(BaseMessage, LoggableTrait):
+ __slots__ = ('nick_color', 'nickname', 'user', 'delayed', 'history',
+ 'highlight', 'me', 'old_message', 'revisions',
+ 'jid', 'ack')
+ nick_color: Optional[Tuple]
+ nickname: Optional[str]
+ user: Optional[User]
+ delayed: bool
+ history: bool
+ highlight: bool
+ me: bool
+ old_message: Optional[Message]
+ revisions: int
+ jid: Optional[JID]
+ ack: int
+
+ def __init__(self,
+ txt: str,
+ nickname: Optional[str],
+ time: Optional[datetime] = None,
+ nick_color: Optional[Tuple] = None,
+ delayed: bool = False,
+ history: bool = False,
+ user: Optional[User] = None,
+ identifier: Optional[str] = '',
+ highlight: bool = False,
+ old_message: Optional[Message] = None,
+ revisions: int = 0,
+ jid: Optional[JID] = None,
+ ack: int = 0) -> None:
+ """
+ Create a new Message object with parameters, check for /me messages,
+ and delayed messages
+ """
+ BaseMessage.__init__(
+ self,
+ txt=txt.replace('\t', ' ') + '\x19o',
+ identifier=identifier or '',
+ time=time,
+ )
+ if txt.startswith('/me '):
+ me = True
+ txt = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_ME_MESSAGE),
+ txt[4:])
+ else:
+ me = False
+ self.txt = txt
+ self.delayed = delayed or history
+ self.history = history
+ self.nickname = nickname
+ self.nick_color = nick_color
+ self.user = user
+ self.highlight = highlight
+ self.me = me
+ self.old_message = old_message
+ self.revisions = revisions
+ self.jid = jid
+ self.ack = ack
+
+ def _other_elems(self) -> str:
+ "Helper for the repr_message function"
+ acc = []
+ fields = list(self.__slots__)
+ fields.remove('old_message')
+ for field in fields:
+ acc.append('%s=%s' % (field, repr(getattr(self, field))))
+ return 'Message(%s, %s' % (', '.join(acc), 'old_message=')
+
+ def __repr__(self) -> str:
+ """
+ repr() for the Message class, for debug purposes, since the default
+ repr() is recursive, so it can stack overflow given too many revisions
+ of a message
+ """
+ init = self._other_elems()
+ acc = [init]
+ next_message = self.old_message
+ rev = 1
+ while next_message is not None:
+ acc.append(next_message._other_elems())
+ next_message = next_message.old_message
+ rev += 1
+ acc.append('None')
+ while rev:
+ acc.append(')')
+ rev -= 1
+ return ''.join(acc)
+
+ def compute_offset(self, with_timestamps: bool, nick_size: int) -> int:
+ """Compute the x-position at which the message should be printed"""
+ offset = 0
+ theme = get_theme()
+ if with_timestamps:
+ if self.history:
+ offset += 1 + theme.LONG_TIME_FORMAT_LENGTH
+ else:
+ offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH
+
+ if not self.nickname: # not a message, nothing to do afterwards
+ return offset
+
+ nick = truncate_nick(self.nickname, nick_size) or ''
+ offset += poopt.wcswidth(nick)
+ if self.ack:
+ theme = get_theme()
+ if self.ack > 0:
+ offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
+ else:
+ offset += poopt.wcswidth(theme.CHAR_NACK) + 1
+ if self.me:
+ offset += 3
+ else:
+ offset += 2
+ if self.revisions:
+ offset += ceil(log10(self.revisions + 1))
+ return offset
diff --git a/poezio/user.py b/poezio/user.py
index 146a70da..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
@@ -38,24 +37,20 @@ class User:
status: str,
role: str,
jid: JID,
- deterministic=True,
color='') -> None:
# The oldest possible time
- self.last_talked = datetime(1, 1, 1) # type: datetime
+ self.last_talked: datetime = datetime(1, 1, 1)
self.update(affiliation, show, status, role)
self.change_nick(nick)
- self.jid = jid # type: JID
- self.chatstate = None # type: Optional[str]
- self.color = (1, 1) # type: Tuple[int, int]
+ self.jid: JID = jid
+ self.chatstate: Optional[str] = None
+ self.color: Tuple[int, int] = (1, 1)
if color != '':
- self.change_color(color, deterministic)
+ self.change_color(color)
else:
- if deterministic:
- self.set_deterministic_color()
- else:
- self.color = choice(get_theme().LIST_COLOR_NICKNAMES)
+ self.set_deterministic_color()
- def set_deterministic_color(self):
+ def set_deterministic_color(self) -> None:
theme = get_theme()
if theme.ccg_palette:
# use XEP-0392 CCG
@@ -82,14 +77,10 @@ class User:
def change_nick(self, nick: str):
self.nick = nick
- def change_color(self, color_name: Optional[str], deterministic=False):
- color = xhtml.colors.get(color_name)
+ def change_color(self, color_name: Optional[str]):
+ color = xhtml.colors.get(color_name or '')
if color is None:
- log.error('Unknown color "%s"', color_name)
- if deterministic:
- self.set_deterministic_color()
- else:
- self.color = choice(get_theme().LIST_COLOR_NICKNAMES)
+ self.set_deterministic_color()
else:
self.color = (color, -1)
@@ -97,7 +88,8 @@ class User:
"""
time: datetime object
"""
- self.last_talked = time
+ if time > self.last_talked:
+ self.last_talked = time
def has_talked_since(self, t: int) -> bool:
"""
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 3543a785..2397b102 100644
--- a/poezio/version.py
+++ b/poezio/version.py
@@ -1 +1,2 @@
-__version__ = '0.13-dev'
+__version__ = '0.14'
+__version_info__ = (0, 14, 0)
diff --git a/poezio/windows/__init__.py b/poezio/windows/__init__.py
index 8775b0a2..bbd6dc30 100644
--- a/poezio/windows/__init__.py
+++ b/poezio/windows/__init__.py
@@ -17,7 +17,7 @@ from poezio.windows.list import ListWin, ColumnHeaderWin
from poezio.windows.misc import VerticalSeparator
from poezio.windows.muc import UserList, Topic
from poezio.windows.roster_win import RosterWin, ContactInfoWin
-from poezio.windows.text_win import BaseTextWin, TextWin, XMLTextWin
+from poezio.windows.text_win import TextWin
from poezio.windows.image import ImageWin
__all__ = [
@@ -28,5 +28,5 @@ __all__ = [
'BookmarksInfoWin', 'ConfirmStatusWin', 'HelpText', 'Input',
'HistoryInput', 'MessageInput', 'CommandInput', 'ListWin',
'ColumnHeaderWin', 'VerticalSeparator', 'UserList', 'Topic', 'RosterWin',
- 'ContactInfoWin', 'TextWin', 'XMLTextWin', 'ImageWin', 'BaseTextWin'
+ 'ContactInfoWin', 'TextWin', 'ImageWin'
]
diff --git a/poezio/windows/base_wins.py b/poezio/windows/base_wins.py
index ac6b4804..658e1533 100644
--- a/poezio/windows/base_wins.py
+++ b/poezio/windows/base_wins.py
@@ -7,44 +7,37 @@ the text window, the roster window, etc.
A Tab (see the poezio.tabs module) is composed of multiple Windows
"""
-TAB_WIN = None # type: _CursesWindow
-
-import logging
-log = logging.getLogger(__name__)
+from __future__ import annotations
import curses
+import logging
import string
from contextlib import contextmanager
-from typing import Optional, Tuple, TYPE_CHECKING
+from typing import Optional, Tuple, TYPE_CHECKING, cast
from poezio.theming import to_curses_attr, read_tuple
-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.
-format_chars = '\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x1A'
+from poezio.ui.consts import FORMAT_CHAR
+
+log = logging.getLogger(__name__)
if TYPE_CHECKING:
from _curses import _CursesWindow # pylint: disable=E0611
-class DummyWin:
- def __getattribute__(self, name: str):
- if name != '__bool__':
- return lambda *args, **kwargs: (0, 0)
- else:
- return object.__getattribute__(self, name)
-
- def __bool__(self) -> bool:
- return False
-
-
class Win:
__slots__ = ('_win', 'height', 'width', 'y', 'x')
+ width: int
+ height: int
+ x: int
+ y: int
+
def __init__(self) -> None:
- self._win = None # type: _CursesWindow
+ if TAB_WIN is None:
+ raise ValueError
+ self._win: _CursesWindow = TAB_WIN
self.height, self.width = 0, 0
def _resize(self, height: int, width: int, y: int, x: int) -> None:
@@ -53,11 +46,11 @@ class Win:
return
self.height, self.width, self.x, self.y = height, width, x, y
try:
+ if TAB_WIN is None:
+ raise ValueError('TAB_WIN is None')
self._win = TAB_WIN.derwin(height, width, y, x)
except:
log.debug('DEBUG: mvwin returned ERR. Please investigate')
- if self._win is None:
- self._win = DummyWin()
def resize(self, height: int, width: int, y: int, x: int) -> None:
"""
@@ -80,6 +73,24 @@ class Win:
# of the screen.
pass
+ @contextmanager
+ def colored_text(self, color: Optional[Tuple]=None, attr: Optional[int]=None):
+ """Context manager which sets up an attr/color when inside"""
+ if color is None and attr is None:
+ yield None
+ return
+ mode: int
+ if color is not None:
+ mode = to_curses_attr(color)
+ if attr is not None:
+ mode = mode | attr
+ else:
+ # attr cannot be none here
+ mode = cast(int, attr)
+ self._win.attron(mode)
+ yield None
+ self._win.attroff(mode)
+
def addstr(self, *args) -> None:
"""
Safe call to addstr
@@ -164,3 +175,6 @@ class Win:
self.addnstr(' ' * size, size, to_curses_attr(color))
else:
self.addnstr(' ' * size, size)
+
+
+TAB_WIN: Optional[_CursesWindow] = None
diff --git a/poezio/windows/bookmark_forms.py b/poezio/windows/bookmark_forms.py
index 28cab8ec..a0e57cc7 100644
--- a/poezio/windows/bookmark_forms.py
+++ b/poezio/windows/bookmark_forms.py
@@ -4,13 +4,13 @@ Windows used inthe bookmarkstab
import curses
from typing import List, Tuple, Optional
-from poezio.windows import base_wins
+from slixmpp import JID, InvalidJID
+
from poezio.windows.base_wins import Win
from poezio.windows.inputs import Input
from poezio.windows.data_forms import FieldInput, FieldInputMixin
from poezio.theming import to_curses_attr, get_theme
-from poezio.common import safeJID
from poezio.bookmarks import Bookmark, BookmarkList
@@ -19,7 +19,8 @@ class BookmarkNameInput(FieldInput, Input):
FieldInput.__init__(self, field)
Input.__init__(self)
self.text = field.name
- self.pos = len(self.text)
+ self.pos = 0
+ self.view_pos = 0
self.color = get_theme().COLOR_NORMAL_TEXT
def save(self) -> None:
@@ -33,14 +34,21 @@ class BookmarkJIDInput(FieldInput, Input):
def __init__(self, field: Bookmark) -> None:
FieldInput.__init__(self, field)
Input.__init__(self)
- jid = safeJID(field.jid)
+ try:
+ jid = JID(field.jid)
+ except InvalidJID:
+ jid = JID('')
jid.resource = field.nick or None
self.text = jid.full
- self.pos = len(self.text)
+ self.pos = 0
+ self.view_pos = 0
self.color = get_theme().COLOR_NORMAL_TEXT
def save(self) -> None:
- jid = safeJID(self.get_text())
+ try:
+ jid = JID(self.get_text())
+ except InvalidJID:
+ jid = JID('')
self._field.jid = jid.bare
self._field.nick = jid.resource
@@ -56,7 +64,7 @@ class BookmarkMethodInput(FieldInputMixin):
# val_pos is the position of the currently selected option
self.val_pos = self.options.index(field.method)
- def do_command(self, key: str) -> None:
+ def do_command(self, key: str, raw: bool = False) -> None:
if key == 'KEY_LEFT':
if self.val_pos > 0:
self.val_pos -= 1
@@ -125,7 +133,7 @@ class BookmarkAutojoinWin(FieldInputMixin):
self.last_key = 'KEY_RIGHT'
self.value = field.autojoin
- def do_command(self, key: str) -> None:
+ def do_command(self, key: str, raw: bool = False) -> None:
if key == 'KEY_LEFT' or key == 'KEY_RIGHT':
self.value = not self.value
self.last_key = key
@@ -155,13 +163,13 @@ class BookmarksWin(Win):
__slots__ = ('scroll_pos', '_current_input', 'current_horizontal_input',
'_bookmarks', 'lines')
- def __init__(self, bookmarks: BookmarkList, height: int, width: int, y: int, x: int) -> None:
- self._win = base_wins.TAB_WIN.derwin(height, width, y, x)
+ def __init__(self, bookmarks: BookmarkList) -> None:
+ Win.__init__(self)
self.scroll_pos = 0
self._current_input = 0
self.current_horizontal_input = 0
self._bookmarks = list(bookmarks)
- self.lines = [] # type: List[Tuple[BookmarkNameInput, BookmarkJIDInput, BookmarkPasswordInput, BookmarkAutojoinWin, BookmarkMethodInput]]
+ self.lines: List[Tuple[BookmarkNameInput, BookmarkJIDInput, BookmarkPasswordInput, BookmarkAutojoinWin, BookmarkMethodInput]] = []
for bookmark in sorted(self._bookmarks, key=lambda x: str(x.jid)):
self.lines.append((BookmarkNameInput(bookmark),
BookmarkJIDInput(bookmark),
@@ -214,9 +222,7 @@ class BookmarksWin(Win):
return bm
def resize(self, height: int, width: int, y: int, x: int) -> None:
- self.height = height
- self.width = width
- self._win = base_wins.TAB_WIN.derwin(height, width, y, x)
+ super().resize(height, width, y, x)
# Adjust the scroll position, if resizing made the window too small
# for the cursor to be visible
while self.current_input - self.scroll_pos > self.height - 1:
@@ -269,7 +275,7 @@ class BookmarksWin(Win):
self.current_horizontal_input].set_color(
theme.COLOR_NORMAL_TEXT)
self.current_horizontal_input += 1
- if self.current_horizontal_input > 3:
+ if self.current_horizontal_input > 4:
self.current_horizontal_input = 0
self.lines[self.current_input][
self.current_horizontal_input].set_color(
@@ -337,11 +343,11 @@ class BookmarksWin(Win):
self.current_horizontal_input].set_color(
theme.COLOR_SELECTED_ROW)
- def on_input(self, key: str) -> None:
+ def on_input(self, key: str, raw: bool = False) -> None:
if not self.lines:
return
self.lines[self.current_input][
- self.current_horizontal_input].do_command(key)
+ self.current_horizontal_input].do_command(key, raw=raw)
def refresh(self) -> None:
# store the cursor status
@@ -363,7 +369,7 @@ class BookmarksWin(Win):
continue
if i >= self.height + self.scroll_pos:
break
- for j in range(4):
+ for j in range(5):
inp[j].refresh()
if self.lines and self.current_input < self.height - 1:
@@ -384,5 +390,8 @@ class BookmarksWin(Win):
def save(self) -> None:
for line in self.lines:
- for item in line:
- item.save()
+ line[0].save()
+ line[1].save()
+ line[2].save()
+ line[3].save()
+ line[4].save()
diff --git a/poezio/windows/data_forms.py b/poezio/windows/data_forms.py
index 7e746774..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
@@ -189,7 +190,7 @@ class TextMultiWin(FieldInputMixin):
if not self.options or self.options[-1] != '':
self.options.append('')
else:
- self.edition_input.do_command(key)
+ self.edition_input.do_command(key, raw=raw)
self.refresh()
def refresh(self):
@@ -330,7 +331,8 @@ class TextSingleWin(FieldInputMixin, Input):
Input.__init__(self)
self.text = field.get_value() if isinstance(field.get_value(), str)\
else ""
- self.pos = len(self.text)
+ self.pos = 0
+ self.view_pos = 0
self.color = get_theme().COLOR_NORMAL_TEXT
def reply(self):
@@ -396,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 ebecb5ad..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:
- class Image:
- pass
HAS_PIL = False
try:
@@ -69,10 +68,10 @@ class ImageWin(Win):
__slots__ = ('_image', '_display_avatar')
def __init__(self) -> None:
- self._image = None # type: Optional[Image.Image]
+ self._image: Optional[Image.Image] = None
Win.__init__(self)
- if config.get('image_use_half_blocks'):
- self._display_avatar = self._display_avatar_half_blocks # type: Callable[[int, int], None]
+ if config.getbool('image_use_half_blocks'):
+ self._display_avatar: Callable[[int, int], None] = self._display_avatar_half_blocks
else:
self._display_avatar = self._display_avatar_full_blocks
diff --git a/poezio/windows/info_bar.py b/poezio/windows/info_bar.py
index ac900103..6e6c3bbd 100644
--- a/poezio/windows/info_bar.py
+++ b/poezio/windows/info_bar.py
@@ -5,14 +5,19 @@ This window is the one listing the current opened tabs in poezio.
The GlobalInfoBar can be either horizontal or vertical
(VerticalGlobalInfoBar).
"""
+import curses
+import itertools
import logging
-log = logging.getLogger(__name__)
-import curses
+from typing import List, Optional
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__)
class GlobalInfoBar(Win):
@@ -29,24 +34,74 @@ class GlobalInfoBar(Win):
self.addstr(0, 0, "[",
to_curses_attr(theme.COLOR_INFORMATION_BAR))
- show_names = config.get('show_tab_names')
- show_nums = config.get('show_tab_numbers')
- use_nicks = config.get('use_tab_nicks')
- show_inactive = config.get('show_inactive_tabs')
+ show_names = config.getbool('show_tab_names')
+ show_nums = config.getbool('show_tab_numbers')
+ 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)
+ sorted_tab_indices = sorted(
+ (str(tab.name), i)
+ for i, tab in enumerate(self.core.tabs)
+ )
+ prev_name = ""
+ for (name, i), next_item in itertools.zip_longest(
+ sorted_tab_indices, sorted_tab_indices[1:]):
+ # TODO: should this maybe use something smarter than .lower()?
+ # something something stringprep?
+ name = name.lower()
+ prefix_prev = unique_prefix_of(name, prev_name)
+ if next_item is not None:
+ prefix_next = unique_prefix_of(name, next_item[0].lower())
+ else:
+ prefix_next = name[0]
+
+ # to be unique, we have to use the longest prefix
+ if len(prefix_next) > len(prefix_prev):
+ prefix = prefix_next
+ else:
+ prefix = prefix_prev
+
+ unique_prefixes[i] = prefix
+ prev_name = name
for nb, tab in enumerate(self.core.tabs):
if not tab:
continue
color = tab.color
- if not show_inactive and color is theme.COLOR_TAB_NORMAL:
+ 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))
if show_names:
self.addstr(' ', to_curses_attr(color))
if show_names:
- if use_nicks:
+ if unique_prefix_tab_names:
+ self.addstr(unique_prefixes[nb], to_curses_attr(color))
+ elif use_nicks:
self.addstr("%s" % str(tab.get_nick()),
to_curses_attr(color))
else:
@@ -78,14 +133,21 @@ class VerticalGlobalInfoBar(Win):
self._win.erase()
sorted_tabs = [tab for tab in self.core.tabs if tab]
theme = get_theme()
- if not config.get('show_inactive_tabs'):
+ if not config.getbool('show_inactive_tabs'):
sorted_tabs = [
tab for tab in sorted_tabs
- if tab.vertical_color != theme.COLOR_VERTICAL_TAB_NORMAL
+ if (
+ tab.vertical_color != theme.COLOR_VERTICAL_TAB_NORMAL or
+ tab.priority > 0
+ )
]
nb_tabs = len(sorted_tabs)
- use_nicks = config.get('use_tab_nicks')
+ use_nicks = config.getbool('use_tab_nicks')
if nb_tabs >= height:
+ # TODO: As sorted_tabs filters out gap tabs this ensures pos is
+ # always set, preventing UnboundLocalError. Now is this how this
+ # should be fixed.
+ pos = 0
for y, tab in enumerate(sorted_tabs):
if tab.vertical_color == theme.COLOR_VERTICAL_TAB_CURRENT:
pos = y
@@ -97,7 +159,7 @@ class VerticalGlobalInfoBar(Win):
sorted_tabs = sorted_tabs[-height:]
else:
sorted_tabs = sorted_tabs[pos - height // 2:pos + height // 2]
- asc_sort = (config.get('vertical_tab_list_sort') == 'asc')
+ asc_sort = (config.getstr('vertical_tab_list_sort') == 'asc')
for y, tab in enumerate(sorted_tabs):
color = tab.vertical_color
if asc_sort:
diff --git a/poezio/windows/info_wins.py b/poezio/windows/info_wins.py
index 3a8d1863..227dc115 100644
--- a/poezio/windows/info_wins.py
+++ b/poezio/windows/info_wins.py
@@ -3,15 +3,27 @@ Module defining all the "info wins", ie the bar which is on top of the
info buffer in normal tabs
"""
+from __future__ import annotations
+
+from typing import Optional, Dict, TYPE_CHECKING, Any
+
import logging
-log = logging.getLogger(__name__)
-from poezio.common import safeJID
+from slixmpp import JID, InvalidJID
+
from poezio.config import config
from poezio.windows.base_wins import Win
-from poezio.windows.funcs import truncate_nick
+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
+ from poezio.tabs import MucTab
+ from poezio.windows import TextWin
+
+log = logging.getLogger(__name__)
class InfoWin(Win):
@@ -92,7 +104,13 @@ class PrivateInfoWin(InfoWin):
to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
def write_room_name(self, name):
- jid = safeJID(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:
+ jid = JID('')
room_name, nick = jid.bare, jid.resource
theme = get_theme()
self.addstr(nick, to_curses_attr(theme.COLOR_PRIVATE_NAME))
@@ -149,7 +167,10 @@ class ConversationInfoWin(InfoWin):
# from someone not in our roster. In this case, we display
# only the maximum information from the message we can get.
log.debug('Refresh: %s', self.__class__.__name__)
- jid = safeJID(jid)
+ try:
+ jid = JID(jid)
+ except InvalidJID:
+ jid = JID('')
if contact:
if jid.resource:
resource = contact[jid.full]
@@ -163,7 +184,7 @@ class ConversationInfoWin(InfoWin):
# resource can now be a Resource: user is in the roster and online
# or resource is None: user is in the roster but offline
self._win.erase()
- if config.get('show_jid_in_conversations'):
+ if config.getbool('show_jid_in_conversations'):
self.write_contact_jid(jid)
self.write_contact_information(contact)
self.write_resource_information(resource)
@@ -206,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):
"""
@@ -220,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):
@@ -260,10 +299,16 @@ class MucInfoWin(InfoWin):
__slots__ = ()
- def __init__(self):
+ def __init__(self) -> None:
InfoWin.__init__(self)
- def refresh(self, room, window=None, user=None, information=None):
+ def refresh(
+ self,
+ room: MucTab,
+ window: Optional[TextWin] = None,
+ user: Optional[User] = None,
+ information: Optional[Dict[str, Any]] = None
+ ) -> None:
log.debug('Refresh: %s', self.__class__.__name__)
self._win.erase()
self.write_room_name(room)
@@ -290,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):
@@ -348,7 +401,10 @@ class ConversationStatusMessageWin(InfoWin):
def refresh(self, jid, contact):
log.debug('Refresh: %s', self.__class__.__name__)
- jid = safeJID(jid)
+ try:
+ jid = JID(jid)
+ except InvalidJID:
+ jid = JID('')
if contact:
if jid.resource:
resource = contact[jid.full]
diff --git a/poezio/windows/input_placeholders.py b/poezio/windows/input_placeholders.py
index 4d414636..3ec57583 100644
--- a/poezio/windows/input_placeholders.py
+++ b/poezio/windows/input_placeholders.py
@@ -23,7 +23,7 @@ class HelpText(Win):
def __init__(self, text: str = '') -> None:
Win.__init__(self)
- self.txt = text # type: str
+ self.txt: str = text
def refresh(self, txt: Optional[str] = None) -> None:
log.debug('Refresh: %s', self.__class__.__name__)
diff --git a/poezio/windows/inputs.py b/poezio/windows/inputs.py
index 84b95599..01b94ac0 100644
--- a/poezio/windows/inputs.py
+++ b/poezio/windows/inputs.py
@@ -5,13 +5,14 @@ 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
from poezio import poopt
-from poezio.windows.base_wins import Win, format_chars
-from poezio.windows.funcs import find_first_format_char
+from poezio.windows.base_wins import Win
+from poezio.ui.consts import FORMAT_CHARS
+from poezio.ui.funcs import find_first_format_char
from poezio.config import config
from poezio.theming import to_curses_attr
@@ -40,7 +41,7 @@ class Input(Win):
# it easy cut and paste text between various input
def __init__(self) -> None:
- self.key_func = {
+ self.key_func: Dict[str, Callable] = {
"KEY_LEFT": self.key_left,
"KEY_RIGHT": self.key_right,
"KEY_END": self.key_end,
@@ -65,7 +66,7 @@ class Input(Win):
'^?': self.key_backspace,
"M-^?": self.delete_word,
# '^J': self.add_line_break,
- } # type: Dict[str, Callable]
+ }
Win.__init__(self)
self.text = ''
self.pos = 0 # The position of the “cursor” in the text
@@ -75,8 +76,8 @@ class Input(Win):
# screen
self.on_input = DEFAULT_ON_INPUT # callback called on any key pressed
self.color = None # use this color on addstr
- self.last_completion = None # type: Optional[str]
- self.hit_list = [] # type: List[str]
+ self.last_completion: Optional[str] = None
+ self.hit_list: List[str] = []
def on_delete(self) -> None:
"""
@@ -109,7 +110,7 @@ class Input(Win):
"""
if self.pos == 0:
return True
- separators = string.punctuation + ' '
+ separators = string.punctuation + ' ' + '\n'
while self.pos > 0 and self.text[self.pos - 1] in separators:
self.key_left()
while self.pos > 0 and self.text[self.pos - 1] not in separators:
@@ -122,7 +123,7 @@ class Input(Win):
"""
if self.is_cursor_at_end():
return True
- separators = string.punctuation + ' '
+ separators = string.punctuation + ' ' + '\n'
while not self.is_cursor_at_end() and self.text[self.pos] in separators:
self.key_right()
while not self.is_cursor_at_end() and self.text[self.
@@ -134,7 +135,7 @@ class Input(Win):
"""
Delete the word just before the cursor
"""
- separators = string.punctuation + ' '
+ separators = string.punctuation + ' ' + '\n'
while self.pos > 0 and self.text[self.pos - 1] in separators:
self.key_backspace()
while self.pos > 0 and self.text[self.pos - 1] not in separators:
@@ -145,7 +146,7 @@ class Input(Win):
"""
Delete the word just after the cursor
"""
- separators = string.punctuation + ' '
+ separators = string.punctuation + ' ' + '\n'
while not self.is_cursor_at_end() and self.text[self.pos] in separators:
self.key_dc()
while not self.is_cursor_at_end() and self.text[self.
@@ -408,12 +409,14 @@ class Input(Win):
Normal completion
"""
pos = self.pos
- if pos < len(
- self.text) and after.endswith(' ') and self.text[pos] == ' ':
+ if pos < len(self.text) and after.endswith(' ') and self.text[pos] in ' \n':
after = after[:
-1] # remove the last space if we are already on a space
if not self.last_completion:
space_before_cursor = self.text.rfind(' ', 0, pos)
+ line_before_cursor = self.text.rfind('\n', 0, pos)
+ if line_before_cursor > space_before_cursor:
+ space_before_cursor = line_before_cursor
if space_before_cursor != -1:
begin = self.text[space_before_cursor + 1:pos]
else:
@@ -487,7 +490,7 @@ class Input(Win):
(\x0E to \x19 instead of \x19 + attr). We do not use any }
char in this version
"""
- chars = format_chars + '\n'
+ chars = FORMAT_CHARS + '\n'
if y is not None and x is not None:
self.move(y, x)
format_char = find_first_format_char(text, chars)
@@ -497,7 +500,7 @@ class Input(Win):
if text[format_char] == '\n':
attr_char = '|'
else:
- attr_char = self.text_attributes[format_chars.index(
+ attr_char = self.text_attributes[FORMAT_CHARS.index(
text[format_char])]
self.addstr(text[:format_char])
self.addstr(attr_char, curses.A_REVERSE)
@@ -589,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 = [] # type: List[str]
+ global_history: ClassVar[List[str]] = []
def __init__(self) -> None:
Input.__init__(self)
@@ -600,9 +604,10 @@ class HistoryInput(Input):
self.current_completed = ''
self.key_func['^R'] = self.toggle_search
self.search = False
- if config.get('separate_history'):
- # pylint: disable=assigning-non-slot
- self.history = [] # type: List[str]
+ if config.getbool('separate_history'):
+ self.history: List[str] = []
+ else:
+ self.history = self.__class__.global_history
def toggle_search(self) -> None:
if self.help_message:
@@ -679,7 +684,7 @@ class MessageInput(HistoryInput):
Also letting the user enter colors or other text markups
"""
# The history is common to all MessageInput
- history = [] # type: List[str]
+ global_history: ClassVar[List[str]] = []
def __init__(self) -> None:
HistoryInput.__init__(self)
@@ -696,7 +701,7 @@ class MessageInput(HistoryInput):
def cb(attr_char):
if attr_char in self.text_attributes:
- char = format_chars[self.text_attributes.index(attr_char)]
+ char = FORMAT_CHARS[self.text_attributes.index(attr_char)]
self.do_command(char, False)
self.rewrite_text()
@@ -725,7 +730,7 @@ class CommandInput(HistoryInput):
HelpMessage when a command is started
The on_input callback
"""
- history = [] # type: 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/list.py b/poezio/windows/list.py
index c427a79e..1c5d834f 100644
--- a/poezio/windows/list.py
+++ b/poezio/windows/list.py
@@ -24,10 +24,10 @@ class ListWin(Win):
def __init__(self, columns: Dict[str, int], with_headers: bool = True) -> None:
Win.__init__(self)
- self._columns = columns # type: Dict[str, int]
- self._columns_sizes = {} # type: Dict[str, int]
+ self._columns: Dict[str, int] = columns
+ self._columns_sizes: Dict[str, int] = {}
self.sorted_by = (None, None) # for example: ('name', '↑')
- self.lines = [] # type: List[str]
+ self.lines: List[str] = []
self._selected_row = 0
self._starting_pos = 0 # The column number from which we start the refresh
@@ -173,7 +173,7 @@ class ColumnHeaderWin(Win):
def __init__(self, columns: List[str]) -> None:
Win.__init__(self)
self._columns = columns
- self._columns_sizes = {} # type: Dict[str, int]
+ self._columns_sizes: Dict[str, int] = {}
self._column_sel = ''
self._column_order = ''
self._column_order_asc = False
diff --git a/poezio/windows/misc.py b/poezio/windows/misc.py
index 6c04b814..a621b61d 100644
--- a/poezio/windows/misc.py
+++ b/poezio/windows/misc.py
@@ -22,8 +22,10 @@ class VerticalSeparator(Win):
__slots__ = ()
def rewrite_line(self) -> None:
- self._win.vline(0, 0, curses.ACS_VLINE, self.height,
- to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR))
+ self._win.vline(
+ 0, 0, curses.ACS_VLINE, self.height,
+ to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR)
+ ) # type: ignore
self._refresh()
def refresh(self) -> None:
@@ -37,7 +39,7 @@ class SimpleTextWin(Win):
def __init__(self, text) -> None:
Win.__init__(self)
self._text = text
- self.built_lines = [] # type: List[str]
+ self.built_lines: List[str] = []
def rebuild_text(self) -> None:
"""
diff --git a/poezio/windows/muc.py b/poezio/windows/muc.py
index 951940e1..0e95ac1b 100644
--- a/poezio/windows/muc.py
+++ b/poezio/windows/muc.py
@@ -33,7 +33,7 @@ class UserList(Win):
def __init__(self) -> None:
Win.__init__(self)
self.pos = 0
- self.cache = [] # type: List[CachedUser]
+ self.cache: List[CachedUser] = []
def scroll_up(self) -> bool:
self.pos += self.height - 1
@@ -65,14 +65,14 @@ class UserList(Win):
def refresh(self, users: List[User]) -> None:
log.debug('Refresh: %s', self.__class__.__name__)
- if config.get('hide_user_list'):
+ if config.getbool('hide_user_list'):
return # do not refresh if this win is hidden.
if len(users) < self.height:
self.pos = 0
elif self.pos >= len(users) - self.height and self.pos != 0:
self.pos = len(users) - self.height
self._win.erase()
- asc_sort = (config.get('user_list_sort').lower() == 'asc')
+ asc_sort = (config.getstr('user_list_sort').lower() == 'asc')
if asc_sort:
y, _ = self._win.getmaxyx()
y -= 1
diff --git a/poezio/windows/roster_win.py b/poezio/windows/roster_win.py
index 2efdd324..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
@@ -26,8 +25,8 @@ class RosterWin(Win):
Win.__init__(self)
self.pos = 0 # cursor position in the contact list
self.start_pos = 1 # position of the start of the display
- self.selected_row = None # type: Optional[Row]
- self.roster_cache = [] # type: List[Row]
+ self.selected_row: Optional[Row] = None
+ self.roster_cache: List[Row] = []
@property
def roster_len(self) -> int:
@@ -99,13 +98,13 @@ class RosterWin(Win):
# This is a search
if roster.contact_filter is not roster.DEFAULT_FILTER:
self.roster_cache = []
- sort = config.get('roster_sort', 'jid:show') or 'jid:show'
+ sort = config.getstr('roster_sort') or 'jid:show'
for contact in roster.get_contacts_sorted_filtered(sort):
self.roster_cache.append(contact)
else:
- show_offline = config.get('roster_show_offline')
- sort = config.get('roster_sort') or 'jid:show'
- group_sort = config.get('roster_group_sort') or 'name'
+ show_offline = config.getbool('roster_show_offline')
+ sort = config.getstr('roster_sort') or 'jid:show'
+ group_sort = config.getstr('roster_group_sort') or 'name'
self.roster_cache = []
# build the cache
for group in roster.get_groups(group_sort):
@@ -155,9 +154,9 @@ class RosterWin(Win):
self.height]
options = {
- 'show_roster_sub': config.get('show_roster_subscriptions'),
- 'show_s2s_errors': config.get('show_s2s_errors'),
- 'show_roster_jids': config.get('show_roster_jids')
+ 'show_roster_sub': config.getbool('show_roster_subscriptions'),
+ 'show_s2s_errors': config.getbool('show_s2s_errors'),
+ 'show_roster_jids': config.getbool('show_roster_jids')
}
for item in roster_view:
@@ -171,7 +170,7 @@ class RosterWin(Win):
group = item.name
elif isinstance(item, Contact):
self.draw_contact_line(y, item, draw_selected, group,
- **options)
+ **options) # type: ignore
elif isinstance(item, Resource):
self.draw_resource_line(y, item, draw_selected)
@@ -268,14 +267,6 @@ class RosterWin(Win):
added += len(theme.CHAR_ROSTER_ASKED)
if show_s2s_errors and contact.error:
added += len(theme.CHAR_ROSTER_ERROR)
- if contact.tune:
- added += len(theme.CHAR_ROSTER_TUNE)
- if contact.mood:
- added += len(theme.CHAR_ROSTER_MOOD)
- if contact.activity:
- added += len(theme.CHAR_ROSTER_ACTIVITY)
- if contact.gaming:
- added += len(theme.CHAR_ROSTER_GAMING)
if show_roster_sub in ('all', 'incomplete', 'to', 'from', 'both',
'none'):
added += len(
@@ -287,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
@@ -309,18 +300,6 @@ class RosterWin(Win):
if show_s2s_errors and contact.error:
self.addstr(theme.CHAR_ROSTER_ERROR,
to_curses_attr(theme.COLOR_ROSTER_ERROR))
- if contact.tune:
- self.addstr(theme.CHAR_ROSTER_TUNE,
- to_curses_attr(theme.COLOR_ROSTER_TUNE))
- if contact.activity:
- self.addstr(theme.CHAR_ROSTER_ACTIVITY,
- to_curses_attr(theme.COLOR_ROSTER_ACTIVITY))
- if contact.mood:
- self.addstr(theme.CHAR_ROSTER_MOOD,
- to_curses_attr(theme.COLOR_ROSTER_MOOD))
- if contact.gaming:
- self.addstr(theme.CHAR_ROSTER_GAMING,
- to_curses_attr(theme.COLOR_ROSTER_GAMING))
self.finish_line()
def draw_resource_line(self, y: int, resource: Resource, colored: bool) -> None:
@@ -394,32 +373,6 @@ class ContactInfoWin(Win):
self.finish_line()
i += 1
- if contact.tune:
- self.addstr(i, 0,
- 'Tune: %s' % common.format_tune_string(contact.tune),
- to_curses_attr(theme.COLOR_NORMAL_TEXT))
- self.finish_line()
- i += 1
-
- if contact.mood:
- self.addstr(i, 0, 'Mood: %s' % contact.mood,
- to_curses_attr(theme.COLOR_NORMAL_TEXT))
- self.finish_line()
- i += 1
-
- if contact.activity:
- self.addstr(i, 0, 'Activity: %s' % contact.activity,
- to_curses_attr(theme.COLOR_NORMAL_TEXT))
- self.finish_line()
- i += 1
-
- if contact.gaming:
- self.addstr(
- i, 0, 'Game: %s' % common.format_gaming_string(contact.gaming),
- to_curses_attr(theme.COLOR_NORMAL_TEXT))
- self.finish_line()
- i += 1
-
def draw_group_info(self, group: RosterGroup) -> None:
"""
draw the group information
diff --git a/poezio/windows/text_win.py b/poezio/windows/text_win.py
index f4c78c2c..12d90e7d 100644
--- a/poezio/windows/text_win.py
+++ b/poezio/windows/text_win.py
@@ -4,50 +4,50 @@ Can be locked, scrolled, has a separator, etc…
"""
import logging
-import curses
-from math import ceil, log10
from typing import Optional, List, Union
-from poezio.windows.base_wins import Win, FORMAT_CHAR
-from poezio.windows.funcs import truncate_nick, parse_attrs
+from poezio.windows.base_wins import Win
+from poezio.text_buffer import TextBuffer
-from poezio import poopt
from poezio.config import config
-from poezio.theming import to_curses_attr, get_theme, dump_tuple
-from poezio.text_buffer import Message
+from poezio.theming import to_curses_attr, get_theme
+from poezio.ui.types import Message, BaseMessage
+from poezio.ui.render import Line, build_lines, write_pre
log = logging.getLogger(__name__)
-# msg is a reference to the corresponding Message object. text_start and
-# text_end are the position delimiting the text in this line.
-class Line:
- __slots__ = ('msg', 'start_pos', 'end_pos', 'prepend')
-
- def __init__(self, msg: Message, start_pos: int, end_pos: int, prepend: str) -> None:
- self.msg = msg
- self.start_pos = start_pos
- self.end_pos = end_pos
- self.prepend = prepend
-
-
-class BaseTextWin(Win):
+class TextWin(Win):
__slots__ = ('lines_nb_limit', 'pos', 'built_lines', 'lock', 'lock_buffer',
- 'separator_after')
+ 'separator_after', 'highlights', 'hl_pos',
+ 'nb_of_highlights_after_separator')
+
+ hl_pos: Optional[int]
def __init__(self, lines_nb_limit: Optional[int] = None) -> None:
- if lines_nb_limit is None:
- lines_nb_limit = config.get('max_lines_in_memory')
Win.__init__(self)
- self.lines_nb_limit = lines_nb_limit # type: int
+ if lines_nb_limit is None:
+ lines_nb_limit = config.getint('max_lines_in_memory')
+ self.lines_nb_limit: int = lines_nb_limit
self.pos = 0
# Each new message is built and kept here.
# on resize, we rebuild all the messages
- self.built_lines = [] # type: List[Union[None, Line]]
+ self.built_lines: List[Union[None, Line]] = []
self.lock = False
- self.lock_buffer = [] # type: List[Union[None, Line]]
- self.separator_after = None # type: Optional[Line]
+ self.lock_buffer: List[Union[None, Line]] = []
+ self.separator_after: Optional[BaseMessage] = None
+ # the Lines of the highlights in that buffer
+ self.highlights: List[Line] = []
+ # the current HL position in that list NaN means that we’re not on
+ # an hl. -1 is a valid position (it's before the first hl of the
+ # list. i.e the separator, in the case where there’s no hl before
+ # it.)
+ self.hl_pos = None
+
+ # Keep track of the number of hl after the separator.
+ # This is useful to make “go to next highlight“ work after a “move to separator”.
+ self.nb_of_highlights_after_separator = 0
def toggle_lock(self) -> bool:
if self.lock:
@@ -80,48 +80,65 @@ class BaseTextWin(Win):
self.pos = 0
return self.pos != pos
- # TODO: figure out the type of history.
def build_new_message(self,
- message: Message,
- history=None,
+ message: BaseMessage,
clean: bool = True,
- highlight: bool = False,
timestamp: bool = False,
- top: Optional[bool] = False,
nick_size: int = 10) -> int:
"""
Take one message, build it and add it to the list
Return the number of lines that are built for the given
message.
"""
- #pylint: disable=assignment-from-no-return
- lines = self.build_message(
- message, timestamp=timestamp, nick_size=nick_size)
- if top:
- lines.reverse()
- for line in lines:
- self.built_lines.insert(0, line)
+ lines = build_lines(
+ message, self.width, timestamp=timestamp, nick_size=nick_size
+ )
+ if self.lock:
+ self.lock_buffer.extend(lines)
else:
- if self.lock:
- self.lock_buffer.extend(lines)
- else:
- self.built_lines.extend(lines)
+ self.built_lines.extend(lines)
if not lines or not lines[0]:
return 0
+ if isinstance(message, Message) and message.highlight:
+ self.highlights.append(lines[0])
+ self.nb_of_highlights_after_separator += 1
+ log.debug("Number of highlights after separator is now %s",
+ self.nb_of_highlights_after_separator)
if clean:
while len(self.built_lines) > self.lines_nb_limit:
self.built_lines.pop(0)
return len(lines)
- def build_message(self, message: Message, timestamp: bool = False, nick_size: int = 10) -> List[Union[None, Line]]:
- """
- Build a list of lines from a message, without adding it
- to a list
- """
- return []
-
def refresh(self) -> None:
- pass
+ log.debug('Refresh: %s', self.__class__.__name__)
+ if self.height <= 0:
+ return
+ if self.pos == 0:
+ lines = self.built_lines[-self.height:]
+ else:
+ lines = self.built_lines[-self.height - self.pos:-self.pos]
+ with_timestamps = config.getbool("show_timestamps")
+ nick_size = config.getint("max_nick_length")
+ self._win.move(0, 0)
+ self._win.erase()
+ offset = 0
+ for y, line in enumerate(lines):
+ if line:
+ msg = line.msg
+ if line.start_pos == 0:
+ offset = write_pre(msg, self, with_timestamps, nick_size)
+ elif y == 0:
+ offset = msg.compute_offset(with_timestamps,
+ nick_size)
+ self.write_text(
+ y, offset,
+ line.prepend + line.msg.txt[line.start_pos:line.end_pos])
+ else:
+ self.write_line_separator(y)
+ if y != self.height - 1:
+ self.addstr('\n')
+ self._win.attrset(0)
+ self._refresh()
def write_text(self, y: int, x: int, txt: str) -> None:
"""
@@ -129,28 +146,15 @@ class BaseTextWin(Win):
"""
self.addstr_colored(txt, y, x)
- def write_time(self, time: str) -> int:
- """
- Write the date on the yth line of the window
- """
- if time:
- color = get_theme().COLOR_TIME_STRING
- curses_color = to_curses_attr(color)
- self._win.attron(curses_color)
- self.addstr(time)
- self._win.attroff(curses_color)
- self.addstr(' ')
- return poopt.wcswidth(time) + 1
- return 0
-
- # TODO: figure out the type of room.
- def resize(self, height: int, width: int, y: int, x: int, room=None) -> None:
+ def resize(self, height: int, width: int, y: int, x: int,
+ room: Optional[TextBuffer] = None, force: bool = False) -> None:
+ old_width: Optional[int]
if hasattr(self, 'width'):
old_width = self.width
else:
old_width = None
self._resize(height, width, y, x)
- if room and self.width != old_width:
+ if room and (self.width != old_width or force):
self.rebuild_everything(room)
# reposition the scrolling after resize
@@ -161,47 +165,54 @@ class BaseTextWin(Win):
if self.pos < 0:
self.pos = 0
- # TODO: figure out the type of room.
- def rebuild_everything(self, room) -> None:
+ def rebuild_everything(self, room: TextBuffer) -> None:
self.built_lines = []
- with_timestamps = config.get('show_timestamps')
- nick_size = config.get('max_nick_length')
+ with_timestamps = config.getbool('show_timestamps')
+ nick_size = config.getint('max_nick_length')
for message in room.messages:
self.build_new_message(
message,
clean=False,
- top=message.top,
timestamp=with_timestamps,
nick_size=nick_size)
if self.separator_after is message:
- self.build_new_message(None)
+ self.built_lines.append(None)
while len(self.built_lines) > self.lines_nb_limit:
self.built_lines.pop(0)
+ def remove_line_separator(self) -> None:
+ """
+ Remove the line separator
+ """
+ log.debug('remove_line_separator')
+ if None in self.built_lines:
+ self.built_lines.remove(None)
+ self.separator_after = None
+
+ def add_line_separator(self, room: TextBuffer = None) -> None:
+ """
+ add a line separator at the end of messages list
+ room is a textbuffer that is needed to get the previous message
+ (in case of resize)
+ """
+ if None not in self.built_lines:
+ self.built_lines.append(None)
+ self.nb_of_highlights_after_separator = 0
+ log.debug("Resetting number of highlights after separator")
+ if room and room.messages:
+ self.separator_after = room.messages[-1]
+
+ def write_line_separator(self, y) -> None:
+ theme = get_theme()
+ char = theme.CHAR_NEW_TEXT_SEPARATOR
+ self.addnstr(y, 0, char * (self.width // len(char) - 1), self.width,
+ to_curses_attr(theme.COLOR_NEW_TEXT_SEPARATOR))
+
def __del__(self) -> None:
log.debug('** TextWin: deleting %s built lines',
(len(self.built_lines)))
del self.built_lines
-
-class TextWin(BaseTextWin):
- __slots__ = ('highlights', 'hl_pos', 'nb_of_highlights_after_separator')
-
- def __init__(self, lines_nb_limit: Optional[int] = None) -> None:
- BaseTextWin.__init__(self, lines_nb_limit)
-
- # the Lines of the highlights in that buffer
- self.highlights = [] # type: List[Line]
- # the current HL position in that list NaN means that we’re not on
- # an hl. -1 is a valid position (it's before the first hl of the
- # list. i.e the separator, in the case where there’s no hl before
- # it.)
- self.hl_pos = float('nan')
-
- # Keep track of the number of hl after the separator.
- # This is useful to make “go to next highlight“ work after a “move to separator”.
- self.nb_of_highlights_after_separator = 0
-
def next_highlight(self) -> None:
"""
Go to the next highlight in the buffer.
@@ -210,13 +221,13 @@ class TextWin(BaseTextWin):
highlights, scroll to the end of the buffer.
"""
log.debug('Going to the next highlight…')
- if (not self.highlights or self.hl_pos != self.hl_pos
+ if (not self.highlights or self.hl_pos is None
or self.hl_pos >= len(self.highlights) - 1):
- self.hl_pos = float('nan')
+ self.hl_pos = None
self.pos = 0
return
hl_size = len(self.highlights) - 1
- if self.hl_pos < hl_size:
+ if self.hl_pos is not None and self.hl_pos < hl_size:
self.hl_pos += 1
else:
self.hl_pos = hl_size
@@ -227,9 +238,10 @@ class TextWin(BaseTextWin):
try:
pos = self.built_lines.index(hl)
except ValueError:
- self.highlights = self.highlights[self.hl_pos + 1:]
+ if isinstance(self.hl_pos, int):
+ del self.highlights[self.hl_pos]
if not self.highlights:
- self.hl_pos = float('nan')
+ self.hl_pos = None
self.pos = 0
return
self.hl_pos = 0
@@ -246,11 +258,11 @@ class TextWin(BaseTextWin):
highlights, scroll to the end of the buffer.
"""
log.debug('Going to the previous highlight…')
- if not self.highlights or self.hl_pos <= 0:
- self.hl_pos = float('nan')
+ if not self.highlights or self.hl_pos and self.hl_pos <= 0:
+ self.hl_pos = None
self.pos = 0
return
- if self.hl_pos != self.hl_pos:
+ if self.hl_pos is None:
self.hl_pos = len(self.highlights) - 1
else:
self.hl_pos -= 1
@@ -261,9 +273,10 @@ class TextWin(BaseTextWin):
try:
pos = self.built_lines.index(hl)
except ValueError:
- self.highlights = self.highlights[self.hl_pos + 1:]
+ if self.hl_pos is not None:
+ del self.highlights[self.hl_pos]
if not self.highlights:
- self.hl_pos = float('nan')
+ self.hl_pos = None
self.pos = 0
return
self.hl_pos = 0
@@ -293,383 +306,31 @@ class TextWin(BaseTextWin):
self.highlights) - self.nb_of_highlights_after_separator - 1
log.debug("self.hl_pos = %s", self.hl_pos)
- def remove_line_separator(self) -> None:
- """
- Remove the line separator
- """
- log.debug('remove_line_separator')
- if None in self.built_lines:
- self.built_lines.remove(None)
- self.separator_after = None
-
- # TODO: figure out the type of room.
- def add_line_separator(self, room=None) -> None:
- """
- add a line separator at the end of messages list
- room is a textbuffer that is needed to get the previous message
- (in case of resize)
- """
- if None not in self.built_lines:
- self.built_lines.append(None)
- self.nb_of_highlights_after_separator = 0
- log.debug("Resetting number of highlights after separator")
- if room and room.messages:
- self.separator_after = room.messages[-1]
-
- # TODO: figure out the type of history.
- def build_new_message(self,
- message: Message,
- history: bool = False,
- clean: bool = True,
- highlight: bool = False,
- timestamp: bool = False,
- top: Optional[bool] = False,
- nick_size: int = 10) -> int:
- """
- Take one message, build it and add it to the list
- Return the number of lines that are built for the given
- message.
- """
- lines = self.build_message(
- message, timestamp=timestamp, nick_size=nick_size)
- if top:
- lines.reverse()
- for line in lines:
- self.built_lines.insert(0, line)
- else:
- if self.lock:
- self.lock_buffer.extend(lines)
- else:
- self.built_lines.extend(lines)
- if not lines or not lines[0]:
- return 0
- if highlight:
- self.highlights.append(lines[0])
- self.nb_of_highlights_after_separator += 1
- log.debug("Number of highlights after separator is now %s",
- self.nb_of_highlights_after_separator)
- if clean:
- while len(self.built_lines) > self.lines_nb_limit:
- self.built_lines.pop(0)
- return len(lines)
-
- def build_message(self, message: Optional[Message], timestamp: bool = False, nick_size: int = 10) -> List[Union[None, Line]]:
- """
- Build a list of lines from a message, without adding it
- to a list
- """
- if message is None: # line separator
- return [None]
- txt = message.txt
- if not txt:
- return []
- theme = get_theme()
- if len(message.str_time) > 8:
- default_color = (
- FORMAT_CHAR + dump_tuple(theme.COLOR_LOG_MSG) + '}') # type: Optional[str]
- else:
- default_color = None
- ret = [] # type: List[Union[None, Line]]
- nick = truncate_nick(message.nickname, nick_size)
- offset = 0
- if message.ack:
- if message.ack > 0:
- offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
- else:
- offset += poopt.wcswidth(theme.CHAR_NACK) + 1
- if nick:
- offset += poopt.wcswidth(nick) + 2 # + nick + '> ' length
- if message.revisions > 0:
- offset += ceil(log10(message.revisions + 1))
- if message.me:
- offset += 1 # '* ' before and ' ' after
- if timestamp:
- if message.str_time:
- offset += 1 + len(message.str_time)
- if theme.CHAR_TIME_LEFT and message.str_time:
- offset += 1
- if theme.CHAR_TIME_RIGHT and message.str_time:
- offset += 1
- lines = poopt.cut_text(txt, self.width - offset - 1)
- prepend = default_color if default_color else ''
- attrs = [] # type: List[str]
- for line in lines:
- saved = Line(
- msg=message,
- start_pos=line[0],
- end_pos=line[1],
- prepend=prepend)
- attrs = parse_attrs(message.txt[line[0]:line[1]], attrs)
- if attrs:
- prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs)
- else:
- if default_color:
- prepend = default_color
- else:
- prepend = ''
- ret.append(saved)
- return ret
-
- def refresh(self) -> None:
- log.debug('Refresh: %s', self.__class__.__name__)
- if self.height <= 0:
- return
- if self.pos == 0:
- lines = self.built_lines[-self.height:]
- else:
- lines = self.built_lines[-self.height - self.pos:-self.pos]
- with_timestamps = config.get("show_timestamps")
- nick_size = config.get("max_nick_length")
- self._win.move(0, 0)
- self._win.erase()
- offset = 0
- for y, line in enumerate(lines):
- if line:
- msg = line.msg
- if line.start_pos == 0:
- offset = self.write_pre_msg(msg, with_timestamps,
- nick_size)
- elif y == 0:
- offset = self.compute_offset(msg, with_timestamps,
- nick_size)
- self.write_text(
- y, offset,
- line.prepend + line.msg.txt[line.start_pos:line.end_pos])
- else:
- self.write_line_separator(y)
- if y != self.height - 1:
- self.addstr('\n')
- self._win.attrset(0)
- self._refresh()
-
- def compute_offset(self, msg, with_timestamps, nick_size) -> int:
- offset = 0
- if with_timestamps and msg.str_time:
- offset += poopt.wcswidth(msg.str_time) + 1
-
- if not msg.nickname: # not a message, nothing to do afterwards
- return offset
-
- nick = truncate_nick(msg.nickname, nick_size)
- offset += poopt.wcswidth(nick)
- if msg.ack:
- theme = get_theme()
- if msg.ack > 0:
- offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
- else:
- offset += poopt.wcswidth(theme.CHAR_NACK) + 1
- if msg.me:
- offset += 3
- else:
- offset += 2
- if msg.revisions:
- offset += ceil(log10(msg.revisions + 1))
- offset += self.write_revisions(msg)
- return offset
-
- def write_pre_msg(self, msg, with_timestamps, nick_size) -> int:
- offset = 0
- if with_timestamps:
- offset += self.write_time(msg.str_time)
-
- if not msg.nickname: # not a message, nothing to do afterwards
- return offset
-
- nick = truncate_nick(msg.nickname, nick_size)
- offset += poopt.wcswidth(nick)
- if msg.nick_color:
- color = msg.nick_color
- elif msg.user:
- color = msg.user.color
- else:
- color = None
- if msg.ack:
- if msg.ack > 0:
- offset += self.write_ack()
- else:
- offset += self.write_nack()
- if msg.me:
- self._win.attron(to_curses_attr(get_theme().COLOR_ME_MESSAGE))
- self.addstr('* ')
- self.write_nickname(nick, color, msg.highlight)
- offset += self.write_revisions(msg)
- self.addstr(' ')
- offset += 3
- else:
- self.write_nickname(nick, color, msg.highlight)
- offset += self.write_revisions(msg)
- self.addstr('> ')
- offset += 2
- return offset
-
- def write_revisions(self, msg) -> int:
- if msg.revisions:
- self._win.attron(
- to_curses_attr(get_theme().COLOR_REVISIONS_MESSAGE))
- self.addstr('%d' % msg.revisions)
- self._win.attrset(0)
- return ceil(log10(msg.revisions + 1))
- return 0
-
- def write_line_separator(self, y) -> None:
- theme = get_theme()
- char = theme.CHAR_NEW_TEXT_SEPARATOR
- self.addnstr(y, 0, char * (self.width // len(char) - 1), self.width,
- to_curses_attr(theme.COLOR_NEW_TEXT_SEPARATOR))
-
- def write_ack(self) -> int:
- theme = get_theme()
- color = theme.COLOR_CHAR_ACK
- self._win.attron(to_curses_attr(color))
- self.addstr(theme.CHAR_ACK_RECEIVED)
- self._win.attroff(to_curses_attr(color))
- self.addstr(' ')
- return poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
-
- def write_nack(self) -> int:
- theme = get_theme()
- color = theme.COLOR_CHAR_NACK
- self._win.attron(to_curses_attr(color))
- self.addstr(theme.CHAR_NACK)
- self._win.attroff(to_curses_attr(color))
- self.addstr(' ')
- return poopt.wcswidth(theme.CHAR_NACK) + 1
-
- def write_nickname(self, nickname, color, highlight=False) -> None:
- """
- Write the nickname, using the user's color
- and return the number of written characters
- """
- if not nickname:
- return
- if highlight:
- hl_color = get_theme().COLOR_HIGHLIGHT_NICK
- if hl_color == "reverse":
- self._win.attron(curses.A_REVERSE)
- else:
- color = hl_color
- if color:
- self._win.attron(to_curses_attr(color))
- self.addstr(nickname)
- if color:
- self._win.attroff(to_curses_attr(color))
- if highlight and hl_color == "reverse":
- self._win.attroff(curses.A_REVERSE)
-
def modify_message(self, old_id, message) -> None:
"""
Find a message, and replace it with a new one
(instead of rebuilding everything in order to correct a message)
"""
- with_timestamps = config.get('show_timestamps')
- nick_size = config.get('max_nick_length')
+ with_timestamps = config.getbool('show_timestamps')
+ nick_size = config.getint('max_nick_length')
for i in range(len(self.built_lines) - 1, -1, -1):
- if self.built_lines[i] and self.built_lines[i].msg.identifier == old_id:
+ current = self.built_lines[i]
+ if current is not None and current.msg.identifier == old_id:
index = i
- while index >= 0 and self.built_lines[index] and self.built_lines[index].msg.identifier == old_id:
+ while (
+ index >= 0
+ and current is not None
+ and current.msg.identifier == old_id
+ ):
self.built_lines.pop(index)
index -= 1
+ if index >= 0:
+ current = self.built_lines[index]
index += 1
- lines = self.build_message(
- message, timestamp=with_timestamps, nick_size=nick_size)
+ lines = build_lines(
+ message, self.width, timestamp=with_timestamps, nick_size=nick_size
+ )
for line in lines:
self.built_lines.insert(index, line)
index += 1
break
-
- def __del__(self) -> None:
- log.debug('** TextWin: deleting %s built lines',
- (len(self.built_lines)))
- del self.built_lines
-
-
-class XMLTextWin(BaseTextWin):
- __slots__ = ()
-
- def __init__(self) -> None:
- BaseTextWin.__init__(self)
-
- def refresh(self) -> None:
- log.debug('Refresh: %s', self.__class__.__name__)
- theme = get_theme()
- if self.height <= 0:
- return
- if self.pos == 0:
- lines = self.built_lines[-self.height:]
- else:
- lines = self.built_lines[-self.height - self.pos:-self.pos]
- self._win.move(0, 0)
- self._win.erase()
- for y, line in enumerate(lines):
- if line:
- msg = line.msg
- if line.start_pos == 0:
- if msg.nickname == theme.CHAR_XML_OUT:
- color = theme.COLOR_XML_OUT
- elif msg.nickname == theme.CHAR_XML_IN:
- color = theme.COLOR_XML_IN
- self.write_time(msg.str_time)
- self.write_prefix(msg.nickname, color)
- self.addstr(' ')
- if y != self.height - 1:
- self.addstr('\n')
- self._win.attrset(0)
- for y, line in enumerate(lines):
- offset = 0
- # Offset for the timestamp (if any) plus a space after it
- offset += len(line.msg.str_time)
- # space
- offset += 1
-
- # Offset for the prefix
- offset += poopt.wcswidth(truncate_nick(line.msg.nickname))
- # space
- offset += 1
-
- self.write_text(
- y, offset,
- line.prepend + line.msg.txt[line.start_pos:line.end_pos])
- if y != self.height - 1:
- self.addstr('\n')
- self._win.attrset(0)
- self._refresh()
-
- def build_message(self, message: Message, timestamp: bool = False, nick_size: int = 10) -> List[Line]:
- txt = message.txt
- ret = []
- default_color = None
- nick = truncate_nick(message.nickname, nick_size)
- offset = 0
- if nick:
- offset += poopt.wcswidth(nick) + 1 # + nick + ' ' length
- if message.str_time:
- offset += 1 + len(message.str_time)
- theme = get_theme()
- if theme.CHAR_TIME_LEFT and message.str_time:
- offset += 1
- if theme.CHAR_TIME_RIGHT and message.str_time:
- offset += 1
- lines = poopt.cut_text(txt, self.width - offset - 1)
- prepend = default_color if default_color else ''
- attrs = [] # type: List[str]
- for line in lines:
- saved = Line(
- msg=message,
- start_pos=line[0],
- end_pos=line[1],
- prepend=prepend)
- attrs = parse_attrs(message.txt[line[0]:line[1]], attrs)
- if attrs:
- prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs)
- else:
- if default_color:
- prepend = default_color
- else:
- prepend = ''
- ret.append(saved)
- return ret
-
- def write_prefix(self, nickname, color) -> None:
- self._win.attron(to_curses_attr(color))
- self.addstr(truncate_nick(nickname))
- self._win.attroff(to_curses_attr(color))
diff --git a/poezio/xdg.py b/poezio/xdg.py
index 0b63998c..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.
@@ -15,11 +15,11 @@ from os import environ
from typing import Dict
# $HOME has already been checked to not be None in test_env().
-DEFAULT_PATHS = {
+DEFAULT_PATHS: Dict[str, Path] = {
'XDG_CONFIG_HOME': Path.home() / '.config',
'XDG_DATA_HOME': Path.home() / '.local' / 'share',
'XDG_CACHE_HOME': Path.home() / '.cache',
-} # type: Dict[str, Path]
+}
def _get_directory(variable: str) -> Path:
diff --git a/poezio/xhtml.py b/poezio/xhtml.py
index 899985ef..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,
@@ -21,6 +21,7 @@ from pathlib import Path
from io import BytesIO
from xml import sax
from xml.sax import saxutils
+from xml.sax.handler import ContentHandler
from typing import List, Dict, Optional, Union, Tuple
from slixmpp.xmlstream import ET
@@ -32,7 +33,7 @@ digits = '0123456789' # never trust the modules
XHTML_NS = 'http://www.w3.org/1999/xhtml'
# HTML named colors
-colors = {
+colors: Dict[str, int] = {
'aliceblue': 231,
'antiquewhite': 231,
'aqua': 51,
@@ -180,7 +181,7 @@ colors = {
'whitesmoke': 255,
'yellow': 226,
'yellowgreen': 149
-} # type: Dict[str, int]
+}
whitespace_re = re.compile(r'\s+')
@@ -299,21 +300,21 @@ def get_hash(data: bytes) -> str:
b'/', b'-').decode()
-class XHTMLHandler(sax.ContentHandler):
+class XHTMLHandler(ContentHandler):
def __init__(self, force_ns=False,
tmp_image_dir: Optional[Path] = None) -> None:
- self.builder = [] # type: List[str]
- self.formatting = [] # type: List[str]
- self.attrs = [] # type: List[Dict[str, str]]
- self.list_state = [] # type: List[Union[str, int]]
- self.cids = {} # type: Dict[str, Optional[str]]
+ self.builder: List[str] = []
+ self.formatting: List[str] = []
+ self.attrs: List[Dict[str, str]] = []
+ self.list_state: List[Union[str, int]] = []
+ self.cids: Dict[str, Optional[str]] = {}
self.is_pre = False
self.a_start = 0
# do not care about xhtml-in namespace
self.force_ns = force_ns
self.tmp_image_dir = Path(tmp_image_dir) if tmp_image_dir else None
- self.enable_css_parsing = config.get('enable_css_parsing')
+ self.enable_css_parsing = config.getbool('enable_css_parsing')
@property
def result(self) -> str:
@@ -430,7 +431,7 @@ class XHTMLHandler(sax.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'):
@@ -488,7 +489,7 @@ def convert_simple_to_full_colors(text: str) -> str:
a \x19n} formatted one.
"""
# TODO, have a single list of this. This is some sort of
- # duplicate from windows.format_chars
+ # duplicate from ui.consts.FORMAT_CHARS
mapping = str.maketrans({
'\x0E': '\x19b',
'\x0F': '\x19o',
@@ -512,7 +513,7 @@ def convert_simple_to_full_colors(text: str) -> str:
return re.sub(xhtml_simple_attr_re, add_curly_bracket, text)
-number_to_color_names = {
+number_to_color_names: Dict[int, str] = {
1: 'red',
2: 'green',
3: 'yellow',
@@ -520,7 +521,7 @@ number_to_color_names = {
5: 'violet',
6: 'turquoise',
7: 'white'
-} # type: Dict[int, str]
+}
def format_inline_css(_dict: Dict[str, str]) -> str:
@@ -535,7 +536,7 @@ def poezio_colors_to_html(string: str) -> str:
# Maintain a list of the current css attributes used
# And check if a tag is open (by design, we only open
# spans tag, and they cannot be nested.
- current_attrs = {} # type: Dict[str, str]
+ current_attrs: Dict[str, str] = {}
tag_open = False
next_attr_char = string.find('\x19')
build = ["<body xmlns='http://www.w3.org/1999/xhtml'><p>"]
diff --git a/requirements-plugins.txt b/requirements-plugins.txt
index 1b523929..d3c041e2 100644
--- a/requirements-plugins.txt
+++ b/requirements-plugins.txt
@@ -1,6 +1,6 @@
git+https://github.com/afflux/pure-python-otr.git#egg=python-potr
pyinotify
python-mpd2
-aiohttp<4 ; python_version <= '3.5'
-aiohttp ; python_version > '3.5'
+aiohttp
pygments
+qrcode
diff --git a/requirements.txt b/requirements.txt
index e865ed37..403cc355 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,3 +4,4 @@ aiodns==1.1.1
pycares==2.3.0
pyasn1==0.4.2
pyasn1-modules==0.2.1
+typing_extensions
diff --git a/scripts/poezio_logs b/scripts/poezio_logs
index c97b304f..b2374a6d 100755
--- a/scripts/poezio_logs
+++ b/scripts/poezio_logs
@@ -3,12 +3,11 @@
A simple script to parse and output logs from a poezio logfile
"""
-from poezio.logger import LogInfo, LogMessage, parse_log_line
+import argparse
from functools import singledispatch
+from typing import List, Optional, IO
from poezio import poopt
-import argparse
-import datetime
-import sys
+from poezio.logger import LogInfo, LogMessage, parse_log_line
INFO_COLOR = '\033[35;2m'
NICK_COLOR = '\033[36;2m'
@@ -18,12 +17,17 @@ TIME_COLOR = '\033[33;2m'
SHOW_INFO = True
SHOW_TIME = True
+
@singledispatch
-def print_log(log_object, additional_lines=None):
+def print_log(log_object: LogMessage,
+ additional_lines: Optional[List[str]] = None):
time = log_object.time.strftime('%Y-%m-%d %H:%M:%S')
nick = log_object.nick
- offset = ((poopt.wcswidth(time) +1) if SHOW_TIME else 0) + 2 + poopt.wcswidth(nick)
+ offset = ((
+ (poopt.wcswidth(time) + 1) if SHOW_TIME else 0)
+ + 2 + poopt.wcswidth(nick)
+ )
pad = ' ' * offset
if additional_lines:
@@ -32,17 +36,29 @@ def print_log(log_object, additional_lines=None):
more = ''
if SHOW_TIME:
- print(('%s%s%s %s%s%s> %s\n' % (TIME_COLOR, time, NO_COLOR, NICK_COLOR, nick, NO_COLOR, log_object.text)) + more, end='')
+ print(
+ f'{TIME_COLOR}{time}{NO_COLOR} {NICK_COLOR}{nick}{NO_COLOR}> '
+ f'{log_object.text}\n' + more,
+ end='',
+ )
else:
- print(('%s%s%s> %s\n' % (NICK_COLOR, nick, NO_COLOR, log_object.text)) + more, end='')
+ print(
+ f'{NICK_COLOR}{nick}{NO_COLOR}> '
+ f'{log_object.text}\n' + more,
+ end='',
+ )
+
@print_log.register(type(None))
-def _(log_object, additional_lines=None):
+def print_log_none(log_object, additional_lines=None):
return
+
@print_log.register(LogInfo)
-def _(log_object, additional_lines=None):
- if not SHOW_INFO: return
+def print_log_loginfo(log_object: LogInfo,
+ additional_lines: Optional[List[str]] = None):
+ if not SHOW_INFO:
+ return
time = log_object.time.strftime('%Y-%m-%d %H:%M:%S') + ' '
offset = (poopt.wcswidth(time) + 1) if SHOW_TIME else 0
@@ -54,13 +70,17 @@ def _(log_object, additional_lines=None):
more = ''
if SHOW_TIME:
- print(('%s%s%s %s%s\n' % (TIME_COLOR, time, NO_COLOR, INFO_COLOR, log_object.text)) + more, end='')
+ print(
+ f'{TIME_COLOR}{time}{NO_COLOR}{log_object.text}\n' + more,
+ end=''
+ )
else:
- print(('%s%s\n' % (INFO_COLOR, log_object.text)) + more, end='')
+ print(f'{INFO_COLOR}{log_object.text}\n' + more, end='')
-def parse_messages(fd):
+
+def parse_messages(fd: IO[str]):
in_text = False
- more_lines = []
+ more_lines: List[str] = []
current_log = None
for line in fd:
if in_text and not line.startswith(' '):
@@ -71,10 +91,11 @@ def parse_messages(fd):
elif in_text:
more_lines.append(line[1:])
continue
- current_log = parse_log_line(line)
+ current_log = parse_log_line(line, fd.name)
in_text = True
print_log(current_log, more_lines)
+
if __name__ == '__main__':
parser = argparse.ArgumentParser('poezio_logs', description="""
Show the logs stored in poezio format in a more human-readable way.
diff --git a/setup.py b/setup.py
index 24eb26f8..8c9fa723 100755
--- a/setup.py
+++ b/setup.py
@@ -1,18 +1,26 @@
#!/usr/bin/env python3
+import os
+import subprocess
+import sys
+from tempfile import TemporaryFile
try:
from setuptools import setup, Extension
except ImportError:
print('\nSetuptools was not found. Install setuptools for python 3.\n')
- import sys
sys.exit(1)
-import os
-import subprocess
-from tempfile import TemporaryFile
+cmdclass = {}
+try:
+ from sphinx.setup_command import BuildDoc
+ cmdclass = {'build_man': BuildDoc}
+except ImportError:
+ print('\nSphinx not found, the build_man command will be unavailable.\n')
current_dir = os.path.dirname(__file__)
+from poezio.version import __version__
+
def get_relative_dir(folder, stopper):
"""
Find the path from a directory to a pseudo-root in order to recreate
@@ -57,8 +65,30 @@ def check_include(library_name, header):
print('%s headers not found.' % library_name)
return False
+def sphinx_man():
+ expected_sphinx_files = [
+ 'build/sphinx/man/poezio.cfg.7',
+ 'build/sphinx/man/poezio.keys.7',
+ 'build/sphinx/man/poezio.commands.7'
+ ]
+ found = []
+ for item in expected_sphinx_files:
+ if os.path.exists(item):
+ found.append(item)
+ if found:
+ return [('share/man/man7/', found)]
+ return []
+
+
+sphinx_files_found = sphinx_man()
+if not sphinx_files_found:
+ print(
+ '\nSphinx-built manpages not found. Only the '
+ 'short handwritten manpages will be installed\n'
+ )
+
+
if not check_include('python3', 'Python.h'):
- import sys
sys.exit(2)
module_poopt = Extension('poezio.poopt',
@@ -83,58 +113,69 @@ if os.path.exists(git_dir):
except:
version = '.dev1'
else:
- version = '.dev1'
+ version = ''
with open('README.rst', encoding='utf-8') as readme_fd:
LONG_DESCRIPTION = readme_fd.read()
-setup(name="poezio",
- version="0.13" + version,
- description="A console XMPP client",
- long_description=LONG_DESCRIPTION,
- ext_modules=[module_poopt],
- url='https://poez.io/',
- license='zlib',
- download_url='https://dev.louiz.org/projects/poezio/files',
-
- author='Florent Le Coz',
- author_email='louiz@louiz.org',
-
- maintainer='Mathieu Pasquet',
- maintainer_email='mathieui@mathieui.net',
-
- classifiers=['Development Status :: 5 - Production/Stable',
- 'Topic :: Communications :: Chat',
- 'Topic :: Internet :: XMPP',
- 'Environment :: Console :: Curses',
- 'Intended Audience :: End Users/Desktop',
- 'License :: OSI Approved :: zlib/libpng License',
- 'Natural Language :: English',
- 'Operating System :: Unix',
- 'Programming Language :: Python :: 3.7',
- 'Programming Language :: Python :: 3.6',
- 'Programming Language :: Python :: 3.5',
- 'Programming Language :: Python :: 3 :: Only'],
- keywords=['jabber', 'xmpp', 'client', 'chat', 'im', 'console'],
- packages=['poezio', 'poezio.core', 'poezio.tabs', 'poezio.windows',
- 'poezio_plugins', 'poezio_themes'],
- package_dir={'poezio': 'poezio',
- 'poezio_plugins': 'plugins',
- 'poezio_themes': 'data/themes'},
- package_data={'poezio': ['default_config.cfg']},
- scripts=['scripts/poezio_logs'],
- entry_points={'console_scripts': ['poezio = poezio.__main__:run']},
- data_files=([('share/man/man1/', ['data/poezio.1',
- 'data/poezio_logs.1']),
- ('share/poezio/', ['README.rst', 'COPYING', 'CHANGELOG']),
- ('share/applications/', ['data/io.poez.Poezio.desktop']),
- ('share/metainfo/', ['data/io.poez.Poezio.appdata.xml'])]
- + find_doc('share/doc/poezio/source', 'source')
- + find_doc('share/doc/poezio/html', 'build/html')),
- install_requires=['slixmpp>=1.3.0', 'aiodns', 'pyasn1_modules', 'pyasn1'],
- extras_require={'OTR plugin': 'python-potr>=1.0',
- 'Screen autoaway plugin': 'pyinotify==0.9.4',
- 'Avoiding cython': 'cffi'})
+setup(
+ name="poezio",
+ version=__version__ + version,
+ description="A console XMPP client",
+ long_description=LONG_DESCRIPTION,
+ ext_modules=[module_poopt],
+ url='https://poez.io/',
+ license='GPL-3.0-or-later',
+ download_url='https://dev.louiz.org/projects/poezio/files',
+
+ author='Florent Le Coz',
+ author_email='louiz@louiz.org',
+
+ maintainer='Mathieu Pasquet',
+ maintainer_email='mathieui@mathieui.net',
+
+ classifiers=['Development Status :: 5 - Production/Stable',
+ 'Topic :: Communications :: Chat',
+ 'Topic :: Internet :: XMPP',
+ 'Environment :: Console :: Curses',
+ 'Intended Audience :: End Users/Desktop',
+ 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
+ 'Natural Language :: English',
+ 'Operating System :: Unix',
+ 'Programming Language :: Python :: 3.9',
+ 'Programming Language :: Python :: 3.8',
+ 'Programming Language :: Python :: 3.7',
+ 'Programming Language :: Python :: 3 :: Only'],
+ keywords=['jabber', 'xmpp', 'client', 'chat', 'im', 'console'],
+ packages=['poezio', 'poezio.core', 'poezio.tabs', 'poezio.windows',
+ 'poezio.ui', 'poezio_plugins', 'poezio_themes'],
+ package_dir={'poezio': 'poezio',
+ 'poezio_plugins': 'plugins',
+ 'poezio_themes': 'data/themes'},
+ package_data={'poezio': ['default_config.cfg', 'py.typed']},
+ scripts=['scripts/poezio_logs'],
+ entry_points={'console_scripts': ['poezio = poezio.__main__:run']},
+ data_files=([
+ ('share/man/man1/', ['data/poezio.1', 'data/poezio_logs.1']),
+ ('share/poezio/', ['README.rst', 'COPYING', 'CHANGELOG']),
+ ('share/applications/', ['data/io.poez.Poezio.desktop']),
+ ('share/metainfo/', ['data/io.poez.Poezio.appdata.xml'])
+ ]
+ + find_doc('share/doc/poezio/source', 'source')
+ + find_doc('share/doc/poezio/html', 'build/html')
+ + sphinx_files_found
+ ),
+ install_requires=['slixmpp>=1.6.0', 'aiodns', 'pyasn1_modules', 'pyasn1', 'typing_extensions', 'setuptools'],
+ extras_require={'OTR plugin': 'python-potr>=1.0',
+ 'Screen autoaway plugin': 'pyinotify==0.9.4',
+ 'Avoiding cython': 'cffi'},
+ cmdclass=cmdclass,
+ command_options={
+ 'build_man' : {
+ 'builder': ('setup.py', 'man'),
+ }
+ },
+)
# Remove the link afterwards
if (os.path.exists(os.path.join(current_dir, 'poezio', 'default_config.cfg')) and
diff --git a/test/test_common.py b/test/test_common.py
index d7bc2b8b..3235632d 100644
--- a/test/test_common.py
+++ b/test/test_common.py
@@ -11,7 +11,7 @@ from poezio.common import (_datetime_tuple as datetime_tuple, get_utc_time,
get_local_time, shell_split, _find_argument_quoted
as find_argument_quoted, _find_argument_unquoted as
find_argument_unquoted, parse_str_to_secs,
- parse_secs_to_str, safeJID)
+ parse_secs_to_str, unique_prefix_of)
def test_utc_time():
delta = timedelta(seconds=-3600)
@@ -60,6 +60,21 @@ def test_parse_secs_to_str():
with pytest.raises(TypeError):
parse_secs_to_str('toto')
-def test_safeJID():
- assert safeJID('toto@titi/tata') == JID('toto@titi/tata')
- assert safeJID('toto@…') == JID('')
+def test_unique_prefix_of__no_shared_prefix():
+ assert unique_prefix_of("a", "b") == "a"
+ assert unique_prefix_of("foo", "bar") == "f"
+ assert unique_prefix_of("foo", "") == "f"
+
+def test_unique_prefix_of__equal():
+ assert unique_prefix_of("foo", "foo") == "foo"
+
+def test_unique_prefix_of__a_prefix():
+ assert unique_prefix_of("foo", "foobar") == "foo"
+
+def test_unique_prefix_of__b_prefix():
+ assert unique_prefix_of("foobar", "foo") == "foob"
+
+def test_unique_prefix_of__normal_shared_prefix():
+ assert unique_prefix_of("foobar", "foobaz") == "foobar"
+ assert unique_prefix_of("fnord", "funky") == "fn"
+ assert unique_prefix_of("asbestos", "aspergers") == "asb"
diff --git a/test/test_completion.py b/test/test_completion.py
index 620b5658..1a970470 100644
--- a/test/test_completion.py
+++ b/test/test_completion.py
@@ -5,15 +5,19 @@ Test the completions methods on an altered input object.
import string
import pytest
import random
+from poezio import config
+from poezio.windows import Input
+
class ConfigShim(object):
def get(self, *args, **kwargs):
return ''
+ def getbool(self, *args, **kwargs):
+ return False
+
-from poezio import config
config.config = ConfigShim()
-from poezio.windows import Input
class SubInput(Input):
def resize(self, *args, **kwargs):
@@ -26,6 +30,8 @@ class SubInput(Input):
@pytest.fixture(scope="function")
def input_obj():
+ from poezio.windows import base_wins
+ base_wins.TAB_WIN = True
obj = SubInput()
obj.reset_completion()
return obj
diff --git a/test/test_config.py b/test/test_config.py
index 4632410b..8e92bf3f 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -9,8 +9,10 @@ from pathlib import Path
import pytest
from poezio import config
+from slixmpp import JID
-@pytest.yield_fixture(scope="module")
+
+@pytest.fixture(scope="module")
def config_obj():
file_ = tempfile.NamedTemporaryFile(delete=False)
conf = config.Config(file_name=Path(file_.name))
@@ -18,6 +20,7 @@ def config_obj():
del conf
os.unlink(file_.name)
+
class TestConfigSimple(object):
def test_get_set(self, config_obj):
config_obj.set_and_save('test', value='coucou')
@@ -97,16 +100,15 @@ class TestConfigSections(object):
class TestTabNames(object):
def test_get_tabname(self, config_obj):
- config.post_logging_setup()
config_obj.set_and_save('test', value='value.toto@toto.com',
section='toto@toto.com')
config_obj.set_and_save('test2', value='value2@toto.com',
section='@toto.com')
- assert config_obj.get_by_tabname('test', 'toto@toto.com') == 'value.toto@toto.com'
- assert config_obj.get_by_tabname('test2', 'toto@toto.com') == 'value2@toto.com'
- assert config_obj.get_by_tabname('test2', 'toto@toto.com', fallback=False) == 'value2@toto.com'
- assert config_obj.get_by_tabname('test2', 'toto@toto.com', fallback_server=False) == 'true'
- assert config_obj.get_by_tabname('test_int', 'toto@toto.com', fallback=False) == ''
+ assert config_obj.get_by_tabname('test', JID('toto@toto.com')) == 'value.toto@toto.com'
+ assert config_obj.get_by_tabname('test2', JID('toto@toto.com')) == 'value2@toto.com'
+ assert config_obj.get_by_tabname('test2', JID('toto@toto.com'), fallback=False) == 'value2@toto.com'
+ assert config_obj.get_by_tabname('test2', JID('toto@toto.com'), fallback_server=False) == 'true'
+ assert config_obj.get_by_tabname('test_int', JID('toto@toto.com'), fallback=False) == ''
diff --git a/test/test_logger.py b/test/test_logger.py
index 09ba720e..a1caad52 100644
--- a/test/test_logger.py
+++ b/test/test_logger.py
@@ -2,8 +2,76 @@
Test the functions in the `logger` module
"""
import datetime
-from poezio.logger import LogMessage, parse_log_line, parse_log_lines, build_log_message
-from poezio.common import get_utc_time, get_local_time
+from pathlib import Path
+from random import sample
+from shutil import rmtree
+from string import hexdigits
+from poezio import logger
+from poezio.logger import (
+ LogMessage, parse_log_line, parse_log_lines, build_log_message
+)
+from poezio.ui.types import Message
+from poezio.common import get_utc_time
+import pytest
+
+
+class ConfigShim:
+ def __init__(self, value):
+ self.value = value
+
+ def get_by_tabname(self, name, *args, **kwargs):
+ return self.value
+
+
+logger.config = ConfigShim(True)
+
+
+@pytest.fixture
+def log_dir():
+ name = 'tmplog-' + ''.join(sample(hexdigits, 16))
+ path = Path('/tmp', name)
+ try:
+ yield path
+ finally:
+ rmtree(path, ignore_errors=True)
+
+
+def read_file(logger, name):
+ if '/' in name:
+ name = name.replace('/', '\\')
+ filename = logger.log_dir / f'{name}'
+ with open(filename) as fd:
+ return fd.read()
+
+
+def test_log_roster(log_dir):
+ instance = logger.Logger()
+ instance.log_dir = log_dir
+ instance.log_roster_change('toto@example.com', 'test test')
+ content = read_file(instance, 'roster.log')
+ assert content[:3] == 'MI '
+ assert content[-32:] == ' 000 toto@example.com test test\n'
+
+
+def test_log_message(log_dir):
+ instance = logger.Logger()
+ instance.log_dir = log_dir
+ msg = Message('content', 'toto')
+ instance.log_message('toto@example.com', msg)
+ content = read_file(instance, 'toto@example.com')
+ line = parse_log_lines(content.split('\n'), '')[0]
+ assert line['nickname'] == 'toto'
+ assert line['txt'] == 'content'
+ msg2 = Message('content\ncontent2', 'titi')
+ instance.log_message('toto@example.com', msg2)
+ content = read_file(instance, 'toto@example.com')
+ lines = parse_log_lines(content.split('\n'), '')
+
+ assert lines[0]['nickname'] == 'toto'
+ assert lines[0]['txt'] == 'content'
+ assert lines[1]['nickname'] == 'titi'
+ assert lines[1]['txt'] == 'content\ncontent2'
+
def test_parse_message():
line = 'MR 20170909T09:09:09Z 000 <nick>  body'
@@ -17,17 +85,27 @@ def test_parse_message():
def test_log_and_parse_messages():
- msg1 = {'nick': 'toto', 'msg': 'coucou', 'date': datetime.datetime.now().replace(microsecond=0)}
+ msg1 = {
+ 'nick': 'toto',
+ 'msg': 'coucou',
+ 'date': datetime.datetime.now().replace(microsecond=0),
+ 'prefix': 'MR',
+ }
msg1_utc = get_utc_time(msg1['date'])
built_msg1 = build_log_message(**msg1)
assert built_msg1 == 'MR %s 000 <toto>  coucou\n' % (msg1_utc.strftime('%Y%m%dT%H:%M:%SZ'))
- msg2 = {'nick': 'toto', 'msg': 'coucou\ncoucou', 'date': datetime.datetime.now().replace(microsecond=0)}
+ msg2 = {
+ 'nick': 'toto',
+ 'msg': 'coucou\ncoucou',
+ 'date': datetime.datetime.now().replace(microsecond=0),
+ 'prefix': 'MR',
+ }
built_msg2 = build_log_message(**msg2)
msg2_utc = get_utc_time(msg2['date'])
assert built_msg2 == 'MR %s 001 <toto>  coucou\n coucou\n' % (msg2_utc.strftime('%Y%m%dT%H:%M:%SZ'))
assert parse_log_lines((built_msg1 + built_msg2).split('\n'), 'user@domain') == [
- {'time': msg1['date'], 'history': True, 'txt': '\x195,-1}coucou', 'nickname': 'toto'},
- {'time': msg2['date'], 'history': True, 'txt': '\x195,-1}coucou\ncoucou', 'nickname': 'toto'},
+ {'time': msg1['date'], 'history': True, 'txt': 'coucou', 'nickname': 'toto', 'type': 'message'},
+ {'time': msg2['date'], 'history': True, 'txt': 'coucou\ncoucou', 'nickname': 'toto', 'type': 'message'},
]
diff --git a/test/test_tabs.py b/test/test_tabs.py
index 0a6930d4..d2503df2 100644
--- a/test/test_tabs.py
+++ b/test/test_tabs.py
@@ -12,16 +12,24 @@ class DummyTab(Tab):
count = 0
def __init__(self):
- self.name = 'dummy%s' % self.count
+ self._name = 'dummy%s' % self.count
DummyTab.count += 1
+ @property
+ def name(self):
+ return self._name
+
+ @name.setter
+ def name(self, value):
+ self._name = value
+
@staticmethod
def reset():
DummyTab.count = 0
def test_append():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
tabs.append(dummy)
assert tabs[0] is dummy
@@ -38,7 +46,7 @@ def test_append():
def test_delete():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
tabs.append(dummy)
@@ -53,7 +61,7 @@ def test_delete():
def test_delete_restore_previous():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -74,7 +82,7 @@ def test_delete_restore_previous():
def test_delete_other_tab():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -94,7 +102,7 @@ def test_delete_other_tab():
def test_insert_and_gaps():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -117,7 +125,7 @@ def test_insert_and_gaps():
def test_replace_tabs():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -132,7 +140,7 @@ def test_replace_tabs():
def test_prev_next():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -158,7 +166,7 @@ def test_prev_next():
def test_set_current():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -174,7 +182,7 @@ def test_set_current():
def test_slice():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -183,3 +191,25 @@ def test_slice():
tabs.append(dummy3)
assert tabs[1:2][0] is dummy2
+
+def test_find_by_unique_prefix():
+ DummyTab.reset()
+ tabs = Tabs(h, None)
+ t1 = DummyTab()
+ t2 = DummyTab()
+ t3 = DummyTab()
+ tabs.append(t1)
+ tabs.append(t2)
+ tabs.append(t3)
+
+ t1.name = "foo"
+ t2.name = "bar"
+ t3.name = "fnord"
+
+ assert tabs.find_by_unique_prefix("f") == (True, None)
+ assert tabs.find_by_unique_prefix("b") == (True, t2)
+ assert tabs.find_by_unique_prefix("fo") == (True, t1)
+ assert tabs.find_by_unique_prefix("fn") == (True, t3)
+ assert tabs.find_by_unique_prefix("fx") == (False, None)
+ assert tabs.find_by_unique_prefix("x") == (False, None)
+ assert tabs.find_by_unique_prefix("") == (True, None)
diff --git a/test/test_text_buffer.py b/test/test_text_buffer.py
new file mode 100644
index 00000000..3daf54d4
--- /dev/null
+++ b/test/test_text_buffer.py
@@ -0,0 +1,200 @@
+"""
+Tests for the TextBuffer class
+"""
+from pytest import fixture
+
+from poezio.text_buffer import (
+ TextBuffer,
+ HistoryGap,
+)
+
+from poezio.ui.types import (
+ Message,
+ BaseMessage,
+ MucOwnJoinMessage,
+ MucOwnLeaveMessage,
+)
+
+
+@fixture(scope='function')
+def buf2048():
+ return TextBuffer(2048)
+
+
+@fixture(scope='function')
+def msgs_nojoin():
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ leave = MucOwnLeaveMessage('leave')
+ return [msg1, msg2, leave]
+
+
+@fixture(scope='function')
+def msgs_noleave():
+ join = MucOwnJoinMessage('join')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ return [join, msg3, msg4]
+
+
+@fixture(scope='function')
+def msgs_doublejoin():
+ join = MucOwnJoinMessage('join')
+ msg1 = Message('1', 'd')
+ msg2 = Message('2', 'f')
+ join2 = MucOwnJoinMessage('join')
+ return [join, msg1, msg2, join2]
+
+
+def test_last_message(buf2048):
+ msg = BaseMessage('toto')
+ buf2048.add_message(BaseMessage('titi'))
+ buf2048.add_message(msg)
+ assert buf2048.last_message is msg
+
+
+def test_message_nb_limit():
+ buf = TextBuffer(5)
+ for i in range(10):
+ buf.add_message(BaseMessage("%s" % i))
+ assert len(buf.messages) == 5
+
+
+def test_find_gap(buf2048, msgs_noleave):
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ leave = MucOwnLeaveMessage('leave')
+ join = MucOwnJoinMessage('join')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ msgs = [msg1, msg2, leave, join, msg3, msg4]
+ for msg in msgs:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert gap.leave_message == leave
+ assert gap.join_message == join
+ assert gap.last_timestamp_before_leave == msg2.time
+ assert gap.first_timestamp_after_join == msg3.time
+
+
+def test_find_gap_doublejoin(buf2048, msgs_doublejoin):
+ for msg in msgs_doublejoin:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert gap.leave_message == msgs_doublejoin[2]
+ assert gap.join_message == msgs_doublejoin[3]
+
+
+def test_find_gap_doublejoin_no_msg(buf2048):
+ join1 = MucOwnJoinMessage('join')
+ join2 = MucOwnJoinMessage('join')
+ for msg in [join1, join2]:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert gap.leave_message is join1
+ assert gap.join_message is join2
+
+
+def test_find_gap_already_filled(buf2048):
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ leave = MucOwnLeaveMessage('leave')
+ msg5 = Message('5', 'g')
+ msg6 = Message('6', 'h')
+ join = MucOwnJoinMessage('join')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ msgs = [msg1, msg2, leave, msg5, msg6, join, msg3, msg4]
+ for msg in msgs:
+ buf2048.add_message(msg)
+ assert buf2048.find_last_gap_muc() is None
+
+
+def test_find_gap_noleave(buf2048, msgs_noleave):
+ for msg in msgs_noleave:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert gap.leave_message is None
+ assert gap.last_timestamp_before_leave is None
+ assert gap.join_message == msgs_noleave[0]
+ assert gap.first_timestamp_after_join == msgs_noleave[1].time
+
+
+def test_find_gap_nojoin(buf2048, msgs_nojoin):
+ for msg in msgs_nojoin:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert gap.leave_message == msgs_nojoin[-1]
+ assert gap.join_message is None
+ assert gap.last_timestamp_before_leave == msgs_nojoin[1].time
+
+
+def test_get_gap_index(buf2048):
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ leave = MucOwnLeaveMessage('leave')
+ join = MucOwnJoinMessage('join')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ msgs = [msg1, msg2, leave, join, msg3, msg4]
+ for msg in msgs:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert buf2048.get_gap_index(gap) == 3
+
+
+def test_get_gap_index_doublejoin(buf2048, msgs_doublejoin):
+ for msg in msgs_doublejoin:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert buf2048.get_gap_index(gap) == 3
+
+
+def test_get_gap_index_doublejoin_no_msg(buf2048):
+ join1 = MucOwnJoinMessage('join')
+ join2 = MucOwnJoinMessage('join')
+ for msg in [join1, join2]:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert buf2048.get_gap_index(gap) == 1
+
+
+def test_get_gap_index_nojoin(buf2048, msgs_nojoin):
+ for msg in msgs_nojoin:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert buf2048.get_gap_index(gap) == 3
+
+
+def test_get_gap_index_noleave(buf2048, msgs_noleave):
+ for msg in msgs_noleave:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert buf2048.get_gap_index(gap) == 0
+
+
+def test_add_history_messages(buf2048):
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ leave = MucOwnLeaveMessage('leave')
+ join = MucOwnJoinMessage('join')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ msgs = [msg1, msg2, leave, join, msg3, msg4]
+ for msg in msgs:
+ buf2048.add_message(msg)
+ msg5 = Message('5', 'g')
+ msg6 = Message('6', 'h')
+ gap = buf2048.find_last_gap_muc()
+ buf2048.add_history_messages([msg5, msg6], gap=gap)
+ assert buf2048.messages == [msg1, msg2, leave, msg5, msg6, join, msg3, msg4]
+
+
+def test_add_history_empty(buf2048):
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ buf2048.add_message(msg1)
+ buf2048.add_history_messages([msg2, msg3, msg4])
+ assert buf2048.messages == [msg2, msg3, msg4, msg1]
diff --git a/test/test_ui/test_funcs.py b/test/test_ui/test_funcs.py
new file mode 100644
index 00000000..0e61549c
--- /dev/null
+++ b/test/test_ui/test_funcs.py
@@ -0,0 +1,46 @@
+from poezio.ui.funcs import (
+ find_first_format_char,
+ parse_attrs,
+ truncate_nick,
+)
+
+
+def test_find_char_not_present():
+ assert find_first_format_char("toto") == -1
+
+
+def test_find_char():
+ assert find_first_format_char('a \x1A 1') == 2
+
+
+def test_truncate_nick():
+ assert truncate_nick("toto") == "toto"
+
+
+def test_truncate_nick_wrong_size():
+ assert truncate_nick("toto", -10) == "t…"
+
+
+def test_truncate_nick_too_long():
+ nick = "012345678901234567"
+ assert truncate_nick(nick) == nick[:10] + "…"
+
+
+def test_truncate_nick_no_nick():
+ assert truncate_nick('') == ''
+
+
+def test_parse_attrs():
+ text = "\x19o\x19u\x19b\x19i\x191}\x19o\x194}"
+ assert parse_attrs(text) == ['4}']
+
+
+def test_parse_attrs_broken_char():
+ text = "coucou\x19"
+ assert parse_attrs(text) == []
+
+
+def test_parse_attrs_previous():
+ text = "coucou"
+ previous = ['u']
+ assert parse_attrs(text, previous=previous) == previous
diff --git a/test/test_ui/test_render.py b/test/test_ui/test_render.py
new file mode 100644
index 00000000..bdfc1be6
--- /dev/null
+++ b/test/test_ui/test_render.py
@@ -0,0 +1,144 @@
+import pytest
+from contextlib import contextmanager
+from datetime import datetime
+from poezio.theming import get_theme
+from poezio.ui.render import build_lines, Line, write_pre
+from poezio.ui.types import BaseMessage, Message, StatusMessage, XMLLog
+
+def test_simple_build_basemsg():
+ msg = BaseMessage(txt='coucou')
+ line = build_lines(msg, 100, True, 10)[0]
+ assert (line.start_pos, line.end_pos) == (0, 6)
+
+
+def test_simple_render_message():
+ msg = Message(txt='coucou', nickname='toto')
+ line = build_lines(msg, 100, True, 10)[0]
+ assert (line.start_pos, line.end_pos) == (0, 6)
+
+
+def test_simple_render_xmllog():
+ msg = XMLLog(txt='coucou', incoming=True)
+ line = build_lines(msg, 100, True, 10)[0]
+ assert (line.start_pos, line.end_pos) == (0, 6)
+
+
+def test_simple_render_separator():
+ line = build_lines(None, 100, True, 10)[0]
+ assert line is None
+
+
+def test_simple_render_status():
+ class Obj:
+ name = 'toto'
+ msg = StatusMessage("Coucou {name}", {'name': lambda: Obj.name})
+ assert msg.txt == "Coucou toto"
+ Obj.name = 'titi'
+ build_lines(msg, 100, True, 10)[0]
+ assert msg.txt == "Coucou titi"
+
+
+class FakeBuffer:
+ def __init__(self):
+ self.text = ''
+
+ @contextmanager
+ def colored_text(self, *args, **kwargs):
+ yield None
+
+ def addstr(self, txt):
+ self.text += txt
+
+@pytest.fixture(scope='function')
+def buffer():
+ return FakeBuffer()
+
+@pytest.fixture
+def time():
+ return datetime.strptime('2019-09-27 10:11:12', '%Y-%m-%d %H:%M:%S')
+
+def test_write_pre_basemsg(buffer):
+ str_time = '10:11:12'
+ time = datetime.strptime(str_time, '%H:%M:%S')
+ msg = BaseMessage(txt='coucou', time=time)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 '
+ assert size == len(buffer.text)
+
+def test_write_pre_message_simple(buffer, time):
+ msg = Message(txt='coucou', nickname='toto', time=time)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 toto> '
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_simple_history(buffer, time):
+ msg = Message(txt='coucou', nickname='toto', time=time, history=True)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '2019-09-27 10:11:12 toto> '
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_highlight(buffer, time):
+ msg = Message(txt='coucou', nickname='toto', time=time, highlight=True)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 toto> '
+ assert size == len(buffer.text)
+
+def test_write_pre_message_no_timestamp(buffer):
+ msg = Message(txt='coucou', nickname='toto')
+ size = write_pre(msg, buffer, False, 10)
+ assert buffer.text == 'toto> '
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_me(buffer, time):
+ msg = Message(txt='/me coucou', nickname='toto', time=time)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 * toto '
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_revisions(buffer, time):
+ msg = Message(txt='coucou', nickname='toto', time=time, revisions=5)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 toto5> '
+ assert size == len(buffer.text)
+
+def test_write_pre_message_revisions_me(buffer, time):
+ msg = Message(txt='/me coucou', nickname='toto', time=time, revisions=5)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 * toto5 '
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_ack(buffer, time):
+ ack = get_theme().CHAR_ACK_RECEIVED
+ expected = '10:11:12 %s toto> ' % ack
+ msg = Message(txt='coucou', nickname='toto', time=time, ack=1)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == expected
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_nack(buffer, time):
+ nack = get_theme().CHAR_NACK
+ expected = '10:11:12 %s toto> ' % nack
+ msg = Message(txt='coucou', nickname='toto', time=time, ack=-1)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == expected
+ assert size == len(buffer.text)
+
+
+def test_write_pre_xmllog_in(buffer):
+ msg = XMLLog(txt="coucou", incoming=True)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '%s IN ' % msg.time.strftime('%H:%M:%S')
+ assert size == len(buffer.text)
+
+
+def test_write_pre_xmllog_out(buffer):
+ msg = XMLLog(txt="coucou", incoming=False)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '%s OUT ' % msg.time.strftime('%H:%M:%S')
+ assert size == len(buffer.text)
diff --git a/test/test_ui/test_types.py b/test/test_ui/test_types.py
new file mode 100644
index 00000000..e4c6c010
--- /dev/null
+++ b/test/test_ui/test_types.py
@@ -0,0 +1,126 @@
+import pytest
+from datetime import datetime
+
+from poezio.ui.types import BaseMessage, Message, XMLLog
+
+
+def test_create_message():
+ now = datetime.now()
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ )
+ assert now < msg.time < datetime.now()
+
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ time=now,
+ )
+ assert msg.time == now
+
+
+def test_message_offset_simple():
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ )
+ example = "10:10:10 toto> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ history=True,
+ )
+ example = "2019:09:01 10:10:10 toto> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+def test_message_offset_no_nick():
+ msg = Message(
+ txt="coucou",
+ nickname="",
+ )
+ example = "10:10:10 "
+ assert msg.compute_offset(True, 10) == len(example)
+
+def test_message_offset_ack():
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ ack=1,
+ )
+ example = "10:10:10 V toto> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ ack=-1,
+ )
+ example = "10:10:10 X toto> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+
+def test_message_offset_me():
+ msg = Message(
+ txt="/me coucou",
+ nickname="toto",
+ )
+ example = "10:10:10 * toto "
+ assert msg.compute_offset(True, 10) == len(example)
+
+
+def test_message_offset_revisions():
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ revisions=3,
+ )
+ example = "10:10:10 toto3> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ revisions=250,
+ )
+ example = "10:10:10 toto250> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+
+def test_message_repr_works():
+ msg1 = Message(
+ txt="coucou",
+ nickname="toto",
+ revisions=250,
+ )
+ msg2 = Message(
+ txt="coucou",
+ nickname="toto",
+ old_message=msg1
+ )
+
+ assert repr(msg2) is not None
+
+def test_xmllog_offset():
+ msg = XMLLog(
+ txt='toto',
+ incoming=True,
+ )
+ example = '10:10:10 IN '
+ assert msg.compute_offset(True, 10) == len(example)
+
+ msg = XMLLog(
+ txt='toto',
+ incoming=False,
+ )
+ example = '10:10:10 OUT '
+ assert msg.compute_offset(True, 10) == len(example)
+
+def test_basemessage_offset():
+ msg = BaseMessage(
+ txt='coucou',
+ )
+ example = '10:10:10 '
+ assert msg.compute_offset(True, 10) == len(example)
diff --git a/test/test_user.py b/test/test_user.py
new file mode 100644
index 00000000..cda85577
--- /dev/null
+++ b/test/test_user.py
@@ -0,0 +1,44 @@
+"""
+Tests for the User class
+"""
+
+import pytest
+from datetime import datetime
+from slixmpp import JID
+from poezio.user import User
+
+
+@pytest.fixture
+def user1():
+ return User(
+ nick='nick1',
+ affiliation='member',
+ show='xa',
+ status='My Status!',
+ role='moderator',
+ jid=JID('foo@muc/nick1'),
+ color='red',
+ )
+
+
+def test_new_user(user1):
+ assert user1.last_talked == datetime(1, 1, 1)
+ assert user1.jid == JID('foo@muc/nick1')
+ assert user1.chatstate is None
+ assert user1.affiliation == 'member'
+ assert user1.show == 'xa'
+ assert user1.status == 'My Status!'
+ assert user1.role == 'moderator'
+ assert user1.nick == 'nick1'
+ assert user1.color == (196, -1)
+ assert str(user1) == '>nick1<'
+
+
+def test_change_nick(user1):
+ user1.change_nick('nick2')
+ assert user1.nick == 'nick2'
+
+
+def test_change_color(user1):
+ user1.change_color('blue')
+ assert user1.color == (21, -1)
diff --git a/test/test_windows.py b/test/test_windows.py
index af1b9d4a..3be96172 100644
--- a/test/test_windows.py
+++ b/test/test_windows.py
@@ -1,22 +1,32 @@
import pytest
+from poezio.windows import Input, HistoryInput, MessageInput
+from poezio import config
+
-class ConfigShim(object):
+class ConfigShim:
def get(self, *args, **kwargs):
return ''
-from poezio import config
+ def getbool(self, *args, **kwargs):
+ return True
+
+
config.config = ConfigShim()
-from poezio.windows import Input, HistoryInput, MessageInput
class SubInput(Input):
+
def rewrite_text(self, *args, **kwargs):
return None
+
@pytest.fixture
def input():
+ from poezio.windows import base_wins
+ base_wins.TAB_WIN = True # The value is not relevant
return SubInput()
+
class TestInput(object):
def test_do_command(self, input):
@@ -29,9 +39,9 @@ class TestInput(object):
assert input.text == 'acoucou'
def test_empty(self, input):
- assert input.is_empty() == True
+ assert input.is_empty()
input.do_command('a')
- assert input.is_empty() == False
+ assert not input.is_empty()
def test_key_left(self, input):
for char in 'this is a line':
diff --git a/test/test_xhtml.py b/test/test_xhtml.py
index 472991c6..333a10c3 100644
--- a/test/test_xhtml.py
+++ b/test/test_xhtml.py
@@ -8,11 +8,15 @@ import poezio.xhtml
from poezio.xhtml import (poezio_colors_to_html, xhtml_to_poezio_colors,
_parse_css as parse_css, clean_text)
-class ConfigShim(object):
+
+class ConfigShim:
def __init__(self):
self.value = True
def get(self, *args, **kwargs):
return self.value
+ def getbool(self, *args, **kwargs):
+ return self.value
+
config = ConfigShim()
poezio.xhtml.config = config
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
+ }
+ }
+}
diff --git a/update.sh b/update.sh
index 9f8bf382..e8ba0d0a 100755
--- a/update.sh
+++ b/update.sh
@@ -23,12 +23,12 @@ command -v "$POEZIO_PYTHON" > /dev/null 2>&1 || {
}
$POEZIO_PYTHON -c 'import venv' &> /dev/null || {
- echo "'$POEZIO_PYTHON' venv module not found. Check that you have python (>= 3.5) installed,"
+ echo "'$POEZIO_PYTHON' venv module not found. Check that you have python (>= 3.7) installed,"
exit 1
}
echo 'Updating poezio'
-git pull --ff-only origin master || {
+git pull --ff-only origin main || {
echo "The script failed to update poezio."
exit 1
}
@@ -43,7 +43,7 @@ then
. "$POEZIO_VENV/bin/activate"
echo 'Updating the in-venv pip'
pip install --upgrade pip
- python3 -c 'import sys;(print("Python 3.5 or newer is required") and exit(1)) if sys.version_info < (3, 5) else exit(0)' || exit 1
+ 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
echo 'Updating the poezio dependencies'
pip install -r requirements.txt --upgrade
echo 'Updating the poezio plugin dependencies'
@@ -55,7 +55,7 @@ else
. "$POEZIO_VENV/bin/activate"
cd "$POEZIO_VENV" # needed to download slixmpp inside the venv
- python3 -c 'import sys;(print("Python 3.5 or newer is required") and exit(1)) if sys.version_info < (3, 5) else exit(0)' || exit 1
+ 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
echo 'Installing the poezio dependencies using pip'
pip install -r "../requirements.txt"